feat: workload registry UI
This commit is contained in:
@@ -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 /> },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user