// Traefik routing panel — only surfaces on the Traefik app's detail page. // Lists every installed app's TCP, routing-eligible ports in two sections: // Recommended → port_recommended=true (defaults to the webui flag for // unmigrated rows) // Advanced → other TCP ports (collapsed behind a toggle) // // Apply flow: for each changed row, mutate field 7 (traefik) of the // CFG__PORT_ string and dispatch `install` with the config override. // The existing task router runs the reinstall, which re-resolves the // compose with the new TRAEFIK_ENABLE label. class RoutingManager { constructor() { this.currentApp = null; this.changes = new Map(); // key = "|" → bool } isApplicable(appName) { return appName === 'traefik'; } async load(appName) { this.currentApp = appName; const list = document.getElementById('routing-list'); if (!list) return; if (!this.isApplicable(appName)) { list.innerHTML = `
Traefik routing controls live here.
`; return; } list.innerHTML = this._render(); this._wire(list); } unload() { this.changes.clear(); } // Walk every installed app's CFG__PORT_ rows, classify each one. _collectPorts() { const out = []; const apps = (window.apps || []).filter(a => a && a.installed && a.command); for (const app of apps) { const slug = (app.command || '').split(' ').pop(); if (!slug || slug === 'traefik') continue; for (let i = 1; i <= 20; i++) { const cfgKey = `CFG_${slug.toUpperCase()}_PORT_${i}`; const raw = app.config && app.config[cfgKey]; if (!raw) continue; const parts = String(raw).split('|'); if (parts.length < 8) continue; const isNine = parts.length >= 9; const isTwelve = parts.length >= 12; const webui = isNine ? parts[7] === 'true' : parts[6] === 'true'; const port = { appSlug: slug, appName: app.name || slug, portIdx: i, cfgKey, rawValue: String(raw), serviceName: parts[1], internalPort: (parts[2] || '').split(':')[1] || '', access: parts[3] || 'private', protocol: parts[4] || 'tcp', traefik: isNine ? parts[6] === 'true' : parts[5] === 'true', webui, subdomain: isTwelve ? (parts[10] || '') : '', recommended: isTwelve ? parts[11] === 'true' : webui, description: isNine ? (parts[8] || '') : (parts[7] || '') }; if (port.protocol !== 'tcp') continue; out.push(port); } } return out; } _previewUrl(port, app) { const domainIdx = app && app.config && app.config[`CFG_${port.appSlug.toUpperCase()}_DOMAIN`]; const domain = app && app.config && app.config[`CFG_DOMAIN_${domainIdx || 1}`]; const sub = (port.subdomain || '').trim(); const placeholder = ''; const base = domain || placeholder; if (sub === '@' || sub === 'root') return `https://${base}`; if (sub) return `https://${sub}.${base}`; return `https://${port.appSlug}.${base}`; } _render() { const ports = this._collectPorts(); const apps = (window.apps || []); const byApp = new Map(); apps.forEach(a => byApp.set((a.command || '').split(' ').pop(), a)); const primary = ports.filter(p => p.recommended); const advanced = ports.filter(p => !p.recommended); const renderRow = (p) => { const app = byApp.get(p.appSlug); const url = this._previewUrl(p, app); const iconUrl = `/core/icons/apps/${encodeURIComponent(p.appSlug)}.svg`; const badges = [ p.webui ? 'webui' : '', p.access === 'public' ? 'public' : '' ].join(''); return `
${escapeHtml(p.appName)}
${escapeHtml(p.appName)} ${escapeHtml(p.serviceName)} :${escapeHtml(p.internalPort)} ${badges}
${escapeHtml(url)}
`; }; return `

🛡️ Traefik Routing

Toggle which app ports Traefik routes. Each change applied below reinstalls the affected app so the new traefik.enable label takes effect.

Recommended ${primary.length}

Ports flagged recommended=true in their PORT config.
${primary.length ? primary.map(renderRow).join('') : '
No recommended ports — install an app whose webui port is recommended.
'}

Advanced ${advanced.length}

${advanced.length ? advanced.map(renderRow).join('') : '
No additional TCP ports across installed apps.
'}
No pending changes.
`; } _wire(root) { root.querySelectorAll('.routing-traefik').forEach(cb => { cb.addEventListener('change', () => this._trackChange(cb)); }); const showAdv = root.querySelector('#routing-show-advanced'); const advTable = root.querySelector('.routing-advanced-table'); if (showAdv && advTable) { const sync = () => { advTable.classList.toggle('routing-advanced-open', showAdv.checked); }; showAdv.addEventListener('change', sync); sync(); } root.querySelector('.routing-apply')?.addEventListener('click', () => this._apply()); } _trackChange(cb) { const row = cb.closest('.routing-row'); if (!row) return; const key = `${row.dataset.app}|${row.dataset.idx}`; // If the new value matches the original (clicked twice), drop the entry. const orig = row.querySelector('.routing-traefik').defaultChecked; if (cb.checked === orig) this.changes.delete(key); else this.changes.set(key, cb.checked); const root = document.getElementById('routing-list'); const hint = root.querySelector('#routing-apply-hint'); const applyBtn = root.querySelector('.routing-apply'); const n = this.changes.size; if (n === 0) { hint.textContent = 'No pending changes.'; applyBtn.disabled = true; } else { const apps = new Set([...this.changes.keys()].map(k => k.split('|')[0])); hint.textContent = `${n} change${n === 1 ? '' : 's'} across ${apps.size} app${apps.size === 1 ? '' : 's'} will reinstall.`; applyBtn.disabled = false; } } async _apply() { if (this.changes.size === 0) return; if (!window.tasksManager || !window.tasksManager.router) { console.error('Tasks router not available'); return; } const apps = window.apps || []; const byApp = new Map(); apps.forEach(a => byApp.set((a.command || '').split(' ').pop(), a)); // Group changes by app so each app reinstalls once with all its port edits. const grouped = new Map(); for (const [key, newTraefik] of this.changes) { const [slug, idxStr] = key.split('|'); const idx = parseInt(idxStr, 10); const app = byApp.get(slug); if (!app) continue; const cfgKey = `CFG_${slug.toUpperCase()}_PORT_${idx}`; const raw = String(app.config[cfgKey] || ''); const parts = raw.split('|'); if (parts.length < 8) continue; // Field 7 is traefik in 9+col, field 6 in 8-col legacy. if (parts.length >= 9) parts[6] = newTraefik ? 'true' : 'false'; else parts[5] = newTraefik ? 'true' : 'false'; const newValue = parts.join('|'); if (!grouped.has(slug)) grouped.set(slug, {}); grouped.get(slug)[cfgKey] = newValue; } const slugs = [...grouped.keys()]; for (const slug of slugs) { try { await window.tasksManager.router.routeAction('install', { appName: slug, config: grouped.get(slug) }); } catch (e) { console.error(`Routing apply failed for ${slug}:`, e); } } this.changes.clear(); if (window.appTabbedManager && typeof window.appTabbedManager.switchTab === 'function') { window.appTabbedManager.switchTab('tasks'); } } } function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function escapeAttr(s) { return escapeHtml(s); } window.routingManager = new RoutingManager();