Add LAN service discovery via UDP multicast #7
@@ -382,6 +382,15 @@ body {
|
||||
}
|
||||
.health-dot.up { background: var(--emerald); }
|
||||
.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 {
|
||||
@@ -1082,16 +1091,22 @@ function renderServices(entries) {
|
||||
el.innerHTML = '<div class="empty-state">No services configured</div>';
|
||||
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">
|
||||
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
|
||||
<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} → proxied</div>
|
||||
</div>
|
||||
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">×</button>`}
|
||||
</div>
|
||||
`).join('');
|
||||
`}).join('');
|
||||
}
|
||||
|
||||
async function addService(event) {
|
||||
|
||||
46
src/api.rs
46
src/api.rs
@@ -590,6 +590,7 @@ struct ServiceResponse {
|
||||
target_port: u16,
|
||||
url: String,
|
||||
healthy: bool,
|
||||
lan_accessible: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -609,22 +610,38 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
|
||||
};
|
||||
let tld = &ctx.proxy_tld;
|
||||
|
||||
// Run all health checks concurrently
|
||||
let health_futures: Vec<_> = entries
|
||||
let lan_ip = crate::lan::detect_lan_ip();
|
||||
|
||||
let check_futures: Vec<_> = entries
|
||||
.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();
|
||||
let health_results = futures::future::join_all(health_futures).await;
|
||||
let check_results = futures::future::join_all(check_futures).await;
|
||||
|
||||
let results: Vec<_> = entries
|
||||
.into_iter()
|
||||
.zip(health_results)
|
||||
.map(|((name, port), healthy)| ServiceResponse {
|
||||
.zip(check_results)
|
||||
.map(
|
||||
|((name, port), (healthy, lan_accessible))| ServiceResponse {
|
||||
url: format!("http://{}.{}", name, tld),
|
||||
name,
|
||||
target_port: port,
|
||||
healthy,
|
||||
})
|
||||
lan_accessible,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
Json(results)
|
||||
}
|
||||
@@ -655,7 +672,15 @@ async fn create_service(
|
||||
let tld = &ctx.proxy_tld;
|
||||
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((
|
||||
StatusCode::CREATED,
|
||||
Json(ServiceResponse {
|
||||
@@ -663,6 +688,7 @@ async fn create_service(
|
||||
name,
|
||||
target_port: req.target_port,
|
||||
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(
|
||||
std::time::Duration::from_millis(100),
|
||||
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)),
|
||||
tokio::net::TcpStream::connect(addr),
|
||||
)
|
||||
.await
|
||||
.map(|r| r.is_ok())
|
||||
|
||||
Reference in New Issue
Block a user