store only status-change events; UI client-side polling refactor
This commit is contained in:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user