// 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.openLogStreams = new Map(); // serviceName -> { es, container } this.servicesIndex = null; // app -> serviceName -> { ports[], urls[] } } // 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] = await Promise.all([ this._loadAggregated(appName), this._fetchStatus(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()); 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); } // ------------------------------------------------------------------ // 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 ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : ''; const iconHtml = iconUrl ? `${escapeHtml(this.currentApp || '')}` : ''; return `
${iconHtml} ${escapeHtml(svc.serviceName)} ${escapeHtml(state.toUpperCase())} ${escapeHtml(svc.statusText)} ${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)}
`; } // ------------------------------------------------------------------ // 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); } }); } 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(); this.refreshTimer = setInterval(() => this._refreshStatusOnly(), 10_000); } _stopRefreshLoop() { if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = 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();