From 5307b5572ac86f1be4cfdfb15c66d95c0324fe8c Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:15:53 +0100 Subject: [PATCH] feat: tray recent tunnels, cloud portal links, TTP defaults The tray lists up to five tunnels with traffic rates and opens bandwidth when you click one. Cards and menus link to Datum Cloud for WAF and hostname settings. New tunnels get a traffic protection policy again unless you opt out with an env var. Clickable UI bits show a pointer. Added a tiny script to serve test traffic on localhost:3001. Co-authored-by: Cursor --- lib/src/lib.rs | 2 + lib/src/tunnel_activity.rs | 224 ++++++++++++++ lib/src/tunnels.rs | 12 +- scripts/traffic-test-server.py | 71 +++++ ui/assets/tailwind.css | 12 + ui/assets/tray/status-offline.png | Bin 0 -> 445 bytes ui/assets/tray/status-online.png | Bin 0 -> 372 bytes ui/assets/tray/tray-icon-template.png | Bin 0 -> 1231 bytes ui/src/components/button.rs | 8 +- ui/src/components/dropdown_menu/component.rs | 6 +- ui/src/components/select/component.rs | 8 +- ui/src/components/switch/component.rs | 2 +- ui/src/main.rs | 300 ++++++++++++++++++- ui/src/state.rs | 10 +- ui/src/util.rs | 5 + ui/src/views/navbar.rs | 10 +- ui/src/views/proxies_list.rs | 53 +++- ui/src/views/tray_nav_handler.rs | 8 + ui/theme.css | 10 +- 19 files changed, 704 insertions(+), 37 deletions(-) create mode 100644 lib/src/tunnel_activity.rs create mode 100755 scripts/traffic-test-server.py create mode 100644 ui/assets/tray/status-offline.png create mode 100644 ui/assets/tray/status-online.png create mode 100644 ui/assets/tray/tray-icon-template.png 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 0000000000000000000000000000000000000000..45cb50adfc80c683474abba251ccb7cf611d7f6b
GIT binary patch
literal 445
zcmeAS@N?(olHy`uVBq!ia0vp^AU2l(8<4!aNcbO+Qb_g)@?~JCQe$9fXklRZ1r%y{
z!N5>zz`*b-fq}tl1_Oh5!JJ)zHb4osByV?@|6srw@%;`^guTSm*OmPhn=qfUX-G2T
zU7(O^iEBhjN@7W>RdP`(kYX@0Ff!9MFw-@(3^6pZGB&j`HP$vTure@sBza%~iiX_$
zl+3hB+!`jkRLB4|XuxeK$;?eHE=kNSz^&&I(3)EF2VS{N990fib~
zFff!FFfhDIU|_JC!N4G1FlSew4NyWTz$e5NNVhF)f}{Wc|K~io$OIH+DGBlmW^g|;
zsr;hsUm%|`$=lt9^+*28<3J93iKnkC`ztnKK4sI8WX8KdA=MJsh?11Vl2ohYqEsNo
zU}RuqrfXoPYiJo_Xkuk-YGrDyZD3$!VDL!tzycHvx%nxXX_dG&Om?Y|0cy~I+fb63
zn_66wm|K8b&ym2$!$3XSo-U3d9M_W*8W`L}T@^APJxV%o;Kidyk4_w5QJE_d(XfPj
h3a?=M3dYs!3}=L8Y*qjO

literal 0
HcmV?d00001

diff --git a/ui/assets/tray/tray-icon-template.png b/ui/assets/tray/tray-icon-template.png
new file mode 100644
index 0000000000000000000000000000000000000000..223a5179369c498cb841caea028d8d09e88a9b3a
GIT binary patch
literal 1231
zcmeAS@N?(olHy`uVBq!ia0vp^Iv~u!1|;QLq8NdcLb6AYF9SoB8UsT^3j@P1pisjL
z28L1t28LG&3=CE?7#PG0=Ijcz0ZOnXdAqwX{0G4WdzViJif|TqL>4nJa0`PlBg3pY
z5H=O_E&7ed}8XXuQw?Jg;YyiBT7;dOH!?pi&B9UgOP!enXZAEuAya!p^25D
zg_Vhcwt<0_fx)JeFD{{I$jwj5OsmAL;qa7zJfH>*xD6$lxv9k^iMa)6dW?*$j4dFR
zC}$sF2kPMi=?TtHD=AMbN@Z}%OwUc6w{coB0|Rq|r;B4q#No3u&vyF-${e@9&SR|V
z{K+iS$U4Q#?dFAwPMs?X{~JVR9la6Jp<^N>?X_@2P>`A`cbL%9l!vK%OqZ;fnKVOO
zXr5&9KC$O_3ZL2O7oXpI{>+{Hzq59pFFt2Gzk1&NzklnV&uM6xG-XA+0E=h4$=}lq
zHsUja7o6lUVEMDfET)g$gL@Ch4knX`1BSj0jt`_SuhAQnpd$K&{+?b^E+_X{D?
zcB`h`X%iQZf$RNXhCCmxbCJ?pzAfjuu)E=yq45->%`69u
z7F^rOx0Wk{$%JusLnY^N3uYOX;QSp2wi@P3eb~RC^YfOe2eKX*TN
znQ|?LT4Tq(7sc)=*SvJ8vyJxJc-!sOo%aVkyHYj^Wo=kzYFTK(^jBoZ^U7|kgl9__
z-50bsw$Ho!+{dcG<*rENW6{8=TaO&L+#orj=Ia~>FXotDPcF~U_V`Tej>yL4MV-8f
z|93n*yGkhHwdFO2wABitAH?R^*vAPKZsEG|!ZIveG2#A{odOn2SEe-e)tGSyo9TU!
zdCmGwmG^H3!;2$7o>h1%>g23ykUhZpKtoM)%B+`C3wUq5eF-%>tGKc=
zukzWH+k1#ylX)Rir+Ll&edFgF3IcsIw@nlH7$&<<>d2IZb1&*|+I40>OAN1mjQJU*
z0_y^vXlobqU7OkN@X0Xz>NY*WRlv9T?yV-_Vs2UGl*I3!WS_C_ZYyn0+7Qh9M{);W
z%!Uh#wMrd=`4Z%}|6<+4n#XKz5PBmcqNjM$6tNxblRLR&t9uh_Hp~C$uzXj=Qo^wJ
zqV!SQZ(7F>K5{zqFv+B`<$(Q~r>RF&(u

literal 0
HcmV?d00001

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;
   }
 }