add LAN accessibility indicator for services

Show whether each service is reachable from the network or bound to
localhost only. Dashboard displays green "LAN" or amber "local only"
badge next to each healthy service. Unified TCP check function,
concurrent health+LAN probes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-22 06:35:12 +02:00
parent 6fdadd637c
commit 9a3de2f231
2 changed files with 58 additions and 17 deletions

View File

@@ -382,6 +382,15 @@ body {
} }
.health-dot.up { background: var(--emerald); } .health-dot.up { background: var(--emerald); }
.health-dot.down { background: var(--rose); } .health-dot.down { background: var(--rose); }
.lan-badge {
font-family: var(--font-mono);
font-size: 0.58rem;
padding: 1px 5px;
border-radius: 3px;
margin-left: 0.3rem;
}
.lan-badge.shared { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
.lan-badge.local-only { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); }
/* Override form */ /* Override form */
.override-form { .override-form {
@@ -1082,16 +1091,22 @@ function renderServices(entries) {
el.innerHTML = '<div class="empty-state">No services configured</div>'; el.innerHTML = '<div class="empty-state">No services configured</div>';
return; return;
} }
el.innerHTML = entries.map(e => ` el.innerHTML = entries.map(e => {
const lanBadge = e.healthy
? (e.lan_accessible
? '<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>')
: '';
return `
<div class="service-item"> <div class="service-item">
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span> <span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
<div class="service-info"> <div class="service-info">
<div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a></div> <div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a>${lanBadge}</div>
<div class="service-port">localhost:${e.target_port} &rarr; proxied</div> <div class="service-port">localhost:${e.target_port} &rarr; proxied</div>
</div> </div>
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">&times;</button>`} ${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">&times;</button>`}
</div> </div>
`).join(''); `}).join('');
} }
async function addService(event) { async function addService(event) {

View File

@@ -590,6 +590,7 @@ struct ServiceResponse {
target_port: u16, target_port: u16,
url: String, url: String,
healthy: bool, healthy: bool,
lan_accessible: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -609,22 +610,38 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
}; };
let tld = &ctx.proxy_tld; let tld = &ctx.proxy_tld;
// Run all health checks concurrently let lan_ip = crate::lan::detect_lan_ip();
let health_futures: Vec<_> = entries
let check_futures: Vec<_> = entries
.iter() .iter()
.map(|(_, port)| check_health(*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));
async move {
let healthy = check_tcp(localhost).await;
let lan_accessible = match lan_addr {
Some(addr) => check_tcp(addr).await,
None => false,
};
(healthy, lan_accessible)
}
})
.collect(); .collect();
let health_results = futures::future::join_all(health_futures).await; let check_results = futures::future::join_all(check_futures).await;
let results: Vec<_> = entries let results: Vec<_> = entries
.into_iter() .into_iter()
.zip(health_results) .zip(check_results)
.map(|((name, port), healthy)| ServiceResponse { .map(
|((name, port), (healthy, lan_accessible))| ServiceResponse {
url: format!("http://{}.{}", name, tld), url: format!("http://{}.{}", name, tld),
name, name,
target_port: port, target_port: port,
healthy, healthy,
}) lan_accessible,
},
)
.collect(); .collect();
Json(results) Json(results)
} }
@@ -655,7 +672,15 @@ async fn create_service(
let tld = &ctx.proxy_tld; let tld = &ctx.proxy_tld;
ctx.services.lock().unwrap().insert(&name, req.target_port); ctx.services.lock().unwrap().insert(&name, req.target_port);
let healthy = check_health(req.target_port).await; let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], req.target_port));
let lan_addr =
crate::lan::detect_lan_ip().map(|ip| std::net::SocketAddr::new(ip.into(), req.target_port));
let (healthy, lan_accessible) = tokio::join!(check_tcp(localhost), async {
match lan_addr {
Some(a) => check_tcp(a).await,
None => false,
}
});
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(ServiceResponse { Json(ServiceResponse {
@@ -663,6 +688,7 @@ async fn create_service(
name, name,
target_port: req.target_port, target_port: req.target_port,
healthy, healthy,
lan_accessible,
}), }),
)) ))
} }
@@ -679,10 +705,10 @@ async fn remove_service(State(ctx): State<Arc<ServerCtx>>, Path(name): Path<Stri
} }
} }
async fn check_health(port: u16) -> bool { async fn check_tcp(addr: std::net::SocketAddr) -> bool {
tokio::time::timeout( tokio::time::timeout(
std::time::Duration::from_millis(100), std::time::Duration::from_millis(100),
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)), tokio::net::TcpStream::connect(addr),
) )
.await .await
.map(|r| r.is_ok()) .map(|r| r.is_ok())