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>
68 lines
2.9 KiB
TypeScript
68 lines
2.9 KiB
TypeScript
import { Server as SocketIoServer } from "socket.io";
|
|
import { createServer } from "./app.js";
|
|
import { env } from "./config/env.js";
|
|
import { logger } from "./lib/logger.service.js";
|
|
import { MonitoringBroadcastService } from "./lib/monitoring-broadcast.service.js";
|
|
import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js";
|
|
import { registerAllRecipeSources } from "./sources/index.js";
|
|
|
|
// Populates the recipe-source registry (recipe-source-registry.ts) before
|
|
// the app starts — see registerAllRecipeSources' doc comment for why this
|
|
// doesn't happen inside app.ts/createServer() itself.
|
|
registerAllRecipeSources();
|
|
|
|
/**
|
|
* Trains the tech-step classifier (a `POST /v1/train` round-trip per locale
|
|
* to `services/tech-step-intent-service` — see
|
|
* `TechStepClassifierService.warmUp`) before accepting any traffic, so the
|
|
* first real recipe save/preview isn't the one stuck waiting for it.
|
|
*
|
|
* Retried with exponential backoff: in Docker Compose, `app`'s own
|
|
* `depends_on: tech-step-intent-service: condition: service_healthy`
|
|
* (`docker-compose.yml`) already means that service is up by the time this
|
|
* runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can
|
|
* easily start this before the intent service has finished loading its
|
|
* spaCy models — a transient connection failure here shouldn't need a
|
|
* manual restart. Still non-fatal after every attempt is exhausted: the
|
|
* *next* real call retries training itself (see `_ensureTrained`'s own
|
|
* retry-on-failure comment), same graceful-degrade posture as before this
|
|
* retry loop existed.
|
|
*/
|
|
async function warmUpTechStepClassifier(): Promise<void> {
|
|
const maxAttempts = 5;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
try {
|
|
await techStepClassifier.warmUp();
|
|
return;
|
|
} catch (err) {
|
|
if (attempt === maxAttempts) {
|
|
logger.error("Tech-step classifier warm-up failed after retries", {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
attempts: attempt,
|
|
});
|
|
return;
|
|
}
|
|
const delayMs = 1000 * 2 ** (attempt - 1);
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
}
|
|
|
|
await warmUpTechStepClassifier();
|
|
|
|
const server = createServer();
|
|
|
|
// Attaches to the same underlying `http.Server` `server.listen()` below
|
|
// will start — `socket.io` binds to the HTTP server itself, not to
|
|
// Express or a port, hence `ExpressServer.httpServer` (packages/express-
|
|
// tools) rather than going through `server.instance` (the Express app).
|
|
// Own CORS config: the WebSocket handshake is a separate HTTP upgrade
|
|
// request that never runs through `setupCore`'s `cors()` middleware.
|
|
const io = new SocketIoServer(server.httpServer, {
|
|
cors: { origin: env.CORS_ORIGIN, credentials: true },
|
|
});
|
|
new MonitoringBroadcastService(io).start();
|
|
|
|
server.listen(env.PORT, () => {
|
|
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
|
|
});
|