librelad 9a92805bdb feat(ui): Beginner/Advanced experience level + linked dev mode + setup-wizard step
Adds the install-time Beginner/Advanced choice the user described, with
the linked dev-mode escape hatch and global body-class machinery that
any surface can hang advanced/dev-only DOM off.

Three-tier mental model, two flags in the data model:

  Beginner            default. nothing extra shown.
  Advanced            .lp-advanced DOM revealed; advanced wizard steps shown
  Adv+Dev             .lp-dev DOM also revealed; dev-only fields visible

Linking rule (enforced inside LpUi):
  - enabling dev auto-enables advanced (dev w/o advanced is incoherent)
  - disabling advanced auto-disables dev

Wire shape:
  CFG_INSTALL_LEVEL                  beginner | advanced (general_basic)
  CFG_DEV_MODE                       existing, unchanged behaviour
  window.LpUi.{advanced,dev}         {get(), set(), apply()}
  localStorage keys                  lp.ui.advanced, lp.ui.dev, lp.ui.seeded
  body classes                       lp-ui--advanced, lp-ui--dev
  events                             lp-ui-advanced-changed, lp-ui-dev-changed
  global CSS gates                   body:not(.lp-ui--advanced) .lp-advanced { hide }
                                     body:not(.lp-ui--dev) .lp-dev { hide }

Setup wizard:
  - New step 1 "Choose your experience" with Beginner/Advanced cards.
    Beginner is preselected so race-through gets the safe default.
  - Picking a level updates totalSteps live (4 for beginner, 5 for
    advanced) so the progress bar reflects the choice.
  - Metrics step (Prometheus + Grafana) is gated to Advanced — beginner
    never sees it, never gets asked, never installs them by accident.
  - Submit payload now carries install_level; setup-routes.js validates
    it against the enum (beginner|advanced).
  - scripts/setup/setup_apply.sh writes it to CFG_INSTALL_LEVEL via
    updateConfigOption.
  - On submit, LpUi.advanced.set is called immediately so the next
    surface (running-tasks page) is already in the right mode — no
    refresh needed.

WebUI bootstrap:
  - js/utils/lp-ui.js loads first thing in index.html (before any other
    bootstrap) so body.lp-ui--advanced is applied pre-paint — no FOUC
    of advanced content on a fresh tab.
  - On first run, seeds lp.ui.advanced from CFG_INSTALL_LEVEL.
    Subsequent loads honour the user's per-browser override.
  - Mirrors CFG_DEV_MODE → lp.ui.dev on the seed pass.

Dev-mode unlock:
  - Existing 10-click LibrePortal-logo easter egg unchanged.
  - NEW: same 10-click unlock on the Advanced toggle (in services-manager).
    Reuses the countdown-toast pattern; on the 10th click delegates to
    the topbar's _setDevMode so there's one canonical setter and the
    config_update task path stays singular.
  - TopbarComponent now exposes its instance as window.topbar so the
    toggle's tap handler can reach _setDevMode.
  - topbar._setDevMode also calls LpUi.dev.set(enabled) so the body
    class flips immediately (no reload needed to see dev-only DOM).

Convention rolled out:
  - Services tab's .service-rich panel was already gated on
    body.lp-ui--advanced.
  - .lp-advanced / .lp-dev are now first-class hide classes any
    component can tag DOM with — see style.css globals.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 23:31:50 +01:00

770 lines
34 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 ? `/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-logs">
<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-logs" data-action="toggle-logs" title="Toggle live logs">
<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">Logs</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>
<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;">
<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>
${this._renderRichDetail(info)}
</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-logs') {
this._toggleLogs(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);
}
}
_toggleLogs(item, serviceName) {
// The task-list uses a .task-details-open class (not the `hidden`
// attribute) because .task-details has `display: none` baked in.
const details = item.querySelector('.task-details');
const output = item.querySelector('.service-log-output');
if (!details || !output) return;
const isOpen = details.classList.contains('task-details-open');
if (isOpen) {
details.classList.remove('task-details-open');
this._closeLogStream(serviceName);
this._hideLogOverlay(output);
return;
}
details.classList.add('task-details-open');
output.textContent = '';
this._hideLogOverlay(output);
output.dataset.stream = 'connecting';
this._openLogStream(serviceName, output);
}
_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);
}
// ------------------------------------------------------------------
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();