dashboard: route CRUD, source-aware service controls, XSS fix
- Add inline route management (+ route / x) per service in dashboard - Expose service source (config vs api) in API response - Only show service delete button for API-created services - Pre-fill route port with service target_port - Fix XSS in route path onclick handlers - Skip renderServices refresh while route form is open (editingRoute guard) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1086,7 +1086,10 @@ async function removeAllowlistDomain(domain) {
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
let editingRoute = false;
|
||||
|
||||
function renderServices(entries) {
|
||||
if (editingRoute) return;
|
||||
const el = document.getElementById('servicesList');
|
||||
if (!entries.length) {
|
||||
el.innerHTML = '<div class="empty-state">No services configured</div>';
|
||||
@@ -1098,13 +1101,16 @@ function renderServices(entries) {
|
||||
? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>'
|
||||
: '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>')
|
||||
: '';
|
||||
const esc = s => s.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
const routeLines = (e.routes || []).map(r =>
|
||||
`<div class="service-port" style="color:var(--text-dim);">` +
|
||||
`<div class="service-port" style="color:var(--text-dim);display:flex;align-items:center;gap:0.3rem;">` +
|
||||
`<span style="display:inline-block;min-width:60px;">${r.path}</span> ` +
|
||||
`→ :${r.port}` +
|
||||
(r.strip ? ` <span style="opacity:0.6;">(strip)</span>` : '') +
|
||||
(e.name === 'numa' ? '' : ` <button class="btn-delete" onclick="deleteRoute('${esc(e.name)}','${esc(r.path)}')" title="Remove route" style="font-size:0.65rem;padding:0 0.25rem;min-width:auto;opacity:0.5;">×</button>`) +
|
||||
`</div>`
|
||||
).join('');
|
||||
const deletable = e.source !== 'config' && e.name !== 'numa';
|
||||
return `
|
||||
<div class="service-item">
|
||||
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
|
||||
@@ -1112,12 +1118,52 @@ function renderServices(entries) {
|
||||
<div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a>${lanBadge}</div>
|
||||
<div class="service-port">localhost:${e.target_port} → proxied</div>
|
||||
${routeLines}
|
||||
${e.name === 'numa' ? '' : `<div style="margin-top:0.3rem;"><button onclick="toggleRouteForm('${e.name}')" style="font-size:0.7rem;padding:0.1rem 0.4rem;background:var(--emerald);color:var(--bg);border:none;border-radius:4px;cursor:pointer;">+ route</button><div id="routeForm-${e.name}" style="display:none;margin-top:0.3rem;"><div style="display:flex;gap:0.3rem;align-items:center;"><input type="text" id="routePath-${e.name}" placeholder="/path" style="flex:2;padding:0.25rem 0.4rem;font-size:0.75rem;"><input type="number" id="routePort-${e.name}" value="${e.target_port}" min="1" max="65535" style="flex:1;padding:0.25rem 0.4rem;font-size:0.75rem;"><label style="font-size:0.7rem;color:var(--text-dim);display:flex;align-items:center;gap:0.2rem;"><input type="checkbox" id="routeStrip-${e.name}">strip</label><button onclick="addRoute('${e.name}')" style="font-size:0.7rem;padding:0.2rem 0.5rem;background:var(--emerald);color:var(--bg);border:none;border-radius:4px;cursor:pointer;">add</button></div><div class="override-error" id="routeError-${e.name}" style="display:none;font-size:0.7rem;"></div></div></div>`}
|
||||
</div>
|
||||
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">×</button>`}
|
||||
${deletable ? `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">×</button>` : ''}
|
||||
</div>
|
||||
`}).join('');
|
||||
}
|
||||
|
||||
function toggleRouteForm(name) {
|
||||
const el = document.getElementById('routeForm-' + name);
|
||||
const opening = el.style.display === 'none';
|
||||
el.style.display = opening ? 'block' : 'none';
|
||||
editingRoute = opening;
|
||||
}
|
||||
|
||||
async function addRoute(name) {
|
||||
const errEl = document.getElementById('routeError-' + name);
|
||||
errEl.style.display = 'none';
|
||||
try {
|
||||
const path = document.getElementById('routePath-' + name).value.trim();
|
||||
const port = parseInt(document.getElementById('routePort-' + name).value) || 0;
|
||||
const strip = document.getElementById('routeStrip-' + name).checked;
|
||||
const res = await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, port, strip }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
editingRoute = false;
|
||||
refresh();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRoute(name, path) {
|
||||
try {
|
||||
await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
refresh();
|
||||
} catch (err) { /* next refresh will update */ }
|
||||
}
|
||||
|
||||
async function addService(event) {
|
||||
event.preventDefault();
|
||||
const errEl = document.getElementById('serviceError');
|
||||
|
||||
Reference in New Issue
Block a user