diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 0593000..dc1ff9c 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -17,7 +17,7 @@ pub use node::*; pub use project_control_plane::ProjectControlPlaneClient; pub use repo::Repo; pub use state::*; -pub use tunnels::{TunnelDeleteOutcome, TunnelService, TunnelSummary}; +pub use tunnels::{TunnelDeleteOutcome, TunnelService, TunnelSummary, check_tunnel_health}; pub use update::{UpdateChecker, UpdateInfo, UpdateSettings}; /// The root domain for datum connect urls to subdomain from. A proxy URL will diff --git a/lib/src/tunnels.rs b/lib/src/tunnels.rs index 6a01fb2..4b6d4d2 100644 --- a/lib/src/tunnels.rs +++ b/lib/src/tunnels.rs @@ -58,6 +58,22 @@ pub struct TunnelSummary { pub programmed: bool, } +/// Probe a tunnel's public hostname with a lightweight HEAD request. +/// Returns `true` if the endpoint responds (any HTTP status), `false` on +/// connection or timeout failure. +pub async fn check_tunnel_health(hostname: &str) -> bool { + let url = format!("https://{hostname}"); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(4)) + .danger_accept_invalid_certs(true) + .redirect(reqwest::redirect::Policy::none()) + .build(); + let Ok(client) = client else { + return false; + }; + client.head(&url).send().await.is_ok() +} + #[derive(Debug, Clone)] pub struct TunnelDeleteOutcome { pub project_id: String, diff --git a/ui/assets/tailwind.css b/ui/assets/tailwind.css index 8c95358..492f27f 100644 --- a/ui/assets/tailwind.css +++ b/ui/assets/tailwind.css @@ -34,6 +34,7 @@ --radius-2xl: 1rem; --animate-spin: spin 1s linear infinite; --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: "Alliance No1", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", @@ -576,6 +577,9 @@ .animate-in { animation: enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none); } + .animate-ping { + animation: var(--animate-ping); + } .animate-pulse { animation: var(--animate-pulse); } @@ -1789,6 +1793,12 @@ inherits: false; initial-value: solid; } +@keyframes ping { + 75%, 100% { + transform: scale(2); + opacity: 0; + } +} @keyframes spin { to { transform: rotate(360deg); diff --git a/ui/src/state.rs b/ui/src/state.rs index b0da2d8..5d963ac 100644 --- a/ui/src/state.rs +++ b/ui/src/state.rs @@ -1,4 +1,6 @@ -use dioxus::prelude::WritableExt; +use std::collections::HashMap; + +use dioxus::prelude::{Readable, ReadableExt, WritableExt}; use lib::{ datum_cloud::{ApiEnv, DatumCloudClient}, HeartbeatAgent, ListenNode, Node, Repo, SelectedContext, TunnelService, TunnelSummary, @@ -13,6 +15,7 @@ pub struct AppState { heartbeat: HeartbeatAgent, tunnel_refresh: std::sync::Arc, tunnel_cache: dioxus::signals::Signal>, + tunnel_health: dioxus::signals::Signal>, } impl AppState { @@ -32,6 +35,7 @@ impl AppState { heartbeat, tunnel_refresh: std::sync::Arc::new(Notify::new()), tunnel_cache: dioxus::signals::Signal::new(Vec::new()), + tunnel_health: dioxus::signals::Signal::new(HashMap::new()), }; Ok(app_state) } @@ -91,6 +95,21 @@ impl AppState { cache.set(list); } + pub fn tunnel_health(&self, tunnel_id: &str) -> Option { + self.tunnel_health.read().get(tunnel_id).copied() + } + + pub fn set_tunnel_health(&self, tunnel_id: &str, healthy: bool) { + let mut cache = self.tunnel_health; + let mut map = cache(); + map.insert(tunnel_id.to_string(), healthy); + cache.set(map); + } + + pub fn tunnel_health_signal(&self) -> dioxus::signals::Signal> { + self.tunnel_health + } + pub fn selected_context(&self) -> Option { self.datum.selected_context() } diff --git a/ui/src/views/proxies_list.rs b/ui/src/views/proxies_list.rs index 4b782f7..b068e49 100644 --- a/ui/src/views/proxies_list.rs +++ b/ui/src/views/proxies_list.rs @@ -321,11 +321,13 @@ pub fn TunnelCard( // Read the tunnel from cache using the ID - this ensures we always have fresh data // when the cache is updated (e.g., after edit or hostname provisioning) let tunnel_cache = state.tunnel_cache(); + let health_map = state.tunnel_health_signal(); let tunnel = tunnel_cache() .into_iter() .find(|t| t.id == tunnel_id) .unwrap_or(tunnel); + let health_state = state.clone(); let tunnel_id_for_toggle = tunnel_id.clone(); let mut toggle_action = use_action(move |next_enabled: bool| { let state = state.clone(); @@ -358,6 +360,33 @@ pub fn TunnelCard( .cloned() .or_else(|| tunnel.hostnames.first().cloned()); let public_hostname_click = public_hostname.clone(); + + let health_tunnel_id = tunnel_id.clone(); + let health_hostname = public_hostname.clone(); + use_future(move || { + let hostname = health_hostname.clone(); + let tid = health_tunnel_id.clone(); + let st = health_state.clone(); + async move { + let Some(h) = hostname else { return }; + let mut consecutive_failures: u8 = 0; + loop { + let probe_ok = lib::check_tunnel_health(&h).await; + if probe_ok { + consecutive_failures = 0; + st.set_tunnel_health(&tid, true); + } else { + consecutive_failures = consecutive_failures.saturating_add(1); + if consecutive_failures >= 2 { + st.set_tunnel_health(&tid, false); + } + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + } + }); + let endpoint_healthy = health_map().get(&tunnel_id).copied(); + let short_id = public_hostname .as_ref() .and_then(|h| h.split('.').next()) @@ -461,15 +490,49 @@ pub fn TunnelCard( size: 14, } if is_ready { - a { - class: "text-xs text-foreground hover:underline cursor-pointer", - onclick: move |_| { - if let Some(h) = public_hostname_click.as_ref() { - let url = format!("https://{}", h); - let _ = that(&url); + div { class: "flex items-center gap-1.5", + a { + class: "text-xs text-foreground hover:underline cursor-pointer", + onclick: move |_| { + if let Some(h) = public_hostname_click.as_ref() { + let url = format!("https://{}", h); + let _ = that(&url); + } + }, + {format!("datum://{}", id)} + } + if endpoint_healthy == Some(true) { + span { + class: "relative flex", + style: "width: 8px; height: 8px", + span { + class: "animate-ping absolute inline-flex rounded-full", + style: "width: 8px; height: 8px; background-color: #34d399; opacity: 0.75", + } + span { + class: "relative inline-flex rounded-full", + style: "width: 8px; height: 8px; background-color: #10b981", + } } - }, - {format!("datum://{}", id)} + } else if endpoint_healthy == Some(false) { + span { + class: "relative flex", + style: "width: 8px; height: 8px", + span { + class: "relative inline-flex rounded-full", + style: "width: 8px; height: 8px; background-color: #f87171", + } + } + } else { + span { + class: "relative flex", + style: "width: 8px; height: 8px", + span { + class: "animate-pulse relative inline-flex rounded-full", + style: "width: 8px; height: 8px; background-color: #a1a1aa", + } + } + } } } else { span { class: "text-xs text-foreground/80",