From 340d013bb324dbe75492d6a43f30b502ea127d71 Mon Sep 17 00:00:00 2001 From: mbecker20 Date: Mon, 25 Mar 2024 01:14:45 -0700 Subject: [PATCH] deleting tags deletes across --- bin/core/src/api/write/tag.rs | 26 +++ bin/core/src/helpers/resource.rs | 17 ++ client/core/rs/src/entities/resource.rs | 1 + .../components/resources/deployment/index.tsx | 179 ++++++++-------- .../src/components/resources/server/index.tsx | 49 +++-- frontend/src/components/tags/index.tsx | 67 +++++- frontend/src/components/util.tsx | 9 + frontend/src/pages/resources.tsx | 38 ++-- frontend/src/pages/tags.tsx | 201 ++++++++++++++++++ frontend/src/pages/tree.tsx | 59 ++++- frontend/src/router.tsx | 2 + 11 files changed, 525 insertions(+), 123 deletions(-) create mode 100644 frontend/src/pages/tags.tsx diff --git a/bin/core/src/api/write/tag.rs b/bin/core/src/api/write/tag.rs index 5430b8ae3..6b8ed3ec9 100644 --- a/bin/core/src/api/write/tag.rs +++ b/bin/core/src/api/write/tag.rs @@ -85,7 +85,33 @@ impl Resolve for State { user: User, ) -> anyhow::Result { let tag = get_tag_check_owner(&id, &user).await?; + + tokio::try_join!( + >::remove_tag_from_resources( + self, &id, + ), + >::remove_tag_from_resources( + self, &id, + ), + >::remove_tag_from_resources( + self, &id, + ), + >::remove_tag_from_resources( + self, &id, + ), + >::remove_tag_from_resources( + self, &id, + ), + >::remove_tag_from_resources( + self, &id, + ), + >::remove_tag_from_resources( + self, &id, + ), + )?; + delete_one_by_id(&db_client().await.tags, &id, None).await?; + Ok(tag) } } diff --git a/bin/core/src/helpers/resource.rs b/bin/core/src/helpers/resource.rs index ddaa9ecab..05011fcbc 100644 --- a/bin/core/src/helpers/resource.rs +++ b/bin/core/src/helpers/resource.rs @@ -194,6 +194,23 @@ pub trait StateResource< .await?; Ok(()) } + + async fn remove_tag_from_resources( + &self, + tag_id: &str, + ) -> anyhow::Result<()> { + self + .coll() + .await + .update_many( + doc! {}, + doc! { "$pull": { "tags": tag_id } }, + None, + ) + .await + .context("failed to remove tag from resources")?; + Ok(()) + } } #[async_trait] diff --git a/client/core/rs/src/entities/resource.rs b/client/core/rs/src/entities/resource.rs index 467fe5ef4..1ce890dcf 100644 --- a/client/core/rs/src/entities/resource.rs +++ b/client/core/rs/src/entities/resource.rs @@ -67,6 +67,7 @@ pub struct ResourceListItem { pub struct ResourceQuery { #[serde(default)] pub names: Vec, + /// Pass Vec of tag ids #[serde(default)] pub tags: Vec, #[serde(default)] diff --git a/frontend/src/components/resources/deployment/index.tsx b/frontend/src/components/resources/deployment/index.tsx index e75c480d2..db62acb5f 100644 --- a/frontend/src/components/resources/deployment/index.tsx +++ b/frontend/src/components/resources/deployment/index.tsx @@ -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 ( - { - const id = row.original.id; - return ( - - - - - ); - }, - }, - // { - // header: "Description", - // accessorKey: "description", - // }, - - { - header: "Server", - cell: ({ row }) => { - const id = row.original.info.server_id; - return ( - - - - - ); - }, - }, - // { - // header: "Build", - // cell: ({ row }) => { - // const id = row.original.info.build_id; - // if (!id) return null; - // return ( - // - // - // - // - // ); - // }, - // }, - { - 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
{status}
; - }, - }, - { - header: "Tags", - cell: ({ row }) => { - return ( -
- -
- ); - } - }, - { - header: "Created", - accessorFn: ({ created_at }) => - fmt_date_with_minutes(new Date(created_at)), - }, - ]} - /> - ); + return ; }, }; + +export const DeploymentTable = ({ + deployments, +}: { + deployments: Types.DeploymentListItem[] | undefined; +}) => { + const tags = useTagsFilter(); + return ( + + tags.every((tag) => deployment.tags.includes(tag)) + ) ?? [] + } + columns={[ + { + accessorKey: "id", + header: "Name", + cell: ({ row }) => { + const id = row.original.id; + return ( + + + + + ); + }, + }, + // { + // header: "Description", + // accessorKey: "description", + // }, + + { + header: "Server", + cell: ({ row }) => { + const id = row.original.info.server_id; + return ( + + + + + ); + }, + }, + // { + // header: "Build", + // cell: ({ row }) => { + // const id = row.original.info.build_id; + // if (!id) return null; + // return ( + // + // + // + // + // ); + // }, + // }, + { + 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
{status}
; + }, + }, + { + header: "Tags", + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + }, + { + header: "Created", + accessorFn: ({ created_at }) => + fmt_date_with_minutes(new Date(created_at)), + }, + ]} + /> + ); +}; diff --git a/frontend/src/components/resources/server/index.tsx b/frontend/src/components/resources/server/index.tsx index a94213dd2..22d96e774 100644 --- a/frontend/src/components/resources/server/index.tsx +++ b/frontend/src/components/resources/server/index.tsx @@ -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 ( <> -
- - {useServer(id)?.info.region} -
+ {showRegion && ( +
+ + {useServer(id)?.info.region} +
+ )}
@@ -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: () => , Table: () => { const servers = useRead("ListServers", {}).data; - const all_tags = useRead("ListTags", {}).data; - - // const nav = useNavigate(); + const tags = useTagsFilter(); return ( 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 }) => , }, { header: "Region", accessorKey: "info.region" }, + { + header: "Tags", + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + }, { header: "Created", accessorFn: ({ created_at }) => diff --git a/frontend/src/components/tags/index.tsx b/frontend/src/components/tags/index.tsx index d44b1d5ae..a9ba080b2 100644 --- a/frontend/src/components/tags/index.tsx +++ b/frontend/src/components/tags/index.tsx @@ -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; +const tagsAtom = atom([]); + +export const useTagsFilter = () => { + const [tags, _] = useAtom(tagsAtom); + return tags +} + +export const TagsFilter = () => { + const [tags, setTags] = useAtom(tagsAtom); + const all_tags = useRead("ListTags", {}).data; + return ( +
+ setTags(tags.filter((id) => id !== tag_id))} + /> + + + + + + + {all_tags + ?.filter((tag) => !tags.includes(tag._id!.$oid)) + .map((tag) => ( + setTags([...tags, tag._id!.$oid])} + > + {tag.name} + + ))} + + + +
+ ); +} + export const ResourceTags = ({ target }: { target: TargetExcludingSystem }) => { const { type, id } = target; const resource = useRead(`List${type}s`, {}).data?.find((d) => d.id === id); - return ; + return ; }; 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) => ( - - {all_tags?.find((t) => t._id?.$oid === tag_id)?.name} + {tag_ids?.map((tag_id) => ( + onBadgeClick && onBadgeClick(tag_id)} + > + {all_tags?.find((t) => t._id?.$oid === tag_id)?.name ?? "unknown"} ))} diff --git a/frontend/src/components/util.tsx b/frontend/src/components/util.tsx index d07198a7c..41487c5c6 100644 --- a/frontend/src/components/util.tsx +++ b/frontend/src/components/util.tsx @@ -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 = () => { ? [, "Tree"] : location.pathname === "/keys" ? [, "Api Keys"] + : location.pathname === "/tags" + ? [, "Tags"] : [, "Dashboard"]; return ( @@ -356,6 +359,12 @@ export const ResourceTypeDropdown = () => { ); })} + + + + Tags + + diff --git a/frontend/src/pages/resources.tsx b/frontend/src/pages/resources.tsx index c9d015da6..9f95ed517 100644 --- a/frontend/src/pages/resources.tsx +++ b/frontend/src/pages/resources.tsx @@ -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 = () => { - - set(e.target.value)} - placeholder="search..." - className="w-96" - /> - +
+
+ + set(e.target.value)} + placeholder="search..." + className="w-96" + /> + +
+
} > diff --git a/frontend/src/pages/tags.tsx b/frontend/src/pages/tags.tsx new file mode 100644 index 000000000..7e25a4fe1 --- /dev/null +++ b/frontend/src/pages/tags.tsx @@ -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 ( + }> + + + ); +}; + +export const TagList = () => { + const tags = useRead("ListTags", {}).data; + return ( +
+ {tags?.map((tag) => ( + + + {tag.name} + + + + {tag.owner && ( +
+ owner: +
+ )} +
+
+ ))} +
+ ); +}; + +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("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 = { + "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 ( + + + + + + {submitted ? ( + <> + + Api Key Created + +
+
+ Key + + +
+
+ Secret + + +
+
+ + + + + ) : ( + <> + + Create Api Key + +
+
+ Name + setName(e.target.value)} + /> +
+
+ Expiry + + + + + + + {Object.keys(expiresOptions) + .filter((option) => option !== expires) + .map((option) => ( + setExpires(option as any)} + > + {option} + + ))} + + + +
+
+ + + + + )} +
+
+ ); +}; + +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 ( + } + onClick={() => mutate({ id: tag_id })} + loading={isPending} + /> + ); +}; diff --git a/frontend/src/pages/tree.tsx b/frontend/src/pages/tree.tsx index 7b1677d2f..ce74bf3e4 100644 --- a/frontend/src/pages/tree.tsx +++ b/frontend/src/pages/tree.tsx @@ -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 (
) -} \ No newline at end of file + const tags = useTagsFilter(); + const servers = useRead("ListServers", { query: { tags } }).data; + return ( + }> +
+ {servers?.map((server) => ( + + ))} +
+
+ ); +}; + +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 ( + <> + setOpen(!open)} + > + +
+ {server?.name} + Server +
+
+ + {server?.id && } + + + +
+
+
+ {open && } + + ); +}; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 65956eaf5..ec0b18791 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: }, { path: "tree", element: }, { path: "keys", element: }, + { path: "tags", element: }, { path: ":type", children: [