import { app, isRouteCurrent } from '../router.js'; import { api } from '../api.js'; import { escapeHTML, relativeTime, getEnvelopeType, getEnvelopePayload, getEnvelopeTS, isCurrentPath, infrastructureSkeleton, formatBytes, } from '../utils.js'; import { subscribeWS } from '../ws.js'; import { openclawState, swarmState, mergeOpenClawEvents, mergeSwarmSnapshot, mergeSwarmServiceSnapshot, getK8sHomelabServices, } from '../state.js'; let infraUnsubscribe = null; let _infraTimerInterval = null; export function cleanup() { if (infraUnsubscribe) { infraUnsubscribe(); infraUnsubscribe = null; } if (_infraTimerInterval) { clearInterval(_infraTimerInterval); _infraTimerInterval = null; } } function handleInfraWS(msg) { if (msg.type !== 'message') return; const eventType = getEnvelopeType(msg.data); if (eventType === 'openclaw.snapshot') { mergeOpenClawEvents([msg.data]); if (isCurrentPath('/infrastructure')) renderInfraGrid(); // Dead branch removed: if (isCurrentPath('/agents')) renderAgentVMStrip() return; } if (eventType === 'swarm.snapshot') { mergeSwarmSnapshot(msg.data); if (isCurrentPath('/infrastructure')) renderInfraGrid(); return; } if (eventType === 'swarm.service.snapshot') { mergeSwarmServiceSnapshot(msg.data); if (isCurrentPath('/infrastructure')) renderInfraGrid(); return; } } async function loadInfrastructureSnapshots() { const [ocData, swarmData, serviceData] = await Promise.all([ api('/v1/events?event_type=openclaw.snapshot&limit=100'), api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })), api('/v1/events?event_type=swarm.service.snapshot&limit=100').catch(() => ({ events: [] })), ]); mergeOpenClawEvents(ocData.events || []); for (const evt of swarmData.events || []) mergeSwarmSnapshot(evt); for (const evt of serviceData.events || []) mergeSwarmServiceSnapshot(evt); } // ── Infrastructure Uptime & Freshness ─────────────────── function getUptimeBadge(uptimeSec) { if (!uptimeSec) return ''; const hours = uptimeSec / 3600; const pct = Math.min(100, (hours / 24) * 100); const cls = pct >= 99 ? 'good' : pct >= 90 ? 'warn' : 'bad'; return `${pct.toFixed(0)}% / 24h`; } function formatUptime(sec) { if (!sec) return '-'; if (sec < 60) return sec + 's'; if (sec < 3600) return Math.floor(sec / 60) + 'm'; if (sec < 86400) return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm'; return Math.floor(sec / 86400) + 'd ' + Math.floor((sec % 86400) / 3600) + 'h'; } function serviceCardHeader(svc) { const uptimeBadge = getUptimeBadge(svc.uptime_sec); return `
${escapeHTML(svc.name)}${uptimeBadge ? ' ' + uptimeBadge : ''}
${escapeHTML(svc.role || '')}
${escapeHTML(svc.status || 'down')}
`; } function serviceStatRow(label, value, valueClass) { return `
${escapeHTML(label)} ${value}
`; } function renderVMCard(name) { const evt = openclawState.instances[name]; const payload = getEnvelopePayload(evt); const inst = payload.instance || {}; const host = payload.host || {}; const guest = payload.guest; const issues = payload.issues; return `

${escapeHTML(inst.name || name)}

${host.state === 'running' ? 'Running' : 'Stopped'}
Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}
Host${escapeHTML(inst.host || '-')}
Domain${escapeHTML(inst.domain || '-')}
vCPUs${host.vcpus || '-'}
Memory${escapeHTML(formatBytes(host.memory_kib ? host.memory_kib * 1024 : 0) || '-')}
Disk${escapeHTML(formatBytes(host.disk_actual_bytes) || '-')}
Autostart${host.autostart ? 'Yes' : 'No'}
${guest ? `
Gateway${guest.service_active ? 'Active' : 'Inactive'}
HTTP${guest.http_status || 'N/A'}
Version${escapeHTML(guest.version || '-')}
Guest Mem${guest.memory_percent !== undefined ? guest.memory_percent.toFixed(1) : '-'}%
Guest Disk${guest.disk_percent !== undefined ? guest.disk_percent.toFixed(1) : '-'}%
Load${guest.load_average !== undefined ? guest.load_average.toFixed(2) : '-'}
Uptime${escapeHTML(guest.service_uptime || '-')}
` : ''} ${issues && Object.values(issues).some(Boolean) ? `
Issues
${Object.entries(issues).filter(([, value]) => value).map(([key]) => ` ${escapeHTML(key.replace(/_/g, ' '))} `).join('')}
` : ''}
`; } function renderLLMProxyCard(svc) { const extra = svc.extra || {}; const modelCount = extra.model_count; const cooldowns = extra.cooldown_count || 0; const httpStatus = svc.http_status; const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; return `
${serviceCardHeader(svc)}
${modelCount !== undefined ? modelCount : '-'} models
${cooldowns > 0 ? `
⚠ ${cooldowns} model${cooldowns > 1 ? 's' : ''} in cooldown
` : ''}
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
`; } function renderDBCard(svc) { const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; return `
${serviceCardHeader(svc)}
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
`; } function renderSearchCard(svc) { const extra = svc.extra || {}; const ms = extra.response_ms; const httpStatus = svc.http_status; const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; return `
${serviceCardHeader(svc)}
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} ${ms !== undefined ? serviceStatRow('Response', ms + 'ms', ms < 500 ? 'ok' : 'warn') : ''} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`; } function renderMCPCard(svc) { const extra = svc.extra || {}; const reachable = extra.port_reachable; return `
${serviceCardHeader(svc)}
${reachable !== undefined ? serviceStatRow('Port', reachable ? 'reachable' : 'unreachable', reachable ? 'ok' : 'bad') : ''} ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`; } function renderVoiceCard(svc) { const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; return `
${serviceCardHeader(svc)}
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`; } function renderAutomationCard(svc) { const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; return `
${serviceCardHeader(svc)}
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`; } function renderAPICard(svc) { const httpStatus = svc.http_status; const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; return `
${serviceCardHeader(svc)}
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
`; } function renderWorkerCard(svc) { return `
${serviceCardHeader(svc)}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`; } function renderGenericServiceCard(svc) { return `
${serviceCardHeader(svc)}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`; } function renderServiceCard(svc) { const role = svc.role || 'unknown'; switch (role) { case 'llm-proxy': return renderLLMProxyCard(svc); case 'db': return renderDBCard(svc); case 'search': return renderSearchCard(svc); case 'mcp': return renderMCPCard(svc); case 'voice': return renderVoiceCard(svc); case 'automation':return renderAutomationCard(svc); case 'api': case 'web': return renderAPICard(svc); case 'worker': case 'queue': return renderWorkerCard(svc); default: return renderGenericServiceCard(svc); } } function renderHomelabServiceCard(svc) { const httpClass = svc.httpStatus === 200 ? 'ok' : svc.httpStatus ? 'bad' : ''; return `
${serviceCardHeader(svc)}
${serviceStatRow('Endpoint', escapeHTML(svc.endpoint || '-'), '')} ${serviceStatRow('Bucket', escapeHTML(svc.bucket ? `${svc.bucket}/${svc.prefix || ''}` : '-'), '')} ${serviceStatRow('Usage', escapeHTML(formatBytes(svc.totalBytes) || '-'), '')} ${serviceStatRow('Objects', escapeHTML(svc.objectCount !== undefined ? String(svc.objectCount) : '-'), '')} ${serviceStatRow('HTTP', svc.httpStatus ? String(svc.httpStatus) : '-', httpClass)} ${serviceStatRow('Source', escapeHTML(svc.sourceInstance || '-'), '')} ${serviceStatRow('Latest', escapeHTML(svc.latestBackup ? relativeTime(svc.latestBackup) : '-'), '')} ${svc.error ? serviceStatRow('Error', escapeHTML(svc.error), 'bad') : ''}
`; } function renderInfraGrid() { const vmNames = Object.keys(openclawState.instances).sort(); const allServices = Object.values(swarmState.services); const agentmonServices = allServices.filter(s => s.group === 'agentmon'); const swarmServices = allServices.filter(s => s.group !== 'agentmon'); const homelabServices = getK8sHomelabServices(); app.innerHTML = `

VMs

${vmNames.length === 0 ? '

No VM data

' : `
${vmNames.map(name => renderVMCard(name)).join('')}
` }

Swarm Services

${swarmServices.length === 0 ? '

No swarm service data

' : `
${swarmServices.map(svc => renderServiceCard(svc)).join('')}
` }

K8s Homelab

${homelabServices.length === 0 ? '

No k8s homelab service data

' : `
${homelabServices.map(svc => renderHomelabServiceCard(svc)).join('')}
` }

Agentmon

${agentmonServices.length === 0 ? '

No agentmon service data

' : `
${agentmonServices.map(svc => renderServiceCard(svc)).join('')}
` }
`; // Start freshness timer — update "Updated X ago" text every 10s if (_infraTimerInterval) clearInterval(_infraTimerInterval); _infraTimerInterval = setInterval(() => { document.querySelectorAll('.freshness-timer[data-ts]').forEach(el => { el.textContent = 'Updated ' + relativeTime(el.dataset.ts); }); }, 10000); // Manual refresh button document.getElementById('infra-refresh-btn')?.addEventListener('click', async () => { const btn = document.getElementById('infra-refresh-btn'); if (btn) btn.disabled = true; try { await loadInfrastructureSnapshots(); renderInfraGrid(); } finally { const b = document.getElementById('infra-refresh-btn'); if (b) b.disabled = false; } }); } export async function renderInfrastructure(routeToken) { app.innerHTML = `
${infrastructureSkeleton()}
`; infraUnsubscribe = subscribeWS(handleInfraWS); try { await loadInfrastructureSnapshots(); if (routeToken && !isRouteCurrent(routeToken)) return; if (isCurrentPath('/infrastructure')) { renderInfraGrid(); } } catch (e) { if (isCurrentPath('/infrastructure')) { app.innerHTML = `

Error: ${escapeHTML(e.message)}

`; } } }