Compare commits

...
Author SHA1 Message Date
mrhid6 0c15b25ecd fix: Fixed agent package version
Server Deploy / deploy (push) Successful in 14s
Chart Release / chart (push) Successful in 26s
Agent Release / build (push) Successful in 11m32s
Agent Release / msi (push) Successful in 1m9s
2026-08-07 10:21:00 +01:00
mrhid6 0c21765da3 feat: Added pagination
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m39s
2026-08-07 09:59:50 +01:00
7 changed files with 183 additions and 20 deletions
+1 -1
View File
@@ -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
}
+14 -1
View File
@@ -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]
+35 -14
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, 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">
+20 -3
View File
@@ -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>
+111
View File
@@ -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>
);
}
+1
View File
@@ -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