Files
keymanager/web/app/servers/[id]/page.tsx
T
domrichardson c9868b2108
Agent Release / build (push) Has been cancelled
Server Deploy / deploy (push) Has been cancelled
first commit
2026-06-15 13:58:45 +01:00

220 lines
8.1 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 { 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="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>
);
}