diff --git a/web/app/(app)/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx index 6d70617..4968f0f 100644 --- a/web/app/(app)/servers/[id]/page.tsx +++ b/web/app/(app)/servers/[id]/page.tsx @@ -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() { +
+ +
+
diff --git a/web/app/(app)/workloads/page.tsx b/web/app/(app)/workloads/page.tsx new file mode 100644 index 0000000..8e3fdca --- /dev/null +++ b/web/app/(app)/workloads/page.tsx @@ -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(); + 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 ( +
+
+

Workloads

+

Containers and systemd services across the fleet, as last reported by each agent.

+
+ + +
{ + e.preventDefault(); + setApplied({ image: image.trim(), stack: stack.trim(), state: state.trim() }); + }} + > + setImage(e.target.value)} /> + setStack(e.target.value)} /> + setState(e.target.value)} /> + +
+
+ + + {hits.isLoading ? ( +

Loading…

+ ) : (hits.data ?? []).length === 0 ? ( +

No workloads match.

+ ) : ( + + + + + + + + + + + + + {(hits.data ?? []).map((h) => ( + + + + + + + + + ))} + +
ServerWorkloadKindStateImageStack
+ + {hostnames.get(h.server_id) ?? h.server_id} + + {h.workload.name} + {h.workload.kind} + {h.workload.state}{h.workload.image ?? "—"}{h.workload.stack ?? "—"}
+ )} +
+
+ ); +} 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 ( +
+
+
+ {w.name} + {w.state} + {w.health && {w.health}} + {!!w.restarts && w.restarts > 0 && {w.restarts} restarts} +
+

+ {w.kind === "container" ? w.image || "no image" : "systemd unit"} + {w.ports && w.ports.length > 0 && {w.ports.join(" ")}} +

+
+ +
+ {canControl && + ACTIONS.map((a) => ( + + ))} + {canControl && ( + + )} +
+
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index bee2700..1b0178b 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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 { + return request(`/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 { + 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(`/workloads${qs ? `?${qs}` : ""}`); + }, +}; + // `request` already prefixes /api, so these paths do not repeat it. export const licence = { get(): Promise {