style: bring the vulnerabilities page onto the house page shape

Every other page under app/(app) opens with `p-4 sm:p-6 lg:p-8` and the
layout adds none of its own, so this page alone sat flush against the shell
edge. Its h1 was text-xl where every other page is text-2xl.

The findings list was a stack of separately bordered cards; it is now rows
inside one Card, separated by border-border-soft, matching the monitors and
workflows lists. Loading is the shared spinner rather than a line of text,
the error is the shared danger strip, and the empty state uses the same
proportions as the monitors one.
This commit is contained in:
2026-08-06 15:49:39 +01:00
parent 6354d54de8
commit 81248bb159
2 changed files with 63 additions and 44 deletions
+51 -38
View File
@@ -4,7 +4,7 @@ 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 { Button, Card } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { FindingRow } from "@/components/vulnerabilities/FindingRow";
@@ -78,12 +78,12 @@ export default function VulnerabilitiesPage() {
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 className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-text-primary">Vulnerabilities</h1>
<h1 className="text-2xl font-bold text-text-primary">Vulnerabilities</h1>
<p className="mt-1 text-sm text-text-secondary">
Installed packages matched against distribution security advisories.
{total} open finding{total !== 1 ? "s" : ""} · installed packages matched against distribution security advisories
</p>
</div>
{isAdmin && (
@@ -93,32 +93,36 @@ export default function VulnerabilitiesPage() {
)}
</div>
<DBFreshness summary={summary.data} />
<div className="mb-4">
<DBFreshness summary={summary.data} />
</div>
<div className="flex flex-wrap gap-4 rounded-lg border border-border bg-surface px-5 py-4">
{/* Severity counts double as the filter. They are always the whole
fleet's open counts, never the filtered view's, so switching
state cannot make the fleet look better than it is. */}
<div className="mb-4 flex flex-wrap gap-2 rounded-lg border border-border bg-surface px-4 py-3 sm:px-5">
{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"
aria-pressed={severity === s}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${
severity === s ? "border-accent bg-surface-2" : "border-transparent 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">
<div className="mb-4 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 ${
aria-pressed={state === s}
className={`rounded-lg 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"
}`}
>
@@ -127,33 +131,42 @@ export default function VulnerabilitiesPage() {
))}
</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>
{groups.error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(groups.error as Error).message}
</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>
<Card padding={false}>
{groups.isLoading ? (
<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}
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 className="px-6 py-14 text-center">
<p className="text-[15px] font-semibold text-text-primary">
No {state} findings{severity ? ` at ${severity} severity` : ""}.
</p>
<p className="mx-auto mt-2 max-w-[52ch] text-sm text-text-secondary">
Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported
on its own page rather than counted as clean here.
</p>
</div>
)}
</Card>
{accepting && (
<AcceptDialog
+12 -6
View File
@@ -32,26 +32,32 @@ export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, on
const anyFix = group.findings.some((f) => f.fixed_in);
return (
<div className="rounded-lg border border-border bg-surface">
// 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 text-left hover:bg-surface-2"
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 text-xs text-text-secondary">
<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="whitespace-nowrap text-xs text-text-tertiary">no fix published</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">
<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">
<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>