librelad 9d5d0103b6 fix(routing): _previewUrl uses port.subdomain, not the retired HOST_NAME
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>
2026-05-27 13:23:47 +01:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(s) { return escapeHtml(s); }
window.routingManager = new RoutingManager();