Files
monlet/web/src/components/AgentsBrowser.tsx

242 lines
7.8 KiB
TypeScript

"use client";
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";
type Agent = components["schemas"]["Agent"];
type CheckState = components["schemas"]["CheckState"];
type CheckStatus = CheckState["status"];
type CheckCounts = Record<CheckStatus, number>;
type SortField = "host" | "status" | "last_seen_at";
export type AgentRow = { agent: Agent; checks: CheckCounts };
const checkStatuses: CheckStatus[] = ["ok", "warning", "critical", "unknown"];
const statusRank: Record<Agent["status"], number> = { alive: 0, stale: 1, dead: 2 };
const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"];
export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
const { data } = usePolling(initialData, "/api/poll/inventory");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortField>("status");
const [dir, setDir] = useState<SortDir>("desc");
const initialRows = useMemo(() => rowsFromInventory(data), [data]);
const rows = useMemo(
() => sortAgents(filterAgents(initialRows, query), sort, dir),
[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"));
} else {
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} of {initialRows.length} items
</span>
</div>
<ListToolbar
query={query}
onQueryChange={onQueryChange}
placeholder="search agent"
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
/>
{rows.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
HOST
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
LAST_SEEN_AT
</SortHeaderButton>
</Th>
<Th>features</Th>
<Th>labels</Th>
</tr>
</thead>
<tbody>
{visible.map(({ agent: a, checks }) => (
<tr key={a.agent_id}>
<Td>
<AgentLink agent={a} agentId={a.agent_id} />
</Td>
<Td>
<AgentStatus status={a.status} checks={checks} />
</Td>
<Td>
<DateTime iso={a.last_seen_at} />
</Td>
<Td>
<AgentFeatures features={a.features} />
</Td>
<Td>
<LabelChips labels={a.labels} limit={4} />
</Td>
</tr>
))}
</tbody>
</table>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</>
);
}
function filterAgents(rows: AgentRow[], query: string): AgentRow[] {
const needle = query.trim().toLowerCase();
if (!needle) return rows;
return rows.filter(({ agent }) =>
`${agent.hostname} ${agent.agent_id}`.toLowerCase().includes(needle),
);
}
function fieldDefaultDir(field: SortField): SortDir {
return field === "host" ? "asc" : "desc";
}
function sortAgents(rows: AgentRow[], sort: SortField, dir: SortDir): AgentRow[] {
return [...rows].sort((a, b) => {
const primary = compareByField(a, b, sort);
if (primary !== 0) return dir === "asc" ? primary : -primary;
return hostName(a.agent).localeCompare(hostName(b.agent)) || a.agent.agent_id.localeCompare(b.agent.agent_id);
});
}
function compareByField(a: AgentRow, b: AgentRow, sort: SortField): number {
switch (sort) {
case "host":
return hostName(a.agent).localeCompare(hostName(b.agent)) || a.agent.agent_id.localeCompare(b.agent.agent_id);
case "last_seen_at":
return Date.parse(a.agent.last_seen_at) - Date.parse(b.agent.last_seen_at);
case "status":
return compareStatusImportance(a, b);
}
}
function compareStatusImportance(a: AgentRow, b: AgentRow): number {
const agentStatus = statusRank[a.agent.status] - statusRank[b.agent.status];
if (agentStatus !== 0) return agentStatus;
for (const status of checkSortStatuses) {
const checkStatus = a.checks[status] - b.checks[status];
if (checkStatus !== 0) return checkStatus;
}
return 0;
}
function hostName(agent: Agent): string {
return agent.hostname || agent.agent_id;
}
function AgentStatus({
status,
checks,
}: {
status: "alive" | "stale" | "dead";
checks?: CheckCounts;
}) {
const counts = checks ?? emptyCheckCounts();
const total = checkStatuses.reduce((sum, s) => sum + counts[s], 0);
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap">
<span className={statusBadgeClass(status)}>{status}</span>
<span className="text-neutral-600">checks {total}</span>
<span className="inline-flex items-center gap-1.5 rounded border border-neutral-800 bg-neutral-900 px-2 py-0.5 text-xs">
{checkStatuses.map((s, i) => (
<span key={s} className="inline-flex items-center gap-1.5">
{i > 0 ? <span className="text-neutral-700">/</span> : null}
<span className={statusBadgeClass(s)}>
{shortStatus(s)} {counts[s]}
</span>
</span>
))}
</span>
</div>
);
}
function emptyCheckCounts(): CheckCounts {
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
}
function rowsFromInventory(data: InventoryData): AgentRow[] {
const checksByAgent = new Map<string, CheckCounts>();
for (const c of data.checks) {
const counts = checksByAgent.get(c.agent_id) ?? emptyCheckCounts();
counts[c.status] += 1;
checksByAgent.set(c.agent_id, counts);
}
return data.agents.map((agent) => ({
agent,
checks: checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(),
}));
}
function shortStatus(status: CheckStatus): string {
switch (status) {
case "ok":
return "ok";
case "warning":
return "warn";
case "critical":
return "crit";
case "unknown":
return "unknown";
}
}