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
+9
View File
@@ -10,6 +10,8 @@ import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
import { TagChips } from "@/components/servers/TagChips";
import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities";
import { WorkloadList } from "@/components/workloads/WorkloadList";
import { useAuth } from "@/components/AuthProvider";
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -311,6 +313,9 @@ export default function ServerDetailPage() {
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
const [applySuccess, setApplySuccess] = useState(false);
const { hasFeature } = useLicense();
// Control actions and log reads are owner|admin server-side; the UI matches
// so a member is not offered buttons the API will refuse.
const { isAdmin } = useAuth();
const consoleAllowed = hasFeature("console");
const {
@@ -554,6 +559,10 @@ export default function ServerDetailPage() {
<ServerVulnerabilities serverId={server.server_id} />
</div>
<div className="lg:col-span-3">
<WorkloadList serverId={server.server_id} canControl={isAdmin} />
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { api, workloads } from "@/lib/api";
/*
* The fleet view answers "which servers run image X", which is the reason the
* snapshot is stored at all rather than fetched on demand and discarded.
*/
export default function WorkloadsPage() {
const [image, setImage] = useState("");
const [stack, setStack] = useState("");
const [state, setState] = useState("");
const [applied, setApplied] = useState<{ image?: string; stack?: string; state?: string }>({});
const hits = useQuery({
queryKey: ["workloads", "fleet", applied],
queryFn: () => workloads.search(applied),
});
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const hostnames = useMemo(() => {
const m = new Map<string, string>();
for (const s of servers.data ?? []) m.set(s.server_id, s.hostname);
return m;
}, [servers.data]);
const inputClass =
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent focus:outline-none";
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Workloads</h1>
<p className="mt-1 text-sm text-text-secondary">Containers and systemd services across the fleet, as last reported by each agent.</p>
</div>
<Card className="mb-6">
<form
className="grid gap-3 sm:grid-cols-4"
onSubmit={(e) => {
e.preventDefault();
setApplied({ image: image.trim(), stack: stack.trim(), state: state.trim() });
}}
>
<input className={inputClass} placeholder="image (exact)" value={image} onChange={(e) => setImage(e.target.value)} />
<input className={inputClass} placeholder="stack" value={stack} onChange={(e) => setStack(e.target.value)} />
<input className={inputClass} placeholder="state" value={state} onChange={(e) => setState(e.target.value)} />
<Button type="submit" variant="primary">
Search
</Button>
</form>
</Card>
<Card padding={false}>
{hits.isLoading ? (
<p className="px-6 py-5 text-sm text-text-secondary">Loading</p>
) : (hits.data ?? []).length === 0 ? (
<p className="px-6 py-5 text-sm text-text-secondary">No workloads match.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>Workload</Th>
<Th>Kind</Th>
<Th>State</Th>
<Th>Image</Th>
<Th>Stack</Th>
</Tr>
</Thead>
<Tbody>
{(hits.data ?? []).map((h) => (
<Tr key={`${h.server_id}:${h.workload.kind}:${h.workload.id}`}>
<Td>
<Link href={`/servers/${h.server_id}`} className="text-accent hover:underline">
{hostnames.get(h.server_id) ?? h.server_id}
</Link>
</Td>
<Td className="font-mono text-xs">{h.workload.name}</Td>
<Td>
<Badge variant="neutral">{h.workload.kind}</Badge>
</Td>
<Td>{h.workload.state}</Td>
<Td className="font-mono text-xs">{h.workload.image ?? "—"}</Td>
<Td>{h.workload.stack ?? "—"}</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
);
}
+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>
);
}
+73
View File
@@ -1054,6 +1054,79 @@ export const vulnerabilities = {
},
};
export type WorkloadKind = "container" | "unit";
export type WorkloadAction = "start" | "stop" | "restart";
/** One container or one systemd unit.
*
* `state` is deliberately not a shared vocabulary across the two kinds:
* containers report running/exited/paused/restarting/created, units report
* active/inactive/failed/activating. A failed unit and an exited container
* mean different things. */
export interface Workload {
kind: WorkloadKind;
id: string;
name: string;
state: string;
health?: string;
image?: string;
stack?: string;
ports?: string[];
restarts?: number;
started_at?: string;
protected: boolean;
}
export interface ServerWorkloads {
server_id: string;
hash?: string;
workloads: Workload[];
collected_at?: string;
/** false with no error means "Docker not in use here", which is not a
* fault. With an error it means installed but not responding. */
docker_ok: boolean;
docker_error?: string;
systemd_ok: boolean;
systemd_error?: string;
}
export interface WorkloadHit {
server_id: string;
workload: Workload;
}
export const workloads = {
forServer(serverId: string): Promise<ServerWorkloads> {
return request<ServerWorkloads>(`/servers/${serverId}/workloads`);
},
refresh(serverId: string): Promise<{ message: string }> {
return request<{ message: string }>(`/servers/${serverId}/workloads/refresh`, { method: "POST" });
},
control(serverId: string, kind: WorkloadKind, id: string, action: WorkloadAction): Promise<{ message: string }> {
return request<{ message: string }>(
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/action`,
{ method: "POST", body: JSON.stringify({ kind, action }) },
);
},
logs(serverId: string, kind: WorkloadKind, id: string, tail = 500): Promise<{ text: string; truncated: boolean }> {
return request<{ text: string; truncated: boolean }>(
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/logs?kind=${kind}&tail=${tail}`,
);
},
search(params?: { image?: string; stack?: string; state?: string }): Promise<WorkloadHit[]> {
const q = new URLSearchParams();
if (params?.image) q.set("image", params.image);
if (params?.stack) q.set("stack", params.stack);
if (params?.state) q.set("state", params.state);
const qs = q.toString();
return request<WorkloadHit[]>(`/workloads${qs ? `?${qs}` : ""}`);
},
};
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {