feat(mam-api): aggregate containers from all nodes + proxy logs

This commit is contained in:
Wild Dragon Dev 2026-06-04 01:42:13 +00:00
parent a90adb5b52
commit 2f13c8d8b1

View file

@ -96,46 +96,83 @@ router.get('/', async (req, res, next) => {
router.get('/containers', async (req, res, next) => { router.get('/containers', async (req, res, next) => {
try { try {
const containers = await dockerRequest('/containers/json?all=true'); const nodesRes = await pool.query(
if (!Array.isArray(containers)) return res.json([]); `SELECT id, hostname, api_url,
const out = await Promise.all(containers.map(async c => { EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
const rawName = (c.Names[0] || '').replace(/^\//, ''); FROM cluster_nodes
const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, ''); ORDER BY registered_at ASC`
const ports = (c.Ports || []) );
.filter(p => p.PublicPort)
.map(p => `${p.PublicPort}${p.PrivatePort}`) const tasks = nodesRes.rows.map(async node => {
.join(', '); const isOnline = Number(node.stale_seconds) < 120;
// Live memory usage requires a per-container stats call (the list endpoint if (!isOnline) return [];
// 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`. const localHostname = process.env.NODE_HOSTNAME || os.hostname();
let memBytes = null; const isLocal = node.hostname === localHostname || !node.api_url;
if (c.State === 'running') {
try { try {
const stats = await dockerRequest(`/containers/${c.Id}/stats?stream=false`); let rawContainers = [];
const ms = stats && stats.memory_stats; if (isLocal) {
if (ms && typeof ms.usage === 'number') { rawContainers = await dockerRequest('/containers/json?all=true') || [];
const cache = (ms.stats && ms.stats.cache) || 0; } else {
memBytes = ms.usage - cache; const resp = await fetch(`${node.api_url}/containers`, {
} headers: agentAuthHeaders(),
} catch (_) { memBytes = null; } 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, const results = await Promise.all(tasks);
image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40), const flattened = results.flat();
state: c.State, res.json(flattened);
uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(), } catch (err) { next(err); }
healthy: (c.Status || '').includes('healthy'), });
ports,
cpu: 0, router.get('/containers/:nodeId/:containerId/logs', requireAdmin, async (req, res, next) => {
memBytes, try {
}; const { nodeId, containerId } = req.params;
})); const node = await resolveNode(nodeId);
res.json(out); if (!node) return res.status(404).json({ error: 'Node not found' });
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]); const localHostname = process.env.NODE_HOSTNAME || os.hostname();
next(err); const isLocal = node.hostname === localHostname || !node.api_url;
}
if (isLocal) {
const logs = await dockerRequest(`/containers/${containerId}/logs?stdout=1&stderr=1&tail=200&timestamps=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) => { router.post('/containers/:nameOrId/restart', async (req, res, next) => {