// 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//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}
Loading services…
`; 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}

No running compose services found for ${escapeHtml(appName)}.

If the app is stopped, start it from the topbar; services will appear here once Docker reports them.

`; return; } list.innerHTML = ` ${title}
${merged.map(svc => this._renderRow(svc)).join('')}
`; this._wireActions(list); this._startRefreshLoop(); } catch (err) { console.error('Services load error', err); list.innerHTML = ` ${title}
⚠️

Failed to load services: ${escapeHtml(err.message || String(err))}

`; } } _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 `

⚡ Services

Inspect, restart and tail logs for the docker compose services that make up ${escapeHtml(display)}

`; } // 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__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 = `
Image${escapeHtml(d.image || '—')}
Container ID${escapeHtml(d.short || '')}
Started${d.state?.started_at ? escapeHtml(fmt.timeAgoIso(d.state.started_at)) : '—'}
Restart count${d.state?.restart_count ?? 0}
Memory limit${memLimit ? escapeHtml(fmt.bytes(memLimit)) : 'unlimited'}
CPU limit${escapeHtml(cpuLimit)}
PIDs limit${lim.pids ? lim.pids : 'unlimited'}
Restart policy${escapeHtml(lim.restart_policy || 'no')}${lim.restart_max ? ` (max ${lim.restart_max})` : ''}
`; const health = d.state?.health ? `

Healthcheck

${escapeHtml(d.state.health.status || 'unknown')} ${d.state.health.failing_streak ? `${d.state.health.failing_streak} failing in a row` : ''}
${(d.state.health.log || []).slice(-3).reverse().map(l => `
${escapeHtml(l.end || l.start || '')} · exit ${l.exit_code ?? '?'}
${escapeHtml(l.output || '')}
` ).join('')}
` : ''; const nets = (d.networks || []).length ? `

Networks

${d.networks.map(n => ` `).join('')}
NameIPGatewayMAC
${escapeHtml(n.name)}${escapeHtml(n.ip || '—')}${escapeHtml(n.gateway || '—')}${escapeHtml(n.mac || '—')}
` : ''; const mounts = (d.mounts || []).length ? `

Mounts

${d.mounts.map(m => ` `).join('')}
TypeSourceTargetMode
${escapeHtml(m.type || '')} ${escapeHtml(m.source || '')} ${escapeHtml(m.target || '')} ${escapeHtml(m.mode || '')}${m.rw === false ? ' (ro)' : ''}
` : ''; return `
${limitsRow}${health}${nets}${mounts}
`; } _paintLiveChips(serviceName, info) { const item = document.querySelector(`.service-item[data-service="${cssEscape(serviceName)}"]`); if (!item) return; const stats = info?.stats; const detail = info?.detail; const memLimit = detail?.limits?.memory || 0; const fmt = window.SystemFmt; const cpuEl = item.querySelector('[data-svc-live="cpu"]'); const memEl = item.querySelector('[data-svc-live="mem"]'); if (cpuEl) cpuEl.textContent = stats ? `${(stats.cpu_percent ?? 0).toFixed(1)}% CPU` : '—'; if (memEl && fmt) { const used = stats?.memory?.used ?? 0; const pct = memLimit > 0 ? (used / memLimit) * 100 : (stats?.memory?.percent ?? 0); const txt = memLimit > 0 ? `${fmt.bytes(used)} (${pct.toFixed(0)}% of ${fmt.bytes(memLimit)})` : fmt.bytes(used); memEl.textContent = stats ? txt : '—'; memEl.classList.toggle('warn', memLimit > 0 && pct >= 80); memEl.classList.toggle('danger', memLimit > 0 && pct >= 95); } } // ------------------------------------------------------------------ // Rendering // ------------------------------------------------------------------ _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 => ` ${escapeHtml(p.external)}${escapeHtml(p.internal)}${escapeHtml(p.protocol || '')}`).join(''); const ipChip = svc.serviceIP ? `${escapeHtml(svc.serviceIP)}` : ''; // 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 = ``; const renderOpenBtn = (url, label) => `${linkArrowSvg}${escapeHtml(label || 'Open')}`; 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 ? `${escapeHtml(this.currentApp || '')}` : ''; // Live stat chips — populated inline if /api/system/containers already // returned for this service, otherwise rendered as placeholders that // the periodic stats refresh fills in. const info = this.containerInfo.get(svc.serviceName) || {}; const stats = info.stats; const detail = info.detail; const memLimit = detail?.limits?.memory || 0; const fmt = window.SystemFmt; const cpuTxt = stats ? `${(stats.cpu_percent ?? 0).toFixed(1)}% CPU` : '—'; const memUsed = stats?.memory?.used ?? 0; const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : (stats?.memory?.percent ?? 0); const memTxt = stats && fmt ? (memLimit > 0 ? `${fmt.bytes(memUsed)} (${memPct.toFixed(0)}% of ${fmt.bytes(memLimit)})` : fmt.bytes(memUsed)) : '—'; const memCls = memLimit > 0 && memPct >= 95 ? ' danger' : (memLimit > 0 && memPct >= 80 ? ' warn' : ''); const liveChips = state === 'running' ? `${escapeHtml(cpuTxt)} ${escapeHtml(memTxt)}` : ''; return `
${iconHtml} ${escapeHtml(svc.serviceName)} ${escapeHtml(state.toUpperCase())} ${escapeHtml(svc.statusText)} ${liveChips} ${portChips} ${ipChip}
${openBtn}
Service: ${escapeHtml(svc.serviceName)}
${svc.containerName ? `
Container: ${escapeHtml(svc.containerName)}
` : ''} ${svc.serviceIP ? `
IP: ${escapeHtml(svc.serviceIP)}
` : ''}
State: ${escapeHtml(state)}
Status: ${escapeHtml(svc.statusText)}
${this._renderRichDetail(info)}
`; } // ------------------------------------------------------------------ // 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, '''); } 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();