Final modularization layout (user-chosen): every page is a self-contained folder under components/<id>/ (controllers + CSS + its html fragment), and all shared/framework code folds into core/: core/kernel (feature-registry, lifecycle, services, spa) core/boot (auth, system-loader/orchestrator, setup, loaders) core/lib (data-loader, router, helpers, the task kernel, shared modules) core/ui (topbar, modal, notifications, … + topbar.html) core/css (all shared stylesheets) core/icons Top level is now just: components/, core/, themes/, index.html (+ runtime data/). Every path reference rewritten (index.html, scripts arrays, fetch()/ loadFragment()/loadScript() literals, system-loader + config-manager controller paths, kernel manifest URL, feature.json, backend FEATURES_DIR). The /api/features/list endpoint NAME is unchanged (it now scans components/). Deleted 3 dead files (app-content.html, apps-content.html, html-cache.js). Verified: 0 stale prefixes, 0 double-rewrites, all JS/JSON valid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
818 lines
36 KiB
JavaScript
818 lines
36 KiB
JavaScript
// Services tab on the app detail page.
|
|
//
|
|
// Each row renders a single docker compose service with:
|
|
// - colored status dot (running / stopped / unknown)
|
|
// - service name + container name
|
|
// - port chips and an "Open" button when a public URL exists
|
|
// - "Up 2 hours" runtime text
|
|
// - restart button (creates a task in the existing task system)
|
|
// - expandable live log tail (SSE backed)
|
|
//
|
|
// Data sources, layered:
|
|
// 1. /data/apps/generated/apps-services.json — canonical list of services
|
|
// and their URLs/ports per app (already maintained by the WebUI updater).
|
|
// 2. /api/apps/<app>/services/status — live state from `docker ps`.
|
|
//
|
|
// The `apps-services.json` file has one row per port. We dedupe by
|
|
// serviceName so a service with multiple ports renders as one row with
|
|
// multiple port chips.
|
|
|
|
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.
|
|
async load(appName) {
|
|
this.currentApp = appName;
|
|
this._stopAllLogs();
|
|
const list = document.getElementById('services-list');
|
|
if (!list) return;
|
|
|
|
const title = this._titleBlock(appName);
|
|
|
|
list.innerHTML = `
|
|
${title}
|
|
<div class="services-loading">
|
|
<div class="services-spinner"></div>
|
|
<span>Loading services…</span>
|
|
</div>`;
|
|
|
|
try {
|
|
const [aggregated, status, _ci] = await Promise.all([
|
|
this._loadAggregated(appName),
|
|
this._fetchStatus(appName),
|
|
this._loadContainerInfo(appName)
|
|
]);
|
|
|
|
const merged = this._merge(aggregated, status);
|
|
|
|
if (merged.length === 0) {
|
|
list.innerHTML = `
|
|
${title}
|
|
<div class="services-empty">
|
|
<span class="services-empty-icon">⚡</span>
|
|
<p>No running compose services found for <strong>${escapeHtml(appName)}</strong>.</p>
|
|
<p class="services-empty-hint">If the app is stopped, start it from the topbar; services will appear here once Docker reports them.</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = `
|
|
${title}
|
|
<div class="services-rows">
|
|
${merged.map(svc => this._renderRow(svc)).join('')}
|
|
</div>`;
|
|
this._wireActions(list);
|
|
this._startRefreshLoop();
|
|
} catch (err) {
|
|
console.error('Services load error', err);
|
|
list.innerHTML = `
|
|
${title}
|
|
<div class="services-empty">
|
|
<span class="services-empty-icon">⚠️</span>
|
|
<p>Failed to load services: ${escapeHtml(err.message || String(err))}</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
_titleBlock(appName) {
|
|
const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
const adv = window.LpUi?.advanced?.get() ? 'checked' : '';
|
|
// The toggle is the visible surface for the global "Advanced UI" mode
|
|
// ([[window.LpUi.advanced]]). Flipping it here unhides the rich
|
|
// container detail (limits, mounts, networks, healthcheck) across
|
|
// every service row. The body class drives the CSS show/hide, so
|
|
// other surfaces that opt into the same mode get it for free.
|
|
return `
|
|
<div class="services-title">
|
|
<div class="services-title-main">
|
|
<h3>⚡ Services</h3>
|
|
<p>Inspect, restart and tail logs for the docker compose services that make up ${escapeHtml(display)}</p>
|
|
</div>
|
|
<label class="lp-ui-advanced-toggle" title="Reveal container internals (limits, mounts, networks, healthcheck)">
|
|
<input type="checkbox" data-action="toggle-advanced" ${adv}>
|
|
<span class="lp-ui-advanced-toggle-track"><span class="lp-ui-advanced-toggle-thumb"></span></span>
|
|
<span class="lp-ui-advanced-toggle-label">Advanced</span>
|
|
</label>
|
|
</div>`;
|
|
}
|
|
|
|
// Called when leaving the Services tab. Tear down timers and SSE.
|
|
unload() {
|
|
this._stopRefreshLoop();
|
|
this._stopAllLogs();
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Data loading
|
|
// ------------------------------------------------------------------
|
|
|
|
async _loadAggregated(appName) {
|
|
let raw = { apps: [] };
|
|
try {
|
|
const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' });
|
|
if (resp.ok) raw = await resp.json();
|
|
} catch { /* file may not exist on a brand-new install */ }
|
|
|
|
const rows = Array.isArray(raw.apps) ? raw.apps : [];
|
|
const byService = new Map();
|
|
for (const row of rows) {
|
|
if (row.app !== appName) continue;
|
|
const key = row.serviceName;
|
|
if (!byService.has(key)) {
|
|
byService.set(key, {
|
|
serviceName: row.serviceName,
|
|
serviceIP: row.serviceIP,
|
|
ports: [],
|
|
openUrl: null,
|
|
openLabel: null,
|
|
// One Open button per entry in this list. Populated from the
|
|
// generator's `links[]` array, which itself comes from the
|
|
// comma-separated label/path pairs in CFG_<APP>_PORT_N.
|
|
openLinks: []
|
|
});
|
|
}
|
|
const entry = byService.get(key);
|
|
if (row.externalPort && row.internalPort) {
|
|
entry.ports.push({
|
|
name: row.name,
|
|
external: row.externalPort,
|
|
internal: row.internalPort,
|
|
access: row.access,
|
|
protocol: row.protocol
|
|
});
|
|
}
|
|
// Pick the first enabled URL as the row's primary "Open" target
|
|
// (kept for back-compat with anything reading openUrl/openLabel).
|
|
if (row.buttonEnabled && (row.externalURL || row.internalURL) && !entry.openUrl) {
|
|
entry.openUrl = row.externalURL || row.internalURL;
|
|
entry.openLabel = row.buttonText || 'Open';
|
|
}
|
|
// Multi-button: append every link from this row, dedup'd by URL.
|
|
if (row.buttonEnabled && Array.isArray(row.links)) {
|
|
for (const link of row.links) {
|
|
const url = link.externalURL || link.internalURL;
|
|
if (!url) continue;
|
|
if (entry.openLinks.some(l => l.url === url)) continue;
|
|
entry.openLinks.push({ url, label: link.label || row.buttonText || 'Open' });
|
|
}
|
|
}
|
|
}
|
|
return [...byService.values()].sort(this._compareServices);
|
|
}
|
|
|
|
_compareServices(a, b) {
|
|
const aPrimary = /-service$/.test(a.serviceName) ? 0 : 1;
|
|
const bPrimary = /-service$/.test(b.serviceName) ? 0 : 1;
|
|
if (aPrimary !== bPrimary) return aPrimary - bPrimary;
|
|
return a.serviceName.localeCompare(b.serviceName);
|
|
}
|
|
|
|
async _fetchStatus(appName) {
|
|
const resp = await fetch(`/api/apps/${encodeURIComponent(appName)}/services/status`, { cache: 'no-store' });
|
|
if (!resp.ok) {
|
|
// Surface the backend's reason instead of silently empty-arraying. The
|
|
// most common cause is the docker socket mount being :ro, which blocks
|
|
// connect() — the resulting EACCES used to disappear into a blank tab.
|
|
const body = await resp.text().catch(() => '');
|
|
let detail = `HTTP ${resp.status}`;
|
|
try { detail = JSON.parse(body).error || detail; } catch { /* not JSON */ }
|
|
throw new Error(`Status fetch failed: ${detail}`);
|
|
}
|
|
return await resp.json();
|
|
}
|
|
|
|
_merge(aggregated, status) {
|
|
const byName = new Map(status.map(s => [s.serviceName, s]));
|
|
const out = aggregated.map(svc => {
|
|
const live = byName.get(svc.serviceName) || {};
|
|
byName.delete(svc.serviceName);
|
|
return {
|
|
...svc,
|
|
state: live.state || 'unknown',
|
|
statusText: live.statusText || 'Container not found',
|
|
containerName: live.containerName || ''
|
|
};
|
|
});
|
|
// Any docker-reported services we didn't know about (e.g. an
|
|
// ephemeral helper container) — surface them too with no port info.
|
|
for (const live of byName.values()) {
|
|
out.push({
|
|
serviceName: live.serviceName,
|
|
serviceIP: '',
|
|
ports: [],
|
|
openUrl: null,
|
|
openLabel: null,
|
|
state: live.state,
|
|
statusText: live.statusText,
|
|
containerName: live.containerName
|
|
});
|
|
}
|
|
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
|
|
// ------------------------------------------------------------------
|
|
|
|
_renderRow(svc) {
|
|
const state = (svc.state || 'unknown').toLowerCase();
|
|
const stateClass = `status-${state}`;
|
|
|
|
// Mirror the task-list .task-info layout: status pill on the left,
|
|
// title, then chips. Ports collapse into the info row instead of
|
|
// sitting next to the action buttons so it reads the same as
|
|
// "duration"/"time" chips on a task row.
|
|
const portChips = svc.ports.map(p => `
|
|
<span class="service-port">${escapeHtml(p.external)}<span class="service-port-arrow">→</span>${escapeHtml(p.internal)}<span class="service-port-proto">${escapeHtml(p.protocol || '')}</span></span>`).join('');
|
|
|
|
const ipChip = svc.serviceIP
|
|
? `<span class="service-ip">${escapeHtml(svc.serviceIP)}</span>`
|
|
: '';
|
|
|
|
// Multi-button render via the same expandServiceLinks() helper the
|
|
// other UI surfaces use, so all four button locations stay in sync
|
|
// (Services tab, app-header, apps-list popup, dashboard hover).
|
|
// Pre-merged svc.openLinks (built from per-row links[] in _loadAggregated)
|
|
// is preferred when present so the user gets a deduped list across
|
|
// multiple ports of the same service.
|
|
const linkArrowSvg = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 3h7v7"/><path d="M10 14L21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>`;
|
|
const renderOpenBtn = (url, label) =>
|
|
`<a class="task-btn open" href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${escapeHtml(label || 'Open')}" onclick="event.stopPropagation();">${linkArrowSvg}<span class="task-btn-label">${escapeHtml(label || 'Open')}</span></a>`;
|
|
const linksToRender = (Array.isArray(svc.openLinks) && svc.openLinks.length > 0)
|
|
? svc.openLinks
|
|
: (svc.openUrl ? [{ url: svc.openUrl, label: svc.openLabel || 'Open' }] : []);
|
|
const openBtn = linksToRender.map(l => renderOpenBtn(l.url, l.label)).join('');
|
|
|
|
const iconUrl = this.currentApp ? `/core/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : '';
|
|
const iconHtml = iconUrl
|
|
? `<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-details">
|
|
<div class="task-info">
|
|
${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 service-status-text">${escapeHtml(svc.statusText)}</span>
|
|
${liveChips}
|
|
${portChips}
|
|
${ipChip}
|
|
</div>
|
|
<div class="task-actions">
|
|
${openBtn}
|
|
<button class="task-btn delete service-restart" data-action="restart" title="Restart this service">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
|
|
</svg>
|
|
<span class="task-btn-label">Restart</span>
|
|
</button>
|
|
<button class="task-btn toggle-details service-details" data-action="toggle-details" title="Show service details">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="6,9 12,15 18,9"></polyline>
|
|
</svg>
|
|
<span class="task-btn-label">Details</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="task-details">
|
|
<div class="task-meta">
|
|
<div class="meta-item"><strong>Service:</strong> ${escapeHtml(svc.serviceName)}</div>
|
|
${svc.containerName ? `<div class="meta-item"><strong>Container:</strong> ${escapeHtml(svc.containerName)}</div>` : ''}
|
|
${svc.serviceIP ? `<div class="meta-item"><strong>IP:</strong> ${escapeHtml(svc.serviceIP)}</div>` : ''}
|
|
<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="service-logs-toggle">
|
|
<button type="button" class="task-btn service-show-logs" data-action="toggle-log-stream" title="Tail this container's logs">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
|
<span class="task-btn-label">Show logs</span>
|
|
</button>
|
|
</div>
|
|
<div class="task-logs" style="position: relative; display: none;">
|
|
<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;">
|
|
<div class="service-log-overlay-msg" style="color: #ddd; font-size: 13px;"></div>
|
|
<button type="button" class="task-btn service-log-resume" data-action="resume-logs" style="padding: 6px 14px;">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5,3 19,12 5,21"></polygon></svg>
|
|
<span class="task-btn-label">Resume</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Actions
|
|
// ------------------------------------------------------------------
|
|
|
|
_wireActions(root) {
|
|
if (root.dataset.wired === '1') return;
|
|
root.dataset.wired = '1';
|
|
root.addEventListener('click', async (ev) => {
|
|
const btn = ev.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
const item = btn.closest('.service-item');
|
|
if (!item) return;
|
|
const serviceName = item.dataset.service;
|
|
const action = btn.dataset.action;
|
|
|
|
if (action === 'restart') {
|
|
await this._restartService(serviceName, btn);
|
|
} else if (action === 'toggle-details') {
|
|
this._toggleDetails(item, serviceName);
|
|
} else if (action === 'toggle-log-stream') {
|
|
this._toggleLogStream(item, serviceName);
|
|
} else if (action === 'resume-logs') {
|
|
this._resumeLogs(item, serviceName);
|
|
}
|
|
});
|
|
// Advanced toggle lives in the title, not on a row — bind change here
|
|
// so it works regardless of which service rows are present.
|
|
root.addEventListener('change', (ev) => {
|
|
const cb = ev.target.closest('[data-action="toggle-advanced"]');
|
|
if (!cb || !window.LpUi?.advanced) return;
|
|
window.LpUi.advanced.set(cb.checked);
|
|
});
|
|
// Dev-mode 10-tap unlock — mirrors the topbar LibrePortal-logo easter
|
|
// egg, but on the Advanced toggle's surrounding label. Captures clicks
|
|
// on the label or the slider track so toggling the checkbox and
|
|
// "tapping" the toggle both count. Counts reset after 3 s of idle and
|
|
// start showing a countdown toast at click 6. On the 10th click we
|
|
// ask the topbar to flip CFG_DEV_MODE the standard way (config_update
|
|
// task), which also writes the persistent config — same path the
|
|
// logo easter egg already uses, so dev-mode behaviour stays singular.
|
|
this._wireDevTapEasterEgg(root);
|
|
}
|
|
|
|
_wireDevTapEasterEgg(root) {
|
|
const TARGET = 10;
|
|
const TOAST_FROM = 6;
|
|
const RESET_AFTER_MS = 3000;
|
|
let count = 0;
|
|
let resetTimer = null;
|
|
let currentToast = null;
|
|
root.addEventListener('click', (ev) => {
|
|
// Count clicks on the toggle's visible surface (label + track),
|
|
// not the hidden checkbox input (those bubble too but are already
|
|
// covered by the label click).
|
|
const inToggle = ev.target.closest('.lp-ui-advanced-toggle');
|
|
if (!inToggle) return;
|
|
count++;
|
|
if (resetTimer) clearTimeout(resetTimer);
|
|
resetTimer = setTimeout(() => { count = 0; currentToast = null; }, RESET_AFTER_MS);
|
|
const remaining = TARGET - count;
|
|
const devOn = (window.systemConfigs?.CFG_DEV_MODE === 'true');
|
|
const verb = devOn ? 'disabling' : 'being';
|
|
const noun = devOn ? 'developer mode' : 'a developer';
|
|
if (remaining > 0 && count >= TOAST_FROM) {
|
|
const msg = `You are ${remaining} click${remaining === 1 ? '' : 's'} away from ${verb} ${noun}.`;
|
|
const msgEl = currentToast && currentToast.parentElement
|
|
? currentToast.querySelector('.notification-message') : null;
|
|
if (msgEl) {
|
|
msgEl.innerHTML = msg;
|
|
} else if (window.notificationSystem?.show) {
|
|
currentToast = window.notificationSystem.show(msg, 'info');
|
|
}
|
|
} else if (remaining === 0) {
|
|
count = 0;
|
|
clearTimeout(resetTimer);
|
|
currentToast = null;
|
|
// Reuse the topbar's setter so there's one canonical path for
|
|
// toggling CFG_DEV_MODE (it handles the task-route, the cache
|
|
// update, the banner toggle, and now the LpUi.dev mirror).
|
|
const topbar = window.topbar || window.libreportalTopbar;
|
|
if (topbar && typeof topbar._setDevMode === 'function') {
|
|
topbar._setDevMode(!devOn);
|
|
} else if (window.LpUi?.dev) {
|
|
// Fallback for the rare case the topbar instance isn't on the
|
|
// window — flip the body class so the user gets immediate
|
|
// feedback; the next page load will re-sync from CFG.
|
|
window.LpUi.dev.set(!devOn);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async _restartService(serviceName, btn) {
|
|
if (!this.currentApp) return;
|
|
btn.disabled = true;
|
|
btn.classList.add('is-running');
|
|
try {
|
|
const resp = await fetch(
|
|
`/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/restart`,
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' } }
|
|
);
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
|
}
|
|
// Background task processor picks it up — refresh status shortly.
|
|
setTimeout(() => this._refreshStatusOnly(), 2500);
|
|
setTimeout(() => this._refreshStatusOnly(), 7000);
|
|
} catch (e) {
|
|
alert(`Restart failed: ${e.message}`);
|
|
} finally {
|
|
setTimeout(() => {
|
|
btn.disabled = false;
|
|
btn.classList.remove('is-running');
|
|
}, 1500);
|
|
}
|
|
}
|
|
|
|
// Toggle the .task-details panel (meta + rich detail + log toggle).
|
|
// Logs are NOT auto-opened here — the user has to click "Show logs"
|
|
// explicitly. Closing the panel also tears down any open log stream
|
|
// and resets the inline log block back to its hidden state.
|
|
_toggleDetails(item, serviceName) {
|
|
const details = item.querySelector('.task-details');
|
|
if (!details) return;
|
|
|
|
const isOpen = details.classList.contains('task-details-open');
|
|
if (isOpen) {
|
|
details.classList.remove('task-details-open');
|
|
this._resetLogBlock(item, serviceName);
|
|
return;
|
|
}
|
|
details.classList.add('task-details-open');
|
|
}
|
|
|
|
// Show / hide the log block inside the open details panel. Opening
|
|
// starts the SSE stream so logs auto-update; closing tears it down.
|
|
_toggleLogStream(item, serviceName) {
|
|
const logsBlock = item.querySelector('.task-logs');
|
|
const output = item.querySelector('.service-log-output');
|
|
if (!logsBlock || !output) return;
|
|
|
|
const showing = item.classList.contains('logs-shown');
|
|
if (showing) {
|
|
this._resetLogBlock(item, serviceName);
|
|
return;
|
|
}
|
|
|
|
logsBlock.style.display = '';
|
|
output.textContent = '';
|
|
this._hideLogOverlay(output);
|
|
output.dataset.stream = 'connecting';
|
|
this._openLogStream(serviceName, output);
|
|
item.classList.add('logs-shown');
|
|
this._setLogToggleLabel(item, 'Hide logs');
|
|
}
|
|
|
|
// Tear down the log stream and put the block back to its closed state.
|
|
// Used both when the user clicks "Hide logs" and when the parent
|
|
// details panel collapses (so reopening starts in the clean state).
|
|
_resetLogBlock(item, serviceName) {
|
|
const logsBlock = item.querySelector('.task-logs');
|
|
const output = item.querySelector('.service-log-output');
|
|
if (output) this._hideLogOverlay(output);
|
|
if (logsBlock) logsBlock.style.display = 'none';
|
|
this._closeLogStream(serviceName);
|
|
item.classList.remove('logs-shown');
|
|
this._setLogToggleLabel(item, 'Show logs');
|
|
}
|
|
|
|
_setLogToggleLabel(item, text) {
|
|
const lbl = item.querySelector('.service-show-logs .task-btn-label');
|
|
if (lbl) lbl.textContent = text;
|
|
}
|
|
|
|
_openLogStream(serviceName, outputEl) {
|
|
if (!this.currentApp) return;
|
|
this._closeLogStream(serviceName);
|
|
|
|
const url = `/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/logs?tail=200`;
|
|
const es = new EventSource(url);
|
|
|
|
es.addEventListener('ready', () => {
|
|
outputEl.dataset.stream = 'live';
|
|
});
|
|
es.addEventListener('log', (ev) => {
|
|
try {
|
|
const data = JSON.parse(ev.data);
|
|
const text = (data.lines || []).join('\n') + '\n';
|
|
const wasAtBottom = isScrolledToBottom(outputEl);
|
|
outputEl.appendChild(document.createTextNode(text));
|
|
// Cap buffer at ~1000 lines to keep the DOM cheap.
|
|
const lines = outputEl.textContent.split('\n');
|
|
if (lines.length > 1000) {
|
|
outputEl.textContent = lines.slice(-1000).join('\n');
|
|
}
|
|
if (wasAtBottom) outputEl.scrollTop = outputEl.scrollHeight;
|
|
} catch { /* ignore malformed event */ }
|
|
});
|
|
es.addEventListener('error', () => {
|
|
// EventSource auto-reconnects; reflect connection state.
|
|
outputEl.dataset.stream = 'disconnected';
|
|
});
|
|
es.addEventListener('end', (ev) => {
|
|
let detail = {};
|
|
try { detail = JSON.parse(ev.data || '{}'); } catch { /* ignore */ }
|
|
outputEl.dataset.stream = 'closed';
|
|
this._closeLogStream(serviceName);
|
|
// Server-side timeouts surface as `end` events with a reason — pop
|
|
// the Resume overlay so the user can re-open the stream with one
|
|
// click. The displayed log buffer is preserved.
|
|
if (detail.reason === 'idle-timeout' || detail.reason === 'max-duration') {
|
|
this._showLogOverlay(outputEl, detail);
|
|
}
|
|
});
|
|
|
|
this.openLogStreams.set(serviceName, { es });
|
|
}
|
|
|
|
_showLogOverlay(outputEl, detail) {
|
|
const wrap = outputEl.closest('.task-logs');
|
|
const overlay = wrap?.querySelector('.service-log-overlay');
|
|
const msg = overlay?.querySelector('.service-log-overlay-msg');
|
|
if (!overlay || !msg) return;
|
|
const minutes = detail.limitMinutes ? Math.round(detail.limitMinutes) : '';
|
|
if (detail.reason === 'idle-timeout') {
|
|
msg.textContent = `Stream paused — no log activity for ${minutes} minute${minutes === 1 ? '' : 's'}.`;
|
|
} else {
|
|
msg.textContent = `Stream stopped — reached the ${minutes}-minute cap.`;
|
|
}
|
|
overlay.style.display = 'flex';
|
|
}
|
|
|
|
_hideLogOverlay(outputEl) {
|
|
const wrap = outputEl.closest('.task-logs');
|
|
const overlay = wrap?.querySelector('.service-log-overlay');
|
|
if (overlay) overlay.style.display = 'none';
|
|
}
|
|
|
|
_resumeLogs(item, serviceName) {
|
|
const output = item.querySelector('.service-log-output');
|
|
if (!output) return;
|
|
this._hideLogOverlay(output);
|
|
output.dataset.stream = 'connecting';
|
|
this._openLogStream(serviceName, output);
|
|
}
|
|
|
|
_closeLogStream(serviceName) {
|
|
const entry = this.openLogStreams.get(serviceName);
|
|
if (!entry) return;
|
|
try { entry.es.close(); } catch { /* already closed */ }
|
|
this.openLogStreams.delete(serviceName);
|
|
}
|
|
|
|
_stopAllLogs() {
|
|
for (const [name] of this.openLogStreams) this._closeLogStream(name);
|
|
}
|
|
|
|
// Public hook for the page lifecycle (pagehide). Closes every live log
|
|
// tail so the browser can BFCache-snapshot the page instead of being
|
|
// forced into a full reload on back/forward.
|
|
pauseStreams() { this._stopAllLogs(); }
|
|
|
|
// ------------------------------------------------------------------
|
|
// Status refresh loop (only updates dots/text, not full re-render)
|
|
// ------------------------------------------------------------------
|
|
|
|
_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() {
|
|
if (this.refreshTimer) {
|
|
clearInterval(this.refreshTimer);
|
|
this.refreshTimer = null;
|
|
}
|
|
if (this.statsTimer) {
|
|
clearInterval(this.statsTimer);
|
|
this.statsTimer = null;
|
|
}
|
|
}
|
|
|
|
async _refreshStatusOnly() {
|
|
if (!this.currentApp) return;
|
|
if (!document.getElementById('services-tab')?.classList.contains('active')) return;
|
|
let status;
|
|
try { status = await this._fetchStatus(this.currentApp); }
|
|
catch { return; }
|
|
|
|
for (const live of status) {
|
|
const item = document.querySelector(`.service-item[data-service="${cssEscape(live.serviceName)}"]`);
|
|
if (!item) continue;
|
|
const state = (live.state || 'unknown').toLowerCase();
|
|
item.dataset.state = state;
|
|
const dot = item.querySelector('.service-dot');
|
|
if (dot) {
|
|
dot.className = `service-dot service-dot-${state}`;
|
|
dot.title = live.statusText || '';
|
|
}
|
|
const txt = item.querySelector('.service-status-text');
|
|
if (txt) txt.textContent = live.statusText || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tiny helpers ----------------------------------------------------------
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function cssEscape(s) {
|
|
if (window.CSS && CSS.escape) return CSS.escape(s);
|
|
return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c);
|
|
}
|
|
|
|
function isScrolledToBottom(el) {
|
|
return el.scrollHeight - el.clientHeight - el.scrollTop < 4;
|
|
}
|
|
|
|
// Singleton — app-tabbed-manager calls `window.servicesManager.load(app)`.
|
|
window.servicesManager = new ServicesManager();
|