feat: add table to show nodes and add dropdown to add manager & workers

This commit is contained in:
Mauricio Siu
2024-05-17 02:56:50 -06:00
parent 42e9aa1834
commit 976d1f312f
10 changed files with 409 additions and 148 deletions

View File

@@ -0,0 +1,49 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
export const AddManager = () => {
const { data } = api.cluster.addManager.useQuery();
return (
<>
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer flex flex-row gap-2 items-center"
onSelect={(e) => e.preventDefault()}
>
Add Manager
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-screen overflow-y-auto ">
<DialogHeader>
<DialogTitle>Add a new manager</DialogTitle>
<DialogDescription>Add a new manager</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2">
curl https://get.docker.com | sh -s -- --version 24.0
</span>
</div>
<div className="flex flex-col gap-4 text-sm">
<span>
2. Run the following command to add the node(server) to your
cluster
</span>
<span className="bg-muted rounded-lg p-2 ">{data}</span>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,43 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
interface Props {
data: unknown;
}
export const ShowNodeData = ({ data }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Config
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Node Config</DialogTitle>
<DialogDescription>
See in detail the metadata of this node
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
<code>
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(data, null, 2)}
</pre>
</code>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,131 @@
import React from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { AddWorker } from "./workers/add-worker";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { Badge } from "@/components/ui/badge";
import { DeleteWorker } from "./workers/delete-worker";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { MoreHorizontal, PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ShowNodeData } from "./show-node-data";
import { AddManager } from "./manager/add-manager";
export const ShowNodes = () => {
const { data, isLoading } = api.cluster.getNodes.useQuery();
return (
<Card className="bg-transparent h-full">
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Cluster</CardTitle>
<CardDescription>Add nodes to your cluster</CardDescription>
</div>
<div className="flex flex-row gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<PlusIcon className="h-4 w-4" />
Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<AddWorker />
<AddManager />
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid md:grid-cols-1 gap-4">
{isLoading && <div>Loading...</div>}
<Table>
<TableCaption>A list of your managers / workers.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Hostname</TableHead>
<TableHead className="text-right">Status</TableHead>
<TableHead className="text-right">Role</TableHead>
<TableHead className="text-right">Availability</TableHead>
<TableHead className="text-right">Engine Version</TableHead>
<TableHead className="text-right">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((node) => {
const isManager = node.Spec.Role === "manager";
return (
<TableRow key={node.ID}>
<TableCell className="w-[100px]">
{node.Description.Hostname}
</TableCell>
<TableCell className="text-right">
{node.Status.State}
</TableCell>
<TableCell className="text-right">
<Badge variant={isManager ? "default" : "secondary"}>
{node?.Spec?.Role}
</Badge>
</TableCell>
<TableCell className="text-right">
{node.Spec.Availability}
</TableCell>
<TableCell className="text-right">
{node?.Description.Engine.EngineVersion}
</TableCell>
<TableCell className="text-right">
<DateTooltip date={node.CreatedAt} className="text-sm">
Created{" "}
</DateTooltip>
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} />
{!node?.ManagerStatus?.Leader && (
<DeleteWorker nodeId={node.ID} />
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,47 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
export const AddWorker = () => {
const { data } = api.cluster.addWorker.useQuery();
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer flex flex-row gap-2 items-center"
onSelect={(e) => e.preventDefault()}
>
Add Worker
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-screen overflow-y-auto ">
<DialogHeader>
<DialogTitle>Add a new worker</DialogTitle>
<DialogDescription>Add a new worker</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2">
curl https://get.docker.com | sh -s -- --version 24.0
</span>
</div>
<div className="flex flex-col gap-4 text-sm">
<span>
2. Run the following command to add the node(server) to your cluster
</span>
<span className="bg-muted rounded-lg p-2 ">{data}</span>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,62 @@
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
interface Props {
nodeId: string;
}
export const DeleteWorker = ({ nodeId }: Props) => {
const { mutateAsync, isLoading } = api.cluster.removeWorker.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
worker.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
nodeId,
})
.then(async () => {
utils.cluster.getNodes.invalidate();
toast.success("Worker deleted succesfully");
})
.catch(() => {
toast.error("Error to delete the worker");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,80 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const AddWorkerSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
});
type AddWorker = z.infer<typeof AddWorkerSchema>;
export const AddWorker = () => {
const utils = api.useUtils();
const { data, isLoading } = api.cluster.addWorker.useQuery();
return (
<Dialog>
<DialogTrigger asChild>
<Button>
<PlusIcon className="h-4 w-4" />
Add Worker
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-screen overflow-y-auto ">
<DialogHeader>
<DialogTitle>Add a new worker</DialogTitle>
<DialogDescription>Add a new worker</DialogDescription>
</DialogHeader>
{/* {isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)} */}
<div className="flex flex-col gap-4 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2">
curl https://get.docker.com | sh -s -- --version 24.0
</span>
</div>
<div className="flex flex-col gap-4 text-sm">
<span>
2. Run the following command to add the node(server) to your cluster
</span>
<span className="bg-muted rounded-lg p-2 ">{data}</span>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,62 +0,0 @@
import React from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { AddWorker } from "./add-worker";
import { DateTooltip } from "@/components/shared/date-tooltip";
export const ShowCluster = () => {
const { data, isLoading } = api.cluster.getWorkers.useQuery();
// console.log(data)
return (
<Card className="bg-transparent h-full">
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Cluster</CardTitle>
<CardDescription>Add nodes to your cluster</CardDescription>
</div>
<AddWorker />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid md:grid-cols-1 gap-4">
{isLoading && <div>Loading...</div>}
{data?.map((worker, index) => (
<div
key={`key-${index}`}
className="flex flex-row gap-4 w-full flex-wrap"
>
<span className="text-sm text-muted-foreground">
{worker.Description.Hostname}
</span>
<span className="text-sm text-muted-foreground">
{worker.Status.State}
</span>
<span className="text-sm text-muted-foreground">
{worker.Spec.Availability}
</span>
<span className="text-sm text-muted-foreground">
{worker?.ManagerStatus?.Reachability || "-"}
</span>
<span className="text-sm text-muted-foreground">
{worker?.Spec?.Role}
</span>
<span className="text-sm text-muted-foreground">
{worker?.Description.Engine.EngineVersion}
</span>
<DateTooltip date={worker.CreatedAt} className="text-sm">
{/* <span className="text-sm text-muted-foreground">Created</span> */}
</DateTooltip>
</div>
))}
</div>
</CardContent>
</Card>
);
};

View File

@@ -1,6 +1,5 @@
import { ShowCertificates } from "@/components/dashboard/settings/certificates/show-certificates";
import { ShowRegistry } from "@/components/dashboard/settings/cluster/registry/show-registry";
import { ShowCluster } from "@/components/dashboard/settings/cluster/worker/show-workers";
import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout";
import { validateRequest } from "@/server/auth/auth";
@@ -11,7 +10,7 @@ const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowRegistry />
<ShowCluster />
<ShowNodes />
</div>
);
};

View File

@@ -1,17 +1,48 @@
import { docker } from "@/server/constants";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { getPublicIpWithFallback } from "@/server/wss/terminal";
import type { DockerNode } from "../services/cluster";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { execAsync } from "@/server/utils/process/execAsync";
export const clusterRouter = createTRPCRouter({
getWorkers: protectedProcedure.query(async () => {
const workers = await docker.listNodes();
// console.log(workers);
getNodes: protectedProcedure.query(async () => {
const workers: DockerNode[] = await docker.listNodes();
return workers;
}),
removeWorker: protectedProcedure
.input(
z.object({
nodeId: z.string(),
}),
)
.mutation(async ({ input }) => {
try {
await execAsync(
`docker node update --availability drain ${input.nodeId}`,
);
await execAsync(`docker node rm ${input.nodeId} --force`);
return true;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Error to remove the node",
cause: error,
});
}
}),
addWorker: protectedProcedure.query(async ({ input }) => {
const result = await docker.swarmInspect();
return `docker swarm join --token ${
result.JoinTokens.Worker
} ${await getPublicIpWithFallback()}:2377`;
}),
addManager: protectedProcedure.query(async ({ input }) => {
const result = await docker.swarmInspect();
return `docker swarm join --token ${
result.JoinTokens.Manager
} ${await getPublicIpWithFallback()}:2377`;
}),
});

View File

@@ -0,0 +1,41 @@
export interface DockerNode {
ID: string;
Version: {
Index: number;
};
CreatedAt: string;
UpdatedAt: string;
Spec: {
Name: string;
Labels: Record<string, string>;
Role: "worker" | "manager";
Availability: "active" | "pause" | "drain";
};
Description: {
Hostname: string;
Platform: {
Architecture: string;
OS: string;
};
Resources: {
NanoCPUs: number;
MemoryBytes: number;
};
Engine: {
EngineVersion: string;
Plugins: Array<{
Type: string;
Name: string;
}>;
};
};
Status: {
State: "unknown" | "down" | "ready" | "disconnected";
Message: string;
Addr: string;
};
ManagerStatus?: {
Leader: boolean;
Addr: string;
};
}