PR 4 du chantier admin. Board de sante temps reel des dependances.
- POST /internal/tech-steps/heartbeat (requireInternalWorker) ->
recordWorkerHeartbeat : upsert WorkerHeartbeat (cle fixe
"tech-step-llm-worker"), lastRunAt/lastResult pour un ping "job".
Schema workerHeartbeatSchema dans packages/shared.
- services/tech-step-llm-worker : api-client.postHeartbeat (best-effort,
ne throw jamais) appele au boot (index.ts), a chaque tick et apres
chaque job (scheduler.ts, avec job/ok/counts).
- admin-monitoring.service.ts + GET /admin/monitoring (requireAdmin) :
sonde active bornee (~2 s) de Postgres (SELECT 1), l'API (uptime/RSS),
tech-step-intent-service (/health), et le worker via son heartbeat.
Statut up/degraded/down/unknown ; une sonde down ne casse ni les autres
ni l'endpoint. Seuils worker : > 8 j degraded, > 21 j down.
- MonitoringView / ServiceHealthView dans packages/shared.
- Front : MonitoringPage (grille de cartes coloree par statut, re-poll
15 s), logique pure monitoring.ts, i18n admin.monitoring.*,
AdminApiClient.getMonitoring.
- Tests : Mocha admin-monitoring.test.ts (heartbeat 401/400/upsert
job+boot ; GET /admin/monitoring 401, board 4 cibles, worker unknown
sans heartbeat puis up apres) ; Cypress monitoring.cy.ts (2 verts).
Worker mocha : 6/6 toujours verts.
- specs/backend-architecture.md : section monitoring. .gitignore :
apps/admin-web/cypress/{screenshots,videos,downloads}.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
5.5 KiB
TypeScript
174 lines
5.5 KiB
TypeScript
import type { MonitoringView, ServiceHealthView, ServiceStatus } from "@batch-cooking/shared";
|
|
import { env } from "../../config/env.js";
|
|
import { prisma } from "../../db/prisma.js";
|
|
|
|
/** How long each outbound probe (Postgres query, intent-service HTTP) is allowed to take before it counts as `down`. */
|
|
const PROBE_TIMEOUT_MS = 2000;
|
|
|
|
/**
|
|
* Heartbeat-age thresholds for the LLM worker. Its default cron is weekly
|
|
* (`TECH_STEP_WORKER_CRON`, `0 3 * * 0`), and it also pings on boot/tick —
|
|
* so no ping for **8 days** means it likely missed its last scheduled fire
|
|
* (`degraded`), and none for **3 weeks** means it's almost certainly not
|
|
* running at all (`down`).
|
|
*/
|
|
const WORKER_STALE_AFTER_MS = 8 * 24 * 60 * 60 * 1000;
|
|
const WORKER_DOWN_AFTER_MS = 21 * 24 * 60 * 60 * 1000;
|
|
|
|
const WORKER_KEY = "tech-step-llm-worker";
|
|
|
|
function nowIso(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function roundMs(value: number): number {
|
|
return Math.round(value * 10) / 10;
|
|
}
|
|
|
|
function errMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
/** `12500` → `"il y a 12 s"`, `90000` → `"il y a 1 min"`, `172800000` → `"il y a 2 j"`. */
|
|
function formatAgo(ms: number): string {
|
|
const s = Math.round(ms / 1000);
|
|
if (s < 60) return `il y a ${s} s`;
|
|
const m = Math.round(s / 60);
|
|
if (m < 60) return `il y a ${m} min`;
|
|
const h = Math.round(m / 60);
|
|
if (h < 48) return `il y a ${h} h`;
|
|
return `il y a ${Math.round(h / 24)} j`;
|
|
}
|
|
|
|
/** `process.uptime()` seconds → `"3 h 12 min"` / `"5 min"` / `"42 s"`. */
|
|
function formatUptime(seconds: number): string {
|
|
const s = Math.floor(seconds);
|
|
if (s < 60) return `${s} s`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m} min`;
|
|
const h = Math.floor(m / 60);
|
|
return `${h} h ${m % 60} min`;
|
|
}
|
|
|
|
/** `prisma.$queryRaw\`SELECT 1\`` with a bounded timeout — the DB connectivity probe. */
|
|
async function probePostgres(): Promise<ServiceHealthView> {
|
|
const start = performance.now();
|
|
try {
|
|
// `$queryRaw` doesn't take an AbortSignal — bound it with a race instead.
|
|
await Promise.race([
|
|
prisma.$queryRaw`SELECT 1`,
|
|
new Promise((_resolve, reject) =>
|
|
setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS),
|
|
),
|
|
]);
|
|
return {
|
|
key: "postgres",
|
|
status: "up",
|
|
latencyMs: roundMs(performance.now() - start),
|
|
detail: null,
|
|
checkedAt: nowIso(),
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
key: "postgres",
|
|
status: "down",
|
|
latencyMs: null,
|
|
detail: errMessage(err),
|
|
checkedAt: nowIso(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/** The API itself — trivially "up" (it's answering), reported with its process uptime/memory. */
|
|
function probeApi(): ServiceHealthView {
|
|
const mem = process.memoryUsage();
|
|
return {
|
|
key: "api",
|
|
status: "up",
|
|
latencyMs: 0,
|
|
detail: `uptime ${formatUptime(process.uptime())} · RSS ${Math.round(mem.rss / 1_000_000)} Mo`,
|
|
checkedAt: nowIso(),
|
|
};
|
|
}
|
|
|
|
/** `GET {INTENT_SERVICE_BASE_URL}/health` — no secret needed on that route (see the service's `routes/health.py`). */
|
|
async function probeIntentService(): Promise<ServiceHealthView> {
|
|
const start = performance.now();
|
|
try {
|
|
const res = await fetch(`${env.INTENT_SERVICE_BASE_URL}/health`, {
|
|
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
});
|
|
const latencyMs = roundMs(performance.now() - start);
|
|
return {
|
|
key: "intent-service",
|
|
status: res.ok ? "up" : "degraded",
|
|
latencyMs,
|
|
detail: `HTTP ${res.status}`,
|
|
checkedAt: nowIso(),
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
key: "intent-service",
|
|
status: "down",
|
|
latencyMs: null,
|
|
detail: errMessage(err),
|
|
checkedAt: nowIso(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Reads the LLM worker's stored `WorkerHeartbeat` (it has no HTTP surface to probe directly) and grades it by age + last job outcome. */
|
|
async function probeWorker(): Promise<ServiceHealthView> {
|
|
const heartbeat = await prisma.workerHeartbeat.findUnique({ where: { workerKey: WORKER_KEY } });
|
|
if (!heartbeat) {
|
|
return {
|
|
key: WORKER_KEY,
|
|
status: "unknown",
|
|
latencyMs: null,
|
|
detail: "aucun battement reçu",
|
|
checkedAt: nowIso(),
|
|
lastRunAt: null,
|
|
lastResult: null,
|
|
};
|
|
}
|
|
|
|
const ageMs = Date.now() - heartbeat.lastSeenAt.getTime();
|
|
const lastResult = (heartbeat.lastResult ?? null) as ServiceHealthView["lastResult"];
|
|
|
|
let status: ServiceStatus = "up";
|
|
if (ageMs > WORKER_DOWN_AFTER_MS) status = "down";
|
|
else if (ageMs > WORKER_STALE_AFTER_MS || lastResult?.ok === false) status = "degraded";
|
|
|
|
return {
|
|
key: WORKER_KEY,
|
|
status,
|
|
latencyMs: null,
|
|
detail: `dernier battement ${formatAgo(ageMs)}`,
|
|
checkedAt: nowIso(),
|
|
lastRunAt: heartbeat.lastRunAt?.toISOString() ?? null,
|
|
lastResult,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Actively probes every dependency the admin monitoring board watches —
|
|
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
|
|
* LLM worker (via its stored heartbeat). Each probe is independent and
|
|
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
|
|
* others or the endpoint.
|
|
*/
|
|
export async function getMonitoring(): Promise<MonitoringView> {
|
|
try {
|
|
const [postgres, intentService, worker] = await Promise.all([
|
|
probePostgres(),
|
|
probeIntentService(),
|
|
probeWorker(),
|
|
]);
|
|
return {
|
|
generatedAt: nowIso(),
|
|
services: [postgres, probeApi(), intentService, worker],
|
|
};
|
|
} catch (err) {
|
|
throw err; // see recipe.service.ts's equivalent catch comment
|
|
}
|
|
}
|