Merge claude/2
This commit is contained in:
commit
092c1c215e
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -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() {
|
||||
|
||||
@ -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') {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user