diff --git a/apps/api/package.json b/apps/api/package.json index 8ccf511..9731811 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,10 +23,12 @@ "@batch-cooking/shared": "workspace:*", "@prisma/client": "^5.22.0", "argon2": "0.31.2", + "cookie": "^2.0.1", "dotenv": "^16.4.5", "express": "^4.21.1", "jsonwebtoken": "^9.0.3", "prisma": "^5.22.0", + "socket.io": "^4.8.3", "zod": "^3.23.8" }, "devDependencies": { @@ -38,6 +40,7 @@ "chai": "^5.1.2", "cross-env": "^10.1.0", "mocha": "^10.8.2", + "socket.io-client": "^4.8.3", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.2" diff --git a/apps/api/src/lib/monitoring-broadcast.service.ts b/apps/api/src/lib/monitoring-broadcast.service.ts new file mode 100644 index 0000000..79aec89 --- /dev/null +++ b/apps/api/src/lib/monitoring-broadcast.service.ts @@ -0,0 +1,95 @@ +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 { + 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), + }); + } + } +} diff --git a/apps/api/src/middlewares/require-admin-socket.ts b/apps/api/src/middlewares/require-admin-socket.ts new file mode 100644 index 0000000..fff0a16 --- /dev/null +++ b/apps/api/src/middlewares/require-admin-socket.ts @@ -0,0 +1,43 @@ +import { parseCookie } from "cookie"; +import type { Socket } from "socket.io"; +import { env } from "../config/env.js"; +import { resolveAdminFromToken } from "./require-admin.js"; + +/** + * `socket.io` handshake middleware guarding the admin WebSocket surface — + * the connection-level equivalent of {@link requireAdmin} (`require- + * admin.ts`). A `socket.io` handshake is a plain HTTP request that never + * runs through Express's own middleware stack (`cookie-parser` included), + * so the raw `Cookie` header has to be parsed by hand here with the + * `cookie` package (the same one `cookie-parser` itself wraps). + * + * Registered via `io.use(requireAdminSocket)` — `socket.io` calls every + * `io.use` middleware once per connection attempt, before `"connection"` + * fires, exactly like Express's per-request middleware chain. Calling + * `next()` with no argument admits the connection; calling it with an + * `Error` rejects it, which the client sees as a `connect_error` event + * (`socket.io-client` never even fires `"connect"` for it). + * + * On success, stashes the resolved admin on `socket.data.adminUser` — + * `socket.io`'s per-connection storage bag, the WebSocket equivalent of + * `res.locals` — for any handler that ends up needing it later. + */ +export async function requireAdminSocket( + socket: Socket, + next: (err?: Error) => void, +): Promise { + try { + const cookieHeader = socket.handshake.headers.cookie; + const token = cookieHeader ? parseCookie(cookieHeader)[env.ADMIN_COOKIE_NAME] : undefined; + if (typeof token !== "string") { + throw new Error("Not authenticated"); + } + + socket.data.adminUser = await resolveAdminFromToken(token); + next(); + } catch { + // Covers everything resolveAdminFromToken can throw, plus a missing + // cookie — same "never distinguish the reason" posture as requireAdmin. + next(new Error("Not authenticated")); + } +} diff --git a/apps/api/src/middlewares/require-admin.ts b/apps/api/src/middlewares/require-admin.ts index e20f24f..be56be0 100644 --- a/apps/api/src/middlewares/require-admin.ts +++ b/apps/api/src/middlewares/require-admin.ts @@ -19,26 +19,50 @@ export interface AdminLocals { } /** - * Express middleware guarding every `/admin/*` route — the operations UI - * (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin - * session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie - * as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET` - * (a different secret than `JWT_SECRET`), and re-checks `tokenVersion` - * against the database so a stateless JWT can still be invalidated - * server-side. + * Verifies an admin session JWT and resolves the `AdminUser` it belongs + * to — the transport-agnostic core both {@link requireAdmin} (HTTP) and + * `requireAdminSocket` (`require-admin-socket.ts`, the WebSocket handshake + * equivalent) build on; they only differ in *where* they read the raw + * cookie value from (`req.cookies`, already parsed by `cookie-parser`, vs + * a `socket.io` handshake's raw `Cookie` header, which isn't). Kept here + * rather than in `lib/admin-jwt.ts`, since it also does the DB + * `tokenVersion` re-check that file deliberately doesn't know about. + * + * @throws {Error} for any failure — malformed/expired JWT, unknown admin, + * stale `tokenVersion`, or no `ADMIN_JWT_SECRET` configured (from + * {@link verifyAdminToken}). Always the same generic message; callers + * map it to their own transport's "unauthenticated" shape. + */ +export async function resolveAdminFromToken(token: string): Promise { + const payload = verifyAdminToken(token); + const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } }); + + if (!admin || admin.tokenVersion !== payload.tokenVersion) { + throw new Error("Not authenticated"); + } + + return toSafeAdmin(admin); +} + +/** + * Express middleware guarding every `/admin/api/*` route — the operations + * UI (`apps/web`'s `/admin/*` pages) authenticating as an `AdminUser`. + * Reads the admin session cookie (`ADMIN_COOKIE_NAME`, deliberately + * **not** the same cookie as end-user sessions) and resolves it via + * {@link resolveAdminFromToken}. * * A completely separate mechanism from {@link requireAuth}, not layered on * it: an end-user session token and an admin session token are never * interchangeable in either direction. * * Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance - * that doesn't run the admin app) makes {@link verifyAdminToken} throw, so - * every request is rejected rather than the surface left open — same - * posture as `requireInternalWorker`. + * that doesn't run the admin app) makes {@link resolveAdminFromToken} + * throw, so every request is rejected rather than the surface left open — + * same posture as `requireInternalWorker`. * * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing - * cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or - * no secret configured. Never distinguishes the reason. + * cookie or anything {@link resolveAdminFromToken} rejects on. Never + * distinguishes the reason. */ export async function requireAdmin( req: Request, @@ -51,20 +75,13 @@ export async function requireAdmin( throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); } - const payload = verifyAdminToken(token); - const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } }); - - if (!admin || admin.tokenVersion !== payload.tokenVersion) { - throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); - } - - res.locals.adminUser = toSafeAdmin(admin); + res.locals.adminUser = await resolveAdminFromToken(token); next(); } catch (err) { if (err instanceof HttpError) { next(err); } else { - // Covers jwt.verify failures and the unset-secret throw from verifyAdminToken. + // Covers everything resolveAdminFromToken can throw. next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated")); } } diff --git a/apps/api/src/modules/admin/admin-monitoring.routes.ts b/apps/api/src/modules/admin/admin-monitoring.routes.ts deleted file mode 100644 index bc07113..0000000 --- a/apps/api/src/modules/admin/admin-monitoring.routes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { Router } from "express"; -import { requireAdmin } from "../../middlewares/require-admin.js"; -import { getMonitoring } from "./admin-monitoring.service.js"; - -/** Router mounted at `/admin/api/monitoring` (via `admin.routes.ts`) — behind {@link requireAdmin}. */ -export const adminMonitoringRouter = Router(); - -/** - * Actively probes Postgres, the API, `tech-step-intent-service` and the LLM - * worker's heartbeat, returning a {@link MonitoringView} status board (see - * {@link getMonitoring}). No params; the admin UI polls it on an interval. - */ -adminMonitoringRouter.get( - "/", - requireAdmin, - wrapAsyncHandler(async (_req, res) => { - res.status(200).json(await getMonitoring()); - }), -); diff --git a/apps/api/src/modules/admin/admin-monitoring.service.ts b/apps/api/src/modules/admin/admin-monitoring.service.ts index db0ffd8..2cac19f 100644 --- a/apps/api/src/modules/admin/admin-monitoring.service.ts +++ b/apps/api/src/modules/admin/admin-monitoring.service.ts @@ -155,7 +155,13 @@ async function probeWorker(): Promise { * Postgres, the API itself, `tech-step-intent-service` (`/health`), and the * LLM worker (via its stored heartbeat). Each probe is independent and * bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the - * others or the endpoint. + * others or this call as a whole. + * + * No HTTP route calls this directly (there was one, `GET + * /admin/api/monitoring` — removed) — `MonitoringBroadcastService` + * (`lib/monitoring-broadcast.service.ts`) is the only caller now, on a + * server-side timer, broadcasting the result over WebSocket instead of + * each admin's browser polling this on its own. */ export async function getMonitoring(): Promise { try { diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 7994653..11073d6 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -2,7 +2,6 @@ import { Router } from "express"; import { adminAuthRouter } from "./admin-auth.routes.js"; import { adminCatalogRouter } from "./admin-catalog.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js"; -import { adminMonitoringRouter } from "./admin-monitoring.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; /** @@ -11,14 +10,17 @@ import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; * comment for why: it would collide with apps/web's `/admin/*` page * routes). Every sub-router here backs one of those pages — * `/admin/api/auth` is public (login), everything added later - * (`/admin/api/metrics`, `/admin/api/monitoring`, `/admin/api/tech-steps/*`, - * `/admin/api/catalog/*`) sits behind `requireAdmin` - * (`middlewares/require-admin.ts`). + * (`/admin/api/metrics`, `/admin/api/tech-steps/*`, `/admin/api/catalog/*`) + * sits behind `requireAdmin` (`middlewares/require-admin.ts`). + * + * Monitoring has **no** REST route here on purpose — it's pushed over + * WebSocket instead (`lib/monitoring-broadcast.service.ts`, wired in + * `server.ts`), not requested per-page-load. See that service's doc + * comment for the full mechanism. */ export const adminRouter = Router(); adminRouter.use("/auth", adminAuthRouter); adminRouter.use("/metrics", adminMetricsRouter); -adminRouter.use("/monitoring", adminMonitoringRouter); adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/catalog", adminCatalogRouter); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 4f124c1..c197cf9 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,6 +1,8 @@ +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"; @@ -50,6 +52,17 @@ 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 }); }); diff --git a/apps/api/test/admin-monitoring-socket.test.ts b/apps/api/test/admin-monitoring-socket.test.ts new file mode 100644 index 0000000..70e7b44 --- /dev/null +++ b/apps/api/test/admin-monitoring-socket.test.ts @@ -0,0 +1,159 @@ +import type { AddressInfo } from "node:net"; +import type { MonitoringView } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import { Server as SocketIoServer } from "socket.io"; +import { type Socket as ClientSocket, io as ioClient } from "socket.io-client"; +import { createServer } from "../src/app.js"; +import { env } from "../src/config/env.js"; +import { prisma } from "../src/db/prisma.js"; +import { signAdminToken } from "../src/lib/admin-jwt.js"; +import { MonitoringBroadcastService } from "../src/lib/monitoring-broadcast.service.js"; +import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +const VALID_STATUSES = ["up", "degraded", "down", "unknown"]; +const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; + +async function seedAdmin(): Promise<{ adminUserId: number; tokenVersion: number }> { + const admin = await prisma.adminUser.create({ + data: { + email: faker.internet.email().toLowerCase(), + name: faker.person.fullName(), + passwordHash: await hashAdminPassword(faker.internet.password({ length: 16 })), + }, + }); + return { adminUserId: admin.id, tokenVersion: admin.tokenVersion }; +} + +/** + * Waits for `event` on `socket`, rejecting after `timeoutMs` (default + * tuned for the auth-rejection tests, which never touch a real probe and + * so resolve almost immediately — the probe-touching tests below pass a + * larger value explicitly) — every real WebSocket round-trip in this file + * goes through this instead of a bare `socket.once`, so a broken push + * fails the test with a clear message instead of hanging until Mocha's + * own default test timeout. + */ +function waitForEvent(socket: ClientSocket, event: string, timeoutMs = 1500): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for "${event}"`)), + timeoutMs, + ); + socket.once(event, (payload: T) => { + clearTimeout(timer); + resolve(payload); + }); + }); +} + +describe("Admin monitoring — WebSocket push", () => { + let baseUrl: string; + let broadcast: MonitoringBroadcastService; + const clientSockets: ClientSocket[] = []; + + // A real listening server, not just createApp()'s bare Express instance + // (supertest's usual approach elsewhere in this suite) — socket.io needs + // an actual http.Server to attach to, and a real client (socket.io- + // client) needs a real port to connect to. Port 0 lets the OS pick a + // free one, so this suite never collides with anything else listening. + before(async () => { + const httpServer = createServer().httpServer; + const io = new SocketIoServer(httpServer, { cors: { origin: "*" } }); + broadcast = new MonitoringBroadcastService(io); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const { port } = httpServer.address() as AddressInfo; + baseUrl = `http://localhost:${port}`; + broadcast.start(); + }); + + after(async () => { + broadcast.stop(); + await prisma.$disconnect(); + }); + + beforeEach(async () => { + await resetDatabase(); + }); + + afterEach(() => { + for (const socket of clientSockets.splice(0)) { + socket.disconnect(); + } + }); + + /** Connects a client with the given cookie header (or none), tracked for automatic teardown in `afterEach`. */ + function connect(cookie?: string): ClientSocket { + const socket = ioClient(baseUrl, { + extraHeaders: cookie ? { Cookie: cookie } : undefined, + // This suite creates several short-lived connections in a row — + // socket.io-client's default reconnection would otherwise keep + // retrying a deliberately-rejected connection in the background + // after its own test has already moved on. + reconnection: false, + forceNew: true, + }); + clientSockets.push(socket); + return socket; + } + + it("rejects a connection with no admin cookie at all", async () => { + const socket = connect(); + const err = await waitForEvent(socket, "connect_error"); + expect(err.message).to.equal("Not authenticated"); + }); + + it("rejects a connection carrying an end-user session cookie, not an admin one", async () => { + const socket = connect("session=whatever-not-an-admin-token"); + const err = await waitForEvent(socket, "connect_error"); + expect(err.message).to.equal("Not authenticated"); + }); + + it("accepts a valid admin session and pushes a monitoring:update covering all four targets", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + // Longer than Mocha's 2000ms default: the very first probe tick can + // take close to PROBE_TIMEOUT_MS (2s) if e.g. the intent-service isn't + // reachable from this test environment (a probe timing out is a valid, + // expected outcome here — this test doesn't assert *which* status + // comes back, only that the shape is right). + this.timeout(8000); + const { adminUserId, tokenVersion } = await seedAdmin(); + const token = signAdminToken({ adminUserId, tokenVersion }); + + const socket = connect(`${env.ADMIN_COOKIE_NAME}=${token}`); + const snapshot = await waitForEvent(socket, "monitoring:update", 6000); + + expect(typeof snapshot.generatedAt).to.equal("string"); + const keys = snapshot.services.map((service) => service.key); + expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]); + for (const service of snapshot.services) { + expect(VALID_STATUSES).to.include(service.status); + } + }); + + it("broadcasts the same update to every connected admin, not one probe per socket", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + this.timeout(8000); // see the previous test's comment + const { adminUserId, tokenVersion } = await seedAdmin(); + const token = signAdminToken({ adminUserId, tokenVersion }); + const cookie = `${env.ADMIN_COOKIE_NAME}=${token}`; + + const first = connect(cookie); + const second = connect(cookie); + const [firstSnapshot, secondSnapshot] = await Promise.all([ + waitForEvent(first, "monitoring:update", 6000), + waitForEvent(second, "monitoring:update", 6000), + ]); + + expect(firstSnapshot.generatedAt).to.equal(secondSnapshot.generatedAt); + }); +}); diff --git a/apps/api/test/admin-monitoring.test.ts b/apps/api/test/admin-monitoring.test.ts deleted file mode 100644 index 80d3767..0000000 --- a/apps/api/test/admin-monitoring.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { expect } from "chai"; -import request from "supertest"; -import { createApp } from "../src/app.js"; -import { env } from "../src/config/env.js"; -import { prisma } from "../src/db/prisma.js"; -import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; -import { resetDatabase } from "../test-support/reset-db.js"; - -const SECRET_HEADER = "X-Internal-Worker-Secret"; -const VALID_STATUSES = ["up", "degraded", "down", "unknown"]; - -async function seedAdmin(): Promise<{ email: string; password: string }> { - const email = faker.internet.email().toLowerCase(); - const password = faker.internet.password({ length: 16 }); - await prisma.adminUser.create({ - data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) }, - }); - return { email, password }; -} - -const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; -const workerSecretConfigured = env.INTERNAL_WORKER_SECRET !== undefined; - -describe("Admin monitoring", () => { - const app = createApp(); - - beforeEach(async () => { - await resetDatabase(); - }); - - after(async () => { - await prisma.$disconnect(); - }); - - describe("POST /internal/tech-steps/heartbeat", () => { - it("rejects a request with no worker secret with 401", async () => { - const res = await request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" }); - expect(res.status).to.equal(401); - }); - - it("rejects a malformed body with 400", async function () { - if (!workerSecretConfigured) { - // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. - (this as any).skip(); - return; - } - const res = await request(app) - .post("/internal/tech-steps/heartbeat") - .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string) - .send({ event: "not-a-real-event" }); - expect(res.status).to.equal(400); - }); - - it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async function () { - if (!workerSecretConfigured) { - // biome-ignore lint/suspicious/noExplicitAny: see above. - (this as any).skip(); - return; - } - const res = await request(app) - .post("/internal/tech-steps/heartbeat") - .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string) - .send({ - event: "job", - job: "audit-low-confidence", - ok: true, - counts: { suggestions: 3 }, - }); - expect(res.status).to.equal(200); - expect(res.body).to.deep.equal({ ok: true }); - - const stored = await prisma.workerHeartbeat.findUniqueOrThrow({ - where: { workerKey: "tech-step-llm-worker" }, - }); - expect(stored.lastRunAt).to.not.equal(null); - expect(stored.lastResult).to.deep.equal({ - job: "audit-low-confidence", - ok: true, - counts: { suggestions: 3 }, - }); - }); - - it("leaves lastRunAt null for a boot/tick ping", async function () { - if (!workerSecretConfigured) { - // biome-ignore lint/suspicious/noExplicitAny: see above. - (this as any).skip(); - return; - } - await request(app) - .post("/internal/tech-steps/heartbeat") - .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string) - .send({ event: "boot" }); - - const stored = await prisma.workerHeartbeat.findUniqueOrThrow({ - where: { workerKey: "tech-step-llm-worker" }, - }); - expect(stored.lastSeenAt).to.be.instanceOf(Date); - expect(stored.lastRunAt).to.equal(null); - }); - }); - - describe("GET /admin/api/monitoring", () => { - it("rejects a request with no admin session with 401", async () => { - const res = await request(app).get("/admin/api/monitoring"); - expect(res.status).to.equal(401); - }); - - it("returns a status board covering all four targets, never crashing on an unreachable probe", async function () { - if (!adminSecretConfigured) { - // biome-ignore lint/suspicious/noExplicitAny: see above. - (this as any).skip(); - return; - } - const { email, password } = await seedAdmin(); - const agent = request.agent(app); - await agent.post("/admin/api/auth/login").send({ email, password }); - - const res = await agent.get("/admin/api/monitoring"); - expect(res.status).to.equal(200); - - const keys = res.body.services.map((s: { key: string }) => s.key); - expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]); - for (const service of res.body.services) { - expect(VALID_STATUSES).to.include(service.status); - } - - const byKey = Object.fromEntries(res.body.services.map((s: { key: string }) => [s.key, s])); - // The DB is up during the test run, and the API is answering us. - expect(byKey.postgres.status).to.equal("up"); - expect(byKey.api.status).to.equal("up"); - // No heartbeat has ever been recorded (resetDatabase truncated it). - expect(byKey["tech-step-llm-worker"].status).to.equal("unknown"); - }); - - it("reports the worker as up once it has sent a recent heartbeat", async function () { - if (!adminSecretConfigured || !workerSecretConfigured) { - // biome-ignore lint/suspicious/noExplicitAny: see above. - (this as any).skip(); - return; - } - await request(app) - .post("/internal/tech-steps/heartbeat") - .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string) - .send({ event: "job", job: "transform-corrections", ok: true, counts: { suggestions: 0 } }); - - const { email, password } = await seedAdmin(); - const agent = request.agent(app); - await agent.post("/admin/api/auth/login").send({ email, password }); - - const res = await agent.get("/admin/api/monitoring"); - const worker = res.body.services.find( - (s: { key: string }) => s.key === "tech-step-llm-worker", - ); - expect(worker.status).to.equal("up"); - expect(worker.lastResult.job).to.equal("transform-corrections"); - expect(worker.lastRunAt).to.be.a("string"); - }); - }); -}); diff --git a/apps/api/test/internal/tech-step-worker.routes.test.ts b/apps/api/test/internal/tech-step-worker.routes.test.ts index b969270..1977cbf 100644 --- a/apps/api/test/internal/tech-step-worker.routes.test.ts +++ b/apps/api/test/internal/tech-step-worker.routes.test.ts @@ -302,5 +302,58 @@ describe("Internal tech-step worker routes", () => { expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND); }); }); + + // Moved here from the now-deleted admin-monitoring.test.ts when that + // file's own REST route (GET /admin/api/monitoring) was replaced by a + // WebSocket push (see admin-monitoring-socket.test.ts) — this route + // (POST /internal/tech-steps/heartbeat) is unrelated to that change + // and belongs with the rest of this router's own tests. No separate + // "rejects with no secret" case here: `requireInternalWorker` above + // already proves that generically for this whole router. + describe("POST /internal/tech-steps/heartbeat", () => { + it("rejects a malformed body with 400", async () => { + const res = await withSecret( + request(app).post("/internal/tech-steps/heartbeat").send({ event: "not-a-real-event" }), + ); + expect(res.status).to.equal(400); + }); + + it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async () => { + const res = await withSecret( + request(app) + .post("/internal/tech-steps/heartbeat") + .send({ + event: "job", + job: "audit-low-confidence", + ok: true, + counts: { suggestions: 3 }, + }), + ); + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ ok: true }); + + const stored = await prisma.workerHeartbeat.findUniqueOrThrow({ + where: { workerKey: "tech-step-llm-worker" }, + }); + expect(stored.lastRunAt).to.not.equal(null); + expect(stored.lastResult).to.deep.equal({ + job: "audit-low-confidence", + ok: true, + counts: { suggestions: 3 }, + }); + }); + + it("leaves lastRunAt null for a boot/tick ping", async () => { + await withSecret( + request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" }), + ); + + const stored = await prisma.workerHeartbeat.findUniqueOrThrow({ + where: { workerKey: "tech-step-llm-worker" }, + }); + expect(stored.lastSeenAt).to.be.instanceOf(Date); + expect(stored.lastRunAt).to.equal(null); + }); + }); }); }); diff --git a/apps/web/cypress/component/MonitoringPage.cy.tsx b/apps/web/cypress/component/MonitoringPage.cy.tsx new file mode 100644 index 0000000..24a3213 --- /dev/null +++ b/apps/web/cypress/component/MonitoringPage.cy.tsx @@ -0,0 +1,140 @@ +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(); + + 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(); + 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(); + 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(); + cy.then(() => client.emitState("error")); + + cy.contains("Impossible de charger").should("be.visible"); + cy.get(".monitoring-grid").should("not.exist"); + }); +}); diff --git a/apps/web/cypress/e2e/admin-monitoring.cy.ts b/apps/web/cypress/e2e/admin-monitoring.cy.ts deleted file mode 100644 index b5faa28..0000000 --- a/apps/web/cypress/e2e/admin-monitoring.cy.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Mocks the admin API via cy.intercept — no live backend. - -const adminBody = { - id: 1, - email: "ops@example.com", - name: "Ops", - createdAt: "2026-08-01T00:00:00.000Z", - lastLoginAt: "2026-08-28T09:00:00.000Z", -}; - -function monitoringFixture() { - return { - generatedAt: "2026-08-28T09:15:00.000Z", - services: [ - { - key: "postgres", - status: "up", - latencyMs: 3.2, - detail: null, - checkedAt: "2026-08-28T09:15:00.000Z", - }, - { - key: "api", - status: "up", - latencyMs: 0, - detail: "uptime 3 h 12 min · RSS 120 Mo", - checkedAt: "2026-08-28T09:15:00.000Z", - }, - { - key: "intent-service", - status: "down", - latencyMs: null, - detail: "fetch failed", - checkedAt: "2026-08-28T09:15:00.000Z", - }, - { - key: "tech-step-llm-worker", - status: "degraded", - latencyMs: null, - detail: "dernier battement il y a 9 j", - checkedAt: "2026-08-28T09:15:00.000Z", - lastRunAt: "2026-08-19T03:00:00.000Z", - lastResult: { job: "audit-low-confidence", ok: true, counts: { suggestions: 2 } }, - }, - ], - }; -} - -describe("Admin monitoring", () => { - beforeEach(() => { - cy.viewport(1400, 900); - cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); - }); - - // Plain URL/method matcher is enough here: the API's `/admin/api/monitoring` - // can no longer collide with the page route `/admin/monitoring` (see - // `mountRouter("/admin/api", ...)`'s comment in the API's `app.ts` for the - // incident that made that distinction necessary in the first place). This - // used to need a `resourceType`-based workaround for that collision, which - // also surfaced an unrelated Cypress bug: on this machine's Chrome build, a - // *declarative* `resourceType` route matcher silently never caught a - // request whose own `req.resourceType` genuinely was that type — worth - // remembering if a `resourceType` matcher elsewhere in this suite ever - // starts failing the same way. - function interceptMonitoringApi(response: { statusCode: number; body: unknown }) { - return cy.intercept("GET", "**/admin/api/monitoring", response); - } - - it("renders one card per service with its status and details", () => { - interceptMonitoringApi({ statusCode: 200, body: monitoringFixture() }).as("getMonitoring"); - cy.visit("/admin/monitoring"); - cy.wait("@getMonitoring"); - - cy.get(".monitoring-card").should("have.length", 4); - - cy.contains(".monitoring-card", "Base de données") - .should("have.class", "monitoring-card--up") - .and("contain.text", "3.2 ms"); - cy.contains(".monitoring-card", "Service NLP (spaCy)") - .should("have.class", "monitoring-card--down") - .and("contain.text", "Hors service"); - cy.contains(".monitoring-card", "Worker LLM") - .should("have.class", "monitoring-card--degraded") - .and("contain.text", "audit-low-confidence"); - }); - - it("shows an error state when the request fails", () => { - // Aliased and waited on (unlike the other tests' plain intercepts) so - // this genuinely proves the 500 came from the mock — without a live API - // server, an unmatched request fails at the network level too, which - // `MonitoringPage`'s `.catch()` can't tell apart from a real 500 (see - // the comment above `interceptMonitoringApi`, this is exactly the class - // of bug that let this test pass for the wrong reason before). - interceptMonitoringApi({ statusCode: 500, body: { code: 5000, message: "x" } }).as( - "getMonitoringError", - ); - cy.visit("/admin/monitoring"); - cy.wait("@getMonitoringError"); - cy.contains("Impossible de charger").should("be.visible"); - }); -}); diff --git a/apps/web/package.json b/apps/web/package.json index 57dd08d..19ede70 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "react-i18next": "^17.0.11", "react-router-dom": "^7.18.2", "recharts": "^2.15.0", + "socket.io-client": "^4.8.3", "zod": "^3.25.76" }, "devDependencies": { diff --git a/apps/web/src/api/admin-client.ts b/apps/web/src/api/admin-client.ts index 4a11b2c..f6dcf30 100644 --- a/apps/web/src/api/admin-client.ts +++ b/apps/web/src/api/admin-client.ts @@ -7,7 +7,6 @@ import { ErrorCode, type MarkPlaceholdersReviewedInput, type MetricsView, - type MonitoringView, type PruneOrphansResultView, type RetrainRequestInput, type RetrainResultView, @@ -123,11 +122,6 @@ export class AdminApiClient { return this._request(`/metrics?days=${days}`); } - /** Live health of Postgres, the API, the intent-service and the LLM worker — polled by the monitoring board. */ - public getMonitoring(): Promise { - return this._request("/monitoring"); - } - /** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */ public getSuggestions(filters: { status?: string; diff --git a/apps/web/src/api/monitoring-socket.ts b/apps/web/src/api/monitoring-socket.ts new file mode 100644 index 0000000..aef2c2d --- /dev/null +++ b/apps/web/src/api/monitoring-socket.ts @@ -0,0 +1,105 @@ +import type { MonitoringView } from "@batch-cooking/shared"; +import { io, type Socket } from "socket.io-client"; + +/** Event name the server pushes on — must match `UPDATE_EVENT` in `monitoring-broadcast.service.ts` (apps/api). */ +const UPDATE_EVENT = "monitoring:update"; + +/** + * Connection state {@link MonitoringPage} renders a badge for whenever it + * isn't `"connected"` — `"connecting"` covers both the very first attempt + * and any later drop that happens *before* a first successful connect + * (rare, but distinct from `"reconnecting"`, which implies the board + * already has real data on screen worth not blanking out). + */ +export type MonitoringConnectionState = "connecting" | "connected" | "reconnecting" | "error"; + +/** + * The shape {@link MonitoringPage} actually depends on — deliberately + * small, and satisfied by both {@link MonitoringSocketClient} (the real + * thing) and a fake `socket.io`-free test double, so a Cypress component + * test can drive the page's connection/update states without a real + * server. See `MonitoringPage.cy.tsx`. + */ +export interface MonitoringSocketClientLike { + connect(): void; + disconnect(): void; + /** Registers a listener for each pushed snapshot; returns an unsubscribe function. */ + onUpdate(listener: (data: MonitoringView) => void): () => void; + /** Registers a listener for connection-state changes; returns an unsubscribe function. */ + onConnectionStateChange(listener: (state: MonitoringConnectionState) => void): () => void; +} + +/** + * Thin wrapper around a single `socket.io-client` connection to the admin + * monitoring room — the WebSocket counterpart of `AdminApiClient` + * (`admin-client.ts`), used only by `MonitoringPage`. Connects to the bare + * API origin (`VITE_API_URL`, same as `AdminApiClient`), **not** the + * `/admin/api` REST prefix: `socket.io` binds directly to the underlying + * `http.Server` (see `ExpressServer.httpServer`, `packages/express-tools`), + * independently of Express route mounting, at its own well-known + * `/socket.io/` handshake path. + * + * `withCredentials: true` is what makes the `admin_session` httpOnly + * cookie ride along on the handshake — the server-side auth + * (`requireAdminSocket`, `apps/api/src/middlewares/require-admin-socket.ts`) + * reads it from there exactly like `requireAdmin` reads it from a normal + * request's cookies. + */ +export class MonitoringSocketClient implements MonitoringSocketClientLike { + private _socket: Socket | null = null; + /** Once a connection has succeeded at least once, a later drop is a "reconnecting" (keep the board), not an "error" (nothing to show yet). */ + private _hasConnectedOnce = false; + private readonly _updateListeners = new Set<(data: MonitoringView) => void>(); + private readonly _stateListeners = new Set<(state: MonitoringConnectionState) => void>(); + + /** Opens the connection. Idempotent — calling it while already connected/connecting does nothing. */ + public connect(): void { + if (this._socket) { + return; + } + + const baseUrl: string = import.meta.env.VITE_API_URL ?? ""; + this._socket = io(baseUrl, { withCredentials: true }); + + this._socket.on("connect", () => { + this._hasConnectedOnce = true; + this._emitState("connected"); + }); + this._socket.on(UPDATE_EVENT, (data: MonitoringView) => { + for (const listener of this._updateListeners) { + listener(data); + } + }); + this._socket.on("disconnect", () => { + this._emitState(this._hasConnectedOnce ? "reconnecting" : "connecting"); + }); + this._socket.on("connect_error", () => { + this._emitState(this._hasConnectedOnce ? "reconnecting" : "error"); + }); + + this._emitState("connecting"); + } + + /** Closes the connection. Registered listeners' subscriptions are left intact — callers still own their own unsubscribe functions from {@link onUpdate}/{@link onConnectionStateChange}; this just stops the transport, ready for a later {@link connect} to reuse the same subscriptions. */ + public disconnect(): void { + this._socket?.disconnect(); + this._socket = null; + this._hasConnectedOnce = false; + } + + 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); + } + + private _emitState(state: MonitoringConnectionState): void { + for (const listener of this._stateListeners) { + listener(state); + } + } +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 3f525ed..4f18e31 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1220,6 +1220,11 @@ "lastRun": "Dernier job", "never": "jamais", "jobFailed": "échec", + "connectionState": { + "connecting": "Connexion en cours…", + "reconnecting": "Connexion perdue, reconnexion en cours…", + "error": "Connexion impossible" + }, "status": { "up": "OK", "degraded": "Dégradé", diff --git a/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx b/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx index cf80284..1e391ee 100644 --- a/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx +++ b/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx @@ -1,66 +1,89 @@ 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 { + type MonitoringConnectionState, + MonitoringSocketClient, + type MonitoringSocketClientLike, +} from "../../../api/monitoring-socket"; import "../admin-page.scss"; import "./monitoring-page.scss"; -import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring"; +import { clockTime, statusModifier } from "./monitoring"; -type MonitoringState = - | { status: "loading" } - | { status: "loaded"; data: MonitoringView } - | { status: "error" }; +interface MonitoringPageProps { + /** + * Overrides the real WebSocket client — for Cypress component tests + * (`MonitoringPage.cy.tsx`), which drive connection/update events + * synthetically through a fake implementing {@link MonitoringSocketClientLike} + * instead of a real `socket.io` server. Defaults to a real + * {@link MonitoringSocketClient}; production never passes this prop. + */ + socketClient?: MonitoringSocketClientLike; +} /** - * Microservice health board. Fetches `GET /admin/api/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. + * Microservice health board. No REST endpoint backs this any more — the + * server probes once a minute and pushes `monitoring:update` over + * WebSocket to every connected admin (`MonitoringBroadcastService`, + * apps/api), instead of each open tab polling independently. One card per + * probed target (Postgres, API, intent-service, LLM worker), coloured by + * status. + * + * `data` and `connectionState` are deliberately separate: a connection + * drop after the board has already loaded once shows a small badge + * (`socket.io-client`'s own automatic reconnection handles the retry) — + * it never blanks the last known board back to a loading state. Only the + * very first connect, before any data has ever arrived, shows the + * full-page loading/error placeholder. */ -export function MonitoringPage() { +export function MonitoringPage({ socketClient }: MonitoringPageProps) { const { t } = useTranslation(); - const [state, setState] = useState({ status: "loading" }); + const [data, setData] = useState(null); + const [connectionState, setConnectionState] = useState("connecting"); + // Lazy useState initializer, not a plain `new MonitoringSocketClient()` + // above it or a bare module-level singleton: this must run exactly once + // per component instance (a fresh client per mount), and must not be + // recreated on every re-render. + const [client] = useState( + () => socketClient ?? new MonitoringSocketClient(), + ); 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); + const unsubscribeUpdate = client.onUpdate(setData); + const unsubscribeState = client.onConnectionStateChange(setConnectionState); + client.connect(); return () => { - cancelled = true; - clearInterval(timer); + unsubscribeUpdate(); + unsubscribeState(); + client.disconnect(); }; - }, []); + }, [client]); return (

{t("admin.monitoring.title")}

{t("admin.monitoring.lead")}

- {state.status === "loading" && ( + {data === null && connectionState !== "error" && (

{t("admin.common.loading")}

)} - {state.status === "error" && ( + {data === null && connectionState === "error" && (

{t("admin.common.loadError")}

)} - {state.status === "loaded" && ( + {data !== null && ( <> + {connectionState !== "connected" && ( +

+ {t(`admin.monitoring.connectionState.${connectionState}`)} +

+ )}

- {t("admin.monitoring.lastChecked", { time: clockTime(state.data.generatedAt) })} + {t("admin.monitoring.lastChecked", { time: clockTime(data.generatedAt) })}

- {state.data.services.map((service) => ( + {data.services.map((service) => ( ))}
diff --git a/apps/web/src/pages/admin/monitoring/monitoring-page.scss b/apps/web/src/pages/admin/monitoring/monitoring-page.scss index d9fe572..7473621 100644 --- a/apps/web/src/pages/admin/monitoring/monitoring-page.scss +++ b/apps/web/src/pages/admin/monitoring/monitoring-page.scss @@ -9,6 +9,30 @@ color: var(--color-text-muted); } +// Shown above the board only while the WebSocket connection isn't +// currently up — the board itself always stays on screen underneath +// (see MonitoringPage.tsx's own comment on why `data` and +// `connectionState` are kept separate), this is just a heads-up that +// what's showing may be stale. +.monitoring-connection-badge { + display: inline-block; + margin: 0 0 var(--space-md); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: 700; + + &--reconnecting { + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + } + + &--error { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 12%, transparent); + } +} + .monitoring-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); diff --git a/apps/web/src/pages/admin/monitoring/monitoring.ts b/apps/web/src/pages/admin/monitoring/monitoring.ts index 95344db..9fb32ca 100644 --- a/apps/web/src/pages/admin/monitoring/monitoring.ts +++ b/apps/web/src/pages/admin/monitoring/monitoring.ts @@ -10,9 +10,6 @@ export function statusModifier(status: ServiceStatus): string { return status; } -/** How often the board re-polls `GET /admin/api/monitoring`, in ms. */ -export const POLL_INTERVAL_MS = 15_000; - /** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */ export function clockTime(iso: string): string { return new Date(iso).toLocaleTimeString("fr-FR"); diff --git a/packages/express-tools/src/express-server.ts b/packages/express-tools/src/express-server.ts index ce46149..8acf62d 100644 --- a/packages/express-tools/src/express-server.ts +++ b/packages/express-tools/src/express-server.ts @@ -1,3 +1,4 @@ +import http from "node:http"; import path from "node:path"; import cookieParser from "cookie-parser"; import cors from "cors"; @@ -38,11 +39,22 @@ export interface ExpressServerCoreOptions { export class ExpressServer { /** The underlying Express application. */ private readonly _app: Express; + /** + * The raw Node HTTP server wrapping {@link _app} — created up front + * (not lazily inside {@link listen}) so callers can attach something + * that needs the actual `http.Server` *before* it starts accepting + * connections, e.g. a `socket.io` server, which binds to the HTTP + * server itself rather than to Express or a port. Express's own + * `.listen()` creates one of these internally too, but never hands it + * back — that's the whole reason this class creates it explicitly. + */ + private readonly _httpServer: http.Server; /** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */ private readonly _registeredRoutes = new Set(); public constructor() { this._app = express(); + this._httpServer = http.createServer(this._app); } /** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */ @@ -50,6 +62,11 @@ export class ExpressServer { return this._app; } + /** The underlying `http.Server` — for anything that needs to attach directly to it (e.g. a `socket.io` server) rather than go through Express. Safe to read before {@link listen} is called; it just won't be accepting connections yet. */ + public get httpServer(): http.Server { + return this._httpServer; + } + /** * Registers the standard middleware stack every service in this * monorepo needs: CORS (with credentials, for the session cookie), @@ -117,6 +134,6 @@ export class ExpressServer { /** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */ public listen(port: number, onListening?: () => void): void { - this._app.listen(port, onListening); + this._httpServer.listen(port, onListening); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a23699f..fb8e91c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: argon2: specifier: 0.31.2 version: 0.31.2 + cookie: + specifier: ^2.0.1 + version: 2.0.1 dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -47,6 +50,9 @@ importers: prisma: specifier: ^5.22.0 version: 5.22.0 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 zod: specifier: ^3.23.8 version: 3.25.76 @@ -75,6 +81,9 @@ importers: mocha: specifier: ^10.8.2 version: 10.8.2 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -114,6 +123,9 @@ importers: recharts: specifier: ^2.15.0 version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 zod: specifier: ^3.25.76 version: 3.25.76 @@ -1114,6 +1126,9 @@ packages: cpu: [x64] os: [win32] + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==, tarball: https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} @@ -1251,6 +1266,9 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==, tarball: https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==, tarball: https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz} @@ -1453,6 +1471,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, tarball: https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==, tarball: https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz} + engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.11.14: resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==, tarball: https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz} engines: {node: '>=6.0.0'} @@ -1718,6 +1740,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==, tarball: https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz} + engines: {node: '>=22'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==, tarball: https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz} @@ -2012,6 +2038,17 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, tarball: https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==, tarball: https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==, tarball: https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz} + engines: {node: '>=10.0.0'} + + engine.io@6.6.10: + resolution: {integrity: sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==, tarball: https://registry.npmjs.org/engine.io/-/engine.io-6.6.10.tgz} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.24.5: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz} engines: {node: '>=10.13.0'} @@ -3465,6 +3502,21 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==, tarball: https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz} engines: {node: '>=10'} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==, tarball: https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==, tarball: https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==, tarball: https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==, tarball: https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz} + engines: {node: '>=10.2.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz} engines: {node: '>=0.10.0'} @@ -3865,10 +3917,26 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==, tarball: https://registry.npmjs.org/ws/-/ws-8.21.3.tgz} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz} engines: {node: '>=8.0'} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==, tarball: https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz} + engines: {node: '>=0.4.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz} engines: {node: '>=10'} @@ -4711,6 +4779,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true + '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} '@teppeis/multimaps@3.0.0': {} @@ -4864,6 +4934,10 @@ snapshots: '@types/uuid@10.0.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.1 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.20.1 @@ -5080,6 +5154,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + baseline-browser-mapping@2.11.14: {} bcrypt-pbkdf@1.0.2: @@ -5328,6 +5404,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cookiejar@2.1.4: {} core-util-is@1.0.2: {} @@ -5672,6 +5750,36 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.10: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.20.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 @@ -7287,6 +7395,47 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-client: 6.6.6 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3(supports-color@8.1.1) + engine.io: 6.6.10 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -7696,8 +7845,12 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + xmlbuilder@15.1.1: {} + xmlhttprequest-ssl@2.1.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index ffba337..a4e1e3f 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -395,13 +395,31 @@ est un `String` libre (`"user.signup"`, `"recipe.imported"`, `"recipe.created"`, ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (un évènement est un fait historique qui survit au compte qu'il décrit). -**Monitoring** (`admin-monitoring.service.ts`, `GET /admin/api/monitoring`, -`requireAdmin`) — sonde active, chaque cible bornée à ~2 s, latence mesurée : -Postgres (`SELECT 1`), l'API elle-même (uptime/RSS), `tech-step-intent-service` +**Monitoring** (`admin-monitoring.service.ts`'s `getMonitoring()`) — sonde +active, chaque cible bornée à ~2 s, latence mesurée : Postgres +(`SELECT 1`), l'API elle-même (uptime/RSS), `tech-step-intent-service` (`GET /health`, sans secret), et le **worker LLM** via sa ligne -`WorkerHeartbeat`. `MonitoringView { services: ServiceHealthView[] }`, statut -`up | degraded | down | unknown` ; une sonde `down` ne fait jamais échouer les -autres ni l'endpoint. Le front (`MonitoringPage`) re-poll toutes les 15 s. +`WorkerHeartbeat`. `MonitoringView { services: ServiceHealthView[] }`, +statut `up | degraded | down | unknown` ; une sonde `down` ne fait jamais +échouer les autres ni cet appel dans son ensemble. + +**Pas de route REST** — poussé en WebSocket. `MonitoringBroadcastService` +(`lib/monitoring-broadcast.service.ts`) sonde une fois au démarrage puis +toutes les 60 s (un seul `setInterval` pour tout le process, pas par +onglet admin ouvert) et diffuse le résultat (`monitoring:update`) à la +room `socket.io` `"monitoring"`. Auth au handshake via +`requireAdminSocket` (`middlewares/require-admin-socket.ts`) — même +vérification que `requireAdmin` (cookie `ADMIN_COOKIE_NAME` + +`verifyAdminToken` + re-check `tokenVersion`, factorisée dans +`resolveAdminFromToken`), lue depuis l'en-tête `Cookie` brut du handshake +(parsé à la main avec le package `cookie` — un handshake `socket.io` ne +passe pas par `cookie-parser`). Le dernier résultat est mis en cache et +rejoué immédiatement à tout admin qui rejoint la room, pour qu'un onglet +fraîchement ouvert n'attende pas la prochaine minute. Le `socket.io` +`Server` s'attache directement au `http.Server` sous-jacent +(`ExpressServer.httpServer`, `packages/express-tools`), pas à Express — +câblé dans `server.ts`, pas `app.ts` (les tests supertest n'ont pas besoin +d'un serveur WebSocket). Le worker (`services/tech-step-llm-worker`) n'a **aucune** surface HTTP entrante — il devient observable via `POST /internal/tech-steps/heartbeat`