feat: vulnerability findings UI

Fleet board grouped by CVE, a per-server section on server detail, and
alert rules beside the channels they consume.

The server detail page has no tab pattern despite the plan saying to
follow one, so this adds a section in the existing vertical stack.

Three states are kept visually distinct because they are identical if
handled carelessly and only one is good news: never reported, no advisory
feed for the distribution, and scanned-and-clean. Database freshness sits
with the findings rather than in settings for the same reason.
This commit is contained in:
2026-08-06 14:40:37 +01:00
parent 5dda3b5c4a
commit 84dfcfeac7
11 changed files with 963 additions and 0 deletions
+5
View File
@@ -9,6 +9,7 @@ import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
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";
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -549,6 +550,10 @@ export default function ServerDetailPage() {
</dl>
</Card>
<div className="lg:col-span-2">
<ServerVulnerabilities serverId={server.server_id} />
</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">
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
import { Badge, Button, Card } from "@/components/ui";
import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
@@ -182,6 +183,12 @@ export default function NotificationSettingsPage() {
channels.map((ch) => <ChannelRow key={ch.channel_id} ch={ch} />)
)}
</Card>
{/* Beside the channels it consumes rather than on its own page: a rule is
a routing decision about destinations configured directly above it. */}
<div className="mt-6">
<VulnAlertRulesCard />
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { FindingRow } from "@/components/vulnerabilities/FindingRow";
import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals";
/*
* The fleet vulnerability board.
*
* Grouped by CVE, defaulting to open findings, with database freshness always
* on screen. The three things this page must never do: imply freshness it does
* not have, present an unsupported distribution as clean, or make one CVE on
* forty servers look like forty problems.
*/
const STATES: FindingState[] = ["open", "accepted", "fixed"];
export default function VulnerabilitiesPage() {
const { isAdmin } = useAuth();
const qc = useQueryClient();
const [state, setState] = useState<FindingState>("open");
const [severity, setSeverity] = useState<Severity | "">("");
const [accepting, setAccepting] = useState<VulnFinding | null>(null);
const groups = useQuery({
queryKey: ["vulnerabilities", state, severity],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined }),
});
const summary = useQuery({
queryKey: ["vulnerabilities", "summary"],
queryFn: () => vulnerabilities.summary(),
});
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const serverName = useMemo(() => {
const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname]));
// Falls back to the raw id rather than an empty cell: an unnamed row is
// worse than an ugly one.
return (id: string) => byId.get(id) ?? id;
}, [servers.data]);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["vulnerabilities"] });
};
const rescan = useMutation({
mutationFn: () => vulnerabilities.rescan(),
onSuccess: invalidate,
});
const accept = useMutation({
mutationFn: ({ id, reason, until }: { id: string; reason: string; until: string }) => vulnerabilities.accept(id, reason, until),
onSuccess: () => {
setAccepting(null);
invalidate();
},
});
const unaccept = useMutation({
mutationFn: (id: string) => vulnerabilities.unaccept(id),
onSuccess: invalidate,
});
const applyUpdates = useMutation({
mutationFn: (serverId: string) => api.applyUpdates(serverId),
});
const counts = summary.data?.counts ?? {};
const total = SEVERITY_ORDER.reduce((n, s) => n + (counts[s] ?? 0), 0);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-text-primary">Vulnerabilities</h1>
<p className="mt-1 text-sm text-text-secondary">
Installed packages matched against distribution security advisories.
</p>
</div>
{isAdmin && (
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
Rescan fleet
</Button>
)}
</div>
<DBFreshness summary={summary.data} />
<div className="flex flex-wrap gap-4 rounded-lg border border-border bg-surface px-5 py-4">
{SEVERITY_ORDER.map((s) => (
<button
key={s}
onClick={() => setSeverity(severity === s ? "" : s)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left transition-colors ${
severity === s ? "bg-surface-2" : "hover:bg-surface-2"
}`}
>
<SeverityBadge severity={s} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
</button>
))}
<span className="ml-auto self-center font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
{total} open
</span>
</div>
<div className="flex gap-2">
{STATES.map((s) => (
<button
key={s}
onClick={() => setState(s)}
className={`rounded border px-3 py-1.5 text-sm capitalize transition-colors ${
state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
>
{s}
</button>
))}
</div>
{groups.isLoading && <p className="text-sm text-text-secondary">Loading</p>}
{groups.error && <p className="text-sm text-danger">{(groups.error as Error).message}</p>}
{groups.data && groups.data.length === 0 && (
<div className="rounded-lg border border-border bg-surface px-5 py-8 text-center">
<p className="text-sm text-text-secondary">No {state} findings.</p>
<p className="mt-1 text-xs text-text-tertiary">
Servers report packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own
page rather than counted here.
</p>
</div>
)}
<div className="space-y-2">
{groups.data?.map((g) => (
<FindingRow
key={g.cve_id}
group={g}
serverName={serverName}
canAct={isAdmin}
onAccept={setAccepting}
onUnaccept={(f) => unaccept.mutate(f.id)}
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
/>
))}
</div>
{accepting && (
<AcceptDialog
finding={accepting}
serverName={serverName(accepting.server_id)}
pending={accept.isPending}
onClose={() => setAccepting(null)}
onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })}
/>
)}
</div>
);
}