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