feat(server): /svc/status — how F is doing, for the dashboard's monitoring (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, its uptime, Node's version. Behind the service key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,8 @@
|
|||||||
// the dashboard sends, copy a project (as it is now, also while people work
|
// 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
|
// 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
|
// 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 { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
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);
|
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<void> {
|
export async function handleService(request: IncomingMessage, response: ServerResponse, path: string, options: ServiceOptions): Promise<void> {
|
||||||
const send = (status: number, value: object) => {
|
const send = (status: number, value: object) => {
|
||||||
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
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 }];
|
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/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');
|
throw new HttpError(404, 'notFound');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ describe('a simulation room on F', () => {
|
|||||||
['sim.stop', 'ben'],
|
['sim.stop', 'ben'],
|
||||||
]);
|
]);
|
||||||
expect(server.sims.stats()).toEqual({ rooms: 1, people: 2, threads: [1] });
|
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<string, unknown> & { 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();
|
mia.socket.close();
|
||||||
ben.socket.close();
|
ben.socket.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user