Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions lib/src/tunnels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions ui/assets/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -1789,6 +1793,12 @@
inherits: false;
initial-value: solid;
}
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
Expand Down
21 changes: 20 additions & 1 deletion ui/src/state.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +15,7 @@ pub struct AppState {
heartbeat: HeartbeatAgent,
tunnel_refresh: std::sync::Arc<Notify>,
tunnel_cache: dioxus::signals::Signal<Vec<TunnelSummary>>,
tunnel_health: dioxus::signals::Signal<HashMap<String, bool>>,
}

impl AppState {
Expand All @@ -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)
}
Expand Down Expand Up @@ -91,6 +95,21 @@ impl AppState {
cache.set(list);
}

pub fn tunnel_health(&self, tunnel_id: &str) -> Option<bool> {
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<HashMap<String, bool>> {
self.tunnel_health
}

pub fn selected_context(&self) -> Option<SelectedContext> {
self.datum.selected_context()
}
Expand Down
79 changes: 71 additions & 8 deletions ui/src/views/proxies_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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",
Expand Down