From 0efef0d81b15cc6b6dc42dd33213dd659964cc18 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 21 May 2026 00:16:36 -0400 Subject: [PATCH] cluster route: fallback IP from request + /devices/blackmagic endpoint Heartbeat handler now overrides 172.x docker bridge IPs with the request's source address when the request itself came from a real LAN. Adds GET /devices/blackmagic that flattens every node's capabilities so the recorder UI can show a card picker spanning the whole cluster. --- services/mam-api/src/routes/cluster.js | 48 +++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index fdd0fca..8c5f638 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -5,6 +5,18 @@ import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); +// If the agent reported a Docker-bridge IP (172.16/12) but the request +// itself came from a real LAN address, prefer the request's source — the +// agent likely runs in bridge mode without NODE_IP set. +function pickIp(reportedIp, reqIp) { + const clean = (s) => (s || '').replace(/^::ffff:/, ''); + const isBridge = (ip) => /^172\.(1[6-9]|2\d|3[01])\./.test(ip || ''); + const r = clean(reqIp); + if (!reportedIp) return r || null; + if (isBridge(reportedIp) && r && !isBridge(r)) return r; + return reportedIp; +} + // GET / – list all registered cluster nodes with online status router.get('/', async (req, res, next) => { try { @@ -33,6 +45,8 @@ router.post('/heartbeat', async (req, res, next) => { 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, @@ -52,7 +66,7 @@ router.post('/heartbeat', async (req, res, next) => { RETURNING *`, [ hostname, - ip_address || null, + effectiveIp, role, version || null, api_url || null, @@ -67,6 +81,38 @@ router.post('/heartbeat', async (req, res, next) => { } 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 {