diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index e50ee34..f94d9fa 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -96,46 +96,83 @@ router.get('/', async (req, res, next) => { 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 = await Promise.all(containers.map(async 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(', '); - // Live memory usage requires a per-container stats call (the list endpoint - // doesn't include it). One extra Docker call each, but the list is small. - // memory_stats.usage includes page cache; subtract it to match `docker stats`. - let memBytes = null; - if (c.State === 'running') { - try { - const stats = await dockerRequest(`/containers/${c.Id}/stats?stream=false`); - const ms = stats && stats.memory_stats; - if (ms && typeof ms.usage === 'number') { - const cache = (ms.stats && ms.stats.cache) || 0; - memBytes = ms.usage - cache; - } - } catch (_) { memBytes = null; } + const nodesRes = await pool.query( + `SELECT id, hostname, api_url, + EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds + FROM cluster_nodes + ORDER BY registered_at ASC` + ); + + const tasks = nodesRes.rows.map(async node => { + const isOnline = Number(node.stale_seconds) < 120; + if (!isOnline) return []; + + const localHostname = process.env.NODE_HOSTNAME || os.hostname(); + const isLocal = node.hostname === localHostname || !node.api_url; + + try { + let rawContainers = []; + if (isLocal) { + rawContainers = await dockerRequest('/containers/json?all=true') || []; + } else { + const resp = await fetch(`${node.api_url}/containers`, { + headers: agentAuthHeaders(), + signal: AbortSignal.timeout(4000), + }); + if (resp.ok) rawContainers = await resp.json(); + } + + if (!Array.isArray(rawContainers)) return []; + + return rawContainers.map(c => { + const rawName = (c.Names && c.Names[0] || '').replace(/^\//, ''); + const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, ''); + return { + id: c.Id.slice(0, 12), + name, + image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40), + state: c.State, + status: c.Status, + uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(), + healthy: (c.Status || '').includes('healthy'), + node_hostname: node.hostname, + node_id: node.id, + }; + }); + } catch (err) { + console.warn(`[cluster] failed to fetch containers from ${node.hostname}:`, err.message); + return []; } - 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, - memBytes, - }; - })); - res.json(out); - } catch (err) { - if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]); - next(err); - } + }); + + const results = await Promise.all(tasks); + const flattened = results.flat(); + res.json(flattened); + } catch (err) { next(err); } +}); + +router.get('/containers/:nodeId/:containerId/logs', requireAdmin, async (req, res, next) => { + try { + const { nodeId, containerId } = req.params; + const node = await resolveNode(nodeId); + if (!node) return res.status(404).json({ error: 'Node not found' }); + + const localHostname = process.env.NODE_HOSTNAME || os.hostname(); + const isLocal = node.hostname === localHostname || !node.api_url; + + if (isLocal) { + const logs = await dockerRequest(`/containers/${containerId}/logs?stdout=1&stderr=1&tail=200×tamps=1`); + res.json({ logs: logs || '(no logs)' }); + } else { + const resp = await fetch(`${node.api_url}/sidecar/${containerId}/logs`, { + headers: agentAuthHeaders(), + signal: AbortSignal.timeout(6000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch remote logs' }); + const data = await resp.json(); + res.json(data); + } + } catch (err) { next(err); } }); router.post('/containers/:nameOrId/restart', async (req, res, next) => {