Node management

This commit is contained in:
2025-12-28 00:46:06 +01:00
parent 97d374920b
commit 127e946780
14 changed files with 666 additions and 31 deletions
+8 -1
View File
@@ -2,9 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { version } from "@/../package.json";
import testAuth from "../auth";
import { StatusCodes } from "http-status-codes";
import { read } from "@/app/api/file";
import { NodeJsonTemplate } from "../types";
import osName from "os-name";
export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = testAuth(req);
const auth = await testAuth(req);
if (auth) {
return NextResponse.json(
@@ -13,9 +16,13 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
);
}
const { node_id } = await read(NodeJsonTemplate, "data", "node.json");
return NextResponse.json({
message: "ok",
app: "dockship",
version,
node_id,
os: osName(),
});
}
+1 -5
View File
@@ -1,10 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { version } from "@/../package.json";
import { read } from "@/app/api/file";
import { NodeJsonTemplate } from "@/app/api/types";
export async function GET(req: NextRequest): Promise<NextResponse> {
const { node_id } = await read(NodeJsonTemplate, "data", "node.json");
return NextResponse.json({ app: "dockship", version, node_id });
return NextResponse.json({ app: "dockship", version });
}
-2
View File
@@ -26,8 +26,6 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
const root = disks.find((d) => d.mount === "/" || d.fs.includes("disk3"));
console.log(mem.available, mem.total);
return NextResponse.json({
message: "ok",
liveData: {
+17 -5
View File
@@ -10,6 +10,8 @@ import { Docker, Node, NodeLiveData } from "@/lib/types";
import { useEffect, useState } from "react";
import { getDockerNode, getLiveNodes } from "@/core/client/node";
import { formatBytes } from "@/lib/format";
import { setNodes as updateNodes } from "@/core/client/node";
import AddNode from "@/components/dialogs/AddNode";
function DiskUsage({ liveData }: NodeLiveData) {
const usage = (liveData.disk.used / liveData.disk.size) * 100;
@@ -56,14 +58,16 @@ export default function NodesPage() {
{/* {nodes.filter((n) => n.status === "running").length} online */}
</Badge>
</div>
<Button size="sm" className="gap-2">
<Plus className="w-4 h-4" /> Add Node
</Button>
<AddNode>
<Button size="sm" className="gap-2">
<Plus className="w-4 h-4" /> Add Node
</Button>
</AddNode>
</div>
<div className="flex flex-col gap-4">
{nodes.map((node, i) => (
<Card key={node.node_id}>
<Card key={i}>
<CardHeader className="flex flex-col sm:flex-row sm:items-start sm:justify-between pb-2 gap-2 sm:gap-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="w-10 h-10 bg-secondary flex items-center justify-center">
@@ -88,7 +92,15 @@ export default function NodesPage() {
<Button variant="ghost" size="icon-sm">
<Settings className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon-sm">
<Button
variant="ghost"
size="icon-sm"
onClick={async () => {
const newNodes = nodes.filter((_, index) => index !== i);
setNodes(newNodes);
await updateNodes(newNodes);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
+2 -5
View File
@@ -227,11 +227,8 @@ export default function OverviewPage() {
</CardHeader>
<CardContent className="overflow-x-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 min-w-75">
{nodes.map((node) => (
<div
key={node.name}
className="border border-border p-4 bg-secondary/30"
>
{nodes.map((node, i) => (
<div key={i} className="border border-border p-4 bg-secondary/30">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-3 gap-2">
<div className="flex items-center gap-2">
<StatusIndicator status={"running"} />
+1 -13
View File
@@ -1,17 +1,5 @@
"use client";
import { getDocker } from "@/core/client/docker";
export default function Home() {
return (
<div>
<button
onClick={async () => {
console.log(await getDocker("localhost", "oD7tMrnkxIvuFrR+b6QZh66E4PU="));
}}
>
Get docker
</button>
</div>
);
return "ok";
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useState, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "../ui/textarea";
import {
authNode,
getNodes,
setNodes as updateNodes,
} from "@/core/client/node";
export default function AddNode({ children }: { children: ReactNode }) {
const [step, setStep] = useState<1 | 2>(1);
const [loading, setLoading] = useState(false);
const [ip, setIp] = useState("");
const [key, setKey] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [auth, setAuth] = useState<{ node_id: string; os: string }>();
const authenticateNode = async () => {
setLoading(true);
try {
setAuth(await authNode(ip.trim(), key.trim()));
setStep(2);
} catch (e) {
console.log(e);
} finally {
setLoading(false);
}
};
const addNode = async () => {
if (!auth) return;
const nodes = await getNodes();
await updateNodes([
...nodes,
{
node_id: auth.node_id,
key,
name,
description,
ip,
os: auth.os,
labels: [],
},
]);
setStep(1);
setAuth(undefined);
setIp("");
setKey("");
setName("");
setDescription("");
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-106.25">
<DialogHeader>
<DialogTitle>
{step === 1 && "Add Node"}
{step === 2 && "Node Details"}
</DialogTitle>
</DialogHeader>
{/* STEP 1: IP & Node Key */}
{step === 1 && (
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="ip">
IP address <span className="text-destructive">*</span>
</Label>
<Input
id="ip"
value={ip}
onChange={(e) => setIp(e.target.value)}
placeholder="10.7.1.21"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="key">
Node Key <span className="text-destructive">*</span>
</Label>
<Input
id="key"
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="xxxxxxxxxxxxxxxx"
/>
</div>
</div>
)}
{/* STEP 3: Node Name & Description */}
{step === 2 && (
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="name">
Node Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Captain's node"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="description">
Node Description{" "}
<span className="text-muted-foreground/60">(optional)</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A node for the Captain's main operations"
/>
</div>
</div>
)}
<DialogFooter>
{step === 1 && (
<>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={authenticateNode} disabled={loading}>
Next
</Button>
</>
)}
{step === 2 && (
<>
<Button variant="outline" onClick={() => setStep(1)}>
Back
</Button>
<DialogClose asChild>
<Button onClick={addNode}>Add Node</Button>
</DialogClose>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+10
View File
@@ -39,3 +39,13 @@ export async function getDockerNode(node: Node): Promise<Docker> {
).data;
return { ...node, containers: data.containers, version: data.version };
}
export async function authNode(ip: string, key: string) {
const data = await (
await axios.get(`http://${ip}:3000/api/auth`, {
headers: { Authorization: `ApiKey ${key}` },
})
).data;
return { version: data.version, node_id: data.node_id, os: data.os };
}
+1
View File
@@ -6,6 +6,7 @@ export interface Node {
ip: string;
os: string;
name: string;
description: string;
labels: string[];
}