diff --git a/backend/.sqlx/query-530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25.json b/backend/.sqlx/query-530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25.json new file mode 100644 index 0000000000..465ee41633 --- /dev/null +++ b/backend/.sqlx/query-530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n VALUES ($1, $2, $3, $4, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25" +} diff --git a/backend/.sqlx/query-63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703.json b/backend/.sqlx/query-63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703.json new file mode 100644 index 0000000000..3959f4e8cc --- /dev/null +++ b/backend/.sqlx/query-63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703" +} diff --git a/backend/.sqlx/query-868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52.json b/backend/.sqlx/query-868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52.json new file mode 100644 index 0000000000..34d99a7e61 --- /dev/null +++ b/backend/.sqlx/query-868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "dev_workspace_label", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52" +} diff --git a/backend/.sqlx/query-8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4.json b/backend/.sqlx/query-8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4.json new file mode 100644 index 0000000000..3f8deb8835 --- /dev/null +++ b/backend/.sqlx/query-8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,\n CASE WHEN $5 THEN dev_workspace_label ELSE NULL END\n FROM workspace WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4" +} diff --git a/backend/.sqlx/query-9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01.json b/backend/.sqlx/query-9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01.json new file mode 100644 index 0000000000..a29b5f0924 --- /dev/null +++ b/backend/.sqlx/query-9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01" +} diff --git a/backend/.sqlx/query-af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322.json b/backend/.sqlx/query-af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322.json new file mode 100644 index 0000000000..c21a21163e --- /dev/null +++ b/backend/.sqlx/query-af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "parent_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_dev_workspace", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "dev_workspace_label", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "operator_settings", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "disabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + true, + null, + false + ] + }, + "hash": "af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322" +} diff --git a/backend/migrations/20260706094033_add_dev_workspace_label.down.sql b/backend/migrations/20260706094033_add_dev_workspace_label.down.sql new file mode 100644 index 0000000000..0b9b81636a --- /dev/null +++ b/backend/migrations/20260706094033_add_dev_workspace_label.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace DROP COLUMN dev_workspace_label; diff --git a/backend/migrations/20260706094033_add_dev_workspace_label.up.sql b/backend/migrations/20260706094033_add_dev_workspace_label.up.sql new file mode 100644 index 0000000000..951f1ec3c9 --- /dev/null +++ b/backend/migrations/20260706094033_add_dev_workspace_label.up.sql @@ -0,0 +1,4 @@ +-- Cosmetic display label for a dev workspace: NULL/'dev' render as "dev", 'staging' renders as "stg". +-- Only meaningful when is_dev_workspace = true; changes nothing about behavior (locking, promote and +-- compare all key off is_dev_workspace / parent_workspace_id). The value is validated in the handler. +ALTER TABLE workspace ADD COLUMN dev_workspace_label VARCHAR; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 228b8d1831..8d53562635 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -156,6 +156,7 @@ pub fn workspaced_service() -> Router { .route("/create_fork", post(create_workspace_fork)) .route("/attach_dev_workspace", post(attach_dev_workspace)) .route("/detach_dev_workspace", post(detach_dev_workspace)) + .route("/set_dev_workspace_label", post(set_dev_workspace_label)) .route("/get_dev_workspace", get(get_dev_workspace)) .route("/change_workspace_name", post(change_workspace_name)) .route("/change_workspace_color", post(change_workspace_color)) @@ -472,6 +473,10 @@ struct CreateWorkspaceFork { /// the team can work in it. Defaults off; the dev-workspace UI defaults it on. #[serde(default)] copy_members: bool, + /// Cosmetic display label for the dev workspace: 'dev' | 'staging'. Purely visual (badge text + + /// wording); ignored for non-dev forks. None defaults to 'dev'. + #[serde(default)] + dev_workspace_label: Option, } #[derive(Deserialize)] @@ -501,6 +506,7 @@ struct UserWorkspace { pub operator_settings: Option>, pub parent_workspace_id: Option, pub is_dev_workspace: bool, + pub dev_workspace_label: Option, pub disabled: bool, } @@ -678,6 +684,20 @@ async fn exists_workspace( struct DevWorkspaceInfo { id: String, name: String, + dev_workspace_label: Option, +} + +/// Normalize/validate the cosmetic dev-workspace display label. None or 'dev' both render as "dev"; +/// 'staging' renders as "stg". Anything else is rejected. Stored explicitly ('dev'/'staging') so it +/// round-trips, but a NULL column is treated as 'dev' on the read side too. +fn normalize_dev_workspace_label(label: Option) -> Result> { + match label.as_deref() { + None | Some("dev") => Ok(Some("dev".to_string())), + Some("staging") => Ok(Some("staging".to_string())), + Some(other) => Err(Error::BadRequest(format!( + "invalid dev workspace label '{other}' (expected 'dev' or 'staging')" + ))), + } } /// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace @@ -691,7 +711,7 @@ async fn get_dev_workspace( ) -> JsonResult> { let dev = sqlx::query_as!( DevWorkspaceInfo, - "SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", + "SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", &w_id ) .fetch_optional(&db) @@ -3697,7 +3717,7 @@ async fn user_workspaces( let workspaces = sqlx::query_as!( UserWorkspace, "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, - workspace.is_dev_workspace, + workspace.is_dev_workspace, workspace.dev_workspace_label, CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, usr.disabled FROM workspace @@ -5187,6 +5207,8 @@ async fn create_workspace_fork_branch( // that second call. Validating early lets a bad request fail before any branch is created. if nw.is_dev_workspace { validate_dev_workspace_id(&nw.id)?; + // Reject a bad cosmetic label before any git branch is created (acted on in create_workspace_fork). + normalize_dev_workspace_label(nw.dev_workspace_label.clone())?; ensure_dev_parent_is_root(&db, &w_id).await?; // Reject before creating any git branch if the parent already has a dev workspace, // otherwise the deferred branch-creation job leaves a dangling branch on the synced repos. @@ -5418,6 +5440,12 @@ async fn create_workspace_fork( validate_fork_workspace_id(&nw.id)?; } validate_workspace_name(&nw.name)?; + // Cosmetic label only applies to dev workspaces; a non-dev fork stores NULL. + let dev_workspace_label = if nw.is_dev_workspace { + normalize_dev_workspace_label(nw.dev_workspace_label.clone())? + } else { + None + }; // Check the id conflict before the CE workspace-count limit so that // re-using a taken (possibly archived) fork id reports the actual // conflict instead of a misleading "maximum number of workspaces" error. @@ -5495,13 +5523,14 @@ async fn create_workspace_fork( sqlx::query!( "INSERT INTO workspace - (id, name, owner, parent_workspace_id, is_dev_workspace) - VALUES ($1, $2, $3, $4, $5)", + (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label) + VALUES ($1, $2, $3, $4, $5, $6)", forked_id, nw.name, authed.email, parent_workspace_id, nw.is_dev_workspace, + dev_workspace_label, ) .execute(&mut *tx) .await?; @@ -5634,6 +5663,9 @@ struct AttachDevWorkspace { lock_prod_deploy: bool, #[serde(default)] lock_prod_forking: bool, + /// Cosmetic display label for the attached dev workspace: 'dev' | 'staging'. None defaults to 'dev'. + #[serde(default)] + dev_workspace_label: Option, } #[derive(Deserialize)] @@ -5687,6 +5719,7 @@ async fn attach_dev_workspace( // The id is interpolated into a `wm-fork//` branch name like any fork. validate_dev_workspace_id(&dev_w_id)?; + let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?; let dev = sqlx::query!( r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, @@ -5754,9 +5787,10 @@ async fn attach_dev_workspace( let mut tx = db.begin().await?; sqlx::query!( - "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2", + "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2", &prod_w_id, - &dev_w_id + &dev_w_id, + dev_workspace_label, ) .execute(&mut *tx) .await?; @@ -5822,6 +5856,51 @@ async fn attach_dev_workspace( )) } +#[derive(Deserialize)] +struct SetDevWorkspaceLabel { + #[serde(default)] + dev_workspace_label: Option, +} + +/// Change the cosmetic display label ('dev' | 'staging') of the current workspace, which must itself +/// be a dev workspace. Purely visual (badge text + wording); requires admin of the dev workspace. +async fn set_dev_workspace_label( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let label = normalize_dev_workspace_label(req.dev_workspace_label)?; + + let mut tx = db.begin().await?; + let updated = sqlx::query_scalar!( + "UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id", + label, + &w_id, + ) + .fetch_optional(&mut *tx) + .await?; + if updated.is_none() { + return Err(Error::BadRequest(format!( + "Workspace '{w_id}' is not a dev workspace" + ))); + } + + audit_log( + &mut *tx, + &authed, + "workspaces.set_dev_workspace_label", + ActionKind::Update, + &w_id, + label.as_deref(), + None, + ) + .await?; + tx.commit().await?; + Ok(format!("Updated dev workspace label for {w_id}")) +} + /// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the /// prod lock. The workspace keeps its `parent_workspace_id` (it remains an ordinary fork). async fn detach_dev_workspace( diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 9fbaad2bf3..6b4ace5f0a 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -91,9 +91,10 @@ pub(crate) async fn change_workspace_id( .await?; } sqlx::query!( - "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace) + "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label) SELECT $1, $2, owner, false, premium, - CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5 + CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5, + CASE WHEN $5 THEN dev_workspace_label ELSE NULL END FROM workspace WHERE id = $3", &rw.new_id, &rw.new_name, @@ -1095,7 +1096,10 @@ pub(crate) async fn delete_workspace( // effort: failures are logged — the workspace row is already gone, and broken storage // credentials must not have made it undeletable. for e in cleanup_fork_ducklake_namespaces(&db, &w_id, fork_ducklake_cleanups).await { - tracing::warn!("deleted workspace {w_id}: ducklake namespace cleanup: {}", e.msg); + tracing::warn!( + "deleted workspace {w_id}: ducklake namespace cleanup: {}", + e.msg + ); } if let Some(parent) = dev_lock_parent { diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 16a1f88ffc..ebeeb999f9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1205,6 +1205,9 @@ paths: type: boolean lock_prod_forking: type: boolean + dev_workspace_label: + type: string + enum: [dev, staging] required: - dev_workspace_id responses: @@ -1263,10 +1266,40 @@ paths: type: string name: type: string + dev_workspace_label: + type: string + nullable: true + description: "Cosmetic display label ('dev' | 'staging'); null defaults to 'dev'" required: - id - name + /w/{workspace}/workspaces/set_dev_workspace_label: + post: + summary: set the cosmetic display label (dev/staging) of this dev workspace + operationId: setDevWorkspaceLabel + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dev_workspace_label: + type: string + enum: [dev, staging] + responses: + "200": + description: dev workspace label updated + content: + text/plain: + schema: + type: string + /workspaces/exists: post: summary: exists workspace @@ -28180,6 +28213,10 @@ components: nullable: true is_dev_workspace: type: boolean + dev_workspace_label: + type: string + nullable: true + description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'" created_by: type: string nullable: true @@ -28249,6 +28286,10 @@ components: copy_members: type: boolean description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it" + dev_workspace_label: + type: string + enum: [dev, staging] + description: "Cosmetic display label for the dev workspace (badge text + wording only); ignored for non-dev forks" required: - id - name diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte index bdba23c57f..e6295d24cd 100644 --- a/frontend/src/lib/components/DevWorkspaceSetting.svelte +++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte @@ -1,7 +1,7 @@