// Admin → System → App — per-container deep-dive page. // // Mounted at /admin/config/system/app/. Lists every container in the // compose project (or single-container "app") and renders a rich card per // container: // // - Status badge + uptime + restart count // - Live cpu/mem/network/blkio (polled every 2s from /api/system/containers//stats) // - Memory-limit gauge if a limit is set; otherwise text "unlimited" // - CPU quota / shares summary // - Health-check state + last log entries (if a healthcheck is configured) // - Image, image digest (short), created/started timestamps // - Networks (name, IP, MAC, gateway) and published ports // - Mounts (volumes + binds), with type/mode badges // - Recent log tail (collapsible, last 200 lines, refresh button) // // One backend hit at mount: GET /api/system/containers (list). Per-card // detail (limits, mounts, networks) comes from GET /api/system/containers/:id. // Live numbers come from GET /api/system/containers/:id/stats every 2s. // Stats endpoint is cached server-side, so multiple tabs share the cost. class SystemAppPage { constructor(rootId = 'config-section') { this.rootId = rootId; this.appName = null; this.members = []; // [{ id, name, ... }] from /containers this.details = new Map(); // id -> /containers/:id detail this.stats = new Map(); // id -> latest /containers/:id/stats sample this._timer = null; this._onClick = this._onClick.bind(this); } root() { return document.getElementById(this.rootId); } async mount(name) { this.appName = name; await this._loadList(); this._renderShell(); // Kick off detail + stats fetches for each member in parallel. await Promise.all(this.members.map(m => this._loadDetail(m.id))); await Promise.all(this.members.map(m => this._loadStats(m.id))); this._renderCards(); this._bind(); // Poll live stats every 2s. Containers refresh list every 15s. this._timer = setInterval(() => { if (!document.querySelector('.sys-app-page')) { clearInterval(this._timer); this._timer = null; return; } this._tickStats(); }, 2000); this._slowTimer = setInterval(() => { if (!document.querySelector('.sys-app-page')) { clearInterval(this._slowTimer); this._slowTimer = null; return; } this._loadList().then(() => this._renderHeader()); }, 15000); } dispose() { if (this._timer) { clearInterval(this._timer); this._timer = null; } if (this._slowTimer) { clearInterval(this._slowTimer); this._slowTimer = null; } const r = this.root(); if (r) r.removeEventListener('click', this._onClick); } async _loadList() { try { const r = await fetch('/api/system/containers'); const j = await r.json().catch(() => ({})); const apps = Array.isArray(j?.apps) ? j.apps : []; const me = apps.find(a => a.app === this.appName); this.members = me && Array.isArray(me.members) ? me.members : []; } catch (_) { this.members = []; } } async _loadDetail(id) { try { const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}`); if (!r.ok) return; const d = await r.json(); this.details.set(id, d); } catch (_) { /* leave missing */ } } async _loadStats(id) { try { const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}/stats`); if (!r.ok) return; this.stats.set(id, await r.json()); } catch (_) { /* leave missing */ } } async _tickStats() { // Only stat running containers — stats for stopped containers return // zeros and waste a daemon roundtrip. const running = this.members.filter(m => m.state === 'running').map(m => m.id); await Promise.all(running.map(id => this._loadStats(id))); for (const id of running) this._renderLive(id); } _bind() { const r = this.root(); if (r) r.addEventListener('click', this._onClick); } _onClick(e) { const lt = e.target.closest('[data-logs-toggle]'); if (lt) { const id = lt.dataset.logsToggle; const body = this.root().querySelector(`[data-logs-body="${id}"]`); if (body) { const open = !body.hidden; body.hidden = open; lt.textContent = open ? 'Show logs' : 'Hide logs'; if (!open && !body.dataset.loaded) { this._loadLogs(id, body); } } return; } const lr = e.target.closest('[data-logs-refresh]'); if (lr) { const id = lr.dataset.logsRefresh; const body = this.root().querySelector(`[data-logs-body="${id}"]`); if (body) { body.dataset.loaded = ''; this._loadLogs(id, body); } return; } const back = e.target.closest('[data-back]'); if (back && window.navigateToRoute) { window.navigateToRoute('/admin/config/system'); } } async _loadLogs(id, bodyEl) { bodyEl.innerHTML = '
Loading…
'; try { const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}/logs?tail=200`); const text = await r.text(); bodyEl.dataset.loaded = '1'; // Render as a pre-block; strip ANSI just in case (rare in our // containers but cheap to do). const clean = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); bodyEl.innerHTML = `
${(window.SystemFmt?.escape || ((s)=>s))(clean) || 'empty'}
`; const pre = bodyEl.querySelector('pre'); if (pre) pre.scrollTop = pre.scrollHeight; } catch (err) { bodyEl.innerHTML = `
Failed to load logs: ${(err && err.message) || err}
`; } } _renderShell() { const r = this.root(); if (!r) return; const fmt = window.SystemFmt; const totalRunning = this.members.filter(m => m.state === 'running').length; const header = ` `; if (!this.members.length) { r.innerHTML = `
${header}
No containers found for "${fmt.escape(this.appName)}". It may not be installed, or its compose project label differs from the app name.
`; return; } r.innerHTML = `
${header}
${this.members.map(m => this._cardSkeleton(m)).join('')}
`; } _renderHeader() { const r = this.root(); if (!r) return; const sub = r.querySelector('[data-app-sub]'); if (!sub) return; const totalRunning = this.members.filter(m => m.state === 'running').length; sub.textContent = `${this.members.length} container${this.members.length === 1 ? '' : 's'} · ${totalRunning} running`; } _cardSkeleton(member) { const fmt = window.SystemFmt; const statusCls = member.state === 'running' ? 'ok' : (member.state === 'restarting' ? 'warn' : 'none'); const service = member.service ? `${fmt.escape(member.service)}` : ''; return `
${fmt.escape(member.state || 'unknown')} ${service}

${fmt.escape(member.name)}

${fmt.escape(member.image || '—')} · ${fmt.escape(member.short || '')}
${fmt.escape(member.status || '')}
CPU
Memory
↓ rx
↑ tx
Block r/w
PIDs
Loading container detail…
`; } _renderCards() { for (const m of this.members) this._renderDetail(m.id); for (const m of this.members) this._renderLive(m.id); } _renderDetail(id) { const r = this.root(); if (!r) return; const body = r.querySelector(`[data-body="${id}"]`); if (!body) return; const d = this.details.get(id); if (!d) { body.innerHTML = `
Couldn't load container detail.
`; return; } const fmt = window.SystemFmt; const lim = d.limits || {}; const memLimit = lim.memory; const cpuLimit = lim.nano_cpus ? `${(lim.nano_cpus / 1e9).toFixed(2)} CPU${(lim.nano_cpus / 1e9) === 1 ? '' : 's'}` : (lim.cpu_quota && lim.cpu_period ? `${(lim.cpu_quota / lim.cpu_period).toFixed(2)} CPU equiv` : 'unlimited'); const limitsRow = `
Memory limit ${memLimit ? fmt.bytes(memLimit) : 'unlimited'}
CPU limit ${fmt.escape(cpuLimit)}
PIDs limit ${lim.pids ? lim.pids : 'unlimited'}
Restart policy ${fmt.escape(lim.restart_policy || 'no')}${lim.restart_max ? ` (max ${lim.restart_max})` : ''}
Restart count ${d.state?.restart_count ?? 0}
Started ${d.state?.started_at ? fmt.timeAgoIso(d.state.started_at) : '—'}
`; const health = d.state?.health ? `

Healthcheck

${fmt.escape(d.state.health.status || 'unknown')} ${d.state.health.failing_streak ? `${d.state.health.failing_streak} failing in a row` : ''}
${(d.state.health.log || []).slice(-3).reverse().map(l => `
${fmt.escape(l.end || l.start || '')} · exit ${l.exit_code ?? '?'}
${fmt.escape(l.output || '')}
` ).join('')}
` : ''; const nets = (d.networks || []).length ? `

Networks

${d.networks.map(n => ` `).join('')}
NameIPGatewayMAC
${fmt.escape(n.name)}${fmt.escape(n.ip || '—')}${fmt.escape(n.gateway || '—')}${fmt.escape(n.mac || '—')}
` : ''; const ports = (d.ports || []).filter(p => p.host).length ? `

Published ports

    ${(d.ports || []).filter(p => p.host).map(p => `
  • ${p.host} → ${p.container}/${fmt.escape(p.proto || '')}
  • ` ).join('')}
` : ''; const mounts = (d.mounts || []).length ? `

Mounts

${d.mounts.map(m => ` `).join('')}
TypeSourceTargetMode
${fmt.escape(m.type || '')} ${fmt.escape(m.source || '')} ${fmt.escape(m.target || '')} ${fmt.escape(m.mode || '')}${m.rw === false ? ' (ro)' : ''}
` : ''; body.innerHTML = `${limitsRow}${health}${nets}${ports}${mounts}`; } _renderLive(id) { const r = this.root(); if (!r) return; const card = r.querySelector(`[data-live="${id}"]`); if (!card) return; const s = this.stats.get(id); const d = this.details.get(id); const memLimit = d?.limits?.memory || 0; const fmt = window.SystemFmt; if (!s) { // Container stopped or stats not loaded — clear the row. for (const v of card.querySelectorAll('.sys-app-stat-v')) v.textContent = '—'; return; } const cpu = s.cpu_percent ?? 0; const memUsed = s.memory?.used ?? 0; const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : (s.memory?.percent ?? 0); const rx = s.network?.rx_total ?? 0; const tx = s.network?.tx_total ?? 0; const br = s.blkio?.read ?? 0; const bw = s.blkio?.write ?? 0; const pids = s.pids?.current ?? 0; const set = (k, v) => { const el = card.querySelector(`[data-live-k="${k}"]`); if (el) el.textContent = v; }; set('cpu', `${cpu.toFixed(1)}%`); set('mem', memLimit > 0 ? `${fmt.bytes(memUsed)} (${memPct.toFixed(0)}%)` : fmt.bytes(memUsed)); set('rx', fmt.bytes(rx)); set('tx', fmt.bytes(tx)); set('blk', `${fmt.bytes(br)} / ${fmt.bytes(bw)}`); set('pids', pids ? String(pids) : '—'); // Memory limit headroom — colour the cell when above 80%. const memEl = card.querySelector('[data-live-k="mem"]'); if (memEl) { memEl.classList.toggle('warn', memLimit > 0 && memPct >= 80); memEl.classList.toggle('danger', memLimit > 0 && memPct >= 95); } } } window.SystemAppPage = SystemAppPage;