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>
);
}