217 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
}
|