From a89ca71d1b9c791a61aaea383a5e1a3ab3dd170c Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:20:15 +0100 Subject: [PATCH] fix: Keep session on quota 403 and gate tunnel create Quota admission returns 403 Forbidden, which we treated as auth failure and logged the user out. Only treat 401 as auth now, surface quota limits in the UI, and skip the macOS titlebar offset on Windows and Linux overlays. --- lib/src/datum_apis/allowance_bucket.rs | 43 +++++ lib/src/datum_apis/mod.rs | 1 + lib/src/lib.rs | 10 +- lib/src/project_control_plane.rs | 96 +++++++++-- lib/src/tunnels.rs | 163 +++++++++++++++++++ ui/src/components/add_tunnel_dialog.rs | 95 ++++++++--- ui/src/components/dialog/component.rs | 4 +- ui/src/components/dropdown_menu/component.rs | 10 +- ui/src/main.rs | 12 +- ui/src/state.rs | 19 ++- ui/src/util.rs | 13 ++ ui/src/views/navbar.rs | 23 ++- ui/src/views/proxies_list.rs | 46 +++++- ui/src/views/select_project.rs | 2 +- 14 files changed, 482 insertions(+), 55 deletions(-) create mode 100644 lib/src/datum_apis/allowance_bucket.rs diff --git a/lib/src/datum_apis/allowance_bucket.rs b/lib/src/datum_apis/allowance_bucket.rs new file mode 100644 index 0000000..8fbd4c5 --- /dev/null +++ b/lib/src/datum_apis/allowance_bucket.rs @@ -0,0 +1,43 @@ +use kube::CustomResource; +use serde::{Deserialize, Serialize}; + +/// Reference to the quota consumer (e.g. a Project) tracked by an AllowanceBucket. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConsumerRef { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_group: Option, + pub kind: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, +} + +/// Milo AllowanceBucket — aggregates grants and tracks consumption for a +/// (consumer, resourceType) pair. Status fields are written by the quota system. +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize)] +#[kube( + group = "quota.miloapis.com", + version = "v1alpha1", + kind = "AllowanceBucket", + plural = "allowancebuckets", + namespaced, + status = "AllowanceBucketStatus", + schema = "disabled" +)] +#[serde(rename_all = "camelCase")] +pub struct AllowanceBucketSpec { + pub consumer_ref: ConsumerRef, + pub resource_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AllowanceBucketStatus { + #[serde(default)] + pub limit: i64, + #[serde(default)] + pub allocated: i64, + #[serde(default)] + pub available: i64, +} diff --git a/lib/src/datum_apis/mod.rs b/lib/src/datum_apis/mod.rs index 6cf5f87..b65fdce 100644 --- a/lib/src/datum_apis/mod.rs +++ b/lib/src/datum_apis/mod.rs @@ -1,3 +1,4 @@ +pub mod allowance_bucket; pub mod connector; pub mod connector_advertisement; pub mod connector_class; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 9431f71..db5bbed 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -18,11 +18,17 @@ pub use diagnostics::DiagnosticsSettings; pub use heartbeat::HeartbeatAgent; pub use http_user_agent::datum_http_user_agent; pub use node::*; -pub use project_control_plane::ProjectControlPlaneClient; +pub use project_control_plane::{ + ProjectControlPlaneClient, error_looks_like_quota, is_kube_auth_failure, + is_kube_quota_exceeded, message_looks_like_quota, +}; pub use repo::Repo; pub use state::*; pub use tunnel_activity::{TunnelActivityEntry, TunnelActivityTracker}; -pub use tunnels::{TunnelDeleteOutcome, TunnelService, TunnelSummary}; +pub use tunnels::{ + CONNECTOR_ADVERTISEMENT_QUOTA_RESOURCE_TYPE, HTTPPROXY_QUOTA_RESOURCE_TYPE, TunnelCreateQuota, + TunnelDeleteOutcome, TunnelService, TunnelSummary, tunnel_create_quota_from_buckets, +}; pub use update::{UpdateChannel, UpdateChecker, UpdateInfo, UpdateSettings}; /// The root domain for datum connect urls to subdomain from. A proxy URL will diff --git a/lib/src/project_control_plane.rs b/lib/src/project_control_plane.rs index 7fa6ffa..431bf7f 100644 --- a/lib/src/project_control_plane.rs +++ b/lib/src/project_control_plane.rs @@ -83,11 +83,14 @@ impl ProjectControlPlaneClient { /// Run a kube operation with auth handling (idempotent / read variant): /// /// 1. Preflight: refresh the access token if near expiry and rebuild the client. - /// 2. Run the operation. On 401/403, force a refresh and retry once. - /// 3. If the retry also returns 401/403, clear the local auth state and return + /// 2. Run the operation. On 401, force a refresh and retry once. + /// 3. If the retry also returns 401, clear the local auth state and return /// [`Unauthorized`]. The watch on `login_state` will then drive a redirect /// to login in the UI. /// + /// 403 responses (RBAC denial, quota admission, etc.) are returned as normal + /// errors and do **not** clear the session. + /// /// The closure is `Fn` so it can be invoked twice. Use this for idempotent /// operations (lists, gets, deletes). For non-idempotent writes, use /// [`Self::with_auth`] which preflights and logs out on auth failure without @@ -125,7 +128,7 @@ impl ProjectControlPlaneClient { "kube auth failure persisted after refresh; logging out" ); if let Err(e) = self.datum.auth().logout().await { - warn!("Failed to clear auth state after persistent 401/403: {e:#}"); + warn!("Failed to clear auth state after persistent 401: {e:#}"); } Err(Unauthorized.into()) } @@ -137,8 +140,9 @@ impl ProjectControlPlaneClient { /// but without retrying. Use for non-idempotent writes (create/patch/etc.) where /// re-running the closure would produce secondary errors like "AlreadyExists". /// - /// On 401/403 the local auth state is cleared and [`Unauthorized`] is returned; + /// On 401 the local auth state is cleared and [`Unauthorized`] is returned; /// the UI's login-state watcher then routes the user back to the login screen. + /// 403 responses (RBAC, quota, etc.) are returned as normal errors. pub async fn with_auth(&self, op: F) -> Result where F: FnOnce(Client) -> Fut, @@ -150,7 +154,7 @@ impl ProjectControlPlaneClient { Err(err) if is_kube_auth_failure(&err) => { warn!(err = %err, "kube auth failure; logging out"); if let Err(e) = self.datum.auth().logout().await { - warn!("Failed to clear auth state on 401/403: {e:#}"); + warn!("Failed to clear auth state on 401: {e:#}"); } Err(Unauthorized.into()) } @@ -227,9 +231,39 @@ impl ProjectControlPlaneClient { } } -/// True if the kube error indicates the bearer token was rejected. +/// True if the kube error indicates the bearer token was rejected (HTTP 401). +/// +/// 403 Forbidden is **not** treated as auth failure: it covers RBAC denials and +/// admission webhook rejections (e.g. Milo quota). Logging out on those would +/// dump the user to the login screen incorrectly. pub fn is_kube_auth_failure(err: &kube::Error) -> bool { - matches!(err, kube::Error::Api(resp) if resp.code == 401 || resp.code == 403) + matches!(err, kube::Error::Api(resp) if resp.code == 401) +} + +/// True if the kube error is a quota admission rejection (403 with a quota message). +pub fn is_kube_quota_exceeded(err: &kube::Error) -> bool { + match err { + kube::Error::Api(resp) if resp.code == 403 => message_looks_like_quota(&resp.message), + _ => false, + } +} + +/// True if an error chain (or Display string) looks like a Milo quota rejection. +pub fn error_looks_like_quota(err: &(dyn std::error::Error + 'static)) -> bool { + let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err); + while let Some(e) = cur { + if message_looks_like_quota(&e.to_string()) { + return true; + } + cur = e.source(); + } + false +} + +/// True if a message string looks like a Milo quota rejection. +pub fn message_looks_like_quota(message: &str) -> bool { + let lower = message.to_lowercase(); + lower.contains("quota") || lower.contains("you've reached your quota") } #[cfg(test)] @@ -246,15 +280,32 @@ mod is_kube_auth_failure_tests { }) } + fn api_err_msg(code: u16, message: &str) -> kube::Error { + kube::Error::Api(ErrorResponse { + status: "Failure".to_string(), + message: message.to_string(), + reason: "Forbidden".to_string(), + code, + }) + } + #[test] - fn classifies_401_and_403_api_errors() { + fn classifies_401_as_auth_failure() { assert!(is_kube_auth_failure(&api_err(401))); - assert!(is_kube_auth_failure(&api_err(403))); + } + + #[test] + fn does_not_treat_403_as_auth_failure() { + assert!(!is_kube_auth_failure(&api_err(403))); + assert!(!is_kube_auth_failure(&api_err_msg( + 403, + "You've reached your quota for this resource type (Insufficient quota resources.)" + ))); } #[test] fn ignores_other_api_status_codes() { - for code in [200u16, 404, 409, 410, 500, 503] { + for code in [200u16, 403, 404, 409, 410, 500, 503] { assert!( !is_kube_auth_failure(&api_err(code)), "code {code} should not be an auth failure" @@ -267,4 +318,29 @@ mod is_kube_auth_failure_tests { let err = kube::Error::TlsRequired; assert!(!is_kube_auth_failure(&err)); } + + #[test] + fn detects_quota_exceeded_403() { + let err = api_err_msg( + 403, + "httpproxies.networking.datumapis.com \"tunnel-jw8vf\" is forbidden: You've reached your quota for this resource type (Insufficient quota resources. Contact your account administrator to review quota limits and usage.).", + ); + assert!(is_kube_quota_exceeded(&err)); + assert!(!is_kube_auth_failure(&err)); + } + + #[test] + fn ignores_non_quota_403() { + let err = api_err_msg(403, "httpproxies.networking.datumapis.com is forbidden"); + assert!(!is_kube_quota_exceeded(&err)); + } + + #[test] + fn error_looks_like_quota_from_display() { + let err = std::io::Error::new( + std::io::ErrorKind::Other, + "Insufficient quota resources available", + ); + assert!(error_looks_like_quota(&err)); + } } diff --git a/lib/src/tunnels.rs b/lib/src/tunnels.rs index 2728832..ccf227c 100644 --- a/lib/src/tunnels.rs +++ b/lib/src/tunnels.rs @@ -8,6 +8,7 @@ use n0_error::{Result, StackResultExt, StdResultExt}; use serde_json::json; use tracing::{debug, warn}; +use crate::datum_apis::allowance_bucket::AllowanceBucket; use crate::datum_apis::connector::{ Connector, ConnectorConnectionDetails, ConnectorConnectionDetailsPublicKey, ConnectorConnectionType, ConnectorSpec, PublicKeyConnectorAddress, PublicKeyDiscoveryMode, @@ -34,11 +35,18 @@ use gateway_api::apis::standard::httproutes::{ }; const DEFAULT_PCP_NAMESPACE: &str = "default"; +const QUOTA_BUCKET_NAMESPACE: &str = "milo-system"; const DEFAULT_CONNECTOR_CLASS_NAME: &str = "iroh-quic-tunnel"; const CONNECTOR_SELECTOR_FIELD: &str = "status.connectionDetails.publicKey.id"; const ADVERTISEMENT_CONNECTOR_FIELD: &str = "spec.connectorRef.name"; const DISPLAY_NAME_ANNOTATION: &str = "app.kubernetes.io/name"; +/// Milo resourceType for HTTPProxy entity quota (one claim per tunnel create). +pub const HTTPPROXY_QUOTA_RESOURCE_TYPE: &str = "networking.datumapis.com/httpproxies"; +/// Milo resourceType for ConnectorAdvertisement entity quota (one claim per tunnel create). +pub const CONNECTOR_ADVERTISEMENT_QUOTA_RESOURCE_TYPE: &str = + "networking.datumapis.com/connectoradvertisements"; + /// Returns true if any rule in the HTTPProxy has a backend that references the given connector by name. fn proxy_uses_connector(proxy: &HTTPProxy, connector_name: &str) -> bool { proxy @@ -82,6 +90,62 @@ pub struct TunnelDeleteOutcome { pub connector_deleted: bool, } +/// Snapshot of whether the selected project has remaining quota to create a tunnel. +/// +/// Missing buckets are treated as "unknown" (allow create) so a transient quota API +/// failure or missing registration does not hard-block the UI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TunnelCreateQuota { + pub project_id: String, + /// Remaining HTTPProxy quota, if the bucket was found. + pub httpproxies_available: Option, + /// Remaining ConnectorAdvertisement quota, if the bucket was found. + pub connector_advertisements_available: Option, +} + +impl TunnelCreateQuota { + /// True when every known required bucket has at least one unit available. + /// If a bucket is missing (`None`), it does not block create. + pub fn can_create_tunnel(&self) -> bool { + self.httpproxies_available.map(|n| n >= 1).unwrap_or(true) + && self + .connector_advertisements_available + .map(|n| n >= 1) + .unwrap_or(true) + } + + /// True when at least one known bucket is present and exhausted. + pub fn is_exhausted(&self) -> bool { + !self.can_create_tunnel() + } +} + +/// Derive create-quota status from a list of AllowanceBuckets. +pub fn tunnel_create_quota_from_buckets( + project_id: impl Into, + buckets: &[AllowanceBucket], +) -> TunnelCreateQuota { + let mut httpproxies_available = None; + let mut connector_advertisements_available = None; + for bucket in buckets { + let available = bucket.status.as_ref().map(|s| s.available); + match bucket.spec.resource_type.as_str() { + HTTPPROXY_QUOTA_RESOURCE_TYPE => { + httpproxies_available = available; + } + CONNECTOR_ADVERTISEMENT_QUOTA_RESOURCE_TYPE => { + connector_advertisements_available = available; + } + _ => {} + } + } + TunnelCreateQuota { + project_id: project_id.into(), + httpproxies_available, + connector_advertisements_available, + } +} + #[derive(Debug, Clone)] pub struct TunnelService { datum: DatumCloudClient, @@ -178,6 +242,30 @@ impl TunnelService { self.delete_project(&selected.project_id, tunnel_id).await } + /// List Milo AllowanceBuckets for the selected project and return whether a + /// new tunnel can be created given remaining HTTPProxy / ConnectorAdvertisement quota. + pub async fn tunnel_create_quota_active(&self) -> Result> { + let Some(selected) = self.datum.selected_context() else { + return Ok(None); + }; + Ok(Some( + self.tunnel_create_quota_project(&selected.project_id) + .await?, + )) + } + + pub async fn tunnel_create_quota_project(&self, project_id: &str) -> Result { + let pcp = self.datum.project_control_plane_client(project_id).await?; + let buckets = pcp + .with_auth_retry(|client| { + let buckets: Api = Api::namespaced(client, QUOTA_BUCKET_NAMESPACE); + async move { buckets.list(&ListParams::default()).await } + }) + .await + .context("Failed to list project allowance buckets")?; + Ok(tunnel_create_quota_from_buckets(project_id, &buckets.items)) + } + pub async fn list_project(&self, project_id: &str) -> Result> { let connector = self.find_connector(project_id).await?; let Some(connector) = connector else { @@ -1224,3 +1312,78 @@ fn create_traffic_protection_policies_enabled() -> bool { _ => true, } } + +#[cfg(test)] +mod tunnel_create_quota_tests { + use super::*; + use crate::datum_apis::allowance_bucket::{ + AllowanceBucket, AllowanceBucketSpec, AllowanceBucketStatus, ConsumerRef, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + fn bucket(resource_type: &str, available: i64) -> AllowanceBucket { + AllowanceBucket { + metadata: ObjectMeta::default(), + spec: AllowanceBucketSpec { + consumer_ref: ConsumerRef { + api_group: Some("resourcemanager.miloapis.com".to_string()), + kind: "Project".to_string(), + name: "proj".to_string(), + namespace: None, + }, + resource_type: resource_type.to_string(), + }, + status: Some(AllowanceBucketStatus { + limit: available + 1, + allocated: 1, + available, + }), + } + } + + #[test] + fn allows_create_when_both_have_capacity() { + let q = tunnel_create_quota_from_buckets( + "proj", + &[ + bucket(HTTPPROXY_QUOTA_RESOURCE_TYPE, 2), + bucket(CONNECTOR_ADVERTISEMENT_QUOTA_RESOURCE_TYPE, 1), + ], + ); + assert!(q.can_create_tunnel()); + assert!(!q.is_exhausted()); + } + + #[test] + fn blocks_when_httpproxies_exhausted() { + let q = tunnel_create_quota_from_buckets( + "proj", + &[ + bucket(HTTPPROXY_QUOTA_RESOURCE_TYPE, 0), + bucket(CONNECTOR_ADVERTISEMENT_QUOTA_RESOURCE_TYPE, 5), + ], + ); + assert!(!q.can_create_tunnel()); + assert!(q.is_exhausted()); + } + + #[test] + fn blocks_when_advertisements_exhausted() { + let q = tunnel_create_quota_from_buckets( + "proj", + &[ + bucket(HTTPPROXY_QUOTA_RESOURCE_TYPE, 3), + bucket(CONNECTOR_ADVERTISEMENT_QUOTA_RESOURCE_TYPE, 0), + ], + ); + assert!(!q.can_create_tunnel()); + } + + #[test] + fn missing_buckets_do_not_block() { + let q = tunnel_create_quota_from_buckets("proj", &[]); + assert!(q.can_create_tunnel()); + assert_eq!(q.httpproxies_available, None); + assert_eq!(q.connector_advertisements_available, None); + } +} diff --git a/ui/src/components/add_tunnel_dialog.rs b/ui/src/components/add_tunnel_dialog.rs index c7007b0..0c4335a 100644 --- a/ui/src/components/add_tunnel_dialog.rs +++ b/ui/src/components/add_tunnel_dialog.rs @@ -1,6 +1,7 @@ use dioxus::events::FormEvent; use dioxus::prelude::*; -use lib::{TcpProxyData, TunnelSummary}; +use lib::{message_looks_like_quota, TcpProxyData, TunnelSummary}; +use open::that; use crate::{ components::{ @@ -9,6 +10,7 @@ use crate::{ Button, ButtonKind, }, state::AppState, + util::project_quotas_portal_url, }; /// Strips "http://" or "https://" from the front of a string (case-insensitive). @@ -58,6 +60,7 @@ pub fn AddTunnelDialog( /// Called after a successful save so the parent can refresh the tunnels list. on_save_success: EventHandler<()>, ) -> Element { + let state = consume_context::(); let mut address = use_signal(String::new); let mut label = use_signal(String::new); let mut basic_auth_enabled = use_signal(|| false); @@ -145,6 +148,30 @@ pub fn AddTunnelDialog( let address_invalid = use_memo(move || address().trim().is_empty() || address_validation().is_some()); + let quota_exhausted = !is_edit + && state.tunnel_create_quota()() + .as_ref() + .map(|q| q.is_exhausted()) + .unwrap_or(false); + let quotas_url = state + .selected_context() + .map(|ctx| project_quotas_portal_url(state.datum().web_url(), &ctx.project_id)); + + let create_blocked = !is_edit && quota_exhausted; + let submit_disabled = save_tunnel.pending() + || save_create_tunnel.pending() + || address_invalid() + || create_blocked; + + let save_err = save_tunnel + .value() + .and_then(|r| r.err()) + .or_else(|| save_create_tunnel.value().and_then(|r| r.err())); + let save_err_text = save_err.as_ref().map(|e| e.to_string()); + let save_err_is_quota = save_err_text + .as_ref() + .is_some_and(|t| !is_edit && message_looks_like_quota(t)); + rsx! { DialogRoot { open: open(), @@ -153,6 +180,26 @@ pub fn AddTunnelDialog( DialogContent { DialogTitle { "{title}" } form { class: "space-y-5 mt-5 w-[452px]", autocomplete: "off", + if create_blocked { + div { class: "rounded-md border border-card-border bg-card-background p-4 shadow-card", + div { class: "text-sm font-medium text-foreground", + "You've hit your tunnel limit" + } + div { class: "text-sm mt-1 text-foreground/60", + "You can delete a tunnel from the list, or review your quotas in the portal and contact support if you need further assistance." + } + if let Some(url) = quotas_url.clone() { + button { + class: "mt-2 text-sm text-foreground/70 underline underline-offset-2 hover:text-foreground cursor-pointer", + r#type: "button", + onclick: move |_| { + let _ = that(&url); + }, + "Review quotas" + } + } + } + } Input { id: Some("tunnel-name".into()), label: Some("Display name".into()), @@ -173,36 +220,38 @@ pub fn AddTunnelDialog( onchange: move |e: FormEvent| address.set(e.value()), r#type: "text", } - // TODO: Add basic authentication - // div { class: "flex flex-col gap-2", - // div { class: "flex items-center justify-between", - // label { class: "text-xs text-form-label/90", "Basic authentication" } - // Switch { - // checked: basic_auth_enabled(), - // on_checked_change: move |checked| basic_auth_enabled.set(checked), - // SwitchThumb {} - // } - // } - // div { class: "text-1xs text-form-description", - // "We'll automatically generate a username and password for you." - // } - // } - if let Some(err) = save_tunnel - .value() - .and_then(|r| r.err()) - .or_else(|| save_create_tunnel.value().and_then(|r| r.err())) - { + if save_err_is_quota { + div { class: "rounded-md border border-card-border bg-card-background p-4 shadow-card", + div { class: "text-sm font-medium text-foreground", + "You've hit your tunnel limit" + } + div { class: "text-sm mt-1 text-foreground/60", + "You can delete a tunnel from the list, or review your quotas in the portal and contact support if you need further assistance." + } + if let Some(url) = quotas_url.clone() { + button { + class: "mt-2 text-sm text-foreground/70 underline underline-offset-2 hover:text-foreground cursor-pointer", + r#type: "button", + onclick: move |_| { + let _ = that(&url); + }, + "Review quotas" + } + } + } + } else if let Some(err_text) = save_err_text.clone() { div { class: "rounded-md border border-red-200 bg-red-50 p-4 text-red-800", div { class: "text-sm font-semibold", "{error_title}" } - div { class: "text-sm mt-1 break-words", "{err}" } + div { class: "text-sm mt-1 break-words", "{err_text}" } } } div { class: "flex items-center gap-2.5 pt-2 justify-start", Button { kind: ButtonKind::Primary, - class: if save_tunnel.pending() || save_create_tunnel.pending() || address_invalid() { Some("opacity-60".to_string()) } else { None }, + disabled: submit_disabled, + class: if submit_disabled { Some("opacity-60".to_string()) } else { None }, onclick: move |_| { - if address_invalid() { + if submit_disabled { return; } if let Some(tunnel_id) = initial_tunnel diff --git a/ui/src/components/dialog/component.rs b/ui/src/components/dialog/component.rs index a756ebb..83a4feb 100644 --- a/ui/src/components/dialog/component.rs +++ b/ui/src/components/dialog/component.rs @@ -3,11 +3,13 @@ use dioxus_primitives::dialog::{ self, DialogContentProps, DialogDescriptionProps, DialogRootProps, DialogTitleProps, }; +use crate::util::OVERLAY_TITLEBAR_OFFSET; + #[component] pub fn DialogRoot(props: DialogRootProps) -> Element { rsx! { dialog::DialogRoot { - class: "bg-foreground/30 absolute mt-[32px] top-0 left-0 w-full h-full inset-0 z-50 flex items-center justify-center animate-in fade-in duration-100 backdrop-blur-[2px]", + class: "bg-foreground/30 absolute {OVERLAY_TITLEBAR_OFFSET} top-0 left-0 w-full h-full inset-0 z-50 flex items-center justify-center animate-in fade-in duration-100 backdrop-blur-[2px]", id: props.id, is_modal: props.is_modal, open: props.open, diff --git a/ui/src/components/dropdown_menu/component.rs b/ui/src/components/dropdown_menu/component.rs index 2a22330..c203101 100644 --- a/ui/src/components/dropdown_menu/component.rs +++ b/ui/src/components/dropdown_menu/component.rs @@ -4,10 +4,14 @@ use dioxus_primitives::dropdown_menu::{ }; use crate::components::icon::{Icon, IconSource}; +use crate::util::OVERLAY_TITLEBAR_OFFSET; /// Dark backdrop when dropdown is open (same style as dialog). Only visible when using controlled `open` state. -const BACKDROP_CLASS: &str = - "fixed inset-0 mt-[32px] z-40 rounded-b-md animate-in fade-in duration-100"; +fn backdrop_class() -> String { + format!( + "fixed inset-0 {OVERLAY_TITLEBAR_OFFSET} z-40 rounded-b-md animate-in fade-in duration-100" + ) +} #[component] pub fn DropdownMenu(props: DropdownMenuProps) -> Element { @@ -15,7 +19,7 @@ pub fn DropdownMenu(props: DropdownMenuProps) -> Element { rsx! { if is_open() { div { - class: BACKDROP_CLASS, + class: backdrop_class(), onclick: move |_| props.on_open_change.call(false), } } diff --git a/ui/src/main.rs b/ui/src/main.rs index a7759b7..7122f22 100644 --- a/ui/src/main.rs +++ b/ui/src/main.rs @@ -211,15 +211,14 @@ fn init_tracing() { let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); let _ = LOG_GUARD.set(guard); - // Promote WARN to a Sentry event (in addition to the default ERROR -> Event - // and INFO -> Breadcrumb). The codebase uses `warn!` for most meaningful - // failure paths (auth, kube, refresh) and very few `error!` sites, so the - // default filter sends almost nothing to Sentry. + // ERROR -> Sentry event; WARN/INFO -> breadcrumb only (attached to later + // events). Do not promote WARN to events — expected operational failures + // (quota, kube 403, refresh blips) would otherwise flood Sentry. let sentry_layer = sentry::integrations::tracing::layer().event_filter(|md| { use sentry::integrations::tracing::EventFilter; match *md.level() { tracing::Level::ERROR => EventFilter::Event | EventFilter::Breadcrumb, - tracing::Level::WARN => EventFilter::Event | EventFilter::Breadcrumb, + tracing::Level::WARN => EventFilter::Breadcrumb, tracing::Level::INFO => EventFilter::Breadcrumb, _ => EventFilter::Ignore, } @@ -841,10 +840,11 @@ fn DiagnosticsPrompt( on_opt_out: EventHandler, ) -> Element { use crate::components::{Button, ButtonKind}; + use crate::util::OVERLAY_TITLEBAR_OFFSET; rsx! { div { - class: "mt-[32px] fixed inset-0 z-50 flex items-center justify-center animate-in fade-in duration-100", + class: "{OVERLAY_TITLEBAR_OFFSET} fixed inset-0 z-50 flex items-center justify-center animate-in fade-in duration-100", style: "background-color: rgba(0,0,0,0.2); -webkit-backdrop-filter: blur(1px); backdrop-filter: blur(1px);", onclick: move |evt| { evt.stop_propagation(); diff --git a/ui/src/state.rs b/ui/src/state.rs index 96c0ad3..d7ca654 100644 --- a/ui/src/state.rs +++ b/ui/src/state.rs @@ -1,8 +1,8 @@ use dioxus::prelude::WritableExt; use lib::{ datum_cloud::{ApiEnv, DatumCloudClient}, - HeartbeatAgent, ListenNode, Node, Repo, SelectedContext, TunnelActivityTracker, TunnelService, - TunnelSummary, + HeartbeatAgent, ListenNode, Node, Repo, SelectedContext, TunnelActivityTracker, + TunnelCreateQuota, TunnelService, TunnelSummary, }; use std::collections::HashSet; use std::sync::{Arc, Mutex}; @@ -24,6 +24,9 @@ pub struct AppState { /// flipped off; they are cleared automatically by `proxies_list` once the /// API stops returning the ID. pending_deletions: dioxus::signals::Signal>, + /// Latest create-quota snapshot for the selected project (refreshed with the + /// tunnel list). `None` means not yet loaded or no project selected. + tunnel_create_quota: dioxus::signals::Signal>, } impl AppState { @@ -45,6 +48,7 @@ impl AppState { tunnel_cache: dioxus::signals::Signal::new(Vec::new()), tunnel_activity: Arc::new(Mutex::new(TunnelActivityTracker::new())), pending_deletions: dioxus::signals::Signal::new(HashSet::new()), + tunnel_create_quota: dioxus::signals::Signal::new(None), }; Ok(app_state) } @@ -86,6 +90,15 @@ impl AppState { cache.set(tunnels); } + pub fn tunnel_create_quota(&self) -> dioxus::signals::Signal> { + self.tunnel_create_quota + } + + pub fn set_tunnel_create_quota(&self, quota: Option) { + let mut signal = self.tunnel_create_quota; + signal.set(quota); + } + pub fn tunnel_activity(&self) -> Arc> { self.tunnel_activity.clone() } @@ -155,6 +168,8 @@ impl AppState { self.datum .set_selected_context(selected_context.clone()) .await?; + // Drop stale quota until the tunnel poll refreshes for the new project. + self.set_tunnel_create_quota(None); Ok(()) } } diff --git a/ui/src/util.rs b/ui/src/util.rs index eae142d..dd2922b 100644 --- a/ui/src/util.rs +++ b/ui/src/util.rs @@ -21,3 +21,16 @@ pub fn tunnel_edge_portal_url(web_url: &str, project_id: &str, tunnel_id: &str) let base = web_url.trim_end_matches('/'); format!("{base}/project/{project_id}/edge/{tunnel_id}/overview") } + +pub fn project_quotas_portal_url(web_url: &str, project_id: &str) -> String { + let base = web_url.trim_end_matches('/'); + format!("{base}/project/{project_id}/quotas") +} + +/// Top offset for full-screen overlays so they clear the custom macOS title bar. +/// Windows/Linux use the native title bar (outside the webview), so no offset. +#[cfg(target_os = "macos")] +pub const OVERLAY_TITLEBAR_OFFSET: &str = "mt-[32px]"; + +#[cfg(not(target_os = "macos"))] +pub const OVERLAY_TITLEBAR_OFFSET: &str = ""; diff --git a/ui/src/views/navbar.rs b/ui/src/views/navbar.rs index 4a413dd..87f73f9 100644 --- a/ui/src/views/navbar.rs +++ b/ui/src/views/navbar.rs @@ -217,6 +217,10 @@ pub fn AppHeader(props: AppHeaderProps) -> Element { // Invite requires a selected org; all orgs can invite (unified organizations). let invite_disabled = use_memo(move || selected_context.read().is_none()); + let quota_exhausted = state.tunnel_create_quota()() + .as_ref() + .map(|q| q.is_exhausted()) + .unwrap_or(false); rsx! { // App header bar - below titlebar, contains Add tunnel button and user menu @@ -224,11 +228,20 @@ pub fn AppHeader(props: AppHeaderProps) -> Element { div { class: "max-w-4xl mx-auto flex items-center justify-between w-full px-4 py-3", // Left side: Add tunnel button if auth_state.get().is_ok() && selected_context.read().is_some() { - Button { - leading_icon: Some(IconSource::Named("plus".into())), - text: "Add New", - kind: ButtonKind::Primary, - onclick: move |_| add_tunnel_dialog_open.set(true), + // Wrap so the native tooltip still shows when the button is disabled + // (disabled elements don't fire hover events in some browsers). + span { title: if quota_exhausted { "You've hit your tunnel limit" } else { "" }, + Button { + leading_icon: Some(IconSource::Named("plus".into())), + text: "Add New", + kind: ButtonKind::Primary, + disabled: quota_exhausted, + onclick: move |_| { + if !quota_exhausted { + add_tunnel_dialog_open.set(true); + } + }, + } } } div { class: "flex-1" } diff --git a/ui/src/views/proxies_list.rs b/ui/src/views/proxies_list.rs index e7f2b55..a26b9ca 100644 --- a/ui/src/views/proxies_list.rs +++ b/ui/src/views/proxies_list.rs @@ -41,7 +41,7 @@ use crate::{ SwitchThumb, }, state::AppState, - util::tunnel_edge_portal_url, + util::{project_quotas_portal_url, tunnel_edge_portal_url}, Route, }; @@ -113,6 +113,19 @@ pub fn ProxiesList() -> Element { has_loaded_for_future.set(true); let _ = list; + match state_for_future + .tunnel_service() + .tunnel_create_quota_active() + .await + { + Ok(quota) => state_for_future.set_tunnel_create_quota(quota), + Err(err) => { + tracing::warn!("failed to load tunnel create quota: {err:#}"); + // Keep the last known quota rather than clearing — a transient + // failure should not flash the create button on/off. + } + } + if has_pending_hostname || has_pending_status { // Poll every 3 seconds when waiting for hostname provisioning tokio::select! { @@ -200,6 +213,13 @@ pub fn ProxiesList() -> Element { .ok() .and_then(|a| a.profile.first_name.clone()) .unwrap_or_else(|| "there".to_string()); + let quota_exhausted = state.tunnel_create_quota()() + .as_ref() + .map(|q| q.is_exhausted()) + .unwrap_or(false); + let quotas_url = state + .selected_context() + .map(|ctx| project_quotas_portal_url(state.datum().web_url(), &ctx.project_id)); const EMPTY_MOON: Asset = asset!("/assets/images/empty-card-moon.png"); const EMPTY_ROCKS: Asset = asset!("/assets/images/empty-card-rocks.png"); @@ -283,7 +303,12 @@ pub fn ProxiesList() -> Element { class: "w-fit text-foreground", text: "Add New", leading_icon: Some(IconSource::Named("plus".into())), - onclick: move |_| dialog_open.set(true), + disabled: quota_exhausted, + onclick: move |_| { + if !quota_exhausted { + dialog_open.set(true); + } + }, } } div { class: "rounded-lg bg-background h-48" } @@ -330,6 +355,23 @@ pub fn ProxiesList() -> Element { div { class: "text-xs mt-1 break-words", "{err}" } } } + if quota_exhausted { + div { class: "mb-4 rounded-lg border border-card-border bg-card-background px-4 py-3 shadow-card", + div { class: "text-sm font-medium text-foreground", "You've hit your tunnel limit" } + div { class: "text-xs mt-1 text-foreground/60", + "You can delete a tunnel below, or review your quotas in the portal and contact support if you need further assistance." + } + if let Some(url) = quotas_url.clone() { + button { + class: "mt-2 text-xs text-foreground/70 underline underline-offset-2 hover:text-foreground cursor-pointer", + onclick: move |_| { + let _ = that(&url); + }, + "Review quotas" + } + } + } + } {list} } AddTunnelDialog { diff --git a/ui/src/views/select_project.rs b/ui/src/views/select_project.rs index 3c7de8f..001dbbb 100644 --- a/ui/src/views/select_project.rs +++ b/ui/src/views/select_project.rs @@ -350,7 +350,7 @@ pub fn SelectProject() -> Element { } } // Overlay (full screen backdrop) - div { class: "bg-foreground/30 mt-[32px] fixed inset-0 z-50 flex items-center justify-center animate-in fade-in duration-100 backdrop-blur-[2px]", + div { class: "bg-foreground/30 {crate::util::OVERLAY_TITLEBAR_OFFSET} fixed inset-0 z-50 flex items-center justify-center animate-in fade-in duration-100 backdrop-blur-[2px]", // Form dialog centered on top div { class: "w-full max-w-lg mx-auto p-8 bg-card-background rounded-lg border border-card-border shadow-card relative z-50", div { class: "mb-6",