refactor web pagination and add partitioned events, flap/timeout showcase agents

This commit is contained in:
Stanislav Rossovskii
2026-05-27 18:23:31 +04:00
parent d5f312f200
commit a2e88b4e76
46 changed files with 1600 additions and 742 deletions

View File

@@ -1,13 +1,24 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures";
import { AgentLink } from "@/components/AgentLink";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
import { LabelChips } from "@/components/Labels";
import { ListToolbar } from "@/components/ListToolbar";
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
@@ -16,7 +27,6 @@ type CheckState = components["schemas"]["CheckState"];
type CheckStatus = CheckState["status"];
type CheckCounts = Record<CheckStatus, number>;
type SortField = "host" | "status" | "last_seen_at";
type SortDir = "asc" | "desc";
export type AgentRow = { agent: Agent; checks: CheckCounts };
@@ -36,77 +46,75 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
[dir, initialRows, query, sort],
);
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const pageCount = pageCountOf(rows.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(rows, currentPage, pageSize);
const onQueryChange = (value: string) => {
setQuery(value);
setPage(1);
};
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const chooseSort = (field: SortField) => {
if (field === sort) {
setDir((current) => (current === "asc" ? "desc" : "asc"));
return;
} else {
setSort(field);
setDir(fieldDefaultDir(field));
}
setSort(field);
setDir(fieldDefaultDir(field));
setPage(1);
};
return (
<>
<div className="mb-4 flex items-baseline gap-3">
<h1 className="text-lg font-semibold">Agents</h1>
<span className="text-sm text-neutral-400">{rows.length} items</span>
</div>
<div className="mb-4 flex max-w-md gap-2">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="search agent"
className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
>
clear
</button>
) : null}
<span className="text-sm text-neutral-400">
{rows.length} of {initialRows.length} items
</span>
</div>
<ListToolbar
query={query}
onQueryChange={onQueryChange}
placeholder="search agent"
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
/>
{rows.length === 0 ? (
<div className="text-sm text-neutral-500 italic">No data.</div>
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
HOST
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
LAST_SEEN_AT
</SortButton>
</SortHeaderButton>
</Th>
<Th>features</Th>
<Th>labels</Th>
</tr>
</thead>
<tbody>
{rows.map(({ agent: a, checks }) => (
{visible.map(({ agent: a, checks }) => (
<tr key={a.agent_id}>
<Td>
<Link
href={`/agents/${encodeURIComponent(a.agent_id)}`}
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
>
<span>{a.hostname}</span>
{a.agent_id !== a.hostname ? (
<span className="text-xs text-neutral-500">{a.agent_id}</span>
) : null}
</Link>
<AgentLink agent={a} agentId={a.agent_id} />
</Td>
<Td>
<AgentStatus status={a.status} checks={checks} />
@@ -125,50 +133,11 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
</tbody>
</table>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</>
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
<th className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-400 border-b border-neutral-800 px-2 py-2">
{children}
</th>
);
}
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<td className={`px-2 py-1.5 border-b border-neutral-900 align-top ${className}`}>{children}</td>
);
}
function SortButton({
field,
current,
dir,
onClick,
children,
}: {
field: SortField;
current: SortField;
dir: SortDir;
onClick: (field: SortField) => void;
children: React.ReactNode;
}) {
const active = field === current;
return (
<button
type="button"
onClick={() => onClick(field)}
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
>
<span>{children}</span>
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
</button>
);
}
function filterAgents(rows: AgentRow[], query: string): AgentRow[] {
const needle = query.trim().toLowerCase();
if (!needle) return rows;