Le tableau de bord /admin/monitoring fonctionnait en polling côté client (15 s) déclenchant une sonde active côté serveur à chaque appel — deux onglets admin ouverts = deux séries de sondes redondantes. Inverse le mécanisme : le serveur sonde une fois par minute et pousse le résultat aux clients connectés via WebSocket (socket.io, room "monitoring"). Décisions actées avec l'utilisateur : l'endpoint REST disparaît entièrement (plus de repli, 100% WebSocket) ; la sonde tourne en continu dès le démarrage, sans lien avec la présence d'un admin connecté (le plus simple des deux, coût des sondes négligeable). Backend : - ExpressServer (packages/express-tools) expose désormais son http.Server sous-jacent (créé explicitement, plus dans .listen()) — socket.io s'attache au serveur HTTP lui-même, pas à Express/un port. - MonitoringBroadcastService (nouveau) : sonde immédiate au démarrage puis toutes les 60 s (réutilise getMonitoring() tel quel, aucune duplication), diffuse à la room "monitoring", rejoue le dernier résultat en cache à tout nouvel admin qui rejoint (pas d'attente jusqu'à une minute pour un onglet fraîchement ouvert). - requireAdminSocket (nouveau) : middleware de handshake socket.io, équivalent WebSocket de requireAdmin — même vérification (cookie + JWT + tokenVersion), factorisée dans resolveAdminFromToken (require-admin.ts) puisque les deux ne diffèrent que par où ils lisent le cookie brut (un handshake socket.io ne passe pas par cookie-parser). - GET /admin/api/monitoring supprimée (route + service de test associés). - server.ts (pas app.ts) instancie socket.io — les tests supertest n'ont pas besoin d'un serveur WebSocket. Frontend : - MonitoringSocketClient (nouveau, api/monitoring-socket.ts) : wrapper socket.io-client, interface MonitoringSocketClientLike volontairement petite et injectable dans MonitoringPage (prop optionnelle) pour rester testable sans serveur réel. - MonitoringPage : data et connectionState séparés — une coupure de connexion après un premier chargement affiche un badge "reconnexion en cours" sans jamais effacer le dernier board connu (repose sur la reconnexion automatique de socket.io-client). Seul le tout premier chargement affiche l'état plein-écran chargement/erreur. - adminApiClient.getMonitoring() supprimée (plus utilisée). Tests : - Nouveau apps/api/test/admin-monitoring-socket.test.ts : vraie connexion socket.io-client contre un serveur réel démarré dans le test (port 0), authentification avec un admin seedé + cookie signé, vérifie la réception de monitoring:update et le rejet sans cookie admin valide. Les tests heartbeat de l'ancien admin-monitoring.test.ts (route /internal/tech-steps/heartbeat, sans rapport avec ce changement) sont déplacés vers tech-step-worker.routes.test.ts, leur router d'origine. - Nouveau cypress/component/MonitoringPage.cy.tsx : monte la page avec un client factice, vérifie rendu des cartes, badge de reconnexion sans perte du board, états chargement/erreur — pas de test e2e Gherkin contre un vrai WebSocket (aucun mécanisme pour ça dans ce repo, fragile ; le test Mocha ci-dessus est la preuve bout-en-bout serveur). - apps/web/cypress/e2e/admin-monitoring.cy.ts (REST) supprimé, remplacé par le test composant ci-dessus. Vérification : pnpm lint clean, build complet du monorepo clean, suite Cypress e2e complète 27/27 fichiers (114/114 tests), suite composant complète 5/5 fichiers (54/54 tests, dont les 4 nouveaux). Suite Mocha apps/api non exécutable cette session (Postgres/Docker injoignable, comme pour #33) — le nouveau test socket.io n'a donc pas pu être run-vérifié, seulement relu attentivement et vérifié par le lint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
140 lines
4.9 KiB
TypeScript
140 lines
4.9 KiB
TypeScript
import type { MonitoringView } from "@batch-cooking/shared";
|
|
import "../../src/i18n/i18n";
|
|
import type {
|
|
MonitoringConnectionState,
|
|
MonitoringSocketClientLike,
|
|
} from "../../src/api/monitoring-socket";
|
|
import { MonitoringPage } from "../../src/pages/admin/monitoring/MonitoringPage";
|
|
|
|
// MonitoringPage takes its WebSocket client as a prop specifically so it
|
|
// can be driven like this — no real socket.io server involved. See that
|
|
// prop's own doc comment in MonitoringPage.tsx for why.
|
|
class FakeMonitoringSocketClient implements MonitoringSocketClientLike {
|
|
public connectCallCount = 0;
|
|
public disconnectCallCount = 0;
|
|
private readonly _updateListeners = new Set<(data: MonitoringView) => void>();
|
|
private readonly _stateListeners = new Set<(state: MonitoringConnectionState) => void>();
|
|
|
|
public connect(): void {
|
|
this.connectCallCount += 1;
|
|
}
|
|
|
|
public disconnect(): void {
|
|
this.disconnectCallCount += 1;
|
|
}
|
|
|
|
public onUpdate(listener: (data: MonitoringView) => void): () => void {
|
|
this._updateListeners.add(listener);
|
|
return () => this._updateListeners.delete(listener);
|
|
}
|
|
|
|
public onConnectionStateChange(listener: (state: MonitoringConnectionState) => void): () => void {
|
|
this._stateListeners.add(listener);
|
|
return () => this._stateListeners.delete(listener);
|
|
}
|
|
|
|
/** Test-only: simulates the server pushing a snapshot. */
|
|
public emitUpdate(data: MonitoringView): void {
|
|
for (const listener of this._updateListeners) listener(data);
|
|
}
|
|
|
|
/** Test-only: simulates a connection-state change. */
|
|
public emitState(state: MonitoringConnectionState): void {
|
|
for (const listener of this._stateListeners) listener(state);
|
|
}
|
|
}
|
|
|
|
function monitoringFixture(): MonitoringView {
|
|
return {
|
|
generatedAt: "2026-09-05T09:15:00.000Z",
|
|
services: [
|
|
{
|
|
key: "postgres",
|
|
status: "up",
|
|
latencyMs: 3.2,
|
|
detail: null,
|
|
checkedAt: "2026-09-05T09:15:00.000Z",
|
|
},
|
|
{
|
|
key: "api",
|
|
status: "up",
|
|
latencyMs: 0,
|
|
detail: "uptime 3 h · RSS 120 Mo",
|
|
checkedAt: "2026-09-05T09:15:00.000Z",
|
|
},
|
|
{
|
|
key: "intent-service",
|
|
status: "down",
|
|
latencyMs: null,
|
|
detail: "fetch failed",
|
|
checkedAt: "2026-09-05T09:15:00.000Z",
|
|
},
|
|
{
|
|
key: "tech-step-llm-worker",
|
|
status: "degraded",
|
|
latencyMs: null,
|
|
detail: "dernier battement il y a 9 j",
|
|
checkedAt: "2026-09-05T09:15:00.000Z",
|
|
lastRunAt: "2026-08-19T03:00:00.000Z",
|
|
lastResult: { job: "audit-low-confidence", ok: true, counts: { suggestions: 2 } },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("MonitoringPage", () => {
|
|
it("shows a loading placeholder before the first update arrives, and connects on mount", () => {
|
|
const client = new FakeMonitoringSocketClient();
|
|
cy.mount(<MonitoringPage socketClient={client} />);
|
|
|
|
cy.contains("Chargement").should("be.visible");
|
|
cy.get(".monitoring-grid").should("not.exist");
|
|
// `.should(...)` (not a bare `cy.then` + chai `expect`) so Cypress
|
|
// retries: `connect()` is called from a `useEffect`, which commits
|
|
// asynchronously after `cy.mount` itself has already resolved.
|
|
cy.wrap(client).its("connectCallCount").should("equal", 1);
|
|
});
|
|
|
|
it("renders one card per service once a snapshot arrives", () => {
|
|
const client = new FakeMonitoringSocketClient();
|
|
cy.mount(<MonitoringPage socketClient={client} />);
|
|
cy.then(() => {
|
|
client.emitState("connected");
|
|
client.emitUpdate(monitoringFixture());
|
|
});
|
|
|
|
cy.get(".monitoring-card").should("have.length", 4);
|
|
cy.contains(".monitoring-card", "Base de données").should("have.class", "monitoring-card--up");
|
|
cy.contains(".monitoring-card", "Service NLP (spaCy)").should(
|
|
"have.class",
|
|
"monitoring-card--down",
|
|
);
|
|
cy.get(".monitoring-connection-badge").should("not.exist");
|
|
});
|
|
|
|
it("shows a reconnecting badge without hiding the last known board", () => {
|
|
const client = new FakeMonitoringSocketClient();
|
|
cy.mount(<MonitoringPage socketClient={client} />);
|
|
cy.then(() => {
|
|
client.emitState("connected");
|
|
client.emitUpdate(monitoringFixture());
|
|
});
|
|
cy.get(".monitoring-card").should("have.length", 4);
|
|
|
|
cy.then(() => client.emitState("reconnecting"));
|
|
|
|
cy.get(".monitoring-connection-badge--reconnecting").should("be.visible");
|
|
// The board itself is still the one from before the drop — never
|
|
// replaced by a loading state just because the connection blipped.
|
|
cy.get(".monitoring-card").should("have.length", 4);
|
|
});
|
|
|
|
it("shows an error placeholder when the connection fails before any data ever arrived", () => {
|
|
const client = new FakeMonitoringSocketClient();
|
|
cy.mount(<MonitoringPage socketClient={client} />);
|
|
cy.then(() => client.emitState("error"));
|
|
|
|
cy.contains("Impossible de charger").should("be.visible");
|
|
cy.get(".monitoring-grid").should("not.exist");
|
|
});
|
|
});
|