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>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { adminApiClient } from "../../api/client";
|
|
import "../admin-page.scss";
|
|
import "./monitoring-page.scss";
|
|
import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring";
|
|
|
|
type MonitoringState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; data: MonitoringView }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* Microservice health board. Fetches `GET /admin/monitoring` on mount and
|
|
* re-polls every {@link POLL_INTERVAL_MS} ms. One card per probed target
|
|
* (Postgres, API, intent-service, LLM worker), coloured by status.
|
|
*/
|
|
export function MonitoringPage() {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<MonitoringState>({ status: "loading" });
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
function load() {
|
|
adminApiClient
|
|
.getMonitoring()
|
|
.then((data) => {
|
|
if (!cancelled) setState({ status: "loaded", data });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled)
|
|
setState((prev) => (prev.status === "loaded" ? prev : { status: "error" }));
|
|
});
|
|
}
|
|
|
|
load();
|
|
const timer = setInterval(load, POLL_INTERVAL_MS);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(timer);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="admin-page">
|
|
<h1 className="admin-page__title">{t("admin.monitoring.title")}</h1>
|
|
<p className="admin-page__lead">{t("admin.monitoring.lead")}</p>
|
|
|
|
{state.status === "loading" && (
|
|
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
)}
|
|
{state.status === "error" && (
|
|
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
)}
|
|
{state.status === "loaded" && (
|
|
<>
|
|
<p className="monitoring-checked">
|
|
{t("admin.monitoring.lastChecked", { time: clockTime(state.data.generatedAt) })}
|
|
</p>
|
|
<div className="monitoring-grid">
|
|
{state.data.services.map((service) => (
|
|
<ServiceCard key={service.key} service={service} />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServiceCard({ service }: { service: ServiceHealthView }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<article className={`monitoring-card monitoring-card--${statusModifier(service.status)}`}>
|
|
<header className="monitoring-card__head">
|
|
<span className="monitoring-card__dot" aria-hidden="true" />
|
|
<h2>{t(`admin.monitoring.service.${service.key}`, { defaultValue: service.key })}</h2>
|
|
<span className="monitoring-card__status">
|
|
{t(`admin.monitoring.status.${service.status}`)}
|
|
</span>
|
|
</header>
|
|
<dl className="monitoring-card__meta">
|
|
{service.latencyMs !== null && (
|
|
<div>
|
|
<dt>{t("admin.monitoring.latency")}</dt>
|
|
<dd>{service.latencyMs} ms</dd>
|
|
</div>
|
|
)}
|
|
{service.detail && (
|
|
<div>
|
|
<dt>{t("admin.monitoring.detail")}</dt>
|
|
<dd>{service.detail}</dd>
|
|
</div>
|
|
)}
|
|
{service.lastRunAt !== undefined && (
|
|
<div>
|
|
<dt>{t("admin.monitoring.lastRun")}</dt>
|
|
<dd>
|
|
{service.lastRunAt ? clockTime(service.lastRunAt) : t("admin.monitoring.never")}
|
|
{service.lastResult?.job ? ` · ${service.lastResult.job}` : ""}
|
|
{service.lastResult?.ok === false ? ` · ${t("admin.monitoring.jobFailed")}` : ""}
|
|
</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
</article>
|
|
);
|
|
}
|