diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index d29a18c691..e7ed0aee65 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, true, true ] diff --git a/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json b/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json deleted file mode 100644 index 7de0416a12..0000000000 --- a/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT item_kind, path FROM ws_specific WHERE workspace_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "item_kind", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87" -} diff --git a/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json b/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json deleted file mode 100644 index 2b5b68dfae..0000000000 --- a/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "label", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "scopes", - "type_info": "TextArray" - }, - { - "ordinal": 3, - "name": "workspace_id", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "owner", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "expiration", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true, - true, - true, - true, - false, - true, - true - ] - }, - "hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026" -} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json b/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json deleted file mode 100644 index 0a39db6822..0000000000 --- a/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4" -} diff --git a/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json b/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json deleted file mode 100644 index 415544ece9..0000000000 --- a/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT s.item_kind, s.path\n FROM ws_specific s\n WHERE s.workspace_id = $1\n AND (\n (s.item_kind = 'resource' AND EXISTS (\n SELECT 1 FROM resource r\n WHERE r.workspace_id = s.workspace_id AND r.path = s.path\n ))\n OR (s.item_kind = 'variable' AND EXISTS (\n SELECT 1 FROM variable v\n WHERE v.workspace_id = s.workspace_id AND v.path = s.path\n ))\n )\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "item_kind", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac" -} diff --git a/backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json b/backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json new file mode 100644 index 0000000000..fce125c6d5 --- /dev/null +++ b/backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bool_and", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9" +} diff --git a/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json b/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json deleted file mode 100644 index 327032afb5..0000000000 --- a/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $2)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb" -} diff --git a/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json b/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json new file mode 100644 index 0000000000..f38c023cb3 --- /dev/null +++ b/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH potential AS (\n SELECT email, operator FROM usr\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "authors!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "operators!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455" +} diff --git a/backend/.sqlx/query-f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd.json b/backend/.sqlx/query-f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd.json new file mode 100644 index 0000000000..e4125ddab9 --- /dev/null +++ b/backend/.sqlx/query-f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT memory, worker, native_mode FROM worker_ping WHERE ping_at > now() - interval '2 minutes'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "memory", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "worker", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "native_mode", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ad6df82501..91d8581950 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2 +c6cd1afe2d9e04809b30751cd1687b28a65e62b1 diff --git a/backend/src/ee_oss.rs b/backend/src/ee_oss.rs index 2aefaf7431..4dcaf2437b 100644 --- a/backend/src/ee_oss.rs +++ b/backend/src/ee_oss.rs @@ -8,6 +8,6 @@ pub async fn set_license_key(_license_key: String, _db: Option<&windmill_common: } #[cfg(all(feature = "enterprise", not(feature = "private")))] -pub async fn verify_license_key() -> () { +pub async fn verify_license_key(_db: Option<&windmill_common::db::DB>) -> () { // Implementation is not open source } diff --git a/backend/src/main.rs b/backend/src/main.rs index 3049064624..9a2709e9a9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1441,7 +1441,7 @@ Windmill Community Edition {GIT_VERSION} tracing::error!("Failed to reload license key on agent: {e:#}"); } #[cfg(feature = "enterprise")] - ee_oss::verify_license_key().await; + ee_oss::verify_license_key(conn.as_sql()).await; } // update min version explicitly. diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 59a2f47ed0..0ddc02b256 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -2373,7 +2373,19 @@ pub async fn monitor_db( let verify_license_key_f = async { #[cfg(feature = "enterprise")] if !initial_load { - verify_license_key().await; + verify_license_key(conn.as_sql()).await; + } + }; + + let enforce_offline_caps_f = async { + #[cfg(feature = "enterprise")] + if server_mode && !initial_load { + if let Some(db) = conn.as_sql() { + // Cheap: one query for workers active in the last 2 minutes. + if let Err(e) = windmill_common::ee_oss::enforce_offline_caps(db).await { + tracing::error!("Failed to enforce offline license caps: {e:#}"); + } + } } }; @@ -2522,6 +2534,7 @@ pub async fn monitor_db( vacuum_queue_f, expose_queue_metrics_f, verify_license_key_f, + enforce_offline_caps_f, worker_groups_alerts_f, jobs_waiting_alerts_f, low_disk_alerts_f, @@ -2853,6 +2866,11 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> { IS_SECURE.store(is_secure, Ordering::Relaxed); + #[cfg(feature = "enterprise")] + { + crate::ee_oss::verify_license_key(conn.as_sql()).await; + } + Ok(()) } diff --git a/backend/windmill-api-settings/src/ee_oss.rs b/backend/windmill-api-settings/src/ee_oss.rs index d0e0f69b62..92b3d6ad53 100644 --- a/backend/windmill-api-settings/src/ee_oss.rs +++ b/backend/windmill-api-settings/src/ee_oss.rs @@ -8,7 +8,11 @@ use anyhow::anyhow; pub async fn validate_license_key( _license_key: String, _db: Option<&windmill_common::DB>, -) -> anyhow::Result<(String, bool)> { +) -> anyhow::Result<( + String, + bool, + Option, +)> { // Implementation is not open source Err(anyhow!("License can't be validated in Windmill CE")) } diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index f027046808..09b52a3d22 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -37,13 +37,13 @@ use axum::{ use serde_json::json; use serde::{Deserialize, Serialize}; +use windmill_ai::ai_cache::bump_instance_ai_config_revision; #[cfg(feature = "enterprise")] use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel}; #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::secret_backend::{ AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; -use windmill_ai::ai_cache::bump_instance_ai_config_revision; use windmill_common::{ email_oss::send_email_plain_text, error::{self, JsonResult, Result}, @@ -118,6 +118,8 @@ pub fn global_service() -> Router { get(get_latest_key_renewal_attempt), ) .route("/renew_license_key", post(renew_license_key)) + .route("/offline_license_status", get(get_offline_license_status)) + .route("/instance_hash", get(get_instance_hash)) .route("/customer_portal", post(create_customer_portal_session)) .route("/test_critical_channels", post(test_critical_channels)) .route("/critical_alerts", get(get_critical_alerts)) @@ -340,7 +342,7 @@ pub async fn test_license_key( Json(TestKey { license_key }): Json, ) -> error::Result { require_super_admin(&db, &authed.email).await?; - let (_, expired) = validate_license_key(license_key, Some(&db)).await?; + let (_, expired, _offline_meta) = validate_license_key(license_key, Some(&db)).await?; if expired { Err(error::Error::BadRequest("Expired license key".to_string())) @@ -349,6 +351,53 @@ pub async fn test_license_key( } } +#[derive(serde::Serialize)] +pub struct InstanceHash { + pub instance_hash: Option, +} + +/// Returns the live cap status for an offline license, or `null` when no +/// offline license is loaded. Used by the superadmin settings panel. +pub async fn get_offline_license_status( + Extension(db): Extension, + authed: ApiAuthed, +) -> error::JsonResult> { + require_super_admin(&db, &authed.email).await?; + + let offline = (**windmill_common::ee_oss::LICENSE_OFFLINE_METADATA.load()).clone(); + let is_offline = matches!(&offline, Some(m) if m.is_offline()); + + if !is_offline { + return Ok(Json(None)); + } + + #[cfg(feature = "enterprise")] + let cap = windmill_common::ee_oss::enforce_offline_caps(&db) + .await + .map_err(|e| error::Error::internal_err(format!("enforce_offline_caps: {e:#}")))?; + #[cfg(not(feature = "enterprise"))] + let cap: Option = None; + + Ok(Json(cap)) +} + +/// Returns the per-instance binding hash that goes into offline license keys. +/// Admin invokes via `curl` with their personal token when requesting a key +/// from support. +pub async fn get_instance_hash( + Extension(db): Extension, + authed: ApiAuthed, +) -> error::JsonResult { + require_super_admin(&db, &authed.email).await?; + #[cfg(feature = "enterprise")] + let hash = windmill_common::ee_oss::compute_instance_hash(&db) + .await + .map_err(|e| error::Error::internal_err(format!("compute_instance_hash: {e:#}")))?; + #[cfg(not(feature = "enterprise"))] + let hash: Option = None; + Ok(Json(InstanceHash { instance_hash: hash })) +} + pub async fn get_local_settings( Extension(db): Extension, authed: ApiAuthed, diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 94e2026ad3..95c7157d61 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5233,6 +5233,13 @@ async fn invite_user( nu.email = nu.email.to_lowercase(); + #[cfg(feature = "enterprise")] + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? + { + return Err(Error::BadRequest(msg)); + } + let mut tx = db.begin().await?; let already_in_workspace = sqlx::query_scalar!( @@ -5306,6 +5313,13 @@ async fn add_user( nu.email = nu.email.to_lowercase(); + #[cfg(feature = "enterprise")] + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? + { + return Err(Error::BadRequest(msg)); + } + let mut tx = db.begin().await?; let already_exists_email = sqlx::query_scalar!( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 82c8b06cf7..16448c622b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1779,6 +1779,63 @@ paths: schema: type: string + /settings/offline_license_status: + get: + summary: get cap-usage status for the currently-loaded offline license + description: | + Returns the live cap status (seats used vs cap, current CU vs cap) for + the offline license key currently in use. Returns `null` if no offline + license is loaded. Super-admin only. + operationId: getOfflineLicenseStatus + tags: + - setting + responses: + "200": + description: cap status (or null when no offline license) + content: + application/json: + schema: + type: object + nullable: true + properties: + seats_used: + type: number + description: Author-equivalent seats consumed (authors + 0.5 × operators) + seats_cap: + type: integer + author_count: + type: integer + operator_count: + type: integer + current_cu: + type: number + description: Sum of CU rate across workers that pinged in the last 2 minutes. + cu_cap: + type: number + cu_over_cap: + type: boolean + + /settings/instance_hash: + get: + summary: per-instance binding hash for offline license issuance + description: | + Returns the hash a superadmin shares with Windmill support when + requesting an offline license. Super-admin only. + operationId: getInstanceHash + tags: + - setting + responses: + '200': + description: instance hash + content: + application/json: + schema: + type: object + properties: + instance_hash: + type: string + nullable: true + /settings/customer_portal: post: summary: create customer portal session diff --git a/backend/windmill-api/src/ee_oss.rs b/backend/windmill-api/src/ee_oss.rs index a1f7a54d20..e5a7f71cbc 100644 --- a/backend/windmill-api/src/ee_oss.rs +++ b/backend/windmill-api/src/ee_oss.rs @@ -10,7 +10,11 @@ use anyhow::anyhow; pub async fn validate_license_key( _license_key: String, _db: Option<&crate::db::DB>, -) -> anyhow::Result<(String, bool)> { +) -> anyhow::Result<( + String, + bool, + Option, +)> { // Implementation is not open source Err(anyhow!("License can't be validated in Windmill CE")) } diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 15d9e7cb5e..e68bc00c6e 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -18,6 +18,60 @@ lazy_static::lazy_static! { pub static ref LICENSE_KEY_VALID: AtomicBool = AtomicBool::new(true); pub static ref LICENSE_KEY_ID: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee("".to_string()); pub static ref LICENSE_KEY: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee("".to_string()); + pub static ref LICENSE_OFFLINE_METADATA: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); + pub static ref LICENSE_OFFLINE_OVER_CU_CAP: AtomicBool = AtomicBool::new(false); + pub static ref LICENSE_OFFLINE_LAST_STATUS: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); + pub static ref LICENSE_OFFLINE_LAST_CHECKED_AT: arc_swap::ArcSwap>> = arc_swap::ArcSwap::from_pointee(None); +} + +#[cfg(not(feature = "private"))] +#[derive(Clone, Debug, Deserialize, serde::Serialize)] +pub struct OfflineMetadata { + pub v: u32, + pub kind: String, + pub hash: String, + pub seats: i64, + pub cu_limit: f64, +} + +#[cfg(not(feature = "private"))] +impl OfflineMetadata { + pub fn is_offline(&self) -> bool { + self.kind == "offline" + } +} + +#[cfg(not(feature = "private"))] +#[derive(Clone, Debug, serde::Serialize)] +pub struct OfflineCapStatus { + pub seats_used: f64, + pub seats_cap: i64, + pub author_count: i64, + pub operator_count: i64, + pub current_cu: f64, + pub cu_cap: f64, + pub cu_over_cap: bool, +} + +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn check_seat_cap_for_new_user( + _db: &DB, + _email: &str, + _new_user_is_operator: bool, +) -> anyhow::Result> { + Ok(None) +} + +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result> { + // Implementation is not open source + Ok(None) +} + +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn enforce_offline_caps(_db: &DB) -> anyhow::Result> { + // Implementation is not open source + Ok(None) } #[cfg(not(feature = "private"))] diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 7c7e3f79ed..39884e3d35 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -121,6 +121,36 @@ pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000; pub const SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers"; +/// Canonical form of a base URL, used as one of the inputs to the offline-license +/// instance hash (`compute_instance_hash`). +/// +/// Rules: lowercase scheme and host, drop default ports (80/443), strip path/query/fragment, +/// strip trailing slash. If URL parsing fails, falls back to a best-effort lowercase + +/// trailing-slash strip so two semantically-equivalent inputs still produce the same +/// canonical form. +pub fn canonical_base_url(input: &str) -> String { + let trimmed = input.trim(); + if trimmed.is_empty() { + return String::new(); + } + match url::Url::parse(trimmed) { + Ok(u) => { + let scheme = u.scheme().to_ascii_lowercase(); + let host = u + .host_str() + .map(|h| h.to_ascii_lowercase()) + .unwrap_or_default(); + let port = match (u.port(), scheme.as_str()) { + (Some(80), "http") | (Some(443), "https") => String::new(), + (Some(p), _) => format!(":{p}"), + (None, _) => String::new(), + }; + format!("{scheme}://{host}{port}") + } + Err(_) => trimmed.trim_end_matches('/').to_ascii_lowercase(), + } +} + /// Checks if the user is allowed to preserve on_behalf_of values (admin or deployer). pub fn can_preserve_on_behalf_of(authed: &impl db::Authable) -> bool { authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP) diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index efe5481e1d..17d9cfd804 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -447,7 +447,9 @@ pub async fn get_license_id_or_uid<'c, E: sqlx::Executor<'c, Database = Postgres } } -async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(db: E) -> Result { +pub async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, +) -> Result { let uid_value = sqlx::query_scalar!( "SELECT value FROM global_settings WHERE name = $1", UNIQUE_ID_SETTING diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index f0097b877a..4964fb9c1d 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -56,6 +56,16 @@ attempted_at: string } | null = $state(null) + let offlineCapStatus: { + seats_used: number + seats_cap: number + author_count: number + operator_count: number + current_cu: number + cu_cap: number + cu_over_cap: boolean + } | null = $state(null) + function showSetting(setting: string, values: Record) { if (setting == 'dev_instance') { if (values['license_key'] == undefined) { @@ -72,6 +82,14 @@ latestKeyRenewalAttempt = await SettingService.getLatestKeyRenewalAttempt() } + async function reloadLicenseStatus() { + try { + offlineCapStatus = (await SettingService.getOfflineLicenseStatus()) as any + } catch { + offlineCapStatus = null + } + } + async function reloadLicenseKey() { $values['license_key'] = await SettingService.getGlobal({ key: 'license_key' @@ -80,7 +98,10 @@ $effect(() => { if (setting.key == 'license_key') { - untrack(() => reloadKeyrenewalAttemptInfo()) + untrack(() => { + reloadKeyrenewalAttemptInfo() + reloadLicenseStatus() + }) } }) @@ -430,7 +451,7 @@ {/if} {/if} - {#if latestKeyRenewalAttempt} + {#if latestKeyRenewalAttempt && !offlineCapStatus} {@const attemptedAt = new Date(latestKeyRenewalAttempt.attempted_at).toLocaleString()} {@const isTrial = latestKeyRenewalAttempt.result.startsWith('error: trial:')}
@@ -500,11 +521,41 @@
{/if} + {#if offlineCapStatus} + {@const cap = offlineCapStatus} + {@const seatsOver = cap.seats_used > cap.seats_cap} + {@const cuOver = cap.cu_over_cap} +
+
+ {#if seatsOver} + + {:else} + + {/if} + + Seats: {cap.seats_used.toFixed(1)} / {cap.seats_cap} + +
+
+ {#if cuOver} + + {:else} + + {/if} + + CUs: {cap.current_cu.toFixed(2)} / {cap.cu_cap.toFixed(2)} + +
+
+ {/if} + {#if valid || expiration}
- + {#if !offlineCapStatus} + + {/if} diff --git a/frontend/src/routes/(root)/(logged)/workers/+page.svelte b/frontend/src/routes/(root)/(logged)/workers/+page.svelte index 9bc4d1d710..c2de0b81d8 100644 --- a/frontend/src/routes/(root)/(logged)/workers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workers/+page.svelte @@ -194,19 +194,6 @@ } } - let defaultTagPerWorkspace: boolean | undefined = $state(undefined) - let defaultTagWorkspaces: string[] = $state([]) - async function loadDefaultTagsPerWorkspace() { - try { - defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace() - defaultTagWorkspaces = (await SettingService.getGlobal({ - key: DEFAULT_TAGS_WORKSPACES_SETTING - })) as any - } catch (err) { - sendUserToast(`Could not load default tag per workspace setting: ${err}`, true) - } - } - function parseLicenseKey(key: string): { valid: boolean expiration?: Date @@ -247,13 +234,11 @@ const { valid, expiration } = parseLicenseKey(licenseKey) if (!valid && expiration) { - // License is expired sendUserToast( `Enterprise license key expired on ${expiration.toLocaleDateString()}. Please renew your license key to continue using Windmill.`, true ) } else if (expiration) { - // Check if expires within 7 days const daysUntilExpiration = Math.floor( (expiration.getTime() - Date.now()) / (1000 * 60 * 60 * 24) ) @@ -266,11 +251,23 @@ } } } catch (err) { - // Silently fail - don't show errors for license check console.error('Failed to check license expiration:', err) } } + let defaultTagPerWorkspace: boolean | undefined = $state(undefined) + let defaultTagWorkspaces: string[] = $state([]) + async function loadDefaultTagsPerWorkspace() { + try { + defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace() + defaultTagWorkspaces = (await SettingService.getGlobal({ + key: DEFAULT_TAGS_WORKSPACES_SETTING + })) as any + } catch (err) { + sendUserToast(`Could not load default tag per workspace setting: ${err}`, true) + } + } + onMount(() => { intervalId = setInterval(() => { loadWorkers()