store only status-change events; UI client-side polling refactor
This commit is contained in:
216
web/src/components/AgentDetailBrowser.tsx
Normal file
216
web/src/components/AgentDetailBrowser.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { Td, Th } from "@/components/DataPanel";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { withLivenessChecks } from "@/lib/check-groups";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
type CheckState = components["schemas"]["CheckState"];
|
||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
type SortKey = "severity" | "check_id" | "last_seen";
|
||||
type TabKey = "checks" | "events";
|
||||
type AgentDetailData = {
|
||||
agent: Agent;
|
||||
checks: CheckState[];
|
||||
events: StoredEvent[];
|
||||
};
|
||||
|
||||
const severityRank: Record<CheckState["status"], number> = {
|
||||
critical: 0,
|
||||
warning: 1,
|
||||
unknown: 2,
|
||||
ok: 3,
|
||||
};
|
||||
|
||||
export function AgentDetailBrowser({
|
||||
initialData,
|
||||
sort,
|
||||
tab,
|
||||
}: {
|
||||
initialData: AgentDetailData;
|
||||
sort: SortKey;
|
||||
tab: TabKey;
|
||||
}) {
|
||||
const { data } = usePolling(
|
||||
initialData,
|
||||
`/api/poll/agent?agent_id=${encodeURIComponent(initialData.agent.agent_id)}`,
|
||||
);
|
||||
const { agent, events } = data;
|
||||
const checks = useMemo(
|
||||
() => sortChecks(withLivenessChecks([agent], data.checks), sort),
|
||||
[agent, data.checks, sort],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link href="/agents" className="text-sm text-blue-300 hover:text-blue-200">
|
||||
← agents
|
||||
</Link>
|
||||
<h1 className="mt-2 text-lg font-semibold">{agent.agent_id}</h1>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<dt className="text-neutral-400">hostname</dt>
|
||||
<dd>{agent.hostname}</dd>
|
||||
<dt className="text-neutral-400">status</dt>
|
||||
<dd className={statusBadgeClass(agent.status)}>{agent.status}</dd>
|
||||
<dt className="text-neutral-400">last_seen_at</dt>
|
||||
<dd>
|
||||
<DateTime iso={agent.last_seen_at} />
|
||||
</dd>
|
||||
<dt className="text-neutral-400">features</dt>
|
||||
<dd>
|
||||
<AgentFeatures features={agent.features} />
|
||||
</dd>
|
||||
<dt className="text-neutral-400">labels</dt>
|
||||
<dd>
|
||||
<LabelChips labels={agent.labels} />
|
||||
</dd>
|
||||
</dl>
|
||||
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
|
||||
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) {
|
||||
const base = `/agents/${encodeURIComponent(agentId)}`;
|
||||
return (
|
||||
<div className="flex gap-4 border-b border-neutral-800 text-sm">
|
||||
<Link
|
||||
href={`${base}?tab=checks&sort=${sort}`}
|
||||
className={`pb-2 ${current === "checks" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
||||
>
|
||||
Checks
|
||||
</Link>
|
||||
<Link
|
||||
href={`${base}?tab=events&sort=${sort}`}
|
||||
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksTable({
|
||||
agentId,
|
||||
checks,
|
||||
sort,
|
||||
}: {
|
||||
agentId: string;
|
||||
checks: CheckState[];
|
||||
sort: SortKey;
|
||||
}) {
|
||||
if (checks.length === 0) return <div className="text-sm text-neutral-500">No checks.</div>;
|
||||
return (
|
||||
<section>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortLink agentId={agentId} sort="check_id" current={sort}>
|
||||
check_id
|
||||
</SortLink>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortLink agentId={agentId} sort="severity" current={sort}>
|
||||
status
|
||||
</SortLink>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortLink agentId={agentId} sort="last_seen" current={sort}>
|
||||
last_observed_at
|
||||
</SortLink>
|
||||
</Th>
|
||||
<Th>summary</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c) => (
|
||||
<tr key={c.check_id}>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
|
||||
<Td>
|
||||
<DateTime iso={c.last_observed_at} />
|
||||
</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{c.summary ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsTable({ events }: { events: StoredEvent[] }) {
|
||||
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
|
||||
return (
|
||||
<section>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>observed_at</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>duration_ms</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>
|
||||
<DateTime iso={e.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SortLink({
|
||||
agentId,
|
||||
sort,
|
||||
current,
|
||||
children,
|
||||
}: {
|
||||
agentId: string;
|
||||
sort: SortKey;
|
||||
current: SortKey;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`}
|
||||
className={sort === current ? "text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] {
|
||||
return [...checks].sort((a, b) => {
|
||||
if (sort === "check_id") return a.check_id.localeCompare(b.check_id);
|
||||
if (sort === "last_seen") return b.last_observed_at.localeCompare(a.last_observed_at);
|
||||
return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user