diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts index 9ba683b..2cf0caa 100644 --- a/packages/server/src/service.ts +++ b/packages/server/src/service.ts @@ -12,7 +12,8 @@ // the dashboard sends, copy a project (as it is now, also while people work // in it), and give a project as a .fls (with what it could not hold, as // warnings in X-Filius-Warnings, base64url JSON). Snapshots and restoring come -// with the commits (F7). +// with the commits (F7). And how F is doing (/svc/status, F9): for the +// dashboard's monitoring. import { createHash, timingSafeEqual } from 'node:crypto'; import type { IncomingMessage, ServerResponse } from 'node:http'; @@ -115,6 +116,26 @@ function openDocuments(sync: Sync): { readonly user: string; readonly project: s return [...seen.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, open]) => open); } +/** + * How F is doing, for the dashboard's monitoring (plan F9): what /healthz + * says — documents, connections, rooms — and what it keeps to itself: the + * people in the rooms, the rooms each thread holds, the process's memory + * and how long it has run. Behind the service key: none of it is anybody + * else's business. + */ +function statusOf(sync: Sync, sims: SimRooms) { + const memory = process.memoryUsage(); + const megabytes = (bytes: number) => Math.round(bytes / 1048576); + return { + documents: sync.hocuspocus.getDocumentsCount(), + connections: sync.hocuspocus.getConnectionsCount(), + ...sims.stats(), + memory: { rssMb: megabytes(memory.rss), heapUsedMb: megabytes(memory.heapUsed) }, + uptimeSeconds: Math.round(process.uptime()), + node: process.version, + }; +} + export async function handleService(request: IncomingMessage, response: ServerResponse, path: string, options: ServiceOptions): Promise { const send = (status: number, value: object) => { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); @@ -253,5 +274,6 @@ async function route(request: IncomingMessage, path: string, { store, sessions, return [200, { revoked, disconnected }]; } if (request.method === 'GET' && path === '/svc/sessions') return [200, { ...(await sessions.active()), open: openDocuments(sync) }]; + if (request.method === 'GET' && path === '/svc/status') return [200, statusOf(sync, sims)]; throw new HttpError(404, 'notFound'); } diff --git a/packages/server/test/sim.test.ts b/packages/server/test/sim.test.ts index f7f414f..9815192 100644 --- a/packages/server/test/sim.test.ts +++ b/packages/server/test/sim.test.ts @@ -132,6 +132,17 @@ describe('a simulation room on F', () => { ['sim.stop', 'ben'], ]); expect(server.sims.stats()).toEqual({ rooms: 1, people: 2, threads: [1] }); + // What the dashboard's monitoring reads (F9): the same, and the process — only with the service key. + const status = await fetch(`${server.base}/svc/status`, { headers: { 'x-filius-service-key': SERVICE_KEY } }); + expect(status.status).toBe(200); + const said = (await status.json()) as Record & { memory: { rssMb: number; heapUsedMb: number }; uptimeSeconds: number }; + expect(said).toMatchObject({ rooms: 1, people: 2, threads: [1], node: process.version }); + expect(said.documents).toBeGreaterThanOrEqual(1); + expect(said.connections).toBeGreaterThanOrEqual(0); + expect(said.memory.rssMb).toBeGreaterThan(0); + expect(said.memory.heapUsedMb).toBeGreaterThan(0); + expect(said.uptimeSeconds).toBeGreaterThanOrEqual(0); + expect((await fetch(`${server.base}/svc/status`)).status).toBe(403); mia.socket.close(); ben.socket.close(); await server.close();