refactor(system): per-app deep-dive moves to the app's Services tab

The Admin → System area was growing a parallel per-container surface
(/admin/config/system/app/<name>) alongside the existing per-app Services
tab on the app page. Two pages onto the same thing is the kind of
duplication that rots fast — they drift, users have to remember which
one to use, and the next person adding a feature has to decide twice.

This commit consolidates onto the existing Services tab (which already
has compose-service awareness, docker socket access, restart actions via
the task system, and live log streaming) and decommissions the parallel
admin sub-page:

  - Delete system-app-page.js and its lazyLoad entry. The dispatch in
    admin-system.js for the 'app' view now redirects to the app page's
    Services tab so old bookmarks still resolve cleanly.

  - System index per-app rows navigate to /app/<name>/services (not
    /admin/config/system/app/<name>) and the row hint copy is updated
    to match.

  - Services tab gains the rich container detail the old admin page
    rendered, fed by /api/system/containers + /containers/:id +
    /containers/:id/stats:

      * Inline live chips in each service header: CPU% and memory
        (with limit + percent if a limit is set). Memory chip flips
        amber at 80% and red at 95% of the configured limit.
      * New "service-rich" panel inside the existing expandable
        details section (above the log block, so the existing Logs
        toggle reveals both):
          - Image + image-id + uptime + restart count
          - Memory / CPU / PIDs limits + restart policy
          - Healthcheck pill + last 3 probes (collapsible per-probe)
          - Networks table (name, IP, gateway, MAC)
          - Mounts table with type badges (volume/bind/tmpfs)
      * Live stats refresh every 5 s; existing status refresh stays
        on 10 s. Both gated on the Services tab being active.

  - Backups for the app already live on the existing /app/<name>/backups
    tab (loadAppBackups → BackupAppCard.render), so the navigational
    promise of "one place per-app" is already met — System index just
    needed to route there.

  - CSS: services.css picks up .service-live-chip (with warn/danger
    colour cues) and the full .service-rich block (grid, tables, mount
    badges, healthcheck pills).

Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-27 22:51:53 +01:00
parent e734c12ff0
commit 57a565aac2
5 changed files with 362 additions and 394 deletions

View File

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

View File

@ -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('') : `<tr><td colspan="6" class="sys-apps-empty">No running containers — install an app to see per-app stats.</td></tr>`;
const appsTable = `
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">click a row to open the deep-dive · sorted by CPU</span></div>
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">click a row to open the app's Services tab · sorted by CPU</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>App</th><th>CPU</th><th>Memory</th><th>Network</th><th>CPU trend</th><th></th></tr></thead>

View File

@ -1,382 +0,0 @@
// Admin → System → App — per-container deep-dive page.
//
// Mounted at /admin/config/system/app/<name>. 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/<id>/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 = '<div class="sys-app-logs-loading">Loading…</div>';
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 = `<pre class="sys-app-logs-pre">${(window.SystemFmt?.escape || ((s)=>s))(clean) || '<em>empty</em>'}</pre>`;
const pre = bodyEl.querySelector('pre');
if (pre) pre.scrollTop = pre.scrollHeight;
} catch (err) {
bodyEl.innerHTML = `<div class="sys-app-logs-err">Failed to load logs: ${(err && err.message) || err}</div>`;
}
}
_renderShell() {
const r = this.root();
if (!r) return;
const fmt = window.SystemFmt;
const totalRunning = this.members.filter(m => m.state === 'running').length;
const header = `
<div class="page-header config-page-header">
<div class="page-header-title">
<div class="admin-breadcrumb">
<a href="/admin/config/system" data-back>Admin · System</a>
</div>
<h1>${fmt.escape(this.appName)}</h1>
<p class="sys-app-sub" data-app-sub>${this.members.length} container${this.members.length === 1 ? '' : 's'} · ${totalRunning} running</p>
</div>
</div>`;
if (!this.members.length) {
r.innerHTML = `
<div class="admin-page sys-app-page">
${header}
<div class="sys-app-empty">
No containers found for "<strong>${fmt.escape(this.appName)}</strong>".
It may not be installed, or its compose project label differs from the app name.
</div>
</div>`;
return;
}
r.innerHTML = `
<div class="admin-page sys-app-page">
${header}
<div class="sys-app-grid" data-app-grid>
${this.members.map(m => this._cardSkeleton(m)).join('')}
</div>
</div>`;
}
_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 ? `<span class="sys-app-card-svc">${fmt.escape(member.service)}</span>` : '';
return `
<article class="sys-app-card" data-cid="${fmt.escape(member.id)}">
<header class="sys-app-card-head">
<div>
<div class="sys-app-card-status">
<span class="admin-status-dot ${statusCls}"></span>
<span class="sys-app-card-state">${fmt.escape(member.state || 'unknown')}</span>
${service}
</div>
<h2 class="sys-app-card-name">${fmt.escape(member.name)}</h2>
<div class="sys-app-card-meta">
<span title="${fmt.escape(member.image_id || '')}">${fmt.escape(member.image || '—')}</span>
<span class="sys-app-card-sep">·</span>
<span>${fmt.escape(member.short || '')}</span>
</div>
</div>
<div class="sys-app-card-status-line">${fmt.escape(member.status || '')}</div>
</header>
<div class="sys-app-card-stats" data-live="${fmt.escape(member.id)}">
<div class="sys-app-stat"><span class="sys-app-stat-k">CPU</span><strong class="sys-app-stat-v" data-live-k="cpu"></strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k">Memory</span><strong class="sys-app-stat-v" data-live-k="mem"></strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k"> rx</span><strong class="sys-app-stat-v" data-live-k="rx"></strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k"> tx</span><strong class="sys-app-stat-v" data-live-k="tx"></strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k">Block r/w</span><strong class="sys-app-stat-v" data-live-k="blk"></strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k">PIDs</span><strong class="sys-app-stat-v" data-live-k="pids"></strong></div>
</div>
<div class="sys-app-card-body" data-body="${fmt.escape(member.id)}">
<div class="sys-app-card-loading">Loading container detail</div>
</div>
<div class="sys-app-card-logs">
<div class="sys-app-card-logs-head">
<button type="button" class="sys-app-logs-toggle" data-logs-toggle="${fmt.escape(member.id)}">Show logs</button>
<button type="button" class="sys-app-logs-refresh" data-logs-refresh="${fmt.escape(member.id)}" title="Refresh"></button>
</div>
<div class="sys-app-card-logs-body" data-logs-body="${fmt.escape(member.id)}" hidden></div>
</div>
</article>`;
}
_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 = `<div class="sys-app-card-err">Couldn't load container detail.</div>`;
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 = `
<div class="sys-app-card-limits">
<div class="sys-app-limit">
<span class="sys-app-limit-k">Memory limit</span>
<strong class="sys-app-limit-v">${memLimit ? fmt.bytes(memLimit) : 'unlimited'}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">CPU limit</span>
<strong class="sys-app-limit-v">${fmt.escape(cpuLimit)}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">PIDs limit</span>
<strong class="sys-app-limit-v">${lim.pids ? lim.pids : 'unlimited'}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">Restart policy</span>
<strong class="sys-app-limit-v">${fmt.escape(lim.restart_policy || 'no')}${lim.restart_max ? ` (max ${lim.restart_max})` : ''}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">Restart count</span>
<strong class="sys-app-limit-v">${d.state?.restart_count ?? 0}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">Started</span>
<strong class="sys-app-limit-v">${d.state?.started_at ? fmt.timeAgoIso(d.state.started_at) : '—'}</strong>
</div>
</div>`;
const health = d.state?.health
? `<div class="sys-app-section">
<h3>Healthcheck</h3>
<div class="sys-app-health">
<span class="sys-app-health-pill sys-app-health-${fmt.escape(d.state.health.status || 'unknown')}">${fmt.escape(d.state.health.status || 'unknown')}</span>
${d.state.health.failing_streak ? `<span class="sys-app-health-fail">${d.state.health.failing_streak} failing in a row</span>` : ''}
</div>
${(d.state.health.log || []).slice(-3).reverse().map(l =>
`<details class="sys-app-health-log"><summary>${fmt.escape(l.end || l.start || '')} · exit ${l.exit_code ?? '?'}</summary><pre>${fmt.escape(l.output || '')}</pre></details>`
).join('')}
</div>` : '';
const nets = (d.networks || []).length
? `<div class="sys-app-section">
<h3>Networks</h3>
<table class="sys-app-table">
<thead><tr><th>Name</th><th>IP</th><th>Gateway</th><th>MAC</th></tr></thead>
<tbody>${d.networks.map(n => `
<tr><td>${fmt.escape(n.name)}</td><td>${fmt.escape(n.ip || '')}</td><td>${fmt.escape(n.gateway || '')}</td><td>${fmt.escape(n.mac || '')}</td></tr>
`).join('')}</tbody>
</table>
</div>` : '';
const ports = (d.ports || []).filter(p => p.host).length
? `<div class="sys-app-section">
<h3>Published ports</h3>
<ul class="sys-app-ports">${(d.ports || []).filter(p => p.host).map(p =>
`<li><strong>${p.host}</strong> → ${p.container}/${fmt.escape(p.proto || '')}</li>`
).join('')}</ul>
</div>` : '';
const mounts = (d.mounts || []).length
? `<div class="sys-app-section">
<h3>Mounts</h3>
<table class="sys-app-table">
<thead><tr><th>Type</th><th>Source</th><th>Target</th><th>Mode</th></tr></thead>
<tbody>${d.mounts.map(m => `
<tr><td><span class="sys-app-mount-type sys-app-mount-${fmt.escape(m.type || '')}">${fmt.escape(m.type || '')}</span></td>
<td class="sys-app-mount-path">${fmt.escape(m.source || '')}</td>
<td class="sys-app-mount-path">${fmt.escape(m.target || '')}</td>
<td>${fmt.escape(m.mode || '')}${m.rw === false ? ' (ro)' : ''}</td></tr>
`).join('')}</tbody>
</table>
</div>` : '';
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;

View File

@ -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 {
</div>`;
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 = `
<div class="service-rich-grid">
<div class="service-rich-cell"><span>Image</span><strong title="${escapeHtml(d.image_id || '')}">${escapeHtml(d.image || '')}</strong></div>
<div class="service-rich-cell"><span>Container ID</span><strong>${escapeHtml(d.short || '')}</strong></div>
<div class="service-rich-cell"><span>Started</span><strong>${d.state?.started_at ? escapeHtml(fmt.timeAgoIso(d.state.started_at)) : ''}</strong></div>
<div class="service-rich-cell"><span>Restart count</span><strong>${d.state?.restart_count ?? 0}</strong></div>
<div class="service-rich-cell"><span>Memory limit</span><strong>${memLimit ? escapeHtml(fmt.bytes(memLimit)) : 'unlimited'}</strong></div>
<div class="service-rich-cell"><span>CPU limit</span><strong>${escapeHtml(cpuLimit)}</strong></div>
<div class="service-rich-cell"><span>PIDs limit</span><strong>${lim.pids ? lim.pids : 'unlimited'}</strong></div>
<div class="service-rich-cell"><span>Restart policy</span><strong>${escapeHtml(lim.restart_policy || 'no')}${lim.restart_max ? ` (max ${lim.restart_max})` : ''}</strong></div>
</div>`;
const health = d.state?.health
? `<div class="service-rich-section">
<h4>Healthcheck</h4>
<div class="service-health">
<span class="service-health-pill service-health-${escapeHtml(d.state.health.status || 'unknown')}">${escapeHtml(d.state.health.status || 'unknown')}</span>
${d.state.health.failing_streak ? `<span class="service-health-fail">${d.state.health.failing_streak} failing in a row</span>` : ''}
</div>
${(d.state.health.log || []).slice(-3).reverse().map(l =>
`<details class="service-health-log"><summary>${escapeHtml(l.end || l.start || '')} · exit ${l.exit_code ?? '?'}</summary><pre>${escapeHtml(l.output || '')}</pre></details>`
).join('')}
</div>` : '';
const nets = (d.networks || []).length
? `<div class="service-rich-section">
<h4>Networks</h4>
<table class="service-rich-table">
<thead><tr><th>Name</th><th>IP</th><th>Gateway</th><th>MAC</th></tr></thead>
<tbody>${d.networks.map(n => `
<tr><td>${escapeHtml(n.name)}</td><td>${escapeHtml(n.ip || '')}</td><td>${escapeHtml(n.gateway || '')}</td><td>${escapeHtml(n.mac || '')}</td></tr>
`).join('')}</tbody>
</table>
</div>` : '';
const mounts = (d.mounts || []).length
? `<div class="service-rich-section">
<h4>Mounts</h4>
<table class="service-rich-table">
<thead><tr><th>Type</th><th>Source</th><th>Target</th><th>Mode</th></tr></thead>
<tbody>${d.mounts.map(m => `
<tr>
<td><span class="service-mount-type service-mount-${escapeHtml(m.type || '')}">${escapeHtml(m.type || '')}</span></td>
<td class="service-mount-path">${escapeHtml(m.source || '')}</td>
<td class="service-mount-path">${escapeHtml(m.target || '')}</td>
<td>${escapeHtml(m.mode || '')}${m.rw === false ? ' (ro)' : ''}</td>
</tr>`).join('')}</tbody>
</table>
</div>` : '';
return `<div class="service-rich">${limitsRow}${health}${nets}${mounts}</div>`;
}
_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 {
? `<img src="${iconUrl}" alt="${escapeHtml(this.currentApp || '')}" class="task-app-icon service-app-icon" onerror="this.style.display='none'">`
: '';
// 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'
? `<span class="service-live-chip" data-svc-live="cpu">${escapeHtml(cpuTxt)}</span>
<span class="service-live-chip${memCls}" data-svc-live="mem">${escapeHtml(memTxt)}</span>`
: '';
return `
<div class="task-item service-item" data-service="${escapeHtml(svc.serviceName)}" data-state="${state}">
<div class="task-header" data-action="toggle-logs">
@ -246,7 +420,8 @@ class ServicesManager {
${iconHtml}
<span class="task-title">${escapeHtml(svc.serviceName)}</span>
<span class="task-status ${stateClass}"><span class="service-dot service-dot-${state}"></span>${escapeHtml(state.toUpperCase())}</span>
<span class="task-time">${escapeHtml(svc.statusText)}</span>
<span class="task-time service-status-text">${escapeHtml(svc.statusText)}</span>
${liveChips}
${portChips}
${ipChip}
</div>
@ -274,6 +449,7 @@ class ServicesManager {
<div class="meta-item"><strong>State:</strong> ${escapeHtml(state)}</div>
<div class="meta-item"><strong>Status:</strong> ${escapeHtml(svc.statusText)}</div>
</div>
${this._renderRichDetail(info)}
<div class="task-logs" style="position: relative;">
<div class="log-container terminal-style service-log-output" data-stream="off" style="height: 200px; overflow-y: auto; border: 1px solid #444; border-radius: 4px; padding: 10px; background-color: #1a1a1a;"></div>
<div class="service-log-overlay" style="position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; gap: 10px; background: rgba(20, 20, 24, 0.85); backdrop-filter: blur(2px); border-radius: 4px;">
@ -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() {

View File

@ -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') {