mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 16:05:00 +00:00
feat: Fork datatables (#8339)
* export_datatable_schema * Propose to fork the datatable on ws fork * dump datatable * Dockerfile * Fix import_datatable_dump * datatable schema fork works! * Option to copy both schema and data * Datatable fork behavior * nit ui * use psql instead * remove fork_datatable route * feat: add fork_pg_database and export_pg_schema routes with DB Manager UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: pluralize "schema" to "schemas" in DB Manager export/import UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add import mode select (schema only vs schema + data) to DB Manager import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Select schema or schema+data when important database * fix: prepend $res: prefix to resource paths in DB Manager import/export Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: dynamic import button label based on selected mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * nits * feat: add warning alert when schema+data import mode is selected Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * nit hide on cloud hosted * refactor: remove fork_behavior from datatable settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: split CreateWorkspace into layout wrapper and CreateWorkspaceInner Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: instantiate CreateWorkspaceInner in globalForkModal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * nit icons * Data table fork UI * feat: pass per-datatable fork behaviors from UI to backend during workspace fork Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix fork overwriting all datatables * UI nits * custom instance db refactor * custom instance db wizard btn for all in dropdown * nit * Delete custom instance database button * Disable forking for resource datatables * Big import buttons when db empty * Revert "Disable forking for resource datatables" This reverts commit9561cc8fd4. * feat: add non_diffable flag to resource table Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add resource-type datatable fork with CREATE DATABASE Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: tag forked datatables with nonDiffable and forkedFrom Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: diff datatable and ducklake settings individually on workspace merge Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: skip non_diffable resources and datatables in workspace diff Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: default datatable fork behavior to keep_original Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: make grant permissions non-fatal in instance datatable fork Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: make datatable and ducklake diffs visible in workspace comparison Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: remove datatable fork logic from workspace fork route Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct ahead/behind logic for datatable and ducklake diffs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: correct ahead/behind logic for datatable and ducklake diffs" This reverts commit6b50884dc6. * revert: remove datatable and ducklake settings diffing logic Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add datatable clone UI with step-by-step confirmation modal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract datatable fork UI into ForkDatatableSection component Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * fix: run datatable cloning before workspace fork creation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit disable fork admins * nit fix switching workspace prematurely * fix: use source workspace for forkPgDatabase calls during fork Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: update forked workspace datatable settings after fork creation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add forked_from field to DataTable and set it for instance forks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit onFinish * fix: add forked_from to DataTableSettings OpenAPI schema Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: track datatable table DDL changes in workspace_diff Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "feat: track datatable table DDL changes in workspace_diff" This reverts commit7526dd68b9. * feat: add get_datatable_full_schema endpoint and snapshot schema on fork Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix duplicate migration key * fix: set forked_from on datatable config for both instance and resource types Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nits * feat: drop forked databases on workspace deletion with confirmation UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract drop_forked_datatable_databases from delete_workspace Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: cast pg char columns to text in FK schema query Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: show dbname instead of resource type in fork deletion modal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ui nit * refactor: extract drop_custom_instance_database into windmill-common Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add datatable schema diff section to merge UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * UI * feat: add review drawer with YAML diff and SQL migration runner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: use Monaco DiffEditor for YAML diff in review drawer Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * Revert "refactor: use Monaco DiffEditor for YAML diff in review drawer" This reverts commita86008ba4c. * Revert "feat: add review drawer with YAML diff and SQL migration runner" This reverts commit0a0deb5ddb. * feat: add review drawer with DiffEditor and SQL migration runner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ui nits * fix: show diff between forked_from schema and changed side Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: re-fetch target live schema after migration for correct baseline Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * revert * nit auto next * feat: add confirmation modal before deploying migration to parent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: handle missing columns/foreignKeys in schema conversion Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nits * refactor: use temp file on disk for pg_dump instead of in-memory string Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Don't replace postgres dbname * fix: add validation to drop_custom_instance_database and use source db for CREATE/DROP Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: type DataTable.forked_from as DataTableForkedFrom struct Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: simplify fork_pg_database to take source + target_dbname Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * dead code * feat: enforce schema_and_data admin-only and extract create_custom_instance_database Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: rename fork_pg_database to import_pg_database with source/target/override params Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * refactor: remove original_dbname/original_resource from forked_from, resolve from parent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * fix: resolve forked dbname from fork workspace when dropping resource databases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nits * fix: always clean up global_settings even if database doesn't exist Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: check datatable resource_type from config instead of URL prefix Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: wrap PG default value expressions in braces to prevent CAST quoting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: wrap PG default value expressions in braces to prevent CAST quoting" This reverts commit77f5a2c4e8. * refactor: reuse columnDefToTableEditorValuesColumn for default value handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: store raw API schema in forked_from to avoid double transformation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: store raw API schema in forked_from to avoid double transformation" This reverts commite326197a20. * Revert "refactor: reuse columnDefToTableEditorValuesColumn for default value handling" This reverts commitbd8f071d9f. * fix: validate dbname with strict regex to prevent SQL injection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix default value * always validate dbname * refactor: move get_datatable_full_schema structs and logic to query_builders.rs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: split import_pg_database into create_pg_database + import_pg_database Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract drop_forked_datatable_databases into its own route Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: require admin when using $res: resource paths in import_pg_database Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use UserDB for $res: resource access and restrict dbname creation - resolve_pg_source_checked uses UserDB (row-level security) for $res: paths - transform_json_unchecked is now pub(crate) to prevent misuse - Non-superadmins can only create databases with wm_fork_ prefix - datatable:// remains accessible to everyone Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: refuse to drop forked databases unless name starts with wm_fork_ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: remove resolve_pg_source, use resolve_pg_source_checked everywhere Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix permissions * sqlx prepare * compilation nits * sqlx prepare * sqlx prepare * wrong route syntax * fix: allow workspace owner to edit datatable config for fork setup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: allow workspace owner to edit datatable config for fork setup" This reverts commitab683e637b. * refactor: move datatable fork setup into create_workspace_fork backend Instead of updating datatable settings from the frontend after fork creation (which required admin/owner access), pass forked_datatables info to create_workspace_fork and handle it atomically in the same transaction. Removes applyPostForkDatatableUpdates from frontend. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: snapshot schema in backend during fork instead of frontend The schema snapshot is now taken by the backend in apply_forked_datatable via snapshot_datatable_schema, which connects to the parent workspace's datatable and runs pg_get_full_schema. This removes the need for the frontend to call getDatatableFullSchema and pass the schema through. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use get_resource_value_interpolated_internal for $res: to resolve $var: references Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * sqlx prepare * fix: add permission check to drop_forked_datatable_databases, validate dbnames, restrict temp file perms - drop_forked_datatable_databases: same permission as delete_workspace (fork owner or super admin) - validate_dbname on target_dbname_override and ForkedDatatableInfo.new_dbname - Enforce wm_fork_ prefix on forked datatable new_dbname - DumpFile: set /tmp/windmill/ to 0700 and create files with 0600 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit CLI * Rename to ws_specific * sqlx prepare * nit always validate dbname * fix: include foreign keys in CREATE TABLE migration for added tables Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: detect nextval defaults and use SERIAL/BIGSERIAL types in CREATE TABLE Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update frontend/src/lib/components/DBManagerDrawer.svelte Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update backend/windmill-common/src/lib.rs Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update backend/windmill-common/src/lib.rs Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: sort foreign keys by constraint name for deterministic schema output Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * sqlx prepare * rename migration to update timestamp --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
cubic-dev-ai[bot]
parent
0bcbc8bd3c
commit
3d4f4c6c38
+9
-1
@@ -162,11 +162,19 @@ ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini \
|
||||
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini gnupg lsb-release \
|
||||
&& if echo "$features" | grep -q "ee"; then apt-get install -y --no-install-recommends libsasl2-modules-gssapi-mit krb5-user; fi \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
|
||||
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN if [ "$WITH_GIT" = "true" ]; then \
|
||||
apt-get update -y \
|
||||
&& apt-get install -y git \
|
||||
|
||||
+11
-5
@@ -50,31 +50,36 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_expired",
|
||||
"name": "ws_specific",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "is_refreshed",
|
||||
"name": "is_expired",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "is_refreshed",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "refresh_error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"ordinal": 13,
|
||||
"name": "is_linked",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"ordinal": 14,
|
||||
"name": "is_oauth?",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 15,
|
||||
"name": "account",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
@@ -95,6 +100,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
|
||||
+7
-1
@@ -47,6 +47,11 @@
|
||||
"ordinal": 8,
|
||||
"name": "labels",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "ws_specific",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -63,7 +68,8 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7"
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, ws_specific, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7, $8) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), ws_specific = EXCLUDED.ws_specific, labels = EXCLUDED.labels",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4a1947f86ab5d2ff63a3f307c6f537caaf6d9177d72f1deeab4b5afcb83c0547"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "4d03f08c86588d3fba178340a3b64f375f82e8904920d51454f10e04fc24b62a"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "63c16a4277983aaed0aed54972923919cee3cc444725ac6b7906922554bae800"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, ws_specific)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), ws_specific = EXCLUDED.ws_specific",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7929fa087a28949906ffb3f508d8b88c922d3442418701edd6b97e5fa50bd739"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value, description, resource_type, ws_specific\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"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "ws_specific",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "83d537bb8a4bb0121b6c7c2c6f7ce67c1281e16025aa3cf559a4f7a6a034d734"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'forked_from'], $3::jsonb)\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b2903da4186589d9df7d4e245f29138047b78f32d270a91a433cd6a04ecb48ea"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE resource\n SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text)),\n ws_specific = true\n WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ba09bbba6bd34ef52c702eddc7b0b047e71a171cf067fcd1e812681d8bd15005"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(\n jsonb_set(datatable, ARRAY['datatables', $2, 'database', 'resource_path'], to_jsonb($3::text)),\n ARRAY['datatables', $2, 'forked_from'], $4::jsonb\n )\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "da6f37f91f2a24cf9ce8a1759adc5e9aebca35a0245598000c5d741dc6ede2d5"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE global_settings SET value = value #- ARRAY['databases', $1] WHERE name = 'custom_instance_pg_databases'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df13aa5740f5475165b934f26d90b0f7c9ed4f2aa5b2d54817e7630ca1d37753"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f85208a1f14536125db503d5cf375737a5855560a7f7c4a105461af73778a078"
|
||||
}
|
||||
Generated
+1
@@ -16696,6 +16696,7 @@ dependencies = [
|
||||
"windmill-git-sync",
|
||||
"windmill-object-store",
|
||||
"windmill-queue",
|
||||
"windmill-store",
|
||||
"windmill-types",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE resource DROP COLUMN IF EXISTS ws_specific;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add ws_specific flag to resource table.
|
||||
-- When true, the resource is excluded from workspace diff comparisons (e.g. auto-created resources during fork).
|
||||
ALTER TABLE resource ADD COLUMN IF NOT EXISTS ws_specific BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -101,6 +101,10 @@ pub fn global_service() -> Router {
|
||||
"/setup_custom_instance_pg_database/{name}",
|
||||
post(setup_custom_instance_pg_database),
|
||||
)
|
||||
.route(
|
||||
"/drop_custom_instance_pg_database/{name}",
|
||||
post(drop_custom_instance_pg_database),
|
||||
)
|
||||
.route(
|
||||
"/critical_alerts/acknowledge_all",
|
||||
post(acknowledge_all_critical_alerts),
|
||||
@@ -1179,6 +1183,18 @@ async fn setup_custom_instance_pg_database_inner(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn drop_custom_instance_pg_database(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(dbname): Path<String>,
|
||||
) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
|
||||
windmill_common::drop_custom_instance_database(&db, &dbname).await?;
|
||||
|
||||
Ok(format!("Database '{}' dropped successfully", dbname))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Secret Backend Settings (HashiCorp Vault Integration) - Enterprise Edition
|
||||
// ============================================================================
|
||||
|
||||
@@ -27,6 +27,7 @@ windmill-git-sync.workspace = true
|
||||
windmill-queue.workspace = true
|
||||
windmill-api-jobs.workspace = true
|
||||
windmill-dep-map.workspace = true
|
||||
windmill-store.workspace = true
|
||||
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -40,8 +40,8 @@ use windmill_common::workspaces::GitRepositorySettings;
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
use windmill_common::workspaces::{
|
||||
check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable,
|
||||
DataTableCatalogResourceType, ProtectionRuleKind, ProtectionRules, ProtectionRuleset,
|
||||
RuleCheckResult, WorkspaceGitSyncSettings,
|
||||
DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules,
|
||||
ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings,
|
||||
};
|
||||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
use windmill_common::PgDatabase;
|
||||
@@ -157,6 +157,17 @@ pub fn workspaced_service() -> Router {
|
||||
post(reset_workspace_diffs),
|
||||
)
|
||||
.route("/compare/{target_workspace_id}", get(compare_workspaces))
|
||||
.route("/create_pg_database", post(create_pg_database))
|
||||
.route("/import_pg_database", post(import_pg_database))
|
||||
.route("/export_pg_schema", post(export_pg_schema))
|
||||
.route(
|
||||
"/drop_forked_datatable_databases",
|
||||
post(crate::workspaces_extra::drop_forked_datatable_databases),
|
||||
)
|
||||
.route(
|
||||
"/get_datatable_full_schema",
|
||||
post(get_datatable_full_schema),
|
||||
)
|
||||
.route("/protection_rules", get(list_protection_rules))
|
||||
.route("/protection_rules", post(create_protection_rule))
|
||||
.route(
|
||||
@@ -381,6 +392,16 @@ struct CreateWorkspaceFork {
|
||||
id: String,
|
||||
name: String,
|
||||
color: Option<String>,
|
||||
/// Datatable names that were forked. For each, the backend will update the
|
||||
/// forked workspace's datatable config to point to the new database.
|
||||
#[serde(default)]
|
||||
forked_datatables: Vec<ForkedDatatableInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ForkedDatatableInfo {
|
||||
name: String,
|
||||
new_dbname: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1273,26 +1294,40 @@ async fn list_ducklakes(
|
||||
Ok(Json(ducklakes))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DataTableListItem {
|
||||
name: String,
|
||||
resource_type: String,
|
||||
resource_path: String,
|
||||
}
|
||||
|
||||
async fn list_datatables(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
let datatables = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT jsonb_object_keys(ws.datatable->'datatables') AS datatable_name
|
||||
FROM workspace_settings ws
|
||||
WHERE ws.workspace_id = $1
|
||||
"#,
|
||||
) -> JsonResult<Vec<DataTableListItem>> {
|
||||
let config = sqlx::query_scalar!(
|
||||
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|s| s)
|
||||
.collect();
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(datatables))
|
||||
let items: Vec<DataTableListItem> = match config {
|
||||
Some(val) => {
|
||||
let map: HashMap<String, DataTable> = serde_json::from_value(val).unwrap_or_default();
|
||||
map.into_iter()
|
||||
.map(|(name, dt)| DataTableListItem {
|
||||
name,
|
||||
resource_type: dt.database.resource_type.as_ref().to_string(),
|
||||
resource_path: dt.database.resource_path,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
/// Compact column representation: "type" or "type?" for nullable, with "=default" suffix if has default
|
||||
@@ -1455,6 +1490,397 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu
|
||||
Ok(schema_map)
|
||||
}
|
||||
|
||||
/// Resolve a source string to PgDatabase credentials with user-scoped permission checks.
|
||||
/// For `datatable://name`: accessible to everyone (variables are resolved internally).
|
||||
/// For `$res:path`: uses UserDB (row-level security) to verify the user can see the resource,
|
||||
/// then interpolates `$var:` references in the resource value.
|
||||
pub(crate) async fn resolve_pg_source_checked(
|
||||
db: &DB,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
source: &str,
|
||||
) -> Result<PgDatabase> {
|
||||
let db_resource = if let Some(name) = source.strip_prefix("datatable://") {
|
||||
get_datatable_resource_from_db_unchecked(db, w_id, name).await?
|
||||
} else if let Some(path) = source.strip_prefix("$res:") {
|
||||
let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed(
|
||||
authed,
|
||||
db.clone(),
|
||||
Some(user_db.clone()),
|
||||
);
|
||||
let value = windmill_store::resources::get_resource_value_interpolated_internal(
|
||||
&db_with_authed,
|
||||
w_id,
|
||||
path,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
match value {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"Resource '{}' not found or you do not have access to it",
|
||||
path
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid source format: '{}'. Expected 'datatable://name' or '$res:path'",
|
||||
source
|
||||
)));
|
||||
};
|
||||
|
||||
serde_json::from_value(db_resource)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))
|
||||
}
|
||||
|
||||
/// A temporary file for pg_dump output that is automatically deleted when dropped.
|
||||
struct DumpFile {
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl DumpFile {
|
||||
fn new() -> Result<Self> {
|
||||
let dir = std::path::Path::new("/tmp/windmill");
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to create /tmp/windmill: {}", e)))?;
|
||||
// Set directory permissions to owner-only
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
let path = dir.join(format!("datatable_dump_{}", uuid::Uuid::new_v4()));
|
||||
// Create the file with restrictive permissions before pg_dump writes to it
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to create dump file: {}", e)))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::File::create(&path)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to create dump file: {}", e)))?;
|
||||
}
|
||||
Ok(Self { path })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DumpFile {
|
||||
fn drop(&mut self) {
|
||||
if self.path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&self.path) {
|
||||
tracing::warn!("Failed to remove dump file {:?}: {}", self.path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run pg_dump against a PgDatabase, writing output to a temp file on disk.
|
||||
/// Returns a DumpFile handle; the file is deleted when the handle is dropped.
|
||||
async fn pg_dump_database(pg_db: &PgDatabase, schema_only: bool) -> Result<DumpFile> {
|
||||
let dump_file = DumpFile::new()?;
|
||||
|
||||
let host = &pg_db.host;
|
||||
let port = pg_db.port.unwrap_or(5432).to_string();
|
||||
let user = pg_db.user.as_deref().unwrap_or("postgres");
|
||||
let dbname = &pg_db.dbname;
|
||||
|
||||
let mut cmd = tokio::process::Command::new("pg_dump");
|
||||
cmd.arg("--format=plain").arg("--file").arg(&dump_file.path);
|
||||
if schema_only {
|
||||
cmd.arg("--schema-only");
|
||||
}
|
||||
cmd.arg("--host")
|
||||
.arg(host)
|
||||
.arg("--port")
|
||||
.arg(&port)
|
||||
.arg("--username")
|
||||
.arg(user)
|
||||
.arg(dbname);
|
||||
|
||||
if let Some(ref password) = pg_db.password {
|
||||
cmd.env("PGPASSWORD", password);
|
||||
}
|
||||
|
||||
if let Some(ref sslmode) = pg_db.sslmode {
|
||||
cmd.env("PGSSLMODE", sslmode);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to execute pg_dump: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::internal_err(format!("pg_dump failed: {}", stderr)));
|
||||
}
|
||||
|
||||
Ok(dump_file)
|
||||
}
|
||||
|
||||
/// Import a pg_dump file into a target database using psql.
|
||||
async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> {
|
||||
let host = &target_db.host;
|
||||
let port = target_db.port.unwrap_or(5432).to_string();
|
||||
let user = target_db.user.as_deref().unwrap_or("postgres");
|
||||
let dbname = &target_db.dbname;
|
||||
|
||||
let mut cmd = tokio::process::Command::new("psql");
|
||||
cmd.arg("--host")
|
||||
.arg(host)
|
||||
.arg("--port")
|
||||
.arg(&port)
|
||||
.arg("--username")
|
||||
.arg(user)
|
||||
.arg("--dbname")
|
||||
.arg(dbname)
|
||||
.arg("--no-psqlrc")
|
||||
.arg("--file")
|
||||
.arg(&dump_file.path)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
if let Some(ref password) = target_db.password {
|
||||
cmd.env("PGPASSWORD", password);
|
||||
}
|
||||
|
||||
if let Some(ref sslmode) = target_db.sslmode {
|
||||
cmd.env("PGSSLMODE", sslmode);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::internal_err(format!(
|
||||
"psql import failed: {}",
|
||||
stderr
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreatePgDatabaseRequest {
|
||||
/// The datatable source to determine connection info: 'datatable://name' or '$res:path'
|
||||
source: String,
|
||||
/// Name for the new database
|
||||
target_dbname: String,
|
||||
}
|
||||
|
||||
/// Create a new PostgreSQL database. For instance datatables, creates on the Windmill PG instance.
|
||||
/// For resource datatables, creates on the same server as the source.
|
||||
async fn create_pg_database(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<CreatePgDatabaseRequest>,
|
||||
) -> Result<String> {
|
||||
windmill_common::validate_dbname(&req.target_dbname)?;
|
||||
|
||||
// Non-superadmin: restrict dbname to wm_fork_ prefix
|
||||
if !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? {
|
||||
if !req.target_dbname.starts_with("wm_fork_") {
|
||||
return Err(Error::BadRequest(
|
||||
"Non-superadmin users can only create databases with names starting with 'wm_fork_'"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this is an instance or resource-backed datatable
|
||||
let is_instance_datatable = if let Some(dt_name) = req.source.strip_prefix("datatable://") {
|
||||
let config = sqlx::query_scalar!(
|
||||
"SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id,
|
||||
dt_name
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
config
|
||||
.and_then(|v| {
|
||||
v.get("database")
|
||||
.and_then(|d| d.get("resource_type"))
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|s| s == "instance")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_instance_datatable {
|
||||
windmill_common::create_custom_instance_database(&db, &req.target_dbname, "datatable")
|
||||
.await?;
|
||||
} else {
|
||||
let source_pg =
|
||||
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let (client, connection) = source_pg.connect().await?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
|
||||
let row = client
|
||||
.query_one(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
&[&req.target_dbname],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("Failed to check database existence: {}", e))
|
||||
})?;
|
||||
let db_exists: bool = row.get(0);
|
||||
|
||||
if db_exists {
|
||||
drop(client);
|
||||
let _ = join_handle.await;
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Database '{}' already exists on the resource server",
|
||||
req.target_dbname
|
||||
)));
|
||||
}
|
||||
|
||||
client
|
||||
.execute(&format!("CREATE DATABASE \"{}\"", &req.target_dbname), &[])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to create database '{}': {}",
|
||||
req.target_dbname, e
|
||||
))
|
||||
})?;
|
||||
|
||||
drop(client);
|
||||
join_handle
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("join error: {}", e)))?
|
||||
.map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?;
|
||||
}
|
||||
|
||||
Ok(format!("Created database '{}'", req.target_dbname))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImportPgDatabaseRequest {
|
||||
source: String,
|
||||
target: String,
|
||||
#[serde(default)]
|
||||
target_dbname_override: Option<String>,
|
||||
fork_behavior: DataTableForkBehavior,
|
||||
}
|
||||
|
||||
/// Import (pg_dump/pg_import) from source to target
|
||||
async fn import_pg_database(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<ImportPgDatabaseRequest>,
|
||||
) -> Result<String> {
|
||||
if req.fork_behavior == DataTableForkBehavior::KeepOriginal {
|
||||
return Ok("No action needed for KeepOriginal behavior".to_string());
|
||||
}
|
||||
|
||||
if req.fork_behavior == DataTableForkBehavior::SchemaAndData {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(Error::BadRequest(
|
||||
"Importing schema and data is not available on cloud".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly;
|
||||
let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let mut target_pg =
|
||||
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?;
|
||||
|
||||
if let Some(ref override_dbname) = req.target_dbname_override {
|
||||
if !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? {
|
||||
if !override_dbname.starts_with("wm_fork_") {
|
||||
return Err(Error::BadRequest(
|
||||
"Non-superadmin users can only override target dbname with names starting with 'wm_fork_'"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
target_pg.dbname = override_dbname.clone();
|
||||
}
|
||||
windmill_common::validate_dbname(&target_pg.dbname)?;
|
||||
|
||||
let dump_file = pg_dump_database(&source_pg, schema_only).await?;
|
||||
pg_import_dump(&target_pg, &dump_file).await?;
|
||||
|
||||
Ok(format!(
|
||||
"Imported from '{}' into '{}'",
|
||||
req.source, target_pg.dbname
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExportPgSchemaRequest {
|
||||
source: String,
|
||||
}
|
||||
|
||||
async fn export_pg_schema(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<ExportPgSchemaRequest>,
|
||||
) -> Result<String> {
|
||||
let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let dump_file = pg_dump_database(&pg, true).await?;
|
||||
tokio::fs::read_to_string(&dump_file.path)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GetDatatableFullSchemaRequest {
|
||||
source: String,
|
||||
}
|
||||
|
||||
async fn get_datatable_full_schema(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<GetDatatableFullSchemaRequest>,
|
||||
) -> JsonResult<windmill_common::query_builders::FullDatabaseSchema> {
|
||||
let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let (client, connection) = pg.connect().await?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
|
||||
let result = windmill_common::query_builders::pg_get_full_schema(&client)
|
||||
.await
|
||||
.map_err(Error::internal_err)?;
|
||||
|
||||
drop(client);
|
||||
join_handle
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("join error: {}", e)))?
|
||||
.map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn edit_ducklake_config(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -3732,6 +4158,120 @@ async fn create_workspace_fork_branch(
|
||||
))
|
||||
}
|
||||
|
||||
/// Update a forked workspace's datatable config to point to the new database.
|
||||
/// For instance datatables: updates resource_path in the datatable config.
|
||||
/// For resource datatables: updates the resource's dbname and sets ws_specific.
|
||||
/// Snapshot the schema from the source datatable by connecting to its database.
|
||||
async fn snapshot_datatable_schema(
|
||||
db: &DB,
|
||||
parent_w_id: &str,
|
||||
dt_name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let pg = get_datatable_resource_from_db_unchecked(db, parent_w_id, dt_name).await?;
|
||||
let pg: PgDatabase = serde_json::from_value(pg)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse db credentials: {}", e)))?;
|
||||
let (client, connection) = pg.connect().await?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
|
||||
let schema = windmill_common::query_builders::pg_get_full_schema(&client)
|
||||
.await
|
||||
.map_err(Error::internal_err)?;
|
||||
|
||||
drop(client);
|
||||
join_handle
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("join error: {}", e)))?
|
||||
.map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?;
|
||||
|
||||
serde_json::to_value(schema)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize schema: {}", e)))
|
||||
}
|
||||
|
||||
async fn apply_forked_datatable(
|
||||
db: &DB,
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
parent_w_id: &str,
|
||||
forked_w_id: &str,
|
||||
fdt: &ForkedDatatableInfo,
|
||||
) -> Result<()> {
|
||||
windmill_common::validate_dbname(&fdt.new_dbname)?;
|
||||
if !fdt.new_dbname.starts_with("wm_fork_") {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Forked datatable database name '{}' must start with 'wm_fork_'",
|
||||
fdt.new_dbname
|
||||
)));
|
||||
}
|
||||
|
||||
// Snapshot the schema from the source (parent) datatable
|
||||
let schema = snapshot_datatable_schema(db, parent_w_id, &fdt.name).await?;
|
||||
let forked_from = serde_json::json!({ "schema": schema });
|
||||
|
||||
// Read the datatable config from the forked workspace
|
||||
let config_val = sqlx::query_scalar!(
|
||||
"SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1",
|
||||
forked_w_id,
|
||||
&fdt.name
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"Datatable '{}' not found in workspace '{}'",
|
||||
fdt.name, forked_w_id
|
||||
))
|
||||
})?;
|
||||
|
||||
let dt: DataTable = serde_json::from_value(config_val)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse datatable config: {}", e)))?;
|
||||
|
||||
if dt.database.resource_type == DataTableCatalogResourceType::Instance {
|
||||
// Instance: update resource_path to the new dbname
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(
|
||||
jsonb_set(datatable, ARRAY['datatables', $2, 'database', 'resource_path'], to_jsonb($3::text)),
|
||||
ARRAY['datatables', $2, 'forked_from'], $4::jsonb
|
||||
)
|
||||
WHERE workspace_id = $1"#,
|
||||
forked_w_id,
|
||||
&fdt.name,
|
||||
&fdt.new_dbname,
|
||||
forked_from,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
} else {
|
||||
// Resource: update the resource's dbname and set ws_specific
|
||||
let resource_path = &dt.database.resource_path;
|
||||
sqlx::query!(
|
||||
r#"UPDATE resource
|
||||
SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text)),
|
||||
ws_specific = true
|
||||
WHERE workspace_id = $1 AND path = $2"#,
|
||||
forked_w_id,
|
||||
resource_path,
|
||||
&fdt.new_dbname,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Set forked_from on the datatable config
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'forked_from'], $3::jsonb)
|
||||
WHERE workspace_id = $1"#,
|
||||
forked_w_id,
|
||||
&fdt.name,
|
||||
forked_from,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_workspace_fork(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -3813,6 +4353,11 @@ async fn create_workspace_fork(
|
||||
// Clone all data from the parent workspace using Rust implementation
|
||||
clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?;
|
||||
|
||||
// Update forked datatable settings to point to new databases
|
||||
for fdt in &nw.forked_datatables {
|
||||
apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -3824,6 +4369,7 @@ async fn create_workspace_fork(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Created forked workspace {}", &forked_id))
|
||||
}
|
||||
|
||||
@@ -5535,7 +6081,7 @@ async fn compare_two_resources(
|
||||
) -> Result<ItemComparison> {
|
||||
// Get resource from each workspace
|
||||
let source_resource = sqlx::query!(
|
||||
"SELECT value, description, resource_type
|
||||
"SELECT value, description, resource_type, ws_specific
|
||||
FROM resource
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
source_workspace_id,
|
||||
@@ -5545,7 +6091,7 @@ async fn compare_two_resources(
|
||||
.await?;
|
||||
|
||||
let target_resource = sqlx::query!(
|
||||
"SELECT value, description, resource_type
|
||||
"SELECT value, description, resource_type, ws_specific
|
||||
FROM resource
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
fork_workspace_id,
|
||||
@@ -5554,6 +6100,17 @@ async fn compare_two_resources(
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
// If either side is ws_specific, consider unchanged
|
||||
let source_ws_specific = source_resource.as_ref().map_or(false, |r| r.ws_specific);
|
||||
let target_ws_specific = target_resource.as_ref().map_or(false, |r| r.ws_specific);
|
||||
if source_ws_specific || target_ws_specific {
|
||||
return Ok(ItemComparison {
|
||||
has_changes: false,
|
||||
exists_in_source: source_resource.is_some(),
|
||||
exists_in_fork: target_resource.is_some(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut has_changes = false;
|
||||
|
||||
// Check metadata differences
|
||||
|
||||
@@ -23,8 +23,10 @@ use windmill_common::worker::CLOUD_HOSTED;
|
||||
|
||||
use windmill_common::{
|
||||
auth::is_super_admin_email,
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
utils::require_admin,
|
||||
workspaces::DataTable,
|
||||
};
|
||||
use windmill_queue::schedule::{get_schedule_opt, push_scheduled_job};
|
||||
|
||||
@@ -858,6 +860,157 @@ pub(crate) async fn delete_workspace(
|
||||
Ok(format!("Deleted workspace {}", &w_id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DropForkedDatatableDatabasesRequest {
|
||||
datatable_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// Drop forked datatable databases. Returns errors per datatable that failed.
|
||||
/// Same permission as delete_workspace: fork owner or super admin.
|
||||
pub async fn drop_forked_datatable_databases(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<DropForkedDatatableDatabasesRequest>,
|
||||
) -> Result<Json<Vec<String>>> {
|
||||
// Same permission check as delete_workspace: fork owner or super admin
|
||||
let mut tx = db.begin().await?;
|
||||
if !(w_id.starts_with(WM_FORK_PREFIX) && is_workspace_owner(&authed, &w_id, &mut tx).await?) {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
let parent_w_id = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten()
|
||||
.ok_or_else(|| Error::BadRequest("No parent workspace found".to_string()))?;
|
||||
|
||||
let datatable_config = sqlx::query_scalar!(
|
||||
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
|
||||
let datatables: HashMap<String, DataTable> =
|
||||
serde_json::from_value(datatable_config).unwrap_or_default();
|
||||
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
for dt_name in &req.datatable_names {
|
||||
let dt = match datatables.get(dt_name) {
|
||||
Some(dt) if dt.forked_from.is_some() => dt,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if dt.database.resource_type
|
||||
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
|
||||
{
|
||||
let db_to_drop = &dt.database.resource_path;
|
||||
if !db_to_drop.starts_with("wm_fork_") {
|
||||
errors.push(format!(
|
||||
"Refusing to drop instance database '{}' for datatable://{}: name does not start with 'wm_fork_'",
|
||||
db_to_drop, dt_name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
|
||||
errors.push(format!(
|
||||
"Could not drop instance database '{}' for datatable://{}: {}",
|
||||
db_to_drop, dt_name, e
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let fork_pg = match crate::workspaces::resolve_pg_source_checked(
|
||||
&db,
|
||||
&user_db,
|
||||
&authed,
|
||||
&w_id,
|
||||
&format!("datatable://{}", dt_name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pg) => pg,
|
||||
Err(e) => {
|
||||
errors.push(format!(
|
||||
"Could not resolve fork resource for datatable://{}: {}",
|
||||
dt_name, e
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// We cannot drop the current database, so we connect to the parent's version to run DROP DATABASE on
|
||||
// the forked version
|
||||
let parent_pg = match crate::workspaces::resolve_pg_source_checked(
|
||||
&db,
|
||||
&user_db,
|
||||
&authed,
|
||||
&parent_w_id,
|
||||
&format!("datatable://{}", dt_name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pg) => pg,
|
||||
Err(e) => {
|
||||
errors.push(format!(
|
||||
"Could not resolve parent resource for datatable://{}: {}",
|
||||
dt_name, e
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let db_to_drop = &fork_pg.dbname;
|
||||
if let Err(e) = windmill_common::validate_dbname(db_to_drop) {
|
||||
errors.push(format!(
|
||||
"Invalid database name '{}' for datatable://{}: {}",
|
||||
db_to_drop, dt_name, e
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !db_to_drop.starts_with("wm_fork_") {
|
||||
errors.push(format!(
|
||||
"Refusing to drop resource database '{}' for datatable://{}: name does not start with 'wm_fork_'",
|
||||
db_to_drop, dt_name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
match parent_pg.connect().await {
|
||||
Ok((client, connection)) => {
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
if let Err(e) = client
|
||||
.execute(&format!("DROP DATABASE \"{}\"", db_to_drop), &[])
|
||||
.await
|
||||
{
|
||||
errors.push(format!(
|
||||
"Could not drop database '{}' for datatable://{}: {}",
|
||||
db_to_drop, dt_name, e
|
||||
));
|
||||
}
|
||||
drop(client);
|
||||
let _ = join_handle.await;
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!(
|
||||
"Could not connect to drop database for datatable://{}: {}",
|
||||
dt_name, e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(errors))
|
||||
}
|
||||
|
||||
async fn is_workspace_owner(
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
|
||||
@@ -1079,6 +1079,27 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CustomInstanceDb"
|
||||
|
||||
/settings/drop_custom_instance_pg_database/{name}:
|
||||
post:
|
||||
summary: Drops a custom instance database (superadmin only, irreversible)
|
||||
operationId: dropCustomInstanceDb
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The name of the database to drop
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/global/{key}:
|
||||
get:
|
||||
summary: get global settings
|
||||
@@ -3755,7 +3776,16 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
type: object
|
||||
required: [name, resource_type, resource_path]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
resource_type:
|
||||
type: string
|
||||
enum: [postgres, instance]
|
||||
resource_path:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/list_datatable_schemas:
|
||||
get:
|
||||
@@ -3828,6 +3858,211 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/workspaces/create_pg_database:
|
||||
post:
|
||||
summary: create a new PostgreSQL database for a datatable
|
||||
operationId: createPgDatabase
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Create pg database request
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [source, target_dbname]
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
description: "Datatable source to determine connection info: 'datatable://name' or '$res:path'"
|
||||
target_dbname:
|
||||
type: string
|
||||
description: "Name for the new database"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/drop_forked_datatable_databases:
|
||||
post:
|
||||
summary: drop forked datatable databases
|
||||
operationId: dropForkedDatatableDatabases
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [datatable_names]
|
||||
properties:
|
||||
datatable_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: list of errors (empty if all succeeded)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/import_pg_database:
|
||||
post:
|
||||
summary: import a PostgreSQL database from source to target via pg_dump
|
||||
operationId: importPgDatabase
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Import pg database request
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [source, target, fork_behavior]
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
description: "Source database: 'datatable://name' or '$res:path'"
|
||||
target:
|
||||
type: string
|
||||
description: "Target database: 'datatable://name' or '$res:path'"
|
||||
target_dbname_override:
|
||||
type: string
|
||||
description: "Override the target database name"
|
||||
fork_behavior:
|
||||
type: string
|
||||
enum:
|
||||
- schema_only
|
||||
- schema_and_data
|
||||
- keep_original
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/export_pg_schema:
|
||||
post:
|
||||
summary: export the schema of a PostgreSQL database
|
||||
operationId: exportPgSchema
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Export pg schema request
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [source]
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
description: "Source database: 'datatable://name' or '$res:path'"
|
||||
responses:
|
||||
"200":
|
||||
description: schema dump
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/get_datatable_full_schema:
|
||||
post:
|
||||
summary: get the full schema of a datatable database as TableEditorValues
|
||||
operationId: getDatatableFullSchema
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [source]
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
description: "Source datatable, e.g. 'datatable://main'"
|
||||
responses:
|
||||
"200":
|
||||
description: "Schema as { schema_name: { table_name: TableEditorValues } }"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
required: [name, columns, foreign_keys]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
columns:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [name, datatype]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
datatype:
|
||||
type: string
|
||||
primary_key:
|
||||
type: boolean
|
||||
default_value:
|
||||
type: string
|
||||
nullable:
|
||||
type: boolean
|
||||
foreign_keys:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [columns, on_delete, on_update]
|
||||
properties:
|
||||
target_table:
|
||||
type: string
|
||||
columns:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
source_column:
|
||||
type: string
|
||||
target_column:
|
||||
type: string
|
||||
on_delete:
|
||||
type: string
|
||||
on_update:
|
||||
type: string
|
||||
fk_constraint_name:
|
||||
type: string
|
||||
pk_constraint_name:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/git_sync_enabled:
|
||||
get:
|
||||
summary: Check if git sync is available for this workspace
|
||||
@@ -18372,7 +18607,7 @@ paths:
|
||||
type: object
|
||||
description: The columns used (for tables)
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/AssetUsageAccessType"
|
||||
$ref: "#/components/schemas/AssetUsageAccessType"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -18476,7 +18711,6 @@ paths:
|
||||
type: string
|
||||
description: The asset path
|
||||
|
||||
|
||||
/w/{workspace}/volumes/list:
|
||||
get:
|
||||
summary: List all volumes in the workspace
|
||||
@@ -21423,6 +21657,9 @@ components:
|
||||
resource_type:
|
||||
type: string
|
||||
description: The new resource_type to be associated with the resource
|
||||
ws_specific:
|
||||
type: boolean
|
||||
description: When true, the resource is excluded from workspace diff comparisons
|
||||
labels:
|
||||
type: array
|
||||
items:
|
||||
@@ -24220,6 +24457,18 @@ components:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
forked_datatables:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [name, new_dbname]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "Datatable name"
|
||||
new_dbname:
|
||||
type: string
|
||||
description: "New database name for the fork"
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -24871,7 +25120,14 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- resource_type
|
||||
|
||||
forked_from:
|
||||
type: object
|
||||
description: Fork origin info with schema snapshot
|
||||
properties:
|
||||
schema:
|
||||
type: object
|
||||
description: Schema snapshot at fork time
|
||||
additionalProperties: true
|
||||
DataTableSchema:
|
||||
type: object
|
||||
required: [datatable_name, schemas]
|
||||
@@ -25453,7 +25709,15 @@ components:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
["script", "flow", "app", "raw_app", "resource", "variable", "resource_type"]
|
||||
[
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"resource_type",
|
||||
]
|
||||
description: Type of the item
|
||||
path:
|
||||
type: string
|
||||
|
||||
@@ -401,7 +401,7 @@ pub struct PrepareQueryResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct PgDatabase {
|
||||
pub host: String,
|
||||
pub user: Option<String>,
|
||||
@@ -632,6 +632,180 @@ impl PgDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a database name to prevent SQL injection.
|
||||
/// Must start with a letter, contain only alphanumeric characters or underscores, and be <= 63 chars.
|
||||
pub fn validate_dbname(dbname: &str) -> error::Result<()> {
|
||||
let dbname = dbname.trim();
|
||||
if dbname.is_empty() {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Database name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if dbname.len() > 63 {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Database name cannot exceed 63 characters".to_string(),
|
||||
));
|
||||
}
|
||||
if !dbname
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(false, |c| c.is_ascii_alphabetic())
|
||||
{
|
||||
return Err(error::Error::BadRequest(
|
||||
"Database name must start with a letter".to_string(),
|
||||
));
|
||||
}
|
||||
if !dbname
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
{
|
||||
return Err(error::Error::BadRequest(
|
||||
"Database name must contain only alphanumeric characters or underscores".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a custom instance database: validate, terminate connections, DROP DATABASE, remove from global_settings.
|
||||
pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Result<()> {
|
||||
let dbname = dbname.trim();
|
||||
validate_dbname(dbname)?;
|
||||
|
||||
let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
if wmill_pg_creds.dbname.trim().eq_ignore_ascii_case(dbname) {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Cannot drop the main Windmill database".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let db_exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
dbname
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if db_exists {
|
||||
// Terminate active connections
|
||||
if let Err(e) = sqlx::query(&format!(
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()",
|
||||
dbname.replace('\'', "''")
|
||||
))
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to terminate connections to '{}': {}", dbname, e);
|
||||
}
|
||||
|
||||
// Drop the database
|
||||
sqlx::query(&format!("DROP DATABASE IF EXISTS \"{}\"", dbname))
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error::Error::internal_err(format!("Failed to drop database '{}': {}", dbname, e))
|
||||
})?;
|
||||
|
||||
tracing::info!("Dropped instance database '{}'", dbname);
|
||||
} else {
|
||||
tracing::info!("Database '{}' does not exist, skipping drop", dbname);
|
||||
}
|
||||
|
||||
// Always remove from global_settings
|
||||
sqlx::query!(
|
||||
r#"UPDATE global_settings SET value = value #- ARRAY['databases', $1] WHERE name = 'custom_instance_pg_databases'"#,
|
||||
dbname
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings.
|
||||
/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake").
|
||||
pub async fn create_custom_instance_database(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
tag: &str,
|
||||
) -> error::Result<()> {
|
||||
let dbname = dbname.trim();
|
||||
validate_dbname(dbname)?;
|
||||
|
||||
let db_exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
dbname
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if db_exists {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"Database '{}' already exists",
|
||||
dbname
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(&format!("CREATE DATABASE \"{}\"", dbname))
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error::Error::internal_err(format!("Failed to create database '{}': {}", dbname, e))
|
||||
})?;
|
||||
|
||||
// Grant permissions to custom_instance_user
|
||||
let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
let new_pg_creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds };
|
||||
let (client, connection) = new_pg_creds.connect().await?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
|
||||
if let Err(e) = client
|
||||
.batch_execute(&format!(
|
||||
"GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user;
|
||||
GRANT USAGE ON SCHEMA public TO custom_instance_user;
|
||||
GRANT CREATE ON SCHEMA public TO custom_instance_user;
|
||||
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;"
|
||||
))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to grant permissions on '{}': {}. Continuing.",
|
||||
dbname,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
drop(client);
|
||||
join_handle
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(format!("join error: {}", e)))?
|
||||
.map_err(|e| error::Error::internal_err(format!("tokio_postgres error: {}", e)))?;
|
||||
|
||||
// Register in global_settings
|
||||
let status_json = serde_json::json!({
|
||||
"logs": {
|
||||
"created_database": "OK",
|
||||
"db_connect": "OK",
|
||||
"grant_permissions": "OK"
|
||||
},
|
||||
"success": true,
|
||||
"error": null,
|
||||
"tag": tag
|
||||
});
|
||||
sqlx::query!(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#,
|
||||
serde_json::json!({ (dbname): status_json })
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Created custom instance database '{}'", dbname);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum DatabaseUrl {
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
|
||||
@@ -4471,3 +4471,224 @@ mod tests {
|
||||
assert_eq!(sql, "SHOW PRIMARY KEYS IN ACCOUNT");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Full schema introspection types and logic (used by get_datatable_full_schema)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct FullSchemaColumn {
|
||||
pub name: String,
|
||||
pub datatype: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_key: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_value: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nullable: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct FullSchemaForeignKeyColumn {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_column: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_column: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct FullSchemaForeignKey {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_table: Option<String>,
|
||||
pub columns: Vec<FullSchemaForeignKeyColumn>,
|
||||
pub on_delete: String,
|
||||
pub on_update: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fk_constraint_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct FullSchemaTable {
|
||||
pub name: String,
|
||||
pub columns: Vec<FullSchemaColumn>,
|
||||
pub foreign_keys: Vec<FullSchemaForeignKey>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pk_constraint_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Full database schema: { schema_name: { table_name: FullSchemaTable } }
|
||||
pub type FullDatabaseSchema =
|
||||
std::collections::HashMap<String, std::collections::HashMap<String, FullSchemaTable>>;
|
||||
|
||||
fn pg_action_to_string(action: &str) -> String {
|
||||
match action {
|
||||
"a" => "NO ACTION".to_string(),
|
||||
"r" => "RESTRICT".to_string(),
|
||||
"c" => "CASCADE".to_string(),
|
||||
"n" => "SET NULL".to_string(),
|
||||
"d" => "SET DEFAULT".to_string(),
|
||||
_ => "NO ACTION".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Introspect a PostgreSQL database and return the full schema.
|
||||
/// Takes a connected tokio_postgres Client.
|
||||
pub async fn pg_get_full_schema(
|
||||
client: &tokio_postgres::Client,
|
||||
) -> Result<FullDatabaseSchema, String> {
|
||||
let column_rows = client
|
||||
.query(
|
||||
"SELECT
|
||||
ns.nspname AS schema_name,
|
||||
c.relname AS table_name,
|
||||
a.attname AS column_name,
|
||||
pg_catalog.format_type(a.atttypid, a.atttypmod) AS datatype,
|
||||
(SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) for 128)
|
||||
FROM pg_catalog.pg_attrdef d
|
||||
WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef) AS default_value,
|
||||
CASE a.attnotnull WHEN false THEN true ELSE false END AS nullable,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_catalog.pg_index i
|
||||
WHERE i.indrelid = c.oid AND i.indisprimary AND a.attnum = ANY(i.indkey)
|
||||
) AS is_primary_key,
|
||||
(SELECT con.conname FROM pg_catalog.pg_constraint con
|
||||
WHERE con.conrelid = c.oid AND con.contype = 'p' LIMIT 1) AS pk_constraint_name
|
||||
FROM pg_catalog.pg_attribute a
|
||||
JOIN pg_catalog.pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid
|
||||
WHERE c.relkind = 'r'
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY ns.nspname, c.relname, a.attnum",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query columns: {}", e))?;
|
||||
|
||||
let fk_rows = client
|
||||
.query(
|
||||
"SELECT
|
||||
ns.nspname AS schema_name,
|
||||
c.relname AS table_name,
|
||||
con.conname AS fk_constraint_name,
|
||||
att_src.attname AS source_column,
|
||||
ns_ref.nspname AS ref_schema,
|
||||
c_ref.relname AS ref_table,
|
||||
att_ref.attname AS ref_column,
|
||||
con.confdeltype::text AS on_delete,
|
||||
con.confupdtype::text AS on_update
|
||||
FROM pg_catalog.pg_constraint con
|
||||
JOIN pg_catalog.pg_class c ON con.conrelid = c.oid
|
||||
JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid
|
||||
JOIN pg_catalog.pg_class c_ref ON con.confrelid = c_ref.oid
|
||||
JOIN pg_catalog.pg_namespace ns_ref ON c_ref.relnamespace = ns_ref.oid
|
||||
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS u(src_attnum, ref_attnum, ord)
|
||||
JOIN pg_catalog.pg_attribute att_src ON att_src.attrelid = c.oid AND att_src.attnum = u.src_attnum
|
||||
JOIN pg_catalog.pg_attribute att_ref ON att_ref.attrelid = c_ref.oid AND att_ref.attnum = u.ref_attnum
|
||||
WHERE con.contype = 'f'
|
||||
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY ns.nspname, c.relname, con.conname, u.ord",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query foreign keys: {}", e))?;
|
||||
|
||||
let mut result: FullDatabaseSchema = std::collections::HashMap::new();
|
||||
|
||||
for row in &column_rows {
|
||||
let schema_name: &str = row.get("schema_name");
|
||||
let table_name: &str = row.get("table_name");
|
||||
let column_name: &str = row.get("column_name");
|
||||
let datatype: &str = row.get("datatype");
|
||||
let default_value: Option<&str> = row.get("default_value");
|
||||
let nullable: bool = row.get("nullable");
|
||||
let is_primary_key: bool = row.get("is_primary_key");
|
||||
let pk_constraint_name: Option<&str> = row.get("pk_constraint_name");
|
||||
|
||||
let schema_tables = result.entry(schema_name.to_string()).or_default();
|
||||
let table = schema_tables
|
||||
.entry(table_name.to_string())
|
||||
.or_insert_with(|| FullSchemaTable {
|
||||
name: table_name.to_string(),
|
||||
columns: vec![],
|
||||
foreign_keys: vec![],
|
||||
pk_constraint_name: pk_constraint_name.map(|s| s.to_string()),
|
||||
});
|
||||
|
||||
table.columns.push(FullSchemaColumn {
|
||||
name: column_name.to_string(),
|
||||
datatype: datatype.to_string(),
|
||||
primary_key: if is_primary_key { Some(true) } else { None },
|
||||
default_value: default_value.map(|s| s.to_string()),
|
||||
nullable: Some(nullable),
|
||||
});
|
||||
}
|
||||
|
||||
let mut fk_map: std::collections::HashMap<
|
||||
(String, String, String),
|
||||
(
|
||||
Option<String>,
|
||||
Vec<FullSchemaForeignKeyColumn>,
|
||||
String,
|
||||
String,
|
||||
),
|
||||
> = std::collections::HashMap::new();
|
||||
|
||||
for row in &fk_rows {
|
||||
let schema_name: &str = row.get("schema_name");
|
||||
let table_name: &str = row.get("table_name");
|
||||
let fk_name: &str = row.get("fk_constraint_name");
|
||||
let source_column: &str = row.get("source_column");
|
||||
let ref_schema: &str = row.get("ref_schema");
|
||||
let ref_table: &str = row.get("ref_table");
|
||||
let ref_column: &str = row.get("ref_column");
|
||||
let on_delete: &str = row.get("on_delete");
|
||||
let on_update: &str = row.get("on_update");
|
||||
|
||||
let target_table = if ref_schema == schema_name {
|
||||
ref_table.to_string()
|
||||
} else {
|
||||
format!("{}.{}", ref_schema, ref_table)
|
||||
};
|
||||
|
||||
let key = (
|
||||
schema_name.to_string(),
|
||||
table_name.to_string(),
|
||||
fk_name.to_string(),
|
||||
);
|
||||
let entry = fk_map.entry(key).or_insert_with(|| {
|
||||
(
|
||||
Some(target_table.clone()),
|
||||
vec![],
|
||||
pg_action_to_string(on_delete),
|
||||
pg_action_to_string(on_update),
|
||||
)
|
||||
});
|
||||
entry.1.push(FullSchemaForeignKeyColumn {
|
||||
source_column: Some(source_column.to_string()),
|
||||
target_column: Some(ref_column.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let mut fk_entries: Vec<_> = fk_map.into_iter().collect();
|
||||
fk_entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
for ((schema_name, table_name, fk_name), (target_table, columns, on_delete, on_update)) in
|
||||
fk_entries
|
||||
{
|
||||
if let Some(schema_tables) = result.get_mut(&schema_name) {
|
||||
if let Some(table) = schema_tables.get_mut(&table_name) {
|
||||
table.foreign_keys.push(FullSchemaForeignKey {
|
||||
target_table,
|
||||
columns,
|
||||
on_delete,
|
||||
on_update,
|
||||
fk_constraint_name: Some(fk_name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -389,9 +389,32 @@ pub async fn check_user_against_rule(
|
||||
Ok(RuleCheckResult::Allowed)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DataTableForkBehavior {
|
||||
SchemaOnly,
|
||||
SchemaAndData,
|
||||
KeepOriginal,
|
||||
}
|
||||
|
||||
impl Default for DataTableForkBehavior {
|
||||
fn default() -> Self {
|
||||
DataTableForkBehavior::KeepOriginal
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DataTable {
|
||||
pub database: DataTableDatabase,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from: Option<DataTableForkedFrom>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DataTableForkedFrom {
|
||||
/// Schema snapshot at fork time
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
|
||||
@@ -125,6 +125,8 @@ pub struct Resource {
|
||||
pub extra_perms: serde_json::Value,
|
||||
pub created_by: Option<String>,
|
||||
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(default)]
|
||||
pub ws_specific: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
@@ -145,6 +147,8 @@ pub struct ListableResource {
|
||||
pub is_expired: Option<bool>,
|
||||
pub refresh_error: Option<String>,
|
||||
pub account: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub ws_specific: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
@@ -156,6 +160,7 @@ pub struct CreateResource {
|
||||
pub description: Option<String>,
|
||||
pub resource_type: String,
|
||||
#[serde(default)]
|
||||
pub ws_specific: Option<bool>,
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
@@ -163,6 +168,7 @@ struct EditResource {
|
||||
path: Option<String>,
|
||||
description: Option<String>,
|
||||
value: Option<Box<RawValue>>,
|
||||
ws_specific: Option<bool>,
|
||||
labels: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -260,6 +266,7 @@ async fn list_resources(
|
||||
"account.refresh_error",
|
||||
"resource.created_by",
|
||||
"resource.edited_at",
|
||||
"resource.ws_specific",
|
||||
"resource.labels",
|
||||
])
|
||||
.left()
|
||||
@@ -836,15 +843,16 @@ async fn create_resource(
|
||||
}
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource
|
||||
(workspace_id, path, value, description, resource_type, created_by, edited_at, labels)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)
|
||||
DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels",
|
||||
(workspace_id, path, value, description, resource_type, created_by, edited_at, ws_specific, labels)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now(), $7, $8) ON CONFLICT (workspace_id, path)
|
||||
DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), ws_specific = EXCLUDED.ws_specific, labels = EXCLUDED.labels",
|
||||
w_id,
|
||||
resource.path,
|
||||
raw_json as sqlx::types::Json<&RawValue>,
|
||||
resource.description,
|
||||
resource.resource_type,
|
||||
authed.username,
|
||||
resource.ws_specific.unwrap_or(false),
|
||||
resource.labels.as_deref() as Option<&[String]>
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
@@ -1212,6 +1220,10 @@ async fn update_resource(
|
||||
if let Some(ndesc) = ns.description {
|
||||
sqlb.set_str("description", ndesc);
|
||||
}
|
||||
if let Some(nd) = ns.ws_specific {
|
||||
sqlb.set_str("ws_specific", if nd { "true" } else { "false" });
|
||||
}
|
||||
|
||||
sqlb.set_str("edited_at", "now()");
|
||||
|
||||
sqlb.returning("path");
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ResourceFile {
|
||||
description?: string;
|
||||
resource_type: string;
|
||||
is_oauth?: boolean; // deprecated
|
||||
ws_specific?: boolean;
|
||||
}
|
||||
|
||||
async function readFilesetDirectory(dirPath: string): Promise<Record<string, string>> {
|
||||
|
||||
@@ -36,7 +36,15 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 \
|
||||
&& apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
|
||||
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
@@ -36,7 +36,15 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 \
|
||||
&& apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
|
||||
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
import { base } from '$lib/base'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import DatatableSchemaDiff from './DatatableSchemaDiff.svelte'
|
||||
|
||||
interface Props {
|
||||
currentWorkspaceId: string
|
||||
@@ -716,382 +717,408 @@
|
||||
{@const selectedConflicts = conflictingDiffs.filter((e) =>
|
||||
selectedItems.includes(getItemKey(e))
|
||||
).length}
|
||||
|
||||
<WorkspaceDeployLayout
|
||||
items={deployableItems}
|
||||
{selectedItems}
|
||||
{deploymentStatus}
|
||||
selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)}
|
||||
{allSelected}
|
||||
onToggleItem={(item) => {
|
||||
const diff = comparison?.diffs.find((d) => getItemKey(d) === item.key)
|
||||
if (diff) toggleItem(diff)
|
||||
}}
|
||||
onSelectAll={selectAll}
|
||||
onDeselectAll={deselectAll}
|
||||
emptyMessage="No comparison data available"
|
||||
>
|
||||
{#snippet header()}
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex flex-col gap-2 border bg-surface-tertiary w-full p-4 border-radius-5 rounded"
|
||||
>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
<ToggleButtonGroup
|
||||
disabled={deploying}
|
||||
selected="deploy_to"
|
||||
onSelected={toggleDeploymentDirection}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="deploy_to"
|
||||
label="Deploy to {parentWorkspaceId}"
|
||||
icon={ArrowUp}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton value="update" label="Update current" icon={ArrowDown} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{#if currentWorkspaceInfo && parentWorkspaceInfo}
|
||||
<Badge
|
||||
color="transparent"
|
||||
class="ml-5 font-semibold"
|
||||
title={mergeIntoParent ? currentWorkspaceInfo.name : parentWorkspaceInfo.name}
|
||||
>
|
||||
<span class="text-secondary">merge:</span>
|
||||
{#if mergeIntoParent}
|
||||
<GitFork size={14} />
|
||||
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
|
||||
{:else}
|
||||
<Building size={14} />
|
||||
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="bg-surface-tertiary p-4 rounded-md border">
|
||||
<WorkspaceDeployLayout
|
||||
items={deployableItems}
|
||||
{selectedItems}
|
||||
{deploymentStatus}
|
||||
selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)}
|
||||
{allSelected}
|
||||
onToggleItem={(item) => {
|
||||
const diff = comparison?.diffs.find((d) => getItemKey(d) === item.key)
|
||||
if (diff) toggleItem(diff)
|
||||
}}
|
||||
onSelectAll={selectAll}
|
||||
onDeselectAll={deselectAll}
|
||||
emptyMessage="No comparison data available"
|
||||
>
|
||||
{#snippet header()}
|
||||
<div class="flex items-center justify-between bg-surface-tertiary">
|
||||
<div class="flex flex-col gap-2 w-full pb-4 border-b">
|
||||
<div class="flex flex-wrap gap-1 items-center">
|
||||
<ToggleButtonGroup
|
||||
disabled={deploying}
|
||||
selected="deploy_to"
|
||||
onSelected={toggleDeploymentDirection}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="deploy_to"
|
||||
label="Deploy to {parentWorkspaceId}"
|
||||
icon={ArrowUp}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton value="update" label="Update current" icon={ArrowDown} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{#if currentWorkspaceInfo && parentWorkspaceInfo}
|
||||
<div class="flex-1 flex gap-1 items-center">
|
||||
<Badge
|
||||
color="transparent"
|
||||
class="ml-5 font-semibold"
|
||||
title={mergeIntoParent ? currentWorkspaceInfo.name : parentWorkspaceInfo.name}
|
||||
>
|
||||
<span class="text-secondary">merge:</span>
|
||||
{#if mergeIntoParent}
|
||||
<GitFork size={14} />
|
||||
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
|
||||
{:else}
|
||||
<Building size={14} />
|
||||
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
|
||||
{/if}
|
||||
</Badge>
|
||||
<ArrowRight size={16} />
|
||||
<Badge
|
||||
color="transparent"
|
||||
class="font-semibold"
|
||||
title={!mergeIntoParent
|
||||
? currentWorkspaceInfo.name
|
||||
: parentWorkspaceInfo.name}
|
||||
>
|
||||
<span class="text-secondary">into:</span>
|
||||
{#if !mergeIntoParent}
|
||||
<GitFork size={14} />
|
||||
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
|
||||
{:else}
|
||||
<Building size={14} />
|
||||
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
|
||||
{/if}
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</Badge>
|
||||
<ArrowRight size={16} />
|
||||
<Badge
|
||||
color="transparent"
|
||||
class="font-semibold"
|
||||
title={!mergeIntoParent ? currentWorkspaceInfo.name : parentWorkspaceInfo.name}
|
||||
>
|
||||
<span class="text-secondary">into:</span>
|
||||
{#if !mergeIntoParent}
|
||||
<GitFork size={14} />
|
||||
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
|
||||
{:else}
|
||||
<Building size={14} />
|
||||
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
|
||||
{/if}
|
||||
</Badge>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Badge color="transparent">
|
||||
{comparison.summary.total_diffs} total items
|
||||
</Badge>
|
||||
<Badge color="transparent">
|
||||
{selectableDiffs.length}
|
||||
{mergeIntoParent ? 'deployable' : 'updateable'}
|
||||
</Badge>
|
||||
{#if conflictingDiffs.length > 0}
|
||||
<Badge color="orange">
|
||||
<AlertTriangle class="w-3 h-3 inline mr-1" />
|
||||
{conflictingDiffs.length} conflicts
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Badge color="transparent">
|
||||
{comparison.summary.total_diffs} total items
|
||||
</Badge>
|
||||
<Badge color="transparent">
|
||||
{selectableDiffs.length}
|
||||
{mergeIntoParent ? 'deployable' : 'updateable'}
|
||||
</Badge>
|
||||
{#if conflictingDiffs.length > 0}
|
||||
<Badge color="orange">
|
||||
<AlertTriangle class="w-3 h-3 inline mr-1" />
|
||||
{conflictingDiffs.length} conflicts
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
{/snippet}
|
||||
|
||||
{#snippet alerts()}
|
||||
{#if mergeIntoParent}
|
||||
<ParentWorkspaceProtectionAlert
|
||||
{parentWorkspaceId}
|
||||
onUpdateCanDeploy={(canDeploy) => {
|
||||
canDeployToParent = canDeploy
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if conflictingDiffs.length > 0}
|
||||
<Alert title="Conflicting changes detected" type="warning" class="mt-2">
|
||||
<span>
|
||||
{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.
|
||||
</span>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)}
|
||||
<Alert
|
||||
title="This fork is behind {parentWorkspaceId} and needs to be up to date before deploying"
|
||||
type="warning"
|
||||
class="my-2"
|
||||
>
|
||||
You have items behind '{parentWorkspaceId}'. You need to update and test your changes
|
||||
before being able to deploy.
|
||||
<span class="font-medium flex flex-row gap-1 text-red-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={allowBehindChangesOverride}
|
||||
class="rounded max-w-4"
|
||||
{#snippet alerts()}
|
||||
{#if mergeIntoParent}
|
||||
<ParentWorkspaceProtectionAlert
|
||||
{parentWorkspaceId}
|
||||
onUpdateCanDeploy={(canDeploy) => {
|
||||
canDeployToParent = canDeploy
|
||||
}}
|
||||
/>
|
||||
Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !==
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible}
|
||||
<Alert title="This fork has changes not visible to your user" type="warning" class="my-2">
|
||||
{#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.
|
||||
</Alert>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemSummary(item)}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const isSelectable = selectableDiffs.includes(diff)}
|
||||
{@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)
|
||||
)}
|
||||
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
|
||||
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
|
||||
{newSummary || diff.path}
|
||||
{:else if !existsInBothWorkspaces}
|
||||
{newSummary || oldSummary || diff.path}
|
||||
{:else}
|
||||
{newSummary || diff.path}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemActions(item)}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const targetOnBehalfOf = getTargetOnBehalfOf(key)}
|
||||
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
|
||||
{@const existsInBothWorkspaces = !(
|
||||
(diff.exists_in_fork && !diff.exists_in_source) ||
|
||||
(!diff.exists_in_fork && diff.exists_in_source)
|
||||
)}
|
||||
<!-- On-behalf-of selector -->
|
||||
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
|
||||
<OnBehalfOfSelector
|
||||
targetWorkspace={deployTargetWorkspace}
|
||||
targetValue={targetOnBehalfOf}
|
||||
selected={onBehalfOfChoice[key]}
|
||||
onSelect={(choice, details) => {
|
||||
onBehalfOfChoice[key] = choice
|
||||
if (details) customOnBehalfOf[key] = details
|
||||
}}
|
||||
kind={diff.kind}
|
||||
canPreserve={canPreserveOnBehalfOf}
|
||||
customValue={customOnBehalfOf[key]?.permissionedAs}
|
||||
/>
|
||||
{/if}
|
||||
{#if diff.kind === 'raw_app'}
|
||||
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
||||
{/if}
|
||||
<!-- Status badges -->
|
||||
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
|
||||
<Badge
|
||||
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
|
||||
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
|
||||
>Deleted</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
|
||||
<Badge
|
||||
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
|
||||
color="red"
|
||||
size="xs">Deleted</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
|
||||
<Badge
|
||||
title="This item was newly created in '{currentWorkspaceId}'"
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isConflict || existsInBothWorkspaces}
|
||||
{#if diff.ahead > 0}
|
||||
<Badge color="green" size="xs">
|
||||
<ArrowUpRight class="w-3 h-3 inline" />
|
||||
{diff.ahead} ahead
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if diff.behind > 0}
|
||||
<Badge color="blue" size="xs">
|
||||
<ArrowDownRight class="w-3 h-3 inline" />
|
||||
{diff.behind} behind
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if isConflict}
|
||||
<Badge color="orange" size="xs">
|
||||
<AlertTriangle class="w-3 h-3 inline" />
|
||||
Conflict
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class:invisible={!existsInBothWorkspaces}>
|
||||
<Button size="xs" variant="subtle" onclick={() => showDiff(diff.kind as Kind, diff.path)}>
|
||||
<DiffIcon class="w-3 h-3" />
|
||||
Show diff
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center justify-between">
|
||||
<div></div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
|
||||
{#if !(mergeIntoParent && !canDeployToParent)}
|
||||
<Button
|
||||
color="blue"
|
||||
disabled={selectedItems.length === 0 ||
|
||||
deploying ||
|
||||
(hasBehindChanges && !allowBehindChangesOverride) ||
|
||||
(mergeIntoParent && !canDeployToParent) ||
|
||||
hasUnselectedOnBehalfOf}
|
||||
loading={deploying}
|
||||
on:click={deployChanges}
|
||||
>
|
||||
{mergeIntoParent ? 'Deploy' : 'Update'}
|
||||
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
|
||||
{#if selectedConflicts != 0}
|
||||
({selectedConflicts} conflicts)
|
||||
{/if}
|
||||
</Button>
|
||||
{#if hasUnselectedOnBehalfOf}
|
||||
<span class="text-xs text-yellow-600">
|
||||
You must set the "on behalf of" user for all items before deploying
|
||||
<Tooltip class="text-yellow-600">
|
||||
The "run on behalf of" field defines which user's permissions will be applied
|
||||
during execution. Make sure this is set to an appropriate user before deploying.
|
||||
</Tooltip>
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if deploymentErrorMessage != ''}
|
||||
<Alert
|
||||
title="Cannot {mergeIntoParent ? 'deploy these changes' : 'update these items'}"
|
||||
type="error"
|
||||
class="my-2 max-w-80"
|
||||
>
|
||||
{#if conflictingDiffs.length > 0}
|
||||
<Alert title="Conflicting changes detected" type="warning" class="mt-2">
|
||||
<span>
|
||||
{deploymentErrorMessage}
|
||||
{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.
|
||||
</span>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</WorkspaceDeployLayout>
|
||||
|
||||
<!-- Fork Triggers Section -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="text-sm font-semibold">Triggers created in this fork</h3>
|
||||
{#if !loadingTriggers}
|
||||
<Badge color="indigo" size="xs"
|
||||
>{forkTriggers.length} trigger{forkTriggers.length !== 1 ? 's' : ''}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Alert title="Deploy and/or delete these triggers" type="info" class="mb-2">
|
||||
When forking a workspace, triggers are not forked to avoid unnecessary executions or
|
||||
collisions. If you created this triggers with the intention of deploying them to the parent
|
||||
workspace, you can do so here. Otherwise it is recommended to delete them or disable them.
|
||||
</Alert>
|
||||
|
||||
{#if loadingTriggers}
|
||||
<div class="flex items-center gap-2 text-secondary text-sm p-4">
|
||||
<Loader2 class="animate-spin w-4 h-4" />
|
||||
Loading triggers...
|
||||
</div>
|
||||
{:else if forkTriggers.length === 0}
|
||||
<div class="text-secondary text-sm p-4 border rounded-md bg-surface-tertiary">
|
||||
No triggers in this fork workspace.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border rounded-md bg-surface-tertiary">
|
||||
{#each forkTriggers as trigger (trigger.triggerKind + ':' + trigger.path)}
|
||||
<Row
|
||||
kind="trigger"
|
||||
triggerKind={trigger.triggerKind}
|
||||
path={trigger.path}
|
||||
href={getTriggerHref(trigger.triggerKind)}
|
||||
marked={undefined}
|
||||
isSelectable={false}
|
||||
canFavorite={false}
|
||||
workspaceId={currentWorkspaceId}
|
||||
>
|
||||
{#snippet customSummary()}
|
||||
<span>{getTriggerDisplayName(trigger.triggerKind)}</span>
|
||||
<span class="text-secondary mx-1">→</span>
|
||||
<span class="text-secondary">{trigger.scriptPath}</span>
|
||||
{#if trigger.isFlow}
|
||||
<Badge color="blue" size="xs">flow</Badge>
|
||||
{#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)}
|
||||
<Alert
|
||||
title="This fork is behind {parentWorkspaceId} and needs to be up to date before deploying"
|
||||
type="warning"
|
||||
class="my-2"
|
||||
>
|
||||
You have items behind '{parentWorkspaceId}'. You need to update and test your changes
|
||||
before being able to deploy.
|
||||
<span class="font-medium flex flex-row gap-1 text-red-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={allowBehindChangesOverride}
|
||||
class="rounded max-w-4"
|
||||
/>
|
||||
Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !==
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible}
|
||||
<Alert
|
||||
title="This fork has changes not visible to your user"
|
||||
type="warning"
|
||||
class="my-2"
|
||||
>
|
||||
{#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}
|
||||
{#if trigger.extraLabel}
|
||||
<span class="text-tertiary text-xs">({trigger.extraLabel})</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if trigger.enabled != null}
|
||||
<Badge color={trigger.enabled ? 'green' : 'gray'} size="xs">
|
||||
{trigger.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
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.
|
||||
</Alert>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemSummary(item)}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const isSelectable = selectableDiffs.includes(diff)}
|
||||
{@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)
|
||||
)}
|
||||
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
|
||||
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
|
||||
{newSummary || diff.path}
|
||||
{:else if !existsInBothWorkspaces}
|
||||
{newSummary || oldSummary || diff.path}
|
||||
{:else}
|
||||
{newSummary || diff.path}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemActions(item)}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const targetOnBehalfOf = getTargetOnBehalfOf(key)}
|
||||
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
|
||||
{@const existsInBothWorkspaces = !(
|
||||
(diff.exists_in_fork && !diff.exists_in_source) ||
|
||||
(!diff.exists_in_fork && diff.exists_in_source)
|
||||
)}
|
||||
<!-- On-behalf-of selector -->
|
||||
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
|
||||
<OnBehalfOfSelector
|
||||
targetWorkspace={deployTargetWorkspace}
|
||||
targetValue={targetOnBehalfOf}
|
||||
selected={onBehalfOfChoice[key]}
|
||||
onSelect={(choice, details) => {
|
||||
onBehalfOfChoice[key] = choice
|
||||
if (details) customOnBehalfOf[key] = details
|
||||
}}
|
||||
kind={diff.kind}
|
||||
canPreserve={canPreserveOnBehalfOf}
|
||||
customValue={customOnBehalfOf[key]?.permissionedAs}
|
||||
/>
|
||||
{/if}
|
||||
{#if diff.kind === 'raw_app'}
|
||||
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
||||
{/if}
|
||||
<!-- Status badges -->
|
||||
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
|
||||
<Badge
|
||||
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
|
||||
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
|
||||
>Deleted</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
|
||||
<Badge
|
||||
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
|
||||
color="red"
|
||||
size="xs">Deleted</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
|
||||
<Badge
|
||||
title="This item was newly created in '{currentWorkspaceId}'"
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isConflict || existsInBothWorkspaces}
|
||||
{#if diff.ahead > 0}
|
||||
<Badge color="green" size="xs">
|
||||
<ArrowUpRight class="w-3 h-3 inline" />
|
||||
{diff.ahead} ahead
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if diff.behind > 0}
|
||||
<Badge color="blue" size="xs">
|
||||
<ArrowDownRight class="w-3 h-3 inline" />
|
||||
{diff.behind} behind
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if isConflict}
|
||||
<Badge color="orange" size="xs">
|
||||
<AlertTriangle class="w-3 h-3 inline" />
|
||||
Conflict
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class:invisible={!existsInBothWorkspaces}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onclick={() => {
|
||||
deploymentDrawer?.openDrawer(trigger.path, 'trigger', {
|
||||
triggers: { kind: trigger.triggerKind }
|
||||
})
|
||||
}}
|
||||
onclick={() => showDiff(diff.kind as Kind, diff.path)}
|
||||
>
|
||||
<Upload size={12} />
|
||||
Deploy
|
||||
<DiffIcon class="w-3 h-3" />
|
||||
Show diff
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" color="red" onclick={() => deleteTrigger(trigger)}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Row>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center justify-between">
|
||||
<div></div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
|
||||
{#if !(mergeIntoParent && !canDeployToParent)}
|
||||
<Button
|
||||
disabled={selectedItems.length === 0 ||
|
||||
deploying ||
|
||||
(hasBehindChanges && !allowBehindChangesOverride) ||
|
||||
(mergeIntoParent && !canDeployToParent) ||
|
||||
hasUnselectedOnBehalfOf}
|
||||
loading={deploying}
|
||||
on:click={deployChanges}
|
||||
>
|
||||
{mergeIntoParent ? 'Deploy' : 'Update'}
|
||||
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
|
||||
{#if selectedConflicts != 0}
|
||||
({selectedConflicts} conflicts)
|
||||
{/if}
|
||||
</Button>
|
||||
{#if hasUnselectedOnBehalfOf}
|
||||
<span class="text-xs text-yellow-600">
|
||||
You must set the "on behalf of" user for all items before deploying
|
||||
<Tooltip class="text-yellow-600">
|
||||
The "run on behalf of" field defines which user's permissions will be
|
||||
applied during execution. Make sure this is set to an appropriate user
|
||||
before deploying.
|
||||
</Tooltip>
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if deploymentErrorMessage != ''}
|
||||
<Alert
|
||||
title="Cannot {mergeIntoParent ? 'deploy these changes' : 'update these items'}"
|
||||
type="error"
|
||||
class="my-2 max-w-80"
|
||||
>
|
||||
<span>
|
||||
{deploymentErrorMessage}
|
||||
</span>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</WorkspaceDeployLayout>
|
||||
</div>
|
||||
|
||||
<!-- Fork Triggers Section -->
|
||||
<div class="bg-surface-tertiary p-4 rounded-md border">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="text-sm font-semibold">Triggers created in this fork</h3>
|
||||
{#if !loadingTriggers}
|
||||
<Badge color="indigo" size="xs"
|
||||
>{forkTriggers.length} trigger{forkTriggers.length !== 1 ? 's' : ''}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Alert title="Deploy and/or delete these triggers" type="info" class="mb-2">
|
||||
When forking a workspace, triggers are not forked to avoid unnecessary executions or
|
||||
collisions. If you created this triggers with the intention of deploying them to the parent
|
||||
workspace, you can do so here. Otherwise it is recommended to delete them or disable them.
|
||||
</Alert>
|
||||
|
||||
{#if loadingTriggers}
|
||||
<div class="flex items-center gap-2 text-secondary text-sm p-4">
|
||||
<Loader2 class="animate-spin w-4 h-4" />
|
||||
Loading triggers...
|
||||
</div>
|
||||
{:else if forkTriggers.length === 0}
|
||||
<div class="text-secondary text-sm p-4 border rounded-md bg-surface-tertiary">
|
||||
No triggers in this fork workspace.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border rounded-md bg-surface-tertiary">
|
||||
{#each forkTriggers as trigger (trigger.triggerKind + ':' + trigger.path)}
|
||||
<Row
|
||||
kind="trigger"
|
||||
triggerKind={trigger.triggerKind}
|
||||
path={trigger.path}
|
||||
href={getTriggerHref(trigger.triggerKind)}
|
||||
marked={undefined}
|
||||
isSelectable={false}
|
||||
canFavorite={false}
|
||||
workspaceId={currentWorkspaceId}
|
||||
>
|
||||
{#snippet customSummary()}
|
||||
<span>{getTriggerDisplayName(trigger.triggerKind)}</span>
|
||||
<span class="text-secondary mx-1">→</span>
|
||||
<span class="text-secondary">{trigger.scriptPath}</span>
|
||||
{#if trigger.isFlow}
|
||||
<Badge color="blue" size="xs">flow</Badge>
|
||||
{/if}
|
||||
{#if trigger.extraLabel}
|
||||
<span class="text-tertiary text-xs">({trigger.extraLabel})</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if trigger.enabled != null}
|
||||
<Badge color={trigger.enabled ? 'green' : 'gray'} size="xs">
|
||||
{trigger.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{/if}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onclick={() => {
|
||||
deploymentDrawer?.openDrawer(trigger.path, 'trigger', {
|
||||
triggers: { kind: trigger.triggerKind }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Upload size={12} />
|
||||
Deploy
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onclick={() => deleteTrigger(trigger)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Row>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-tertiary p-4 rounded-md border">
|
||||
<DatatableSchemaDiff {currentWorkspaceId} {parentWorkspaceId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { type DBSchema } from '$lib/stores'
|
||||
import { ChevronDownIcon, EditIcon, Loader2, Plus, Table2, Trash2Icon } from 'lucide-svelte'
|
||||
import { superadmin, userStore, type DBSchema } from '$lib/stores'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
EditIcon,
|
||||
Loader2,
|
||||
Plus,
|
||||
Table2,
|
||||
Trash2Icon,
|
||||
UploadIcon
|
||||
} from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { ClearableInput, Drawer, DrawerContent } from './common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -36,6 +44,7 @@
|
||||
dbType: DbType
|
||||
dbSchema: DBSchema
|
||||
dbSupportsSchemas: boolean
|
||||
databaseIsEmpty?: boolean
|
||||
colDefs: Record<string, ColumnDef[]> | undefined
|
||||
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
|
||||
dbSchemaOps: IDbSchemaOps
|
||||
@@ -53,6 +62,7 @@
|
||||
disabledTables?: SelectedTable[]
|
||||
features?: DbFeatures
|
||||
asset?: Asset
|
||||
onImport?: (mode: 'schema_and_data' | 'schema_only') => void
|
||||
}
|
||||
let {
|
||||
dbType,
|
||||
@@ -60,6 +70,7 @@
|
||||
dbTableOpsFactory,
|
||||
dbSchemaOps,
|
||||
dbSupportsSchemas,
|
||||
databaseIsEmpty,
|
||||
colDefs,
|
||||
refresh,
|
||||
initialSchemaKey,
|
||||
@@ -71,7 +82,8 @@
|
||||
selectedTables = $bindable([]),
|
||||
disabledTables = [],
|
||||
features,
|
||||
asset
|
||||
asset,
|
||||
onImport
|
||||
}: Props = $props()
|
||||
|
||||
// Helper to check if a table is selected in multi-select mode
|
||||
@@ -525,6 +537,34 @@
|
||||
{#if tableKey && colDefs?.[tableKey]?.length}
|
||||
{@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey })}
|
||||
<DBTable {dbTableOps} bind:this={_dbTable} />
|
||||
{:else if databaseIsEmpty}
|
||||
<div class="h-full w-full center-center flex-col gap-4">
|
||||
<span class="text-hint">Database is empty</span>
|
||||
{#if onImport}
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
onclick={() => onImport('schema_only')}
|
||||
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
|
||||
>
|
||||
<UploadIcon size={64} class="text-secondary" />
|
||||
<span class="text-center font-normal text-sm text-secondary">
|
||||
Import schema from database
|
||||
</span>
|
||||
</button>
|
||||
{#if !!$userStore?.is_admin || !!$superadmin}
|
||||
<button
|
||||
onclick={() => onImport('schema_and_data')}
|
||||
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
|
||||
>
|
||||
<UploadIcon size={64} class="text-secondary" />
|
||||
<span class="text-center font-normal text-sm text-secondary">
|
||||
Import schema and data from database
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
@@ -575,7 +615,10 @@
|
||||
computePreview={async ({ values }) => {
|
||||
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
|
||||
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
|
||||
let sql = await dbSchemaOps.previewAlterSql({ values: diff, schema: selected.schemaKey })
|
||||
let sql = await dbSchemaOps.previewAlterSql({
|
||||
values: diff,
|
||||
schema: selected.schemaKey
|
||||
})
|
||||
let alert = !dbSupportsTransactionalDdl(dbType)
|
||||
? {
|
||||
title: capitalize(dbType) + ' does not support transactional DDL',
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
selectedTables?: SelectedTable[]
|
||||
/** Tables that are already added and should show as disabled */
|
||||
disabledTables?: SelectedTable[]
|
||||
onImport?: (mode: 'schema_and_data' | 'schema_only') => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -45,7 +46,8 @@
|
||||
dbSelector,
|
||||
multiSelectMode = false,
|
||||
selectedTables = $bindable([]),
|
||||
disabledTables = []
|
||||
disabledTables = [],
|
||||
onImport
|
||||
}: Props = $props()
|
||||
|
||||
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[getDbSchemasPath(input)])
|
||||
@@ -149,6 +151,7 @@
|
||||
</div>
|
||||
<DbManager
|
||||
dbSupportsSchemas={input?.type == 'database' && dbSupportsSchemas(input.resourceType)}
|
||||
databaseIsEmpty={!Object.values(dbSchema.schema).flatMap((s) => Object.values(s)).length}
|
||||
{dbSchema}
|
||||
colDefs={colDefs.current}
|
||||
dbTableOpsFactory={({ colDefs, tableKey }) =>
|
||||
@@ -172,6 +175,7 @@
|
||||
{dbType}
|
||||
refresh={() => refresh()}
|
||||
{dbSelector}
|
||||
{onImport}
|
||||
bind:selectedSchemaKey
|
||||
bind:selectedTableKey
|
||||
{multiSelectMode}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Drawer from './common/drawer/Drawer.svelte'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import Select from './select/Select.svelte'
|
||||
import { ArrowLeft, Expand, LoaderCircle, Minimize, RefreshCcw } from 'lucide-svelte'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Download,
|
||||
Expand,
|
||||
LoaderCircle,
|
||||
Minimize,
|
||||
RefreshCcw,
|
||||
Upload
|
||||
} from 'lucide-svelte'
|
||||
import DBManagerContent from './DBManagerContent.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { untrack } from 'svelte'
|
||||
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
|
||||
import ResourcePicker from './ResourcePicker.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
uriState: DbManagerUriState
|
||||
@@ -25,7 +38,9 @@
|
||||
const datatables = resource<string[]>([], async () => {
|
||||
if (!$workspaceStore) return []
|
||||
try {
|
||||
return await WorkspaceService.listDataTables({ workspace: $workspaceStore })
|
||||
return (await WorkspaceService.listDataTables({ workspace: $workspaceStore })).map(
|
||||
(d) => d.name
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatables:', e)
|
||||
return []
|
||||
@@ -64,6 +79,70 @@
|
||||
let dbManagerContent: DBManagerContent | undefined = $state()
|
||||
|
||||
let hasReplResult = $state(false)
|
||||
|
||||
// Export/Import state
|
||||
let exportDrawerOpen = $state(false)
|
||||
let exportResult = $state('')
|
||||
let importDrawerOpen = $state(false)
|
||||
let importLoading = $state(false)
|
||||
let importSource = $state<string | undefined>(undefined)
|
||||
let importBehavior = $state<'schema_only' | 'schema_and_data'>('schema_only')
|
||||
|
||||
let isPostgresqlInput = $derived(
|
||||
uriState.isDatatableInput ||
|
||||
(uriState.input?.type === 'database' && uriState.input.resourceType === 'postgresql')
|
||||
)
|
||||
let enableImportExport = $derived(isPostgresqlInput)
|
||||
|
||||
function toSourceIdentifier(raw: string): string {
|
||||
if (raw.startsWith('datatable://') || raw.startsWith('$res:')) return raw
|
||||
return `$res:${raw}`
|
||||
}
|
||||
|
||||
function currentSourceIdentifier(): string | undefined {
|
||||
const input = uriState.effectiveInput
|
||||
if (!input || input.type !== 'database') return undefined
|
||||
return toSourceIdentifier(input.resourcePath)
|
||||
}
|
||||
|
||||
async function handleExportSchema() {
|
||||
const source = currentSourceIdentifier()
|
||||
if (!source || !$workspaceStore) return
|
||||
try {
|
||||
exportResult = await WorkspaceService.exportPgSchema({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: { source }
|
||||
})
|
||||
exportDrawerOpen = true
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to export schema: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportDatabase() {
|
||||
if (!importSource || !$workspaceStore) return
|
||||
const target = currentSourceIdentifier()
|
||||
if (!target) return
|
||||
importLoading = true
|
||||
try {
|
||||
await WorkspaceService.importPgDatabase({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: {
|
||||
source: toSourceIdentifier(importSource),
|
||||
target,
|
||||
fork_behavior: importBehavior
|
||||
}
|
||||
})
|
||||
sendUserToast('Database import completed successfully')
|
||||
importDrawerOpen = false
|
||||
importSource = undefined
|
||||
dbManagerContent?.refresh()
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to import database: ${e}`, true)
|
||||
} finally {
|
||||
importLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={windowWidth} />
|
||||
@@ -96,6 +175,9 @@
|
||||
bind:hasReplResult
|
||||
bind:selectedSchemaKey={uriState.selectedSchema}
|
||||
bind:selectedTableKey={uriState.selectedTable}
|
||||
onImport={enableImportExport
|
||||
? (mode) => ((importDrawerOpen = true), (importBehavior = mode))
|
||||
: undefined}
|
||||
>
|
||||
{#snippet dbSelector()}
|
||||
{#if uriState.isDatatableInput}
|
||||
@@ -119,6 +201,12 @@
|
||||
{/key}
|
||||
{/if}
|
||||
{#snippet actions()}
|
||||
{#if enableImportExport}
|
||||
<Button startIcon={{ icon: Download }} onClick={handleExportSchema}>Export</Button>
|
||||
<Button startIcon={{ icon: Upload }} onClick={() => (importDrawerOpen = true)}>
|
||||
Import
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
loading={dbManagerContent?.isLoading() ?? false}
|
||||
on:click={() => {
|
||||
@@ -141,3 +229,68 @@
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:open={exportDrawerOpen} size="800px" offset={offset + 1}>
|
||||
<DrawerContent title="Export Schemas" on:close={() => (exportDrawerOpen = false)}>
|
||||
{#if exportResult}
|
||||
<div class="flex flex-col gap-2 h-full relative">
|
||||
<pre class="overflow-auto text-xs bg-surface-secondary p-4 rounded flex-1">{exportResult}</pre>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Copy }}
|
||||
wrapperClasses="absolute top-2 right-2"
|
||||
btnClasses="bg-surface-tertiary"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(exportResult)
|
||||
sendUserToast('Copied to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:open={importDrawerOpen} size="600px" offset={offset + 1}>
|
||||
<DrawerContent title="Import Database" on:close={() => (importDrawerOpen = false)}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Alert type="warning" title="Warning">
|
||||
This will import the schemas from the selected source into the current database. Existing
|
||||
tables with the same names may be affected.
|
||||
</Alert>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium">Source database</span>
|
||||
<ResourcePicker datatableAsPgResource bind:value={importSource} resourceType="postgresql" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium">Import mode</span>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'schema_only', label: 'Schema only' },
|
||||
...(isCloudHosted() || (!$superadmin && !$userStore?.is_admin)
|
||||
? []
|
||||
: [{ value: 'schema_and_data', label: 'Schema and data' }])
|
||||
]}
|
||||
bind:value={importBehavior}
|
||||
/>
|
||||
</div>
|
||||
{#if importBehavior === 'schema_and_data'}
|
||||
<Alert type="warning" title="Heavy operation">
|
||||
Importing schema and data will copy all rows from every table in the source database. This
|
||||
may take a long time and use significant storage space depending on the size of the
|
||||
source.
|
||||
</Alert>
|
||||
{/if}
|
||||
<Button
|
||||
disabled={!importSource}
|
||||
loading={importLoading}
|
||||
color="red"
|
||||
on:click={handleImportDatabase}
|
||||
>
|
||||
Import {importBehavior === 'schema_and_data' ? 'schemas and data' : 'schemas'} into current database
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
}: Props = $props()
|
||||
|
||||
let datatables = usePromise(() =>
|
||||
WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
|
||||
WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' }).then((d) =>
|
||||
d.map((d) => d.name)
|
||||
)
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
@@ -46,9 +47,6 @@
|
||||
{onClear}
|
||||
/>
|
||||
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'datatable', path: value })}
|
||||
<ExploreAssetButton
|
||||
class="mt-1 w-fit"
|
||||
asset={{ kind: 'datatable', path: value }}
|
||||
/>
|
||||
<ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'datatable', path: value }} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
<script module lang="ts">
|
||||
import type {
|
||||
TableEditorValues,
|
||||
TableEditorValuesColumn,
|
||||
TableEditorForeignKey
|
||||
} from '$lib/components/apps/components/display/dbtable/tableEditor'
|
||||
import {
|
||||
diffTableEditorValues,
|
||||
type AlterTableValues,
|
||||
makeAlterTableQueries
|
||||
} from '$lib/components/apps/components/display/dbtable/queries/alterTable'
|
||||
import { renderForeignKey } from '$lib/components/apps/components/display/dbtable/queries/dbQueriesUtils'
|
||||
import type { GetDatatableFullSchemaResponse } from '$lib/gen'
|
||||
|
||||
export type DatabaseSchema = Record<string, Record<string, TableEditorValues>>
|
||||
|
||||
export function apiSchemaToEditorSchema(
|
||||
apiSchema: GetDatatableFullSchemaResponse
|
||||
): DatabaseSchema {
|
||||
const result: DatabaseSchema = {}
|
||||
for (const [schemaName, tables] of Object.entries(apiSchema)) {
|
||||
result[schemaName] = {}
|
||||
for (const [tableName, table] of Object.entries(tables as Record<string, any>)) {
|
||||
if (!table || typeof table !== 'object') continue
|
||||
result[schemaName][tableName] = {
|
||||
name: table.name ?? tableName,
|
||||
columns: (table.columns ?? []).map(
|
||||
(c: any): TableEditorValuesColumn => ({
|
||||
name: c.name,
|
||||
datatype: c.datatype,
|
||||
primaryKey: c.primary_key ?? c.primaryKey,
|
||||
defaultValue: c.default_value ?? c.defaultValue,
|
||||
nullable: c.nullable
|
||||
})
|
||||
),
|
||||
foreignKeys: (table.foreign_keys ?? table.foreignKeys ?? []).map(
|
||||
(fk: any): TableEditorForeignKey => ({
|
||||
targetTable: fk.target_table ?? fk.targetTable,
|
||||
columns: (fk.columns ?? []).map((col: any) => ({
|
||||
sourceColumn: col.source_column ?? col.sourceColumn,
|
||||
targetColumn: col.target_column ?? col.targetColumn
|
||||
})),
|
||||
onDelete: (fk.on_delete ?? fk.onDelete ?? 'NO ACTION') as
|
||||
| 'CASCADE'
|
||||
| 'SET NULL'
|
||||
| 'NO ACTION',
|
||||
onUpdate: (fk.on_update ?? fk.onUpdate ?? 'NO ACTION') as
|
||||
| 'CASCADE'
|
||||
| 'SET NULL'
|
||||
| 'NO ACTION',
|
||||
fk_constraint_name: fk.fk_constraint_name
|
||||
})
|
||||
),
|
||||
pk_constraint_name: table.pk_constraint_name
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type TableDiff = {
|
||||
schemaName: string
|
||||
tableName: string
|
||||
kind: 'added' | 'removed' | 'modified'
|
||||
operations?: AlterTableValues
|
||||
}
|
||||
|
||||
export type DatatableDiff = {
|
||||
datatableName: string
|
||||
aheadChanges: TableDiff[]
|
||||
behindChanges: TableDiff[]
|
||||
originalSchema: DatabaseSchema
|
||||
parentSchema: DatabaseSchema
|
||||
forkSchema: DatabaseSchema
|
||||
}
|
||||
|
||||
export function diffDatabaseSchemas(
|
||||
original: DatabaseSchema,
|
||||
current: DatabaseSchema
|
||||
): TableDiff[] {
|
||||
const diffs: TableDiff[] = []
|
||||
const allSchemas = new Set([...Object.keys(original), ...Object.keys(current)])
|
||||
for (const schemaName of allSchemas) {
|
||||
const origTables = original[schemaName] ?? {}
|
||||
const currTables = current[schemaName] ?? {}
|
||||
const allTables = new Set([...Object.keys(origTables), ...Object.keys(currTables)])
|
||||
for (const tableName of allTables) {
|
||||
const origTable = origTables[tableName]
|
||||
const currTable = currTables[tableName]
|
||||
if (!origTable && currTable) {
|
||||
diffs.push({ schemaName, tableName, kind: 'added' })
|
||||
} else if (origTable && !currTable) {
|
||||
diffs.push({ schemaName, tableName, kind: 'removed' })
|
||||
} else if (origTable && currTable) {
|
||||
const currWithInitial: TableEditorValues = {
|
||||
...currTable,
|
||||
columns: currTable.columns.map((col) => ({
|
||||
...col,
|
||||
initialName: col.name,
|
||||
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
|
||||
}))
|
||||
}
|
||||
const origTableTransformed: TableEditorValues = {
|
||||
...origTable,
|
||||
columns: origTable.columns.map((col) => ({
|
||||
...col,
|
||||
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
|
||||
}))
|
||||
}
|
||||
const diff = diffTableEditorValues(origTableTransformed, currWithInitial)
|
||||
if (diff.operations.length > 0) {
|
||||
diffs.push({ schemaName, tableName, kind: 'modified', operations: diff })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
export function computeDatatableDiff(
|
||||
datatableName: string,
|
||||
originalSchema: DatabaseSchema,
|
||||
parentSchema: DatabaseSchema,
|
||||
forkSchema: DatabaseSchema
|
||||
): DatatableDiff {
|
||||
return {
|
||||
datatableName,
|
||||
behindChanges: diffDatabaseSchemas(originalSchema, parentSchema),
|
||||
aheadChanges: diffDatabaseSchemas(originalSchema, forkSchema),
|
||||
originalSchema,
|
||||
parentSchema,
|
||||
forkSchema
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect PostgreSQL auto-increment columns and return the serial type + cleaned props.
|
||||
* e.g. bigint + nextval('seq'::regclass) → BIGSERIAL (no DEFAULT needed) */
|
||||
function resolveColumnType(c: TableEditorValuesColumn): {
|
||||
datatype: string
|
||||
defaultValue: string | undefined
|
||||
} {
|
||||
const dv = c.defaultValue ?? ''
|
||||
if (/^{?nextval\(/.test(dv)) {
|
||||
const dt = c.datatype?.toLowerCase() ?? ''
|
||||
if (dt === 'bigint') return { datatype: 'BIGSERIAL', defaultValue: undefined }
|
||||
if (dt === 'integer' || dt === 'int') return { datatype: 'SERIAL', defaultValue: undefined }
|
||||
if (dt === 'smallint') return { datatype: 'SMALLSERIAL', defaultValue: undefined }
|
||||
}
|
||||
return { datatype: c.datatype, defaultValue: c.defaultValue }
|
||||
}
|
||||
|
||||
export function generateMigrationSql(change: TableDiff, sourceSchema: DatabaseSchema): string {
|
||||
if (change.kind === 'modified' && change.operations) {
|
||||
const queries = makeAlterTableQueries(change.operations, 'postgresql', change.schemaName)
|
||||
if (queries.length === 0) return ''
|
||||
return 'BEGIN;\n' + queries.join('\n') + '\nCOMMIT;'
|
||||
}
|
||||
if (change.kind === 'added') {
|
||||
const table = sourceSchema[change.schemaName]?.[change.tableName]
|
||||
if (!table) return ''
|
||||
const colDefs = table.columns
|
||||
.map((c) => {
|
||||
const { datatype, defaultValue } = resolveColumnType(c)
|
||||
let def = `"${c.name}" ${datatype}`
|
||||
if (c.nullable === false) def += ' NOT NULL'
|
||||
if (defaultValue) def += ` DEFAULT ${defaultValue}`
|
||||
return def
|
||||
})
|
||||
.join(',\n ')
|
||||
const pkCols = table.columns.filter((c) => c.primaryKey).map((c) => `"${c.name}"`)
|
||||
const pkLine = pkCols.length > 0 ? `,\n PRIMARY KEY (${pkCols.join(', ')})` : ''
|
||||
const qualifiedName = `"${change.schemaName}"."${change.tableName}"`
|
||||
let sql = `BEGIN;\nCREATE TABLE ${qualifiedName} (\n ${colDefs}${pkLine}\n);`
|
||||
for (const fk of table.foreignKeys ?? []) {
|
||||
const fkSql = renderForeignKey(fk, {
|
||||
useSchema: true,
|
||||
dbType: 'postgresql',
|
||||
tableName: change.tableName
|
||||
})
|
||||
sql += `\nALTER TABLE ${qualifiedName} ADD ${fkSql};`
|
||||
}
|
||||
sql += '\nCOMMIT;'
|
||||
return sql
|
||||
}
|
||||
if (change.kind === 'removed') {
|
||||
return `BEGIN;\nDROP TABLE IF EXISTS "${change.schemaName}"."${change.tableName}";\nCOMMIT;`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { Loader2, ChevronDown, ChevronRight, Plus, Minus, Pencil, Eye } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
|
||||
import YAML from 'yaml'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
|
||||
|
||||
interface Props {
|
||||
currentWorkspaceId: string
|
||||
parentWorkspaceId: string
|
||||
}
|
||||
|
||||
let { currentWorkspaceId, parentWorkspaceId }: Props = $props()
|
||||
|
||||
let loading = $state(true)
|
||||
let error: string | undefined = $state(undefined)
|
||||
let diffs: DatatableDiff[] = $state([])
|
||||
let expandedDatatables: Set<string> = $state(new Set())
|
||||
|
||||
// Drawer state
|
||||
let drawerOpen = $state(false)
|
||||
let drawerChange: TableDiff | undefined = $state(undefined)
|
||||
let drawerDiff: DatatableDiff | undefined = $state(undefined)
|
||||
let drawerDirection: 'ahead' | 'behind' | undefined = $state(undefined)
|
||||
let migrationSql = $state('')
|
||||
let migrationRunning = $state(false)
|
||||
let confirmDeployOpen = $state(false)
|
||||
|
||||
async function loadDiffs() {
|
||||
loading = true
|
||||
error = undefined
|
||||
diffs = []
|
||||
try {
|
||||
const forkSettings = await WorkspaceService.getSettings({
|
||||
workspace: currentWorkspaceId
|
||||
})
|
||||
const datatables = forkSettings.datatable?.datatables ?? {}
|
||||
const forkedEntries = Object.entries(datatables).filter(([_, dt]) => dt.forked_from != null)
|
||||
if (forkedEntries.length === 0) {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
const results: DatatableDiff[] = []
|
||||
for (const [dtName, dt] of forkedEntries) {
|
||||
try {
|
||||
const originalSchema = apiSchemaToEditorSchema((dt.forked_from as any)?.schema ?? {})
|
||||
const [parentSchemaRaw, forkSchemaRaw] = await Promise.all([
|
||||
WorkspaceService.getDatatableFullSchema({
|
||||
workspace: parentWorkspaceId,
|
||||
requestBody: { source: `datatable://${dtName}` }
|
||||
}),
|
||||
WorkspaceService.getDatatableFullSchema({
|
||||
workspace: currentWorkspaceId,
|
||||
requestBody: { source: `datatable://${dtName}` }
|
||||
})
|
||||
])
|
||||
const parentSchema = apiSchemaToEditorSchema(parentSchemaRaw)
|
||||
const forkSchema = apiSchemaToEditorSchema(forkSchemaRaw)
|
||||
const diff = computeDatatableDiff(dtName, originalSchema, parentSchema, forkSchema)
|
||||
if (diff.aheadChanges.length > 0 || diff.behindChanges.length > 0) {
|
||||
results.push(diff)
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to diff datatable ${dtName}:`, e)
|
||||
}
|
||||
}
|
||||
diffs = results
|
||||
} catch (e: any) {
|
||||
error = e?.body ?? e?.message ?? String(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void [currentWorkspaceId, parentWorkspaceId]
|
||||
loadDiffs()
|
||||
})
|
||||
|
||||
function toggleExpanded(name: string) {
|
||||
const next = new Set(expandedDatatables)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
expandedDatatables = next
|
||||
}
|
||||
|
||||
function operationSummary(d: TableDiff): string {
|
||||
if (d.kind === 'added') return 'New table'
|
||||
if (d.kind === 'removed') return 'Deleted table'
|
||||
const ops = d.operations?.operations ?? []
|
||||
const parts: string[] = []
|
||||
const adds = ops.filter((o) => o.kind === 'addColumn').length
|
||||
const drops = ops.filter((o) => o.kind === 'dropColumn').length
|
||||
const alters = ops.filter((o) => o.kind === 'alterColumn').length
|
||||
const renames = ops.filter((o) => o.kind === 'renameTable').length
|
||||
const fkAdds = ops.filter((o) => o.kind === 'addForeignKey').length
|
||||
const fkDrops = ops.filter((o) => o.kind === 'dropForeignKey').length
|
||||
const pkChanges = ops.filter(
|
||||
(o) => o.kind === 'addPrimaryKey' || o.kind === 'dropPrimaryKey'
|
||||
).length
|
||||
if (adds) parts.push(`+${adds} col`)
|
||||
if (drops) parts.push(`-${drops} col`)
|
||||
if (alters) parts.push(`~${alters} col`)
|
||||
if (renames) parts.push('renamed')
|
||||
if (fkAdds) parts.push(`+${fkAdds} FK`)
|
||||
if (fkDrops) parts.push(`-${fkDrops} FK`)
|
||||
if (pkChanges) parts.push('PK changed')
|
||||
return parts.join(', ') || 'Modified'
|
||||
}
|
||||
|
||||
function openReview(change: TableDiff, diff: DatatableDiff, direction: 'ahead' | 'behind') {
|
||||
drawerChange = change
|
||||
drawerDiff = diff
|
||||
drawerDirection = direction
|
||||
// ahead = fork changed → migration runs on parent to deploy
|
||||
// behind = parent changed → migration runs on fork to update
|
||||
const sourceSchema = direction === 'ahead' ? diff.forkSchema : diff.parentSchema
|
||||
migrationSql =
|
||||
'-- Migration is auto-generated on a best-effort basis. You can adjust it here \n\n' +
|
||||
generateMigrationSql(change, sourceSchema)
|
||||
drawerOpen = true
|
||||
}
|
||||
|
||||
function getDiffYaml(): { original: string; modified: string } {
|
||||
if (!drawerChange || !drawerDiff || !drawerDirection) return { original: '', modified: '' }
|
||||
const { schemaName, tableName } = drawerChange
|
||||
const origTable = drawerDiff.originalSchema[schemaName]?.[tableName]
|
||||
// ahead = fork changed → show original vs fork
|
||||
// behind = parent changed → show original vs parent
|
||||
const changedSchema =
|
||||
drawerDirection === 'ahead' ? drawerDiff.forkSchema : drawerDiff.parentSchema
|
||||
const changedTable = changedSchema[schemaName]?.[tableName]
|
||||
return {
|
||||
original: origTable ? YAML.stringify(origTable) : '# table does not exist',
|
||||
modified: changedTable ? YAML.stringify(changedTable) : '# table does not exist'
|
||||
}
|
||||
}
|
||||
|
||||
async function runMigration() {
|
||||
if (!drawerDiff || !drawerChange || !drawerDirection) return
|
||||
migrationRunning = true
|
||||
|
||||
// ahead → run on parent; behind → run on fork
|
||||
const targetWorkspace = drawerDirection === 'ahead' ? parentWorkspaceId : currentWorkspaceId
|
||||
const dtName = drawerDiff.datatableName
|
||||
|
||||
try {
|
||||
await runScriptAndPollResult({
|
||||
workspace: targetWorkspace,
|
||||
requestBody: {
|
||||
args: { database: `datatable://${dtName}` },
|
||||
language: 'postgresql',
|
||||
content: migrationSql
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
migrationRunning = false
|
||||
return
|
||||
}
|
||||
|
||||
// Update forked_from.schema for the migrated table
|
||||
try {
|
||||
const sourceSchema =
|
||||
drawerDirection === 'ahead' ? drawerDiff.forkSchema : drawerDiff.parentSchema
|
||||
const { schemaName, tableName } = drawerChange
|
||||
const newTableDef = sourceSchema[schemaName]?.[tableName]
|
||||
|
||||
const forkSettings = await WorkspaceService.getSettings({
|
||||
workspace: currentWorkspaceId
|
||||
})
|
||||
const datatableConfig = forkSettings.datatable ?? { datatables: {} }
|
||||
const dtConfig = datatableConfig.datatables[dtName]
|
||||
if (dtConfig?.forked_from) {
|
||||
const forkedFrom = dtConfig.forked_from as any
|
||||
if (!forkedFrom.schema) forkedFrom.schema = {}
|
||||
if (!forkedFrom.schema[schemaName]) forkedFrom.schema[schemaName] = {}
|
||||
if (newTableDef) {
|
||||
forkedFrom.schema[schemaName][tableName] = newTableDef
|
||||
} else {
|
||||
delete forkedFrom.schema[schemaName][tableName]
|
||||
}
|
||||
await WorkspaceService.editDataTableConfig({
|
||||
workspace: currentWorkspaceId,
|
||||
requestBody: { settings: datatableConfig }
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to update forked_from schema:', e)
|
||||
}
|
||||
|
||||
migrationRunning = false
|
||||
drawerOpen = false
|
||||
sendUserToast('Migration applied successfully')
|
||||
await loadDiffs()
|
||||
}
|
||||
</script>
|
||||
|
||||
<h3 class="text-sm font-semibold">Datatable schema changes</h3>
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 text-xs text-tertiary py-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" /> Loading datatable diffs...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-xs text-red-500 py-2">Failed to load datatable diffs: {error}</div>
|
||||
{:else if diffs.length > 0}
|
||||
<div class="flex flex-col gap-2 mt-3 mb-1">
|
||||
{#each diffs as diff}
|
||||
<ResizeTransitionWrapper class="border rounded-md" innerClass="w-full" vertical>
|
||||
<button
|
||||
class="w-full flex items-center justify-between px-3 py-2 hover:bg-surface-hover"
|
||||
onclick={() => toggleExpanded(diff.datatableName)}
|
||||
>
|
||||
<span class="text-xs font-medium">{diff.datatableName}</span>
|
||||
<div class="flex items-center gap-2 text-2xs text-tertiary">
|
||||
{#if diff.aheadChanges.length > 0}
|
||||
<span class="text-blue-500">{diff.aheadChanges.length} ahead</span>
|
||||
{/if}
|
||||
{#if diff.behindChanges.length > 0}
|
||||
<span class="text-orange-500">{diff.behindChanges.length} behind</span>
|
||||
{/if}
|
||||
{#if expandedDatatables.has(diff.datatableName)}
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
{:else}
|
||||
<ChevronRight class="w-3 h-3" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if expandedDatatables.has(diff.datatableName)}
|
||||
<div class="border-t divide-y">
|
||||
{#if diff.aheadChanges.length > 0}
|
||||
<div class="px-3 py-1.5">
|
||||
<div class="text-2xs font-semibold text-blue-500 mb-1">Fork changes (ahead)</div>
|
||||
{#each diff.aheadChanges as change}
|
||||
<div class="flex items-center gap-2 text-xs py-0.5">
|
||||
{#if change.kind === 'added'}
|
||||
<Plus class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else if change.kind === 'removed'}
|
||||
<Minus class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Pencil class="w-3 h-3 text-yellow-500 shrink-0" />
|
||||
{/if}
|
||||
<span class="text-tertiary">{change.schemaName}.</span>
|
||||
<span class="font-medium">{change.tableName}</span>
|
||||
<span class="text-tertiary text-2xs grow">{operationSummary(change)}</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Eye }}
|
||||
onclick={() => openReview(change, diff, 'ahead')}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if diff.behindChanges.length > 0}
|
||||
<div class="px-3 py-1.5">
|
||||
<div class="text-2xs font-semibold text-orange-500 mb-1">
|
||||
Parent changes (behind)
|
||||
</div>
|
||||
{#each diff.behindChanges as change}
|
||||
<div class="flex items-center gap-2 text-xs py-0.5">
|
||||
{#if change.kind === 'added'}
|
||||
<Plus class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else if change.kind === 'removed'}
|
||||
<Minus class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Pencil class="w-3 h-3 text-yellow-500 shrink-0" />
|
||||
{/if}
|
||||
<span class="text-tertiary">{change.schemaName}.</span>
|
||||
<span class="font-medium">{change.tableName}</span>
|
||||
<span class="text-tertiary text-2xs grow">{operationSummary(change)}</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Eye }}
|
||||
onclick={() => openReview(change, diff, 'behind')}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-xs text-secondary"> No changes detected </span>
|
||||
{/if}
|
||||
|
||||
<Drawer bind:open={drawerOpen} size="900px">
|
||||
{#if drawerChange && drawerDiff && drawerDirection}
|
||||
{@const yaml = getDiffYaml()}
|
||||
<DrawerContent
|
||||
on:close={() => (drawerOpen = false)}
|
||||
title="{drawerChange.schemaName}.{drawerChange.tableName} ({drawerDirection === 'ahead'
|
||||
? 'Fork → Parent'
|
||||
: 'Parent → Fork'})"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={migrationRunning}
|
||||
onclick={() => {
|
||||
if (drawerDirection === 'ahead') {
|
||||
confirmDeployOpen = true
|
||||
} else {
|
||||
runMigration()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Run migration
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="flex flex-col h-full">
|
||||
<Alert title="Changes to {drawerChange.tableName}" type="info">
|
||||
{#if drawerDirection == 'ahead'}
|
||||
You have made these changes in {currentWorkspaceId} that are not yet deployed in {parentWorkspaceId}.
|
||||
{:else if drawerDirection == 'behind'}
|
||||
These changes were made in {parentWorkspaceId} but the current workspace is not up to date.
|
||||
{/if}
|
||||
</Alert>
|
||||
<!-- Diff section -->
|
||||
<div style="height: 45%;">
|
||||
<div class="py-1.5 text-2xs font-semibold text-secondary">
|
||||
Schema diff (parent ↔ fork)
|
||||
</div>
|
||||
<div class="h-[calc(100%-28px)] border rounded-md overflow-clip">
|
||||
{#await import('$lib/components/DiffEditor.svelte')}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
{:then Module}
|
||||
<Module.default
|
||||
open={true}
|
||||
automaticLayout
|
||||
className="h-full"
|
||||
defaultLang="yaml"
|
||||
defaultOriginal={yaml.original}
|
||||
defaultModified={yaml.modified}
|
||||
readOnly
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQL migration section -->
|
||||
<div class="flex flex-col grow overflow-hidden mt-4">
|
||||
<div class="py-1.5 text-2xs font-semibold text-secondary"> SQL migration </div>
|
||||
<div class="grow overflow-clip rounded-md border">
|
||||
<SimpleEditor class="h-full" lang="sql" bind:code={migrationSql} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
{/if}
|
||||
</Drawer>
|
||||
|
||||
<ConfirmationModal
|
||||
open={confirmDeployOpen}
|
||||
title="Deploy to parent workspace"
|
||||
confirmationText="Run migration"
|
||||
onConfirmed={async () => {
|
||||
confirmDeployOpen = false
|
||||
await runMigration()
|
||||
}}
|
||||
onCanceled={() => {
|
||||
confirmDeployOpen = false
|
||||
}}
|
||||
>
|
||||
<p class="text-sm">
|
||||
This will run the following SQL on workspace <b>{parentWorkspaceId}</b>:
|
||||
</p>
|
||||
<pre
|
||||
class="mt-2 p-3 bg-surface-secondary rounded text-xs font-mono whitespace-pre-wrap max-h-60 overflow-auto"
|
||||
>{migrationSql}</pre
|
||||
>
|
||||
</ConfirmationModal>
|
||||
@@ -1573,7 +1573,9 @@
|
||||
|
||||
let customTsTypesData = resource([() => lang], async () => {
|
||||
if (lang !== 'typescript') return undefined
|
||||
let datatables = await WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
|
||||
let datatables = (
|
||||
await WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
|
||||
).map((d) => d.name)
|
||||
let ducklakes = await WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' })
|
||||
return { datatables, ducklakes }
|
||||
})
|
||||
|
||||
@@ -791,7 +791,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
|
||||
itemName="data table"
|
||||
loadItems={async () =>
|
||||
(await WorkspaceService.listDataTables({ workspace: $workspaceStore ?? 'NO_W' })).map(
|
||||
(path) => ({ path })
|
||||
(d) => ({ path: d.name })
|
||||
)}
|
||||
>
|
||||
{#snippet submission()}
|
||||
|
||||
@@ -192,15 +192,13 @@
|
||||
</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}
|
||||
<Button size="xs" color="blue" on:click={openComparisonDrawer}>
|
||||
{#if (comparison?.summary.total_ahead ?? 0) > 0}
|
||||
Review & Deploy Changes
|
||||
{:else}
|
||||
Review & Update fork
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,9 +127,11 @@
|
||||
|
||||
if (datatableAsPgResource && resourceType === 'postgresql') {
|
||||
try {
|
||||
const datatables = await WorkspaceService.listDataTables({
|
||||
workspace: effectiveWorkspace
|
||||
})
|
||||
const datatables = (
|
||||
await WorkspaceService.listDataTables({
|
||||
workspace: effectiveWorkspace
|
||||
})
|
||||
).map((d) => d.name)
|
||||
for (const dt of datatables) {
|
||||
nc.push({
|
||||
value: `datatable://${dt}`,
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<!-- Select all row -->
|
||||
<div class="px-4 py-2 flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center gap-2 text-secondary text-sm"
|
||||
class="flex items-center gap-2 text-secondary text-xs"
|
||||
class:opacity-50={!hasSelectableItems}
|
||||
>
|
||||
<input
|
||||
@@ -143,13 +143,13 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-gray-500">{emptyMessage}</div>
|
||||
<div class="text-hint text-xs">{emptyMessage}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
{#if footer}
|
||||
<div class="p-4 bg-surface">
|
||||
<div class="pt-4">
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
})
|
||||
|
||||
let datatables = resource([], () =>
|
||||
WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
|
||||
WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' }).then((d) =>
|
||||
d.map((d) => d.name)
|
||||
)
|
||||
)
|
||||
let ducklakes = resource([], () =>
|
||||
WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' })
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
{#if open}
|
||||
<div
|
||||
transition:fadeFast|local
|
||||
class={'fixed top-0 bottom-0 left-0 right-0 z-[5000]'}
|
||||
class={'fixed top-0 bottom-0 left-0 right-0 z-[9999]'}
|
||||
role="dialog"
|
||||
{id}
|
||||
>
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
const datatables = resource<string[]>([], async () => {
|
||||
if (!$workspaceStore) return []
|
||||
try {
|
||||
return await WorkspaceService.listDataTables({ workspace: $workspaceStore })
|
||||
return (await WorkspaceService.listDataTables({ workspace: $workspaceStore })).map(
|
||||
(d) => d.name
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatables:', e)
|
||||
return []
|
||||
|
||||
@@ -13,7 +13,7 @@ export function createDatatablesResource(getWorkspace: () => string | undefined)
|
||||
const workspace = getWorkspace()
|
||||
if (!workspace) return []
|
||||
try {
|
||||
return await WorkspaceService.listDataTables({ workspace })
|
||||
return (await WorkspaceService.listDataTables({ workspace })).map((d) => d.name)
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatables:', e)
|
||||
return []
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
itemButtonWrapperClasses,
|
||||
size = 'md',
|
||||
showPlaceholderOnOpen = false,
|
||||
dropdownClass,
|
||||
transformInputSelectedText,
|
||||
groupBy,
|
||||
sortBy,
|
||||
@@ -80,6 +81,7 @@
|
||||
itemButtonWrapperClasses?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showPlaceholderOnOpen?: boolean
|
||||
dropdownClass?: string
|
||||
transformInputSelectedText?: (text: string, value: Value) => string
|
||||
groupBy?: (item: Item) => string
|
||||
sortBy?: (a: Item, b: Item) => number
|
||||
@@ -203,6 +205,7 @@
|
||||
{id}
|
||||
/>
|
||||
<SelectDropdown
|
||||
class={dropdownClass}
|
||||
{disablePortal}
|
||||
onSelectValue={setValue}
|
||||
{open}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clearStores } from '$lib/storeUtils'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -81,8 +82,47 @@
|
||||
goto('/user/workspaces')
|
||||
}
|
||||
|
||||
type ForkedDatatable = {
|
||||
name: string
|
||||
resourceType: string
|
||||
resourcePath: string
|
||||
dropOnDelete: boolean
|
||||
}
|
||||
let forkedDatatables: ForkedDatatable[] = $state([])
|
||||
|
||||
async function loadForkedDatatables() {
|
||||
if (!$workspaceStore) return
|
||||
try {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore })
|
||||
const datatables = settings.datatable?.datatables ?? {}
|
||||
forkedDatatables = Object.entries(datatables)
|
||||
.filter(([_, dt]) => dt.forked_from != null)
|
||||
.map(([name, dt]) => ({
|
||||
name,
|
||||
resourceType: dt.database.resource_type ?? 'instance',
|
||||
resourcePath: dt.database.resource_path ?? '',
|
||||
dropOnDelete: true
|
||||
}))
|
||||
} catch {
|
||||
forkedDatatables = []
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFork() {
|
||||
await WorkspaceService.deleteWorkspace({ workspace: $workspaceStore ?? '' })
|
||||
const workspace = $workspaceStore ?? ''
|
||||
const dbsToDrop = forkedDatatables.filter((dt) => dt.dropOnDelete).map((dt) => dt.name)
|
||||
|
||||
if (dbsToDrop.length > 0) {
|
||||
const errors = await WorkspaceService.dropForkedDatatableDatabases({
|
||||
workspace,
|
||||
requestBody: { datatable_names: dbsToDrop }
|
||||
})
|
||||
for (const err of errors) {
|
||||
sendUserToast(err, true)
|
||||
}
|
||||
}
|
||||
|
||||
await WorkspaceService.deleteWorkspace({ workspace })
|
||||
sendUserToast('You deleted the workspace')
|
||||
clearStores()
|
||||
goto('/user/workspaces')
|
||||
@@ -440,7 +480,8 @@
|
||||
? [
|
||||
{
|
||||
label: 'Delete Forked Workspace',
|
||||
action: () => {
|
||||
action: async () => {
|
||||
await loadForkedDatatables()
|
||||
deleteWorkspaceForkModal = true
|
||||
},
|
||||
icon: Trash2,
|
||||
@@ -756,6 +797,29 @@
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to delete this workspace fork? (deleting {$workspaceStore})</span>
|
||||
{#if forkedDatatables.length > 0}
|
||||
<div class="border rounded-md divide-y">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-secondary"> Forked databases </div>
|
||||
{#each forkedDatatables as dt}
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium text-secondary">{dt.name}</span>
|
||||
<span class="text-3xs text-hint">
|
||||
{dt.resourceType === 'instance'
|
||||
? dt.resourcePath
|
||||
: `${$workspaceStore?.replace(/-/g, '_')}__${dt.name}`}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
class="shrink-0"
|
||||
size="xs"
|
||||
bind:checked={dt.dropOnDelete}
|
||||
options={{ right: 'Drop database' }}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
workspaceStore,
|
||||
workspaceUsageStore,
|
||||
workspaceColor,
|
||||
clearWorkspaceFromStorage
|
||||
clearWorkspaceFromStorage,
|
||||
globalForkModal
|
||||
} from '$lib/stores'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { Building, Plus, Settings, GitFork } from 'lucide-svelte'
|
||||
@@ -86,7 +87,7 @@
|
||||
})
|
||||
|
||||
const itemClass =
|
||||
'text-primary flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
'text-primary w-full flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
</script>
|
||||
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
@@ -178,9 +179,13 @@
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !strictWorkspaceSelect && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')}
|
||||
{#if !strictWorkspaceSelect && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem href="{base}/user/fork_workspace" class={itemClass} {item}>
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
{item}
|
||||
onClick={() => (globalForkModal.val = { opened: true })}
|
||||
>
|
||||
<GitFork size={16} />
|
||||
Fork current workspace
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,749 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import {
|
||||
JobService,
|
||||
ResourceService,
|
||||
SettingService,
|
||||
UserService,
|
||||
VariableService,
|
||||
WorkspaceService,
|
||||
type AIProvider,
|
||||
type CompletedJob,
|
||||
type GetCopilotInfoResponse
|
||||
} from '$lib/gen'
|
||||
import { validateUsername } from '$lib/utils'
|
||||
import { logoutWithRedirect } from '$lib/logoutKit'
|
||||
import { page } from '$app/state'
|
||||
import { usersWorkspaceStore, workspaceStore } from '$lib/stores'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import TestAIKey from '$lib/components/copilot/TestAIKey.svelte'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { AI_PROVIDERS } from '$lib/components/copilot/lib'
|
||||
import { GitFork, LoaderCircle } from 'lucide-svelte'
|
||||
import PrefixedInput from '../PrefixedInput.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { jobManager } from '$lib/services/JobManager'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import { GitForkIcon } from 'lucide-svelte'
|
||||
import CreateWorkspaceInner from './CreateWorkspaceInner.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/state'
|
||||
|
||||
interface Props {
|
||||
isFork?: boolean
|
||||
}
|
||||
|
||||
let { isFork = false }: Props = $props()
|
||||
|
||||
const rd = page.url.searchParams.get('rd')
|
||||
|
||||
let id = $state('')
|
||||
let name = $state('')
|
||||
let username = $state('')
|
||||
|
||||
let errorId = $state('')
|
||||
let errorUser = $state('')
|
||||
let aiKey = $state('')
|
||||
let codeCompletionEnabled = $state(true)
|
||||
let checking = $state(false)
|
||||
let createLoading = $state(false)
|
||||
let aiSetupLoading = $state(false)
|
||||
let creationStep = $state<'details' | 'ai'>('details')
|
||||
let createdWorkspaceId: string | undefined = $state(undefined)
|
||||
|
||||
let workspaceColor: string | undefined = $state(undefined)
|
||||
let colorEnabled = $state(false)
|
||||
|
||||
function generateRandomColor() {
|
||||
const randomColor =
|
||||
'#' +
|
||||
Math.floor(Math.random() * 16777215)
|
||||
.toString(16)
|
||||
.padStart(6, '0')
|
||||
workspaceColor = randomColor
|
||||
}
|
||||
|
||||
async function validateName(id: string): Promise<void> {
|
||||
checking = true
|
||||
let exists = await WorkspaceService.existsWorkspace({ requestBody: { id } })
|
||||
if (exists) {
|
||||
errorId = 'ID already exists'
|
||||
} else if (id != '' && !/^\w+(-\w+)*$/.test(id)) {
|
||||
errorId = 'ID can only contain letters, numbers and dashes and must not finish by a dash'
|
||||
} else {
|
||||
errorId = ''
|
||||
}
|
||||
checking = false
|
||||
}
|
||||
|
||||
const WM_FORK_PREFIX = 'wm-fork-'
|
||||
|
||||
let forkCreationLoading = $state(false)
|
||||
let forkCreationError = $state('')
|
||||
let errorMsgs: string[] = $state([])
|
||||
let failedSyncJobs: string[] = $state([])
|
||||
|
||||
function getErrorMessage(error: any): string {
|
||||
return (
|
||||
error?.body?.error?.message ||
|
||||
error?.body?.message ||
|
||||
(typeof error?.body === 'string' ? error.body : null) ||
|
||||
error?.message ||
|
||||
'Unknown error'
|
||||
)
|
||||
}
|
||||
|
||||
function hasEffectiveAi(copilotInfo: GetCopilotInfoResponse): boolean {
|
||||
return Object.keys(copilotInfo.providers ?? {}).length > 0
|
||||
}
|
||||
|
||||
async function finishWorkspaceSetup(workspaceId: string): Promise<void> {
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(workspaceId)
|
||||
goto(rd ?? '/')
|
||||
}
|
||||
|
||||
async function getWorkspaceUsername(workspaceId: string): Promise<string> {
|
||||
if (!automateUsernameCreation) {
|
||||
return username
|
||||
}
|
||||
|
||||
const user = await UserService.whoami({
|
||||
workspace: workspaceId
|
||||
})
|
||||
return user.username
|
||||
}
|
||||
|
||||
async function maybeShowAiSetupStep(workspaceId: string): Promise<void> {
|
||||
try {
|
||||
const copilotInfo = await WorkspaceService.getCopilotInfo({
|
||||
workspace: workspaceId
|
||||
})
|
||||
|
||||
if (hasEffectiveAi(copilotInfo)) {
|
||||
await finishWorkspaceSetup(workspaceId)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check effective AI configuration for new workspace', error)
|
||||
sendUserToast(
|
||||
'Workspace created, but Windmill AI availability could not be verified. You can configure it later in Workspace settings.',
|
||||
true
|
||||
)
|
||||
await finishWorkspaceSetup(workspaceId)
|
||||
return
|
||||
}
|
||||
|
||||
createdWorkspaceId = workspaceId
|
||||
creationStep = 'ai'
|
||||
aiKey = ''
|
||||
codeCompletionEnabled = true
|
||||
selected = 'openai'
|
||||
}
|
||||
|
||||
async function fetchFailedSyncJobs(jobs: string[]): Promise<CompletedJob[]> {
|
||||
let ret: CompletedJob[] = []
|
||||
for (const job of jobs) {
|
||||
let j = await JobService.getCompletedJob({
|
||||
id: job,
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
ret.push(j)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
function isPathVersionLessThan(path: string | undefined, version: number): boolean {
|
||||
if (!path || !path.startsWith('hub/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = path.split('/')
|
||||
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
const embeddedVersion = parseInt(parts[1], 10)
|
||||
|
||||
if (isNaN(embeddedVersion)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return embeddedVersion < version
|
||||
}
|
||||
|
||||
async function createOrForkWorkspace() {
|
||||
const prefixed_id = `${WM_FORK_PREFIX}${id}`
|
||||
if (isFork) {
|
||||
if ($workspaceStore) {
|
||||
forkCreationLoading = true
|
||||
errorMsgs = []
|
||||
failedSyncJobs = []
|
||||
forkCreationError = ''
|
||||
|
||||
let gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
gitSyncJobIds.map((jobId) =>
|
||||
jobManager.runWithProgress(() => Promise.resolve(jobId), {
|
||||
workspace: $workspaceStore,
|
||||
timeout: 60000,
|
||||
timeoutMessage: `Deploy fork job timed out after 60s`,
|
||||
onProgress: (status) => {
|
||||
if (status.status === 'failure') {
|
||||
errorMsgs.push(status.error ?? 'Deploy fork job failed')
|
||||
failedSyncJobs.push(jobId)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
`Could not fork workspace ${$workspaceStore} because branch creation failed: ${errorMsgs} - ${error}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
if (errorMsgs.length != 0) {
|
||||
forkCreationError = 'Failed to create a branch for this fork on the git sync repo(s)'
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
`Could not fork workspace ${$workspaceStore} because branch creation failed: ${errorMsgs}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await WorkspaceService.createWorkspaceFork({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
forkCreationError = `Failed to create fork '${prefixed_id}'`
|
||||
errorMsgs.push(e?.body ?? e ?? 'Unknown error')
|
||||
forkCreationLoading = false
|
||||
sendUserToast(`Could not create fork '${prefixed_id}' ${e}`, true)
|
||||
return
|
||||
}
|
||||
|
||||
forkCreationLoading = false
|
||||
sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`)
|
||||
await finishWorkspaceSetup(prefixed_id)
|
||||
} else {
|
||||
sendUserToast('No workspace selected, cannot fork non-existent workspace', true)
|
||||
}
|
||||
} else {
|
||||
createLoading = true
|
||||
try {
|
||||
const workspaceId = await createWorkspace()
|
||||
await maybeShowAiSetupStep(workspaceId)
|
||||
} finally {
|
||||
createLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkspace(): Promise<string> {
|
||||
await WorkspaceService.createWorkspace({
|
||||
requestBody: {
|
||||
id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
username: automateUsernameCreation ? undefined : username
|
||||
}
|
||||
})
|
||||
if (auto_invite) {
|
||||
await WorkspaceService.editAutoInvite({
|
||||
workspace: id,
|
||||
requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd }
|
||||
})
|
||||
}
|
||||
|
||||
sendUserToast(`Created workspace id: ${id}`)
|
||||
return id
|
||||
}
|
||||
|
||||
async function saveWorkspaceAiSetup(): Promise<void> {
|
||||
if (!createdWorkspaceId || !aiKey) {
|
||||
return
|
||||
}
|
||||
|
||||
aiSetupLoading = true
|
||||
try {
|
||||
const actualUsername = await getWorkspaceUsername(createdWorkspaceId)
|
||||
const path = `u/${actualUsername}/${selected}_windmill_codegen`
|
||||
|
||||
await VariableService.createVariable({
|
||||
workspace: createdWorkspaceId,
|
||||
requestBody: {
|
||||
path,
|
||||
value: aiKey,
|
||||
is_secret: true,
|
||||
description: 'Ai token'
|
||||
}
|
||||
})
|
||||
await ResourceService.createResource({
|
||||
workspace: createdWorkspaceId,
|
||||
requestBody: {
|
||||
path,
|
||||
value: {
|
||||
api_key: '$var:' + path
|
||||
},
|
||||
resource_type: selected
|
||||
}
|
||||
})
|
||||
await WorkspaceService.editCopilotConfig({
|
||||
workspace: createdWorkspaceId,
|
||||
requestBody: {
|
||||
providers: {
|
||||
[selected]: {
|
||||
resource_path: path,
|
||||
models: [AI_PROVIDERS[selected].defaultModels[0]]
|
||||
}
|
||||
},
|
||||
default_model: {
|
||||
model: AI_PROVIDERS[selected].defaultModels[0],
|
||||
provider: selected
|
||||
},
|
||||
code_completion_model: codeCompletionEnabled
|
||||
? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected }
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
|
||||
sendUserToast('Windmill AI configured')
|
||||
await finishWorkspaceSetup(createdWorkspaceId)
|
||||
} catch (error) {
|
||||
sendUserToast(`Failed to configure Windmill AI: ${getErrorMessage(error)}`, true)
|
||||
} finally {
|
||||
aiSetupLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
if (key === 'Enter') {
|
||||
event.preventDefault()
|
||||
createOrForkWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
function handleAiKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
if (key === 'Enter' && aiKey) {
|
||||
event.preventDefault()
|
||||
saveWorkspaceAiSetup()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkspaces() {
|
||||
if (!$usersWorkspaceStore) {
|
||||
try {
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
} catch {}
|
||||
}
|
||||
if (!$usersWorkspaceStore) {
|
||||
const url = page.url
|
||||
console.log('logout 2')
|
||||
await logoutWithRedirect(url.href.replace(url.origin, ''))
|
||||
}
|
||||
}
|
||||
|
||||
let automateUsernameCreation = $state(true)
|
||||
async function getAutomateUsernameCreationSetting() {
|
||||
automateUsernameCreation =
|
||||
((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? true
|
||||
|
||||
if (!automateUsernameCreation) {
|
||||
UserService.globalWhoami().then((x) => {
|
||||
let uname = ''
|
||||
if (x.name) {
|
||||
uname = x.name.split(' ')[0]
|
||||
} else {
|
||||
uname = x.email.split('@')[0]
|
||||
}
|
||||
uname = uname.replace(/\./gi, '')
|
||||
username = uname.toLowerCase()
|
||||
})
|
||||
}
|
||||
}
|
||||
getAutomateUsernameCreationSetting()
|
||||
|
||||
onMount(() => {
|
||||
loadWorkspaces()
|
||||
|
||||
WorkspaceService.isDomainAllowed().then((x) => {
|
||||
isDomainAllowed = x
|
||||
})
|
||||
})
|
||||
|
||||
let isDomainAllowed: undefined | boolean = $state(undefined)
|
||||
|
||||
let auto_invite = $state(false)
|
||||
let operatorOnly = $state(false)
|
||||
let autoAdd = $state(true)
|
||||
let selected: Exclude<AIProvider, 'customai'> = $state('openai')
|
||||
let modalTitle = $derived(
|
||||
isFork ? 'Fork Workspace' : creationStep === 'ai' ? 'Set up Windmill AI' : 'New Workspace'
|
||||
)
|
||||
run(() => {
|
||||
id = name.toLowerCase().replace(/\s/gi, '-')
|
||||
})
|
||||
run(() => {
|
||||
validateName(id)
|
||||
})
|
||||
run(() => {
|
||||
errorUser = validateUsername(username)
|
||||
})
|
||||
run(() => {
|
||||
colorEnabled && !workspaceColor && generateRandomColor()
|
||||
})
|
||||
let domain = $derived($usersWorkspaceStore?.email.split('@')[1])
|
||||
</script>
|
||||
|
||||
<CenteredModal title={modalTitle} centerVertically={false}>
|
||||
<div class="flex flex-col gap-8">
|
||||
{#if isFork}
|
||||
<div class="flex flex-block gap-2">
|
||||
<GitFork size={16} />
|
||||
<span class="text-xs text-normal">Forking </span>
|
||||
<span class="text-xs text-emphasis font-semibold">
|
||||
{$workspaceStore}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if errorMsgs.length != 0}
|
||||
<Alert class="p-2" title={forkCreationError} type="error">
|
||||
<ul class="pl-2 pr-4 break-words pb-5">
|
||||
{#each errorMsgs as errorMsg}
|
||||
<li><pre class="whitespace-pre-wrap">- {errorMsg}</pre></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if failedSyncJobs.length != 0}
|
||||
More details on the jobs that failed:
|
||||
{#await fetchFailedSyncJobs(failedSyncJobs)}
|
||||
<LoaderCircle class="animate-spin" />
|
||||
{:then failedJobs}
|
||||
<ul class="pl-2 pr-4 break-words">
|
||||
{#each failedJobs as job}
|
||||
<li>
|
||||
-
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${job.id}?workspace=${$workspaceStore}`}
|
||||
>
|
||||
{job.id}
|
||||
</a>
|
||||
</li>
|
||||
<!-- This 28073 is the version where git sync on fork was introduced -->
|
||||
{#if isPathVersionLessThan(job.script_path, 28073)}
|
||||
<div class="font-bold">
|
||||
This job was not running the latest version of the git sync script available on
|
||||
the hub. You might be able to solve this issue by going to `Workspace Settings`
|
||||
-> `Git Sync` and updating the script.
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{:catch error}
|
||||
Tried to fetch jobs to get more information, but failed: {error}. Here are the failed
|
||||
job ids:
|
||||
<ul class="pl-2 pr-4 break-words">
|
||||
{#each failedSyncJobs as jobId}
|
||||
<li>
|
||||
-
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${jobId}?workspace=${$workspaceStore}`}
|
||||
>
|
||||
{jobId}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/await}
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if isFork || creationStep === 'details'}
|
||||
<label class="flex flex-col gap-1">
|
||||
{#if isFork}
|
||||
<span class="text-xs font-semibold text-emphasis">Fork name</span>
|
||||
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
|
||||
<span class="text-xs text-secondary">Displayable name</span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
|
||||
{#if isFork}
|
||||
<span class="text-xs text-secondary"
|
||||
>Slug to uniquely identify your fork (this will also set the branch name)</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
|
||||
{/if}
|
||||
|
||||
{#if isFork}
|
||||
<PrefixedInput
|
||||
prefix={WM_FORK_PREFIX}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="example.com"
|
||||
class={errorId != '' ? 'input-error' : ''}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput bind:value={id} error={errorId} />
|
||||
{/if}
|
||||
{#if errorId}
|
||||
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace color</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>Color to identify the current workspace in the list of workspaces</span
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
||||
{#if colorEnabled}
|
||||
<div class="flex items-center gap-1 grow">
|
||||
<input
|
||||
class="grow min-w-10"
|
||||
type="color"
|
||||
bind:value={workspaceColor}
|
||||
disabled={!colorEnabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
class="w-24"
|
||||
bind:value={workspaceColor}
|
||||
inputProps={{ disabled: !colorEnabled }}
|
||||
/>
|
||||
<Button
|
||||
on:click={generateRandomColor}
|
||||
size="xs"
|
||||
variant="default"
|
||||
disabled={!colorEnabled}>Random</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{#if !automateUsernameCreation}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Your username in that workspace</span>
|
||||
<TextInput
|
||||
bind:value={username}
|
||||
inputProps={{ onkeyup: handleCreateKeyUp }}
|
||||
error={errorUser}
|
||||
/>
|
||||
{#if errorUser}
|
||||
<span class="text-red-500 text-2xs">{errorUser}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{#if !isFork}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
|
||||
>{isCloudHosted()
|
||||
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
|
||||
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
|
||||
>
|
||||
<Toggle
|
||||
id="auto-invite"
|
||||
disabled={isCloudHosted() && !isDomainAllowed}
|
||||
bind:checked={auto_invite}
|
||||
/>
|
||||
{#if isCloudHosted() && isDomainAllowed == false}
|
||||
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
|
||||
{/if}
|
||||
|
||||
{#if auto_invite}
|
||||
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
{#if isCloudHosted()}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Mode</span>
|
||||
<span class="text-xs text-secondary font-normal"
|
||||
>Whether to invite or add users directly to the workspace.</span
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
selected={autoAdd ? 'add' : 'invite'}
|
||||
on:selected={async (e) => {
|
||||
autoAdd = e.detail === 'add'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="invite" label="Auto-invite" {item} />
|
||||
<ToggleButton value="add" label="Auto-add" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="font-semibold flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Role</span>
|
||||
<span class="text-xs text-secondary font-normal">Role of the auto-invited users</span>
|
||||
<ToggleButtonGroup
|
||||
selected={operatorOnly ? 'operator' : 'developer'}
|
||||
on:selected={(e) => {
|
||||
operatorOnly = e.detail == 'operator'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="operator" label="Operator" {item} />
|
||||
<ToggleButton value="developer" label="Developer" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
|
||||
<Button
|
||||
disabled={forkCreationLoading || createLoading}
|
||||
variant="default"
|
||||
size="sm"
|
||||
href="{base}/user/workspaces">← Back to workspaces</Button
|
||||
>
|
||||
{#if !forkCreationLoading}
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={createLoading}
|
||||
disabled={createLoading ||
|
||||
checking ||
|
||||
errorId != '' ||
|
||||
!name ||
|
||||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
|
||||
!id}
|
||||
on:click={createOrForkWorkspace}
|
||||
>
|
||||
{#if isFork}
|
||||
Fork workspace
|
||||
{:else}
|
||||
Create workspace
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="accent" disabled={true}>
|
||||
<LoaderCircle class="animate-spin" /> Creating branch
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="ai-key" class="flex flex-row gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">
|
||||
Set up Windmill AI
|
||||
<Tooltip>
|
||||
Find out how it can help you <a
|
||||
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">in the docs</a
|
||||
>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</label>
|
||||
<span class="text-xs text-secondary">
|
||||
Windmill AI powers the chat, code generation, flow creation, and code completion. Set
|
||||
it up now or configure it later in Workspace settings.
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<ToggleButtonGroup bind:selected class="mt-4">
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="openai" label="OpenAI" {item} />
|
||||
<ToggleButton value="anthropic" label="Anthropic" {item} />
|
||||
<ToggleButton value="mistral" label="Mistral" {item} />
|
||||
<ToggleButton value="deepseek" label="DeepSeek" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<div class="flex flex-row gap-1">
|
||||
<input
|
||||
id="ai-key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={aiKey}
|
||||
onkeyup={handleAiKeyUp}
|
||||
/>
|
||||
<TestAIKey
|
||||
apiKey={aiKey}
|
||||
disabled={!aiKey}
|
||||
aiProvider={selected}
|
||||
model={AI_PROVIDERS[selected].defaultModels[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if aiKey}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
disabled={!aiKey}
|
||||
bind:checked={codeCompletionEnabled}
|
||||
options={{ right: 'Enable code completion' }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={aiSetupLoading || !createdWorkspaceId}
|
||||
on:click={() => createdWorkspaceId && finishWorkspaceSetup(createdWorkspaceId)}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={aiSetupLoading}
|
||||
disabled={aiSetupLoading || !aiKey}
|
||||
on:click={saveWorkspaceAiSetup}
|
||||
>
|
||||
Set up AI
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<CenteredModal title="{isFork ? 'Forking' : 'New'} Workspace" centerVertically={false}>
|
||||
{#if isFork}
|
||||
<div class="flex flex-block gap-2">
|
||||
<GitForkIcon size={16} />
|
||||
<span class="text-xs text-normal">Forking </span>
|
||||
<span class="text-xs text-emphasis font-semibold">
|
||||
{$workspaceStore}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<CreateWorkspaceInner {isFork} onFinish={() => goto(rd ?? '/')} />
|
||||
</CenteredModal>
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
JobService,
|
||||
ResourceService,
|
||||
SettingService,
|
||||
UserService,
|
||||
VariableService,
|
||||
WorkspaceService,
|
||||
type AIProvider,
|
||||
type CompletedJob
|
||||
} from '$lib/gen'
|
||||
import { validateUsername } from '$lib/utils'
|
||||
import { logoutWithRedirect } from '$lib/logoutKit'
|
||||
import { page } from '$app/state'
|
||||
import { usersWorkspaceStore, workspaceStore } from '$lib/stores'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import TestAIKey from '$lib/components/copilot/TestAIKey.svelte'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { AI_PROVIDERS } from '$lib/components/copilot/lib'
|
||||
import { LoaderCircle } from 'lucide-svelte'
|
||||
import PrefixedInput from '../PrefixedInput.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { jobManager } from '$lib/services/JobManager'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import Label from '../Label.svelte'
|
||||
import ForkDatatableSection from './ForkDatatableSection.svelte'
|
||||
|
||||
interface Props {
|
||||
isFork?: boolean
|
||||
onFinish?: () => void
|
||||
}
|
||||
|
||||
let { isFork = false, onFinish }: Props = $props()
|
||||
|
||||
let id = $state('')
|
||||
let name = $state('')
|
||||
let username = $state('')
|
||||
|
||||
let errorId = $state('')
|
||||
let errorUser = $state('')
|
||||
let aiKey = $state('')
|
||||
let codeCompletionEnabled = $state(true)
|
||||
let checking = $state(false)
|
||||
|
||||
let forkDatatableSection: ReturnType<typeof ForkDatatableSection> | undefined = $state(undefined)
|
||||
|
||||
let workspaceColor: string | undefined = $state(undefined)
|
||||
let colorEnabled = $state(false)
|
||||
|
||||
function generateRandomColor() {
|
||||
const randomColor =
|
||||
'#' +
|
||||
Math.floor(Math.random() * 16777215)
|
||||
.toString(16)
|
||||
.padStart(6, '0')
|
||||
workspaceColor = randomColor
|
||||
}
|
||||
|
||||
async function validateName(id: string): Promise<void> {
|
||||
checking = true
|
||||
let exists = await WorkspaceService.existsWorkspace({ requestBody: { id } })
|
||||
if (exists) {
|
||||
errorId = 'ID already exists'
|
||||
} else if (id != '' && !/^\w+(-\w+)*$/.test(id)) {
|
||||
errorId = 'ID can only contain letters, numbers and dashes and must not finish by a dash'
|
||||
} else {
|
||||
errorId = ''
|
||||
}
|
||||
checking = false
|
||||
}
|
||||
|
||||
const WM_FORK_PREFIX = 'wm-fork-'
|
||||
|
||||
let forkCreationLoading = $state(false)
|
||||
let forkCreationError = $state('')
|
||||
let errorMsgs: string[] = $state([])
|
||||
let failedSyncJobs: string[] = $state([])
|
||||
|
||||
async function fetchFailedSyncJobs(jobs: string[]): Promise<CompletedJob[]> {
|
||||
let ret: CompletedJob[] = []
|
||||
for (const job of jobs) {
|
||||
let j = await JobService.getCompletedJob({
|
||||
id: job,
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
ret.push(j)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
function isPathVersionLessThan(path: string | undefined, version: number): boolean {
|
||||
if (!path || !path.startsWith('hub/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = path.split('/')
|
||||
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
const embeddedVersion = parseInt(parts[1], 10)
|
||||
|
||||
if (isNaN(embeddedVersion)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return embeddedVersion < version
|
||||
}
|
||||
|
||||
async function createOrForkWorkspace() {
|
||||
const prefixed_id = `${WM_FORK_PREFIX}${id}`
|
||||
if (isFork) {
|
||||
await forkWorkspace(prefixed_id)
|
||||
} else {
|
||||
await createWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
async function forkWorkspace(prefixed_id: string): Promise<void> {
|
||||
if ($workspaceStore) {
|
||||
forkCreationLoading = true
|
||||
errorMsgs = []
|
||||
failedSyncJobs = []
|
||||
forkCreationError = ''
|
||||
|
||||
// Clone datatables BEFORE creating the workspace fork
|
||||
if (forkDatatableSection) {
|
||||
const queue = forkDatatableSection.buildCloneQueue(prefixed_id)
|
||||
if (queue.length > 0) {
|
||||
forkDatatableSection.startCloning(queue)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await completeFork(prefixed_id)
|
||||
} else {
|
||||
sendUserToast('No workspace selected, cannot fork non-existent workspace', true)
|
||||
}
|
||||
}
|
||||
|
||||
async function completeFork(prefixed_id: string): Promise<void> {
|
||||
let gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
gitSyncJobIds.map((jobId) =>
|
||||
jobManager.runWithProgress(() => Promise.resolve(jobId), {
|
||||
workspace: $workspaceStore!,
|
||||
timeout: 60000,
|
||||
timeoutMessage: `Deploy fork job timed out after 60s`,
|
||||
onProgress: (status) => {
|
||||
if (status.status === 'failure') {
|
||||
errorMsgs.push(status.error ?? 'Deploy fork job failed')
|
||||
failedSyncJobs.push(jobId)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
`Could not fork workspace ${$workspaceStore} because branch creation failed: ${errorMsgs} - ${error}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
if (errorMsgs.length != 0) {
|
||||
forkCreationError = 'Failed to create a branch for this fork on the git sync repo(s)'
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
`Could not fork workspace ${$workspaceStore} because branch creation failed: ${errorMsgs}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Build forked_datatables info from completed clone jobs
|
||||
const forkedDatatables = forkDatatableSection
|
||||
? forkDatatableSection.getCompletedCloneJobs().map((job) => ({
|
||||
name: job.name,
|
||||
new_dbname: job._newDbName
|
||||
}))
|
||||
: []
|
||||
|
||||
try {
|
||||
await WorkspaceService.createWorkspaceFork({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
forked_datatables: forkedDatatables
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
forkCreationError = `Failed to create fork '${prefixed_id}'`
|
||||
errorMsgs.push(e?.body ?? e ?? 'Unknown error')
|
||||
forkCreationLoading = false
|
||||
sendUserToast(`Could not create fork '${prefixed_id}' ${e}`, true)
|
||||
return
|
||||
}
|
||||
|
||||
forkCreationLoading = false
|
||||
sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`)
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(prefixed_id)
|
||||
|
||||
onFinish?.()
|
||||
}
|
||||
|
||||
async function createWorkspace(): Promise<void> {
|
||||
await WorkspaceService.createWorkspace({
|
||||
requestBody: {
|
||||
id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
username: automateUsernameCreation ? undefined : username
|
||||
}
|
||||
})
|
||||
if (auto_invite) {
|
||||
await WorkspaceService.editAutoInvite({
|
||||
workspace: id,
|
||||
requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd }
|
||||
})
|
||||
}
|
||||
if (aiKey != '') {
|
||||
let actualUsername = username
|
||||
if (automateUsernameCreation) {
|
||||
const user = await UserService.whoami({
|
||||
workspace: id
|
||||
})
|
||||
actualUsername = user.username
|
||||
}
|
||||
let path = `u/${actualUsername}/${selected}_windmill_codegen`
|
||||
await VariableService.createVariable({
|
||||
workspace: id,
|
||||
requestBody: {
|
||||
path,
|
||||
value: aiKey,
|
||||
is_secret: true,
|
||||
description: 'Ai token'
|
||||
}
|
||||
})
|
||||
await ResourceService.createResource({
|
||||
workspace: id,
|
||||
requestBody: {
|
||||
path,
|
||||
value: {
|
||||
api_key: '$var:' + path
|
||||
},
|
||||
resource_type: selected
|
||||
}
|
||||
})
|
||||
await WorkspaceService.editCopilotConfig({
|
||||
workspace: id,
|
||||
requestBody: aiKey
|
||||
? {
|
||||
providers: {
|
||||
[selected]: {
|
||||
resource_path: path,
|
||||
models: [AI_PROVIDERS[selected].defaultModels[0]]
|
||||
}
|
||||
},
|
||||
default_model: {
|
||||
model: AI_PROVIDERS[selected].defaultModels[0],
|
||||
provider: selected
|
||||
},
|
||||
code_completion_model: codeCompletionEnabled
|
||||
? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected }
|
||||
: undefined
|
||||
}
|
||||
: {}
|
||||
})
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(id)
|
||||
}
|
||||
|
||||
sendUserToast(`Created workspace id: ${id}`)
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(id)
|
||||
onFinish?.()
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
if (key === 'Enter') {
|
||||
event.preventDefault()
|
||||
createWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkspaces() {
|
||||
if (!$usersWorkspaceStore) {
|
||||
try {
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
} catch {}
|
||||
}
|
||||
if (!$usersWorkspaceStore) {
|
||||
const url = page.url
|
||||
console.log('logout 2')
|
||||
await logoutWithRedirect(url.href.replace(url.origin, ''))
|
||||
}
|
||||
}
|
||||
|
||||
let automateUsernameCreation = $state(true)
|
||||
async function getAutomateUsernameCreationSetting() {
|
||||
automateUsernameCreation =
|
||||
((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? true
|
||||
|
||||
if (!automateUsernameCreation) {
|
||||
UserService.globalWhoami().then((x) => {
|
||||
let uname = ''
|
||||
if (x.name) {
|
||||
uname = x.name.split(' ')[0]
|
||||
} else {
|
||||
uname = x.email.split('@')[0]
|
||||
}
|
||||
uname = uname.replace(/\./gi, '')
|
||||
username = uname.toLowerCase()
|
||||
})
|
||||
}
|
||||
}
|
||||
getAutomateUsernameCreationSetting()
|
||||
|
||||
onMount(() => {
|
||||
loadWorkspaces()
|
||||
|
||||
WorkspaceService.isDomainAllowed().then((x) => {
|
||||
isDomainAllowed = x
|
||||
})
|
||||
})
|
||||
|
||||
let isDomainAllowed: undefined | boolean = $state(undefined)
|
||||
|
||||
let auto_invite = $state(false)
|
||||
let operatorOnly = $state(false)
|
||||
let autoAdd = $state(true)
|
||||
let selected: Exclude<AIProvider, 'customai'> = $state('openai')
|
||||
run(() => {
|
||||
id = name.toLowerCase().replace(/\s/gi, '-')
|
||||
})
|
||||
run(() => {
|
||||
validateName(id)
|
||||
})
|
||||
run(() => {
|
||||
errorUser = validateUsername(username)
|
||||
})
|
||||
run(() => {
|
||||
colorEnabled && !workspaceColor && generateRandomColor()
|
||||
})
|
||||
let domain = $derived($usersWorkspaceStore?.email.split('@')[1])
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex-1 relative min-h-[32rem]">
|
||||
<div class="flex flex-col gap-8 absolute inset-0 overflow-y-auto">
|
||||
{#if errorMsgs.length != 0}
|
||||
<Alert class="p-2" title={forkCreationError} type="error">
|
||||
<ul class="pl-2 pr-4 break-words pb-5">
|
||||
{#each errorMsgs as errorMsg}
|
||||
<li><pre class="whitespace-pre-wrap">- {errorMsg}</pre></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if failedSyncJobs.length != 0}
|
||||
More details on the jobs that failed:
|
||||
{#await fetchFailedSyncJobs(failedSyncJobs)}
|
||||
<LoaderCircle class="animate-spin" />
|
||||
{:then failedJobs}
|
||||
<ul class="pl-2 pr-4 break-words">
|
||||
{#each failedJobs as job}
|
||||
<li>
|
||||
-
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${job.id}?workspace=${$workspaceStore}`}
|
||||
>
|
||||
{job.id}
|
||||
</a>
|
||||
</li>
|
||||
<!-- This 28073 is the version where git sync on fork was introduced -->
|
||||
{#if isPathVersionLessThan(job.script_path, 28073)}
|
||||
<div class="font-bold">
|
||||
This job was not running the latest version of the git sync script available
|
||||
on the hub. You might be able to solve this issue by going to `Workspace
|
||||
Settings` -> `Git Sync` and updating the script.
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{:catch error}
|
||||
Tried to fetch jobs to get more information, but failed: {error}. Here are the failed
|
||||
job ids:
|
||||
<ul class="pl-2 pr-4 break-words">
|
||||
{#each failedSyncJobs as jobId}
|
||||
<li>
|
||||
-
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${jobId}?workspace=${$workspaceStore}`}
|
||||
>
|
||||
{jobId}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/await}
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1">
|
||||
{#if isFork}
|
||||
<span class="text-xs font-semibold text-emphasis">Fork name</span>
|
||||
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
|
||||
<span class="text-xs text-secondary">Displayable name</span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
|
||||
{#if isFork}
|
||||
<span class="text-xs text-secondary"
|
||||
>Slug to uniquely identify your fork (this will also set the branch name)</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
|
||||
{/if}
|
||||
|
||||
{#if isFork}
|
||||
<PrefixedInput
|
||||
prefix={WM_FORK_PREFIX}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="example.com"
|
||||
class={errorId != '' ? 'input-error' : ''}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput bind:value={id} error={errorId} />
|
||||
{/if}
|
||||
{#if errorId}
|
||||
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<Label label="Workspace color">
|
||||
<span class="text-xs text-secondary">
|
||||
Color to identify the current workspace in the list of workspaces
|
||||
</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
||||
{#if colorEnabled}
|
||||
<div class="flex items-center gap-1 grow">
|
||||
<input
|
||||
class="grow min-w-10"
|
||||
type="color"
|
||||
bind:value={workspaceColor}
|
||||
disabled={!colorEnabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
class="w-24"
|
||||
bind:value={workspaceColor}
|
||||
inputProps={{ disabled: !colorEnabled }}
|
||||
/>
|
||||
<Button
|
||||
on:click={generateRandomColor}
|
||||
size="xs"
|
||||
variant="default"
|
||||
disabled={!colorEnabled}>Random</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Label>
|
||||
{#if isFork}
|
||||
<ForkDatatableSection
|
||||
bind:this={forkDatatableSection}
|
||||
onAllDone={() => {
|
||||
completeFork(`${WM_FORK_PREFIX}${id}`)
|
||||
}}
|
||||
onCanceled={() => {
|
||||
forkCreationLoading = false
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if !automateUsernameCreation}
|
||||
<Label label="Your username in that workspace">
|
||||
<TextInput
|
||||
bind:value={username}
|
||||
inputProps={{ onkeyup: handleKeyUp }}
|
||||
error={errorUser}
|
||||
/>
|
||||
{#if errorUser}
|
||||
<span class="text-red-500 text-2xs">{errorUser}</span>
|
||||
{/if}
|
||||
</Label>
|
||||
{/if}
|
||||
{#if !isFork}
|
||||
<div class="block">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="ai-key" class="flex flex-row gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">
|
||||
AI key for Windmill AI
|
||||
<Tooltip>
|
||||
Find out how it can help you <a
|
||||
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">in the docs</a
|
||||
>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span class="text-2xs text-secondary">(optional but recommended)</span>
|
||||
</label>
|
||||
|
||||
<ToggleButtonGroup bind:selected>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="openai" label="OpenAI" {item} />
|
||||
<ToggleButton value="anthropic" label="Anthropic" {item} />
|
||||
<ToggleButton value="mistral" label="Mistral" {item} />
|
||||
<ToggleButton value="deepseek" label="DeepSeek" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<div class="flex flex-row gap-1">
|
||||
<input
|
||||
id="ai-key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={aiKey}
|
||||
onkeyup={handleKeyUp}
|
||||
/>
|
||||
<TestAIKey
|
||||
apiKey={aiKey}
|
||||
disabled={!aiKey}
|
||||
aiProvider={selected}
|
||||
model={AI_PROVIDERS[selected].defaultModels[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if aiKey}
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<Toggle
|
||||
disabled={!aiKey}
|
||||
bind:checked={codeCompletionEnabled}
|
||||
options={{ right: 'Enable code completion' }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
|
||||
>{isCloudHosted()
|
||||
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
|
||||
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
|
||||
>
|
||||
<Toggle
|
||||
id="auto-invite"
|
||||
disabled={isCloudHosted() && !isDomainAllowed}
|
||||
bind:checked={auto_invite}
|
||||
/>
|
||||
{#if isCloudHosted() && isDomainAllowed == false}
|
||||
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
|
||||
{/if}
|
||||
|
||||
{#if auto_invite}
|
||||
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
{#if isCloudHosted()}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Mode</span>
|
||||
<span class="text-xs text-secondary font-normal"
|
||||
>Whether to invite or add users directly to the workspace.</span
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
selected={autoAdd ? 'add' : 'invite'}
|
||||
on:selected={async (e) => {
|
||||
autoAdd = e.detail === 'add'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="invite" label="Auto-invite" {item} />
|
||||
<ToggleButton value="add" label="Auto-add" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="font-semibold flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Role</span>
|
||||
<span class="text-xs text-secondary font-normal"
|
||||
>Role of the auto-invited users</span
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
selected={operatorOnly ? 'operator' : 'developer'}
|
||||
on:selected={(e) => {
|
||||
operatorOnly = e.detail == 'operator'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="operator" label="Operator" {item} />
|
||||
<ToggleButton value="developer" label="Developer" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
|
||||
<Button disabled={forkCreationLoading} variant="default" size="sm" href="{base}/user/workspaces"
|
||||
>← Back to workspaces</Button
|
||||
>
|
||||
{#if !forkCreationLoading}
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={checking ||
|
||||
errorId != '' ||
|
||||
!name ||
|
||||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
|
||||
!id}
|
||||
on:click={createOrForkWorkspace}
|
||||
>
|
||||
{#if isFork}
|
||||
Fork workspace
|
||||
{:else}
|
||||
Create workspace
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="accent" disabled={true}>
|
||||
<LoaderCircle class="animate-spin" /> Creating branch
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,9 +27,7 @@
|
||||
tag
|
||||
}: Props = $props()
|
||||
|
||||
let openedDbNameWizard = $state(false)
|
||||
|
||||
let status = $derived(customInstanceDbs.current?.[value ?? ''])
|
||||
let openedDbNameWizard: string | undefined = $state(undefined)
|
||||
|
||||
let onlySelectedTags = $derived(
|
||||
safeSelectItems(
|
||||
@@ -38,12 +36,13 @@
|
||||
.map(([name, _]) => name)
|
||||
)
|
||||
)
|
||||
let currentIsAlreadyUsedElsewhere = $derived(tag && status && status.tag !== tag)
|
||||
let open = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="flex relative items-center {className}">
|
||||
<Select
|
||||
class="flex-1"
|
||||
bind:open
|
||||
bind:value
|
||||
onCreateItem={(i) => (value = i)}
|
||||
placeholder="Search or create..."
|
||||
@@ -53,35 +52,12 @@
|
||||
disabled={!$isCustomInstanceDbEnabled}
|
||||
>
|
||||
{#snippet endSnippet({ item })}
|
||||
{#if !customInstanceDbs.current?.[item.value]?.success}
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-red-400"></div>
|
||||
{:else}
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
|
||||
{/if}
|
||||
{@render customInstanceDbWizardButton(item.value)}
|
||||
{/snippet}
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
spacingSize="xs2"
|
||||
variant="default"
|
||||
wrapperClasses={'absolute right-1.5 h-6 bg-surface-input'}
|
||||
onClick={() => (openedDbNameWizard = true)}
|
||||
disabled={currentIsAlreadyUsedElsewhere}
|
||||
>
|
||||
{#if !status}
|
||||
<span class="text-yellow-600 dark:text-yellow-400">
|
||||
Setup <ArrowRight class="inline" size={14} />
|
||||
</span>
|
||||
{:else if currentIsAlreadyUsedElsewhere}
|
||||
<span class="text-red-400 flex gap-1">Already used as {status.tag}</span>
|
||||
{:else if !status.success}
|
||||
<span class="text-red-400 flex gap-1">
|
||||
Error <TriangleAlert class="inline" size={16} />
|
||||
</span>
|
||||
{:else}
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
|
||||
{/if}
|
||||
</Button>
|
||||
{#if value}
|
||||
{@render customInstanceDbWizardButton(value, 'absolute right-1.5')}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<CustomInstanceDbWizardModal
|
||||
@@ -90,7 +66,32 @@
|
||||
{tag}
|
||||
bottomHint={wizardBottomHint}
|
||||
bind:opened={
|
||||
() => (openedDbNameWizard ? { dbname: value ?? '', status: status! } : undefined),
|
||||
(v) => !v && (openedDbNameWizard = false)
|
||||
() =>
|
||||
openedDbNameWizard
|
||||
? { dbname: openedDbNameWizard, status: customInstanceDbs.current?.[openedDbNameWizard] }
|
||||
: undefined,
|
||||
(v) => !v && (openedDbNameWizard = undefined)
|
||||
}
|
||||
/>
|
||||
|
||||
{#snippet customInstanceDbWizardButton(dbname: string, clazz: string = '')}
|
||||
{@const status = customInstanceDbs.current?.[dbname]}
|
||||
<Button
|
||||
spacingSize="xs2"
|
||||
variant="default"
|
||||
wrapperClasses="bg-surface-input h-6 -my-2 {clazz}"
|
||||
onClick={() => ((openedDbNameWizard = dbname), (open = false))}
|
||||
>
|
||||
{#if !status}
|
||||
<span class="text-yellow-600 dark:text-yellow-400">
|
||||
Setup <ArrowRight class="inline" size={14} />
|
||||
</span>
|
||||
{:else if !status.success}
|
||||
<span class="text-red-400 flex gap-1">
|
||||
Error <TriangleAlert class="inline" size={16} />
|
||||
</span>
|
||||
{:else}
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import type { ResourceReturn } from 'runed'
|
||||
import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import ExploreAssetButton from '../ExploreAssetButton.svelte'
|
||||
import { ArrowRight, InfoIcon } from 'lucide-svelte'
|
||||
import { ArrowRight, InfoIcon, Trash2 } from 'lucide-svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import { truncate } from '$lib/utils'
|
||||
import Tooltip from '../meltComponents/Tooltip.svelte'
|
||||
@@ -38,6 +38,7 @@
|
||||
}: Props = $props()
|
||||
|
||||
let customInstanceDbSetupIsRunning = $state(false)
|
||||
let dropIsRunning = $state(false)
|
||||
let preventClose = false
|
||||
</script>
|
||||
|
||||
@@ -70,13 +71,50 @@
|
||||
{@render bottomHint()}
|
||||
</div>
|
||||
{/if}
|
||||
<ExploreAssetButton
|
||||
class="flex-1"
|
||||
asset={{ kind: 'resource', path: 'CUSTOM_INSTANCE_DB/' + dbname }}
|
||||
_resourceMetadata={{ resource_type: 'postgresql' }}
|
||||
disabled={!$isCustomInstanceDbEnabled || !enableManageButton}
|
||||
onClick={() => (opened = undefined)}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<ExploreAssetButton
|
||||
class="flex-1"
|
||||
asset={{ kind: 'resource', path: 'CUSTOM_INSTANCE_DB/' + dbname }}
|
||||
_resourceMetadata={{ resource_type: 'postgresql' }}
|
||||
disabled={!$isCustomInstanceDbEnabled || !enableManageButton}
|
||||
onClick={() => (opened = undefined)}
|
||||
/>
|
||||
{#if $superadmin}
|
||||
<Button
|
||||
size="sm"
|
||||
destructive
|
||||
iconOnly
|
||||
variant="accent"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
loading={dropIsRunning}
|
||||
onClick={async () => {
|
||||
preventClose = true
|
||||
let confirm = await confirmationModal.ask({
|
||||
title: 'Drop database',
|
||||
children: `This will permanently drop the database "${dbname}". All data will be lost. This action is irreversible.`,
|
||||
confirmationText: 'Drop database'
|
||||
})
|
||||
preventClose = false
|
||||
if (!confirm) return
|
||||
|
||||
try {
|
||||
dropIsRunning = true
|
||||
await SettingService.dropCustomInstanceDb({ name: dbname })
|
||||
await customInstanceDbs.refetch()
|
||||
sendUserToast(`Database "${dbname}" dropped successfully`)
|
||||
opened = undefined
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to drop database: ${e}`, true)
|
||||
console.error('Error dropping custom instance database', e)
|
||||
} finally {
|
||||
dropIsRunning = false
|
||||
}
|
||||
}}
|
||||
>
|
||||
Drop database
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 shrink-0 flex flex-col pl-4 gap-2">
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
const s: DataTableSettingsType = { dataTables: [] }
|
||||
if (settings?.datatables) {
|
||||
for (const [name, rest] of Object.entries(settings.datatables)) {
|
||||
s.dataTables.push({ name, ...rest })
|
||||
s.dataTables.push({
|
||||
name,
|
||||
...rest
|
||||
})
|
||||
}
|
||||
}
|
||||
return s
|
||||
@@ -107,7 +110,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
const customInstanceDbs = resource([], SettingService.listCustomInstanceDbs)
|
||||
const customInstanceDbs = resource([() => $workspaceStore], SettingService.listCustomInstanceDbs)
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
@@ -252,27 +255,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
|
||||
<Cell class="w-12">
|
||||
{#if dirtyMap[dataTable.name]}
|
||||
<Popover
|
||||
openOnHover
|
||||
contentClasses="p-2 text-sm text-secondary italic"
|
||||
class="cursor-not-allowed"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<ExploreAssetButton
|
||||
class="h-9"
|
||||
asset={{ kind: 'datatable', path: dataTable.name }}
|
||||
disabled
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
Please save settings first
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<ExploreAssetButton class="h-9" asset={{ kind: 'datatable', path: dataTable.name }} />
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
{#if dirtyMap[dataTable.name]}
|
||||
<Popover
|
||||
openOnHover
|
||||
contentClasses="p-2 text-sm text-secondary italic"
|
||||
class="cursor-not-allowed"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<ExploreAssetButton
|
||||
asset={{ kind: 'datatable', path: dataTable.name }}
|
||||
disabled
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
Please save settings first
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<ExploreAssetButton asset={{ kind: 'datatable', path: dataTable.name }} />
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell class="w-12">
|
||||
<CloseButton small on:close={() => removeDataTable(dataTableIndex)} />
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
!Object.values(ducklakeIsDirty).every((v) => v === false)
|
||||
)
|
||||
|
||||
const customInstanceDbs = resource([], SettingService.listCustomInstanceDbs)
|
||||
const customInstanceDbs = resource([() => $workspaceStore], SettingService.listCustomInstanceDbs)
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script module lang="ts">
|
||||
export type ForkStep = {
|
||||
label: string
|
||||
status: 'pending' | 'running' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type DatatableCloneJob = {
|
||||
name: string
|
||||
resourceType: string
|
||||
behavior: 'schema_only' | 'schema_and_data'
|
||||
steps: ForkStep[]
|
||||
_newDbName: string
|
||||
_isInstance: boolean
|
||||
_sourceWorkspace: string
|
||||
_targetWorkspace: string
|
||||
_resourcePath: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { resource } from 'runed'
|
||||
import Select from '../select/Select.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { Check, X, Loader2 } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
onAllDone?: () => void
|
||||
onCanceled?: () => void
|
||||
}
|
||||
|
||||
let { onAllDone, onCanceled }: Props = $props()
|
||||
|
||||
let allDatatables = resource([], async () =>
|
||||
$workspaceStore
|
||||
? WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
|
||||
: undefined
|
||||
)
|
||||
|
||||
let datatableBehaviors: Record<string, 'schema_only' | 'schema_and_data' | 'keep_original'> =
|
||||
$state({})
|
||||
|
||||
let cloneModalOpen = $state(false)
|
||||
let currentCloneJob: DatatableCloneJob | undefined = $state(undefined)
|
||||
let cloneQueue: DatatableCloneJob[] = $state([])
|
||||
let cloneRunning = $state(false)
|
||||
|
||||
export function hasDatatables(): boolean {
|
||||
return (allDatatables.current?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
export function buildCloneQueue(targetWorkspaceId: string): DatatableCloneJob[] {
|
||||
return (allDatatables.current ?? [])
|
||||
.filter((dt) => {
|
||||
const behavior = datatableBehaviors[dt.name] ?? 'keep_original'
|
||||
return behavior !== 'keep_original'
|
||||
})
|
||||
.map((dt) => {
|
||||
const behavior = datatableBehaviors[dt.name] as 'schema_only' | 'schema_and_data'
|
||||
const isInstance = dt.resource_type === 'instance'
|
||||
const newDbName = `${targetWorkspaceId.replace(/-/g, '_')}__${dt.name}`
|
||||
|
||||
const steps: ForkStep[] = [
|
||||
{
|
||||
label: `CREATE DATABASE "${newDbName}"`,
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
label: `pg_dump → pg_import (${behavior === 'schema_only' ? 'schema only' : 'schema + data'})`,
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
name: dt.name,
|
||||
resourceType: dt.resource_type,
|
||||
behavior,
|
||||
steps,
|
||||
_newDbName: newDbName,
|
||||
_isInstance: isInstance,
|
||||
_sourceWorkspace: $workspaceStore!,
|
||||
_targetWorkspace: targetWorkspaceId,
|
||||
_resourcePath: dt.resource_path
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let completedJobs: DatatableCloneJob[] = $state([])
|
||||
|
||||
export function startCloning(queue: DatatableCloneJob[]) {
|
||||
completedJobs = []
|
||||
cloneQueue = queue
|
||||
currentCloneJob = cloneQueue[0]
|
||||
cloneModalOpen = true
|
||||
}
|
||||
|
||||
export function getCompletedCloneJobs(): DatatableCloneJob[] {
|
||||
return completedJobs
|
||||
}
|
||||
|
||||
async function executeCloneJob(job: DatatableCloneJob) {
|
||||
cloneRunning = true
|
||||
let stepIdx = 0
|
||||
|
||||
// Step 1: Create the database
|
||||
job.steps[stepIdx].status = 'running'
|
||||
try {
|
||||
await WorkspaceService.createPgDatabase({
|
||||
workspace: job._sourceWorkspace,
|
||||
requestBody: {
|
||||
source: `datatable://${job.name}`,
|
||||
target_dbname: job._newDbName
|
||||
}
|
||||
})
|
||||
job.steps[stepIdx].status = 'done'
|
||||
} catch (e: any) {
|
||||
job.steps[stepIdx].status = 'error'
|
||||
job.steps[stepIdx].error = e?.body ?? e?.message ?? String(e)
|
||||
cloneRunning = false
|
||||
return
|
||||
}
|
||||
stepIdx++
|
||||
|
||||
// Step 2: Import data
|
||||
job.steps[stepIdx].status = 'running'
|
||||
try {
|
||||
await WorkspaceService.importPgDatabase({
|
||||
workspace: job._sourceWorkspace,
|
||||
requestBody: {
|
||||
source: `datatable://${job.name}`,
|
||||
target: `datatable://${job.name}`,
|
||||
target_dbname_override: job._newDbName,
|
||||
fork_behavior: job.behavior
|
||||
}
|
||||
})
|
||||
job.steps[stepIdx].status = 'done'
|
||||
} catch (e: any) {
|
||||
job.steps[stepIdx].status = 'error'
|
||||
job.steps[stepIdx].error = e?.body ?? e?.message ?? String(e)
|
||||
cloneRunning = false
|
||||
return
|
||||
}
|
||||
stepIdx++
|
||||
|
||||
cloneRunning = false
|
||||
}
|
||||
|
||||
function advanceCloneQueue() {
|
||||
if (currentCloneJob) {
|
||||
completedJobs.push(currentCloneJob)
|
||||
}
|
||||
const idx = cloneQueue.indexOf(currentCloneJob!)
|
||||
if (idx < cloneQueue.length - 1) {
|
||||
currentCloneJob = cloneQueue[idx + 1]
|
||||
} else {
|
||||
cloneModalOpen = false
|
||||
currentCloneJob = undefined
|
||||
cloneQueue = []
|
||||
onAllDone?.()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if allDatatables.current && allDatatables.current.length > 0}
|
||||
<Label label="Data table behavior">
|
||||
<span class="text-xs text-secondary"> Choose how to handle each datatable when forking </span>
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each allDatatables.current as dt}
|
||||
<div class="flex items-center gap-2 justify-between px-4 py-1.5">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium">{dt.name}</span>
|
||||
<span class="text-2xs text-tertiary"
|
||||
>{dt.resource_type === 'instance' ? 'Instance DB' : 'Resource DB'}</span
|
||||
>
|
||||
</div>
|
||||
<Select
|
||||
dropdownClass="max-w-96"
|
||||
bind:value={
|
||||
() => datatableBehaviors[dt.name] ?? 'keep_original',
|
||||
(v) => (datatableBehaviors[dt.name] = v)
|
||||
}
|
||||
items={[
|
||||
{ value: 'keep_original', label: 'Keep original' },
|
||||
{ value: 'schema_only', label: 'Clone schema only' },
|
||||
...(!isCloudHosted() && $userStore?.is_admin
|
||||
? [{ value: 'schema_and_data', label: 'Clone schema and data' }]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Label>
|
||||
{/if}
|
||||
|
||||
{#if cloneModalOpen && currentCloneJob}
|
||||
<ConfirmationModal
|
||||
title="Clone datatable: {currentCloneJob.name}"
|
||||
confirmationText={cloneRunning ? 'Running...' : 'Start'}
|
||||
open={cloneModalOpen}
|
||||
loading={cloneRunning}
|
||||
onConfirmed={async () => {
|
||||
await executeCloneJob(currentCloneJob!)
|
||||
advanceCloneQueue()
|
||||
}}
|
||||
onCanceled={() => {
|
||||
cloneModalOpen = false
|
||||
currentCloneJob = undefined
|
||||
cloneQueue = []
|
||||
onCanceled?.()
|
||||
}}
|
||||
>
|
||||
{#if currentCloneJob.behavior === 'schema_and_data'}
|
||||
<Alert type="error" title="Heavy operation">
|
||||
This will copy the <b>entire database</b> including all data. The pg_dump output is temporarily
|
||||
stored on disk and may consume significant server disk space during the operation.
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert type="info" title="Schema only">
|
||||
This will copy the database schema only. All tables will be empty. This is a lightweight
|
||||
operation.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if currentCloneJob.resourceType === 'instance'}
|
||||
<p class="text-xs text-secondary mt-2">
|
||||
This will run <code
|
||||
>CREATE DATABASE {currentCloneJob.steps[0]?.label.match(/"([^"]+)"/)?.[1] ?? ''}</code
|
||||
> on the Windmill PostgreSQL instance.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary mt-2">
|
||||
This will run <code>CREATE DATABASE</code> on the resource's PostgreSQL server.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
{#each currentCloneJob.steps as step}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
{#if step.status === 'done'}
|
||||
<Check class="w-4 h-4 shrink-0 text-green-500" />
|
||||
{:else if step.status === 'running'}
|
||||
<Loader2 class="w-4 h-4 shrink-0 animate-spin text-blue-500" />
|
||||
{:else if step.status === 'error'}
|
||||
<X class="w-4 h-4 shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<div class="w-4 h-4 shrink-0 rounded-full border border-gray-300"></div>
|
||||
{/if}
|
||||
<span
|
||||
class:text-tertiary={step.status === 'pending'}
|
||||
class:font-medium={step.status === 'running'}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{#if step.error}
|
||||
<p class="text-2xs text-red-500 ml-6">{step.error}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
@@ -132,6 +132,11 @@ export const codeCompletionSessionEnabled = writable<boolean>(
|
||||
export const usedTriggerKinds = writable<string[]>([])
|
||||
|
||||
export let globalDbManagerDrawer: StateStore<DbManagerUriState | undefined> = { val: undefined }
|
||||
export let globalForkModal: StateStore<GlobalForkModalState | undefined> = createState({ val: undefined })
|
||||
|
||||
export type GlobalForkModalState = {
|
||||
opened: true
|
||||
}
|
||||
|
||||
type SQLBaseSchema = {
|
||||
[schemaKey: string]: {
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
usedTriggerKinds,
|
||||
devopsRole,
|
||||
whitelabelNameStore,
|
||||
globalDbManagerDrawer
|
||||
globalDbManagerDrawer,
|
||||
globalForkModal
|
||||
} from '$lib/stores'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation'
|
||||
@@ -65,6 +66,8 @@
|
||||
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
|
||||
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
|
||||
import { useDbManagerUriState } from '$lib/components/dbManagerDrawerModel.svelte'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import CreateWorkspaceInner from '$lib/components/workspaceSettings/CreateWorkspaceInner.svelte'
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
@@ -825,3 +828,16 @@
|
||||
{#if $workspaceStore && globalDbManagerDrawer.val}
|
||||
<DBManagerDrawer uriState={globalDbManagerDrawer.val} />
|
||||
{/if}
|
||||
|
||||
<Modal2
|
||||
title="Forking {$workspaceStore}"
|
||||
target="#content"
|
||||
fixedHeight="lg"
|
||||
fixedWidth="sm"
|
||||
contentClasses="flex-col"
|
||||
bind:isOpen={() => !!globalForkModal.val?.opened, (v) => !v && (globalForkModal.val = undefined)}
|
||||
>
|
||||
{#if globalForkModal.val}
|
||||
<CreateWorkspaceInner isFork onFinish={() => (globalForkModal.val = undefined)} />
|
||||
{/if}
|
||||
</Modal2>
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
() => $workspaceStore,
|
||||
() =>
|
||||
WorkspaceService.listDataTables({ workspace: $workspaceStore! }).then((d) =>
|
||||
d.map((d) => ({ label: d == 'main' ? 'Main data table' : d, value: d }))
|
||||
d.map((d) => d.name).map((d) => ({ label: d == 'main' ? 'Main data table' : d, value: d }))
|
||||
)
|
||||
)
|
||||
let allVolumes = resource(
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<script lang="ts">
|
||||
import CompareWorkspaces from "$lib/components/CompareWorkspaces.svelte"
|
||||
import { WorkspaceService, type WorkspaceComparison } from "$lib/gen"
|
||||
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"
|
||||
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 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) {
|
||||
async function checkForChanges() {
|
||||
if (!currentWorkspaceId || !parentWorkspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,22 +44,14 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
[
|
||||
currentWorkspaceId,
|
||||
parentWorkspaceId,
|
||||
];
|
||||
;[currentWorkspaceId, parentWorkspaceId]
|
||||
|
||||
untrack(() => checkForChanges())
|
||||
})
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<CenteredPage>
|
||||
|
||||
<PageHeader
|
||||
title="Merge workspaces"
|
||||
/>
|
||||
<CenteredPage>
|
||||
<PageHeader title="Merge workspaces" />
|
||||
{#if currentWorkspaceId && parentWorkspaceId}
|
||||
<!-- <WorkspaceComparisonDrawer -->
|
||||
<!-- {comparison} -->
|
||||
@@ -66,7 +61,7 @@
|
||||
<!-- sendUserToast('Changes deployed successfully') -->
|
||||
<!-- }} -->
|
||||
<!-- /> -->
|
||||
<CompareWorkspaces {currentWorkspaceId} {parentWorkspaceId} {comparison}/>
|
||||
<CompareWorkspaces {currentWorkspaceId} {parentWorkspaceId} {comparison} />
|
||||
{/if}
|
||||
{#if !currentWorkspaceId}
|
||||
No workspace selected
|
||||
|
||||
@@ -418,6 +418,7 @@ impl Windmill {
|
||||
description: None,
|
||||
value: Some(value),
|
||||
resource_type: Some(resource_type.to_owned()),
|
||||
ws_specific: None,
|
||||
labels: None,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user