Compare commits

...
1 Commits
Author SHA1 Message Date
mrhid6 d1769fc886 feat: Updated vuln style
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
2026-08-06 16:35:04 +01:00
5 changed files with 189 additions and 120 deletions
+16 -9
View File
@@ -7,16 +7,21 @@ import { useAuth } from "@/components/AuthProvider";
import { Button, Card } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { FindingRow } from "@/components/vulnerabilities/FindingRow";
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals";
import { groupByPackage } from "@/lib/vulnPackages";
/*
* 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.
* Grouped by package, 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
* upgrade look like several problems.
*
* The API groups by CVE; the rollup to packages happens here, in
* `lib/vulnPackages`, because a finding is still per-CVE everywhere it is
* stored, accepted or remediated.
*/
const STATES: FindingState[] = ["open", "accepted", "fixed"];
@@ -41,6 +46,8 @@ export default function VulnerabilitiesPage() {
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const packages = useMemo(() => groupByPackage(groups.data ?? []), [groups.data]);
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
@@ -142,10 +149,10 @@ export default function VulnerabilitiesPage() {
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : groups.data && groups.data.length > 0 ? (
groups.data.map((g) => (
<FindingRow
key={g.cve_id}
) : packages.length > 0 ? (
packages.map((g) => (
<PackageRow
key={g.package_name}
group={g}
serverName={serverName}
canAct={isAdmin}
@@ -1,110 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding, VulnGroup } from "@/lib/api";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per CVE, expandable to the servers it affects.
*
* The grouping is the point. The same CVE across forty servers is one decision
* — patch it, or accept it and say why — and a flat list of forty findings
* makes it look like forty decisions, which is how a board stops being read.
*/
interface Props {
group: VulnGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A CVE with no fix anywhere cannot be patched, only accepted. Saying so on
// the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.findings.some((f) => f.fixed_in);
return (
// A row inside the page's one bordered container, not a card of its
// own — the same stack idiom as the monitors and workflows lists.
<div className="border-t border-border-soft first:border-t-0">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.cve_id}</span>
{group.title && <span className="hidden truncate text-sm text-text-secondary sm:block">{group.title}</span>}
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && (
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
no fix
</span>
)}
</button>
{open && (
<div className="border-t border-border-soft bg-surface-2/40">
{group.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
<Link href={`/servers/${f.server_id}`} className="text-sm text-accent hover:underline">
{serverName(f.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto flex gap-2">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
{f.fixed_in && f.state !== "fixed" && (
<Button size="sm" variant="secondary" loading={applying === f.server_id} onClick={() => onApplyUpdates(f.server_id)}>
Apply updates
</Button>
)}
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding } from "@/lib/api";
import type { PackageGroup, PackageServer } from "@/lib/vulnPackages";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per package, expandable to the servers carrying it and the CVEs on
* each.
*
* The grouping is the point, and it is the same argument as the CVE grouping it
* replaced, one level in: an operator upgrades a package, not a CVE. Two CVEs
* on one apache2 are one upgrade to the higher of the two fix versions, and
* showing them as two rows with two different targets is how a fleet gets
* patched to the lower one.
*/
interface Props {
group: PackageGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function PackageRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A package with no fix anywhere cannot be patched, only accepted. Saying so
// on the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.servers.some((s) => s.target);
return (
// A row inside the page's one bordered container, not a card of its
// own — the same stack idiom as the monitors and workflows lists.
<div className="border-t border-border-soft first:border-t-0">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.package_name}</span>
<span className="whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.cve_count} {group.cve_count === 1 ? "CVE" : "CVEs"}
</span>
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && (
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
no fix
</span>
)}
</button>
{open && (
<div className="border-t border-border-soft bg-surface-2/40">
{group.servers.map((s) => (
<ServerBlock
key={`${s.package_name}:${s.server_id}`}
row={s}
serverName={serverName}
canAct={canAct}
onAccept={onAccept}
onUnaccept={onUnaccept}
onApplyUpdates={onApplyUpdates}
applying={applying}
/>
))}
</div>
)}
</div>
);
}
function ServerBlock({
row,
serverName,
canAct,
onAccept,
onUnaccept,
onApplyUpdates,
applying,
}: Omit<Props, "group"> & { row: PackageServer }) {
const patchable = row.target && row.findings.some((f) => f.state !== "fixed");
return (
<div className="border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Link href={`/servers/${row.server_id}`} className="text-sm text-accent hover:underline">
{serverName(row.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">{row.installed_version}</span>
{/* One target, the highest across every CVE on this package.
A lower fix version does not remediate a higher one, so it
is never the number offered. */}
<span className="font-mono text-xs text-text-tertiary">{row.target ? `${row.target}` : "no fix published"}</span>
{row.superseded && (
<span
className="whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary"
title="Several CVEs name different fix versions; the highest is shown and covers the rest."
>
supersedes lower fixes
</span>
)}
{canAct && patchable && (
<div className="ml-auto">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
<Button size="sm" variant="secondary" loading={applying === row.server_id} onClick={() => onApplyUpdates(row.server_id)}>
Apply updates
</Button>
</div>
)}
</div>
<div className="mt-2 space-y-1.5">
{row.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 pl-1">
<SeverityBadge severity={f.severity} />
<span className="font-mono text-xs text-text-secondary">{f.cve_id}</span>
{f.title && <span className="hidden truncate text-xs text-text-tertiary sm:block">{f.title}</span>}
{/* The per-CVE fix stays visible when it differs from
the row's target, so the rollup can be checked
rather than taken on trust. */}
{f.fixed_in && f.fixed_in !== row.target && (
<span className="font-mono text-[11px] text-text-tertiary">fixed in {f.fixed_in}</span>
)}
{!f.fixed_in && <span className="font-mono text-[11px] text-text-tertiary">no fix</span>}
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto">
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
</div>
);
}
Binary file not shown.
File diff suppressed because one or more lines are too long