feat: Updated vuln style
This commit is contained in:
@@ -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
Reference in New Issue
Block a user