242 lines
7.8 KiB
TypeScript
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";
|
|
}
|
|
}
|