forked from github-starred/komodo
deleting tags deletes across
This commit is contained in:
@@ -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)),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
201
frontend/src/pages/tags.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user