Files
monlet/web/src/components/AgentDetailBrowser.tsx
2026-05-27 14:58:46 +04:00

217 lines
6.3 KiB
TypeScript

"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);
});
}