2026-05-19 23:46:16 -04:00
|
|
|
|
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: Number(row.stale_seconds) < 120,
|
|
|
|
|
|
})));
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-20 14:18:22 -04:00
|
|
|
|
// POST /heartbeat – upsert this node's registration (includes hardware capabilities)
|
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-20 14:18:22 -04:00
|
|
|
|
capabilities, metadata,
|
2026-05-19 23:46:16 -04:00
|
|
|
|
} = 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,
|
2026-05-20 14:18:22 -04:00
|
|
|
|
cpu_usage, mem_used_mb, mem_total_mb, last_seen, capabilities, metadata)
|
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10)
|
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-20 14:18:22 -04:00
|
|
|
|
capabilities = EXCLUDED.capabilities,
|
2026-05-19 23:46:16 -04:00
|
|
|
|
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,
|
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,
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
|
|
|
|
|
res.json(r.rows[0]);
|
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-20 13:49:56 -04:00
|
|
|
|
// 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); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-19 23:46:16 -04:00
|
|
|
|
// 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]
|
|
|
|
|
|
);
|
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;
|