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 commit 9561cc8fd4.

* 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 commit 6b50884dc6.

* 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 commit 7526dd68b9.

* 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 commit a86008ba4c.

* Revert "feat: add review drawer with YAML diff and SQL migration runner"

This reverts commit 0a0deb5ddb.

* 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 commit 77f5a2c4e8.

* 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 commit e326197a20.

* Revert "refactor: reuse columnDefToTableEditorValuesColumn for default value handling"

This reverts commit bd8f071d9f.

* 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 commit ab683e637b.

* 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:
Diego Imbert
2026-04-07 21:03:06 +00:00
committed by GitHub
co-authored by Claude Opus 4.5 cubic-dev-ai[bot]
parent 0bcbc8bd3c
commit 3d4f4c6c38
58 changed files with 4088 additions and 1258 deletions
+9 -1
View File
@@ -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 \
@@ -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,
@@ -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"
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1
View File
@@ -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;
+16
View File
@@ -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
+574 -17
View File
@@ -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,
+269 -5
View File
@@ -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
+175 -1
View File
@@ -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)
}
+23
View File
@@ -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)]
+15 -3
View File
@@ -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");
+1
View File
@@ -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>> {
+9 -1
View File
@@ -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
+9 -1
View File
@@ -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">&rarr;</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">&rarr;</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} />
+47 -4
View File
@@ -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>
+3 -1
View File
@@ -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 }
})
+1 -1
View File
@@ -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">&leftarrow; 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"
>&leftarrow; 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}
+5
View File
@@ -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
+1
View File
@@ -418,6 +418,7 @@ impl Windmill {
description: None,
value: Some(value),
resource_type: Some(resource_type.to_owned()),
ws_specific: None,
labels: None,
},
)