248 lines
9.6 KiB
TypeScript
248 lines
9.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { api, ServerStatus } from "@/lib/api";
|
|
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
|
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
|
|
|
function statusVariant(status: ServerStatus) {
|
|
switch (status) {
|
|
case "active": return "success";
|
|
case "pending": return "warning";
|
|
case "offline": return "danger";
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr: string) {
|
|
return new Date(dateStr).toLocaleString();
|
|
}
|
|
|
|
export default function ServerDetailPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const queryClient = useQueryClient();
|
|
const serverId = params.id as string;
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
|
|
|
const { data: server, isLoading, error } = useQuery({
|
|
queryKey: ["servers", serverId],
|
|
queryFn: () => api.getServer(serverId),
|
|
refetchInterval: 30_000,
|
|
});
|
|
|
|
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
|
mutationFn: () => api.generateKeyForServer(serverId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
|
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
|
},
|
|
});
|
|
|
|
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
|
mutationFn: () => api.deleteServer(serverId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
|
router.push("/servers");
|
|
},
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !server) {
|
|
return (
|
|
<div className="p-8">
|
|
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
|
|
Server not found or failed to load.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="mb-6 flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
|
|
← Servers
|
|
</Link>
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-3">
|
|
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
|
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
|
</div>
|
|
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
loading={isGenerating}
|
|
onClick={() => generateKey()}
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
|
</svg>
|
|
Generate SSH Key
|
|
</Button>
|
|
{!confirmDelete ? (
|
|
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
|
Remove Server
|
|
</Button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-danger">Are you sure?</span>
|
|
<Button
|
|
variant="danger"
|
|
loading={isDeleting}
|
|
onClick={() => deleteServer()}
|
|
>
|
|
Confirm
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Update Agent</CardTitle>
|
|
</CardHeader>
|
|
<p className="mb-4 text-sm text-text-secondary">
|
|
Run this command on the server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code> to update the agent to the latest version:
|
|
</p>
|
|
<div className="relative rounded-lg border border-border bg-[#0a0c14] p-4 font-mono text-sm">
|
|
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
|
|
<span className="text-accent">$</span>{" "}
|
|
<span className="text-text-primary">{api.getUpdateCommand()}</span>
|
|
</pre>
|
|
<button
|
|
onClick={async () => {
|
|
await navigator.clipboard.writeText(api.getUpdateCommand());
|
|
setCopiedUpdate(true);
|
|
setTimeout(() => setCopiedUpdate(false), 2000);
|
|
}}
|
|
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
|
>
|
|
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
|
</button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
<Card className="lg:col-span-1">
|
|
<CardHeader>
|
|
<CardTitle>Details</CardTitle>
|
|
</CardHeader>
|
|
<dl className="space-y-3 text-sm">
|
|
<div>
|
|
<dt className="text-text-secondary">Server ID</dt>
|
|
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
|
|
</div>
|
|
<div className="border-t border-border pt-3">
|
|
<dt className="text-text-secondary">OS</dt>
|
|
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
|
</div>
|
|
<div className="border-t border-border pt-3">
|
|
<dt className="text-text-secondary">Last Seen</dt>
|
|
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
|
</div>
|
|
<div className="border-t border-border pt-3">
|
|
<dt className="text-text-secondary">Registered</dt>
|
|
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
|
|
</div>
|
|
</dl>
|
|
</Card>
|
|
|
|
<div className="lg:col-span-2">
|
|
<Card padding={false}>
|
|
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
|
<h2 className="text-lg font-semibold text-text-primary">
|
|
Installed Keys
|
|
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">
|
|
{server.keys?.filter(k => !k.revoked_at).length ?? 0} active
|
|
</span>
|
|
</h2>
|
|
<Link href="/keys">
|
|
<Button variant="ghost" size="sm">Manage Keys →</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{!server.keys || server.keys.length === 0 ? (
|
|
<div className="py-16 text-center">
|
|
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
|
|
<Link href="/keys">
|
|
<Button variant="secondary" size="sm" className="mt-3">
|
|
Assign a key
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<Thead>
|
|
<Tr>
|
|
<Th>Label</Th>
|
|
<Th>Fingerprint</Th>
|
|
<Th>Source</Th>
|
|
<Th>Status</Th>
|
|
<Th>Assigned</Th>
|
|
<Th />
|
|
</Tr>
|
|
</Thead>
|
|
<Tbody>
|
|
{server.keys.map((assignment) => (
|
|
<Tr key={assignment.key_id}>
|
|
<Td>
|
|
<span className="font-medium">{assignment.key.label}</span>
|
|
</Td>
|
|
<Td>
|
|
<span className="font-mono text-xs text-text-secondary">
|
|
{assignment.key.fingerprint}
|
|
</span>
|
|
</Td>
|
|
<Td>
|
|
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>
|
|
{assignment.key.source}
|
|
</Badge>
|
|
</Td>
|
|
<Td>
|
|
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
|
|
{assignment.revoked_at ? "revoked" : "active"}
|
|
</Badge>
|
|
</Td>
|
|
<Td>
|
|
<span className="text-text-secondary text-xs">
|
|
{formatDate(assignment.assigned_at)}
|
|
</span>
|
|
</Td>
|
|
<Td>
|
|
<Link href={`/keys/${assignment.key_id}`}>
|
|
<Button variant="ghost" size="sm">View</Button>
|
|
</Link>
|
|
</Td>
|
|
</Tr>
|
|
))}
|
|
</Tbody>
|
|
</Table>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|