deleting tags deletes across

This commit is contained in:
mbecker20
2024-03-25 01:14:45 -07:00
parent f6c99c4c20
commit 340d013bb3
11 changed files with 525 additions and 123 deletions

View File

@@ -20,7 +20,7 @@ import { DeploymentLogs } from "./logs";
import { Link } from "react-router-dom";
import { DataTable } from "@ui/data-table";
import { ResourceComponents } from "..";
import { TagsWithBadge } from "@components/tags";
import { TagsWithBadge, useTagsFilter } from "@components/tags";
export const useDeployment = (id?: string) =>
useRead("ListDeployments", {}, { refetchInterval: 5000 }).data?.find(
@@ -122,87 +122,100 @@ export const Deployment: RequiredResourceComponents = {
},
Table: () => {
const deployments = useRead("ListDeployments", {}).data;
return (
<DataTable
data={deployments ?? []}
columns={[
{
accessorKey: "id",
header: "Name",
cell: ({ row }) => {
const id = row.original.id;
return (
<Link
to={`/deployments/${id}`}
className="flex items-center gap-2"
>
<ResourceComponents.Deployment.Icon id={id} />
<ResourceComponents.Deployment.Name id={id} />
</Link>
);
},
},
// {
// header: "Description",
// accessorKey: "description",
// },
{
header: "Server",
cell: ({ row }) => {
const id = row.original.info.server_id;
return (
<Link to={`/servers/${id}`} className="flex items-center gap-2">
<ResourceComponents.Server.Icon id={id} />
<ResourceComponents.Server.Name id={id} />
</Link>
);
},
},
// {
// header: "Build",
// cell: ({ row }) => {
// const id = row.original.info.build_id;
// if (!id) return null;
// return (
// <Link to={`/builds/${id}`} className="flex items-center gap-2">
// <ResourceComponents.Build.Icon id={id} />
// <ResourceComponents.Build.Name id={id} />
// </Link>
// );
// },
// },
{
accessorKey: "info.image",
header: "Image",
},
{
header: "Status",
cell: ({ row }) => {
const status = row.original.info.status;
if (!status) return null;
const state = row.original.info.state;
const color = deployment_state_text_color(state);
return <div className={color}>{status}</div>;
},
},
{
header: "Tags",
cell: ({ row }) => {
return (
<div className="flex gap-1">
<TagsWithBadge resource_tags={row.original.tags} />
</div>
);
}
},
{
header: "Created",
accessorFn: ({ created_at }) =>
fmt_date_with_minutes(new Date(created_at)),
},
]}
/>
);
return <DeploymentTable deployments={deployments} />;
},
};
export const DeploymentTable = ({
deployments,
}: {
deployments: Types.DeploymentListItem[] | undefined;
}) => {
const tags = useTagsFilter();
return (
<DataTable
data={
deployments?.filter((deployment) =>
tags.every((tag) => deployment.tags.includes(tag))
) ?? []
}
columns={[
{
accessorKey: "id",
header: "Name",
cell: ({ row }) => {
const id = row.original.id;
return (
<Link
to={`/deployments/${id}`}
className="flex items-center gap-2"
>
<ResourceComponents.Deployment.Icon id={id} />
<ResourceComponents.Deployment.Name id={id} />
</Link>
);
},
},
// {
// header: "Description",
// accessorKey: "description",
// },
{
header: "Server",
cell: ({ row }) => {
const id = row.original.info.server_id;
return (
<Link to={`/servers/${id}`} className="flex items-center gap-2">
<ResourceComponents.Server.Icon id={id} />
<ResourceComponents.Server.Name id={id} />
</Link>
);
},
},
// {
// header: "Build",
// cell: ({ row }) => {
// const id = row.original.info.build_id;
// if (!id) return null;
// return (
// <Link to={`/builds/${id}`} className="flex items-center gap-2">
// <ResourceComponents.Build.Icon id={id} />
// <ResourceComponents.Build.Name id={id} />
// </Link>
// );
// },
// },
{
accessorKey: "info.image",
header: "Image",
},
{
header: "Status",
cell: ({ row }) => {
const status = row.original.info.status;
if (!status) return null;
const state = row.original.info.state;
const color = deployment_state_text_color(state);
return <div className={color}>{status}</div>;
},
},
{
header: "Tags",
cell: ({ row }) => {
return (
<div className="flex gap-1">
<TagsWithBadge tag_ids={row.original.tags} />
</div>
);
},
},
{
header: "Created",
accessorFn: ({ created_at }) =>
fmt_date_with_minutes(new Date(created_at)),
},
]}
/>
);
};

View File

@@ -11,11 +11,18 @@ import { Input } from "@ui/input";
import { DataTable } from "@ui/data-table";
import { Link } from "react-router-dom";
import { ResourceComponents } from "..";
import { TagsWithBadge, useTagsFilter } from "@components/tags";
export const useServer = (id?: string) =>
useRead("ListServers", {}).data?.find((d) => d.id === id);
const ServerInfo = ({ id }: { id: string }) => {
export const ServerInfo = ({
id,
showRegion = true,
}: {
id: string;
showRegion?: boolean;
}) => {
const server = useServer(id);
const stats = useRead(
"GetBasicSystemStats",
@@ -29,10 +36,12 @@ const ServerInfo = ({ id }: { id: string }) => {
).data;
return (
<>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
{useServer(id)?.info.region}
</div>
{showRegion && (
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
{useServer(id)?.info.region}
</div>
)}
<div className="flex gap-4 text-muted-foreground">
<div className="flex gap-2 items-center">
<Cpu className="w-4 h-4" />
@@ -51,7 +60,7 @@ const ServerInfo = ({ id }: { id: string }) => {
);
};
const ServerIconComponent = ({ id }: { id?: string }) => {
export const ServerIconComponent = ({ id }: { id?: string }) => {
const status = useServer(id)?.info.status;
const color = () => {
@@ -144,13 +153,15 @@ export const ServerComponents: RequiredResourceComponents = {
New: () => <NewServer />,
Table: () => {
const servers = useRead("ListServers", {}).data;
const all_tags = useRead("ListTags", {}).data;
// const nav = useNavigate();
const tags = useTagsFilter();
return (
<DataTable
// onRowClick={({ id }) => nav(`/servers/${id}`)}
data={servers ?? []}
data={
servers?.filter((server) =>
tags.every((tag) => server.tags.includes(tag))
) ?? []
}
columns={[
{
header: "Name",
@@ -172,18 +183,22 @@ export const ServerComponents: RequiredResourceComponents = {
// header: "Description",
// accessorKey: "description",
// },
{
header: "Tags",
accessorFn: ({ tags }) =>
tags
.map((t) => all_tags?.find((tg) => tg._id?.$oid === t)?.name)
.join(", "),
},
{
header: "Deployments",
cell: ({ row }) => <DeploymentCountOnServer id={row.original.id} />,
},
{ header: "Region", accessorKey: "info.region" },
{
header: "Tags",
cell: ({ row }) => {
return (
<div className="flex gap-1">
<TagsWithBadge tag_ids={row.original.tags} />
</div>
);
},
},
{
header: "Created",
accessorFn: ({ created_at }) =>

View File

@@ -1,6 +1,7 @@
import { useInvalidate, useRead, useWrite } from "@lib/hooks";
import { Types } from "@monitor/client";
import { Badge } from "@ui/badge";
import { Button } from "@ui/button";
import { Checkbox } from "@ui/checkbox";
import {
Command,
@@ -9,31 +10,85 @@ import {
CommandInput,
CommandItem,
} from "@ui/command";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from "@ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
import { useToast } from "@ui/use-toast";
import { atom, useAtom } from "jotai";
import { Pen, PlusCircle } from "lucide-react";
import { useEffect, useState } from "react";
type TargetExcludingSystem = Exclude<Types.ResourceTarget, { type: "System" }>;
const tagsAtom = atom<string[]>([]);
export const useTagsFilter = () => {
const [tags, _] = useAtom(tagsAtom);
return tags
}
export const TagsFilter = () => {
const [tags, setTags] = useAtom(tagsAtom);
const all_tags = useRead("ListTags", {}).data;
return (
<div className="flex gap-4">
<TagsWithBadge
className="cursor-pointer"
tag_ids={tags}
onBadgeClick={(tag_id) => setTags(tags.filter((id) => id !== tag_id))}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex gap-2">
Filter by Tag <PlusCircle className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-36" side="bottom">
<DropdownMenuGroup>
{all_tags
?.filter((tag) => !tags.includes(tag._id!.$oid))
.map((tag) => (
<DropdownMenuItem
className="cursor-pointer"
key={tag.name}
onClick={() => setTags([...tags, tag._id!.$oid])}
>
{tag.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export const ResourceTags = ({ target }: { target: TargetExcludingSystem }) => {
const { type, id } = target;
const resource = useRead(`List${type}s`, {}).data?.find((d) => d.id === id);
return <TagsWithBadge resource_tags={resource?.tags} />;
return <TagsWithBadge tag_ids={resource?.tags} />;
};
export const TagsWithBadge = ({
resource_tags,
tag_ids,
onBadgeClick,
className,
}: {
resource_tags?: string[];
tag_ids?: string[];
onBadgeClick?: (tag_id: string) => void;
className?: string;
}) => {
const all_tags = useRead("ListTags", {}).data;
return (
<>
{resource_tags?.map((tag_id) => (
<Badge key={tag_id} variant="secondary" className="px-1.5 py-0.5">
{all_tags?.find((t) => t._id?.$oid === tag_id)?.name}
{tag_ids?.map((tag_id) => (
<Badge
key={tag_id}
variant="secondary"
className={className ?? "px-1.5 py-0.5 cursor-pointer"}
onClick={() => onBadgeClick && onBadgeClick(tag_id)}
>
{all_tags?.find((t) => t._id?.$oid === tag_id)?.name ?? "unknown"}
</Badge>
))}
</>

View File

@@ -11,6 +11,7 @@ import {
Moon,
Settings,
SunMedium,
Tag,
User,
} from "lucide-react";
import { Input } from "../ui/input";
@@ -319,6 +320,8 @@ export const ResourceTypeDropdown = () => {
? [<FolderTree className="w-4 h-4" />, "Tree"]
: location.pathname === "/keys"
? [<Key className="w-4 h-4" />, "Api Keys"]
: location.pathname === "/tags"
? [<Tag className="w-4 h-4" />, "Tags"]
: [<Box className="w-4 h-4" />, "Dashboard"];
return (
@@ -356,6 +359,12 @@ export const ResourceTypeDropdown = () => {
</Link>
);
})}
<Link to="/tags">
<DropdownMenuItem className="flex items-center gap-2">
<Tag className="w-4 h-4" />
Tags
</DropdownMenuItem>
</Link>
<Link to="/keys">
<DropdownMenuItem className="flex items-center gap-2">
<Box className="w-4 h-4" />

View File

@@ -1,5 +1,6 @@
import { Page, Section, ResourceCard } from "@components/layouts";
import { ResourceComponents } from "@components/resources";
import { TagsFilter, useTagsFilter } from "@components/tags";
import { useRead, useResourceParamType } from "@lib/hooks";
import { Button } from "@ui/button";
import { Input } from "@ui/input";
@@ -9,7 +10,9 @@ export const Resources = () => {
const type = useResourceParamType();
const Components = ResourceComponents[type];
const list = useRead(`List${type}s`, {}).data;
const tags = useTagsFilter();
const list = useRead(`List${type}s`, { query: { tags } }).data;
const [search, set] = useState("");
const [view, setView] = useState<"cards" | "table">("table");
@@ -18,20 +21,25 @@ export const Resources = () => {
<Page
title={`${type}s`}
actions={
<div className="flex gap-4">
<Button
variant="outline"
onClick={() => setView((v) => (v === "cards" ? "table" : "cards"))}
>
show as {view === "cards" ? "table" : "cards"}
</Button>
<Input
value={search}
onChange={(e) => set(e.target.value)}
placeholder="search..."
className="w-96"
/>
<Components.New />
<div className="grid gap-4 justify-items-end">
<div className="flex gap-4">
<Button
variant="outline"
onClick={() =>
setView((v) => (v === "cards" ? "table" : "cards"))
}
>
show as {view === "cards" ? "table" : "cards"}
</Button>
<Input
value={search}
onChange={(e) => set(e.target.value)}
placeholder="search..."
className="w-96"
/>
<Components.New />
</div>
<TagsFilter />
</div>
}
>

201
frontend/src/pages/tags.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { Page } from "@components/layouts";
import { ConfirmButton, CopyButton } from "@components/util";
import { useInvalidate, useRead, useWrite } from "@lib/hooks";
import { fmt_date } from "@lib/utils";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { Button } from "@ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@ui/card";
import { useToast } from "@ui/use-toast";
import { Trash, PlusCircle, Loader2, Check, Tag } from "lucide-react";
import { useState } from "react";
import { Input } from "@ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/dropdown-menu";
import { UpdateUser } from "@components/updates/details";
export const Tags = () => {
return (
<Page title="Tags" actions={<CreateKey />}>
<TagList />
</Page>
);
};
export const TagList = () => {
const tags = useRead("ListTags", {}).data;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tags?.map((tag) => (
<Card
id={tag._id!.$oid}
className="h-full hover:bg-accent/50 group-focus:bg-accent/50 transition-colors"
>
<CardHeader className="flex-row justify-between items-center">
<CardTitle>{tag.name}</CardTitle>
<DeleteTag tag_id={tag._id!.$oid} />
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{tag.owner && (
<div>
owner: <UpdateUser user_id={tag.owner} />
</div>
)}
</CardContent>
</Card>
))}
</div>
);
};
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
type ExpiresOptions = "90 days" | "180 days" | "1 year" | "never";
const CreateKey = () => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [expires, setExpires] = useState<ExpiresOptions>("never");
const [submitted, setSubmitted] = useState<{ key: string; secret: string }>();
const invalidate = useInvalidate();
const { mutate, isPending } = useWrite("CreateApiKey", {
onSuccess: ({ key, secret }) => {
invalidate(["ListApiKeys"]);
setSubmitted({ key, secret });
},
});
const now = Date.now();
const expiresOptions: Record<ExpiresOptions, number> = {
"90 days": now + ONE_DAY_MS * 90,
"180 days": now + ONE_DAY_MS * 180,
"1 year": now + ONE_DAY_MS * 365,
never: 0,
};
const submit = () => mutate({ name, expires: expiresOptions[expires] });
const onOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
setName("");
setExpires("never");
setSubmitted(undefined);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button className="items-center gap-2">
New Api Key <PlusCircle className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent>
{submitted ? (
<>
<DialogHeader>
<DialogTitle>Api Key Created</DialogTitle>
</DialogHeader>
<div className="py-8 flex flex-col gap-4">
<div className="flex items-center justify-between">
Key
<Input className="w-72" value={submitted.key} disabled />
<CopyButton content={submitted.key} />
</div>
<div className="flex items-center justify-between">
Secret
<Input className="w-72" value={submitted.secret} disabled />
<CopyButton content={submitted.secret} />
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={() => onOpenChange(false)}>
Confirm <Check className="w-4" />
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Create Api Key</DialogTitle>
</DialogHeader>
<div className="py-8 flex flex-col gap-4">
<div className="flex items-center justify-between">
Name
<Input
className="w-72"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex items-center justify-between">
Expiry
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-36 justify-between px-3">
{expires}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-36" side="bottom">
<DropdownMenuGroup>
{Object.keys(expiresOptions)
.filter((option) => option !== expires)
.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => setExpires(option as any)}
>
{option}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={submit} disabled={isPending}>
Submit
{isPending ? (
<Loader2 className="w-4 animate-spin" />
) : (
<Check className="w-4" />
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
const DeleteTag = ({ tag_id }: { tag_id: string }) => {
const invalidate = useInvalidate();
const { toast } = useToast();
const { mutate, isPending } = useWrite("DeleteTag", {
onSuccess: () => {
invalidate(["ListTags"]);
toast({ title: "Tag Deleted" });
},
onError: () => {
toast({ title: "Failed to delete tag" });
},
});
return (
<ConfirmButton
title="Delete"
icon={<Trash className="w-4 h-4" />}
onClick={() => mutate({ id: tag_id })}
loading={isPending}
/>
);
};

View File

@@ -1,3 +1,58 @@
import { Page, Section } from "@components/layouts";
import { DeploymentTable } from "@components/resources/deployment";
import { ServerIconComponent, ServerInfo } from "@components/resources/server";
import { TagsFilter, TagsWithBadge, useTagsFilter } from "@components/tags";
import { useRead } from "@lib/hooks";
import { Button } from "@ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@ui/card";
import { useState } from "react";
import { Link } from "react-router-dom";
export const Tree = () => {
return (<div></div>)
}
const tags = useTagsFilter();
const servers = useRead("ListServers", { query: { tags } }).data;
return (
<Page title="Tree" actions={<TagsFilter />}>
<Section title="">
{servers?.map((server) => (
<Server key={server.id} id={server.id} />
))}
</Section>
</Page>
);
};
const Server = ({ id }: { id: string }) => {
const [open, setOpen] = useState(false);
const server = useRead("ListServers", {}).data?.find(
(server) => server.id === id
);
const deployments = useRead("ListDeployments", {}).data?.filter(
(deployment) => deployment.info.server_id === id
);
return (
<>
<Card
className="h-full hover:bg-accent/50 group-focus:bg-accent/50 transition-colors cursor-pointer"
onClick={() => setOpen(!open)}
>
<CardHeader className="p-4 flex-row justify-between items-center">
<div>
<CardTitle>{server?.name}</CardTitle>
<CardDescription>Server</CardDescription>
</div>
<div className="flex gap-3 justify-between items-center">
<TagsWithBadge tag_ids={server?.tags} />
{server?.id && <ServerInfo id={server.id} showRegion={false} />}
<Link to={`/servers/${server?.id}`}>
<Button variant="outline">
<ServerIconComponent id={server?.id} />
</Button>
</Link>
</div>
</CardHeader>
</Card>
{open && <DeploymentTable deployments={deployments} />}
</>
);
};

View File

@@ -7,6 +7,7 @@ import { Resources } from "@pages/resources";
import { Keys } from "@pages/keys";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { Tree } from "@pages/tree";
import { Tags } from "@pages/tags";
const router = createBrowserRouter([
{
@@ -16,6 +17,7 @@ const router = createBrowserRouter([
{ path: "", element: <Dashboard /> },
{ path: "tree", element: <Tree /> },
{ path: "keys", element: <Keys /> },
{ path: "tags", element: <Tags /> },
{
path: ":type",
children: [