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] <windmill-internal-app[bot]@users.noreply.github.com> * 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] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
19
backend/.sqlx/query-034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2.json
generated
Normal file
19
backend/.sqlx/query-034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
59
backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json
generated
Normal file
59
backend/.sqlx/query-0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
17
backend/.sqlx/query-0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf.json
generated
Normal file
17
backend/.sqlx/query-0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
23
backend/.sqlx/query-2d1ba3c92c0385c530934082284cf548a50d533dc1bef58dfd0ecc163c9920f3.json
generated
Normal file
23
backend/.sqlx/query-2d1ba3c92c0385c530934082284cf548a50d533dc1bef58dfd0ecc163c9920f3.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
35
backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json
generated
Normal file
35
backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
22
backend/.sqlx/query-4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a.json
generated
Normal file
22
backend/.sqlx/query-4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
23
backend/.sqlx/query-50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101.json
generated
Normal file
23
backend/.sqlx/query-50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
23
backend/.sqlx/query-5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3.json
generated
Normal file
23
backend/.sqlx/query-5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
35
backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json
generated
Normal file
35
backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
35
backend/.sqlx/query-819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33.json
generated
Normal file
35
backend/.sqlx/query-819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
17
backend/.sqlx/query-90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c.json
generated
Normal file
17
backend/.sqlx/query-90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
59
backend/.sqlx/query-a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e.json
generated
Normal file
59
backend/.sqlx/query-a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
17
backend/.sqlx/query-ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30.json
generated
Normal file
17
backend/.sqlx/query-ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
15
backend/.sqlx/query-af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973.json
generated
Normal file
15
backend/.sqlx/query-af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
|
||||
|
||||
23
backend/.sqlx/query-c5897802334f09596a0297f9b9a1e24f3c6f8b1628965438a9360c073d70924e.json
generated
Normal file
23
backend/.sqlx/query-c5897802334f09596a0297f9b9a1e24f3c6f8b1628965438a9360c073d70924e.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
41
backend/.sqlx/query-dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a.json
generated
Normal file
41
backend/.sqlx/query-dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
23
backend/.sqlx/query-ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09.json
generated
Normal file
23
backend/.sqlx/query-ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
55e8296c3758b031e50d7ebdaef7c33666f02385
|
||||
bbf406edc222199ca2e6076da12c376fb4ff28c5
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add down migration script here
|
||||
DROP TABLE IF EXISTS skip_workspace_diff_tally;
|
||||
DROP TABLE IF EXISTS workspace_diff;
|
||||
@@ -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;
|
||||
@@ -1 +1,2 @@
|
||||
-- Add down migration script here
|
||||
ALTER TABLE email_trigger DROP COLUMN enabled;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<WorkspaceDiffRow>,
|
||||
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<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<()> {
|
||||
// Needed to compute the has_changes: Option<bool>. 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<bool>,
|
||||
exists_in_source: Option<bool>,
|
||||
exists_in_fork: Option<bool>,
|
||||
}
|
||||
|
||||
async fn compare_workspaces(
|
||||
authed: ApiAuthed,
|
||||
Path((source_workspace_id, fork_workspace_id)): Path<(String, String)>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<WorkspaceComparison> {
|
||||
// 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<Vec<WorkspaceDiffRow>> {
|
||||
// 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<WorkspaceDiffRow> = 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<HashSet<(String, String)>> {
|
||||
let mut visible = HashSet::new();
|
||||
|
||||
for (kind, paths) in items_by_kind {
|
||||
let paths_vec: Vec<String> = 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<ItemComparison> {
|
||||
// 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<ItemComparison> {
|
||||
// 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<ItemComparison> {
|
||||
// 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<ItemComparison> {
|
||||
// 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<ItemComparison> {
|
||||
// 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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
1007
frontend/src/lib/components/CompareWorkspaces.svelte
Normal file
1007
frontend/src/lib/components/CompareWorkspaces.svelte
Normal file
File diff suppressed because it is too large
Load Diff
192
frontend/src/lib/components/ForkWorkspaceBanner.svelte
Normal file
192
frontend/src/lib/components/ForkWorkspaceBanner.svelte
Normal file
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import type { WorkspaceComparison } from '$lib/gen'
|
||||
import { Button } from './common'
|
||||
import { AlertTriangle, GitFork } from 'lucide-svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
|
||||
let loading = $state(false)
|
||||
let comparison: WorkspaceComparison | undefined = $state(undefined)
|
||||
let error: string | undefined = $state(undefined)
|
||||
|
||||
let isFork = $derived($workspaceStore?.startsWith('wm-fork-') ?? false)
|
||||
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
|
||||
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
|
||||
|
||||
$effect(() => {
|
||||
;[$workspaceStore, parentWorkspaceId]
|
||||
untrack(() => {
|
||||
if (isFork && $workspaceStore) {
|
||||
checkForChanges()
|
||||
} else {
|
||||
comparison = undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (isFork && $workspaceStore) {
|
||||
checkForChanges()
|
||||
} else {
|
||||
comparison = undefined
|
||||
}
|
||||
})
|
||||
|
||||
async function checkForChanges() {
|
||||
if (!$workspaceStore || !parentWorkspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
error = undefined
|
||||
|
||||
try {
|
||||
// Compare with parent workspace
|
||||
const result = await WorkspaceService.compareWorkspaces({
|
||||
workspace: parentWorkspaceId,
|
||||
targetWorkspaceId: $workspaceStore
|
||||
})
|
||||
|
||||
comparison = result
|
||||
} catch (e) {
|
||||
console.error('Failed to compare workspaces:', e)
|
||||
error = `Failed to check for changes: ${e}`
|
||||
// Still show banner if there's an error, but with error message
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function openComparisonDrawer() {
|
||||
if (parentWorkspaceId && $workspaceStore) {
|
||||
goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore), {
|
||||
replaceState: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function forkAheadBehindMessage(
|
||||
changesAhead: number,
|
||||
changesBehind: number,
|
||||
) {
|
||||
let msg: string[] = []
|
||||
if (changesAhead > 0 || changesBehind > 0) {
|
||||
msg.push('This fork is ')
|
||||
if (changesAhead > 0)
|
||||
msg.push(`${changesAhead} change${changesAhead > 1 ? 's' : ''} ahead of `)
|
||||
if (changesAhead > 0 && changesBehind > 0) msg.push('and ')
|
||||
if (changesBehind > 0)
|
||||
msg.push(`${changesBehind} change${changesBehind > 1 ? 's' : ''} behind `)
|
||||
}
|
||||
return msg.join('')
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isFork}
|
||||
<div class="w-full bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800">
|
||||
<div class="px-4 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<GitFork class="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-blue-900 dark:text-blue-100">
|
||||
Fork of <b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400"> Checking for changes... </span>
|
||||
{:else if error}
|
||||
<span class="text-xs text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
{:else if comparison}
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
{#if comparison.summary.total_diffs > 0}
|
||||
<span>
|
||||
{forkAheadBehindMessage(
|
||||
comparison.summary.total_ahead,
|
||||
comparison.summary.total_behind,
|
||||
)}
|
||||
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison.summary
|
||||
.total_diffs} items:
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if comparison.summary.scripts_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-300">
|
||||
{comparison.summary.scripts_changed} script{comparison.summary
|
||||
.scripts_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.flows_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-300">
|
||||
{comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.apps_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-300">
|
||||
{comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.resources_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-300">
|
||||
{comparison.summary.resources_changed} resource{comparison.summary
|
||||
.resources_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.variables_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-300">
|
||||
{comparison.summary.variables_changed} variable{comparison.summary
|
||||
.variables_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if comparison.summary.conflicts > 0}
|
||||
-
|
||||
<div class="flex items-center gap-1 text-orange-600 dark:text-orange-400">
|
||||
<AlertTriangle class="w-3 h-3" />
|
||||
<span
|
||||
>{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1
|
||||
? 's'
|
||||
: ''}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if comparison.skipped_comparison}
|
||||
<span class="text-blue-600 dark:text-blue-400"> This fork was created before the addition of certain windmill features, and therefore the changes with its parent workspace cannot be displayed.</span>
|
||||
{:else}
|
||||
<span class="text-blue-600 dark:text-blue-400"> Everything is up to date </span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if comparison && comparison.summary.total_diffs > 0}
|
||||
<Button size="xs" color="blue" on:click={openComparisonDrawer}>
|
||||
{#if comparison.summary.total_ahead > 0}
|
||||
Review & Deploy Changes
|
||||
{:else}
|
||||
Review & Update fork
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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]) ?? ''
|
||||
</script>
|
||||
|
||||
<div
|
||||
style="display: none"
|
||||
use:triggerableByAI={{
|
||||
id: aiId,
|
||||
description: aiDescription,
|
||||
callback: () => {
|
||||
goto(href)
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
{#if href}
|
||||
<div
|
||||
style="display: none"
|
||||
use:triggerableByAI={{
|
||||
id: aiId,
|
||||
description: aiDescription,
|
||||
callback: () => {
|
||||
goto(href)
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class={twMerge(
|
||||
'hover:bg-surface-hover 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' : ''
|
||||
'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;` : ''}
|
||||
>
|
||||
<a {href} class="min-w-0 grow hover:underline decoration-gray-400 inline-flex items-center gap-4">
|
||||
<div class="shrink">
|
||||
<RowIcon {href} {kind} />
|
||||
</div>
|
||||
<div class="grow">
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
|
||||
{#if marked}
|
||||
{@html marked}
|
||||
{:else}
|
||||
{!summary || summary.length == 0 ? displayPath : summary}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-hint text-3xs truncate text-left font-normal">
|
||||
{path}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{#if isSelectable}
|
||||
<input type="checkbox" checked={selected} onchange={onSelect} class="rounded max-w-4 w-full" />
|
||||
{:else if alignWithSelectable}
|
||||
<div class="rounded max-w-4 w-full"></div>
|
||||
{/if}
|
||||
|
||||
{#if href}
|
||||
<a {href} class="min-w-0 grow hover:underline decoration-gray-400 inline-flex items-center gap-4">
|
||||
{@render rowContent()}
|
||||
</a>
|
||||
{:else}
|
||||
{@render rowContent()}
|
||||
{/if}
|
||||
|
||||
{#if errorHandlerMuted}
|
||||
<BellOff class="w-8 opacity-60" size={12} fill="currentcolor" />
|
||||
@@ -91,8 +108,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canFavorite}
|
||||
<div class="center-center h-full text-xs font-semibold text-secondary">
|
||||
{#if canFavorite && kind !== 'resource' && kind !== 'variable'}
|
||||
<div class="center-center h-full text-xs font-semibold text-secondary w-9">
|
||||
<Star
|
||||
{kind}
|
||||
{path}
|
||||
@@ -111,3 +128,25 @@
|
||||
{@render actions?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet rowContent()}
|
||||
<div class="shrink">
|
||||
<RowIcon {kind} />
|
||||
</div>
|
||||
<div class="grow">
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
|
||||
{#if customSummary}
|
||||
{@render customSummary?.()}
|
||||
{:else}
|
||||
{#if marked}
|
||||
{@html marked}
|
||||
{:else}
|
||||
{!summary || summary.length == 0 ? displayPath : summary}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-hint text-3xs truncate text-left font-normal">
|
||||
{path}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script lang="ts">
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import { Code2, LayoutDashboard } from 'lucide-svelte'
|
||||
import { Boxes, Code2, DollarSign, LayoutDashboard } from 'lucide-svelte'
|
||||
|
||||
export let kind: 'script' | 'flow' | 'app' | 'raw_app'
|
||||
export let href: string = '#'
|
||||
export let kind: 'script' | 'flow' | 'app' | 'raw_app' | 'resource' | 'variable'
|
||||
</script>
|
||||
|
||||
<a {href} class="flex justify-center items-center">
|
||||
<div class="flex justify-center items-center">
|
||||
{#if kind === 'flow'}
|
||||
<BarsStaggered size={16} class="text-teal-500" />
|
||||
{:else if kind === 'app' || kind === 'raw_app'}
|
||||
<LayoutDashboard size={16} class="text-orange-500" />
|
||||
{:else if kind === 'script'}
|
||||
<Code2 size={16} class="text-blue-500" />
|
||||
{:else if kind === 'variable'}
|
||||
<DollarSign size={16} class="text-gray-400" />
|
||||
{:else if kind === 'resource'}
|
||||
<Boxes size={16} class="text-gray-400" />
|
||||
{:else}
|
||||
<div class="w-[16px]"></div>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -709,14 +709,16 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<AiChatLayout
|
||||
{children}
|
||||
noPadding={devOnly}
|
||||
{isCollapsed}
|
||||
onMenuOpen={() => {
|
||||
menuOpen = true
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-col h-full w-full">
|
||||
<AiChatLayout
|
||||
{children}
|
||||
noPadding={devOnly}
|
||||
{isCollapsed}
|
||||
onMenuOpen={() => {
|
||||
menuOpen = true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredModal title="Loading user...">
|
||||
|
||||
@@ -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 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<ForkWorkspaceBanner />
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-8 md:px-8 h-fit">
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<div class="my-4"></div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Compare / Deploy to main workspace' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import CompareWorkspaces from "$lib/components/CompareWorkspaces.svelte"
|
||||
import { WorkspaceService, type WorkspaceComparison } from "$lib/gen"
|
||||
import { page } from '$app/state'
|
||||
import { userWorkspaces } from "$lib/stores"
|
||||
import { untrack } from "svelte"
|
||||
import CenteredPage from "$lib/components/CenteredPage.svelte"
|
||||
import PageHeader from "$lib/components/PageHeader.svelte"
|
||||
|
||||
let comparison: WorkspaceComparison | undefined = $state(undefined)
|
||||
|
||||
let currentWorkspaceId: string | undefined = $state(page.url.searchParams.get('workspace_id') ?? undefined)
|
||||
|
||||
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === currentWorkspaceId))
|
||||
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
|
||||
|
||||
async function checkForChanges() { if (!currentWorkspaceId || !parentWorkspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
// loading = true
|
||||
// error = undefined
|
||||
|
||||
try {
|
||||
// Compare with parent workspace
|
||||
const result = await WorkspaceService.compareWorkspaces({
|
||||
workspace: parentWorkspaceId,
|
||||
targetWorkspaceId: currentWorkspaceId
|
||||
})
|
||||
|
||||
comparison = result
|
||||
// isVisible = result.summary.total_diffs > 0
|
||||
} catch (e) {
|
||||
console.error('Failed to compare workspaces:', e)
|
||||
// error = 'Failed to check for changes'
|
||||
// Still show banner if there's an error, but with error message
|
||||
// isVisible = true
|
||||
} finally {
|
||||
// loading = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
[
|
||||
currentWorkspaceId,
|
||||
parentWorkspaceId,
|
||||
];
|
||||
|
||||
untrack(() => checkForChanges())
|
||||
})
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<CenteredPage>
|
||||
|
||||
<PageHeader
|
||||
title="Merge workspaces"
|
||||
/>
|
||||
{#if currentWorkspaceId && parentWorkspaceId}
|
||||
<!-- <WorkspaceComparisonDrawer -->
|
||||
<!-- {comparison} -->
|
||||
<!-- sourceWorkspace={currentWorkspaceId} -->
|
||||
<!-- targetWorkspace={parentWorkspaceId} -->
|
||||
<!-- on:deployed={() => { -->
|
||||
<!-- sendUserToast('Changes deployed successfully') -->
|
||||
<!-- }} -->
|
||||
<!-- /> -->
|
||||
<CompareWorkspaces {currentWorkspaceId} {parentWorkspaceId} {comparison}/>
|
||||
{/if}
|
||||
{#if !currentWorkspaceId}
|
||||
No workspace selected
|
||||
{:else if !parentWorkspaceId}
|
||||
workspace {currentWorkspaceId} has no parent workspace
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
Reference in New Issue
Block a user