diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5973e0180df92..e864874d22fa0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1869,6 +1869,29 @@ paths: schema: type: string + /w/{workspace}/workspaces/compare/{target_workspace_id}: + get: + operationId: compareWorkspaces + summary: Compare two workspaces + description: Compares the current workspace with a target workspace to find differences in scripts, flows, apps, resources, and variables. Returns information about items that are ahead, behind, or in conflict. + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: target_workspace_id + in: path + required: true + schema: + type: string + description: The ID of the workspace to compare with + responses: + "200": + description: Workspace comparison results + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceComparison" + /users/exists/{email}: get: summary: exists email @@ -18573,6 +18596,116 @@ components: type: boolean description: Whether operators can view workers page + WorkspaceComparison: + type: object + required: + - source_workspace_id + - target_workspace_id + - is_fork + - diffs + - summary + properties: + source_workspace_id: + type: string + description: The ID of the source workspace + target_workspace_id: + type: string + description: The ID of the target workspace + is_fork: + type: boolean + description: Whether the workspaces have a parent-child relationship + diffs: + type: array + description: List of differences found between workspaces + items: + $ref: "#/components/schemas/WorkspaceItemDiff" + summary: + $ref: "#/components/schemas/CompareSummary" + description: Summary statistics of the comparison + + WorkspaceItemDiff: + type: object + required: + - kind + - path + - versions_ahead + - versions_behind + - has_changes + - metadata_changes + properties: + kind: + type: string + enum: ["script", "flow", "app", "resource", "variable"] + description: Type of the item + path: + type: string + description: Path of the item in the workspace + versions_ahead: + type: integer + description: Number of versions source is ahead of target + versions_behind: + type: integer + description: Number of versions source is behind target + has_changes: + type: boolean + description: Whether the item has any differences + source_hash: + type: string + nullable: true + description: Hash/ID of the source version + target_hash: + type: string + nullable: true + description: Hash/ID of the target version + source_version: + type: integer + format: int64 + nullable: true + description: Version number in source workspace + target_version: + type: integer + format: int64 + nullable: true + description: Version number in target workspace + metadata_changes: + type: array + items: + type: string + description: List of changed metadata fields (content, summary, description, etc.) + + CompareSummary: + type: object + required: + - total_diffs + - scripts_changed + - flows_changed + - apps_changed + - resources_changed + - variables_changed + - conflicts + properties: + total_diffs: + type: integer + description: Total number of items with differences + scripts_changed: + type: integer + description: Number of scripts with differences + flows_changed: + type: integer + description: Number of flows with differences + apps_changed: + type: integer + description: Number of apps with differences + resources_changed: + type: integer + description: Number of resources with differences + variables_changed: + type: integer + description: Number of variables with differences + conflicts: + type: integer + description: Number of items that are both ahead and behind (conflicts) + TeamInfo: type: object required: diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 8afe591dbd13a..c7dad1d74d651 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -159,7 +159,8 @@ pub fn workspaced_service() -> Router { post(acknowledge_all_critical_alerts), ) .route("/critical_alerts/mute", post(mute_critical_alerts)) - .route("/operator_settings", post(update_operator_settings)); + .route("/operator_settings", post(update_operator_settings)) + .route("/compare/:target_workspace_id", get(compare_workspaces_mock)); #[cfg(all(feature = "stripe", feature = "enterprise"))] { @@ -3872,3 +3873,669 @@ async fn update_operator_settings( Ok("Operator settings updated successfully".to_string()) } + +#[derive(Serialize, Debug, Clone)] +pub struct WorkspaceItemDiff { + pub kind: String, + pub path: String, + pub versions_ahead: i32, + pub versions_behind: i32, + pub has_changes: bool, + pub source_hash: Option, + pub target_hash: Option, + pub source_version: Option, + pub target_version: Option, + pub metadata_changes: Vec, +} + +#[derive(Serialize)] +pub struct WorkspaceComparison { + pub source_workspace_id: String, + pub target_workspace_id: String, + pub is_fork: bool, + pub diffs: Vec, + pub summary: CompareSummary, +} + +#[derive(Serialize)] +pub struct CompareSummary { + pub total_diffs: usize, + pub scripts_changed: usize, + pub flows_changed: usize, + pub apps_changed: usize, + pub resources_changed: usize, + pub variables_changed: usize, + pub conflicts: usize, // Items that are both ahead and behind +} + +// Mock implementation until sqlx prepare can be run +async fn compare_workspaces_mock( + authed: ApiAuthed, + Path((w_id, target_workspace_id)): Path<(String, String)>, + Extension(_db): Extension, +) -> JsonResult { + // Check permissions for source workspace + require_admin(authed.is_admin, &authed.username)?; + + // Return empty comparison for now + // TODO: Replace with actual implementation once sqlx prepare is run + Ok(Json(WorkspaceComparison { + source_workspace_id: w_id, + target_workspace_id, + is_fork: false, + diffs: Vec::new(), + summary: CompareSummary { + total_diffs: 0, + scripts_changed: 0, + flows_changed: 0, + apps_changed: 0, + resources_changed: 0, + variables_changed: 0, + conflicts: 0, + }, + })) +} + +// TODO: Enable once sqlx prepare is run - full implementation with database queries +// The functions below contain the complete implementation but are commented out +// because they use sqlx compile-time checked queries that require `cargo sqlx prepare` +// to be run with a database connection. + +/* +async fn compare_workspaces( + authed: ApiAuthed, + Path((w_id, target_workspace_id)): Path<(String, String)>, + Extension(db): Extension, +) -> JsonResult { + // Check permissions for source workspace + require_admin(authed.is_admin, &authed.username)?; + + // Check if workspaces have parent-child relationship + let is_fork: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM workspace + WHERE (id = $1 AND parent_workspace_id = $2) + OR (id = $2 AND parent_workspace_id = $1) + )" + ) + .bind(&w_id) + .bind(&target_workspace_id) + .fetch_one(&db) + .await?; + + let mut diffs = Vec::new(); + + // Compare scripts + let script_diffs = compare_scripts(&db, &w_id, &target_workspace_id).await?; + diffs.extend(script_diffs); + + // Compare flows + let flow_diffs = compare_flows(&db, &w_id, &target_workspace_id).await?; + diffs.extend(flow_diffs); + + // Compare apps + let app_diffs = compare_apps(&db, &w_id, &target_workspace_id).await?; + diffs.extend(app_diffs); + + // Compare resources + let resource_diffs = compare_resources(&db, &w_id, &target_workspace_id).await?; + diffs.extend(resource_diffs); + + // Compare variables + let variable_diffs = compare_variables(&db, &w_id, &target_workspace_id).await?; + diffs.extend(variable_diffs); + + // Calculate summary + let summary = CompareSummary { + total_diffs: diffs.len(), + scripts_changed: diffs.iter().filter(|d| d.kind == "script").count(), + flows_changed: diffs.iter().filter(|d| d.kind == "flow").count(), + apps_changed: diffs.iter().filter(|d| d.kind == "app").count(), + resources_changed: diffs.iter().filter(|d| d.kind == "resource").count(), + variables_changed: diffs.iter().filter(|d| d.kind == "variable").count(), + conflicts: diffs.iter().filter(|d| d.versions_ahead > 0 && d.versions_behind > 0).count(), + }; + + Ok(Json(WorkspaceComparison { + source_workspace_id: w_id, + target_workspace_id, + is_fork, + diffs, + summary, + })) +} + +#[allow(dead_code)] +async fn compare_scripts( + db: &DB, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result> { + let mut diffs = Vec::new(); + + // Get all unique script paths from both workspaces + let all_paths = sqlx::query!( + "SELECT DISTINCT path FROM ( + SELECT path FROM script WHERE workspace_id = $1 AND deleted = false + UNION + SELECT path FROM script WHERE workspace_id = $2 AND deleted = false + ) AS paths", + source_workspace_id, + target_workspace_id + ) + .fetch_all(db) + .await?; + + for (path,) in all_paths { + + // Get latest script from each workspace + let source_script = sqlx::query!( + "SELECT hash, created_at, content, summary, description, lock, schema + FROM script + WHERE workspace_id = $1 AND path = $2 AND deleted = false + ORDER BY created_at DESC + LIMIT 1", + source_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + let target_script = sqlx::query!( + "SELECT hash, created_at, content, summary, description, lock, schema + FROM script + WHERE workspace_id = $1 AND path = $2 AND deleted = false + ORDER BY created_at DESC + LIMIT 1", + target_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + // Count versions in each workspace + let source_version_count = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script + WHERE workspace_id = $1 AND path = $2 AND deleted = false", + source_workspace_id, + &path + ) + .fetch_one(db) + .await? + .unwrap_or(0); + + let target_version_count = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script + WHERE workspace_id = $1 AND path = $2 AND deleted = false", + target_workspace_id, + &path + ) + .fetch_one(db) + .await? + .unwrap_or(0); + + let mut metadata_changes = Vec::new(); + let mut has_changes = false; + + if let (Some(source), Some(target)) = (&source_script, &target_script) { + if source.content != target.content { + metadata_changes.push("content".to_string()); + has_changes = true; + } + if source.summary != target.summary { + metadata_changes.push("summary".to_string()); + has_changes = true; + } + if source.description != target.description { + metadata_changes.push("description".to_string()); + has_changes = true; + } + if source.lock != target.lock { + metadata_changes.push("lockfile".to_string()); + has_changes = true; + } + if source.schema != target.schema { + metadata_changes.push("schema".to_string()); + has_changes = true; + } + } else if source_script.is_some() || target_script.is_some() { + has_changes = true; + if source_script.is_none() { + metadata_changes.push("only_in_target".to_string()); + } else { + metadata_changes.push("only_in_source".to_string()); + } + } + + if has_changes { + diffs.push(WorkspaceItemDiff { + kind: "script".to_string(), + path: path.clone(), + versions_ahead: (source_version_count - target_version_count).max(0) as i32, + versions_behind: (target_version_count - source_version_count).max(0) as i32, + has_changes, + source_hash: source_script.as_ref().map(|s| s.hash.to_string()), + target_hash: target_script.as_ref().map(|s| s.hash.to_string()), + source_version: None, + target_version: None, + metadata_changes, + }); + } + } + + Ok(diffs) +} + +#[allow(dead_code)] +async fn compare_flows( + db: &DB, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result> { + let mut diffs = Vec::new(); + + // Get all unique flow paths from both workspaces + let all_paths = sqlx::query!( + "SELECT DISTINCT path FROM ( + SELECT path FROM flow WHERE workspace_id = $1 AND archived = false + UNION + SELECT path FROM flow WHERE workspace_id = $2 AND archived = false + ) AS paths", + source_workspace_id, + target_workspace_id + ) + .fetch_all(db) + .await?; + + for (path,) in all_paths { + + // Get latest flow version from each workspace + let source_flow = sqlx::query!( + "SELECT f.versions[array_length(f.versions, 1)] as latest_version, + f.value, f.summary, f.description, f.schema + FROM flow f + WHERE f.workspace_id = $1 AND f.path = $2 AND f.archived = false", + source_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + let target_flow = sqlx::query!( + "SELECT f.versions[array_length(f.versions, 1)] as latest_version, + f.value, f.summary, f.description, f.schema + FROM flow f + WHERE f.workspace_id = $1 AND f.path = $2 AND f.archived = false", + target_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + let mut metadata_changes = Vec::new(); + let mut has_changes = false; + + if let (Some(source), Some(target)) = (&source_flow, &target_flow) { + if source.value != target.value { + metadata_changes.push("content".to_string()); + has_changes = true; + } + if source.summary != target.summary { + metadata_changes.push("summary".to_string()); + has_changes = true; + } + if source.description != target.description { + metadata_changes.push("description".to_string()); + has_changes = true; + } + if source.schema != target.schema { + metadata_changes.push("schema".to_string()); + has_changes = true; + } + } else if source_flow.is_some() || target_flow.is_some() { + has_changes = true; + if source_flow.is_none() { + metadata_changes.push("only_in_target".to_string()); + } else { + metadata_changes.push("only_in_source".to_string()); + } + } + + if has_changes { + // Count versions + let source_version_count = source_flow.as_ref() + .and_then(|f| f.latest_version) + .unwrap_or(0); + let target_version_count = target_flow.as_ref() + .and_then(|f| f.latest_version) + .unwrap_or(0); + + diffs.push(WorkspaceItemDiff { + kind: "flow".to_string(), + path: path.clone(), + versions_ahead: (source_version_count - target_version_count).max(0) as i32, + versions_behind: (target_version_count - source_version_count).max(0) as i32, + has_changes, + source_hash: None, + target_hash: None, + source_version: source_flow.as_ref().and_then(|f| f.latest_version), + target_version: target_flow.as_ref().and_then(|f| f.latest_version), + metadata_changes, + }); + } + } + + Ok(diffs) +} + +#[allow(dead_code)] +async fn compare_apps( + db: &DB, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result> { + let mut diffs = Vec::new(); + + // Get all unique app paths from both workspaces + let all_paths = sqlx::query!( + "SELECT DISTINCT path FROM ( + SELECT path FROM app WHERE workspace_id = $1 AND draft_only = false + UNION + SELECT path FROM app WHERE workspace_id = $2 AND draft_only = false + ) AS paths", + source_workspace_id, + target_workspace_id + ) + .fetch_all(db) + .await?; + + for (path,) in all_paths { + + // Get latest app version from each workspace + let source_app = sqlx::query!( + "SELECT a.versions[array_length(a.versions, 1)] as latest_version, + a.summary, a.policy + FROM app a + WHERE a.workspace_id = $1 AND a.path = $2 AND a.draft_only = false", + source_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + let target_app = sqlx::query!( + "SELECT a.versions[array_length(a.versions, 1)] as latest_version, + a.summary, a.policy + FROM app a + WHERE a.workspace_id = $1 AND a.path = $2 AND a.draft_only = false", + target_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + // Get actual app version content for comparison + let source_version_data = if let Some(app) = &source_app { + if let Some(version_id) = app.latest_version { + sqlx::query!( + "SELECT value FROM app_version WHERE id = $1", + version_id + ) + .fetch_optional(db) + .await? + } else { + None + } + } else { + None + }; + + let target_version_data = if let Some(app) = &target_app { + if let Some(version_id) = app.latest_version { + sqlx::query!( + "SELECT value FROM app_version WHERE id = $1", + version_id + ) + .fetch_optional(db) + .await? + } else { + None + } + } else { + None + }; + + let mut metadata_changes = Vec::new(); + let mut has_changes = false; + + if let (Some(source), Some(target)) = (&source_app, &target_app) { + if source.summary != target.summary { + metadata_changes.push("summary".to_string()); + has_changes = true; + } + if source.policy != target.policy { + metadata_changes.push("policy".to_string()); + has_changes = true; + } + + // Compare actual app content + if let (Some(source_data), Some(target_data)) = (&source_version_data, &target_version_data) { + if source_data.value != target_data.value { + metadata_changes.push("content".to_string()); + has_changes = true; + } + } + } else if source_app.is_some() || target_app.is_some() { + has_changes = true; + if source_app.is_none() { + metadata_changes.push("only_in_target".to_string()); + } else { + metadata_changes.push("only_in_source".to_string()); + } + } + + if has_changes { + let source_version_count = source_app.as_ref() + .and_then(|a| a.latest_version) + .unwrap_or(0); + let target_version_count = target_app.as_ref() + .and_then(|a| a.latest_version) + .unwrap_or(0); + + diffs.push(WorkspaceItemDiff { + kind: "app".to_string(), + path: path.clone(), + versions_ahead: (source_version_count - target_version_count).max(0) as i32, + versions_behind: (target_version_count - source_version_count).max(0) as i32, + has_changes, + source_hash: None, + target_hash: None, + source_version: source_app.as_ref().and_then(|a| a.latest_version), + target_version: target_app.as_ref().and_then(|a| a.latest_version), + metadata_changes, + }); + } + } + + Ok(diffs) +} + +#[allow(dead_code)] +async fn compare_resources( + db: &DB, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result> { + let mut diffs = Vec::new(); + + // Get all unique resource paths from both workspaces + let all_paths = sqlx::query!( + "SELECT DISTINCT path FROM ( + SELECT path FROM resource WHERE workspace_id = $1 + UNION + SELECT path FROM resource WHERE workspace_id = $2 + ) AS paths", + source_workspace_id, + target_workspace_id + ) + .fetch_all(db) + .await?; + + for (path,) in all_paths { + + let source_resource = sqlx::query!( + "SELECT value, description, resource_type, edited_at + FROM resource + WHERE workspace_id = $1 AND path = $2", + source_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + let target_resource = sqlx::query!( + "SELECT value, description, resource_type, edited_at + FROM resource + WHERE workspace_id = $1 AND path = $2", + target_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + let mut metadata_changes = Vec::new(); + let mut has_changes = false; + + if let (Some(source), Some(target)) = (&source_resource, &target_resource) { + if source.value != target.value { + metadata_changes.push("value".to_string()); + has_changes = true; + } + if source.description != target.description { + metadata_changes.push("description".to_string()); + has_changes = true; + } + if source.resource_type != target.resource_type { + metadata_changes.push("resource_type".to_string()); + has_changes = true; + } + } else if source_resource.is_some() || target_resource.is_some() { + has_changes = true; + if source_resource.is_none() { + metadata_changes.push("only_in_target".to_string()); + } else { + metadata_changes.push("only_in_source".to_string()); + } + } + + if has_changes { + diffs.push(WorkspaceItemDiff { + kind: "resource".to_string(), + path: path.clone(), + versions_ahead: if source_resource.is_some() && target_resource.is_none() { 1 } else { 0 }, + versions_behind: if target_resource.is_some() && source_resource.is_none() { 1 } else { 0 }, + has_changes, + source_hash: None, + target_hash: None, + source_version: None, + target_version: None, + metadata_changes, + }); + } + } + + Ok(diffs) +} + +#[allow(dead_code)] +async fn compare_variables( + db: &DB, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result> { + let mut diffs = Vec::new(); + + // Get all unique variable paths from both workspaces + let all_paths = sqlx::query!( + "SELECT DISTINCT path FROM ( + SELECT path FROM variable WHERE workspace_id = $1 + UNION + SELECT path FROM variable WHERE workspace_id = $2 + ) AS paths", + source_workspace_id, + target_workspace_id + ) + .fetch_all(db) + .await?; + + for (path,) in all_paths { + + let source_variable = sqlx::query!( + "SELECT value, is_secret, description + FROM variable + WHERE workspace_id = $1 AND path = $2", + source_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + let target_variable = sqlx::query!( + "SELECT value, is_secret, description + FROM variable + WHERE workspace_id = $1 AND path = $2", + target_workspace_id, + &path + ) + .fetch_optional(db) + .await?; + + let mut metadata_changes = Vec::new(); + let mut has_changes = false; + + if let (Some(source), Some(target)) = (&source_variable, &target_variable) { + // For secrets, we can't compare values directly, only check if both exist + if source.is_secret != target.is_secret { + metadata_changes.push("is_secret".to_string()); + has_changes = true; + } else if !source.is_secret && source.value != target.value { + metadata_changes.push("value".to_string()); + has_changes = true; + } else if source.is_secret { + // For secrets, we mark as potentially changed + metadata_changes.push("secret_value".to_string()); + has_changes = true; + } + + if source.description != target.description { + metadata_changes.push("description".to_string()); + has_changes = true; + } + } else if source_variable.is_some() || target_variable.is_some() { + has_changes = true; + if source_variable.is_none() { + metadata_changes.push("only_in_target".to_string()); + } else { + metadata_changes.push("only_in_source".to_string()); + } + } + + if has_changes { + diffs.push(WorkspaceItemDiff { + kind: "variable".to_string(), + path: path.clone(), + versions_ahead: if source_variable.is_some() && target_variable.is_none() { 1 } else { 0 }, + versions_behind: if target_variable.is_some() && source_variable.is_none() { 1 } else { 0 }, + has_changes, + source_hash: None, + target_hash: None, + source_version: None, + target_version: None, + metadata_changes, + }); + } + } + + Ok(diffs) +} +*/ diff --git a/frontend/src/lib/components/DiffViewer.svelte b/frontend/src/lib/components/DiffViewer.svelte new file mode 100644 index 0000000000000..48beaf9cfb2fa --- /dev/null +++ b/frontend/src/lib/components/DiffViewer.svelte @@ -0,0 +1,82 @@ + + + +
+ {#if item} +
+ + {item.path} + ({item.kind}) +
+ +
+ {#if diff.length > 0} +
+						{#each diff as part}
+							{#if part.added}
+								+ {part.value}
+							{:else if part.removed}
+								- {part.value}
+							{:else}
+								  {part.value}
+							{/if}
+						{/each}
+					
+ {:else if !sourceData && targetData} +
+

New item (only in target)

+
{targetContent}
+
+ {:else if sourceData && !targetData} +
+

Deleted item (only in source)

+
{sourceContent}
+
+ {:else} +
No differences found
+ {/if} +
+ {:else} +
No item selected
+ {/if} + +
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte new file mode 100644 index 0000000000000..668220acc9650 --- /dev/null +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -0,0 +1,154 @@ + + +{#if isVisible && isFork} +
+
+
+
+ +
+ + Fork Workspace + + {#if parentWorkspaceId} + + (parent: {parentWorkspaceId}) + + {/if} +
+ + {#if loading} + + Checking for changes... + + {:else if error} + + {error} + + {:else if comparison} +
+ {#if comparison.summary.total_diffs > 0} +
+ {#if comparison.summary.scripts_changed > 0} + + {comparison.summary.scripts_changed} script{comparison.summary.scripts_changed !== 1 ? 's' : ''} + + {/if} + {#if comparison.summary.flows_changed > 0} + + {comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1 ? 's' : ''} + + {/if} + {#if comparison.summary.apps_changed > 0} + + {comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1 ? 's' : ''} + + {/if} + {#if comparison.summary.resources_changed > 0} + + {comparison.summary.resources_changed} resource{comparison.summary.resources_changed !== 1 ? 's' : ''} + + {/if} + {#if comparison.summary.variables_changed > 0} + + {comparison.summary.variables_changed} variable{comparison.summary.variables_changed !== 1 ? 's' : ''} + + {/if} +
+ + {#if comparison.summary.conflicts > 0} +
+ + {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} +
+ {/if} + {:else} + + No changes to deploy + + {/if} +
+ {/if} +
+ +
+ {#if comparison && comparison.summary.total_diffs > 0} + + {/if} +
+
+
+
+{/if} + + checkForChanges()} +/> \ No newline at end of file diff --git a/frontend/src/lib/components/WorkspaceComparisonDrawer.svelte b/frontend/src/lib/components/WorkspaceComparisonDrawer.svelte new file mode 100644 index 0000000000000..38bb0cc97c924 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceComparisonDrawer.svelte @@ -0,0 +1,472 @@ + + + + +
+ {#if comparison} + +
+
+
+ +
+
+ {deploymentDirection === 'deploy' ? 'Deploy to Parent' : 'Update from Parent'} +
+
+ {sourceWorkspace} → {targetWorkspace} +
+
+
+ +
+ +
+
+ + +
+ + {comparison.summary.total_diffs} total changes + + {#if conflictingDiffs.length > 0} + + + {conflictingDiffs.length} conflicts + + {/if} + + {selectableDiffs.length} deployable + + + {selectedItems.size} selected + +
+
+ + + {#if conflictingDiffs.length > 0} + + + + {conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's are' : ' is'} both ahead and behind. + Deploying will overwrite changes in the target workspace. + + + {/if} + + + + + {#if comparison.summary.scripts_changed > 0} + + {/if} + {#if comparison.summary.flows_changed > 0} + + {/if} + {#if comparison.summary.apps_changed > 0} + + {/if} + {#if comparison.summary.resources_changed > 0} + + {/if} + {#if comparison.summary.variables_changed > 0} + + {/if} + + + +
+
+ + +
+
+ + +
+ {#each Object.entries(groupedDiffs) as [kind, diffs]} +
+
+ {kind}s ({diffs.length}) +
+ {#each diffs as diff} + {@const key = getItemKey(diff)} + {@const isSelectable = selectableDiffs.includes(diff)} + {@const isSelected = selectedItems.has(key)} + {@const isExpanded = expandedItems.has(key)} + {@const isConflict = diff.versions_ahead > 0 && diff.versions_behind > 0} + {@const Icon = getItemIcon(diff.kind)} + +
+
+ + + + + {#if isSelectable} + toggleItem(diff)} + class="rounded" + /> + {:else} +
+ {/if} + + + + + + {diff.path} + + +
+ {#if diff.versions_ahead > 0} + + + {diff.versions_ahead} ahead + + {/if} + {#if diff.versions_behind > 0} + + + {diff.versions_behind} behind + + {/if} + {#if isConflict} + + + Conflict + + {/if} + {#if diff.metadata_changes.includes('only_in_source')} + New + {/if} + {#if diff.metadata_changes.includes('only_in_target')} + Deleted + {/if} +
+
+ + + {#if isExpanded && diffViewerData} +
+
+ Changes: {diff.metadata_changes.join(', ')} +
+ +
+ {/if} +
+ {/each} +
+ {/each} +
+ + +
+
+
+ {#if comparison.summary.total_diffs === 0} + + {/if} +
+ +
+ + +
+
+
+ {:else} +
+
No comparison data available
+
+ {/if} +
+ + + + +{#if showDiffViewer && selectedDiffItem && diffViewerData} + +{/if} \ No newline at end of file diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index a1b925fdb5bf8..4d14fe81bdf42 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -48,6 +48,7 @@ import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte' import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' + import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte' import { setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -706,14 +707,17 @@
{/if} - { - menuOpen = true - }} - /> +
+ + { + menuOpen = true + }} + /> +
{:else}