2026-05-19 23:46:16 -04:00
|
|
|
|
import express from 'express';
|
2026-05-22 16:57:33 -04:00
|
|
|
|
import http from 'http';
|
2026-05-19 23:46:16 -04:00
|
|
|
|
import pool from '../db/pool.js';
|
2026-05-31 17:37:37 -04:00
|
|
|
|
import { requireAdmin } from '../middleware/auth.js';
|
2026-05-19 23:46:16 -04:00
|
|
|
|
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
|
2026-05-31 17:37:37 -04:00
|
|
|
|
// GET /onboard-info – admin-only. Supplies the Add Node wizard with the bits it
|
|
|
|
|
|
// needs to build a `curl … | bash` onboarding command: the primary API URL the
|
|
|
|
|
|
// remote node-agent should heartbeat to, the raw URL of onboard-node.sh, and
|
|
|
|
|
|
// the deploy branch. apiUrl is a best guess the UI lets the operator edit.
|
|
|
|
|
|
router.get('/onboard-info', requireAdmin, (req, res) => {
|
|
|
|
|
|
const branch = process.env.DEPLOY_BRANCH || 'main';
|
|
|
|
|
|
const apiUrl = process.env.PUBLIC_API_URL
|
|
|
|
|
|
|| `${req.protocol}://${req.hostname}:${process.env.API_PORT || 47432}`;
|
|
|
|
|
|
const scriptUrl =
|
|
|
|
|
|
`https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/${branch}/deploy/onboard-node.sh`;
|
|
|
|
|
|
res.json({ apiUrl, scriptUrl, branch });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 21:27:15 -04:00
|
|
|
|
// If the agent reported Docker's default bridge IP (172.17.x) but the request
|
|
|
|
|
|
// itself came from a real LAN address, prefer the request source IP instead.
|
|
|
|
|
|
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918
|
|
|
|
|
|
// 172.16/12 block, since real LANs (e.g. 172.18.91.x) fall in that range.
|
2026-05-21 00:16:36 -04:00
|
|
|
|
function pickIp(reportedIp, reqIp) {
|
|
|
|
|
|
const clean = (s) => (s || '').replace(/^::ffff:/, '');
|
2026-05-21 21:27:15 -04:00
|
|
|
|
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
|
2026-05-21 00:16:36 -04:00
|
|
|
|
const r = clean(reqIp);
|
|
|
|
|
|
if (!reportedIp) return r || null;
|
2026-05-21 21:27:15 -04:00
|
|
|
|
if (isDockerBridge(reportedIp) && r && !isDockerBridge(r)) return r;
|
2026-05-21 00:16:36 -04:00
|
|
|
|
return reportedIp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:57:33 -04:00
|
|
|
|
function dockerRequest(path, method = 'GET', body = null) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const opts = {
|
|
|
|
|
|
socketPath: '/var/run/docker.sock',
|
|
|
|
|
|
path: `/v1.41${path}`,
|
|
|
|
|
|
method,
|
|
|
|
|
|
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
|
|
|
|
|
};
|
|
|
|
|
|
const req = http.request(opts, (res) => {
|
|
|
|
|
|
let data = '';
|
|
|
|
|
|
res.on('data', d => { data += d; });
|
|
|
|
|
|
res.on('end', () => {
|
|
|
|
|
|
if (!data.trim()) return resolve(null);
|
|
|
|
|
|
try { resolve(JSON.parse(data)); }
|
|
|
|
|
|
catch (e) { resolve(null); }
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
req.on('error', reject);
|
|
|
|
|
|
req.setTimeout(5000, () => { req.destroy(); reject(new Error('Docker socket timeout')); });
|
|
|
|
|
|
if (body) req.write(JSON.stringify(body));
|
|
|
|
|
|
req.end();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 23:46:16 -04:00
|
|
|
|
router.get('/', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await pool.query(
|
|
|
|
|
|
`SELECT *,
|
|
|
|
|
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
|
|
|
|
|
FROM cluster_nodes
|
|
|
|
|
|
ORDER BY registered_at ASC`
|
|
|
|
|
|
);
|
|
|
|
|
|
res.json(r.rows.map(row => ({
|
|
|
|
|
|
...row,
|
|
|
|
|
|
online: Number(row.stale_seconds) < 120,
|
|
|
|
|
|
})));
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-22 16:57:33 -04:00
|
|
|
|
router.get('/containers', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const containers = await dockerRequest('/containers/json?all=true');
|
|
|
|
|
|
if (!Array.isArray(containers)) return res.json([]);
|
|
|
|
|
|
const out = containers.map(c => {
|
|
|
|
|
|
const rawName = (c.Names[0] || '').replace(/^\//, '');
|
|
|
|
|
|
const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, '');
|
|
|
|
|
|
const ports = (c.Ports || [])
|
|
|
|
|
|
.filter(p => p.PublicPort)
|
|
|
|
|
|
.map(p => `${p.PublicPort}→${p.PrivatePort}`)
|
|
|
|
|
|
.join(', ');
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: c.Id.slice(0, 12),
|
|
|
|
|
|
name,
|
|
|
|
|
|
image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40),
|
|
|
|
|
|
state: c.State,
|
|
|
|
|
|
uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(),
|
|
|
|
|
|
healthy: (c.Status || '').includes('healthy'),
|
|
|
|
|
|
ports,
|
|
|
|
|
|
cpu: 0,
|
|
|
|
|
|
mem: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
res.json(out);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]);
|
|
|
|
|
|
next(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
|
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-19 23:46:16 -04:00
|
|
|
|
router.post('/heartbeat', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const {
|
|
|
|
|
|
hostname, ip_address,
|
|
|
|
|
|
role = 'worker', version, api_url,
|
|
|
|
|
|
cpu_usage, mem_used_mb, mem_total_mb,
|
2026-05-28 21:04:24 -04:00
|
|
|
|
capabilities, metadata, metrics,
|
2026-05-19 23:46:16 -04:00
|
|
|
|
} = req.body;
|
|
|
|
|
|
|
|
|
|
|
|
if (!hostname) return res.status(400).json({ error: 'hostname is required' });
|
|
|
|
|
|
|
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
|
|
|
|
if (process.env.AUTH_ENABLED === 'true') {
|
|
|
|
|
|
const bound = req.tokenBoundHostname;
|
|
|
|
|
|
if (bound && bound !== hostname) {
|
|
|
|
|
|
return res.status(403).json({
|
|
|
|
|
|
error: `Token is bound to "${bound}" but heartbeat reported "${hostname}"`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!bound && req.user?.role !== 'admin') {
|
|
|
|
|
|
return res.status(403).json({
|
|
|
|
|
|
error: 'Heartbeat requires a node-bound token or admin session',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 00:16:36 -04:00
|
|
|
|
const effectiveIp = pickIp(ip_address, req.ip || req.socket?.remoteAddress);
|
|
|
|
|
|
|
2026-05-19 23:46:16 -04:00
|
|
|
|
const r = await pool.query(
|
|
|
|
|
|
`INSERT INTO cluster_nodes
|
|
|
|
|
|
(hostname, ip_address, role, version, api_url,
|
2026-05-30 16:32:11 -04:00
|
|
|
|
cpu_usage, mem_used_mb, mem_total_mb, last_seen, last_seen_at, capabilities, metadata, metrics)
|
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW(),$9,$10,$11)
|
2026-05-19 23:46:16 -04:00
|
|
|
|
ON CONFLICT (hostname) DO UPDATE SET
|
|
|
|
|
|
ip_address = EXCLUDED.ip_address,
|
|
|
|
|
|
role = EXCLUDED.role,
|
|
|
|
|
|
version = EXCLUDED.version,
|
|
|
|
|
|
api_url = EXCLUDED.api_url,
|
|
|
|
|
|
cpu_usage = EXCLUDED.cpu_usage,
|
|
|
|
|
|
mem_used_mb = EXCLUDED.mem_used_mb,
|
|
|
|
|
|
mem_total_mb = EXCLUDED.mem_total_mb,
|
|
|
|
|
|
last_seen = NOW(),
|
2026-05-30 16:32:11 -04:00
|
|
|
|
last_seen_at = NOW(),
|
2026-05-20 14:18:22 -04:00
|
|
|
|
capabilities = EXCLUDED.capabilities,
|
2026-05-28 21:04:24 -04:00
|
|
|
|
metadata = EXCLUDED.metadata,
|
|
|
|
|
|
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
|
2026-05-19 23:46:16 -04:00
|
|
|
|
RETURNING *`,
|
|
|
|
|
|
[
|
|
|
|
|
|
hostname,
|
2026-05-21 00:16:36 -04:00
|
|
|
|
effectiveIp,
|
2026-05-19 23:46:16 -04:00
|
|
|
|
role,
|
|
|
|
|
|
version || null,
|
|
|
|
|
|
api_url || null,
|
|
|
|
|
|
cpu_usage != null ? cpu_usage : null,
|
|
|
|
|
|
mem_used_mb != null ? mem_used_mb : null,
|
|
|
|
|
|
mem_total_mb != null ? mem_total_mb : null,
|
2026-05-20 14:18:22 -04:00
|
|
|
|
capabilities != null ? JSON.stringify(capabilities) : '{}',
|
2026-05-19 23:46:16 -04:00
|
|
|
|
metadata != null ? JSON.stringify(metadata) : null,
|
2026-05-28 21:04:24 -04:00
|
|
|
|
metrics != null ? JSON.stringify(metrics) : null,
|
2026-05-19 23:46:16 -04:00
|
|
|
|
]
|
|
|
|
|
|
);
|
|
|
|
|
|
res.json(r.rows[0]);
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-26 18:02:38 -04:00
|
|
|
|
router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const nodesResult = await pool.query(
|
|
|
|
|
|
`SELECT id, hostname, ip_address, api_url, capabilities,
|
|
|
|
|
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
|
|
|
|
|
FROM cluster_nodes
|
|
|
|
|
|
WHERE capabilities IS NOT NULL`
|
|
|
|
|
|
);
|
|
|
|
|
|
const recResult = await pool.query(
|
|
|
|
|
|
`SELECT id, name, status, container_id, node_id, device_index,
|
|
|
|
|
|
source_config
|
|
|
|
|
|
FROM recorders
|
|
|
|
|
|
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
|
|
|
|
|
|
);
|
|
|
|
|
|
const recByPort = new Map();
|
|
|
|
|
|
for (const r of recResult.rows) {
|
|
|
|
|
|
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
|
|
|
|
|
|
recByPort.set(`${r.node_id}:${devIdx}`, r);
|
|
|
|
|
|
}
|
|
|
|
|
|
const tasks = [];
|
|
|
|
|
|
for (const node of nodesResult.rows) {
|
|
|
|
|
|
const nodeOnline = Number(node.stale_seconds) < 120;
|
|
|
|
|
|
const bm = (node.capabilities && node.capabilities.blackmagic) || [];
|
|
|
|
|
|
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
|
|
|
|
|
|
const localHostname = process.env.NODE_HOSTNAME || '';
|
|
|
|
|
|
const isRemote = node.api_url && node.hostname !== localHostname;
|
|
|
|
|
|
bm.forEach((d, idx) => {
|
|
|
|
|
|
const portIndex = d.index !== undefined ? d.index : idx;
|
|
|
|
|
|
const rec = recByPort.get(`${node.id}:${portIndex}`);
|
|
|
|
|
|
tasks.push((async () => {
|
|
|
|
|
|
const base = {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
node_id: node.id, hostname: node.hostname, index: portIndex,
|
|
|
|
|
|
device: d.device || null, model, node_online: nodeOnline,
|
|
|
|
|
|
recorder_id: rec ? rec.id : null, recorder_name: rec ? rec.name : null,
|
2026-05-26 18:02:38 -04:00
|
|
|
|
recorder_status: rec ? rec.status : null,
|
2026-05-30 16:32:11 -04:00
|
|
|
|
signal: 'no-recorder', framesReceived: null, currentFps: null,
|
2026-05-26 18:02:38 -04:00
|
|
|
|
};
|
|
|
|
|
|
if (!rec || rec.status !== 'recording' || !rec.container_id) {
|
|
|
|
|
|
if (rec && rec.status !== 'recording') base.signal = 'idle';
|
|
|
|
|
|
return base;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
let live = null;
|
|
|
|
|
|
if (isRemote) {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
|
2026-05-26 18:02:38 -04:00
|
|
|
|
if (r.ok) live = (await r.json()).live;
|
|
|
|
|
|
} else {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
2026-05-26 18:02:38 -04:00
|
|
|
|
if (r.ok) live = await r.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (live && live.signal) {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
base.signal = live.signal;
|
2026-05-26 18:02:38 -04:00
|
|
|
|
base.framesReceived = live.framesReceived ?? null;
|
2026-05-30 16:32:11 -04:00
|
|
|
|
base.currentFps = live.currentFps ?? null;
|
|
|
|
|
|
} else { base.signal = 'connecting'; }
|
|
|
|
|
|
} catch (_) { base.signal = 'connecting'; }
|
2026-05-26 18:02:38 -04:00
|
|
|
|
return base;
|
|
|
|
|
|
})());
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
const results = await Promise.all(tasks);
|
|
|
|
|
|
res.json(results);
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 00:16:36 -04:00
|
|
|
|
router.get('/devices/blackmagic', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await pool.query(
|
|
|
|
|
|
`SELECT id, hostname, ip_address, role, capabilities,
|
|
|
|
|
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
2026-05-30 16:32:11 -04:00
|
|
|
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
2026-05-21 00:16:36 -04:00
|
|
|
|
);
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
for (const row of r.rows) {
|
|
|
|
|
|
const online = Number(row.stale_seconds) < 120;
|
|
|
|
|
|
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
|
|
|
|
|
|
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
|
|
|
|
|
|
bm.forEach((d, idx) => {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
|
|
|
|
|
|
role: row.role, online, model, index: d.index !== undefined ? d.index : idx, device: d.device });
|
2026-05-21 00:16:36 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
res.json(out);
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-28 19:12:40 -04:00
|
|
|
|
router.get('/devices/deltacast', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await pool.query(
|
|
|
|
|
|
`SELECT id, hostname, ip_address, role, capabilities,
|
|
|
|
|
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
2026-05-30 16:32:11 -04:00
|
|
|
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
2026-05-28 19:12:40 -04:00
|
|
|
|
);
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
for (const row of r.rows) {
|
|
|
|
|
|
const online = Number(row.stale_seconds) < 120;
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const dc = (row.capabilities && row.capabilities.deltacast) || [];
|
2026-05-28 19:12:40 -04:00
|
|
|
|
const model = (row.capabilities && row.capabilities.deltacast_model) || null;
|
|
|
|
|
|
dc.forEach((d, idx) => {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
|
|
|
|
|
|
role: row.role, online, model: model || 'Deltacast',
|
|
|
|
|
|
index: d.index !== undefined ? d.index : idx, device: d.device,
|
|
|
|
|
|
present: d.present !== false, port_count: dc.length });
|
2026-05-28 19:12:40 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
res.json(out);
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
router.get('/devices/deltacast/signal', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [nodesRes, recordersRes] = await Promise.all([
|
2026-05-30 16:32:11 -04:00
|
|
|
|
pool.query(`SELECT id, hostname, ip_address, api_url, capabilities,
|
2026-05-28 19:12:40 -04:00
|
|
|
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
2026-05-30 16:32:11 -04:00
|
|
|
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`),
|
|
|
|
|
|
pool.query(`SELECT id, node_id, device_index, status, source_type, container_id
|
|
|
|
|
|
FROM recorders WHERE source_type = 'deltacast'`),
|
2026-05-28 19:12:40 -04:00
|
|
|
|
]);
|
|
|
|
|
|
const recByNodePort = {};
|
|
|
|
|
|
for (const rec of recordersRes.rows) {
|
|
|
|
|
|
recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec;
|
|
|
|
|
|
}
|
|
|
|
|
|
const results = [];
|
|
|
|
|
|
const fetchPromises = [];
|
|
|
|
|
|
for (const node of nodesRes.rows) {
|
|
|
|
|
|
const online = Number(node.stale_seconds) < 120;
|
|
|
|
|
|
const dc = (node.capabilities && node.capabilities.deltacast) || [];
|
|
|
|
|
|
const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast';
|
|
|
|
|
|
for (const port of dc) {
|
|
|
|
|
|
const idx = port.index !== undefined ? port.index : dc.indexOf(port);
|
|
|
|
|
|
const rec = recByNodePort[`${node.id}:${idx}`];
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const base = { node_id: node.id, hostname: node.hostname, ip_address: node.ip_address,
|
|
|
|
|
|
online, model, index: idx, device: port.device, present: port.present !== false,
|
|
|
|
|
|
recorder_id: rec ? rec.id : null, recorder_status: rec ? rec.status : null,
|
|
|
|
|
|
signal: 'no-recorder', framesReceived: null, currentFps: null };
|
2026-05-28 19:12:40 -04:00
|
|
|
|
if (!rec) { results.push(base); continue; }
|
|
|
|
|
|
if (rec.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; }
|
|
|
|
|
|
const fetchIdx = results.length;
|
|
|
|
|
|
results.push(base);
|
|
|
|
|
|
fetchPromises.push((async () => {
|
|
|
|
|
|
try {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const url = node.api_url ? `${node.api_url}/sidecar/${rec.container_id}/status`
|
2026-05-28 19:12:40 -04:00
|
|
|
|
: `http://recorder-${rec.id}:3001/capture/status`;
|
|
|
|
|
|
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
|
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
|
const live = await r.json();
|
|
|
|
|
|
if (live && live.signal) {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
results[fetchIdx].signal = live.signal;
|
2026-05-28 19:12:40 -04:00
|
|
|
|
results[fetchIdx].framesReceived = live.framesReceived ?? null;
|
2026-05-30 16:32:11 -04:00
|
|
|
|
results[fetchIdx].currentFps = live.currentFps ?? null;
|
2026-05-28 19:12:40 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-30 16:32:11 -04:00
|
|
|
|
} catch (_) { results[fetchIdx].signal = 'connecting'; }
|
2026-05-28 19:12:40 -04:00
|
|
|
|
})());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await Promise.all(fetchPromises);
|
|
|
|
|
|
res.json(results);
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-20 13:49:56 -04:00
|
|
|
|
router.get('/:id/ping', async (req, res, next) => {
|
|
|
|
|
|
try {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const r = await pool.query('SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id]);
|
2026-05-20 13:49:56 -04:00
|
|
|
|
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
|
|
|
|
|
const node = r.rows[0];
|
|
|
|
|
|
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
|
|
|
|
|
|
const start = Date.now();
|
|
|
|
|
|
try {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
|
2026-05-20 13:49:56 -04:00
|
|
|
|
const latency_ms = Date.now() - start;
|
|
|
|
|
|
const body = await upstream.json().catch(() => ({}));
|
|
|
|
|
|
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
res.json({ reachable: false, latency_ms: Date.now() - start, reason: err.message });
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-28 21:04:24 -04:00
|
|
|
|
router.get('/metrics', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await pool.query(
|
|
|
|
|
|
`SELECT id, hostname, role, last_seen,
|
|
|
|
|
|
cpu_usage, mem_used_mb, mem_total_mb,
|
|
|
|
|
|
capabilities, metrics,
|
|
|
|
|
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
2026-05-30 16:32:11 -04:00
|
|
|
|
FROM cluster_nodes ORDER BY registered_at ASC`
|
2026-05-28 21:04:24 -04:00
|
|
|
|
);
|
|
|
|
|
|
const nodes = r.rows.map(row => {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const capGpus = (row.capabilities && row.capabilities.gpus) || [];
|
2026-05-28 21:04:24 -04:00
|
|
|
|
const liveGpus = (row.metrics && row.metrics.gpus) || [];
|
|
|
|
|
|
const gpus = capGpus.map((g, idx) => {
|
|
|
|
|
|
const live = liveGpus.find(l => l.index === g.index) || liveGpus[idx] || {};
|
2026-05-30 16:32:11 -04:00
|
|
|
|
return { name: g.name || null, util_pct: live.util_pct != null ? live.util_pct : null,
|
|
|
|
|
|
memory_used_mb: live.memory_used_mb != null ? live.memory_used_mb : null,
|
|
|
|
|
|
memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null) };
|
2026-05-28 21:04:24 -04:00
|
|
|
|
});
|
|
|
|
|
|
for (const lg of liveGpus) {
|
|
|
|
|
|
if (!capGpus.some(g => g.index === lg.index)) {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
gpus.push({ name: lg.name || null, util_pct: lg.util_pct != null ? lg.util_pct : null,
|
|
|
|
|
|
memory_used_mb: lg.memory_used_mb != null ? lg.memory_used_mb : null,
|
|
|
|
|
|
memory_total_mb: lg.memory_total_mb != null ? lg.memory_total_mb : null });
|
2026-05-28 21:04:24 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-30 16:32:11 -04:00
|
|
|
|
return { id: row.id, hostname: row.hostname, role: row.role,
|
|
|
|
|
|
online: Number(row.stale_seconds) < 120, last_seen: row.last_seen,
|
|
|
|
|
|
cpu_util_pct: row.cpu_usage != null ? Number(row.cpu_usage) : null,
|
|
|
|
|
|
ram_used_mb: row.mem_used_mb != null ? row.mem_used_mb : null,
|
|
|
|
|
|
ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null, gpus };
|
2026-05-28 21:04:24 -04:00
|
|
|
|
});
|
|
|
|
|
|
res.json({ nodes });
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-19 23:46:16 -04:00
|
|
|
|
router.delete('/:id', async (req, res, next) => {
|
|
|
|
|
|
try {
|
2026-05-30 16:32:11 -04:00
|
|
|
|
const r = await pool.query('DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id]);
|
2026-05-20 14:18:22 -04:00
|
|
|
|
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
2026-05-19 23:46:16 -04:00
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
export default router;
|