diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 7474e9f..9431f71 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -9,6 +9,7 @@ mod node; pub mod project_control_plane; mod repo; mod state; +pub mod tunnel_activity; pub mod tunnels; pub mod update; @@ -20,6 +21,7 @@ pub use node::*; pub use project_control_plane::ProjectControlPlaneClient; pub use repo::Repo; pub use state::*; +pub use tunnel_activity::{TunnelActivityEntry, TunnelActivityTracker}; pub use tunnels::{TunnelDeleteOutcome, TunnelService, TunnelSummary}; pub use update::{UpdateChannel, UpdateChecker, UpdateInfo, UpdateSettings}; diff --git a/lib/src/tunnel_activity.rs b/lib/src/tunnel_activity.rs new file mode 100644 index 0000000..66569dd --- /dev/null +++ b/lib/src/tunnel_activity.rs @@ -0,0 +1,224 @@ +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, Instant}; + +use iroh_proxy_utils::upstream::UpstreamMetrics; + +use crate::TunnelSummary; + +const HOUR: Duration = Duration::from_secs(3600); + +/// Snapshot of a tunnel's recent traffic for tray menu display. +#[derive(Debug, Clone)] +pub struct TunnelActivityEntry { + pub tunnel_id: String, + pub label: String, + pub online: bool, + pub bytes_last_hour: u64, + pub rate_per_s: u64, + pub last_activity_at: Instant, +} + +#[derive(Debug)] +struct TunnelSampleState { + label: String, + online: bool, + last_send: u64, + last_recv: u64, + last_sample_at: Instant, + last_activity_at: Option, + hourly_deltas: VecDeque<(Instant, u64)>, + rate_per_s: u64, +} + +#[derive(Debug, Default)] +pub struct TunnelActivityTracker { + tunnels: HashMap, +} + +impl TunnelActivityTracker { + pub fn new() -> Self { + Self::default() + } + + /// Sample cumulative proxy metrics against the current tunnel list. + /// + /// Metrics are keyed by local endpoint authority; two tunnels targeting the same + /// host:port share counters. + pub fn tick(&mut self, tunnels: &[TunnelSummary], metrics: &UpstreamMetrics) { + let now = Instant::now(); + let active_ids: std::collections::HashSet<&str> = + tunnels.iter().map(|t| t.id.as_str()).collect(); + self.tunnels + .retain(|id, _| active_ids.contains(id.as_str())); + + for tunnel in tunnels { + let Some(_authority) = tunnel.origin_authority() else { + continue; + }; + let (send, recv) = metrics_bytes_for_tunnel(metrics, tunnel); + + let entry = + self.tunnels + .entry(tunnel.id.clone()) + .or_insert_with(|| TunnelSampleState { + label: tunnel.label.clone(), + online: tunnel_is_online(tunnel), + last_send: send, + last_recv: recv, + last_sample_at: now, + last_activity_at: None, + hourly_deltas: VecDeque::new(), + rate_per_s: 0, + }); + + entry.label = tunnel.label.clone(); + entry.online = tunnel_is_online(tunnel); + + if send < entry.last_send || recv < entry.last_recv { + entry.last_send = send; + entry.last_recv = recv; + entry.last_sample_at = now; + entry.rate_per_s = 0; + continue; + } + + let delta_send = send.saturating_sub(entry.last_send); + let delta_recv = recv.saturating_sub(entry.last_recv); + let delta_total = delta_send + delta_recv; + + if delta_total > 0 { + entry.hourly_deltas.push_back((now, delta_total)); + entry.last_activity_at = Some(now); + } + + while let Some(front) = entry.hourly_deltas.front() { + if now.duration_since(front.0) > HOUR { + entry.hourly_deltas.pop_front(); + } else { + break; + } + } + + let dt = now + .duration_since(entry.last_sample_at) + .as_secs_f64() + .max(0.001); + entry.rate_per_s = if delta_total > 0 { + (delta_total as f64 / dt) as u64 + } else { + 0 + }; + + entry.last_send = send; + entry.last_recv = recv; + entry.last_sample_at = now; + } + } + + /// Active tunnels for the tray menu, most recently used first (up to `limit`). + /// Includes tunnels with no recent traffic. + pub fn recent_active(&self, limit: usize) -> Vec { + let mut entries: Vec = self + .tunnels + .iter() + .map(|(id, state)| { + let bytes_last_hour: u64 = state.hourly_deltas.iter().map(|(_, b)| *b).sum(); + TunnelActivityEntry { + tunnel_id: id.clone(), + label: state.label.clone(), + online: state.online, + bytes_last_hour, + rate_per_s: state.rate_per_s, + last_activity_at: state.last_activity_at.unwrap_or(state.last_sample_at), + } + }) + .collect(); + entries.sort_by(|a, b| { + let a_active = a.bytes_last_hour > 0 || a.rate_per_s > 0; + let b_active = b.bytes_last_hour > 0 || b.rate_per_s > 0; + match (a_active, b_active) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => b.last_activity_at.cmp(&a.last_activity_at), + } + }); + entries.truncate(limit); + entries + } +} + +fn metrics_bytes_for_tunnel(metrics: &UpstreamMetrics, tunnel: &TunnelSummary) -> (u64, u64) { + let Some(authority) = tunnel.origin_authority() else { + return (0, 0); + }; + for auth in authority_lookup_variants(&authority) { + if let Some(m) = metrics.get(&auth) { + return (m.bytes_from_origin(), m.bytes_to_origin()); + } + } + (0, 0) +} + +fn tunnel_is_online(tunnel: &TunnelSummary) -> bool { + tunnel.enabled && tunnel.accepted && tunnel.programmed +} + +fn authority_lookup_variants( + authority: &iroh_proxy_utils::Authority, +) -> Vec { + use iroh_proxy_utils::Authority; + let mut variants = vec![authority.clone()]; + if authority.host == "localhost" { + variants.push(Authority { + host: "127.0.0.1".to_string(), + port: authority.port, + }); + } else if authority.host == "127.0.0.1" { + variants.push(Authority { + host: "localhost".to_string(), + port: authority.port, + }); + } + variants +} + +#[cfg(test)] +mod tests { + use super::*; + use iroh_proxy_utils::Authority; + use std::str::FromStr; + + fn tunnel(id: &str, label: &str, endpoint: &str) -> TunnelSummary { + TunnelSummary { + id: id.to_string(), + label: label.to_string(), + endpoint: endpoint.to_string(), + hostnames: vec![], + enabled: true, + accepted: true, + programmed: true, + } + } + + #[test] + fn recent_active_includes_idle_tunnels() { + let mut tracker = TunnelActivityTracker::new(); + let tunnels = vec![tunnel("t1", "app", "http://127.0.0.1:8080")]; + let metrics = UpstreamMetrics::default(); + tracker.tick(&tunnels, &metrics); + let recent = tracker.recent_active(5); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].tunnel_id, "t1"); + assert_eq!(recent[0].label, "app"); + assert!(recent[0].online); + assert_eq!(recent[0].rate_per_s, 0); + } + + #[test] + fn origin_authority_parses_endpoint() { + let t = tunnel("t1", "app", "http://127.0.0.1:8080"); + assert!(t.origin_authority().is_some()); + let auth = t.origin_authority().unwrap(); + assert_eq!(Authority::from_str("127.0.0.1:8080").ok(), Some(auth)); + } +} diff --git a/lib/src/tunnels.rs b/lib/src/tunnels.rs index 4d18aa8..2728832 100644 --- a/lib/src/tunnels.rs +++ b/lib/src/tunnels.rs @@ -455,7 +455,7 @@ impl TunnelService { debug!( project_id = %project_id_owned, proxy = %proxy_name, - "skipping TrafficProtectionPolicy creation (env disabled)" + "skipping TrafficProtectionPolicy creation (disabled via env)" ); } @@ -1213,12 +1213,14 @@ fn publish_tickets_enabled() -> bool { } fn create_traffic_protection_policies_enabled() -> bool { - std::env::var("DATUM_CONNECT_CREATE_TRAFFIC_PROTECTION_POLICIES") + let raw = std::env::var("DATUM_CONNECT_CREATE_TRAFFIC_PROTECTION_POLICIES") .ok() .or_else(|| { option_env!("BUILD_DATUM_CONNECT_CREATE_TRAFFIC_PROTECTION_POLICIES") .map(str::to_string) - }) - .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) + }); + match raw { + Some(value) if matches!(value.as_str(), "0" | "false" | "FALSE" | "no" | "NO") => false, + _ => true, + } } diff --git a/scripts/traffic-test-server.py b/scripts/traffic-test-server.py new file mode 100755 index 0000000..872b14f --- /dev/null +++ b/scripts/traffic-test-server.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Minimal HTTP server for exercising Datum tunnel traffic metrics.""" + +from http.server import BaseHTTPRequestHandler, HTTPServer + +HOST = "127.0.0.1" +PORT = 3001 +PAYLOAD_KB = 64 + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path in ("/", "/index.html"): + body = f""" + + Datum traffic test + +

Traffic test server

+

Listening on http://{HOST}:{PORT}

+
    +
  • /ping — small response
  • +
  • /data — {PAYLOAD_KB} KB payload
  • +
+ +

+    
+  
+
+""".encode()
+            self._respond(200, "text/html; charset=utf-8", body)
+            return
+
+        if self.path == "/ping":
+            self._respond(200, "text/plain; charset=utf-8", b"ok\n")
+            return
+
+        if self.path == "/data":
+            body = b"x" * (PAYLOAD_KB * 1024)
+            self._respond(200, "application/octet-stream", body)
+            return
+
+        self._respond(404, "text/plain; charset=utf-8", b"not found\n")
+
+    def _respond(self, status: int, content_type: str, body: bytes) -> None:
+        self.send_response(status)
+        self.send_header("Content-Type", content_type)
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+    def log_message(self, format: str, *args) -> None:
+        print(f"[{self.log_date_time_string()}] {format % args}")
+
+
+def main() -> None:
+    server = HTTPServer((HOST, PORT), Handler)
+    print(f"Traffic test server running at http://{HOST}:{PORT}")
+    print("  /ping  — small response")
+    print(f"  /data  — {PAYLOAD_KB} KB download")
+    server.serve_forever()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/ui/assets/tailwind.css b/ui/assets/tailwind.css
index 05f175f..00ae09d 100644
--- a/ui/assets/tailwind.css
+++ b/ui/assets/tailwind.css
@@ -1390,6 +1390,11 @@
       outline-style: none;
     }
   }
+  .disabled\:cursor-not-allowed {
+    &:disabled {
+      cursor: not-allowed;
+    }
+  }
   .disabled\:bg-content-background {
     &:disabled {
       background-color: var(--content-background);
@@ -1635,7 +1640,14 @@
     font-family: "Alliance No1", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
     "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
   }
+  button:not(:disabled) {
+    cursor: pointer;
+  }
+  button:disabled {
+    cursor: not-allowed;
+  }
   a {
+    cursor: pointer;
     &:focus {
       outline-style: var(--tw-outline-style);
       outline-width: 1px;
diff --git a/ui/assets/tray/status-offline.png b/ui/assets/tray/status-offline.png
new file mode 100644
index 0000000..45cb50a
Binary files /dev/null and b/ui/assets/tray/status-offline.png differ
diff --git a/ui/assets/tray/status-online.png b/ui/assets/tray/status-online.png
new file mode 100644
index 0000000..49e3d28
Binary files /dev/null and b/ui/assets/tray/status-online.png differ
diff --git a/ui/assets/tray/tray-icon-template.png b/ui/assets/tray/tray-icon-template.png
new file mode 100644
index 0000000..223a517
Binary files /dev/null and b/ui/assets/tray/tray-icon-template.png differ
diff --git a/ui/src/components/button.rs b/ui/src/components/button.rs
index 42556f7..d8cca38 100644
--- a/ui/src/components/button.rs
+++ b/ui/src/components/button.rs
@@ -37,10 +37,10 @@ pub struct ButtonProps {
 fn class_for(kind: ButtonKind) -> &'static str {
     // [transform:translateZ(0)] keeps the button on its own compositing layer so opacity hover doesn't cause subpixel shift
     match kind {
-        ButtonKind::Primary => "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2.5 bg-button-primary-background/90 text-button-primary-foreground hover:opacity-80 transition-opacity duration-300 border border-1 border-button-primary-background [transform:translateZ(0)] text-xs focus:outline-2 focus:outline-button-primary-background/50",
-        ButtonKind::Secondary => "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2.5 bg-button-secondary-background/90 text-button-secondary-foreground border border-1 border-button-secondary-background hover:opacity-80 transition-opacity duration-300 text-xs [transform:translateZ(0)] focus:outline-2 focus:outline-button-secondary-background/50",
-        ButtonKind::Ghost => "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2.5 bg-transparent text-button-outline-foreground border border-1 border-button-outline-background hover:opacity-80 transition-opacity duration-300 [transform:translateZ(0)] text-xs focus:outline-2 focus:outline-button-outline-background/50",
-        ButtonKind::Outline => "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2.5 bg-transparent text-foreground border border-1 border-foreground hover:opacity-80 transition-opacity duration-300 [transform:translateZ(0)] text-xs focus:outline-2 focus:outline-foreground/50",
+        ButtonKind::Primary => "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2.5 bg-button-primary-background/90 text-button-primary-foreground hover:opacity-80 transition-opacity duration-300 border border-1 border-button-primary-background [transform:translateZ(0)] text-xs focus:outline-2 focus:outline-button-primary-background/50 cursor-pointer disabled:cursor-not-allowed",
+        ButtonKind::Secondary => "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2.5 bg-button-secondary-background/90 text-button-secondary-foreground border border-1 border-button-secondary-background hover:opacity-80 transition-opacity duration-300 text-xs [transform:translateZ(0)] focus:outline-2 focus:outline-button-secondary-background/50 cursor-pointer disabled:cursor-not-allowed",
+        ButtonKind::Ghost => "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2.5 bg-transparent text-button-outline-foreground border border-1 border-button-outline-background hover:opacity-80 transition-opacity duration-300 [transform:translateZ(0)] text-xs focus:outline-2 focus:outline-button-outline-background/50 cursor-pointer disabled:cursor-not-allowed",
+        ButtonKind::Outline => "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2.5 bg-transparent text-foreground border border-1 border-foreground hover:opacity-80 transition-opacity duration-300 [transform:translateZ(0)] text-xs focus:outline-2 focus:outline-foreground/50 cursor-pointer disabled:cursor-not-allowed",
     }
 }
 
diff --git a/ui/src/components/dropdown_menu/component.rs b/ui/src/components/dropdown_menu/component.rs
index 36bc80e..2a22330 100644
--- a/ui/src/components/dropdown_menu/component.rs
+++ b/ui/src/components/dropdown_menu/component.rs
@@ -62,8 +62,8 @@ pub fn DropdownMenuSeparator() -> Element {
     }
 }
 
-const ITEM_CLASS: &str = "w-full text-left px-2 py-2 text-xs hover:bg-content-background text-foreground rounded-md cursor-default";
-const ITEM_DESTRUCTIVE_CLASS: &str = "w-full text-left px-2 py-2 text-xs hover:bg-red-50/20 text-alert-red-dark rounded-md cursor-default";
+const ITEM_CLASS: &str = "w-full text-left px-2 py-2 text-xs hover:bg-content-background text-foreground rounded-md cursor-pointer";
+const ITEM_DESTRUCTIVE_CLASS: &str = "w-full text-left px-2 py-2 text-xs hover:bg-red-50/20 text-alert-red-dark rounded-md cursor-pointer";
 
 /// Props for our DropdownMenuItem wrapper (adds `destructive` and optional `icon` over the primitive).
 #[derive(Props, Clone, PartialEq)]
@@ -95,7 +95,7 @@ pub fn DropdownMenuItem(
         ITEM_CLASS
     };
     let item_class = if (props.disabled)() {
-        format!("{base_class} opacity-50")
+        format!("{base_class} opacity-50 cursor-not-allowed")
     } else {
         base_class.to_string()
     };
diff --git a/ui/src/components/select/component.rs b/ui/src/components/select/component.rs
index b20e967..88946ef 100644
--- a/ui/src/components/select/component.rs
+++ b/ui/src/components/select/component.rs
@@ -61,8 +61,8 @@ impl Clone for SelectTriggerPropsWithSize {
 #[component]
 pub fn SelectTrigger(props: SelectTriggerPropsWithSize) -> Element {
     let class = match props.size {
-        SelectSize::Default => "w-full h-9 min-w-0 rounded-md border border-app-border bg-card-background px-2 text-left text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-app-border inline-flex items-center justify-between gap-2 cursor-default data-disabled:opacity-50 data-disabled:cursor-not-allowed",
-        SelectSize::Small => "w-full h-6 min-w-0 rounded-md border border-app-border bg-card-background px-2 text-left text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-app-border inline-flex items-center justify-between gap-2 cursor-default data-disabled:opacity-50 data-disabled:cursor-not-allowed",
+        SelectSize::Default => "w-full h-9 min-w-0 rounded-md border border-app-border bg-card-background px-2 text-left text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-app-border inline-flex items-center justify-between gap-2 cursor-pointer data-disabled:opacity-50 data-disabled:cursor-not-allowed",
+        SelectSize::Small => "w-full h-6 min-w-0 rounded-md border border-app-border bg-card-background px-2 text-left text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-app-border inline-flex items-center justify-between gap-2 cursor-pointer data-disabled:opacity-50 data-disabled:cursor-not-allowed",
     };
 
     rsx! {
@@ -140,7 +140,7 @@ pub fn SelectGroupLabel(props: SelectGroupLabelProps) -> Element {
 pub fn SelectOption(props: SelectOptionProps) -> Element {
     rsx! {
         select::SelectOption:: {
-            class: "w-full text-left px-2 py-2 text-xs hover:bg-content-background text-foreground rounded-md cursor-default data-highlighted:bg-content-background flex items-center justify-between gap-2 whitespace-nowrap",
+            class: "w-full text-left px-2 py-2 text-xs hover:bg-content-background text-foreground rounded-md cursor-pointer data-highlighted:bg-content-background flex items-center justify-between gap-2 whitespace-nowrap data-disabled:cursor-not-allowed data-disabled:opacity-50",
             value: props.value,
             text_value: props.text_value,
             disabled: props.disabled,
@@ -172,7 +172,7 @@ pub fn SelectOptionItem(
     use_effect(move || {
         value_signal.set(value.clone());
     });
-    let base_class = "w-full text-left px-2 py-2 text-xs hover:bg-content-background text-foreground rounded-md cursor-default data-highlighted:bg-content-background flex items-center justify-between gap-2 whitespace-nowrap";
+    let base_class = "w-full text-left px-2 py-2 text-xs hover:bg-content-background text-foreground rounded-md cursor-pointer data-highlighted:bg-content-background flex items-center justify-between gap-2 whitespace-nowrap data-disabled:cursor-not-allowed data-disabled:opacity-50";
     let class = match &option_class {
         Some(extra) => format!("{base_class} {extra}"),
         None => base_class.to_string(),
diff --git a/ui/src/components/switch/component.rs b/ui/src/components/switch/component.rs
index 9783c71..c44a009 100644
--- a/ui/src/components/switch/component.rs
+++ b/ui/src/components/switch/component.rs
@@ -5,7 +5,7 @@ use dioxus_primitives::switch::{self, SwitchProps, SwitchThumbProps};
 pub fn Switch(props: SwitchProps) -> Element {
     rsx! {
         switch::Switch {
-            class: "group relative w-10 h-5.5 rounded-full bg-switch-disabled
+            class: "group relative w-10 h-5.5 rounded-full bg-switch-disabled cursor-pointer
                     transition-colors duration-150 data-[state=checked]:bg-switch-checked 
                     data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 px-[1.70px] focus:outline-2 focus:outline-app-border",
             checked: props.checked,
diff --git a/ui/src/main.rs b/ui/src/main.rs
index 69e7e7d..a7759b7 100644
--- a/ui/src/main.rs
+++ b/ui/src/main.rs
@@ -13,7 +13,7 @@ use crate::views::{
 #[cfg(feature = "desktop")]
 use dioxus_desktop::{
     trayicon::{
-        menu::{IconMenuItem, Menu, MenuItem, NativeIcon, PredefinedMenuItem},
+        menu::{Icon as MenuIcon, IconMenuItem, Menu, MenuItem, NativeIcon, PredefinedMenuItem},
         Icon, TrayIcon, TrayIconBuilder,
     },
     use_muda_event_handler, use_window,
@@ -507,13 +507,15 @@ fn App() -> Element {
     }
 
     let mut open_add_tunnel_from_tray = use_signal(|| false);
+    let mut open_tunnel_from_tray = use_signal(|| None::);
     let mut login_state_for_tray = use_signal(|| None::);
+    let mut tray_menu_version = use_signal(|| 0u32);
+    #[cfg(feature = "desktop")]
+    let mut tray_holder = use_signal(|| None::>);
 
     // Create tray when app state is ready. Must run before early return.
     #[cfg(feature = "desktop")]
     {
-        let mut tray_holder = use_signal(|| None::);
-
         use_effect(move || {
             if !app_state_ready() {
                 return;
@@ -526,8 +528,37 @@ fn App() -> Element {
         });
 
         let window = use_window();
+        let tray_for_handler = tray_holder;
         use_muda_event_handler(move |event| -> () {
-            let _: () = match event.id.0.as_str() {
+            let id = event.id.0.as_str();
+            if let Some(idx) = id
+                .strip_prefix("recent_tunnel:")
+                .and_then(|s| s.parse::().ok())
+            {
+                if login_state_for_tray() == Some(lib::datum_cloud::LoginState::Missing)
+                    || login_state_for_tray().is_none()
+                {
+                    return;
+                }
+                let Some(tray) = tray_for_handler() else {
+                    return;
+                };
+                let tunnel_id = tray
+                    .slot_tunnel_ids
+                    .lock()
+                    .ok()
+                    .and_then(|slots| slots.get(idx).cloned())
+                    .flatten();
+                let Some(tunnel_id) = tunnel_id else {
+                    return;
+                };
+                window.set_visible(true);
+                window.set_focus();
+                open_tunnel_from_tray.set(Some(tunnel_id));
+                return;
+            }
+
+            let _: () = match id {
                 "about" => {
                     let _ = open::that("https://datum.net");
                 }
@@ -679,6 +710,56 @@ fn App() -> Element {
 
     // Tray can trigger opening the add tunnel dialog; Chrome (inside Router) handles navigation + dialog.
     provide_context(open_add_tunnel_from_tray);
+    provide_context(open_tunnel_from_tray);
+
+    // Sample tunnel traffic and refresh tray recent-tunnels section.
+    #[cfg(feature = "desktop")]
+    {
+        let state_for_tray_activity = consume_context::();
+        let tray_holder_for_sync = tray_holder;
+        let mut tray_menu_version_for_sync = tray_menu_version;
+        use_future(move || {
+            let state = state_for_tray_activity.clone();
+            async move {
+                let refresh = state.tunnel_refresh();
+                loop {
+                    let tunnels = match state.tunnel_service().list_active().await {
+                        Ok(list) => list,
+                        Err(err) => {
+                            tracing::debug!("tray: failed to list tunnels for activity: {err:#}");
+                            state.tunnel_cache()()
+                        }
+                    };
+                    let metrics = state.node().listen.metrics().clone();
+                    if let Ok(mut tracker) = state.tunnel_activity().lock() {
+                        tracker.tick(&tunnels, &metrics);
+                    }
+                    tray_menu_version_for_sync.set(tray_menu_version_for_sync().wrapping_add(1));
+
+                    tokio::select! {
+                        _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {}
+                        _ = refresh.notified() => {}
+                    }
+                }
+            }
+        });
+
+        use_effect(move || {
+            let _ = tray_menu_version();
+            let _ = auth_changed();
+            let Some(tray) = tray_holder_for_sync() else {
+                return;
+            };
+            let state = consume_context::();
+            let logged_in = state.datum().login_state() != lib::datum_cloud::LoginState::Missing;
+            let entries = state
+                .tunnel_activity()
+                .lock()
+                .map(|tracker| tracker.recent_active(5))
+                .unwrap_or_default();
+            sync_tray_recent_menu(&tray, &entries, logged_in);
+        });
+    }
 
     // Signal for whether we're on the login page (used by TitleBar for background color).
     // Start true since we always land on login after splash; Login's use_effect/use_drop keep it in sync.
@@ -801,7 +882,119 @@ fn DiagnosticsPrompt(
 }
 
 #[cfg(feature = "desktop")]
-fn init_tray() -> TrayIcon {
+struct TrayState {
+    _tray: TrayIcon,
+    menu: Menu,
+    recent_sep: PredefinedMenuItem,
+    recent_sep_bottom: PredefinedMenuItem,
+    recent_header: MenuItem,
+    recent_items: [IconMenuItem; 5],
+    icon_online: MenuIcon,
+    icon_offline: MenuIcon,
+    slot_tunnel_ids: std::sync::Mutex<[Option; 5]>,
+    /// How many tunnel rows are currently inserted in the menu (0–5).
+    mounted_tunnel_slots: std::sync::Mutex,
+    recent_section_open: std::sync::Mutex,
+}
+
+#[cfg(feature = "desktop")]
+fn format_tray_tunnel_label(entry: &lib::TunnelActivityEntry) -> String {
+    let label = truncate_tray_label(&entry.label, 32);
+    if entry.rate_per_s > 0 {
+        let rate = util::humanize_bytes(entry.rate_per_s);
+        format!("{label} — {rate}/s")
+    } else {
+        format!("{label} — No traffic")
+    }
+}
+
+#[cfg(feature = "desktop")]
+fn sync_tray_recent_menu(tray: &TrayState, entries: &[lib::TunnelActivityEntry], logged_in: bool) {
+    let count = if logged_in { entries.len().min(5) } else { 0 };
+
+    let mut mounted = tray
+        .mounted_tunnel_slots
+        .lock()
+        .unwrap_or_else(|e| e.into_inner());
+    let mut slots = tray
+        .slot_tunnel_ids
+        .lock()
+        .unwrap_or_else(|e| e.into_inner());
+    let mut section_open = tray
+        .recent_section_open
+        .lock()
+        .unwrap_or_else(|e| e.into_inner());
+
+    if count == 0 {
+        if *section_open {
+            let _ = tray.menu.remove(&tray.recent_sep_bottom);
+            for i in (0..*mounted).rev() {
+                let _ = tray.menu.remove(&tray.recent_items[i]);
+                slots[i] = None;
+            }
+            let _ = tray.menu.remove(&tray.recent_header);
+            let _ = tray.menu.remove(&tray.recent_sep);
+            *mounted = 0;
+            *section_open = false;
+        }
+        return;
+    }
+
+    if !*section_open {
+        let _ = tray.menu.insert(&tray.recent_sep, 1);
+        tray.recent_header.set_text("Recent tunnels");
+        let _ = tray.menu.insert(&tray.recent_header, 2);
+        *section_open = true;
+    } else {
+        let _ = tray.menu.remove(&tray.recent_sep_bottom);
+    }
+
+    while *mounted > count {
+        let i = *mounted - 1;
+        let _ = tray.menu.remove(&tray.recent_items[i]);
+        slots[i] = None;
+        *mounted -= 1;
+    }
+
+    for i in 0..count {
+        let entry = &entries[i];
+        let text = format_tray_tunnel_label(entry);
+        let icon = if entry.online {
+            tray.icon_online.clone()
+        } else {
+            tray.icon_offline.clone()
+        };
+        tray.recent_items[i].set_text(text);
+        tray.recent_items[i].set_icon(Some(icon));
+        tray.recent_items[i].set_enabled(true);
+        slots[i] = Some(entry.tunnel_id.clone());
+        if i >= *mounted {
+            let _ = tray.menu.insert(&tray.recent_items[i], 3 + i);
+        }
+    }
+
+    for i in count..5 {
+        slots[i] = None;
+    }
+
+    *mounted = count;
+    let _ = tray.menu.insert(&tray.recent_sep_bottom, 3 + count);
+}
+
+#[cfg(feature = "desktop")]
+fn truncate_tray_label(label: &str, max_chars: usize) -> String {
+    if label.chars().count() <= max_chars {
+        return label.to_string();
+    }
+    label
+        .chars()
+        .take(max_chars.saturating_sub(1))
+        .collect::()
+        + "…"
+}
+
+#[cfg(feature = "desktop")]
+fn init_tray() -> std::sync::Arc {
     use n0_error::StdResultExt;
 
     let tray_menu = Menu::new();
@@ -826,12 +1019,21 @@ fn init_tray() -> TrayIcon {
         false,
         None,
     );
+    let recent_sep = PredefinedMenuItem::separator();
+    let recent_sep_bottom = PredefinedMenuItem::separator();
+    let recent_header = MenuItem::with_id("recent_header", "Recent tunnels", false, None);
+    let recent_item_0 = IconMenuItem::with_id("recent_tunnel:0", "", true, None, None);
+    let recent_item_1 = IconMenuItem::with_id("recent_tunnel:1", "", true, None, None);
+    let recent_item_2 = IconMenuItem::with_id("recent_tunnel:2", "", true, None, None);
+    let recent_item_3 = IconMenuItem::with_id("recent_tunnel:3", "", true, None, None);
+    let recent_item_4 = IconMenuItem::with_id("recent_tunnel:4", "", true, None, None);
+    let icon_online = make_status_menu_icon(0x86, 0xa1, 0x82);
+    let icon_offline = make_status_menu_icon(0xf0, 0x8c, 0x8c);
     let sep = PredefinedMenuItem::separator();
 
     tray_menu
         .append_items(&[
             &new_tunnel_item,
-            &sep,
             &about_item,
             &show_item,
             &hide_item,
@@ -840,29 +1042,97 @@ fn init_tray() -> TrayIcon {
         ])
         .expect("Failed to build tray menu");
 
-    TrayIconBuilder::new()
+    let menu_for_state = tray_menu.clone();
+    let mut builder = TrayIconBuilder::new()
         .with_menu(Box::new(tray_menu))
         .with_tooltip("Datum")
-        .with_icon(icon())
+        .with_icon(tray_icon());
+    let tray = builder
         .build()
         .std_context("building tray icon")
-        .expect("failed to build tray icon")
+        .expect("failed to build tray icon");
+
+    std::sync::Arc::new(TrayState {
+        _tray: tray,
+        menu: menu_for_state,
+        recent_sep,
+        recent_sep_bottom,
+        recent_header,
+        recent_items: [
+            recent_item_0,
+            recent_item_1,
+            recent_item_2,
+            recent_item_3,
+            recent_item_4,
+        ],
+        icon_online,
+        icon_offline,
+        slot_tunnel_ids: std::sync::Mutex::new([None, None, None, None, None]),
+        mounted_tunnel_slots: std::sync::Mutex::new(0),
+        recent_section_open: std::sync::Mutex::new(false),
+    })
+}
+
+/// Load an icon from a PNG file for the tray (colored, non-macOS).
+#[cfg(all(feature = "desktop", not(target_os = "macos")))]
+fn tray_icon() -> Icon {
+    icon()
+}
+
+/// macOS menu bar icon (white logo on transparent background).
+#[cfg(all(feature = "desktop", target_os = "macos"))]
+fn tray_icon() -> Icon {
+    load_icon_from_bytes(include_bytes!("../assets/tray/tray-icon-template.png"))
 }
 
-/// Load an icon from a PNG file for the tray
 #[cfg(feature = "desktop")]
-fn icon() -> Icon {
+fn make_status_menu_icon(r: u8, g: u8, b: u8) -> MenuIcon {
+    // muda scales menu icons to 18pt on macOS; 54px covers 3x Retina without upscaling blur.
+    const SIZE: u32 = 54;
+    const DIAMETER: f32 = 30.0;
+    let center = SIZE as f32 / 2.0;
+    let radius = DIAMETER / 2.0;
+    let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize];
+
+    for y in 0..SIZE {
+        for x in 0..SIZE {
+            let dx = x as f32 + 0.5 - center;
+            let dy = y as f32 + 0.5 - center;
+            let dist = (dx * dx + dy * dy).sqrt();
+            let alpha = if dist <= radius - 0.5 {
+                255
+            } else if dist >= radius + 0.5 {
+                0
+            } else {
+                ((radius + 0.5 - dist) * 255.0).round() as u8
+            };
+            let i = ((y * SIZE + x) * 4) as usize;
+            rgba[i] = r;
+            rgba[i + 1] = g;
+            rgba[i + 2] = b;
+            rgba[i + 3] = alpha;
+        }
+    }
+
+    MenuIcon::from_rgba(rgba, SIZE, SIZE).expect("Failed to create status menu icon")
+}
+
+#[cfg(feature = "desktop")]
+fn load_icon_from_bytes(icon_bytes: &[u8]) -> Icon {
     use image::GenericImageView;
 
-    let icon_bytes = include_bytes!("../assets/bundle/linux/512.png");
     let image = image::load_from_memory(icon_bytes).unwrap();
-
     let (width, height) = image.dimensions();
     let rgba = image.to_rgba8().into_raw();
-
     Icon::from_rgba(rgba, width, height).expect("Failed to create icon from image")
 }
 
+/// Load an icon from a PNG file for the tray
+#[cfg(feature = "desktop")]
+fn icon() -> Icon {
+    load_icon_from_bytes(include_bytes!("../assets/bundle/linux/512.png"))
+}
+
 /// Load an icon from a PNG file for the window
 #[cfg(feature = "desktop")]
 fn window_icon() -> dioxus_desktop::tao::window::Icon {
@@ -877,8 +1147,6 @@ fn window_icon() -> dioxus_desktop::tao::window::Icon {
     dioxus_desktop::tao::window::Icon::from_rgba(rgba, width, height)
         .expect("Failed to create window icon from image")
 }
-
-/// Custom Objective-C class to handle About menu action and route navigation
 #[cfg(all(feature = "desktop", target_os = "macos"))]
 mod macos_menu_handler {
     use objc2::rc::Retained;
diff --git a/ui/src/state.rs b/ui/src/state.rs
index 4a095b1..96c0ad3 100644
--- a/ui/src/state.rs
+++ b/ui/src/state.rs
@@ -1,9 +1,11 @@
 use dioxus::prelude::WritableExt;
 use lib::{
     datum_cloud::{ApiEnv, DatumCloudClient},
-    HeartbeatAgent, ListenNode, Node, Repo, SelectedContext, TunnelService, TunnelSummary,
+    HeartbeatAgent, ListenNode, Node, Repo, SelectedContext, TunnelActivityTracker, TunnelService,
+    TunnelSummary,
 };
 use std::collections::HashSet;
+use std::sync::{Arc, Mutex};
 use tokio::sync::Notify;
 use tracing::info;
 
@@ -14,6 +16,7 @@ pub struct AppState {
     heartbeat: HeartbeatAgent,
     tunnel_refresh: std::sync::Arc,
     tunnel_cache: dioxus::signals::Signal>,
+    tunnel_activity: Arc>,
     /// IDs of tunnels we've just deleted locally but whose backend resources
     /// (HTTPProxy + ConnectorAdvertisement + …) may still appear in the next
     /// few `list_active` polls while Kubernetes is reaping them. Tombstones
@@ -40,6 +43,7 @@ impl AppState {
             heartbeat,
             tunnel_refresh: std::sync::Arc::new(Notify::new()),
             tunnel_cache: dioxus::signals::Signal::new(Vec::new()),
+            tunnel_activity: Arc::new(Mutex::new(TunnelActivityTracker::new())),
             pending_deletions: dioxus::signals::Signal::new(HashSet::new()),
         };
         Ok(app_state)
@@ -82,6 +86,10 @@ impl AppState {
         cache.set(tunnels);
     }
 
+    pub fn tunnel_activity(&self) -> Arc> {
+        self.tunnel_activity.clone()
+    }
+
     pub fn upsert_tunnel(&self, tunnel: TunnelSummary) {
         let mut cache = self.tunnel_cache;
         let mut list = cache();
diff --git a/ui/src/util.rs b/ui/src/util.rs
index 28dc9e0..eae142d 100644
--- a/ui/src/util.rs
+++ b/ui/src/util.rs
@@ -16,3 +16,8 @@ pub fn humanize_bytes(bytes: u64) -> String {
 
     format!("{:.1} {}", size, UNITS[unit_idx])
 }
+
+pub fn tunnel_edge_portal_url(web_url: &str, project_id: &str, tunnel_id: &str) -> String {
+    let base = web_url.trim_end_matches('/');
+    format!("{base}/project/{project_id}/edge/{tunnel_id}/overview")
+}
diff --git a/ui/src/views/navbar.rs b/ui/src/views/navbar.rs
index a543126..2a8bba6 100644
--- a/ui/src/views/navbar.rs
+++ b/ui/src/views/navbar.rs
@@ -30,6 +30,7 @@ pub fn Chrome() -> Element {
     let mut invite_user_dialog_open = use_signal(|| false);
     let mut editing_tunnel = use_signal(|| None::);
     let mut open_add_tunnel_from_tray = consume_context::>();
+    let mut open_tunnel_from_tray = consume_context::>>();
 
     provide_context(OpenEditTunnelDialog {
         editing_tunnel,
@@ -44,6 +45,13 @@ pub fn Chrome() -> Element {
         }
     });
 
+    use_effect(move || {
+        if let Some(id) = open_tunnel_from_tray() {
+            nav.push(Route::TunnelBandwidth { id });
+            open_tunnel_from_tray.set(None);
+        }
+    });
+
     use_effect(move || {
         let _ = auth_changed();
         if state.datum().login_state() == LoginState::Missing {
@@ -239,7 +247,7 @@ pub fn AppHeader(props: AppHeaderProps) -> Element {
                                 on_open_change: move |v| profile_menu_open.set(Some(v)),
                                 disabled: use_signal(|| false),
                                 DropdownMenuTrigger {
-                                    class: "flex items-center gap-2 cursor-default focus:outline-none hover:opacity-80 transition-opacity",
+                                    class: "flex items-center gap-2 cursor-pointer focus:outline-none hover:opacity-80 transition-opacity",
                                     tabindex: "-1",
                                     div { class: "w-[37px] h-[37px] rounded-lg border border-app-border bg-white flex items-center justify-center overflow-hidden shrink-0",
                                         if let Some(avatar_url) = user_avatar_url.as_ref() {
diff --git a/ui/src/views/proxies_list.rs b/ui/src/views/proxies_list.rs
index 6857e11..e7f2b55 100644
--- a/ui/src/views/proxies_list.rs
+++ b/ui/src/views/proxies_list.rs
@@ -41,6 +41,7 @@ use crate::{
         SwitchThumb,
     },
     state::AppState,
+    util::tunnel_edge_portal_url,
     Route,
 };
 
@@ -404,6 +405,17 @@ pub fn TunnelCard(
         .find(|t| t.id == tunnel_id)
         .unwrap_or(tunnel);
 
+    let state_for_portal = state.clone();
+    let tunnel_id_for_portal = tunnel_id.clone();
+    let portal_url = use_memo(move || {
+        let ctx = state_for_portal.selected_context()?;
+        Some(tunnel_edge_portal_url(
+            state_for_portal.datum().web_url(),
+            &ctx.project_id,
+            &tunnel_id_for_portal,
+        ))
+    });
+
     let tunnel_id_for_toggle = tunnel_id.clone();
     let mut toggle_action = use_action(move |next_enabled: bool| {
         let state = state.clone();
@@ -565,6 +577,28 @@ pub fn TunnelCard(
                                 }
                             }
                         }
+                        if show_bandwidth && !is_deleting() {
+                            if let Some(url) = portal_url() {
+                                div { class: "mt-2 pt-2 border-t border-tunnel-card-border",
+                                    p { class: "text-xs text-foreground/80",
+                                        "Manage WAF, hostnames, and other tunnel settings in "
+                                        a {
+                                            class: "text-button-link-foreground cursor-pointer inline-flex items-center gap-1",
+                                            onclick: move |_| {
+                                                let _ = that(&url);
+                                            },
+                                            "Datum Cloud"
+                                            Icon {
+                                                source: IconSource::Named("external-link".into()),
+                                                size: 12,
+                                                class: "text-icon-select",
+                                            }
+                                        }
+                                        "."
+                                    }
+                                }
+                            }
+                        }
                     }
                     div { class: "relative",
                         DropdownMenu {
@@ -572,7 +606,7 @@ pub fn TunnelCard(
                             default_open: false,
                             on_open_change: move |v| menu_open.set(Some(v)),
                             disabled: is_disabled,
-                            DropdownMenuTrigger { class: if is_disabled() { "w-8 h-8 rounded-lg border border-app-border text-foreground/50 flex items-center justify-center bg-transparent opacity-70 cursor-not-allowed pointer-events-none" } else { "w-8 h-8 rounded-lg border border-app-border text-foreground/60 flex items-center justify-center bg-transparent focus:outline-2 focus:outline-app-border/50" },
+                            DropdownMenuTrigger { class: if is_disabled() { "w-8 h-8 rounded-lg border border-app-border text-foreground/50 flex items-center justify-center bg-transparent opacity-70 cursor-not-allowed pointer-events-none" } else { "w-8 h-8 rounded-lg border border-app-border text-foreground/60 flex items-center justify-center bg-transparent focus:outline-2 focus:outline-app-border/50 cursor-pointer" },
                                 Icon {
                                     source: IconSource::Named("ellipsis".into()),
                                     size: 16,
@@ -605,6 +639,23 @@ pub fn TunnelCard(
                                     on_select: move |_| on_edit.call(tunnel_for_edit.clone()),
                                     "Edit"
                                 }
+                                {
+                                    if let Some(url) = portal_url() {
+                                        rsx! {
+                                            DropdownMenuItem:: {
+                                                value: use_signal(|| "portal".to_string()),
+                                                index: use_signal(|| 1),
+                                                disabled: is_deleting,
+                                                on_select: move |_| {
+                                                    let _ = that(&url);
+                                                },
+                                                "Manage settings"
+                                            }
+                                        }
+                                    } else {
+                                        rsx! {}
+                                    }
+                                }
                                 DropdownMenuSeparator {}
                                 DropdownMenuItem:: {
                                     value: use_signal(|| "delete".to_string()),
diff --git a/ui/src/views/tray_nav_handler.rs b/ui/src/views/tray_nav_handler.rs
index 985ffb9..8fce780 100644
--- a/ui/src/views/tray_nav_handler.rs
+++ b/ui/src/views/tray_nav_handler.rs
@@ -8,6 +8,7 @@ use dioxus::prelude::*;
 pub fn TrayNavHandler() -> Element {
     let nav = use_navigator();
     let open_add_tunnel_from_tray = consume_context::>();
+    let mut open_tunnel_from_tray = consume_context::>>();
 
     use_effect(move || {
         if open_add_tunnel_from_tray() {
@@ -16,6 +17,13 @@ pub fn TrayNavHandler() -> Element {
         }
     });
 
+    use_effect(move || {
+        if let Some(id) = open_tunnel_from_tray() {
+            nav.push(Route::TunnelBandwidth { id });
+            open_tunnel_from_tray.set(None);
+        }
+    });
+
     rsx! {
         Outlet:: {}
     }
diff --git a/ui/theme.css b/ui/theme.css
index 8bdf9ef..28b89e1 100644
--- a/ui/theme.css
+++ b/ui/theme.css
@@ -232,8 +232,16 @@
     @apply font-sans;
   }
 
+  button:not(:disabled) {
+    @apply cursor-pointer;
+  }
+
+  button:disabled {
+    @apply cursor-not-allowed;
+  }
+
   a {
-    @apply focus:outline-1 focus:outline-app-border;
+    @apply cursor-pointer focus:outline-1 focus:outline-app-border;
   }
 }