From 0b632f80bbd53b17e509a29c1425a7c49445a018 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 6 Aug 2026 22:04:29 +0200 Subject: [PATCH 01/70] feat(frontend): guided setup wizard for data tables On Cloud a data table cannot use the Windmill instance database, so a new workspace hit a dead end: an alert telling the user to go find a PostgreSQL resource somewhere else. Setting one up meant three disconnected places, and the connection could only be tested after the config had already been saved. Adds a three-step wizard (choose a database -> set it up -> name it) reached from the data tables settings page: - Supabase: signs in via the existing supabase_wizard OAuth client and creates the project from inside Windmill. Because db_pass is an input to project creation, Windmill sets the password and the user never visits a dashboard. - Your own database: picks an existing postgresql resource, or adds one with a connection string through the form that already supports it. - Windmill database: hands back to the inline row editor, since instance databases are provisioned by a superadmin. Verifying access is no longer a step the user takes: Continue runs the check and passing it is what advances the wizard, so a database that cannot create tables never reaches the workspace config. Co-Authored-By: Claude Opus 5 (1M context) --- .../windmill-api-workspaces/src/workspaces.rs | 43 +- backend/windmill-api/openapi.yaml | 76 +- .../src/lib/components/SupabaseConnect.svelte | 151 +++- .../lib/components/common/alert/Alert.svelte | 46 +- .../lib/components/common/modal/Modal2.svelte | 8 +- .../components/common/stepper/Stepper.svelte | 38 +- .../AddDataTableWizard.svelte | 661 ++++++++++++++++++ .../DataTableSettings.svelte | 80 ++- .../workspaceSettings/supabaseProvisioning.ts | 131 ++++ .../oauth/callback_supabase/+page.svelte | 7 +- 10 files changed, 1160 insertions(+), 81 deletions(-) create mode 100644 frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index d6534bd736..d6b271d4e3 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -134,6 +134,10 @@ pub fn workspaced_service() -> Router { get(get_datatable_table_schema), ) .route("/edit_datatable_config", post(edit_datatable_config)) + .route( + "/test_datatable_resource_connection", + get(test_datatable_resource_connection), + ) .route( "/test_datatable_connection/{datatable_name}", get(test_datatable_connection), @@ -2035,6 +2039,31 @@ struct DataTableConnectionCheck { /// from the settings page is the difference between finding out here and finding /// out on a first schema change, when the failure reads as a Postgres refusal /// deep inside a migration. +#[derive(Deserialize)] +struct TestDataTableResourceQuery { + resource_path: String, +} + +/// Same check as [`test_datatable_connection`], but against a resource that is not yet +/// referenced by any data table. The setup wizard creates the resource first and needs to +/// prove the role can create tables *before* writing the workspace's data table config. +async fn test_datatable_resource_connection( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Query(query): Query, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + + let db_resource = windmill_common::workspaces::transform_json_value_unchecked( + &serde_json::Value::String(format!("$res:{}", query.resource_path)), + &w_id, + &db, + ) + .await?; + check_datatable_connection(&db, db_resource).await +} + async fn test_datatable_connection( authed: ApiAuthed, Extension(db): Extension, @@ -2043,9 +2072,16 @@ async fn test_datatable_connection( require_admin(authed.is_admin, &authed.username)?; let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?; + check_datatable_connection(&db, db_resource).await +} + +async fn check_datatable_connection( + db: &DB, + db_resource: serde_json::Value, +) -> JsonResult { let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; - let (client, connection) = pg_db.connect(Some(&db)).await?; + let (client, connection) = pg_db.connect(Some(db)).await?; let join_handle = tokio::spawn(async move { connection.await }); // One round trip, no side effects: `has_*_privilege` answers for the @@ -9098,7 +9134,10 @@ async fn reject_attach_cycle<'e, E: sqlx::Executor<'e, Database = Postgres>>( /// /// Recomputed inside the transaction, but from a set that may already be stale — harmless, because /// whoever made it stale is the operation holding the node this one is missing. -pub(crate) async fn lock_dev_pairing(tx: &mut Transaction<'_, Postgres>, seeds: &[&str]) -> Result<()> { +pub(crate) async fn lock_dev_pairing( + tx: &mut Transaction<'_, Postgres>, + seeds: &[&str], +) -> Result<()> { let seeds: Vec = seeds.iter().map(|s| s.to_string()).collect(); // Depth bounds are the cycle-safety backstop used by every other hierarchy walk. let nodes = sqlx::query_scalar!( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 869c94a51f..e2ab2bfa80 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4792,32 +4792,28 @@ paths: content: application/json: schema: - type: object - properties: - user: - type: string - schema: - type: string - nullable: true - can_create_table: - type: boolean - can_create_schema: - type: boolean - migrations_table_exists: - type: boolean - suggested_grants: - type: array - items: - type: string - suggested_search_path: - type: string - required: - - user - - schema - - can_create_table - - can_create_schema - - migrations_table_exists - - suggested_grants + $ref: "#/components/schemas/DataTableConnectionCheck" + + /w/{workspace}/workspaces/test_datatable_resource_connection: + get: + summary: check what a postgres resource lets its role do, before it is attached to a data table + operationId: testDataTableResourceConnection + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: resource_path + in: query + required: true + schema: + type: string + responses: + "200": + description: connection and privilege report + content: + application/json: + schema: + $ref: "#/components/schemas/DataTableConnectionCheck" /w/{workspace}/workspaces/list_datatable_tables: get: @@ -32133,6 +32129,34 @@ components: - ran - not_run - unknown + DataTableConnectionCheck: + type: object + properties: + user: + type: string + schema: + type: string + nullable: true + can_create_table: + type: boolean + can_create_schema: + type: boolean + migrations_table_exists: + type: boolean + suggested_grants: + type: array + items: + type: string + suggested_search_path: + type: string + required: + - user + - schema + - can_create_table + - can_create_schema + - migrations_table_exists + - suggested_grants + DataTableSchema: type: object required: [datatable_name, schemas] diff --git a/frontend/src/lib/components/SupabaseConnect.svelte b/frontend/src/lib/components/SupabaseConnect.svelte index e44399ee84..999dc822e1 100644 --- a/frontend/src/lib/components/SupabaseConnect.svelte +++ b/frontend/src/lib/components/SupabaseConnect.svelte @@ -4,6 +4,8 @@ import { Loader2, RotateCwIcon } from 'lucide-svelte' import { Button, DrawerContent } from './common' + import Select from './select/Select.svelte' + import TextInput from './text_input/TextInput.svelte' import Drawer from './common/drawer/Drawer.svelte' import Path from './Path.svelte' import { sendUserToast } from '$lib/toast' @@ -45,13 +47,126 @@ database?: { host: string } region: string id: string + status?: string } let databases: undefined | Database[] = $state(undefined) + type Organization = { id: string; name: string; slug?: string } + let orgs: undefined | Organization[] = $state(undefined) + let selectedOrgSlug: string | undefined = $state(undefined) + let newProjectName = $state('') + let creating = $state(false) + let createStatus = $state('') + + // region_selection is { type: 'specific' | 'smartGroup', code: }. Neither the + // published docs nor the OpenAPI spec describe it correctly (they give `kind`/`region` and + // `primary`), so this shape comes from the API's own validation errors -- do not "correct" + // it against the documentation. Only 'specific' has a shape we can rely on. + const SUPABASE_REGIONS = [ + 'us-east-1', + 'us-west-1', + 'eu-central-1', + 'eu-west-1', + 'eu-west-3', + 'ap-southeast-1', + 'ap-northeast-1' + ] + let selectedRegion: string = $state('eu-central-1') + + async function listOrgs() { + if (!token) return + const res = await fetch('/api/oauth/list_supabase_orgs', { + headers: { 'Content-Type': 'application/json', 'X-Supabase-Token': token } + }) + if (!res.ok) { + sendUserToast(`Could not list Supabase organizations: ${await res.text()}`, true) + return + } + orgs = await res.json() + // organization_slug is what create takes; older payloads only carry an id. + if (orgs?.length === 1) selectedOrgSlug = orgs[0].slug ?? orgs[0].id + } + + // Supabase never lets a password be read back, so the only way to know it is to be the + // one who set it: db_pass is an input to project creation. + function generatePassword(): string { + const charset = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + const values = new Uint32Array(32) + crypto.getRandomValues(values) + return Array.from(values, (v) => charset[v % charset.length]).join('') + } + + // Creation returns immediately with the project still coming up, so the pooler is not + // reachable yet — poll until Supabase reports it healthy before building the resource. + async function waitUntilHealthy(id: string): Promise { + for (let i = 0; i < 60; i++) { + await new Promise((r) => setTimeout(r, 5000)) + const res = await fetch('/api/oauth/list_supabase', { + headers: { 'Content-Type': 'application/json', 'X-Supabase-Token': token ?? '' } + }) + if (!res.ok) continue + const list: Database[] = await res.json() + const project = list?.find?.((d) => d.id === id) + if (project?.status === 'ACTIVE_HEALTHY') return project + createStatus = `Waiting for Supabase to finish provisioning${ + project?.status ? ` (${project.status})` : '' + }...` + } + throw new Error('Timed out waiting for the project to become healthy') + } + + async function createProject() { + if (!token || !selectedOrgSlug || !newProjectName) return + creating = true + createStatus = 'Creating the project...' + try { + const db_pass = generatePassword() + const res = await fetch('/api/oauth/create_supabase_project', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Supabase-Token': token }, + body: JSON.stringify({ + name: newProjectName, + organization_slug: selectedOrgSlug, + db_pass, + // Supabase rejects the request unless exactly one of region / region_selection is set. + region_selection: { type: 'specific', code: selectedRegion } + }) + }) + if (!res.ok) { + sendUserToast(`Supabase refused to create the project: ${await res.text()}`, true) + return + } + const created = await res.json() + // Surface the password before waiting: Supabase never hands it back, so if the poll + // below fails the project would otherwise exist with a password nobody holds. + password = db_pass + selectedDatabase = created + step = 'resource' + try { + selectedDatabase = await waitUntilHealthy(created.id ?? created.ref) + } catch (err) { + sendUserToast( + `${created.name} was created but is not reachable yet (${err}). Its password is filled in below - save the resource and retry the connection once Supabase reports it ready.`, + true + ) + } + await listDatabases() + } catch (err) { + sendUserToast(`Could not create the Supabase project: ${err}`, true) + } finally { + creating = false + createStatus = '' + } + } + run(() => { token != undefined && listDatabases() }) + run(() => { + token != undefined && listOrgs() + }) + let selectedDatabase: undefined | Database = $state(undefined) let description = $state('') @@ -142,11 +257,39 @@ {/if}

Create a new database

-

Create a new database in your Supabase account - +

+ Windmill creates the project in your Supabase organization and generates its database + password, so you never have to retrieve it from the Supabase dashboard.

+
+ ({ label: r, value: r }))} + bind:value={selectedRegion} + placeholder="Region" + disabled={creating} + /> + + {#if createStatus} +

{createStatus}

+ {/if} +
{:else if step === 'resource'}
-
- - {title} - {#if tooltip != '' || documentationLink} - {tooltip} - {/if} - - {#if collapsible} - - {/if} -
+ + {#if collapsible} + + {/if} +
+ {/if} {#if children && !isCollapsed} -
+
{:else if children && !collapsible} -
+