Some checks are pending
offline / test (push) Waiting to run
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.
145 lines
5.9 KiB
JavaScript
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;
|
|
}
|