mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: add cosmetic dev/staging label for dev workspaces (#9959)
* feat: add cosmetic dev/staging label for dev workspaces Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: prefill dev fork name and use a link to switch its label Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: reword the dev/staging label link copy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: preview the dev/staging label as a badge in the switch link Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: show the dev/staging badge in the session diff drawer header Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspace DROP COLUMN dev_workspace_label;
|
||||
@@ -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;
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -501,6 +506,7 @@ struct UserWorkspace {
|
||||
pub operator_settings: Option<Option<serde_json::Value>>,
|
||||
pub parent_workspace_id: Option<String>,
|
||||
pub is_dev_workspace: bool,
|
||||
pub dev_workspace_label: Option<String>,
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
@@ -678,6 +684,20 @@ async fn exists_workspace(
|
||||
struct DevWorkspaceInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
dev_workspace_label: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>) -> Result<Option<String>> {
|
||||
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<Option<DevWorkspaceInfo>> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -5687,6 +5719,7 @@ async fn attach_dev_workspace(
|
||||
|
||||
// The id is interpolated into a `wm-fork/<branch>/<id>` 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<String>,
|
||||
}
|
||||
|
||||
/// 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<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<SetDevWorkspaceLabel>,
|
||||
) -> Result<String> {
|
||||
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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userWorkspaces, usersWorkspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -9,12 +9,14 @@
|
||||
import { goto } from '$app/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { GitFork, ExternalLink } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
|
||||
let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
let isDev = $derived(currentWs?.is_dev_workspace ?? false)
|
||||
let currentLabel = $derived(devLabelKey(currentWs?.dev_workspace_label))
|
||||
let parentId = $derived(currentWs?.parent_workspace_id ?? undefined)
|
||||
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
|
||||
|
||||
@@ -29,12 +31,18 @@
|
||||
// else the server result (pairing + detach still available to a prod admin).
|
||||
let pairedDev = $derived(
|
||||
canonicalDev
|
||||
? { id: canonicalDev.id, name: canonicalDev.name, isMember: true }
|
||||
? {
|
||||
id: canonicalDev.id,
|
||||
name: canonicalDev.name,
|
||||
isMember: true,
|
||||
label: canonicalDev.dev_workspace_label
|
||||
}
|
||||
: devWorkspaceResource.current
|
||||
? {
|
||||
id: devWorkspaceResource.current.id,
|
||||
name: devWorkspaceResource.current.name,
|
||||
isMember: false
|
||||
isMember: false,
|
||||
label: devWorkspaceResource.current.dev_workspace_label
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
@@ -42,7 +50,10 @@
|
||||
let selectedDevId = $state<string | undefined>(undefined)
|
||||
let lockProdDeploy = $state(true)
|
||||
let lockProdForking = $state(true)
|
||||
// Cosmetic display label chosen when attaching an existing workspace as dev.
|
||||
let attachLabel = $state<'dev' | 'staging'>('dev')
|
||||
let busy = $state(false)
|
||||
let labelBusy = $state(false)
|
||||
|
||||
// A standalone root workspace, or an existing fork of this prod (same family), can be attached.
|
||||
// A fork parented to a different workspace can't (the backend rejects a parent that isn't this
|
||||
@@ -82,10 +93,11 @@
|
||||
requestBody: {
|
||||
dev_workspace_id: selectedDevId,
|
||||
lock_prod_deploy: lockProdDeploy,
|
||||
lock_prod_forking: lockProdForking
|
||||
lock_prod_forking: lockProdForking,
|
||||
dev_workspace_label: attachLabel
|
||||
}
|
||||
})
|
||||
sendUserToast(`Attached ${selectedDevId} as dev workspace`)
|
||||
sendUserToast(`Attached ${selectedDevId} as ${attachLabel} workspace`)
|
||||
selectedDevId = undefined
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
@@ -95,6 +107,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function setLabel(label: 'dev' | 'staging') {
|
||||
if (!$workspaceStore || label === devLabelKey(currentWs?.dev_workspace_label)) return
|
||||
labelBusy = true
|
||||
try {
|
||||
await WorkspaceService.setDevWorkspaceLabel({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: { dev_workspace_label: label }
|
||||
})
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to update display label: ${e?.body ?? e}`, true)
|
||||
} finally {
|
||||
labelBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function detach(devId: string) {
|
||||
if (!$workspaceStore) return
|
||||
busy = true
|
||||
@@ -116,9 +144,20 @@
|
||||
{#if isDev && parentId}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm">
|
||||
This is a <b>dev workspace</b> paired with root workspace <b>{parentId}</b>. Promote changes
|
||||
from the home page banner or the Compare & Deploy page.
|
||||
This is a <b>{devLabelNoun(currentWs?.dev_workspace_label)}</b> paired with root workspace
|
||||
<b>{parentId}</b>. Promote changes from the home page banner or the Compare & Deploy page.
|
||||
</p>
|
||||
<div class="text-2xs text-secondary">
|
||||
Cosmetic label: <Badge color="indigo" small>{devBadgeText(currentLabel)}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
disabled={labelBusy}
|
||||
class="text-secondary hover:text-primary hover:underline disabled:opacity-50"
|
||||
onclick={() => setLabel(currentLabel === 'staging' ? 'dev' : 'staging')}
|
||||
>
|
||||
Change to {currentLabel === 'staging' ? 'dev' : 'staging'}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -132,8 +171,8 @@
|
||||
{:else if pairedDev}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm">
|
||||
This workspace's dev workspace is <b>{pairedDev.name}</b> ({pairedDev.id}). Edits to this
|
||||
workspace are redirected there.
|
||||
This workspace's {devLabelNoun(pairedDev.label)} is <b>{pairedDev.name}</b> ({pairedDev.id}).
|
||||
Edits to this workspace are redirected there.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if pairedDev.isMember}
|
||||
@@ -142,7 +181,7 @@
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => switchWorkspace(pairedDev.id)}
|
||||
>
|
||||
Go to dev workspace
|
||||
Go to {devLabelNoun(pairedDev.label)}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button color="red" disabled={busy} onclick={() => detach(pairedDev.id)}>Detach</Button>
|
||||
@@ -168,6 +207,16 @@
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="text-2xs text-secondary">
|
||||
Cosmetic label: <Badge color="indigo" small>{devBadgeText(attachLabel)}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
class="text-secondary hover:text-primary hover:underline"
|
||||
onclick={() => (attachLabel = attachLabel === 'staging' ? 'dev' : 'staging')}
|
||||
>
|
||||
Change to {attachLabel === 'staging' ? 'dev' : 'staging'}
|
||||
</button>
|
||||
</div>
|
||||
<Toggle
|
||||
bind:checked={lockProdDeploy}
|
||||
options={{
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
|
||||
|
||||
let loading = $state(false)
|
||||
let comparison: WorkspaceComparison | undefined = $state(undefined)
|
||||
@@ -170,7 +171,9 @@
|
||||
<GitFork class="w-4 h-4 text-accent" />
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-blue-900 dark:text-blue-100">
|
||||
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
|
||||
{isDevWorkspace
|
||||
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
|
||||
: 'Fork of'}
|
||||
<b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
isRuleActive
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { Alert, Button } from './common'
|
||||
@@ -40,12 +41,14 @@
|
||||
<div class="my-2">
|
||||
<Alert
|
||||
type="info"
|
||||
title={canonicalDev ? 'Edits happen in the dev workspace' : 'Workspace protection active'}
|
||||
title={canonicalDev
|
||||
? `Edits happen in the ${devLabelNoun(canonicalDev.dev_workspace_label)}`
|
||||
: 'Workspace protection active'}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if canonicalDev}
|
||||
<p>
|
||||
Edits to this workspace are made in its dev workspace
|
||||
Edits to this workspace are made in its {devLabelNoun(canonicalDev.dev_workspace_label)}
|
||||
<b>{canonicalDev.name}</b> ({canonicalDev.id}) and promoted here.
|
||||
</p>
|
||||
<div>
|
||||
@@ -58,7 +61,7 @@
|
||||
if (canonicalDev) switchWorkspace(canonicalDev.id)
|
||||
}}
|
||||
>
|
||||
Go to dev workspace
|
||||
Go to {devLabelNoun(canonicalDev.dev_workspace_label)}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import WorkspaceDiffDrawer from './WorkspaceDiffDrawer.svelte'
|
||||
import { ArrowRight, GitFork, Pencil } from 'lucide-svelte'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import { useSessionDeployModel } from './sessionDeployModel.svelte'
|
||||
import type { DeployItem } from './sessionDeployModel'
|
||||
|
||||
@@ -92,6 +94,9 @@
|
||||
<span class="font-medium truncate" title={ws?.name ?? workspaceId}>
|
||||
{ws?.name ?? workspaceId}
|
||||
</span>
|
||||
{#if ws?.is_dev_workspace}
|
||||
<Badge color="indigo" small>{devBadgeText(ws.dev_workspace_label)}</Badge>
|
||||
{/if}
|
||||
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
|
||||
<span class="font-medium truncate" title={parentWs?.name ?? parentWorkspaceId}>
|
||||
{parentWs?.name ?? parentWorkspaceId}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
} from './sessionState.svelte'
|
||||
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import { Building, ChevronDown, GitFork } from 'lucide-svelte'
|
||||
|
||||
let { session }: { session: Session } = $props()
|
||||
@@ -67,7 +68,7 @@
|
||||
{pendingFork?.name ?? currentWs?.name ?? effectiveId ?? 'Pick workspace'}
|
||||
</span>
|
||||
{#if !pendingFork && currentWs?.is_dev_workspace}
|
||||
<Badge color="indigo" small>dev</Badge>
|
||||
<Badge color="indigo" small>{devBadgeText(currentWs.dev_workspace_label)}</Badge>
|
||||
{/if}
|
||||
{#if pendingFork}
|
||||
<span class="text-2xs text-tertiary italic shrink-0">(new)</span>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import InputError from '$lib/components/InputError.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { devBadgeText, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { Building, Check, GitFork, Plus } from 'lucide-svelte'
|
||||
|
||||
type PendingFork = { parent_workspace_id: string; id: string; name: string }
|
||||
@@ -414,7 +415,7 @@
|
||||
disabled={rootDisabled}
|
||||
title={rootDisabled
|
||||
? devOfRoot
|
||||
? `${root.name} is locked. Run in its dev workspace instead.`
|
||||
? `${root.name} is locked. Run in its ${devLabelNoun(devOfRoot.dev_workspace_label)} instead.`
|
||||
: `${root.name} is locked for direct deploys.`
|
||||
: undefined}
|
||||
class={`${rowBase} ${rootDisabled ? 'opacity-50 cursor-not-allowed' : ''} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${!rootDisabled && keyArrowPos === rootIdx ? 'bg-surface-hover' : !rootDisabled ? 'hover:bg-surface-hover' : ''}`}
|
||||
@@ -440,7 +441,7 @@
|
||||
<GitFork size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{f.name}</span>
|
||||
{#if f.is_dev_workspace}
|
||||
<Badge color="indigo" small>dev</Badge>
|
||||
<Badge color="indigo" small>{devBadgeText(f.dev_workspace_label)}</Badge>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
import type { MenubarBuilders } from '@melt-ui/svelte'
|
||||
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
import { devBadgeText, devLabelWord } from '$lib/utils/devWorkspaceLabel'
|
||||
|
||||
interface Props {
|
||||
isCollapsed?: boolean
|
||||
@@ -133,7 +134,7 @@
|
||||
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
|
||||
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
|
||||
sublabel={parentWorkspace?.name
|
||||
? `${forkedWorkspace.is_dev_workspace ? 'Dev workspace of' : 'Fork of'} ${parentWorkspace.name}`
|
||||
? `${forkedWorkspace.is_dev_workspace ? `${devLabelWord(forkedWorkspace.dev_workspace_label)} workspace of` : 'Fork of'} ${parentWorkspace.name}`
|
||||
: undefined}
|
||||
{isCollapsed}
|
||||
color={$workspaceColor}
|
||||
@@ -177,6 +178,7 @@
|
||||
workspaceColor={workspace.color}
|
||||
{isForked}
|
||||
isDevWorkspace={workspace.is_dev_workspace}
|
||||
devWorkspaceLabel={workspace.dev_workspace_label}
|
||||
{parentName}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -191,7 +193,7 @@
|
||||
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
|
||||
</div>
|
||||
{#if workspace.is_dev_workspace}
|
||||
<Badge color="indigo">dev</Badge>
|
||||
<Badge color="indigo">{devBadgeText(workspace.dev_workspace_label)}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import WorkspaceIcon from './WorkspaceIcon.svelte'
|
||||
import WorkspaceCard from './WorkspaceCard.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
|
||||
interface ExtendedWorkspace extends UserWorkspace {
|
||||
_children?: ExtendedWorkspace[]
|
||||
@@ -120,6 +121,7 @@
|
||||
workspaceColor={workspace.color}
|
||||
{isForked}
|
||||
isDevWorkspace={workspace.is_dev_workspace}
|
||||
devWorkspaceLabel={workspace.dev_workspace_label}
|
||||
parentName={workspace.parent_workspace_id ?? undefined}
|
||||
size={12}
|
||||
/>
|
||||
@@ -135,7 +137,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#if workspace.is_dev_workspace}
|
||||
<Badge color="indigo">dev</Badge>
|
||||
<Badge color="indigo">{devBadgeText(workspace.dev_workspace_label)}</Badge>
|
||||
{/if}
|
||||
<span class="text-secondary text-xs">-</span>
|
||||
{#if workspace.id === 'admins'}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { Building, GitFork } from 'lucide-svelte'
|
||||
import { Tooltip } from '$lib/components/meltComponents'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
|
||||
|
||||
interface Props {
|
||||
workspaceColor?: string
|
||||
isForked?: boolean
|
||||
isDevWorkspace?: boolean
|
||||
devWorkspaceLabel?: string | null
|
||||
parentName?: string
|
||||
size?: number
|
||||
}
|
||||
@@ -15,6 +17,7 @@
|
||||
workspaceColor,
|
||||
isForked = false,
|
||||
isDevWorkspace = false,
|
||||
devWorkspaceLabel,
|
||||
parentName,
|
||||
size = 14
|
||||
}: Props = $props()
|
||||
@@ -27,7 +30,7 @@
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
{#if isForked && parentName}
|
||||
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
|
||||
{isDevWorkspace ? `${devLabelWord(devWorkspaceLabel)} workspace of` : 'Fork of'}
|
||||
{parentName}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
import { usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { resource } from 'runed'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
@@ -58,6 +59,12 @@
|
||||
$effect(() => {
|
||||
copyMembers = createAsDevWorkspace
|
||||
})
|
||||
// Cosmetic display label for the new dev workspace: 'dev' | 'staging'. Purely visual (badge text +
|
||||
// wording); reset when the dev toggle is turned off.
|
||||
let devWorkspaceLabel = $state<'dev' | 'staging'>('dev')
|
||||
$effect(() => {
|
||||
if (!createAsDevWorkspace) devWorkspaceLabel = 'dev'
|
||||
})
|
||||
|
||||
// The dev-workspace option is only offered when forking a root workspace that doesn't already
|
||||
// have one: a workspace gets at most one dev, and dev workspaces don't nest (a dev of a dev).
|
||||
@@ -239,6 +246,7 @@
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
is_dev_workspace: createAsDevWorkspace,
|
||||
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
|
||||
// Send the lock intent in this first phase too so the backend can reject a non-admin's
|
||||
// locked-dev request before any branch is created (avoids dangling branches).
|
||||
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
|
||||
@@ -308,6 +316,7 @@
|
||||
forked_datatables: forkedDatatables,
|
||||
shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [],
|
||||
is_dev_workspace: createAsDevWorkspace,
|
||||
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
|
||||
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
|
||||
lock_prod_forking: createAsDevWorkspace && lockProdForking,
|
||||
copy_members: copyMembers
|
||||
@@ -324,7 +333,7 @@
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
createAsDevWorkspace
|
||||
? `Created dev workspace ${effectiveForkId} for ${$workspaceStore}`
|
||||
? `Created ${devWorkspaceLabel === 'staging' ? 'staging' : 'dev'} workspace ${effectiveForkId} for ${$workspaceStore}`
|
||||
: `Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`
|
||||
)
|
||||
|
||||
@@ -467,6 +476,21 @@
|
||||
run(() => {
|
||||
id = name.toLowerCase().replace(/\s/gi, '-')
|
||||
})
|
||||
// When creating a dev workspace, prefill the fork name with `<root>-dev` / `<root>-stg` (the effect
|
||||
// above slugifies it into the id). Only fill an empty field or one still holding a prior suggestion,
|
||||
// so a user-typed name is never overwritten; flipping Dev<->Staging updates the suffix, and turning
|
||||
// the dev toggle back off clears the suggestion.
|
||||
let lastAutoDevName = $state<string | undefined>(undefined)
|
||||
$effect(() => {
|
||||
const target =
|
||||
createAsDevWorkspace && $workspaceStore
|
||||
? `${$workspaceStore}-${devWorkspaceLabel === 'staging' ? 'stg' : 'dev'}`
|
||||
: ''
|
||||
if (name === '' || name === lastAutoDevName) {
|
||||
name = target
|
||||
lastAutoDevName = target === '' ? undefined : target
|
||||
}
|
||||
})
|
||||
run(() => {
|
||||
validateName(id)
|
||||
})
|
||||
@@ -598,6 +622,18 @@
|
||||
<div class="flex flex-col gap-2 pt-1">
|
||||
<Toggle bind:checked={createAsDevWorkspace} options={{ right: 'Dev workspace' }} />
|
||||
{#if createAsDevWorkspace}
|
||||
<div class="text-2xs text-secondary">
|
||||
Cosmetic label: <Badge color="indigo" small>{devBadgeText(devWorkspaceLabel)}</Badge
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-secondary hover:text-primary hover:underline"
|
||||
onclick={() =>
|
||||
(devWorkspaceLabel = devWorkspaceLabel === 'staging' ? 'dev' : 'staging')}
|
||||
>
|
||||
Change to {devWorkspaceLabel === 'staging' ? 'dev' : 'staging'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-emphasis"
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface UserWorkspace {
|
||||
operator_settings?: OperatorSettings
|
||||
parent_workspace_id?: string | null
|
||||
is_dev_workspace?: boolean
|
||||
dev_workspace_label?: string | null
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Cosmetic display label for a dev workspace. The paired-fork machinery is unchanged; this only
|
||||
// swaps the badge text and identity wording so a team can present the environment as "staging"
|
||||
// instead of "dev". A null/unknown stored value renders as "dev" (the default).
|
||||
|
||||
export type DevWorkspaceLabelKey = 'dev' | 'staging'
|
||||
|
||||
/** Resolve the stored `dev_workspace_label` to a known key; anything unset/unknown is 'dev'. */
|
||||
export function devLabelKey(label: string | null | undefined): DevWorkspaceLabelKey {
|
||||
return label === 'staging' ? 'staging' : 'dev'
|
||||
}
|
||||
|
||||
/** Short badge text: 'dev' or 'stg'. */
|
||||
export function devBadgeText(label: string | null | undefined): string {
|
||||
return devLabelKey(label) === 'staging' ? 'stg' : 'dev'
|
||||
}
|
||||
|
||||
/** Capitalized word for identity wording, e.g. `${devLabelWord(l)} workspace of X`. */
|
||||
export function devLabelWord(label: string | null | undefined): string {
|
||||
return devLabelKey(label) === 'staging' ? 'Staging' : 'Dev'
|
||||
}
|
||||
|
||||
/** Lowercase noun phrase for prose, e.g. "made in its ${devLabelNoun(l)}". */
|
||||
export function devLabelNoun(label: string | null | undefined): string {
|
||||
return devLabelKey(label) === 'staging' ? 'staging workspace' : 'dev workspace'
|
||||
}
|
||||
Reference in New Issue
Block a user