use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::Duration;
use axum::Json;
use reqwest::Url;
use serde_json::{Value, json};
pub(crate) const ARCHITECT_DASHBOARD_APP: &str = "trusty-architect";
pub(crate) const PROBE_TIMEOUT: Duration = Duration::from_millis(300);
const V4: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
const V6: IpAddr = IpAddr::V6(Ipv6Addr::LOCALHOST);
pub async fn dashboard_link_handler() -> Json<Value> {
let recorded = tokio::task::spawn_blocking(|| {
trusty_common::resolve_daemon_base_url(ARCHITECT_DASHBOARD_APP)
})
.await
.inspect_err(|e| tracing::error!(error = %e, "architect dashboard discovery task failed"))
.ok()
.flatten();
Json(json!({ "url": live_dashboard_url(recorded.as_deref()).await }))
}
pub(crate) async fn live_dashboard_url(recorded: Option<&str>) -> Option<String> {
let recorded = recorded?;
let Some((url, addrs)) = dashboard_target(recorded) else {
tracing::warn!(
recorded,
"architect dashboard address is not a loopback http URL; no link shown"
);
return None;
};
let probe = async {
for addr in addrs {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return true;
}
}
false
};
let live = matches!(tokio::time::timeout(PROBE_TIMEOUT, probe).await, Ok(true));
if !live {
tracing::debug!(%url, "architect dashboard recorded but not listening");
}
live.then(|| url.to_string())
}
pub(crate) fn dashboard_target(recorded: &str) -> Option<(Url, Vec<SocketAddr>)> {
let recorded = recorded.trim();
let url = if recorded.contains("://") {
Url::parse(recorded)
} else {
Url::parse(&format!("http://{recorded}"))
}
.ok()?;
if url.scheme() != "http" || !url.username().is_empty() || url.password().is_some() {
return None;
}
let ips: &[IpAddr] = match url.host_str()? {
"127.0.0.1" => &[V4],
"[::1]" => &[V6],
"localhost" => &[V4, V6],
_ => return None,
};
let port = url.port_or_known_default()?;
let addrs = ips.iter().map(|ip| SocketAddr::new(*ip, port)).collect();
Some((url, addrs))
}
#[cfg(test)]
#[path = "architect_tests.rs"]
mod tests;