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",