store only status-change events; UI client-side polling refactor

This commit is contained in:
Stanislav Rossovskii
2026-05-27 14:58:46 +04:00
parent 71f0035b0b
commit d5f312f200
48 changed files with 2247 additions and 753 deletions

View File

@@ -0,0 +1,212 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
import { Td, Th } from "@/components/DataPanel";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import {
groupChecks,
statusSeverity,
withLivenessChecks,
type Agent,
type CheckGroup,
} from "@/lib/check-groups";
import { statusBadgeClass } from "@/lib/format";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
type TabKey = "agents" | "events";
export function CheckDetailBrowser({
checkId,
initialData,
initialEvents,
tab,
}: {
checkId: string;
initialData: InventoryData;
initialEvents: EventsPageData;
tab: TabKey;
}) {
const { data } = usePolling(initialData, "/api/poll/inventory");
const agentsById = useMemo(() => new Map(data.agents.map((agent) => [agent.agent_id, agent])), [data.agents]);
const checks = useMemo(
() => withLivenessChecks(data.agents, data.checks),
[data.agents, data.checks],
);
const group = useMemo(
() => groupChecks(checks, agentsById).find((item) => item.checkId === checkId),
[agentsById, checkId, checks],
);
if (!group) {
return (
<div>
<Link href="/checks" className="text-sm text-blue-300 hover:text-blue-200">
checks
</Link>
<h1 className="mt-2 text-lg font-semibold">{checkId}</h1>
<div className="mt-4 text-sm text-neutral-500">Check not found.</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<Link href="/checks" className="text-sm text-blue-300 hover:text-blue-200">
checks
</Link>
<h1 className="mt-2 text-lg font-semibold">{checkId}</h1>
</div>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-neutral-400">status</dt>
<dd>
<CheckStatusSummary counts={group.counts} />
</dd>
<dt className="text-neutral-400">agents</dt>
<dd>{group.checks.length}</dd>
<dt className="text-neutral-400">last_observed_at</dt>
<dd>
<DateTime iso={group.lastObservedAt} />
</dd>
</dl>
<TabNav checkId={checkId} current={tab} />
{tab === "agents" ? (
<AgentsForCheck group={group} />
) : (
<EventsForCheck checkId={checkId} initialData={initialEvents} agentsById={agentsById} />
)}
</div>
);
}
function TabNav({ checkId, current }: { checkId: string; current: TabKey }) {
const base = `/checks/${encodeURIComponent(checkId)}`;
return (
<div className="flex gap-4 border-b border-neutral-800 text-sm">
<Link
href={`${base}?tab=agents`}
className={`pb-2 ${current === "agents" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Agents
</Link>
<Link
href={`${base}?tab=events`}
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Events
</Link>
</div>
);
}
function AgentsForCheck({ group }: { group: CheckGroup }) {
const rows = [...group.checks].sort(
(a, b) =>
statusSeverity[b.check.status] - statusSeverity[a.check.status] ||
hostName(a).localeCompare(hostName(b)),
);
return (
<table className="w-full text-sm">
<thead>
<tr>
<Th>HOST</Th>
<Th>STATUS</Th>
<Th>LAST_OBSERVED_AT</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{rows.map(({ check, agent }) => (
<tr key={check.agent_id}>
<Td>
<Link
href={`/agents/${encodeURIComponent(check.agent_id)}`}
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
>
<span>{agent?.hostname || check.agent_id}</span>
{agent && agent.hostname !== check.agent_id ? (
<span className="text-xs text-neutral-500">{check.agent_id}</span>
) : null}
</Link>
</Td>
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
<Td>
<DateTime iso={check.last_observed_at} />
</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{check.summary ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
);
}
function EventsForCheck({
checkId,
initialData,
agentsById,
}: {
checkId: string;
initialData: EventsPageData;
agentsById: Map<string, Agent>;
}) {
const { data } = usePolling(
initialData,
`/api/poll/events?check_id=${encodeURIComponent(checkId)}&limit=50`,
);
if (data.items.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
return (
<table className="w-full text-sm">
<thead>
<tr>
<Th>OBSERVED_AT</Th>
<Th>RECEIVED_AT</Th>
<Th>HOST</Th>
<Th>STATUS</Th>
<Th>DURATION_MS</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{data.items.map((event) => {
const agent = agentsById.get(event.agent_id);
return (
<tr key={event.event_id}>
<Td>
<DateTime iso={event.observed_at} />
</Td>
<Td>
<DateTime iso={event.received_at} />
</Td>
<Td>
<Link
href={`/agents/${encodeURIComponent(event.agent_id)}`}
className="text-blue-300 hover:text-blue-200"
>
{agent?.hostname || event.agent_id}
</Link>
</Td>
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
<Td>{event.duration_ms}</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{event.output ?? "—"}</Td>
</tr>
);
})}
</tbody>
</table>
);
}
function hostName(row: { agent?: Agent; check: { agent_id: string } }): string {
return row.agent?.hostname || row.check.agent_id;
}