From fa4f608da2fb6c06d5c7f58131ccebec40fbd7c8 Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Fri, 12 Dec 2025 23:52:26 +0100 Subject: [PATCH] feat: workspace forks merge UI (#7333) * feat: Add workspace diff viewer and deployment UI for forked workspaces - Add backend endpoint for comparing two workspaces - Implement comparison logic for scripts, flows, apps, resources, variables - Create ForkWorkspaceBanner component to detect and display fork status - Build WorkspaceComparisonDrawer for detailed diff viewing and deployment - Add DiffViewer component for line-by-line comparisons - Support bidirectional deployment (fork to parent or parent to fork) - Add conflict detection for items that are both ahead and behind - Include delete fork option when no changes remain Note: Backend implementation requires sqlx prepare to be run for full functionality Co-authored-by: windmill-internal-app[bot] * Fix banner and use wworkspace_diff table * satisfactory UI WIP * UI complete * Deploy button * Comaprison and reset tally * compare all types of items * Show summaries * Disable buttons during deployment * Auto select all on entering page * Change migration to have 'exists_in' cols * Show new and deleted items * frontend fixes * Block delpoyment if changes don't match (new chagnes detected) * Message to block whe changes are behind * Skip workspaces pre-migration * Remove unused code * Fix apps comparison * Only return changes where user has visibility * No deploy button if no access to all changes * Prepare sqlx * Remove redundant message * CI: update ee repo ref * eereporef bis * Small tweaks * Remove unused struct * Remove unused refactor component * Fix npm run check * Remove unused component * chore: update ee-repo-ref to bbf406edc222199ca2e6076da12c376fb4ff28c5 This commit updates the EE repository reference after PR #359 was merged in windmill-ee-private. Previous ee-repo-ref: 6aae845c5629ae32da43dbfbdc4566e5bf90fb1e New ee-repo-ref: bbf406edc222199ca2e6076da12c376fb4ff28c5 Automated by sync-ee-ref workflow. --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...2f896c83bb39e1cb52fe3c29c1a224c3859c2.json | 19 + ...405b698a368626da42dd9f4ce9d0681d016a1.json | 59 + ...15f102122665bc3cc15718831b991052b4caf.json | 17 + ...cf548a50d533dc1bef58dfd0ecc163c9920f3.json | 23 + ...30fd0a3b4e6e746c659da1767edafd139d45e.json | 35 + ...472581b9d90d330957539c0019ec2e7d9a68a.json | 22 + ...3c89130c709a57aca712e2af996bd4a4fa101.json | 23 + ...c8cbea692ef6c1a838b970f382ee72db492d3.json | 23 + ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...703b87daa538cf3a72007649bbb074f56131e.json | 35 + ...5f67c4e1fc217f216f609e36a9944a7807b33.json | 35 + ...19d1165860c5daf0de76fb2ac2201de11553c.json | 17 + ...107ceab2c5b17258958bde03b3e492bb2790e.json | 59 + ...450f0721f888d18475fae862d675d40779a30.json | 17 + ...15b0e36938a5853285be485647bc8cc846973.json | 15 + ...0cb549a34b96554ae1872355b90304f5dcb76.json | 4 +- ...1e24f3c6f8b1628965438a9360c073d70924e.json | 23 + ...1a9c07d211351195c399f6ff25c1dc7adb01a.json | 41 + ...80fc516207c8a474446a1ee09e8324f85ba09.json | 23 + backend/ee-repo-ref.txt | 2 +- ...1113112115_merge_ui_tally_changes.down.sql | 3 + ...251113112115_merge_ui_tally_changes.up.sql | 24 + .../20251117222148_email_enabled.down.sql | 1 + backend/windmill-api/openapi.yaml | 148 +++ backend/windmill-api/src/workspaces.rs | 617 +++++++++- backend/windmill-git-sync/src/lib.rs | 26 + .../lib/components/CompareWorkspaces.svelte | 1007 +++++++++++++++++ .../lib/components/ForkWorkspaceBanner.svelte | 192 ++++ .../lib/components/common/table/Row.svelte | 109 +- .../components/common/table/RowIcon.svelte | 15 +- .../src/routes/(root)/(logged)/+layout.svelte | 18 +- .../src/routes/(root)/(logged)/+page.svelte | 2 + .../(root)/(logged)/forks/compare/+page.js | 5 + .../(logged)/forks/compare/+page.svelte | 76 ++ 34 files changed, 2683 insertions(+), 54 deletions(-) create mode 100644 backend/.sqlx/query-034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2.json create mode 100644 backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json create mode 100644 backend/.sqlx/query-0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf.json create mode 100644 backend/.sqlx/query-2d1ba3c92c0385c530934082284cf548a50d533dc1bef58dfd0ecc163c9920f3.json create mode 100644 backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json create mode 100644 backend/.sqlx/query-4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a.json create mode 100644 backend/.sqlx/query-50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101.json create mode 100644 backend/.sqlx/query-5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3.json create mode 100644 backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json create mode 100644 backend/.sqlx/query-819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33.json create mode 100644 backend/.sqlx/query-90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c.json create mode 100644 backend/.sqlx/query-a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e.json create mode 100644 backend/.sqlx/query-ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30.json create mode 100644 backend/.sqlx/query-af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973.json create mode 100644 backend/.sqlx/query-c5897802334f09596a0297f9b9a1e24f3c6f8b1628965438a9360c073d70924e.json create mode 100644 backend/.sqlx/query-dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a.json create mode 100644 backend/.sqlx/query-ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09.json create mode 100644 backend/migrations/20251113112115_merge_ui_tally_changes.down.sql create mode 100644 backend/migrations/20251113112115_merge_ui_tally_changes.up.sql create mode 100644 frontend/src/lib/components/CompareWorkspaces.svelte create mode 100644 frontend/src/lib/components/ForkWorkspaceBanner.svelte create mode 100644 frontend/src/routes/(root)/(logged)/forks/compare/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte diff --git a/backend/.sqlx/query-034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2.json b/backend/.sqlx/query-034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2.json new file mode 100644 index 0000000000..25130fb447 --- /dev/null +++ b/backend/.sqlx/query-034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6\n WHERE path = $3 AND kind = $4 AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2" +} diff --git a/backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json b/backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json new file mode 100644 index 0000000000..50ed7549e8 --- /dev/null +++ b/backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "kind", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "ahead", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "behind", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "has_changes", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "exists_in_source", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "exists_in_fork", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true + ] + }, + "hash": "0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1" +} diff --git a/backend/.sqlx/query-0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf.json b/backend/.sqlx/query-0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf.json new file mode 100644 index 0000000000..6544f5dbc1 --- /dev/null +++ b/backend/.sqlx/query-0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf" +} diff --git a/backend/.sqlx/query-2d1ba3c92c0385c530934082284cf548a50d533dc1bef58dfd0ecc163c9920f3.json b/backend/.sqlx/query-2d1ba3c92c0385c530934082284cf548a50d533dc1bef58dfd0ecc163c9920f3.json new file mode 100644 index 0000000000..40701734d9 --- /dev/null +++ b/backend/.sqlx/query-2d1ba3c92c0385c530934082284cf548a50d533dc1bef58dfd0ecc163c9920f3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM script\n WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2d1ba3c92c0385c530934082284cf548a50d533dc1bef58dfd0ecc163c9920f3" +} diff --git a/backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json b/backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json new file mode 100644 index 0000000000..2939d5ac88 --- /dev/null +++ b/backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value, is_secret, description\n FROM variable\n WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_secret", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e" +} diff --git a/backend/.sqlx/query-4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a.json b/backend/.sqlx/query-4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a.json new file mode 100644 index 0000000000..67f6bff897 --- /dev/null +++ b/backend/.sqlx/query-4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM workspace WHERE parent_workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a" +} diff --git a/backend/.sqlx/query-50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101.json b/backend/.sqlx/query-50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101.json new file mode 100644 index 0000000000..9be1f7b782 --- /dev/null +++ b/backend/.sqlx/query-50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM app\n WHERE workspace_id = $1 AND path = ANY($2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101" +} diff --git a/backend/.sqlx/query-5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3.json b/backend/.sqlx/query-5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3.json new file mode 100644 index 0000000000..960c2d7f3f --- /dev/null +++ b/backend/.sqlx/query-5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM resource\n WHERE workspace_id = $1 AND path = ANY($2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json b/backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json new file mode 100644 index 0000000000..e9919c7da4 --- /dev/null +++ b/backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e" +} diff --git a/backend/.sqlx/query-819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33.json b/backend/.sqlx/query-819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33.json new file mode 100644 index 0000000000..8720b09d70 --- /dev/null +++ b/backend/.sqlx/query-819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value, description, resource_type\n FROM resource\n WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "resource_type", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + true, + false + ] + }, + "hash": "819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33" +} diff --git a/backend/.sqlx/query-90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c.json b/backend/.sqlx/query-90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c.json new file mode 100644 index 0000000000..14948f62d7 --- /dev/null +++ b/backend/.sqlx/query-90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n SELECT $1, unnest($2::varchar[]), $3, $4, 0, 1, NULL\n ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)\n DO UPDATE SET\n behind = workspace_diff.behind + 1,\n has_changes = NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "VarcharArray", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c" +} diff --git a/backend/.sqlx/query-a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e.json b/backend/.sqlx/query-a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e.json new file mode 100644 index 0000000000..7c2918993b --- /dev/null +++ b/backend/.sqlx/query-a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT hash, created_at, content, summary, description, lock, schema\n FROM script\n WHERE workspace_id = $1 AND path = $2 AND archived = false\n ORDER BY created_at DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "content", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "summary", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "lock", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "schema", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e" +} diff --git a/backend/.sqlx/query-ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30.json b/backend/.sqlx/query-ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30.json new file mode 100644 index 0000000000..af15f7a1c3 --- /dev/null +++ b/backend/.sqlx/query-ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ($1, $2, $3, $4, 1, 0, NULL)\n ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)\n DO UPDATE SET\n ahead = workspace_diff.ahead + 1,\n has_changes = NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30" +} diff --git a/backend/.sqlx/query-af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973.json b/backend/.sqlx/query-af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973.json new file mode 100644 index 0000000000..e7026271b0 --- /dev/null +++ b/backend/.sqlx/query-af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM workspace_diff WHERE has_changes = false AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973" +} diff --git a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json index 54e94cfb8f..99269c9851 100644 --- a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json +++ b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json @@ -18,8 +18,8 @@ "Left": [] }, "nullable": [ - false, - true + true, + false ] }, "hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76" diff --git a/backend/.sqlx/query-c5897802334f09596a0297f9b9a1e24f3c6f8b1628965438a9360c073d70924e.json b/backend/.sqlx/query-c5897802334f09596a0297f9b9a1e24f3c6f8b1628965438a9360c073d70924e.json new file mode 100644 index 0000000000..70d02aa1d2 --- /dev/null +++ b/backend/.sqlx/query-c5897802334f09596a0297f9b9a1e24f3c6f8b1628965438a9360c073d70924e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM flow\n WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c5897802334f09596a0297f9b9a1e24f3c6f8b1628965438a9360c073d70924e" +} diff --git a/backend/.sqlx/query-dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a.json b/backend/.sqlx/query-dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a.json new file mode 100644 index 0000000000..51a178a3e3 --- /dev/null +++ b/backend/.sqlx/query-dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value, summary, description, schema\n FROM flow\n WHERE workspace_id = $1 AND path = $2 AND archived = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "summary", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "schema", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a" +} diff --git a/backend/.sqlx/query-ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09.json b/backend/.sqlx/query-ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09.json new file mode 100644 index 0000000000..118ea1d4e0 --- /dev/null +++ b/backend/.sqlx/query-ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM variable\n WHERE workspace_id = $1 AND path = ANY($2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 7748571172..efccc47b4b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -55e8296c3758b031e50d7ebdaef7c33666f02385 +bbf406edc222199ca2e6076da12c376fb4ff28c5 diff --git a/backend/migrations/20251113112115_merge_ui_tally_changes.down.sql b/backend/migrations/20251113112115_merge_ui_tally_changes.down.sql new file mode 100644 index 0000000000..e9af407b11 --- /dev/null +++ b/backend/migrations/20251113112115_merge_ui_tally_changes.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +DROP TABLE IF EXISTS skip_workspace_diff_tally; +DROP TABLE IF EXISTS workspace_diff; diff --git a/backend/migrations/20251113112115_merge_ui_tally_changes.up.sql b/backend/migrations/20251113112115_merge_ui_tally_changes.up.sql new file mode 100644 index 0000000000..6b26bd5fdb --- /dev/null +++ b/backend/migrations/20251113112115_merge_ui_tally_changes.up.sql @@ -0,0 +1,24 @@ +-- Add up migration script here +CREATE TABLE workspace_diff ( + source_workspace_id VARCHAR(50) NOT NULL, + fork_workspace_id VARCHAR(50) NOT NULL, + path VARCHAR(255) NOT NULL, + kind VARCHAR(50) NOT NULL, + ahead INTEGER NOT NULL DEFAULT 0, + behind INTEGER NOT NULL DEFAULT 0, + has_changes BOOLEAN DEFAULT NULL, + exists_in_source BOOLEAN DEFAULT NULL, + exists_in_fork BOOLEAN DEFAULT NULL, + PRIMARY KEY (source_workspace_id, fork_workspace_id, path, kind) +); + +-- Create table to track workspaces that should be excluded from diff tallying +-- Old workspaces that are linked but have already diverged need to be skipped +CREATE TABLE skip_workspace_diff_tally ( + workspace_id VARCHAR(50) PRIMARY KEY, + added_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Populate with all existing workspaces to exclude them from new tallying logic +INSERT INTO skip_workspace_diff_tally (workspace_id) +SELECT id FROM workspace; diff --git a/backend/migrations/20251117222148_email_enabled.down.sql b/backend/migrations/20251117222148_email_enabled.down.sql index d2f607c5b8..2bd12fa991 100644 --- a/backend/migrations/20251117222148_email_enabled.down.sql +++ b/backend/migrations/20251117222148_email_enabled.down.sql @@ -1 +1,2 @@ -- Add down migration script here +ALTER TABLE email_trigger DROP COLUMN enabled; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6fb2796db8..ff0dbd25c7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2024,6 +2024,52 @@ 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" + + /w/{workspace}/workspaces/reset_diff_tally/{fork_workspace_id}: + post: + operationId: resetDiffTally + summary: Resets the ahead and behind deployement counter after a deployement + description: This endpoint should be called after a fork deployement + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: fork_workspace_id + in: path + required: true + schema: + type: string + description: The ID of the workspace to compare with + + responses: + "200": + description: status + content: + application/json: + schema: {} + /users/exists/{email}: get: summary: exists email @@ -20927,6 +20973,108 @@ components: type: boolean description: Whether operators can view workers page + WorkspaceComparison: + type: object + required: + - all_ahead_items_visible + - all_behind_items_visible + - skipped_comparison + - diffs + - summary + properties: + all_ahead_items_visible: + type: boolean + description: All items with changes ahead are visible by the user of the request. + all_behind_items_visible: + type: boolean + description: All items with changes behind are visible by the user of the request. + skipped_comparison: + type: boolean + description: Whether the comparison was skipped. This happens with old forks that where not being kept track of + 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 + - ahead + - behind + - has_changes + - exists_in_source + - exists_in_fork + 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 + ahead: + type: integer + description: Number of versions source is ahead of target + behind: + type: integer + description: Number of versions source is behind target + has_changes: + type: boolean + description: Whether the item has any differences + exists_in_source: + type: boolean + description: If the item exists in the source workspace + exists_in_fork: + type: boolean + description: If the item exists in the fork workspace + + CompareSummary: + type: object + required: + - total_diffs + - total_ahead + - total_behind + - scripts_changed + - flows_changed + - apps_changed + - resources_changed + - variables_changed + - conflicts + properties: + total_diffs: + type: integer + description: Total number of items with differences + total_ahead: + type: integer + description: Total number of ahead changes + total_behind: + type: integer + description: Total number of behind changes + 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 8c1a83ce13..eec1efb6d2 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -29,7 +29,7 @@ use regex::Regex; use hex; use sha2::{Digest, Sha256}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; @@ -172,11 +172,16 @@ 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( "/create_workspace_fork_branch", post(create_workspace_fork_branch), ) - .route("/operator_settings", post(update_operator_settings)); + .route( + "/reset_diff_tally/:fork_workspace_id", + post(reset_workspace_diffs), + ) + .route("/compare/:target_workspace_id", get(compare_workspaces)); #[cfg(all(feature = "stripe", feature = "enterprise"))] { @@ -4042,3 +4047,611 @@ async fn update_operator_settings( Ok("Operator settings updated successfully".to_string()) } + +#[derive(Serialize)] +pub struct WorkspaceComparison { + pub all_ahead_items_visible: bool, + pub all_behind_items_visible: bool, + pub skipped_comparison: bool, + pub diffs: Vec, + pub summary: CompareSummary, +} + +#[derive(Serialize, Default)] +pub struct CompareSummary { + pub total_diffs: usize, + pub total_ahead: usize, + pub total_behind: 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 +} + +async fn reset_workspace_diffs( + authed: ApiAuthed, + Path((w_id, target_workspace_id)): Path<(String, String)>, + Extension(db): Extension, + Extension(user_db): Extension, +) -> JsonResult<()> { + // Needed to compute the has_changes: Option. Otherwise it will be None, and the query will not hit the items + let _ = compare_workspaces( + authed, + Path((w_id.clone(), target_workspace_id.clone())), + Extension(db.clone()), + Extension(user_db), + ) + .await?; + + sqlx::query!( + "DELETE FROM workspace_diff WHERE has_changes = false AND ( + (source_workspace_id = $1 AND fork_workspace_id = $2) + OR (source_workspace_id = $2 AND fork_workspace_id =$1) + )", + target_workspace_id, + w_id, + ) + .execute(&db) + .await?; + + Ok(Json(())) +} + +#[derive(Serialize, Debug, Clone, Default)] +pub struct WorkspaceDiffRow { + kind: String, + path: String, + ahead: i32, + behind: i32, + has_changes: Option, + exists_in_source: Option, + exists_in_fork: Option, +} + +async fn compare_workspaces( + authed: ApiAuthed, + Path((source_workspace_id, fork_workspace_id)): Path<(String, String)>, + Extension(db): Extension, + Extension(user_db): Extension, +) -> JsonResult { + // require_admin(authed.is_admin, &authed.username)?; + + let skipped_comparison: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM skip_workspace_diff_tally + WHERE workspace_id = $1 + )", + ) + .bind(&fork_workspace_id) + .fetch_one(&db) + .await?; + + if skipped_comparison { + return Ok(Json(WorkspaceComparison { + all_ahead_items_visible: true, + all_behind_items_visible: true, + skipped_comparison, + diffs: vec![], + summary: Default::default(), + })); + } + + let diff_items = sqlx::query_as!( + WorkspaceDiffRow, + "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff + WHERE source_workspace_id = $1 AND fork_workspace_id = $2", + source_workspace_id, + fork_workspace_id, + ) + .fetch_all(&db) + .await?; + + let mut confirmed_diffs = vec![]; + for item in diff_items { + if let Some(has_changes) = item.has_changes { + if has_changes { + confirmed_diffs.push(item); + } + continue; + } + + let item_comparison = match item.kind.as_str() { + "script" => Some( + compare_two_scripts(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + "flow" => Some( + compare_two_flows(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + "app" => Some( + compare_two_apps(&db, &source_workspace_id, &fork_workspace_id, &item.path).await?, + ), + "resource" => Some( + compare_two_resources(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + "variable" => Some( + compare_two_variables(&db, &source_workspace_id, &fork_workspace_id, &item.path) + .await?, + ), + k => { + tracing::error!("Received unrecognized item kind `{k}` with path: `{}` while computing diff of {fork_workspace_id} and {source_workspace_id} workspaces. Skipping this item", item.path); + None + // Some(ItemComparison { + // has_changes: true, + // exists_in_source: true, + // exists_in_fork: true, + // }) + } + }; + + if let Some(item_comparison) = item_comparison { + if item_comparison.has_changes { + sqlx::query!( + "UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6 + WHERE path = $3 AND kind = $4 AND ( + (source_workspace_id = $1 AND fork_workspace_id = $2) + OR (source_workspace_id = $2 AND fork_workspace_id =$1) + )", + source_workspace_id, + fork_workspace_id, + item.path, + item.kind, + item_comparison.exists_in_source, + item_comparison.exists_in_fork, + ) + .execute(&db) + .await?; + confirmed_diffs.push(WorkspaceDiffRow { + has_changes: Some(item_comparison.has_changes), + exists_in_source: Some(item_comparison.exists_in_source), + exists_in_fork: Some(item_comparison.exists_in_fork), + ..item + }); + } else { + sqlx::query!( + "DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND ( + (source_workspace_id = $1 AND fork_workspace_id = $2) + OR (source_workspace_id = $2 AND fork_workspace_id =$1) + )", + source_workspace_id, + fork_workspace_id, + item.path, + item.kind, + ) + .execute(&db) + .await?; + } + } + } + + let visible_diffs = filter_visible_diffs( + &confirmed_diffs, + &source_workspace_id, + &fork_workspace_id, + user_db.begin(&authed).await?, + ) + .await?; + + let summary = CompareSummary { + total_diffs: visible_diffs.len(), + total_ahead: visible_diffs + .iter() + .map(|s| s.ahead) + .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)), + total_behind: visible_diffs + .iter() + .map(|s| s.behind) + .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)), + scripts_changed: visible_diffs.iter().filter(|s| s.kind == "script").count(), + flows_changed: visible_diffs.iter().filter(|s| s.kind == "flow").count(), + apps_changed: visible_diffs.iter().filter(|s| s.kind == "app").count(), + resources_changed: visible_diffs + .iter() + .filter(|s| s.kind == "resource") + .count(), + variables_changed: visible_diffs + .iter() + .filter(|s| s.kind == "variable") + .count(), + conflicts: visible_diffs + .iter() + .filter(|s| s.ahead > 0 && s.behind > 0) + .count(), + }; + + let all_ahead_items_visible = summary.total_ahead + == confirmed_diffs + .iter() + .map(|s| s.ahead) + .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)); + let all_behind_items_visible = summary.total_behind + == confirmed_diffs + .iter() + .map(|s| s.behind) + .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)); + + return Ok(Json(WorkspaceComparison { + all_ahead_items_visible, + all_behind_items_visible, + skipped_comparison: false, + diffs: visible_diffs, + summary, + })); +} + +async fn filter_visible_diffs( + confirmed_diffs: &[WorkspaceDiffRow], + source_workspace_id: &str, + fork_workspace_id: &str, + mut tx: Transaction<'static, Postgres>, +) -> Result> { + // Step 1: Group paths by (workspace, kind) + let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut fork_items: HashMap<&str, Vec<&str>> = HashMap::new(); + + for diff in confirmed_diffs { + if diff.exists_in_source.unwrap_or(false) { + source_items.entry(&diff.kind).or_default().push(&diff.path); + } + if diff.exists_in_fork.unwrap_or(false) { + fork_items.entry(&diff.kind).or_default().push(&diff.path); + } + } + + // Step 2: Batch query for each (workspace, kind) combination + let source_visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?; + let fork_visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?; + + // Step 3: Filter diffs based on visibility + let visible_diffs: Vec = confirmed_diffs + .iter() + .filter(|diff| { + let v = (diff.kind.to_string(), diff.path.to_string()); + let source_ok = !diff.exists_in_source.unwrap_or(false) || source_visible.contains(&v); + let fork_ok = !diff.exists_in_fork.unwrap_or(false) || fork_visible.contains(&v); + source_ok && fork_ok + }) + .cloned() + .collect(); + + Ok(visible_diffs) +} + +async fn query_visible_items<'c>( + tx: &mut Transaction<'c, Postgres>, + workspace_id: &str, + items_by_kind: &HashMap<&str, Vec<&str>>, +) -> Result> { + let mut visible = HashSet::new(); + + for (kind, paths) in items_by_kind { + let paths_vec: Vec = paths.iter().map(|s| s.to_string()).collect(); + + let results = match *kind { + "script" => { + sqlx::query_scalar!( + "SELECT path FROM script + WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "flow" => { + sqlx::query_scalar!( + "SELECT path FROM flow + WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "app" => { + sqlx::query_scalar!( + "SELECT path FROM app + WHERE workspace_id = $1 AND path = ANY($2)", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "resource" => { + sqlx::query_scalar!( + "SELECT path FROM resource + WHERE workspace_id = $1 AND path = ANY($2)", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + "variable" => { + sqlx::query_scalar!( + "SELECT path FROM variable + WHERE workspace_id = $1 AND path = ANY($2)", + workspace_id, + &paths_vec + ) + .fetch_all(&mut **tx) + .await? + } + _ => vec![], // Unknown kind + }; + + for path in results { + visible.insert((kind.to_string(), path)); + } + } + + Ok(visible) +} + +#[derive(Debug)] +struct ItemComparison { + has_changes: bool, + exists_in_source: bool, + exists_in_fork: bool, +} + +async fn compare_two_scripts( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // 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 archived = 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 archived = false + ORDER BY created_at DESC + LIMIT 1", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_script, &target_script) { + if source.content != target.content + || source.summary != target.summary + || source.description != target.description + || source.lock != target.lock + || source.schema != target.schema + { + has_changes = true; + } + } else if source_script.is_some() || target_script.is_some() { + // The script exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_script.is_some(), + exists_in_fork: target_script.is_some(), + }); +} + +async fn compare_two_flows( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get latest flow from each workspace + let source_flow = sqlx::query!( + "SELECT value, summary, description, schema + FROM flow + WHERE workspace_id = $1 AND path = $2 AND archived = false", + source_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let target_flow = sqlx::query!( + "SELECT value, summary, description, schema + FROM flow + WHERE workspace_id = $1 AND path = $2 AND archived = false", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_flow, &target_flow) { + if source.value != target.value + || source.summary != target.summary + || source.description != target.description + || source.schema != target.schema + { + has_changes = true; + } + } else if source_flow.is_some() || target_flow.is_some() { + // The flow exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_flow.is_some(), + exists_in_fork: target_flow.is_some(), + }); +} + +async fn compare_two_apps( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get app with its latest version data from source workspace + let source_app = sqlx::query!( + "SELECT app.summary, app.policy, app_version.value + FROM app + JOIN app_version + ON app_version.id = app.versions[array_upper(app.versions, 1)] + WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + source_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let target_app = sqlx::query!( + "SELECT app.summary, app.policy, app_version.value + FROM app + JOIN app_version + ON app_version.id = app.versions[array_upper(app.versions, 1)] + WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata and content differences + if let (Some(source), Some(target)) = (&source_app, &target_app) { + if source.summary != target.summary + || source.policy != target.policy + || source.value != target.value + { + has_changes = true; + } + } else if source_app.is_some() || target_app.is_some() { + // The app exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_app.is_some(), + exists_in_fork: target_app.is_some(), + }); +} + +async fn compare_two_resources( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get resource from each workspace + let source_resource = sqlx::query!( + "SELECT value, description, resource_type + 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 + FROM resource + WHERE workspace_id = $1 AND path = $2", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_resource, &target_resource) { + if source.value != target.value + || source.description != target.description + || source.resource_type != target.resource_type + { + has_changes = true; + } + } else if source_resource.is_some() || target_resource.is_some() { + // The resource exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_resource.is_some(), + exists_in_fork: target_resource.is_some(), + }); +} + +async fn compare_two_variables( + db: &DB, + source_workspace_id: &str, + fork_workspace_id: &str, + path: &str, +) -> Result { + // Get variable from each workspace + 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", + fork_workspace_id, + path + ) + .fetch_optional(db) + .await?; + + let mut has_changes = false; + + // Check metadata differences + if let (Some(source), Some(target)) = (&source_variable, &target_variable) { + if source.is_secret != target.is_secret + || source.value != target.value + || source.description != target.description + { + has_changes = true; + } + } else if source_variable.is_some() || target_variable.is_some() { + // The variable exists in one of source or target, but not the other, this is considered as a change + has_changes = true + } + + return Ok(ItemComparison { + has_changes, + exists_in_source: source_variable.is_some(), + exists_in_fork: target_variable.is_some(), + }); +} diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index 42ceec8cda..340573edf1 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -102,4 +102,30 @@ impl DeployedObject { DeployedObject::Key { .. } => None, } } + + pub fn get_kind(&self) -> String { + match self { + DeployedObject::Script { .. } => "script", + DeployedObject::Flow { .. } => "flow", + DeployedObject::App { .. } => "app", + DeployedObject::Folder { .. } => "folder", + DeployedObject::Resource { .. } => "resource", + DeployedObject::Variable { .. } => "variable", + DeployedObject::Schedule { .. } => "schedule", + DeployedObject::ResourceType { .. } => "resource_type", + DeployedObject::User { .. } => "user", + DeployedObject::Group { .. } => "group", + DeployedObject::HttpTrigger { .. } => "http_trigger", + DeployedObject::WebsocketTrigger { .. } => "websocket_trigger", + DeployedObject::KafkaTrigger { .. } => "kafka_trigger", + DeployedObject::NatsTrigger { .. } => "nats_trigger", + DeployedObject::PostgresTrigger { .. } => "postgres_trigger", + DeployedObject::MqttTrigger { .. } => "mqtt_trigger", + DeployedObject::SqsTrigger { .. } => "sqs_trigger", + DeployedObject::GcpTrigger { .. } => "gcp_trigger", + DeployedObject::EmailTrigger { .. } => "email_trigger", + DeployedObject::Settings { .. } => "settings", + DeployedObject::Key { .. } => "key", + }.to_string() + } } diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte new file mode 100644 index 0000000000..2cdb36f3e7 --- /dev/null +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -0,0 +1,1007 @@ + + +
+ {#if $workspaceStore != currentWorkspaceId} + + + You are currently seeing the deployement page of workspace {currentWorkspaceInfo?.name} ({currentWorkspaceInfo?.id}) which is not your currently selected workspace. + + Click here to go home ({$workspaceStore}) + + {/if} + {#if comparison} + {@const selectedConflicts = conflictingDiffs.filter((e) => + selectedItems.includes(getItemKey(e)) + ).length} +
+
+
+
+ + {#snippet children({ item })} + + + {/snippet} + + {#if currentWorkspaceInfo && parentWorkspaceInfo} + + merge: + {#if mergeIntoParent} + + {currentWorkspaceInfo.id} + {:else} + + {parentWorkspaceInfo.id} + {/if} + + + + into: + {#if !mergeIntoParent} + + {currentWorkspaceInfo.id} + {:else} + + {parentWorkspaceInfo.id} + {/if} + + {/if} +
+
+ + {comparison.summary.total_diffs} total items + + + {selectableDiffs.length} + {mergeIntoParent ? 'deployable' : 'updateable'} + + {#if conflictingDiffs.length > 0} + + + {conflictingDiffs.length} conflicts + + {/if} +
+
+
+
+ + {#if conflictingDiffs.length > 0} + + + + {conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's have' : ' has'} conflicting + changes, it was modified on the original workspace while changes were made on this fork. Make + sure to resolve these before merging. + + + {/if} + {#if hasBehindChanges && hasAheadChanges} + + You have items behind '{parentWorkspaceId}'. You need to update and test your changes before + being able to deploy. + + + Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !== + 1 + ? 's' + : ''} + + + {/if} + {#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible} + + {#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible} + This fork is ahead and behind its parent + {:else if !comparison.all_behind_items_visible} + This fork is behind of its parent + {:else if !comparison.all_ahead_items_visible} + This fork is ahead of its parent + {/if} + and some of the changes are not visible by you. Only a user with access to the whole context + may deploy or update this fork. You can share the link to this page to someone with proper permissions to get it deployed. + + {/if} + +
+
+ Select all +
+
+ +
+
+ {#each comparison.diffs as diff} + {@const key = getItemKey(diff)} + {@const isSelectable = selectableDiffs.includes(diff)} + {@const isSelected = selectedItems.includes(key)} + {@const isConflict = diff.ahead > 0 && diff.behind > 0} + {@const oldSummary = mergeIntoParent + ? summaryCache[key]?.parent + : summaryCache[key]?.current} + {@const newSummary = mergeIntoParent + ? summaryCache[key]?.current + : summaryCache[key]?.parent} + {@const existsInBothWorkspaces = !( + (diff.exists_in_fork && !diff.exists_in_source) || + (!diff.exists_in_fork && diff.exists_in_source) + )} + {@const isDeployedAndIrrelevant = + deploymentStatus[key]?.status == 'deployed' && !isSelectable} + + {#if !isDeployedAndIrrelevant} + toggleItem(diff)} + path={diff.kind != 'resource' && diff.kind != 'variable' ? diff.path : ''} + marked={undefined} + kind={diff.kind} + canFavorite={false} + workspaceId="" + starred={false} + > + {#snippet customSummary()} + {#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces} + {oldSummary || diff.path} + {newSummary || diff.path} + {:else if !existsInBothWorkspaces} + {newSummary || oldSummary || diff.path} + {:else} + {newSummary || diff.path} + {/if} + {/snippet} + {#snippet actions()} + + {#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0} + New + {/if} + {#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0} + Deleted + {/if} + {#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0} + Deleted + {/if} + {#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0} + New + {/if} + {#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'} +
+ {#if isConflict || existsInBothWorkspaces} + {#if diff.ahead > 0} + + + {diff.ahead} ahead + + {/if} + {#if diff.behind > 0} + + + {diff.behind} behind + + {/if} + {#if isConflict} + + + Conflict + + {/if} + {/if} +
+
+ +
+ {/if} + {#if deploymentStatus[key]} + {#if deploymentStatus[key].status == 'loading'} + + {:else if deploymentStatus[key].status == 'deployed'} + Deployed + {:else if deploymentStatus[key].status == 'failed'} +
+ Failed + {deploymentStatus[key].error}
+ {/if} + {/if} + {/snippet} +
+ {/if} + {/each} +
+
+ +
+
+
+ + + + + +
+ +
+ {#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible} + + {/if} + + {#if deploymentErrorMessage != ''} + + + {deploymentErrorMessage} + + + {/if} +
+
+
+ + {:else} +
+
No comparison data available
+
+ {/if} + + +
diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte new file mode 100644 index 0000000000..8baf7281ea --- /dev/null +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -0,0 +1,192 @@ + + +{#if isFork} +
+
+
+
+ +
+ + Fork of {parentWorkspaceData?.name} ({parentWorkspaceId}) + +
+ + {#if loading} + Checking for changes... + {:else if error} + + {error} + + {:else if comparison} +
+ {#if comparison.summary.total_diffs > 0} + + {forkAheadBehindMessage( + comparison.summary.total_ahead, + comparison.summary.total_behind, + )} + {parentWorkspaceId} over {comparison.summary + .total_diffs} items: + +
+ {#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 if comparison.skipped_comparison} + This fork was created before the addition of certain windmill features, and therefore the changes with its parent workspace cannot be displayed. + {:else} + Everything is up to date + {/if} +
+ {/if} +
+ +
+ {#if comparison && comparison.summary.total_diffs > 0} + + {/if} +
+
+
+
+{/if} diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index e8875af449..0ac3531bd3 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -12,74 +12,91 @@ interface Props { marked: string | undefined starred: boolean + selected?: boolean + disabled?: boolean canFavorite?: boolean + isSelectable?: boolean + alignWithSelectable?: boolean errorHandlerMuted?: boolean aiId?: string | undefined aiDescription?: string | undefined - kind?: 'script' | 'flow' | 'app' | 'raw_app' + kind?: 'script' | 'flow' | 'app' | 'raw_app' | 'resource' | 'variable' summary?: string | undefined path: string - href: string + href?: string workspaceId: string depth?: number badges?: import('svelte').Snippet actions?: import('svelte').Snippet + customSummary?: import('svelte').Snippet + onSelect?: ( + e: Event & { + currentTarget: EventTarget & HTMLInputElement + } + ) => void } let { marked, starred, + selected = false, + disabled = false, canFavorite = true, + isSelectable = false, + alignWithSelectable = false, errorHandlerMuted = false, aiId = undefined, aiDescription = undefined, kind = 'script', summary = undefined, path, - href, + href = undefined, workspaceId, depth = 0, badges, - actions + actions, + customSummary, + onSelect = () => {} }: Props = $props() let displayPath: string = (depth === 0 ? path : path?.split('/')?.slice(-1)?.[0]) ?? '' -
{ - goto(href) - } - }} ->
+{#if href} +
{ + goto(href) + } + }} + >
+{/if}
0 ? '!rounded-none' : '' + 'w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0', + depth > 0 ? '!rounded-none' : '', + disabled ? 'opacity-25' : 'hover:bg-surface-hover', + selected ? 'bg-surface-accent-selected' : '' + )} style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''} > - -
- -
-
-
- {#if marked} - {@html marked} - {:else} - {!summary || summary.length == 0 ? displayPath : summary} - {/if} -
-
- {path} -
-
-
+ {#if isSelectable} + + {:else if alignWithSelectable} +
+ {/if} + + {#if href} + + {@render rowContent()} + + {:else} + {@render rowContent()} + {/if} {#if errorHandlerMuted} @@ -91,8 +108,8 @@
{/if} - {#if canFavorite} -
+ {#if canFavorite && kind !== 'resource' && kind !== 'variable'} +
+ +{#snippet rowContent()} +
+ +
+
+
+ {#if customSummary} + {@render customSummary?.()} + {:else} + {#if marked} + {@html marked} + {:else} + {!summary || summary.length == 0 ? displayPath : summary} + {/if} + {/if} +
+
+ {path} +
+
+{/snippet} diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 8803268166..723ba53ebd 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -1,17 +1,22 @@ - + diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 032961cd9a..f15cd48b76 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -709,14 +709,16 @@
{/if} - { - menuOpen = true - }} - /> +
+ { + menuOpen = true + }} + /> +
{:else} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index d125a72109..c668fc4374 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -34,6 +34,7 @@ import { setQuery } from '$lib/navigation' import { page } from '$app/stores' import { goto, replaceState } from '$app/navigation' + import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte' import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte' import { onMount, setContext } from 'svelte' import { tutorialsToDo } from '$lib/stores' @@ -253,6 +254,7 @@ +
{#if $workspaceStore == 'admins'}
diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.js b/frontend/src/routes/(root)/(logged)/forks/compare/+page.js new file mode 100644 index 0000000000..712d677d68 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.js @@ -0,0 +1,5 @@ +export function load() { + return { + stuff: { title: 'Compare / Deploy to main workspace' } + } +} diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte new file mode 100644 index 0000000000..4e729e5ac6 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -0,0 +1,76 @@ + + + + + {#if currentWorkspaceId && parentWorkspaceId} + + + + + + + + + + {/if} + {#if !currentWorkspaceId} + No workspace selected + {:else if !parentWorkspaceId} + workspace {currentWorkspaceId} has no parent workspace + {/if} +