From 66844b93d3fdb1fb7befd1ff6bcba518b6107f11 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Tue, 19 May 2026 23:46:16 -0400 Subject: [PATCH] =?UTF-8?q?feat(cluster):=20node=20registry=20API=20?= =?UTF-8?q?=E2=80=94=20heartbeat,=20list,=20deregister?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/mam-api/src/routes/cluster.js | 81 ++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 services/mam-api/src/routes/cluster.js diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js new file mode 100644 index 0000000..0b686e3 --- /dev/null +++ b/services/mam-api/src/routes/cluster.js @@ -0,0 +1,81 @@ +import express from 'express'; +import pool from '../db/pool.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(requireAuth); + +// 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 = last heartbeat within 2 minutes + online: Number(row.stale_seconds) < 120, + }))); + } catch (err) { next(err); } +}); + +// POST /heartbeat – upsert this node's registration +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, + metadata, + } = req.body; + + if (!hostname) return res.status(400).json({ error: 'hostname is required' }); + + 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, metadata) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9) + 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(), + metadata = EXCLUDED.metadata + RETURNING *`, + [ + hostname, + ip_address || null, + 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, + metadata != null ? JSON.stringify(metadata) : null, + ] + ); + res.json(r.rows[0]); + } 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;