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');
|
||||
|
||||
21
src/api.rs
21
src/api.rs
@@ -601,6 +601,7 @@ struct ServiceResponse {
|
||||
lan_accessible: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
routes: Vec<crate::service_store::RouteEntry>,
|
||||
source: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -615,7 +616,19 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
|
||||
store
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|e| (e.name.clone(), e.target_port, e.routes.clone()))
|
||||
.map(|e| {
|
||||
let source = if store.is_config_service(&e.name) {
|
||||
"config"
|
||||
} else {
|
||||
"api"
|
||||
};
|
||||
(
|
||||
e.name.clone(),
|
||||
e.target_port,
|
||||
e.routes.clone(),
|
||||
source.to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let tld = &ctx.proxy_tld;
|
||||
@@ -624,7 +637,7 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
|
||||
|
||||
let check_futures: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(_, port, _)| {
|
||||
.map(|(_, port, _, _)| {
|
||||
let port = *port;
|
||||
let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let lan_addr = lan_ip.map(|ip| std::net::SocketAddr::new(ip.into(), port));
|
||||
@@ -644,13 +657,14 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
|
||||
.into_iter()
|
||||
.zip(check_results)
|
||||
.map(
|
||||
|((name, port, routes), (healthy, lan_accessible))| ServiceResponse {
|
||||
|((name, port, routes, source), (healthy, lan_accessible))| ServiceResponse {
|
||||
url: format!("http://{}.{}", name, tld),
|
||||
name,
|
||||
target_port: port,
|
||||
healthy,
|
||||
lan_accessible,
|
||||
routes,
|
||||
source,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
@@ -701,6 +715,7 @@ async fn create_service(
|
||||
healthy,
|
||||
lan_accessible,
|
||||
routes: Vec::new(),
|
||||
source: "api".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -143,6 +143,11 @@ impl ServiceStore {
|
||||
removed
|
||||
}
|
||||
|
||||
/// Names are always stored lowercased, so callers must pass lowercase keys.
|
||||
pub fn is_config_service(&self, name: &str) -> bool {
|
||||
self.config_services.contains(name)
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<&ServiceEntry> {
|
||||
let mut entries: Vec<_> = self.entries.values().collect();
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
Reference in New Issue
Block a user