feat(admin): pousse le monitoring en WebSocket au lieu du polling client #45

Merged
kyuno merged 2 commits from feat/admin-monitoring-websocket into main 2026-09-05 20:11:51 +02:00
23 changed files with 946 additions and 359 deletions
Showing only changes of commit 13d92f8fa8 - Show all commits

View file

@ -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"

View file

@ -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<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),
});
}
}
}

View file

@ -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<void> {
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"));
}
}

View file

@ -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<AdminUserView> {
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"));
}
}

View file

@ -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());
}),
);

View file

@ -155,7 +155,13 @@ async function probeWorker(): Promise<ServiceHealthView> {
* 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<MonitoringView> {
try {

View file

@ -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);

View file

@ -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 });
});

View file

@ -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<T>(socket: ClientSocket, event: string, timeoutMs = 1500): Promise<T> {
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<void>((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<Error>(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<Error>(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<MonitoringView>(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<MonitoringView>(first, "monitoring:update", 6000),
waitForEvent<MonitoringView>(second, "monitoring:update", 6000),
]);
expect(firstSnapshot.generatedAt).to.equal(secondSnapshot.generatedAt);
});
});

View file

@ -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");
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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(<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");
});
});

View file

@ -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");
});
});

View file

@ -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": {

View file

@ -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<MonitoringView> {
return this._request("/monitoring");
}
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
public getSuggestions(filters: {
status?: string;

View file

@ -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);
}
}
}

View file

@ -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é",

View file

@ -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";
type MonitoringState =
| { status: "loading" }
| { status: "loaded"; data: MonitoringView }
| { status: "error" };
import { clockTime, statusModifier } from "./monitoring";
interface MonitoringPageProps {
/**
* 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.
* 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.
*/
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" }));
});
socketClient?: MonitoringSocketClientLike;
}
load();
const timer = setInterval(load, POLL_INTERVAL_MS);
/**
* 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({ socketClient }: MonitoringPageProps) {
const { t } = useTranslation();
const [data, setData] = useState<MonitoringView | null>(null);
const [connectionState, setConnectionState] = useState<MonitoringConnectionState>("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<MonitoringSocketClientLike>(
() => socketClient ?? new MonitoringSocketClient(),
);
useEffect(() => {
const unsubscribeUpdate = client.onUpdate(setData);
const unsubscribeState = client.onConnectionStateChange(setConnectionState);
client.connect();
return () => {
cancelled = true;
clearInterval(timer);
unsubscribeUpdate();
unsubscribeState();
client.disconnect();
};
}, []);
}, [client]);
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" && (
{data === null && connectionState !== "error" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
{data === null && connectionState === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" && (
{data !== null && (
<>
{connectionState !== "connected" && (
<p
className={`monitoring-connection-badge monitoring-connection-badge--${connectionState}`}
>
{t(`admin.monitoring.connectionState.${connectionState}`)}
</p>
)}
<p className="monitoring-checked">
{t("admin.monitoring.lastChecked", { time: clockTime(state.data.generatedAt) })}
{t("admin.monitoring.lastChecked", { time: clockTime(data.generatedAt) })}
</p>
<div className="monitoring-grid">
{state.data.services.map((service) => (
{data.services.map((service) => (
<ServiceCard key={service.key} service={service} />
))}
</div>

View file

@ -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));

View file

@ -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");

View file

@ -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<string>();
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);
}
}

View file

@ -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: {}

View file

@ -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`