feat: workload registry UI

This commit is contained in:
2026-08-07 09:06:36 +01:00
parent fd4c51f3db
commit 483053b9a2
7 changed files with 519 additions and 0 deletions
+13
View File
@@ -133,10 +133,23 @@ function ShieldIcon() {
);
}
function WorkloadIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Modal } from "@/components/ui";
import { workloads, type WorkloadKind } from "@/lib/api";
/*
* A bounded snapshot, not a follow. The browser console already offers a real
* terminal on the same server where `docker logs -f` works properly, with its
* own scrollback and cancellation.
*/
export function LogDialog({
serverId,
kind,
id,
name,
onClose,
}: {
serverId: string;
kind: WorkloadKind;
id: string;
name: string;
onClose: () => void;
}) {
const pre = useRef<HTMLPreElement>(null);
const logs = useQuery({
queryKey: ["workload-logs", serverId, kind, id],
queryFn: () => workloads.logs(serverId, kind, id),
refetchOnWindowFocus: false,
});
// Newest output is the point of the snapshot, so it opens at the bottom.
useEffect(() => {
if (logs.data && pre.current) pre.current.scrollTop = pre.current.scrollHeight;
}, [logs.data]);
return (
<Modal open title={`Logs · ${name}`} onClose={onClose} wide>
{logs.isLoading && <p className="text-sm text-text-secondary">Reading logs from the agent</p>}
{logs.isError && <p className="text-sm text-danger">{(logs.error as Error).message}</p>}
{logs.data && (
<>
{/* Stated, not implied: a truncated log must never be read as
a complete one. */}
{logs.data.truncated && (
<p className="mb-3 rounded border border-warning/50 px-3 py-2 text-xs text-warning">
Output was capped at 500 lines or 256KB, whichever came first. Older lines are not shown.
</p>
)}
<pre ref={pre} className="max-h-[55dvh] overflow-auto rounded border border-border bg-well p-3 font-mono text-xs text-text-primary">
{logs.data.text || "(no output)"}
</pre>
</>
)}
</Modal>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Card } from "@/components/ui";
import { workloads, type Workload, type WorkloadAction, type WorkloadKind } from "@/lib/api";
import { WorkloadRow } from "./WorkloadRow";
import { LogDialog } from "./LogDialog";
function relativeAge(iso?: string): string {
if (!iso) return "never";
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${Math.round(secs)}s ago`;
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
return `${Math.round(secs / 3600)}h ago`;
}
/** Compose stacks first, grouped under the stack name; then loose containers;
* then units. Not cosmetic: a stack is one thing to an operator even when it
* is six containers, and a flat list turns one decision into six rows. */
function group(list: Workload[]) {
const stacks = new Map<string, Workload[]>();
const loose: Workload[] = [];
const units: Workload[] = [];
for (const w of list) {
if (w.kind === "unit") units.push(w);
else if (w.stack) stacks.set(w.stack, [...(stacks.get(w.stack) ?? []), w]);
else loose.push(w);
}
const byName = (a: Workload, b: Workload) => a.name.localeCompare(b.name);
return {
stacks: [...stacks.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([name, items]) => [name, items.sort(byName)] as const),
loose: loose.sort(byName),
units: units.sort(byName),
};
}
export function WorkloadList({ serverId, canControl }: { serverId: string; canControl: boolean }) {
const qc = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [logTarget, setLogTarget] = useState<Workload | null>(null);
const snapshot = useQuery({
queryKey: ["workloads", serverId],
queryFn: () => workloads.forServer(serverId),
});
const refresh = useMutation({
mutationFn: () => workloads.refresh(serverId),
// The refresh returns no data — the agent reports through the normal
// path, so the only correct move is to refetch the stored document.
onSuccess: () => {
setError(null);
setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500);
},
onError: (e: Error) => setError(e.message),
});
const control = useMutation({
mutationFn: ({ w, action }: { w: Workload; action: WorkloadAction }) => workloads.control(serverId, w.kind as WorkloadKind, w.id, action),
onSuccess: () => {
setError(null);
qc.invalidateQueries({ queryKey: ["workloads", serverId] });
},
onError: (e: Error) => setError(e.message),
});
// Opening the panel asks for a fresh list: this page carries a Restart
// button, and a stale row is a wrong action aimed at something already dead.
useEffect(() => {
refresh.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId]);
const data = snapshot.data;
const grouped = useMemo(() => group(data?.workloads ?? []), [data]);
const row = (w: Workload) => (
<WorkloadRow
key={`${w.kind}:${w.id}`}
workload={w}
canControl={canControl}
busy={control.isPending}
onAction={(action) => control.mutate({ w, action })}
onLogs={() => setLogTarget(w)}
/>
);
return (
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-text-primary">Workloads</h2>
<p className="mt-0.5 text-xs text-text-secondary">Collected {relativeAge(data?.collected_at)}</p>
</div>
<Button variant="ghost" size="sm" loading={refresh.isPending} onClick={() => refresh.mutate()}>
Refresh
</Button>
</div>
<div className="px-6 py-4">
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
{snapshot.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !data ? (
<p className="text-sm text-text-secondary">Nothing reported yet. Agents report every 60 seconds, on Linux only.</p>
) : (
<div className="space-y-2 text-sm">
{/* Docker absent is the common case on a fleet built
around SSH keys, and is not a fault. Installed but
not responding is a different problem, so it reads
differently. */}
{data.docker_error ? (
<p className="text-warning">Docker is installed but not responding: {data.docker_error}</p>
) : !data.docker_ok ? (
<p className="text-text-secondary">Docker is not in use on this server.</p>
) : null}
{data.systemd_error ? (
<p className="text-warning">systemd could not be read: {data.systemd_error}</p>
) : !data.systemd_ok ? (
<p className="text-text-secondary">systemd is not in use on this server.</p>
) : null}
</div>
)}
</div>
{grouped.stacks.map(([stack, items]) => (
<div key={stack}>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">
stack · {stack}
</div>
{items.map(row)}
</div>
))}
{grouped.loose.length > 0 && (
<div>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">containers</div>
{grouped.loose.map(row)}
</div>
)}
{grouped.units.length > 0 && (
<div>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">services</div>
{grouped.units.map(row)}
</div>
)}
{logTarget && (
<LogDialog
serverId={serverId}
kind={logTarget.kind as WorkloadKind}
id={logTarget.id}
name={logTarget.name}
onClose={() => setLogTarget(null)}
/>
)}
</Card>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { Badge, Button } from "@/components/ui";
import type { Workload, WorkloadAction } from "@/lib/api";
/*
* Container and unit states are kept in their own vocabularies on purpose — a
* failed unit and an exited container mean different things. Colour never
* carries the state on its own: Badge already pairs a dot with the word.
*/
function stateVariant(w: Workload): "success" | "warning" | "danger" | "neutral" {
if (w.kind === "container") {
switch (w.state) {
case "running":
return w.health === "unhealthy" ? "danger" : "success";
case "restarting":
case "paused":
case "created":
return "warning";
case "dead":
case "exited":
return "danger";
default:
return "neutral";
}
}
switch (w.state) {
case "active":
return "success";
case "activating":
case "reloading":
return "warning";
case "failed":
return "danger";
case "inactive":
return "neutral";
default:
return "neutral";
}
}
const ACTIONS: WorkloadAction[] = ["start", "stop", "restart"];
export function WorkloadRow({
workload,
canControl,
busy,
onAction,
onLogs,
}: {
workload: Workload;
canControl: boolean;
busy: boolean;
onAction: (action: WorkloadAction) => void;
onLogs: () => void;
}) {
const w = workload;
return (
<div className="flex flex-col gap-3 border-b border-border px-6 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-sm text-text-primary">{w.name}</span>
<Badge variant={stateVariant(w)}>{w.state}</Badge>
{w.health && <Badge variant={w.health === "healthy" ? "success" : "warning"}>{w.health}</Badge>}
{!!w.restarts && w.restarts > 0 && <Badge variant="warning">{w.restarts} restarts</Badge>}
</div>
<p className="mt-1 truncate text-xs text-text-secondary">
{w.kind === "container" ? w.image || "no image" : "systemd unit"}
{w.ports && w.ports.length > 0 && <span className="ml-2 font-mono">{w.ports.join(" ")}</span>}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{canControl &&
ACTIONS.map((a) => (
<Button
key={a}
size="sm"
variant={a === "stop" ? "danger" : "secondary"}
/* Protected rows show the action disabled with the
reason rather than offering a button whose refusal
the agent has already told us about. */
disabled={w.protected || busy}
title={w.protected ? "This workload runs the Vantage agent and cannot be controlled from here" : undefined}
onClick={() => onAction(a)}
>
{a}
</Button>
))}
{canControl && (
<Button size="sm" variant="ghost" onClick={onLogs}>
Logs
</Button>
)}
</div>
</div>
);
}