L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.
Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
(les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
un ref requestSeq.
Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.
Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
`app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.
Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.
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/admin-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>
|
|
);
|
|
}
|