Implement Monlet MVP stack and UI updates

This commit is contained in:
Stanislav Rossovskii
2026-05-27 10:01:59 +04:00
parent dcea096327
commit edc51e9c59
145 changed files with 15618 additions and 248 deletions

648
web/src/lib/api-types.ts Normal file
View File

@@ -0,0 +1,648 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/v1/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Health check */
get: operations["getHealth"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/ready": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Readiness check */
get: operations["getReady"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/heartbeat": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Ingest agent heartbeat */
post: operations["ingestHeartbeat"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Ingest agent check result events */
post: operations["ingestEvents"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List agents */
get: operations["listAgents"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents/{agent_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get agent detail */
get: operations["getAgent"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/checks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List current check states */
get: operations["listChecks"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/incidents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List incidents */
get: operations["listIncidents"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/events/query": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Query stored events */
get: operations["queryEvents"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/notifiers/outbox": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List notification outbox items */
get: operations["listOutbox"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
HealthResponse: {
/** @enum {string} */
status: "ok";
};
ErrorResponse: {
error: {
/** @enum {string} */
code: "unauthorized" | "not_found" | "validation" | "payload_too_large" | "rate_limited" | "internal" | "not_ready";
message: string;
/** @description Server-generated request id, mirrored from X-Request-Id. */
request_id: string;
};
};
PageEnvelope: {
/** @description Pass back as `cursor` to fetch the next page. Null/absent if no more results. */
next_cursor?: string | null;
};
AcceptedResponse: {
accepted: boolean;
};
EventBatchAcceptedResponse: {
/** @description Number of events stored on this request. */
accepted: number;
/** @description Number of events whose `event_id` was already known. */
deduplicated: number;
};
HeartbeatRequest: {
agent_id: string;
/** Format: date-time */
observed_at: string;
hostname: string;
features: components["schemas"]["AgentFeatures"];
/** @description Up to 32 labels. Key max 64, value max 256. Monlet agents reserve `monlet_agent_version`. Secret-like keys are rejected. */
labels?: {
[key: string]: string;
};
};
AgentFeatures: {
/** @description Agent pushes heartbeats/events to the server. */
push: boolean;
/** @description Agent exposes a Prometheus-compatible metrics endpoint. */
metrics: boolean;
};
EventBatchRequest: {
agent_id: string;
events: components["schemas"]["CheckResultEvent"][];
};
/**
* @description Event body used inside `EventBatchRequest.events`. The owning `agent_id`
* is supplied once at the batch envelope; per-event `agent_id` is intentionally
* absent so requests cannot mix or spoof other agents under a shared token.
* Server response endpoints (`GET /events/query`) include `agent_id` separately
* in their item schema.
*/
CheckResultEvent: {
/** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */
event_id: string;
check_id: string;
/** Format: date-time */
observed_at: string;
/** @enum {string} */
status: "ok" | "warning" | "critical" | "unknown";
exit_code: number;
duration_ms: number;
/**
* @description Limit is 8 KiB measured in UTF-8 **bytes** (not characters). Agent truncates
* on a UTF-8 boundary before send and appends marker `...[truncated N bytes]`.
* `maxLength: 8192` here is a coarse schema-level upper bound; servers MUST
* additionally reject events whose UTF-8 byte length exceeds 8192 with
* `400 validation`.
*/
output?: string;
/** @default false */
output_truncated: boolean;
/**
* @description If false, the server stores state/incidents but does not enqueue notifications for this event.
* @default true
*/
notifications_enabled: boolean;
incident_key?: string;
};
/** @description Stored event returned by `GET /events/query`. Includes the resolved `agent_id` and server-side `received_at`. */
StoredCheckResultEvent: components["schemas"]["CheckResultEvent"] & {
agent_id: string;
/** Format: date-time */
received_at: string;
};
Agent: {
agent_id: string;
hostname: string;
/** @enum {string} */
status: "alive" | "stale" | "dead";
/** Format: date-time */
last_seen_at: string;
features: components["schemas"]["AgentFeatures"];
labels?: {
[key: string]: string;
};
};
CheckState: {
agent_id: string;
check_id: string;
/** @enum {string} */
status: "ok" | "warning" | "critical" | "unknown";
/** Format: date-time */
last_observed_at: string;
exit_code?: number;
incident_key?: string;
};
Incident: {
id: string;
incident_key: string;
/** @enum {string} */
state: "open" | "resolved";
/** @enum {string} */
severity: "warning" | "critical" | "unknown";
agent_id?: string;
check_id?: string;
/** Format: date-time */
opened_at: string;
/** Format: date-time */
resolved_at?: string | null;
summary?: string;
};
NotificationOutboxItem: {
id: string;
notifier: string;
/** @enum {string} */
state: "pending" | "sending" | "sent" | "retry" | "failed" | "discarded";
incident_id: string;
/** @enum {string} */
event_type: "firing" | "resolved";
attempts: number;
/** Format: date-time */
next_attempt_at?: string | null;
last_error?: string | null;
/** Format: date-time */
updated_at?: string;
};
};
responses: {
/** @description Authentication failed. */
Unauthorized: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Resource not found. */
NotFound: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Payload exceeds configured limit (1 MiB). */
PayloadTooLarge: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Request body or parameters failed validation. */
ValidationError: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Unexpected server error. */
InternalError: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
parameters: {
AgentId: string;
/** @description Opaque pagination cursor returned in `next_cursor`. */
Cursor: string;
Limit: number;
};
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
getHealth: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service is alive. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HealthResponse"];
};
};
};
};
getReady: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service is ready. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HealthResponse"];
};
};
/** @description Service is not ready. */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
ingestHeartbeat: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["HeartbeatRequest"];
};
};
responses: {
/** @description Heartbeat accepted. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AcceptedResponse"];
};
};
400: components["responses"]["ValidationError"];
401: components["responses"]["Unauthorized"];
413: components["responses"]["PayloadTooLarge"];
500: components["responses"]["InternalError"];
};
};
ingestEvents: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EventBatchRequest"];
};
};
responses: {
/** @description Events accepted. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EventBatchAcceptedResponse"];
};
};
400: components["responses"]["ValidationError"];
401: components["responses"]["Unauthorized"];
413: components["responses"]["PayloadTooLarge"];
500: components["responses"]["InternalError"];
};
};
listAgents: {
parameters: {
query?: {
/** @description Opaque pagination cursor returned in `next_cursor`. */
cursor?: components["parameters"]["Cursor"];
limit?: components["parameters"]["Limit"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Agent list. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageEnvelope"] & {
items: components["schemas"]["Agent"][];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
getAgent: {
parameters: {
query?: never;
header?: never;
path: {
agent_id: components["parameters"]["AgentId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Agent detail. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Agent"];
};
};
401: components["responses"]["Unauthorized"];
404: components["responses"]["NotFound"];
};
};
listChecks: {
parameters: {
query?: {
agent_id?: string;
/** @description Opaque pagination cursor returned in `next_cursor`. */
cursor?: components["parameters"]["Cursor"];
limit?: components["parameters"]["Limit"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Check state list. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageEnvelope"] & {
items: components["schemas"]["CheckState"][];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
listIncidents: {
parameters: {
query?: {
state?: "open" | "resolved";
/** @description Opaque pagination cursor returned in `next_cursor`. */
cursor?: components["parameters"]["Cursor"];
limit?: components["parameters"]["Limit"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Incident list. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageEnvelope"] & {
items: components["schemas"]["Incident"][];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
queryEvents: {
parameters: {
query?: {
agent_id?: string;
check_id?: string;
/** @description Opaque pagination cursor returned in `next_cursor`. */
cursor?: components["parameters"]["Cursor"];
limit?: components["parameters"]["Limit"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Event list. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageEnvelope"] & {
items: components["schemas"]["StoredCheckResultEvent"][];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
listOutbox: {
parameters: {
query?: {
/** @description Opaque pagination cursor returned in `next_cursor`. */
cursor?: components["parameters"]["Cursor"];
limit?: components["parameters"]["Limit"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Outbox list. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageEnvelope"] & {
items: components["schemas"]["NotificationOutboxItem"][];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
}

67
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,67 @@
import "server-only";
import type { components, operations } from "./api-types";
export type Agent = components["schemas"]["Agent"];
export type CheckState = components["schemas"]["CheckState"];
export type Incident = components["schemas"]["Incident"];
export type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
export type OutboxItem = components["schemas"]["NotificationOutboxItem"];
type OkBody<O> = O extends { responses: { 200: { content: { "application/json": infer B } } } }
? B
: never;
type QueryOf<O> = O extends { parameters: { query?: infer Q } } ? NonNullable<Q> : never;
type AgentsBody = OkBody<operations["listAgents"]>;
type ChecksBody = OkBody<operations["listChecks"]>;
type IncidentsBody = OkBody<operations["listIncidents"]>;
type EventsBody = OkBody<operations["queryEvents"]>;
type OutboxBody = OkBody<operations["listOutbox"]>;
const BASE = process.env.MONLET_API_BASE_URL ?? "http://127.0.0.1:8000";
const TOKEN = process.env.MONLET_API_TOKEN ?? "";
export class ApiError extends Error {
constructor(public status: number, public code: string, message: string) {
super(message);
}
}
async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
const url = new URL(path, BASE);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
}
}
const headers: Record<string, string> = { Accept: "application/json" };
if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
const res = await fetch(url, { headers, cache: "no-store" });
if (!res.ok) {
let code = "http_error";
let message = res.statusText;
try {
const body = await res.json();
code = body?.error?.code ?? code;
message = body?.error?.message ?? message;
} catch {}
throw new ApiError(res.status, code, message);
}
return res.json() as Promise<T>;
}
export const api = {
listAgents: (q: QueryOf<operations["listAgents"]> = {}) =>
get<AgentsBody>("/api/v1/agents", q),
getAgent: (agentId: string) => get<Agent>(`/api/v1/agents/${encodeURIComponent(agentId)}`),
listChecks: (q: QueryOf<operations["listChecks"]> = {}) =>
get<ChecksBody>("/api/v1/checks", q),
listIncidents: (q: QueryOf<operations["listIncidents"]> = {}) =>
get<IncidentsBody>("/api/v1/incidents", q),
queryEvents: (q: QueryOf<operations["queryEvents"]> = {}) =>
get<EventsBody>("/api/v1/events/query", q),
listOutbox: (q: QueryOf<operations["listOutbox"]> = {}) =>
get<OutboxBody>("/api/v1/notifiers/outbox", q),
health: () => get<{ status: string }>("/api/v1/health"),
};

28
web/src/lib/format.ts Normal file
View File

@@ -0,0 +1,28 @@
export function fmtDate(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toISOString().replace("T", " ").replace(/\..+$/, "Z");
}
export function statusBadgeClass(s: string): string {
switch (s) {
case "ok":
case "alive":
case "resolved":
case "sent":
return "text-emerald-400";
case "warning":
case "stale":
case "retry":
return "text-amber-400";
case "critical":
case "dead":
case "open":
case "failed":
return "text-red-400";
case "discarded":
return "text-neutral-500";
default:
return "text-neutral-300";
}
}