librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

508 lines
20 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.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}
<div class="services-loading">
<div class="services-spinner"></div>
<span>Loading services…</span>
</div>`;
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}
<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());
return `
<div class="services-title">
<h3>⚡ Services</h3>
<p>Inspect, restart and tail logs for the docker compose services that make up ${escapeHtml(display)}</p>
</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);
}
// ------------------------------------------------------------------
// 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'">`
: '';
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">${escapeHtml(svc.statusText)}</span>
${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>
</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);
}
});
}
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, '&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();