import express from 'express'; import http from 'http'; import pool from '../db/pool.js'; import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); // 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. function pickIp(reportedIp, reqIp) { const clean = (s) => (s || '').replace(/^::ffff:/, ''); const isDockerBridge = (ip) => /^172\.17\./.test(ip || ''); const r = clean(reqIp); if (!reportedIp) return r || null; if (isDockerBridge(reportedIp) && r && !isDockerBridge(r)) return r; return reportedIp; } 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(); }); } // GET / – list all registered cluster nodes with online status 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); } }); // GET /containers – list all containers on the local Docker host 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); } }); // POST /containers/:nameOrId/restart 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); } }); // POST /heartbeat – upsert this node's registration (includes hardware capabilities) 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, capabilities, metadata, } = req.body; if (!hostname) return res.status(400).json({ error: 'hostname is required' }); const effectiveIp = pickIp(ip_address, req.ip || req.socket?.remoteAddress); const r = await pool.query( `INSERT INTO cluster_nodes (hostname, ip_address, role, version, api_url, cpu_usage, mem_used_mb, mem_total_mb, last_seen, capabilities, metadata) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10) 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(), capabilities = EXCLUDED.capabilities, metadata = EXCLUDED.metadata RETURNING *`, [ hostname, effectiveIp, 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, capabilities != null ? JSON.stringify(capabilities) : '{}', metadata != null ? JSON.stringify(metadata) : null, ] ); res.json(r.rows[0]); } catch (err) { next(err); } }); // GET /devices/blackmagic – flatten every node's DeckLink cards for the // recorder picker. Returns one entry per device with the host node info. 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 FROM cluster_nodes WHERE capabilities IS NOT NULL` ); 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) => { 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, }); }); } res.json(out); } catch (err) { next(err); } }); // GET /:id/ping – probe the node's api_url/health endpoint directly router.get('/:id/ping', async (req, res, next) => { try { const r = await pool.query( 'SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id] ); 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 { const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000), }); 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); } }); // DELETE /:id – deregister a node router.delete('/:id', async (req, res, next) => { try { const r = await pool.query( 'DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id] ); if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' }); res.json({ ok: true }); } catch (err) { next(err); } }); export default router;