diff --git a/containers/libreportal/frontend/css/services.css b/containers/libreportal/frontend/css/services.css index c1e6fab..b483c37 100644 --- a/containers/libreportal/frontend/css/services.css +++ b/containers/libreportal/frontend/css/services.css @@ -197,3 +197,162 @@ opacity: 0.6; cursor: wait; } + +/* ============================================================ + Live container chips (CPU%, memory) — rendered inline in the + service row header alongside the existing port/IP chips. + Updated in place by the periodic stats refresh. + ============================================================ */ +.service-live-chip { + display: inline-flex; + align-items: center; + padding: 2px 9px; + font-size: 0.74rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: rgba(var(--text-rgb), 0.85); + background: rgba(var(--accent-rgb), 0.12); + border: 1px solid rgba(var(--accent-rgb), 0.25); + border-radius: 999px; + transition: color .2s ease, background .2s ease, border-color .2s ease; +} +.service-live-chip.warn { + color: var(--status-warning); + background: rgba(var(--status-warning-rgb), 0.14); + border-color: rgba(var(--status-warning-rgb), 0.4); +} +.service-live-chip.danger { + color: var(--status-danger); + background: rgba(var(--status-danger-rgb), 0.16); + border-color: rgba(var(--status-danger-rgb), 0.5); +} + +/* ============================================================ + Rich container detail panel — limits, image, healthcheck, + networks, mounts. Rendered inside .task-details above the + log container so it's discoverable from the existing "Logs" + expand action. + ============================================================ */ +.service-rich { + display: flex; + flex-direction: column; + gap: 14px; + margin: 8px 0 14px; +} + +.service-rich-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; +} +.service-rich-cell { + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.service-rich-cell span { + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(var(--text-rgb), 0.45); + font-weight: 700; +} +.service-rich-cell strong { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary); + word-break: break-word; + font-variant-numeric: tabular-nums; +} + +.service-rich-section h4 { + font-size: 0.78rem; + font-weight: 700; + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(var(--text-rgb), 0.7); +} + +.service-rich-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + background: rgba(var(--text-rgb), 0.03); + border-radius: 8px; + overflow: hidden; +} +.service-rich-table th { + text-align: left; + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(var(--text-rgb), 0.45); + padding: 8px 10px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); +} +.service-rich-table td { + padding: 7px 10px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.04); + color: rgba(var(--text-rgb), 0.85); + font-variant-numeric: tabular-nums; +} +.service-rich-table tr:last-child td { border-bottom: none; } + +.service-mount-type { + display: inline-block; + padding: 1px 7px; + border-radius: 4px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.service-mount-volume { background: rgba(var(--status-success-rgb), 0.2); color: var(--status-success); } +.service-mount-bind { background: rgba(var(--accent-rgb), 0.18); color: var(--accent); } +.service-mount-tmpfs { background: rgba(var(--status-warning-rgb), 0.18); color: var(--status-warning); } +.service-mount-path { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.76rem; + word-break: break-all; +} + +.service-health { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} +.service-health-pill { + padding: 2px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.service-health-healthy { background: rgba(var(--status-success-rgb), 0.18); color: var(--status-success); } +.service-health-starting { background: rgba(var(--status-warning-rgb), 0.18); color: var(--status-warning); } +.service-health-unhealthy { background: rgba(var(--status-danger-rgb), 0.18); color: var(--status-danger); } +.service-health-unknown { background: rgba(var(--text-rgb), 0.10); color: rgba(var(--text-rgb), 0.6); } +.service-health-fail { color: var(--status-danger); font-size: 0.76rem; font-weight: 600; } +.service-health-log { + background: rgba(var(--text-rgb), 0.03); + border-radius: 6px; + margin-top: 4px; + padding: 4px 8px; + font-size: 0.76rem; +} +.service-health-log summary { cursor: pointer; color: rgba(var(--text-rgb), 0.65); } +.service-health-log pre { + margin: 6px 0 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.76rem; + white-space: pre-wrap; + color: rgba(var(--text-rgb), 0.7); +} diff --git a/containers/libreportal/frontend/js/components/admin/admin-system.js b/containers/libreportal/frontend/js/components/admin/admin-system.js index e73f88c..e6ab925 100644 --- a/containers/libreportal/frontend/js/components/admin/admin-system.js +++ b/containers/libreportal/frontend/js/components/admin/admin-system.js @@ -39,9 +39,9 @@ class AdminSystem { const segs = String(window.location.pathname || '').split('/').filter(Boolean); // segs = ['admin','config','system', ...] const sub = segs[3]; - if (sub === 'metric' && segs[4]) return { view: 'metric', key: decodeURIComponent(segs[4]) }; - if (sub === 'app' && segs[4]) return { view: 'app', name: decodeURIComponent(segs[4]) }; - if (sub === 'storage') return { view: 'storage' }; + if (sub === 'metric' && segs[4]) return { view: 'metric', key: decodeURIComponent(segs[4]) }; + if (sub === 'app' && segs[4]) return { view: 'app', name: decodeURIComponent(segs[4]) }; + if (sub === 'storage') return { view: 'storage' }; return { view: 'index' }; } @@ -64,9 +64,13 @@ class AdminSystem { await this._subview.mount(parsed.key); return; } - if (parsed.view === 'app' && window.SystemAppPage) { - this._subview = new window.SystemAppPage(this.rootId); - await this._subview.mount(parsed.name); + if (parsed.view === 'app') { + // Per-app container deep-dive lives on the app page's Services + // tab, not under /admin/system. Redirect any direct hit so old + // bookmarks still resolve. + if (window.navigateToRoute) { + window.navigateToRoute(`/app/${encodeURIComponent(parsed.name)}/services`); + } return; } if (parsed.view === 'storage' && window.SystemStoragePage) { @@ -136,7 +140,11 @@ class AdminSystem { const ap = e.target.closest('[data-sys-app]'); if (ap) { const name = ap.dataset.sysApp; - if (window.navigateToRoute) window.navigateToRoute(`/admin/config/system/app/${encodeURIComponent(name)}`); + // App rows jump to the app page's Services tab — the existing + // home for per-container detail (logs, restart, IP, ports) + // which the new docker-info endpoints now enrich with live + // stats, limits, mounts, networks and healthcheck. + if (window.navigateToRoute) window.navigateToRoute(`/app/${encodeURIComponent(name)}/services`); return; } const st = e.target.closest('[data-sys-storage]'); @@ -290,7 +298,7 @@ class AdminSystem { }).join('') : `No running containers — install an app to see per-app stats.`; const appsTable = ` -

Per-app usage

click a row to open the deep-dive · sorted by CPU
+

Per-app usage

click a row to open the app's Services tab · sorted by CPU
diff --git a/containers/libreportal/frontend/js/components/admin/system-app-page.js b/containers/libreportal/frontend/js/components/admin/system-app-page.js deleted file mode 100644 index 8e6dfcc..0000000 --- a/containers/libreportal/frontend/js/components/admin/system-app-page.js +++ /dev/null @@ -1,382 +0,0 @@ -// 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

-
AppCPUMemoryNetworkCPU trend
- - ${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

- -
` : ''; - - 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; diff --git a/containers/libreportal/frontend/js/components/app/services-manager.js b/containers/libreportal/frontend/js/components/app/services-manager.js index 723d710..5c47559 100644 --- a/containers/libreportal/frontend/js/components/app/services-manager.js +++ b/containers/libreportal/frontend/js/components/app/services-manager.js @@ -21,8 +21,14 @@ class ServicesManager { constructor() { this.currentApp = null; this.refreshTimer = null; + this.statsTimer = null; this.openLogStreams = new Map(); // serviceName -> { es, container } this.servicesIndex = null; // app -> serviceName -> { ports[], urls[] } + // Per-service rich container info: serviceName -> { id, summary, detail, stats }. + // Populated from /api/system/containers + /api/system/containers/:id and + // refreshed via /api/system/containers/:id/stats. Drives the live chips + // and the expanded detail panel. + this.containerInfo = new Map(); } // Entrypoint called by app-tabbed-manager. @@ -42,9 +48,10 @@ class ServicesManager { `; try { - const [aggregated, status] = await Promise.all([ + const [aggregated, status, _ci] = await Promise.all([ this._loadAggregated(appName), - this._fetchStatus(appName) + this._fetchStatus(appName), + this._loadContainerInfo(appName) ]); const merged = this._merge(aggregated, status); @@ -201,6 +208,151 @@ class ServicesManager { return out.sort(this._compareServices); } + // ------------------------------------------------------------------ + // Rich container info — /api/system/containers + per-id detail/stats. + // ------------------------------------------------------------------ + // The list endpoint already groups by compose project; we ask for the + // app by name and then fan out per-id detail + stats in parallel. Both + // endpoints are cached server-side so a few tabs in parallel don't + // hammer the daemon. Best-effort: failures leave the slot empty and + // the row degrades gracefully back to the legacy fields. + async _loadContainerInfo(appName) { + this.containerInfo = new Map(); + let listResp; + try { + const r = await fetch('/api/system/containers', { cache: 'no-store' }); + if (!r.ok) return; + listResp = await r.json(); + } catch { return; } + const apps = Array.isArray(listResp?.apps) ? listResp.apps : []; + const me = apps.find(a => a.app === appName); + if (!me || !Array.isArray(me.members)) return; + // Seed map by service-name (compose label); fall back to container name + // for the rare ad-hoc container without a service label. + for (const m of me.members) { + const key = m.service || m.name; + this.containerInfo.set(key, { summary: m, detail: null, stats: null }); + } + // Fan out detail + stats. We don't await each individually so a single + // slow inspect doesn't gate the others. + await Promise.all([...this.containerInfo.entries()].map(async ([k, info]) => { + const id = info.summary.id; + const [d, s] = await Promise.all([ + fetch(`/api/system/containers/${encodeURIComponent(id)}`).then(r => r.ok ? r.json() : null).catch(() => null), + fetch(`/api/system/containers/${encodeURIComponent(id)}/stats`).then(r => r.ok ? r.json() : null).catch(() => null) + ]); + info.detail = d; + info.stats = s; + })); + } + + async _refreshStatsOnly() { + if (!this.currentApp) return; + if (!document.getElementById('services-tab')?.classList.contains('active')) return; + // Refresh live numbers in place — don't rebuild the DOM. + await Promise.all([...this.containerInfo.entries()].map(async ([k, info]) => { + if (!info.summary?.id) return; + try { + const r = await fetch(`/api/system/containers/${encodeURIComponent(info.summary.id)}/stats`); + if (!r.ok) return; + info.stats = await r.json(); + } catch { /* drop */ } + this._paintLiveChips(k, info); + })); + } + + // Render the rich detail panel — limits, image, healthcheck, networks, + // mounts — that sits above the log block in the expanded details. Bails + // out cleanly if the docker-info endpoints haven't returned for this + // service (e.g. cold start, daemon hiccup); the legacy meta + logs + // remain usable. + _renderRichDetail(info) { + if (!info || !info.detail) return ''; + const d = info.detail; + const fmt = window.SystemFmt; + if (!fmt) return ''; + const lim = d.limits || {}; + const memLimit = lim.memory || 0; + 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 = ` +
+
Image${escapeHtml(d.image || '—')}
+
Container ID${escapeHtml(d.short || '')}
+
Started${d.state?.started_at ? escapeHtml(fmt.timeAgoIso(d.state.started_at)) : '—'}
+
Restart count${d.state?.restart_count ?? 0}
+
Memory limit${memLimit ? escapeHtml(fmt.bytes(memLimit)) : 'unlimited'}
+
CPU limit${escapeHtml(cpuLimit)}
+
PIDs limit${lim.pids ? lim.pids : 'unlimited'}
+
Restart policy${escapeHtml(lim.restart_policy || 'no')}${lim.restart_max ? ` (max ${lim.restart_max})` : ''}
+
`; + + const health = d.state?.health + ? `
+

Healthcheck

+
+ ${escapeHtml(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 => + `
${escapeHtml(l.end || l.start || '')} · exit ${l.exit_code ?? '?'}
${escapeHtml(l.output || '')}
` + ).join('')} +
` : ''; + + const nets = (d.networks || []).length + ? `
+

Networks

+ + + ${d.networks.map(n => ` + + `).join('')} +
NameIPGatewayMAC
${escapeHtml(n.name)}${escapeHtml(n.ip || '—')}${escapeHtml(n.gateway || '—')}${escapeHtml(n.mac || '—')}
+
` : ''; + + const mounts = (d.mounts || []).length + ? `
+

Mounts

+ + + ${d.mounts.map(m => ` + + + + + + `).join('')} +
TypeSourceTargetMode
${escapeHtml(m.type || '')}${escapeHtml(m.source || '')}${escapeHtml(m.target || '')}${escapeHtml(m.mode || '')}${m.rw === false ? ' (ro)' : ''}
+
` : ''; + + return `
${limitsRow}${health}${nets}${mounts}
`; + } + + _paintLiveChips(serviceName, info) { + const item = document.querySelector(`.service-item[data-service="${cssEscape(serviceName)}"]`); + if (!item) return; + const stats = info?.stats; + const detail = info?.detail; + const memLimit = detail?.limits?.memory || 0; + const fmt = window.SystemFmt; + const cpuEl = item.querySelector('[data-svc-live="cpu"]'); + const memEl = item.querySelector('[data-svc-live="mem"]'); + if (cpuEl) cpuEl.textContent = stats ? `${(stats.cpu_percent ?? 0).toFixed(1)}% CPU` : '—'; + if (memEl && fmt) { + const used = stats?.memory?.used ?? 0; + const pct = memLimit > 0 ? (used / memLimit) * 100 : (stats?.memory?.percent ?? 0); + const txt = memLimit > 0 + ? `${fmt.bytes(used)} (${pct.toFixed(0)}% of ${fmt.bytes(memLimit)})` + : fmt.bytes(used); + memEl.textContent = stats ? txt : '—'; + memEl.classList.toggle('warn', memLimit > 0 && pct >= 80); + memEl.classList.toggle('danger', memLimit > 0 && pct >= 95); + } + } + // ------------------------------------------------------------------ // Rendering // ------------------------------------------------------------------ @@ -239,6 +391,28 @@ class ServicesManager { ? `${escapeHtml(this.currentApp || '')}` : ''; + // Live stat chips — populated inline if /api/system/containers already + // returned for this service, otherwise rendered as placeholders that + // the periodic stats refresh fills in. + const info = this.containerInfo.get(svc.serviceName) || {}; + const stats = info.stats; + const detail = info.detail; + const memLimit = detail?.limits?.memory || 0; + const fmt = window.SystemFmt; + const cpuTxt = stats ? `${(stats.cpu_percent ?? 0).toFixed(1)}% CPU` : '—'; + const memUsed = stats?.memory?.used ?? 0; + const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : (stats?.memory?.percent ?? 0); + const memTxt = stats && fmt + ? (memLimit > 0 + ? `${fmt.bytes(memUsed)} (${memPct.toFixed(0)}% of ${fmt.bytes(memLimit)})` + : fmt.bytes(memUsed)) + : '—'; + const memCls = memLimit > 0 && memPct >= 95 ? ' danger' : (memLimit > 0 && memPct >= 80 ? ' warn' : ''); + const liveChips = state === 'running' + ? `${escapeHtml(cpuTxt)} + ${escapeHtml(memTxt)}` + : ''; + return `
@@ -246,7 +420,8 @@ class ServicesManager { ${iconHtml} ${escapeHtml(svc.serviceName)} ${escapeHtml(state.toUpperCase())} - ${escapeHtml(svc.statusText)} + ${escapeHtml(svc.statusText)} + ${liveChips} ${portChips} ${ipChip}
@@ -274,6 +449,7 @@ class ServicesManager {
State: ${escapeHtml(state)}
Status: ${escapeHtml(svc.statusText)}
+ ${this._renderRichDetail(info)}