store only status-change events; UI client-side polling refactor
This commit is contained in:
@@ -1,55 +1,14 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { ErrorPanel, Td, Th } from "@/components/DataPanel";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { ApiError, type Agent, type CheckState, type StoredEvent, api } from "@/lib/api";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { withLivenessEvents } from "@/lib/check-groups";
|
||||
import { loadChecks } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SortKey = "severity" | "check_id" | "last_seen";
|
||||
type TabKey = "checks" | "events";
|
||||
|
||||
type LoadResult =
|
||||
| { kind: "ok"; agent: Agent; checks: CheckState[]; events: StoredEvent[] }
|
||||
| { kind: "not_found" }
|
||||
| { kind: "error"; error: unknown };
|
||||
|
||||
const severityRank: Record<CheckState["status"], number> = {
|
||||
critical: 0,
|
||||
warning: 1,
|
||||
unknown: 2,
|
||||
ok: 3,
|
||||
};
|
||||
|
||||
async function load(agentId: string, sort: SortKey, tab: TabKey): Promise<LoadResult> {
|
||||
try {
|
||||
const [agent, checks, events] = await Promise.all([
|
||||
api.getAgent(agentId),
|
||||
tab === "checks" ? loadChecks(agentId).then((items) => sortChecks(items, sort)) : [],
|
||||
tab === "events" ? api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items) : [],
|
||||
]);
|
||||
return { kind: "ok", agent, checks, events };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return { kind: "not_found" };
|
||||
return { kind: "error", error: e };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChecks(agentId: string): Promise<CheckState[]> {
|
||||
const checks: CheckState[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listChecks({ agent_id: agentId, cursor, limit: 500 });
|
||||
checks.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return checks;
|
||||
}
|
||||
|
||||
function normalizeSort(sort?: string): SortKey {
|
||||
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
|
||||
}
|
||||
@@ -58,14 +17,6 @@ function normalizeTab(tab?: string): TabKey {
|
||||
return tab === "events" ? "events" : "checks";
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AgentDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -77,163 +28,27 @@ export default async function AgentDetailPage({
|
||||
const sp = await searchParams;
|
||||
const sort = normalizeSort(sp.sort);
|
||||
const tab = normalizeTab(sp.tab);
|
||||
const r = await load(agent_id, sort, tab);
|
||||
if (r.kind === "not_found") return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
||||
if (r.kind === "error") return <ErrorPanel error={r.error} />;
|
||||
const { agent, checks, events } = r;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link href="/agents" className="text-blue-300 hover:text-blue-200 text-sm">
|
||||
← 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)}`;
|
||||
let data;
|
||||
try {
|
||||
const [agent, checks, events] = await Promise.all([
|
||||
api.getAgent(agent_id),
|
||||
loadChecks(agent_id),
|
||||
api.queryEvents({ agent_id, limit: 50 }).then((page) => page.items),
|
||||
]);
|
||||
data = { agent, checks, events: withLivenessEvents([agent], events, { agentId: agent_id }) };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
||||
}
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
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>exit</Th>
|
||||
<Th>
|
||||
<SortLink agentId={agentId} sort="last_seen" current={sort}>
|
||||
last_observed_at
|
||||
</SortLink>
|
||||
</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>{c.exit_code ?? "—"}</Td>
|
||||
<Td>
|
||||
<DateTime iso={c.last_observed_at} />
|
||||
</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>
|
||||
<AgentDetailBrowser
|
||||
key={`${agent_id}:${tab}:${sort}`}
|
||||
initialData={data}
|
||||
sort={sort}
|
||||
tab={tab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,51 +2,10 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { AgentsBrowser } from "@/components/AgentsBrowser";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { api, type Agent, type CheckState } from "@/lib/api";
|
||||
import { loadInventory } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type CheckStatus = CheckState["status"];
|
||||
type CheckCounts = Record<CheckStatus, number>;
|
||||
|
||||
async function load() {
|
||||
const [agents, checksByAgent] = await Promise.all([
|
||||
loadAgents(),
|
||||
loadCheckCounts(),
|
||||
]);
|
||||
return { agents, checksByAgent };
|
||||
}
|
||||
|
||||
async function loadAgents(): Promise<Agent[]> {
|
||||
const agents: Agent[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listAgents({ cursor, limit: 500 });
|
||||
agents.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return agents;
|
||||
}
|
||||
|
||||
async function loadCheckCounts(): Promise<Map<string, CheckCounts>> {
|
||||
const byAgent = new Map<string, CheckCounts>();
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listChecks({ cursor, limit: 500 });
|
||||
for (const c of page.items) {
|
||||
const counts = byAgent.get(c.agent_id) ?? emptyCheckCounts();
|
||||
counts[c.status] += 1;
|
||||
byAgent.set(c.agent_id, counts);
|
||||
}
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return byAgent;
|
||||
}
|
||||
|
||||
function emptyCheckCounts(): CheckCounts {
|
||||
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
export default async function AgentsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -55,17 +14,11 @@ export default async function AgentsPage({
|
||||
const sp = await searchParams;
|
||||
if (Object.keys(sp).length > 0) redirect("/agents");
|
||||
|
||||
let data: Awaited<ReturnType<typeof load>>;
|
||||
let data;
|
||||
try {
|
||||
data = await load();
|
||||
data = await loadInventory();
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
|
||||
const rows = data.agents.map((agent) => ({
|
||||
agent,
|
||||
checks: data.checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(),
|
||||
}));
|
||||
|
||||
return <AgentsBrowser rows={rows} />;
|
||||
return <AgentsBrowser initialData={data} />;
|
||||
}
|
||||
|
||||
41
web/src/app/api/poll/agent/route.ts
Normal file
41
web/src/app/api/poll/agent/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { withLivenessEvents } from "@/lib/check-groups";
|
||||
import { loadChecks } from "@/lib/loaders";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const agentId = url.searchParams.get("agent_id");
|
||||
|
||||
if (!agentId) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: "validation", message: "agent_id is required" } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [agent, checks, events] = await Promise.all([
|
||||
api.getAgent(agentId),
|
||||
loadChecks(agentId),
|
||||
api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
agent,
|
||||
checks,
|
||||
events: withLivenessEvents([agent], events, { agentId }),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: e.code, message: e.message } },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return jsonError(e);
|
||||
}
|
||||
}
|
||||
23
web/src/app/api/poll/events/route.ts
Normal file
23
web/src/app/api/poll/events/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { loadEventsWithLiveness } from "@/lib/loaders";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get("limit") ?? "100");
|
||||
|
||||
try {
|
||||
const data = await loadEventsWithLiveness({
|
||||
agentId: url.searchParams.get("agent_id") ?? undefined,
|
||||
checkId: url.searchParams.get("check_id") ?? undefined,
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
limit: Number.isFinite(limit) ? limit : 100,
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
}
|
||||
19
web/src/app/api/poll/incidents/route.ts
Normal file
19
web/src/app/api/poll/incidents/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { loadIncidents } from "@/lib/loaders";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const stateParam = url.searchParams.get("state");
|
||||
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
|
||||
|
||||
try {
|
||||
const data = await loadIncidents(state);
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
}
|
||||
14
web/src/app/api/poll/inventory/route.ts
Normal file
14
web/src/app/api/poll/inventory/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { loadInventory } from "@/lib/loaders";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(await loadInventory());
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
}
|
||||
19
web/src/app/api/poll/outbox/route.ts
Normal file
19
web/src/app/api/poll/outbox/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
try {
|
||||
const data = await api.listOutbox({
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
}
|
||||
14
web/src/app/api/poll/overview/route.ts
Normal file
14
web/src/app/api/poll/overview/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { loadOverview } from "@/lib/loaders";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(await loadOverview());
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
}
|
||||
51
web/src/app/checks/[check_id]/page.tsx
Normal file
51
web/src/app/checks/[check_id]/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CheckDetailBrowser } from "@/components/CheckDetailBrowser";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { api } from "@/lib/api";
|
||||
import { withLivenessEvents } from "@/lib/check-groups";
|
||||
import { loadInventory } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type TabKey = "agents" | "events";
|
||||
type EventsPage = Awaited<ReturnType<typeof api.queryEvents>>;
|
||||
|
||||
function normalizeTab(tab?: string): TabKey {
|
||||
return tab === "events" ? "events" : "agents";
|
||||
}
|
||||
|
||||
export default async function CheckDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ check_id: string }>;
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const { check_id } = await params;
|
||||
const sp = await searchParams;
|
||||
const tab = normalizeTab(sp.tab);
|
||||
|
||||
let data;
|
||||
let events: EventsPage = { items: [], next_cursor: null };
|
||||
try {
|
||||
data = await loadInventory();
|
||||
if (tab === "events") {
|
||||
const rawEvents = await api.queryEvents({ check_id, limit: 50 });
|
||||
events = {
|
||||
...rawEvents,
|
||||
items: withLivenessEvents(data.agents, rawEvents.items, { checkId: check_id }),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckDetailBrowser
|
||||
key={`${check_id}:${tab}`}
|
||||
checkId={check_id}
|
||||
initialData={data}
|
||||
initialEvents={events}
|
||||
tab={tab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,15 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { ChecksBrowser } from "@/components/ChecksBrowser";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { loadInventory } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChecksPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
export default async function ChecksPage() {
|
||||
let data;
|
||||
try {
|
||||
data = await api.listChecks({ cursor: sp.cursor });
|
||||
data = await loadInventory();
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Checks" count={data.items.length} />
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>agent_id</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>exit</Th>
|
||||
<Th>last_observed_at</Th>
|
||||
<Th>incident_key</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((c) => (
|
||||
<tr key={`${c.agent_id}:${c.check_id}`}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(c.agent_id)}`}
|
||||
className="text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
{c.agent_id}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={c.agent_id} checkId={c.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
|
||||
<Td>{c.exit_code ?? "—"}</Td>
|
||||
<Td>
|
||||
<DateTime iso={c.last_observed_at} />
|
||||
</Td>
|
||||
<Td className="text-xs text-neutral-400">{c.incident_key ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager basePath="/checks" cursor={sp.cursor} nextCursor={data.next_cursor} />
|
||||
</>
|
||||
);
|
||||
return <ChecksBrowser initialData={data} />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { EventsBrowser } from "@/components/EventsBrowser";
|
||||
import { loadEventsWithLiveness } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -17,102 +12,21 @@ export default async function EventsPage({
|
||||
const sp = await searchParams;
|
||||
let data;
|
||||
try {
|
||||
data = await api.queryEvents({
|
||||
agent_id: sp.agent_id,
|
||||
check_id: sp.check_id,
|
||||
data = await loadEventsWithLiveness({
|
||||
agentId: sp.agent_id,
|
||||
checkId: sp.check_id,
|
||||
cursor: sp.cursor,
|
||||
});
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Events" count={data.items.length} />
|
||||
<ActiveFilters agentId={sp.agent_id} checkId={sp.check_id} />
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>observed_at</Th>
|
||||
<Th>received_at</Th>
|
||||
<Th>agent_id</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>exit</Th>
|
||||
<Th>duration_ms</Th>
|
||||
<Th>output</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>
|
||||
<DateTime iso={e.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={e.received_at} />
|
||||
</Td>
|
||||
<Td>{e.agent_id}</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.exit_code}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
<Td className="text-xs text-neutral-400 max-w-md truncate">{e.output ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager
|
||||
basePath="/events"
|
||||
cursor={sp.cursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ agent_id: sp.agent_id, check_id: sp.check_id }}
|
||||
/>
|
||||
</>
|
||||
<EventsBrowser
|
||||
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
|
||||
initialData={data}
|
||||
cursor={sp.cursor}
|
||||
agentId={sp.agent_id}
|
||||
checkId={sp.check_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
|
||||
if (!agentId && !checkId) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
||||
{agentId ? (
|
||||
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
|
||||
) : null}
|
||||
{checkId ? (
|
||||
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
|
||||
) : null}
|
||||
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
|
||||
clear
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label, value, href }: { label: string; value: string; href: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex max-w-xs items-center gap-1 rounded border border-neutral-800 bg-neutral-900 px-2 py-1 text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
|
||||
title={`${label}=${value}`}
|
||||
>
|
||||
<span className="text-neutral-500">{label}=</span>
|
||||
<span className="truncate">{value}</span>
|
||||
<span className="text-neutral-600">x</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.agent_id) qs.set("agent_id", params.agent_id);
|
||||
if (params.check_id) qs.set("check_id", params.check_id);
|
||||
const query = qs.toString();
|
||||
return query ? `/events?${query}` : "/events";
|
||||
}
|
||||
|
||||
@@ -1,89 +1,29 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { IncidentsBrowser } from "@/components/IncidentsBrowser";
|
||||
import { loadIncidents, loadInventory } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATES = ["all", "open", "resolved"] as const;
|
||||
|
||||
export default async function IncidentsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string; state?: string }>;
|
||||
searchParams: Promise<{ state?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
||||
let data;
|
||||
let inventory;
|
||||
try {
|
||||
data = await api.listIncidents({ state, cursor: sp.cursor });
|
||||
[data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Incidents" count={data.items.length} />
|
||||
<div className="mb-3 flex gap-3 text-sm">
|
||||
{STATES.map((s) => {
|
||||
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
||||
const active = (s === "all" && !state) || s === state;
|
||||
return (
|
||||
<Link
|
||||
key={s}
|
||||
href={href}
|
||||
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>opened_at</Th>
|
||||
<Th>state</Th>
|
||||
<Th>severity</Th>
|
||||
<Th>agent_id</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>resolved_at</Th>
|
||||
<Th>summary</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<Td>
|
||||
<DateTime iso={i.opened_at} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(i.state)}>{i.state}</Td>
|
||||
<Td className={statusBadgeClass(i.severity)}>{i.severity}</Td>
|
||||
<Td>{i.agent_id ?? "—"}</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={i.agent_id} checkId={i.check_id} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={i.resolved_at} />
|
||||
</Td>
|
||||
<Td className="text-neutral-400">{i.summary ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager
|
||||
basePath="/incidents"
|
||||
cursor={sp.cursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ state }}
|
||||
/>
|
||||
</>
|
||||
<IncidentsBrowser
|
||||
key={state ?? ""}
|
||||
initialData={data}
|
||||
initialInventory={inventory}
|
||||
state={state}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { AutoRefresh } from "@/components/AutoRefresh";
|
||||
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
|
||||
import { getUiTimeZone } from "@/lib/timezone";
|
||||
import "./globals.css";
|
||||
@@ -19,16 +18,6 @@ const NAV = [
|
||||
{ href: "/outbox", label: "Outbox" },
|
||||
];
|
||||
|
||||
const DEFAULT_REFRESH_MS = 10_000;
|
||||
|
||||
function getRefreshIntervalMs(): number {
|
||||
const raw = process.env.NEXT_PUBLIC_MONLET_REFRESH_MS?.trim();
|
||||
if (!raw) return DEFAULT_REFRESH_MS;
|
||||
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : DEFAULT_REFRESH_MS;
|
||||
}
|
||||
|
||||
function Brand() {
|
||||
return (
|
||||
<Link href="/" aria-label="Monlet overview" className="group flex shrink-0 items-center gap-2">
|
||||
@@ -47,7 +36,6 @@ function Brand() {
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const refreshIntervalMs = getRefreshIntervalMs();
|
||||
const configuredTimeZone = getUiTimeZone();
|
||||
|
||||
return (
|
||||
@@ -66,7 +54,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<TimeZoneCarousel />
|
||||
</header>
|
||||
<main className="flex-1 px-4 py-6">{children}</main>
|
||||
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
|
||||
</TimeZoneProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { OutboxBrowser } from "@/components/OutboxBrowser";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -18,44 +16,5 @@ export default async function OutboxPage({
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Notification outbox" count={data.items.length} />
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>updated_at</Th>
|
||||
<Th>notifier</Th>
|
||||
<Th>event</Th>
|
||||
<Th>state</Th>
|
||||
<Th>attempts</Th>
|
||||
<Th>next_attempt_at</Th>
|
||||
<Th>last_error</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<Td>
|
||||
<DateTime iso={o.updated_at} />
|
||||
</Td>
|
||||
<Td>{o.notifier}</Td>
|
||||
<Td>{o.event_type}</Td>
|
||||
<Td className={statusBadgeClass(o.state)}>{o.state}</Td>
|
||||
<Td>{o.attempts}</Td>
|
||||
<Td>
|
||||
<DateTime iso={o.next_attempt_at} />
|
||||
</Td>
|
||||
<Td className="text-xs text-red-300">{o.last_error ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager basePath="/outbox" cursor={sp.cursor} nextCursor={data.next_cursor} />
|
||||
</>
|
||||
);
|
||||
return <OutboxBrowser key={sp.cursor ?? ""} initialData={data} cursor={sp.cursor} />;
|
||||
}
|
||||
|
||||
@@ -1,81 +1,15 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { OverviewDashboard } from "@/components/OverviewDashboard";
|
||||
import { loadOverview } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function load() {
|
||||
const [agents, checks, incidents] = await Promise.all([
|
||||
api.listAgents({ limit: 500 }),
|
||||
api.listChecks({ limit: 500 }),
|
||||
api.listIncidents({ state: "open", limit: 500 }),
|
||||
]);
|
||||
const tally = (xs: { status?: string }[]) =>
|
||||
xs.reduce<Record<string, number>>((acc, x) => {
|
||||
const k = x.status ?? "unknown";
|
||||
acc[k] = (acc[k] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
agents: tally(agents.items),
|
||||
checks: tally(checks.items),
|
||||
openIncidents: incidents.items.length,
|
||||
totalAgents: agents.items.length,
|
||||
totalChecks: checks.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OverviewPage() {
|
||||
let data: Awaited<ReturnType<typeof load>>;
|
||||
let data;
|
||||
try {
|
||||
data = await load();
|
||||
data = await loadOverview();
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-lg font-semibold">Overview</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card title="Agents" total={data.totalAgents} href="/agents" tally={data.agents} />
|
||||
<Card title="Checks" total={data.totalChecks} href="/checks" tally={data.checks} />
|
||||
<Card
|
||||
title="Open incidents"
|
||||
total={data.openIncidents}
|
||||
href="/incidents"
|
||||
tally={{}}
|
||||
highlight={data.openIncidents > 0 ? "open" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
total,
|
||||
href,
|
||||
tally,
|
||||
highlight,
|
||||
}: {
|
||||
title: string;
|
||||
total: number;
|
||||
href: string;
|
||||
tally: Record<string, number>;
|
||||
highlight?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="block border border-neutral-800 hover:border-neutral-600 p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-neutral-400">{title}</div>
|
||||
<div className={`mt-1 text-3xl ${highlight ? statusBadgeClass(highlight) : ""}`}>{total}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
{Object.entries(tally).map(([k, v]) => (
|
||||
<span key={k} className={statusBadgeClass(k)}>
|
||||
{k} {v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
return <OverviewDashboard initialData={data} />;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Agent } from "@/lib/api";
|
||||
import type { components } from "@/lib/api-types";
|
||||
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
|
||||
export function AgentFeatures({ features }: { features?: Agent["features"] }) {
|
||||
const enabled = enabledFeatures(features);
|
||||
|
||||
@@ -6,9 +6,13 @@ import { useMemo, useState } from "react";
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { Agent, CheckState } from "@/lib/api";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
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";
|
||||
@@ -20,10 +24,12 @@ 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({ rows: initialRows }: { rows: AgentRow[] }) {
|
||||
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),
|
||||
@@ -239,6 +245,19 @@ 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":
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AutoRefresh({ intervalMs }: { intervalMs: number }) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalMs <= 0) return;
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const stop = () => {
|
||||
if (timer !== undefined) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
stop();
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState === "visible") {
|
||||
router.refresh();
|
||||
}
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
router.refresh();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
start();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [intervalMs, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
212
web/src/components/CheckDetailBrowser.tsx
Normal file
212
web/src/components/CheckDetailBrowser.tsx
Normal 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;
|
||||
}
|
||||
18
web/src/components/CheckStatusSummary.tsx
Normal file
18
web/src/components/CheckStatusSummary.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { CheckCounts } from "@/lib/check-groups";
|
||||
import { checkStatuses, shortStatus } from "@/lib/check-groups";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export function CheckStatusSummary({ counts }: { counts: CheckCounts }) {
|
||||
return (
|
||||
<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((status, i) => (
|
||||
<span key={status} className="inline-flex items-center gap-1.5">
|
||||
{i > 0 ? <span className="text-neutral-700">/</span> : null}
|
||||
<span className={statusBadgeClass(status)}>
|
||||
{shortStatus(status)} {counts[status]}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
181
web/src/components/ChecksBrowser.tsx
Normal file
181
web/src/components/ChecksBrowser.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import {
|
||||
compareStatusCounts,
|
||||
groupChecks,
|
||||
type CheckGroup,
|
||||
} from "@/lib/check-groups";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
type SortField = "check" | "status" | "last_observed_at";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
export function ChecksBrowser({ 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 agentsById = useMemo(() => new Map(data.agents.map((agent) => [agent.agent_id, agent])), [data.agents]);
|
||||
const groups = useMemo(() => groupChecks(data.checks, agentsById), [agentsById, data.checks]);
|
||||
const rows = useMemo(
|
||||
() => sortChecks(filterChecks(groups, query), sort, dir),
|
||||
[dir, groups, query, sort],
|
||||
);
|
||||
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-baseline gap-3">
|
||||
<h1 className="text-lg font-semibold">Checks</h1>
|
||||
<span className="text-sm text-neutral-400">{rows.length} items</span>
|
||||
</div>
|
||||
<div className="mb-4 flex max-w-md gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="search check"
|
||||
className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 italic">No data.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
CHECK
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="last_observed_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
LAST_OBSERVED_AT
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>AGENTS</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.checkId}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/checks/${encodeURIComponent(row.checkId)}`}
|
||||
className="text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
{row.checkId}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckStatusSummary counts={row.counts} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={row.lastObservedAt} />
|
||||
</Td>
|
||||
<Td>{row.checks.length}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="border-b border-neutral-800 px-2 py-2 text-left text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return <td className={`border-b border-neutral-900 px-2 py-1.5 align-top ${className}`}>{children}</td>;
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
field: SortField;
|
||||
current: SortField;
|
||||
dir: SortDir;
|
||||
onClick: (field: SortField) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = field === current;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(field)}
|
||||
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function filterChecks(rows: CheckGroup[], query: string): CheckGroup[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return rows;
|
||||
return rows.filter((row) => row.checkId.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
function fieldDefaultDir(field: SortField): SortDir {
|
||||
return field === "check" ? "asc" : "desc";
|
||||
}
|
||||
|
||||
function sortChecks(rows: CheckGroup[], sort: SortField, dir: SortDir): CheckGroup[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const primary = compareByField(a, b, sort);
|
||||
if (primary !== 0) return dir === "asc" ? primary : -primary;
|
||||
return a.checkId.localeCompare(b.checkId);
|
||||
});
|
||||
}
|
||||
|
||||
function compareByField(a: CheckGroup, b: CheckGroup, sort: SortField): number {
|
||||
switch (sort) {
|
||||
case "check":
|
||||
return a.checkId.localeCompare(b.checkId);
|
||||
case "last_observed_at":
|
||||
return Date.parse(a.lastObservedAt) - Date.parse(b.lastObservedAt);
|
||||
case "status":
|
||||
return compareStatusCounts(a, b);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
export function PageHeader({ title, count }: { title: string; count?: number }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-baseline gap-3">
|
||||
@@ -11,7 +9,7 @@ export function PageHeader({ title, count }: { title: string; count?: number })
|
||||
|
||||
export function ErrorPanel({ error }: { error: unknown }) {
|
||||
const e = error instanceof Error ? error : new Error(String(error));
|
||||
const status = error instanceof ApiError ? error.status : "—";
|
||||
const status = hasStatus(error) ? error.status : "—";
|
||||
return (
|
||||
<div className="border border-red-700 bg-red-950/40 p-3 text-sm">
|
||||
<div className="font-semibold text-red-300">API error ({String(status)})</div>
|
||||
@@ -20,6 +18,10 @@ export function ErrorPanel({ error }: { error: unknown }) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasStatus(error: unknown): error is { status: number } {
|
||||
return typeof error === "object" && error !== null && "status" in error;
|
||||
}
|
||||
|
||||
export function EmptyPanel({ label = "No data" }: { label?: string }) {
|
||||
return <div className="text-sm text-neutral-500 italic">{label}.</div>;
|
||||
}
|
||||
|
||||
136
web/src/components/EventsBrowser.tsx
Normal file
136
web/src/components/EventsBrowser.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
|
||||
|
||||
export function EventsBrowser({
|
||||
initialData,
|
||||
cursor,
|
||||
agentId,
|
||||
checkId,
|
||||
}: {
|
||||
initialData: EventsPageData;
|
||||
cursor?: string;
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
}) {
|
||||
const url = useMemo(() => eventsPollUrl({ cursor, agentId, checkId }), [agentId, checkId, cursor]);
|
||||
const { data } = usePolling(initialData, url);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Events" count={data.items.length} />
|
||||
<ActiveFilters agentId={agentId} checkId={checkId} />
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>observed_at</Th>
|
||||
<Th>received_at</Th>
|
||||
<Th>agent_id</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>duration_ms</Th>
|
||||
<Th>summary</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>
|
||||
<DateTime iso={e.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={e.received_at} />
|
||||
</Td>
|
||||
<Td>{e.agent_id}</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>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{e.output ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager
|
||||
basePath="/events"
|
||||
cursor={cursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ agent_id: agentId, check_id: checkId }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
|
||||
if (!agentId && !checkId) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
||||
{agentId ? (
|
||||
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
|
||||
) : null}
|
||||
{checkId ? (
|
||||
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
|
||||
) : null}
|
||||
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
|
||||
clear
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label, value, href }: { label: string; value: string; href: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex max-w-xs items-center gap-1 rounded border border-neutral-800 bg-neutral-900 px-2 py-1 text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
|
||||
title={`${label}=${value}`}
|
||||
>
|
||||
<span className="text-neutral-500">{label}=</span>
|
||||
<span className="truncate">{value}</span>
|
||||
<span className="text-neutral-600">x</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.agent_id) qs.set("agent_id", params.agent_id);
|
||||
if (params.check_id) qs.set("check_id", params.check_id);
|
||||
const query = qs.toString();
|
||||
return query ? `/events?${query}` : "/events";
|
||||
}
|
||||
|
||||
function eventsPollUrl({
|
||||
cursor,
|
||||
agentId,
|
||||
checkId,
|
||||
}: {
|
||||
cursor?: string;
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (cursor) qs.set("cursor", cursor);
|
||||
if (agentId) qs.set("agent_id", agentId);
|
||||
if (checkId) qs.set("check_id", checkId);
|
||||
const query = qs.toString();
|
||||
return query ? `/api/poll/events?${query}` : "/api/poll/events";
|
||||
}
|
||||
276
web/src/components/IncidentsBrowser.tsx
Normal file
276
web/src/components/IncidentsBrowser.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
const STATES = ["all", "open", "resolved"] as const;
|
||||
|
||||
type Incident = components["schemas"]["Incident"];
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
type IncidentsPageData = { items: Incident[] };
|
||||
type SortField = "status" | "host" | "check" | "opened_at" | "resolved_at";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
const stateRank: Record<Incident["state"], number> = { resolved: 0, open: 1 };
|
||||
const severityRank: Record<Incident["severity"], number> = { unknown: 1, warning: 2, critical: 3 };
|
||||
|
||||
export function IncidentsBrowser({
|
||||
initialData,
|
||||
initialInventory,
|
||||
state,
|
||||
}: {
|
||||
initialData: IncidentsPageData;
|
||||
initialInventory: InventoryData;
|
||||
state?: "open" | "resolved";
|
||||
}) {
|
||||
const url = useMemo(() => incidentsPollUrl({ state }), [state]);
|
||||
const { data } = usePolling(initialData, url);
|
||||
const { data: inventory } = usePolling(initialInventory, "/api/poll/inventory");
|
||||
const [sort, setSort] = useState<SortField>("status");
|
||||
const [dir, setDir] = useState<SortDir>("desc");
|
||||
const agentsById = useMemo(
|
||||
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
|
||||
[inventory.agents],
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => sortIncidents(data.items, agentsById, sort, dir),
|
||||
[agentsById, data.items, dir, sort],
|
||||
);
|
||||
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Incidents" count={rows.length} />
|
||||
<div className="mb-3 flex gap-3 text-sm">
|
||||
{STATES.map((s) => {
|
||||
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
||||
const active = (s === "all" && !state) || s === state;
|
||||
return (
|
||||
<Link
|
||||
key={s}
|
||||
href={href}
|
||||
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
HOST
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
CHECK
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
OPENED_AT
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
RESOLVED_AT
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>SUMMARY</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ incident, agent }) => (
|
||||
<IncidentRow key={incident.id} incident={incident} agent={agent} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent }) {
|
||||
const agentId = incidentAgentId(incident);
|
||||
const checkId = incidentCheckId(incident);
|
||||
return (
|
||||
<tr>
|
||||
<Td>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap">
|
||||
<span className={statusBadgeClass(incident.state)}>{incident.state}</span>
|
||||
<span className={statusBadgeClass(incident.severity)}>{incident.severity}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{agentId ? (
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(agentId)}`}
|
||||
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<span>{agent?.hostname || agentId}</span>
|
||||
{agent && agent.hostname !== agentId ? (
|
||||
<span className="text-xs text-neutral-500">{agentId}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<IncidentCheckLink agentId={agentId} checkId={checkId} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={incident.opened_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={incident.resolved_at} />
|
||||
</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{incident.summary ?? "—"}</Td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentCheckLink({ agentId, checkId }: { agentId: string; checkId: string }) {
|
||||
if (!checkId) return <span className="text-neutral-500">—</span>;
|
||||
if (checkId === "agent_liveness") {
|
||||
return (
|
||||
<Link
|
||||
href={`/checks/${encodeURIComponent(checkId)}`}
|
||||
className="text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
{checkId}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return agentId ? <CheckEventsLink agentId={agentId} checkId={checkId} /> : <span>{checkId}</span>;
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
field: SortField;
|
||||
current: SortField;
|
||||
dir: SortDir;
|
||||
onClick: (field: SortField) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = field === current;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(field)}
|
||||
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function fieldDefaultDir(field: SortField): SortDir {
|
||||
return field === "host" || field === "check" ? "asc" : "desc";
|
||||
}
|
||||
|
||||
function sortIncidents(
|
||||
incidents: Incident[],
|
||||
agentsById: Map<string, Agent>,
|
||||
sort: SortField,
|
||||
dir: SortDir,
|
||||
): { incident: Incident; agent?: Agent }[] {
|
||||
return incidents
|
||||
.map((incident) => ({ incident, agent: agentsById.get(incidentAgentId(incident)) }))
|
||||
.sort((a, b) => {
|
||||
const primary = compareByField(a, b, sort);
|
||||
if (primary !== 0) return dir === "asc" ? primary : -primary;
|
||||
return fallbackCompare(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
function compareByField(
|
||||
a: { incident: Incident; agent?: Agent },
|
||||
b: { incident: Incident; agent?: Agent },
|
||||
sort: SortField,
|
||||
): number {
|
||||
switch (sort) {
|
||||
case "host":
|
||||
return hostName(a).localeCompare(hostName(b)) || incidentAgentId(a.incident).localeCompare(incidentAgentId(b.incident));
|
||||
case "check":
|
||||
return incidentCheckId(a.incident).localeCompare(incidentCheckId(b.incident));
|
||||
case "opened_at":
|
||||
return Date.parse(a.incident.opened_at) - Date.parse(b.incident.opened_at);
|
||||
case "resolved_at":
|
||||
return dateValue(a.incident.resolved_at) - dateValue(b.incident.resolved_at);
|
||||
case "status":
|
||||
return compareIncidentImportance(a.incident, b.incident);
|
||||
}
|
||||
}
|
||||
|
||||
function compareIncidentImportance(a: Incident, b: Incident): number {
|
||||
return (
|
||||
stateRank[a.state] - stateRank[b.state] ||
|
||||
severityRank[a.severity] - severityRank[b.severity] ||
|
||||
Date.parse(a.opened_at) - Date.parse(b.opened_at)
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackCompare(
|
||||
a: { incident: Incident; agent?: Agent },
|
||||
b: { incident: Incident; agent?: Agent },
|
||||
): number {
|
||||
return (
|
||||
compareIncidentImportance(a.incident, b.incident) ||
|
||||
hostName(a).localeCompare(hostName(b)) ||
|
||||
incidentCheckId(a.incident).localeCompare(incidentCheckId(b.incident))
|
||||
);
|
||||
}
|
||||
|
||||
function dateValue(value?: string | null): number {
|
||||
return value ? Date.parse(value) : 0;
|
||||
}
|
||||
|
||||
function hostName(row: { incident: Incident; agent?: Agent }): string {
|
||||
return row.agent?.hostname || incidentAgentId(row.incident);
|
||||
}
|
||||
|
||||
function incidentAgentId(incident: Incident): string {
|
||||
return incident.agent_id ?? "";
|
||||
}
|
||||
|
||||
function incidentCheckId(incident: Incident): string {
|
||||
return incident.check_id ?? "";
|
||||
}
|
||||
|
||||
function incidentsPollUrl({ state }: { state?: string }) {
|
||||
return state ? `/api/poll/incidents?state=${encodeURIComponent(state)}` : "/api/poll/incidents";
|
||||
}
|
||||
68
web/src/components/OutboxBrowser.tsx
Normal file
68
web/src/components/OutboxBrowser.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
|
||||
type OutboxPageData = { items: OutboxItem[]; next_cursor?: string | null };
|
||||
|
||||
export function OutboxBrowser({
|
||||
initialData,
|
||||
cursor,
|
||||
}: {
|
||||
initialData: OutboxPageData;
|
||||
cursor?: string;
|
||||
}) {
|
||||
const url = useMemo(() => {
|
||||
if (!cursor) return "/api/poll/outbox";
|
||||
return `/api/poll/outbox?cursor=${encodeURIComponent(cursor)}`;
|
||||
}, [cursor]);
|
||||
const { data } = usePolling(initialData, url);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Notification outbox" count={data.items.length} />
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>updated_at</Th>
|
||||
<Th>notifier</Th>
|
||||
<Th>event</Th>
|
||||
<Th>state</Th>
|
||||
<Th>attempts</Th>
|
||||
<Th>next_attempt_at</Th>
|
||||
<Th>last_error</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<Td>
|
||||
<DateTime iso={o.updated_at} />
|
||||
</Td>
|
||||
<Td>{o.notifier}</Td>
|
||||
<Td>{o.event_type}</Td>
|
||||
<Td className={statusBadgeClass(o.state)}>{o.state}</Td>
|
||||
<Td>{o.attempts}</Td>
|
||||
<Td>
|
||||
<DateTime iso={o.next_attempt_at} />
|
||||
</Td>
|
||||
<Td className="text-xs text-red-300">{o.last_error ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager basePath="/outbox" cursor={cursor} nextCursor={data.next_cursor} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
web/src/components/OverviewDashboard.tsx
Normal file
56
web/src/components/OverviewDashboard.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { OverviewData } from "@/lib/view-types";
|
||||
|
||||
export function OverviewDashboard({ initialData }: { initialData: OverviewData }) {
|
||||
const { data } = usePolling(initialData, "/api/poll/overview");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-lg font-semibold">Overview</h1>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card title="Agents" total={data.totalAgents} href="/agents" tally={data.agents} />
|
||||
<Card title="Checks" total={data.totalChecks} href="/checks" tally={data.checks} />
|
||||
<Card
|
||||
title="Open incidents"
|
||||
total={data.openIncidents}
|
||||
href="/incidents"
|
||||
tally={{}}
|
||||
highlight={data.openIncidents > 0 ? "open" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
total,
|
||||
href,
|
||||
tally,
|
||||
highlight,
|
||||
}: {
|
||||
title: string;
|
||||
total: number;
|
||||
href: string;
|
||||
tally: Record<string, number>;
|
||||
highlight?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="block border border-neutral-800 p-4 hover:border-neutral-600">
|
||||
<div className="text-xs uppercase tracking-wide text-neutral-400">{title}</div>
|
||||
<div className={`mt-1 text-3xl ${highlight ? statusBadgeClass(highlight) : ""}`}>{total}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
{Object.entries(tally).map(([k, v]) => (
|
||||
<span key={k} className={statusBadgeClass(k)}>
|
||||
{k} {v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -206,7 +206,10 @@ export interface components {
|
||||
};
|
||||
HeartbeatRequest: {
|
||||
agent_id: string;
|
||||
/** Format: date-time */
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected.
|
||||
*/
|
||||
observed_at: string;
|
||||
hostname: string;
|
||||
features: components["schemas"]["AgentFeatures"];
|
||||
@@ -236,7 +239,10 @@ export interface components {
|
||||
/** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */
|
||||
event_id: string;
|
||||
check_id: string;
|
||||
/** Format: date-time */
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected.
|
||||
*/
|
||||
observed_at: string;
|
||||
/** @enum {string} */
|
||||
status: "ok" | "warning" | "critical" | "unknown";
|
||||
@@ -286,6 +292,7 @@ export interface components {
|
||||
last_observed_at: string;
|
||||
exit_code?: number;
|
||||
incident_key?: string;
|
||||
summary?: string;
|
||||
};
|
||||
Incident: {
|
||||
id: string;
|
||||
|
||||
131
web/src/lib/check-groups.ts
Normal file
131
web/src/lib/check-groups.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { components } from "@/lib/api-types";
|
||||
|
||||
export type Agent = components["schemas"]["Agent"];
|
||||
export type CheckState = components["schemas"]["CheckState"];
|
||||
export type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
export type CheckStatus = CheckState["status"];
|
||||
export type CheckCounts = Record<CheckStatus, number>;
|
||||
|
||||
export type CheckGroup = {
|
||||
checkId: string;
|
||||
checks: Array<{ check: CheckState; agent?: Agent }>;
|
||||
counts: CheckCounts;
|
||||
lastObservedAt: string;
|
||||
};
|
||||
|
||||
export const LIVENESS_CHECK_ID = "agent_liveness";
|
||||
export const checkStatuses: CheckStatus[] = ["ok", "warning", "critical", "unknown"];
|
||||
|
||||
export const statusSeverity: Record<CheckStatus, number> = {
|
||||
ok: 0,
|
||||
unknown: 1,
|
||||
warning: 2,
|
||||
critical: 3,
|
||||
};
|
||||
|
||||
export function groupChecks(checks: CheckState[], agentsById: Map<string, Agent>): CheckGroup[] {
|
||||
const byCheck = new Map<string, CheckGroup>();
|
||||
for (const check of checks) {
|
||||
const group =
|
||||
byCheck.get(check.check_id) ??
|
||||
({
|
||||
checkId: check.check_id,
|
||||
checks: [],
|
||||
counts: emptyCheckCounts(),
|
||||
lastObservedAt: check.last_observed_at,
|
||||
} satisfies CheckGroup);
|
||||
group.checks.push({ check, agent: agentsById.get(check.agent_id) });
|
||||
group.counts[check.status] += 1;
|
||||
if (Date.parse(check.last_observed_at) > Date.parse(group.lastObservedAt)) {
|
||||
group.lastObservedAt = check.last_observed_at;
|
||||
}
|
||||
byCheck.set(check.check_id, group);
|
||||
}
|
||||
return [...byCheck.values()];
|
||||
}
|
||||
|
||||
export function withLivenessChecks(agents: Agent[], checks: CheckState[]): CheckState[] {
|
||||
const regularChecks = checks.filter((check) => check.check_id !== LIVENESS_CHECK_ID);
|
||||
const livenessChecks = agents
|
||||
.map(livenessCheckForAgent)
|
||||
.filter((check): check is CheckState => check !== null);
|
||||
return [...regularChecks, ...livenessChecks];
|
||||
}
|
||||
|
||||
export function livenessCheckForAgent(agent: Agent): CheckState | null {
|
||||
if (agent.status !== "stale" && agent.status !== "dead") return null;
|
||||
const status: CheckStatus = agent.status === "dead" ? "critical" : "warning";
|
||||
return {
|
||||
agent_id: agent.agent_id,
|
||||
check_id: LIVENESS_CHECK_ID,
|
||||
status,
|
||||
last_observed_at: agent.last_seen_at,
|
||||
exit_code: status === "critical" ? 2 : 1,
|
||||
incident_key: `agent:${agent.agent_id}:liveness`,
|
||||
summary: `agent is ${agent.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function withLivenessEvents(
|
||||
agents: Agent[],
|
||||
events: StoredEvent[],
|
||||
filters: { agentId?: string; checkId?: string; cursor?: string } = {},
|
||||
): StoredEvent[] {
|
||||
if (filters.cursor) return events;
|
||||
const livenessEvents = agents
|
||||
.map(livenessEventForAgent)
|
||||
.filter((event): event is StoredEvent => event !== null)
|
||||
.filter((event) => {
|
||||
if (filters.agentId && event.agent_id !== filters.agentId) return false;
|
||||
if (filters.checkId && event.check_id !== filters.checkId) return false;
|
||||
return true;
|
||||
});
|
||||
return [...events, ...livenessEvents].sort((a, b) => b.observed_at.localeCompare(a.observed_at));
|
||||
}
|
||||
|
||||
export function livenessEventForAgent(agent: Agent): StoredEvent | null {
|
||||
const check = livenessCheckForAgent(agent);
|
||||
if (!check) return null;
|
||||
// Synthetic event represents the current liveness state, not the last heartbeat,
|
||||
// so observed_at uses "now" to keep it at the top of the events list across polls.
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
event_id: `synthetic:${LIVENESS_CHECK_ID}:${agent.agent_id}:${agent.status}:${agent.last_seen_at}`,
|
||||
agent_id: agent.agent_id,
|
||||
check_id: LIVENESS_CHECK_ID,
|
||||
observed_at: now,
|
||||
received_at: now,
|
||||
status: check.status,
|
||||
exit_code: check.status === "critical" ? 2 : 1,
|
||||
duration_ms: 0,
|
||||
output: check.summary,
|
||||
output_truncated: false,
|
||||
notifications_enabled: false,
|
||||
incident_key: check.incident_key,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyCheckCounts(): CheckCounts {
|
||||
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
export function compareStatusCounts(a: CheckGroup, b: CheckGroup): number {
|
||||
for (const status of ["critical", "warning", "unknown", "ok"] satisfies CheckStatus[]) {
|
||||
const diff = a.counts[status] - b.counts[status];
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function shortStatus(status: CheckStatus): string {
|
||||
switch (status) {
|
||||
case "ok":
|
||||
return "ok";
|
||||
case "warning":
|
||||
return "warn";
|
||||
case "critical":
|
||||
return "crit";
|
||||
case "unknown":
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
102
web/src/lib/loaders.ts
Normal file
102
web/src/lib/loaders.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import "server-only";
|
||||
|
||||
import { ApiError, api, type Agent, type CheckState, type StoredEvent } from "@/lib/api";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { LIVENESS_CHECK_ID, withLivenessChecks, withLivenessEvents } from "@/lib/check-groups";
|
||||
import type { InventoryData, OverviewData } from "@/lib/view-types";
|
||||
|
||||
type Incident = components["schemas"]["Incident"];
|
||||
|
||||
export async function loadAgents(): Promise<Agent[]> {
|
||||
const agents: Agent[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listAgents({ cursor, limit: 500 });
|
||||
agents.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return agents;
|
||||
}
|
||||
|
||||
export async function loadChecks(agentId?: string): Promise<CheckState[]> {
|
||||
const checks: CheckState[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listChecks({ agent_id: agentId, cursor, limit: 500 });
|
||||
checks.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return checks;
|
||||
}
|
||||
|
||||
export async function loadInventory(): Promise<InventoryData> {
|
||||
const [agents, checks] = await Promise.all([loadAgents(), loadChecks()]);
|
||||
return { agents, checks: withLivenessChecks(agents, checks) };
|
||||
}
|
||||
|
||||
export async function loadEventsWithLiveness({
|
||||
agentId,
|
||||
checkId,
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> {
|
||||
const data = await api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
|
||||
if (cursor || (checkId && checkId !== LIVENESS_CHECK_ID)) return data;
|
||||
|
||||
const agents = agentId ? await loadOptionalAgent(agentId) : await loadAgents();
|
||||
return {
|
||||
...data,
|
||||
items: withLivenessEvents(agents, data.items, { agentId, checkId, cursor }),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOptionalAgent(agentId: string): Promise<Agent[]> {
|
||||
try {
|
||||
return [await api.getAgent(agentId)];
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadIncidents(
|
||||
state?: "open" | "resolved",
|
||||
): Promise<{ items: Incident[] }> {
|
||||
const items: Incident[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listIncidents({ state, cursor, limit: 500 });
|
||||
items.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function loadOverview(): Promise<OverviewData> {
|
||||
const [agents, checks, incidents] = await Promise.all([
|
||||
loadAgents(),
|
||||
loadChecks(),
|
||||
api.listIncidents({ state: "open", limit: 500 }),
|
||||
]);
|
||||
const checksWithLiveness = withLivenessChecks(agents, checks);
|
||||
return {
|
||||
agents: tally(agents),
|
||||
checks: tally(checksWithLiveness),
|
||||
openIncidents: incidents.items.length,
|
||||
totalAgents: agents.length,
|
||||
totalChecks: checksWithLiveness.length,
|
||||
};
|
||||
}
|
||||
|
||||
function tally(xs: { status?: string }[]): Record<string, number> {
|
||||
return xs.reduce<Record<string, number>>((acc, x) => {
|
||||
const k = x.status ?? "unknown";
|
||||
acc[k] = (acc[k] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
18
web/src/lib/poll-response.ts
Normal file
18
web/src/lib/poll-response.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import "server-only";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
export function jsonError(e: unknown) {
|
||||
if (e instanceof ApiError) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: e.code, message: e.message } },
|
||||
{ status: e.status },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: { code: "internal_error", message: "internal server error" } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
88
web/src/lib/usePolling.ts
Normal file
88
web/src/lib/usePolling.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const DEFAULT_POLL_MS = 10_000;
|
||||
|
||||
export function usePolling<T>(initialData: T, url: string, intervalMs = pollIntervalMs()) {
|
||||
const [data, setData] = useState(initialData);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalMs <= 0) return;
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let inFlight = false;
|
||||
let stopped = false;
|
||||
let controller: AbortController | undefined;
|
||||
|
||||
const refresh = () => {
|
||||
if (stopped || inFlight || document.visibilityState !== "visible") return;
|
||||
inFlight = true;
|
||||
controller = new AbortController();
|
||||
|
||||
fetch(url, { cache: "no-store", signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.error?.message ?? response.statusText);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
})
|
||||
.then((body) => {
|
||||
setData(body);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!controller?.signal.aborted) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
});
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
stopTimer();
|
||||
timer = setInterval(refresh, intervalMs);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer !== undefined) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
start();
|
||||
refresh();
|
||||
} else {
|
||||
stopTimer();
|
||||
controller?.abort();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") start();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTimer();
|
||||
controller?.abort();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [intervalMs, url]);
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
|
||||
export function pollIntervalMs() {
|
||||
const raw = process.env.NEXT_PUBLIC_MONLET_POLL_MS?.trim();
|
||||
if (!raw) return DEFAULT_POLL_MS;
|
||||
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : DEFAULT_POLL_MS;
|
||||
}
|
||||
17
web/src/lib/view-types.ts
Normal file
17
web/src/lib/view-types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { components } from "@/lib/api-types";
|
||||
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
type CheckState = components["schemas"]["CheckState"];
|
||||
|
||||
export type InventoryData = {
|
||||
agents: Agent[];
|
||||
checks: CheckState[];
|
||||
};
|
||||
|
||||
export type OverviewData = {
|
||||
agents: Record<string, number>;
|
||||
checks: Record<string, number>;
|
||||
openIncidents: number;
|
||||
totalAgents: number;
|
||||
totalChecks: number;
|
||||
};
|
||||
@@ -19,7 +19,7 @@ const fixtures = {
|
||||
{
|
||||
agent_id: "agent-2",
|
||||
hostname: "host-2",
|
||||
status: "stale",
|
||||
status: "dead",
|
||||
last_seen_at: now,
|
||||
features: { push: true, metrics: false },
|
||||
labels: {},
|
||||
@@ -44,6 +44,7 @@ const fixtures = {
|
||||
exit_code: 0,
|
||||
last_observed_at: now,
|
||||
incident_key: "agent-1:disk",
|
||||
summary: "disk ok",
|
||||
},
|
||||
{
|
||||
agent_id: "agent-1",
|
||||
@@ -52,6 +53,7 @@ const fixtures = {
|
||||
exit_code: 1,
|
||||
last_observed_at: now,
|
||||
incident_key: "agent-1:load",
|
||||
summary: "load high",
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
@@ -68,6 +70,27 @@ const fixtures = {
|
||||
opened_at: now,
|
||||
summary: "load high",
|
||||
},
|
||||
{
|
||||
id: "00000000-0000-0000-0000-000000000003",
|
||||
incident_key: "agent:agent-2:liveness",
|
||||
state: "open",
|
||||
severity: "critical",
|
||||
agent_id: "agent-2",
|
||||
check_id: "agent_liveness",
|
||||
opened_at: now,
|
||||
summary: "agent is dead",
|
||||
},
|
||||
{
|
||||
id: "00000000-0000-0000-0000-000000000004",
|
||||
incident_key: "agent-2:disk",
|
||||
state: "resolved",
|
||||
severity: "critical",
|
||||
agent_id: "agent-2",
|
||||
check_id: "disk",
|
||||
opened_at: now,
|
||||
resolved_at: now,
|
||||
summary: "disk recovered",
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
},
|
||||
@@ -82,6 +105,22 @@ const fixtures = {
|
||||
status: "ok",
|
||||
exit_code: 0,
|
||||
duration_ms: 12,
|
||||
output: "disk ok",
|
||||
output_truncated: false,
|
||||
notifications_enabled: true,
|
||||
},
|
||||
{
|
||||
event_id: "00000000-0000-7000-8000-000000000002",
|
||||
agent_id: "agent-1",
|
||||
check_id: "load",
|
||||
observed_at: now,
|
||||
received_at: now,
|
||||
status: "warning",
|
||||
exit_code: 1,
|
||||
duration_ms: 34,
|
||||
output: "load high",
|
||||
output_truncated: false,
|
||||
notifications_enabled: true,
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
@@ -117,6 +156,29 @@ const server = createServer((req, res) => {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/api/v1/events/query" && body) {
|
||||
const agentId = url.searchParams.get("agent_id");
|
||||
const checkId = url.searchParams.get("check_id");
|
||||
body = {
|
||||
...body,
|
||||
items: body.items.filter((item) => {
|
||||
if (agentId && item.agent_id !== agentId) return false;
|
||||
if (checkId && item.check_id !== checkId) return false;
|
||||
return true;
|
||||
}),
|
||||
next_cursor: null,
|
||||
};
|
||||
}
|
||||
if (url.pathname === "/api/v1/incidents" && body) {
|
||||
const state = url.searchParams.get("state");
|
||||
if (state) {
|
||||
body = {
|
||||
...body,
|
||||
items: body.items.filter((item) => item.state === state),
|
||||
next_cursor: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (!body) {
|
||||
res.statusCode = 404;
|
||||
|
||||
@@ -38,6 +38,8 @@ test("agents list links to detail", async ({ page }) => {
|
||||
await expect(page.getByRole("link", { name: "Checks" }).last()).toBeVisible();
|
||||
await expect(page.locator("tbody").first().locator("tr").first()).toContainText("warning");
|
||||
await expect(page.locator("thead").first()).not.toContainText("incident_key");
|
||||
await expect(page.locator("thead").first()).not.toContainText("exit");
|
||||
await expect(page.locator("tbody").first()).toContainText("load high");
|
||||
await page.getByRole("link", { name: "check_id" }).click();
|
||||
await expect(page).toHaveURL(/sort=check_id/);
|
||||
await page.getByRole("link", { name: "Events" }).last().click();
|
||||
@@ -49,18 +51,57 @@ test("agents list links to detail", async ({ page }) => {
|
||||
|
||||
test("checks page lists rows", async ({ page }) => {
|
||||
await page.goto("/checks");
|
||||
await expect(page.getByRole("heading", { name: "Checks" })).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/checks$/);
|
||||
await expect(page.locator("thead").first()).not.toContainText("agent_id");
|
||||
await expect(page.locator("thead").first()).not.toContainText("incident_key");
|
||||
await expect(page.locator("thead").first()).not.toContainText("exit");
|
||||
await expect(page.locator("thead").first()).toContainText("↓");
|
||||
await expect(page.locator("body")).toContainText("disk");
|
||||
await expect(page.locator("body")).toContainText("load");
|
||||
await expect(page.locator("body")).toContainText("agent_liveness");
|
||||
await page.getByPlaceholder("search check").fill("lo");
|
||||
await expect(page).toHaveURL(/\/checks$/);
|
||||
await expect(page.locator("tbody")).toContainText("load");
|
||||
await expect(page.locator("tbody")).not.toContainText("disk");
|
||||
await page.getByRole("button", { name: "clear" }).click();
|
||||
await page.getByRole("button", { name: /^CHECK/ }).click();
|
||||
await expect(page).toHaveURL(/\/checks$/);
|
||||
await page.getByRole("link", { name: "disk" }).click();
|
||||
await expect(page).toHaveURL(/\/events\?agent_id=agent-1&check_id=disk$/);
|
||||
await expect(page).toHaveURL(/\/checks\/disk$/);
|
||||
await expect(page.getByRole("link", { name: "Agents" }).last()).toBeVisible();
|
||||
await expect(page.locator("body")).toContainText("host-1");
|
||||
await expect(page.locator("body")).toContainText("disk ok");
|
||||
await page.getByRole("link", { name: "Events" }).last().click();
|
||||
await expect(page).toHaveURL(/\/checks\/disk\?tab=events$/);
|
||||
await expect(page.locator("body")).toContainText("12");
|
||||
await expect(page.locator("thead").last()).not.toContainText("exit");
|
||||
});
|
||||
|
||||
test("incidents filter renders", async ({ page }) => {
|
||||
await page.goto("/incidents");
|
||||
await expect(page.locator("body")).toContainText("load high");
|
||||
await expect(page.locator("thead")).toContainText("HOST");
|
||||
await expect(page.locator("thead")).not.toContainText("agent_id");
|
||||
await expect(page.locator("thead")).toContainText("↓");
|
||||
await expect(page.locator("tbody")).toContainText("host-1");
|
||||
await expect(page.locator("tbody")).toContainText("agent_liveness");
|
||||
await expect(page.locator("tbody")).toContainText("agent is dead");
|
||||
await page.getByRole("link", { name: "agent_liveness" }).click();
|
||||
await expect(page).toHaveURL(/\/checks\/agent_liveness$/);
|
||||
await expect(page.locator("body")).toContainText("host-2");
|
||||
await page.goto("/incidents");
|
||||
await page.getByRole("button", { name: /^HOST/ }).click();
|
||||
await expect(page).toHaveURL(/\/incidents$/);
|
||||
await expect(page.locator("thead")).toContainText("↑");
|
||||
await page.getByRole("link", { name: "host-1" }).click();
|
||||
await expect(page).toHaveURL(/\/agents\/agent-1$/);
|
||||
});
|
||||
|
||||
test("events page", async ({ page }) => {
|
||||
await page.goto("/events");
|
||||
await expect(page.locator("body")).toContainText("agent_liveness");
|
||||
await expect(page.locator("body")).toContainText("agent is dead");
|
||||
await page.goto("/events?agent_id=agent-1&check_id=disk");
|
||||
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "query" })).toHaveCount(0);
|
||||
|
||||
Reference in New Issue
Block a user