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

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

View File

@@ -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}
/>
);
}

View File

@@ -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} />;
}

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

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

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

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

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

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

View 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}
/>
);
}

View File

@@ -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} />;
}

View File

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

View File

@@ -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}
/>
);
}

View File

@@ -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>

View File

@@ -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} />;
}

View File

@@ -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} />;
}

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

View File

@@ -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);

View File

@@ -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":

View File

@@ -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;
}

View File

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

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

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

View File

@@ -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>;
}

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

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

View 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} />
</>
);
}

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

View File

@@ -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
View 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
View 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;
}, {});
}

View 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
View 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
View 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;
};

View File

@@ -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;

View File

@@ -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);