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 `
`;
}
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 `
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)}
`;
}
}
}