feat(admin): pousse le monitoring en WebSocket au lieu du polling client #45
23 changed files with 961 additions and 359 deletions
|
|
@ -23,10 +23,12 @@
|
||||||
"@batch-cooking/shared": "workspace:*",
|
"@batch-cooking/shared": "workspace:*",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"argon2": "0.31.2",
|
"argon2": "0.31.2",
|
||||||
|
"cookie": "^2.0.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -38,6 +40,7 @@
|
||||||
"chai": "^5.1.2",
|
"chai": "^5.1.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"mocha": "^10.8.2",
|
"mocha": "^10.8.2",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|
|
||||||
95
apps/api/src/lib/monitoring-broadcast.service.ts
Normal file
95
apps/api/src/lib/monitoring-broadcast.service.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/api/src/middlewares/require-admin-socket.ts
Normal file
43
apps/api/src/middlewares/require-admin-socket.ts
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,26 +19,50 @@ export interface AdminLocals {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Express middleware guarding every `/admin/*` route — the operations UI
|
* Verifies an admin session JWT and resolves the `AdminUser` it belongs
|
||||||
* (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin
|
* to — the transport-agnostic core both {@link requireAdmin} (HTTP) and
|
||||||
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
|
* `requireAdminSocket` (`require-admin-socket.ts`, the WebSocket handshake
|
||||||
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
|
* equivalent) build on; they only differ in *where* they read the raw
|
||||||
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
|
* cookie value from (`req.cookies`, already parsed by `cookie-parser`, vs
|
||||||
* against the database so a stateless JWT can still be invalidated
|
* a `socket.io` handshake's raw `Cookie` header, which isn't). Kept here
|
||||||
* server-side.
|
* 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
|
* A completely separate mechanism from {@link requireAuth}, not layered on
|
||||||
* it: an end-user session token and an admin session token are never
|
* it: an end-user session token and an admin session token are never
|
||||||
* interchangeable in either direction.
|
* interchangeable in either direction.
|
||||||
*
|
*
|
||||||
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
|
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
|
||||||
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so
|
* that doesn't run the admin app) makes {@link resolveAdminFromToken}
|
||||||
* every request is rejected rather than the surface left open — same
|
* throw, so every request is rejected rather than the surface left open —
|
||||||
* posture as `requireInternalWorker`.
|
* same posture as `requireInternalWorker`.
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||||
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or
|
* cookie or anything {@link resolveAdminFromToken} rejects on. Never
|
||||||
* no secret configured. Never distinguishes the reason.
|
* distinguishes the reason.
|
||||||
*/
|
*/
|
||||||
export async function requireAdmin(
|
export async function requireAdmin(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|
@ -51,20 +75,13 @@ export async function requireAdmin(
|
||||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = verifyAdminToken(token);
|
res.locals.adminUser = await resolveAdminFromToken(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);
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
next(err);
|
next(err);
|
||||||
} else {
|
} 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"));
|
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -155,7 +155,13 @@ async function probeWorker(): Promise<ServiceHealthView> {
|
||||||
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
|
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
|
||||||
* LLM worker (via its stored heartbeat). Each probe is independent and
|
* LLM worker (via its stored heartbeat). Each probe is independent and
|
||||||
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
|
* 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> {
|
export async function getMonitoring(): Promise<MonitoringView> {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Router } from "express";
|
||||||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
import { adminAuthRouter } from "./admin-auth.routes.js";
|
||||||
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
||||||
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
||||||
import { adminMonitoringRouter } from "./admin-monitoring.routes.js";
|
|
||||||
import { adminTechStepsRouter } from "./admin-tech-steps.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
|
* comment for why: it would collide with apps/web's `/admin/*` page
|
||||||
* routes). Every sub-router here backs one of those pages —
|
* routes). Every sub-router here backs one of those pages —
|
||||||
* `/admin/api/auth` is public (login), everything added later
|
* `/admin/api/auth` is public (login), everything added later
|
||||||
* (`/admin/api/metrics`, `/admin/api/monitoring`, `/admin/api/tech-steps/*`,
|
* (`/admin/api/metrics`, `/admin/api/tech-steps/*`, `/admin/api/catalog/*`)
|
||||||
* `/admin/api/catalog/*`) sits behind `requireAdmin`
|
* sits behind `requireAdmin` (`middlewares/require-admin.ts`).
|
||||||
* (`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();
|
export const adminRouter = Router();
|
||||||
|
|
||||||
adminRouter.use("/auth", adminAuthRouter);
|
adminRouter.use("/auth", adminAuthRouter);
|
||||||
adminRouter.use("/metrics", adminMetricsRouter);
|
adminRouter.use("/metrics", adminMetricsRouter);
|
||||||
adminRouter.use("/monitoring", adminMonitoringRouter);
|
|
||||||
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||||
adminRouter.use("/catalog", adminCatalogRouter);
|
adminRouter.use("/catalog", adminCatalogRouter);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { Server as SocketIoServer } from "socket.io";
|
||||||
import { createServer } from "./app.js";
|
import { createServer } from "./app.js";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { logger } from "./lib/logger.service.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 { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js";
|
||||||
import { registerAllRecipeSources } from "./sources/index.js";
|
import { registerAllRecipeSources } from "./sources/index.js";
|
||||||
|
|
||||||
|
|
@ -50,6 +52,17 @@ await warmUpTechStepClassifier();
|
||||||
|
|
||||||
const server = createServer();
|
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, () => {
|
server.listen(env.PORT, () => {
|
||||||
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
|
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
174
apps/api/test/admin-monitoring-socket.test.ts
Normal file
174
apps/api/test/admin-monitoring-socket.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
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;
|
||||||
|
// Hoisted to suite scope (not a local in `before`) so `after` can shut
|
||||||
|
// it down. `broadcast.stop()` alone only clears the probe interval — the
|
||||||
|
// socket.io server keeps engine.io's ping timers and the listening
|
||||||
|
// http.Server (port 0) alive, and Mocha runs without `--exit`
|
||||||
|
// (`.mocharc.json`), so that one leaked handle stops the whole process
|
||||||
|
// from ever draining once the last test passes. That is exactly what
|
||||||
|
// stalled CI: the `test` job printed "N passing" and then hung for
|
||||||
|
// hours until the runner was killed.
|
||||||
|
let io: SocketIoServer;
|
||||||
|
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;
|
||||||
|
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();
|
||||||
|
// Disconnects any socket still open, stops engine.io's timers, and
|
||||||
|
// closes the http.Server socket.io was attached to — without this the
|
||||||
|
// suite never lets Node's event loop drain (see the `io` field's note).
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
io.close((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -302,5 +302,58 @@ describe("Internal tech-step worker routes", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
140
apps/web/cypress/component/MonitoringPage.cy.tsx
Normal file
140
apps/web/cypress/component/MonitoringPage.cy.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type MarkPlaceholdersReviewedInput,
|
type MarkPlaceholdersReviewedInput,
|
||||||
type MetricsView,
|
type MetricsView,
|
||||||
type MonitoringView,
|
|
||||||
type PruneOrphansResultView,
|
type PruneOrphansResultView,
|
||||||
type RetrainRequestInput,
|
type RetrainRequestInput,
|
||||||
type RetrainResultView,
|
type RetrainResultView,
|
||||||
|
|
@ -123,11 +122,6 @@ export class AdminApiClient {
|
||||||
return this._request(`/metrics?days=${days}`);
|
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. */
|
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
|
||||||
public getSuggestions(filters: {
|
public getSuggestions(filters: {
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
|
||||||
105
apps/web/src/api/monitoring-socket.ts
Normal file
105
apps/web/src/api/monitoring-socket.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1220,6 +1220,11 @@
|
||||||
"lastRun": "Dernier job",
|
"lastRun": "Dernier job",
|
||||||
"never": "jamais",
|
"never": "jamais",
|
||||||
"jobFailed": "échec",
|
"jobFailed": "échec",
|
||||||
|
"connectionState": {
|
||||||
|
"connecting": "Connexion en cours…",
|
||||||
|
"reconnecting": "Connexion perdue, reconnexion en cours…",
|
||||||
|
"error": "Connexion impossible"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"up": "OK",
|
"up": "OK",
|
||||||
"degraded": "Dégradé",
|
"degraded": "Dégradé",
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,89 @@
|
||||||
import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared";
|
import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 "../admin-page.scss";
|
||||||
import "./monitoring-page.scss";
|
import "./monitoring-page.scss";
|
||||||
import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring";
|
import { clockTime, statusModifier } from "./monitoring";
|
||||||
|
|
||||||
type MonitoringState =
|
interface MonitoringPageProps {
|
||||||
| { status: "loading" }
|
/**
|
||||||
| { status: "loaded"; data: MonitoringView }
|
* Overrides the real WebSocket client — for Cypress component tests
|
||||||
| { status: "error" };
|
* (`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
|
* Microservice health board. No REST endpoint backs this any more — the
|
||||||
* re-polls every {@link POLL_INTERVAL_MS} ms. One card per probed target
|
* server probes once a minute and pushes `monitoring:update` over
|
||||||
* (Postgres, API, intent-service, LLM worker), coloured by status.
|
* 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 { t } = useTranslation();
|
||||||
const [state, setState] = useState<MonitoringState>({ status: "loading" });
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const unsubscribeUpdate = client.onUpdate(setData);
|
||||||
|
const unsubscribeState = client.onConnectionStateChange(setConnectionState);
|
||||||
function load() {
|
client.connect();
|
||||||
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);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
unsubscribeUpdate();
|
||||||
clearInterval(timer);
|
unsubscribeState();
|
||||||
|
client.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [client]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
<h1 className="admin-page__title">{t("admin.monitoring.title")}</h1>
|
<h1 className="admin-page__title">{t("admin.monitoring.title")}</h1>
|
||||||
<p className="admin-page__lead">{t("admin.monitoring.lead")}</p>
|
<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>
|
<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>
|
<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">
|
<p className="monitoring-checked">
|
||||||
{t("admin.monitoring.lastChecked", { time: clockTime(state.data.generatedAt) })}
|
{t("admin.monitoring.lastChecked", { time: clockTime(data.generatedAt) })}
|
||||||
</p>
|
</p>
|
||||||
<div className="monitoring-grid">
|
<div className="monitoring-grid">
|
||||||
{state.data.services.map((service) => (
|
{data.services.map((service) => (
|
||||||
<ServiceCard key={service.key} service={service} />
|
<ServiceCard key={service.key} service={service} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,30 @@
|
||||||
color: var(--color-text-muted);
|
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 {
|
.monitoring-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ export function statusModifier(status: ServiceStatus): string {
|
||||||
return status;
|
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. */
|
/** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */
|
||||||
export function clockTime(iso: string): string {
|
export function clockTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleTimeString("fr-FR");
|
return new Date(iso).toLocaleTimeString("fr-FR");
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import http from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
|
@ -38,11 +39,22 @@ export interface ExpressServerCoreOptions {
|
||||||
export class ExpressServer {
|
export class ExpressServer {
|
||||||
/** The underlying Express application. */
|
/** The underlying Express application. */
|
||||||
private readonly _app: Express;
|
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. */
|
/** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */
|
||||||
private readonly _registeredRoutes = new Set<string>();
|
private readonly _registeredRoutes = new Set<string>();
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
this._app = express();
|
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. */
|
/** 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;
|
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
|
* Registers the standard middleware stack every service in this
|
||||||
* monorepo needs: CORS (with credentials, for the session cookie),
|
* 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). */
|
/** 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 {
|
public listen(port: number, onListening?: () => void): void {
|
||||||
this._app.listen(port, onListening);
|
this._httpServer.listen(port, onListening);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
153
pnpm-lock.yaml
153
pnpm-lock.yaml
|
|
@ -35,6 +35,9 @@ importers:
|
||||||
argon2:
|
argon2:
|
||||||
specifier: 0.31.2
|
specifier: 0.31.2
|
||||||
version: 0.31.2
|
version: 0.31.2
|
||||||
|
cookie:
|
||||||
|
specifier: ^2.0.1
|
||||||
|
version: 2.0.1
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.5
|
specifier: ^16.4.5
|
||||||
version: 16.6.1
|
version: 16.6.1
|
||||||
|
|
@ -47,6 +50,9 @@ importers:
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0
|
version: 5.22.0
|
||||||
|
socket.io:
|
||||||
|
specifier: ^4.8.3
|
||||||
|
version: 4.8.3
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.8
|
specifier: ^3.23.8
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
|
@ -75,6 +81,9 @@ importers:
|
||||||
mocha:
|
mocha:
|
||||||
specifier: ^10.8.2
|
specifier: ^10.8.2
|
||||||
version: 10.8.2
|
version: 10.8.2
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.3
|
||||||
|
version: 4.8.3
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
|
|
@ -114,6 +123,9 @@ importers:
|
||||||
recharts:
|
recharts:
|
||||||
specifier: ^2.15.0
|
specifier: ^2.15.0
|
||||||
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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:
|
zod:
|
||||||
specifier: ^3.25.76
|
specifier: ^3.25.76
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
|
@ -1114,6 +1126,9 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@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}
|
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':
|
'@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}
|
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':
|
'@types/yauzl@2.10.3':
|
||||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==, tarball: https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz}
|
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:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, tarball: https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz}
|
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:
|
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}
|
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'}
|
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}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz}
|
||||||
engines: {node: '>=18'}
|
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:
|
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}
|
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:
|
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}
|
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:
|
enhanced-resolve@5.24.5:
|
||||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz}
|
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz}
|
||||||
engines: {node: '>=10.13.0'}
|
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}
|
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==, tarball: https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz}
|
||||||
engines: {node: '>=10'}
|
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:
|
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}
|
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'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -3865,10 +3917,26 @@ packages:
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz}
|
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:
|
xmlbuilder@15.1.1:
|
||||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz}
|
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz}
|
||||||
engines: {node: '>=8.0'}
|
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:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -4711,6 +4779,8 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc@4.62.4':
|
'@rollup/rollup-win32-x64-msvc@4.62.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@teppeis/multimaps@3.0.0': {}
|
'@teppeis/multimaps@3.0.0': {}
|
||||||
|
|
@ -4864,6 +4934,10 @@ snapshots:
|
||||||
|
|
||||||
'@types/uuid@10.0.0': {}
|
'@types/uuid@10.0.0': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.20.1
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.20.1
|
'@types/node': 22.20.1
|
||||||
|
|
@ -5080,6 +5154,8 @@ snapshots:
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.11.14: {}
|
baseline-browser-mapping@2.11.14: {}
|
||||||
|
|
||||||
bcrypt-pbkdf@1.0.2:
|
bcrypt-pbkdf@1.0.2:
|
||||||
|
|
@ -5328,6 +5404,8 @@ snapshots:
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
cookie@2.0.1: {}
|
||||||
|
|
||||||
cookiejar@2.1.4: {}
|
cookiejar@2.1.4: {}
|
||||||
|
|
||||||
core-util-is@1.0.2: {}
|
core-util-is@1.0.2: {}
|
||||||
|
|
@ -5672,6 +5750,36 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
once: 1.4.0
|
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:
|
enhanced-resolve@5.24.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
@ -7287,6 +7395,47 @@ snapshots:
|
||||||
astral-regex: 2.0.0
|
astral-regex: 2.0.0
|
||||||
is-fullwidth-code-point: 3.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-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
|
|
@ -7696,8 +7845,12 @@ snapshots:
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.21.3: {}
|
||||||
|
|
||||||
xmlbuilder@15.1.1: {}
|
xmlbuilder@15.1.1: {}
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
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).
|
évènement est un fait historique qui survit au compte qu'il décrit).
|
||||||
|
|
||||||
**Monitoring** (`admin-monitoring.service.ts`, `GET /admin/api/monitoring`,
|
**Monitoring** (`admin-monitoring.service.ts`'s `getMonitoring()`) — sonde
|
||||||
`requireAdmin`) — sonde active, chaque cible bornée à ~2 s, latence mesurée :
|
active, chaque cible bornée à ~2 s, latence mesurée : Postgres
|
||||||
Postgres (`SELECT 1`), l'API elle-même (uptime/RSS), `tech-step-intent-service`
|
(`SELECT 1`), l'API elle-même (uptime/RSS), `tech-step-intent-service`
|
||||||
(`GET /health`, sans secret), et le **worker LLM** via sa ligne
|
(`GET /health`, sans secret), et le **worker LLM** via sa ligne
|
||||||
`WorkerHeartbeat`. `MonitoringView { services: ServiceHealthView[] }`, statut
|
`WorkerHeartbeat`. `MonitoringView { services: ServiceHealthView[] }`,
|
||||||
`up | degraded | down | unknown` ; une sonde `down` ne fait jamais échouer les
|
statut `up | degraded | down | unknown` ; une sonde `down` ne fait jamais
|
||||||
autres ni l'endpoint. Le front (`MonitoringPage`) re-poll toutes les 15 s.
|
é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
|
Le worker (`services/tech-step-llm-worker`) n'a **aucune** surface HTTP
|
||||||
entrante — il devient observable via `POST /internal/tech-steps/heartbeat`
|
entrante — il devient observable via `POST /internal/tech-steps/heartbeat`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue