/** * 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; }