+ );
+}
diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx
index 24c401a..8216135 100644
--- a/web/components/Sidebar.tsx
+++ b/web/components/Sidebar.tsx
@@ -133,10 +133,23 @@ function ShieldIcon() {
);
}
+function WorkloadIcon() {
+ return (
+
+ );
+}
+
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: },
{ href: "/monitors", label: "Monitors", icon: },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: },
+ { href: "/workloads", label: "Workloads", icon: },
{ href: "/keys", label: "SSH Keys", icon: },
{ href: "/secrets", label: "Secrets", icon: },
{ href: "/workflows", label: "Workflows", icon: },
diff --git a/web/components/workloads/LogDialog.tsx b/web/components/workloads/LogDialog.tsx
new file mode 100644
index 0000000..a44b5a6
--- /dev/null
+++ b/web/components/workloads/LogDialog.tsx
@@ -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(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 (
+
+ {logs.isLoading &&
Reading logs from the agent…
}
+
+ {logs.isError &&
{(logs.error as Error).message}
}
+
+ {logs.data && (
+ <>
+ {/* Stated, not implied: a truncated log must never be read as
+ a complete one. */}
+ {logs.data.truncated && (
+
+ Output was capped at 500 lines or 256KB, whichever came first. Older lines are not shown.
+
+ )}
+
+ {logs.data.text || "(no output)"}
+
+ >
+ )}
+
+ );
+}
diff --git a/web/components/workloads/WorkloadList.tsx b/web/components/workloads/WorkloadList.tsx
new file mode 100644
index 0000000..aa4fe5f
--- /dev/null
+++ b/web/components/workloads/WorkloadList.tsx
@@ -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();
+ 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(null);
+ const [logTarget, setLogTarget] = useState(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) => (
+ control.mutate({ w, action })}
+ onLogs={() => setLogTarget(w)}
+ />
+ );
+
+ return (
+
+
+
+
Workloads
+
Collected {relativeAge(data?.collected_at)}
+
+
+
+
+
+ {error &&
{error}
}
+
+ {snapshot.isLoading ? (
+
Loading…
+ ) : !data ? (
+
Nothing reported yet. Agents report every 60 seconds, on Linux only.
+ ) : (
+
+ {/* 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 ? (
+
Docker is installed but not responding: {data.docker_error}
+ ) : !data.docker_ok ? (
+
Docker is not in use on this server.
+ ) : null}
+
+ {data.systemd_error ? (
+
systemd could not be read: {data.systemd_error}
+ ) : !data.systemd_ok ? (
+
systemd is not in use on this server.
+ ) : null}
+
+ )}
+
+
+ {grouped.stacks.map(([stack, items]) => (
+
+
+ stack · {stack}
+
+ {items.map(row)}
+
+ ))}
+
+ {grouped.loose.length > 0 && (
+
+
containers
+ {grouped.loose.map(row)}
+
+ )}
+
+ {grouped.units.length > 0 && (
+
+
services
+ {grouped.units.map(row)}
+
+ )}
+
+ {logTarget && (
+ setLogTarget(null)}
+ />
+ )}
+
+ );
+}
diff --git a/web/components/workloads/WorkloadRow.tsx b/web/components/workloads/WorkloadRow.tsx
new file mode 100644
index 0000000..c135e8f
--- /dev/null
+++ b/web/components/workloads/WorkloadRow.tsx
@@ -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 (
+