Add LAN service discovery via UDP multicast #7
@@ -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} → proxied</div>
|
<div class="service-port">localhost:${e.target_port} → proxied</div>
|
||||||
</div>
|
</div>
|
||||||
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">×</button>`}
|
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">×</button>`}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addService(event) {
|
async function addService(event) {
|
||||||
|
|||||||
54
src/api.rs
54
src/api.rs
@@ -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(
|
||||||
url: format!("http://{}.{}", name, tld),
|
|((name, port), (healthy, lan_accessible))| ServiceResponse {
|
||||||
name,
|
url: format!("http://{}.{}", name, tld),
|
||||||
target_port: port,
|
name,
|
||||||
healthy,
|
target_port: port,
|
||||||
})
|
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())
|
||||||
|
|||||||
Reference in New Issue
Block a user