master-zapier-plan-draft/packages/verae-fleet/src/server.js
George Lambert 5ad3222def
Some checks are pending
offline / test (push) Waiting to run
Default the public catalog to colored PDFs
Render published markdown with pandoc + WeasyPrint (indigo tables,
dark code). Home page links to PDFs with a top-right switch to
Markdown indexes. Serve CONSOLE.pdf from the operator console.
2026-09-11 14:23:19 -04:00

145 lines
5.9 KiB
JavaScript

/**
* Operator HTTP: list, status, pause/resume/stop/start/restart.
* Binds 127.0.0.1 only.
* @module server
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { listServices } from './load.js';
import { ACTIONS, Simulator } from '../../verae-zapier-simulator/src/pipeline.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
function json(res, code, obj) {
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(obj, null, 2));
}
/**
* @param {import('./supervisor.js').Supervisor} sup
* @param {import('./monitor.js').Monitor} [mon]
*/
export function startControlServer(sup, mon) {
const bind = sup.loaded.control?.bind || '127.0.0.1';
const port = Number(sup.loaded.control?.port || 3850);
const sim = new Simulator();
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${bind}:${port}`);
try {
if (req.method === 'GET' && url.pathname === '/health') {
return json(res, 200, { ok: true, role: 'operator-console' });
}
if (req.method === 'GET' && url.pathname === '/api/sim/actions') {
return json(res, 200, { actions: ACTIONS, state: sim.snapshot() });
}
if (req.method === 'POST' && url.pathname === '/api/sim/reset') {
sim.reset();
return json(res, 200, { ok: true, state: sim.snapshot() });
}
if (req.method === 'POST' && url.pathname === '/api/sim/run') {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
const result = await sim.run(body);
return json(res, 200, result);
}
if (req.method === 'GET' && url.pathname === '/api/services') {
return json(res, 200, { services: listServices(sup.loaded), fleetPath: sup.loaded.fleetPath });
}
if (req.method === 'GET' && url.pathname === '/api/status') {
return json(res, 200, { ...sup.status(), monitor: mon?.last || null });
}
if (req.method === 'GET' && url.pathname === '/api/machines') {
return json(res, 200, { machines: sup.status().machines });
}
if (req.method === 'POST' && url.pathname === '/api/machines') {
const chunks = [];
for await (const c of req) chunks.push(c);
const spec = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
const m = sup.addMachine(spec);
return json(res, 200, { machine: m, machines: sup.status().machines });
}
const mach = url.pathname.match(/^\/api\/machines\/([^/]+)\/(enable|disable|check)$/);
if (mach && req.method === 'POST') {
const [, id, op] = mach;
if (op === 'check') {
const check = await sup.checkMachine(id);
return json(res, check.ok ? 200 : 400, { check, machines: sup.status().machines });
}
const m = sup.setMachineEnabled(id, op === 'enable');
return json(res, 200, { machine: m, machines: sup.status().machines });
}
if (req.method === 'POST' && url.pathname === '/api/reconcile') {
const actions = await sup.reconcile();
return json(res, 200, { actions, status: sup.status() });
}
const svc = url.pathname.match(/^\/api\/services\/([^/]+)\/(start|stop|pause|resume)$/);
if (svc && req.method === 'POST') {
const [, id, op] = svc;
const fn = {
start: () => sup.startService(id),
stop: () => sup.stopService(id),
pause: () => sup.pauseService(id),
resume: () => sup.resumeService(id),
}[op];
const result = await fn();
return json(res, 200, { op, id, result, status: sup.status().services[id] });
}
const inst = url.pathname.match(/^\/api\/instances\/([^/]+)\/(pause|resume|restart|stop|unhealthy)$/);
if (inst && req.method === 'POST') {
const [, id, op] = inst;
const fn = {
pause: () => sup.pauseInstance(id),
resume: () => sup.resumeInstance(id),
restart: () => sup.restartInstance(id),
stop: () => sup.stopInstance(id, { replace: true }),
unhealthy: () => sup.markUnhealthy(id),
}[op];
const result = await fn();
return json(res, 200, { op, id, result: result?.id || result, status: sup.status() });
}
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
const dest = path.join(PUBLIC, 'index.html');
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
fs.createReadStream(dest).pipe(res);
return;
}
if (req.method === 'GET' && url.pathname.startsWith('/docs/')) {
const dest = path.normalize(path.join(PUBLIC, '..', url.pathname));
const root = path.normalize(path.join(PUBLIC, '..', 'docs'));
if (
!(dest === root || dest.startsWith(root + path.sep)) ||
!fs.existsSync(dest) ||
!fs.statSync(dest).isFile()
) {
return json(res, 404, { error: 'not found' });
}
const ext = path.extname(dest).toLowerCase();
const types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
'.md': 'text/markdown; charset=utf-8',
'.html': 'text/html; charset=utf-8',
};
res.writeHead(200, { 'content-type': types[ext] || 'application/octet-stream' });
fs.createReadStream(dest).pipe(res);
return;
}
json(res, 404, { error: 'not found' });
} catch (err) {
json(res, 400, { error: err.message });
}
});
server.listen(port, bind, () => {
process.stdout.write(`fleet control http://${bind}:${port}/\n`);
});
return server;
}