Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c15b25ecd | ||
|
|
0c21765da3 |
@@ -33,7 +33,7 @@ func Collect() (OSRelease, []Package, error) {
|
||||
switch {
|
||||
case have("dpkg-query"):
|
||||
out, err := run(ctx, "dpkg-query", "-W", "-f",
|
||||
`${Package}\t${Version}\t${Architecture}\t${source:Package}\n`)
|
||||
`${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n`)
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
|
||||
@@ -20,12 +20,20 @@ type Package struct {
|
||||
}
|
||||
|
||||
// ParseDpkg reads tab-separated output of
|
||||
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n'
|
||||
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n'
|
||||
//
|
||||
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
|
||||
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
|
||||
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
|
||||
// alone finds one of the three.
|
||||
//
|
||||
// The fifth column is why "rc" packages do not appear. dpkg-query -W lists
|
||||
// every package dpkg knows about, including ones removed with their config
|
||||
// files left behind — a host that has upgraded its kernel a dozen times reports
|
||||
// a dozen old linux-modules versions that are not on disk, and the oldest of
|
||||
// them sorts first and reads as the installed version. Only "installed" is
|
||||
// installed. An empty status means dpkg did not understand the field, in which
|
||||
// case the line is kept rather than the whole inventory silently vanishing.
|
||||
func ParseDpkg(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
@@ -36,6 +44,11 @@ func ParseDpkg(out string) []Package {
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
if len(f) > 4 {
|
||||
if s := strings.TrimSpace(f[4]); s != "" && s != "installed" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
|
||||
if len(f) > 3 && f[3] != "" {
|
||||
p.SourceName = f[3]
|
||||
|
||||
@@ -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, Card } from "@/components/ui";
|
||||
import { Button, Card, Pagination, usePagination } from "@/components/ui";
|
||||
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
|
||||
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
|
||||
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
|
||||
@@ -48,6 +48,10 @@ export default function VulnerabilitiesPage() {
|
||||
|
||||
const packages = useMemo(() => groupByPackage(groups.data ?? []), [groups.data]);
|
||||
|
||||
// Each package row carries its own findings and servers underneath it, so
|
||||
// the cost of a full fleet's board is well past the row count alone.
|
||||
const paged = usePagination(packages, 25);
|
||||
|
||||
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
|
||||
@@ -111,7 +115,10 @@ export default function VulnerabilitiesPage() {
|
||||
{SEVERITY_ORDER.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSeverity(severity === s ? "" : s)}
|
||||
onClick={() => {
|
||||
setSeverity(severity === s ? "" : s);
|
||||
paged.reset();
|
||||
}}
|
||||
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"
|
||||
@@ -127,7 +134,10 @@ export default function VulnerabilitiesPage() {
|
||||
{STATES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setState(s)}
|
||||
onClick={() => {
|
||||
setState(s);
|
||||
paged.reset();
|
||||
}}
|
||||
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"
|
||||
@@ -150,18 +160,29 @@ export default function VulnerabilitiesPage() {
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : packages.length > 0 ? (
|
||||
packages.map((g) => (
|
||||
<PackageRow
|
||||
key={g.package_name}
|
||||
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}
|
||||
<>
|
||||
{paged.slice.map((g) => (
|
||||
<PackageRow
|
||||
key={g.package_name}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
<Pagination
|
||||
page={paged.page}
|
||||
pageCount={paged.pageCount}
|
||||
size={paged.size}
|
||||
total={paged.total}
|
||||
onPage={paged.setPage}
|
||||
onSize={paged.setSize}
|
||||
unit="packages"
|
||||
/>
|
||||
))
|
||||
</>
|
||||
) : (
|
||||
<div className="px-6 py-14 text-center">
|
||||
<p className="text-[15px] font-semibold text-text-primary">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { Badge, Button, Card, Pagination, Table, Thead, Tbody, Tr, Th, Td, usePagination } from "@/components/ui";
|
||||
import { api, workloads } from "@/lib/api";
|
||||
|
||||
/*
|
||||
@@ -29,6 +29,11 @@ export default function WorkloadsPage() {
|
||||
return m;
|
||||
}, [servers.data]);
|
||||
|
||||
// A fleet of a few hundred servers reports tens of thousands of workloads;
|
||||
// the whole set in one table is what freezes the tab.
|
||||
const rows = useMemo(() => hits.data ?? [], [hits.data]);
|
||||
const paged = usePagination(rows, 50);
|
||||
|
||||
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";
|
||||
|
||||
@@ -45,6 +50,7 @@ export default function WorkloadsPage() {
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setApplied({ image: image.trim(), stack: stack.trim(), state: state.trim() });
|
||||
paged.reset();
|
||||
}}
|
||||
>
|
||||
<input className={inputClass} placeholder="image (exact)" value={image} onChange={(e) => setImage(e.target.value)} />
|
||||
@@ -59,9 +65,10 @@ export default function WorkloadsPage() {
|
||||
<Card padding={false}>
|
||||
{hits.isLoading ? (
|
||||
<p className="px-6 py-5 text-sm text-text-secondary">Loading…</p>
|
||||
) : (hits.data ?? []).length === 0 ? (
|
||||
) : rows.length === 0 ? (
|
||||
<p className="px-6 py-5 text-sm text-text-secondary">No workloads match.</p>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
@@ -74,7 +81,7 @@ export default function WorkloadsPage() {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{(hits.data ?? []).map((h) => (
|
||||
{paged.slice.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">
|
||||
@@ -92,6 +99,16 @@ export default function WorkloadsPage() {
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
<Pagination
|
||||
page={paged.page}
|
||||
pageCount={paged.pageCount}
|
||||
size={paged.size}
|
||||
total={paged.total}
|
||||
onPage={paged.setPage}
|
||||
onSize={paged.setSize}
|
||||
unit="workloads"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
/*
|
||||
* Client-side pagination.
|
||||
*
|
||||
* The fleet endpoints answer with the whole result set, and a few thousand rows
|
||||
* rendered at once is what locks the tab up. Slicing in the browser is enough:
|
||||
* the payload was never the problem, the DOM node count was. If a result set
|
||||
* ever outgrows the response itself, this is the seam a server-side cursor
|
||||
* would replace.
|
||||
*/
|
||||
|
||||
export const PAGE_SIZES = [25, 50, 100, 200];
|
||||
|
||||
export function usePagination<T>(items: T[], initialSize = 50) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [size, setSize] = useState(initialSize);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(items.length / size));
|
||||
|
||||
// A filter change shortens the list under a page that no longer exists;
|
||||
// clamping here rather than in every caller keeps the empty state honest.
|
||||
useEffect(() => {
|
||||
if (page > pageCount) setPage(1);
|
||||
}, [page, pageCount]);
|
||||
|
||||
const slice = useMemo(() => {
|
||||
const start = (page - 1) * size;
|
||||
return items.slice(start, start + size);
|
||||
}, [items, page, size]);
|
||||
|
||||
return {
|
||||
slice,
|
||||
page,
|
||||
size,
|
||||
pageCount,
|
||||
total: items.length,
|
||||
setPage,
|
||||
setSize: (n: number) => {
|
||||
setSize(n);
|
||||
setPage(1);
|
||||
},
|
||||
reset: () => setPage(1),
|
||||
};
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
pageCount,
|
||||
size,
|
||||
total,
|
||||
onPage,
|
||||
onSize,
|
||||
unit = "rows",
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
size: number;
|
||||
total: number;
|
||||
onPage: (n: number) => void;
|
||||
onSize: (n: number) => void;
|
||||
unit?: string;
|
||||
}) {
|
||||
if (total === 0) return null;
|
||||
|
||||
const first = (page - 1) * size + 1;
|
||||
const last = Math.min(page * size, total);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-t border-border px-4 py-3 text-sm text-text-secondary sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<span className="tabular-nums">
|
||||
{first}–{last} of {total} {unit}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
aria-label="Rows per page"
|
||||
value={size}
|
||||
onChange={(e) => onSize(Number(e.target.value))}
|
||||
className="rounded border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} / page
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => onPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="rounded border border-border px-2.5 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-40 disabled:hover:text-text-secondary"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{page} / {pageCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPage(page + 1)}
|
||||
disabled={page >= pageCount}
|
||||
className="rounded border border-border px-2.5 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-40 disabled:hover:text-text-secondary"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export { Badge } from "./Badge";
|
||||
export { Card, CardHeader, CardTitle } from "./Card";
|
||||
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
|
||||
export { Modal } from "./Modal";
|
||||
export { Pagination, usePagination, PAGE_SIZES } from "./Pagination";
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user