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

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