routing-manager.js read CFG_<APP>_HOST_NAME for its preview URL, but that key was retired by the per-port subdomain refactor (2e4f420, 2026-05-22) and no .config defines it anymore. The lookup always returned undefined, so even with a configured domain the preview fell through to the `<your-domain>` placeholder instead of showing the real host. Now derives the preview from the port's own subdomain (parts[10] of the 12-col PORT row), matching the canonical host_setup rule in scripts/network/variables/variables_init_app.sh: @ / root -> apex (`https://<domain>`) set -> `https://<sub>.<domain>` empty -> `https://<app>.<domain>` Also adds `subdomain` to the port object emitted by _collectPorts so this and any future per-row consumer can read it. Signed-off-by: librelad <librelad@digitalangels.vip>
249 lines
9.8 KiB
JavaScript
249 lines
9.8 KiB
JavaScript
// 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_<APP>_PORT_<n> 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 = "<appSlug>|<portIdx>" → 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 = `<div class="routing-empty">Traefik routing controls live here.</div>`;
|
|
return;
|
|
}
|
|
list.innerHTML = this._render();
|
|
this._wire(list);
|
|
}
|
|
|
|
unload() { this.changes.clear(); }
|
|
|
|
// Walk every installed app's CFG_<APP>_PORT_<n> 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 = '<your-domain>';
|
|
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 = `/icons/apps/${encodeURIComponent(p.appSlug)}.svg`;
|
|
const badges = [
|
|
p.webui ? '<span class="routing-badge routing-badge-webui" title="Marked as the webui (click-to-open) port">webui</span>' : '',
|
|
p.access === 'public' ? '<span class="routing-badge routing-badge-public" title="Host port is exposed">public</span>' : ''
|
|
].join('');
|
|
return `
|
|
<div class="routing-row" data-app="${escapeAttr(p.appSlug)}" data-idx="${p.portIdx}">
|
|
<img class="routing-icon" src="${iconUrl}" alt="${escapeHtml(p.appName)}" onerror="this.style.visibility='hidden'">
|
|
<div class="routing-meta">
|
|
<div class="routing-title">
|
|
<span class="routing-app">${escapeHtml(p.appName)}</span>
|
|
<span class="routing-port-name">${escapeHtml(p.serviceName)}</span>
|
|
<span class="routing-port-num">:${escapeHtml(p.internalPort)}</span>
|
|
${badges}
|
|
</div>
|
|
<div class="routing-url">${escapeHtml(url)}</div>
|
|
</div>
|
|
<label class="routing-toggle" title="Toggle Traefik routing for this port">
|
|
<input type="checkbox" class="routing-traefik" ${p.traefik ? 'checked' : ''}>
|
|
<span class="routing-toggle-track"></span>
|
|
</label>
|
|
</div>`;
|
|
};
|
|
|
|
return `
|
|
<div class="routing-title-block">
|
|
<h3>🛡️ Traefik Routing</h3>
|
|
<p>Toggle which app ports Traefik routes. Each change applied below reinstalls the affected app so the new <code>traefik.enable</code> label takes effect.</p>
|
|
</div>
|
|
|
|
<div class="routing-section">
|
|
<div class="routing-section-head">
|
|
<h4>Recommended <span class="routing-count">${primary.length}</span></h4>
|
|
<span class="routing-section-hint">Ports flagged <code>recommended=true</code> in their PORT config.</span>
|
|
</div>
|
|
<div class="routing-table">
|
|
${primary.length ? primary.map(renderRow).join('') : '<div class="routing-empty">No recommended ports — install an app whose webui port is recommended.</div>'}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="routing-section">
|
|
<div class="routing-section-head">
|
|
<h4>Advanced <span class="routing-count">${advanced.length}</span></h4>
|
|
<label class="routing-show-advanced">
|
|
<input type="checkbox" id="routing-show-advanced">
|
|
<span>Show advanced ports</span>
|
|
</label>
|
|
</div>
|
|
<div class="routing-table routing-advanced-table">
|
|
${advanced.length ? advanced.map(renderRow).join('') : '<div class="routing-empty">No additional TCP ports across installed apps.</div>'}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="routing-apply-bar">
|
|
<span class="routing-apply-hint" id="routing-apply-hint">No pending changes.</span>
|
|
<button type="button" class="btn btn-primary routing-apply" disabled>Apply</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_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, '"').replace(/'/g, ''');
|
|
}
|
|
function escapeAttr(s) { return escapeHtml(s); }
|
|
|
|
window.routingManager = new RoutingManager();
|