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
| App | CPU | Memory | Network | CPU trend | |
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
-
- | Name | IP | Gateway | MAC |
- ${d.networks.map(n => `
- | ${fmt.escape(n.name)} | ${fmt.escape(n.ip || '—')} | ${fmt.escape(n.gateway || '—')} | ${fmt.escape(n.mac || '—')} |
- `).join('')}
-
-
` : '';
-
- 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
-
- | Type | Source | Target | Mode |
- ${d.mounts.map(m => `
- | ${fmt.escape(m.type || '')} |
- ${fmt.escape(m.source || '')} |
- ${fmt.escape(m.target || '')} |
- ${fmt.escape(m.mode || '')}${m.rw === false ? ' (ro)' : ''} |
- `).join('')}
-
-
` : '';
-
- 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
+
+ | Name | IP | Gateway | MAC |
+ ${d.networks.map(n => `
+ | ${escapeHtml(n.name)} | ${escapeHtml(n.ip || '—')} | ${escapeHtml(n.gateway || '—')} | ${escapeHtml(n.mac || '—')} |
+ `).join('')}
+
+
` : '';
+
+ const mounts = (d.mounts || []).length
+ ? `
+
Mounts
+
+ | Type | Source | Target | Mode |
+ ${d.mounts.map(m => `
+
+ | ${escapeHtml(m.type || '')} |
+ ${escapeHtml(m.source || '')} |
+ ${escapeHtml(m.target || '')} |
+ ${escapeHtml(m.mode || '')}${m.rw === false ? ' (ro)' : ''} |
+
`).join('')}
+
+
` : '';
+
+ 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 {
? `
`
: '';
+ // 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 `
@@ -274,6 +449,7 @@ class ServicesManager {
State: ${escapeHtml(state)}
Status: ${escapeHtml(svc.statusText)}
+ ${this._renderRichDetail(info)}
@@ -450,7 +626,11 @@ class ServicesManager {
_startRefreshLoop() {
this._stopRefreshLoop();
+ // Status (state + statusText) is cheap; refresh every 10s. Live stats
+ // tick faster (5s) so the chips feel alive but still let the server
+ // cache (1.5s) absorb concurrent tabs.
this.refreshTimer = setInterval(() => this._refreshStatusOnly(), 10_000);
+ this.statsTimer = setInterval(() => this._refreshStatsOnly(), 5_000);
}
_stopRefreshLoop() {
@@ -458,6 +638,10 @@ class ServicesManager {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
+ if (this.statsTimer) {
+ clearInterval(this.statsTimer);
+ this.statsTimer = null;
+ }
}
async _refreshStatusOnly() {
diff --git a/containers/libreportal/frontend/js/components/config/config-manager.js b/containers/libreportal/frontend/js/components/config/config-manager.js
index 2a528d4..7d0cd8d 100755
--- a/containers/libreportal/frontend/js/components/config/config-manager.js
+++ b/containers/libreportal/frontend/js/components/config/config-manager.js
@@ -103,7 +103,6 @@ if (typeof window.ConfigManager === 'undefined') {
lazyLoad('/js/components/admin/charts.js'),
lazyLoad('/js/components/admin/admin-system.js'),
lazyLoad('/js/components/admin/system-metric-page.js'),
- lazyLoad('/js/components/admin/system-app-page.js'),
lazyLoad('/js/components/admin/system-storage-page.js')
]);
if (typeof AdminSystem !== 'undefined') {