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>
95 lines
4 KiB
TypeScript
95 lines
4 KiB
TypeScript
import type { MonitoringView } from "@batch-cooking/shared";
|
|
import type { Server as SocketIoServer } from "socket.io";
|
|
import { requireAdminSocket } from "../middlewares/require-admin-socket.js";
|
|
import { getMonitoring } from "../modules/admin/admin-monitoring.service.js";
|
|
import { logger } from "./logger.service.js";
|
|
|
|
/** How often the server re-probes every dependency, in ms — see `MonitoringBroadcastService`. */
|
|
const PROBE_INTERVAL_MS = 60_000;
|
|
|
|
/** Every authenticated admin socket joins this one room — a single probe loop serves every open tab, instead of each polling independently. */
|
|
const MONITORING_ROOM = "monitoring";
|
|
|
|
/** Event name every `monitoring:update` push carries — the sole message this service ever emits. */
|
|
const UPDATE_EVENT = "monitoring:update";
|
|
|
|
/**
|
|
* Pushes the admin monitoring status board to every connected admin over
|
|
* WebSocket, replacing the old `GET /admin/api/monitoring` polling
|
|
* endpoint entirely (removed — see `admin.routes.ts`'s doc comment): one
|
|
* server-side probe loop, shared by every viewer, instead of each browser
|
|
* tab re-probing Postgres/the API/the intent-service/the LLM worker on its
|
|
* own timer.
|
|
*
|
|
* Owns the *whole* `socket.io` server passed to it (`io.use`, top-level
|
|
* `"connection"` handler) — fine while this is the server's only
|
|
* real-time feature; a second one added later should likely get its own
|
|
* namespace rather than share this room's connection handler.
|
|
*/
|
|
export class MonitoringBroadcastService {
|
|
private readonly _io: SocketIoServer;
|
|
/**
|
|
* Most recent probe result — replayed to a newly-joined admin
|
|
* immediately (see the `"connection"` handler below), so a freshly
|
|
* opened tab isn't left blank for up to a minute waiting for the next
|
|
* scheduled tick. `null` until the very first probe (kicked off by
|
|
* {@link start}) completes.
|
|
*/
|
|
private _lastSnapshot: MonitoringView | null = null;
|
|
/** Handle for the running probe loop, or `null` before {@link start}/after {@link stop}. */
|
|
private _intervalHandle: NodeJS.Timeout | null = null;
|
|
|
|
public constructor(io: SocketIoServer) {
|
|
this._io = io;
|
|
this._io.use(requireAdminSocket);
|
|
this._io.on("connection", (socket) => {
|
|
void socket.join(MONITORING_ROOM);
|
|
if (this._lastSnapshot) {
|
|
socket.emit(UPDATE_EVENT, this._lastSnapshot);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Starts the probe loop: one immediate probe (so the very first admin
|
|
* to connect after a server restart doesn't wait a full minute), then
|
|
* every {@link PROBE_INTERVAL_MS}. Call once, at server boot
|
|
* (`server.ts`) — meant to run for the lifetime of the process, same
|
|
* posture as the tech-step worker's own cron (`server.ts` never calls
|
|
* {@link stop} today; the process exiting takes the interval with it).
|
|
*/
|
|
public start(): void {
|
|
void this._tick();
|
|
this._intervalHandle = setInterval(() => void this._tick(), PROBE_INTERVAL_MS);
|
|
}
|
|
|
|
/**
|
|
* Stops the probe loop. Nothing in production calls this today — it
|
|
* exists so a graceful-shutdown hook could use it later, and so tests
|
|
* can tear an instance down cleanly instead of leaking a live
|
|
* `setInterval` past their own lifetime (which would otherwise hold the
|
|
* whole Mocha process open).
|
|
*/
|
|
public stop(): void {
|
|
if (this._intervalHandle) {
|
|
clearInterval(this._intervalHandle);
|
|
this._intervalHandle = null;
|
|
}
|
|
}
|
|
|
|
/** Probes every dependency, caches the result, and broadcasts it to {@link MONITORING_ROOM}. */
|
|
private async _tick(): Promise<void> {
|
|
try {
|
|
this._lastSnapshot = await getMonitoring();
|
|
this._io.to(MONITORING_ROOM).emit(UPDATE_EVENT, this._lastSnapshot);
|
|
} catch (err) {
|
|
// getMonitoring() itself never rejects — every individual probe is
|
|
// already bounded/caught (see its own doc comment) — so this is
|
|
// purely defensive, per the repo's await-in-try/catch rule; not
|
|
// expected to actually fire.
|
|
logger.error("Monitoring probe tick failed unexpectedly", {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
}
|