From cd4ecbe52aa7803df1367304a2d56bc4bd01ed36 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 11 Aug 2026 10:15:15 +0200 Subject: [PATCH] feat(frontend): rebuild data table setup around a read-only row The wizard gathers intent over two steps, reviews it on a third and writes nothing until Finish, so a billable Supabase project is created only once the user has seen what will happen. runSetup is also the retry: every step probes for its own result before doing anything, so running it again on a half-finished data table resumes instead of duplicating. Its steps are keyed rather than dispatched on their titles, where rewording one changed what it did. The settings row stops being an editable form with a dirty/save cycle. It carries the name, where the database came from, a health dot and two actions; everything rare moved into the gear panel, which also offers Finish setup for a data table whose wizard never completed. Manage is ExploreAssetButton, the control the ducklake list already uses, and the row and panel both link out to the underlying resource. supabaseResourceValue no longer assembles the pooler host from the region. aws-0-.pooler.supabase.com is wrong for any project Supabase allocated elsewhere, so the host, user and port come from the pooler config endpoint. Two data tables sharing one database also share _wm_migrations, which is probed unqualified, so the review step warns when the database being connected is already behind another data table. SupabaseConnect is deleted. The resource drawer uses the shared project step restricted to existing projects: creating one is a billed action and belongs in the wizard, which has somewhere to report what it did. The kitchen_sink checklist playground goes with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/ApiConnectForm.svelte | 135 +-- .../src/lib/components/SupabaseConnect.svelte | 282 ------ .../AddDataTableWizard.svelte | 932 +++++++++--------- .../DataTableConnectionReport.svelte | 77 ++ .../DataTableSettings.svelte | 466 +++------ .../DataTableSettingsPanel.svelte | 554 +++++++++++ .../SupabaseConnectionMode.svelte | 61 ++ .../SupabaseProjectStep.svelte | 372 +++---- .../workspaceSettings/addDataTableModel.ts | 477 +++++++++ .../dataTableHealth.svelte.ts | 44 + .../workspaceSettings/dataTableOrigin.ts | 60 ++ .../workspaceSettings/supabaseProvisioning.ts | 128 ++- .../utils/postgresConnectionString.test.ts | 35 + .../src/lib/utils/postgresConnectionString.ts | 36 + .../(root)/(logged)/resources/+page.svelte | 8 - .../kitchen_sink/setup_checklist/+page.svelte | 132 --- 16 files changed, 2294 insertions(+), 1505 deletions(-) delete mode 100644 frontend/src/lib/components/SupabaseConnect.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/DataTableConnectionReport.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/DataTableSettingsPanel.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/SupabaseConnectionMode.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/addDataTableModel.ts create mode 100644 frontend/src/lib/components/workspaceSettings/dataTableHealth.svelte.ts create mode 100644 frontend/src/lib/components/workspaceSettings/dataTableOrigin.ts create mode 100644 frontend/src/lib/utils/postgresConnectionString.test.ts create mode 100644 frontend/src/lib/utils/postgresConnectionString.ts delete mode 100644 frontend/src/routes/kitchen_sink/setup_checklist/+page.svelte diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 89343beb29..05bf006d52 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -17,12 +17,16 @@ import ResourceGen from './copilot/ResourceGen.svelte' import SyncResourceTypes from './SyncResourceTypes.svelte' import Modal2 from './common/modal/Modal2.svelte' - import SupabaseProjectStep, { - type SupabasePick - } from './workspaceSettings/SupabaseProjectStep.svelte' - import { supabaseResourceValue } from './workspaceSettings/supabaseProvisioning' + import SupabaseProjectStep from './workspaceSettings/SupabaseProjectStep.svelte' + import { newWizardState } from './workspaceSettings/addDataTableModel' + import { + getSupabasePooler, + projectRef, + supabaseResourceValue + } from './workspaceSettings/supabaseProvisioning' import { useSupabaseOauth } from './workspaceSettings/supabaseOauth.svelte' import { sendUserToast } from '$lib/toast' + import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString' interface Props { resourceType: string @@ -104,38 +108,38 @@ let connectionString = $state('') let validConnectionString = $state(true) function parseConnectionString(close: (_: any) => void) { - const regex = - /postgres(?:ql)?:\/\/(?[^:@]+)(?::(?[^@]+))?@(?[^:\/?]+)(?::(?\d+))?\/(?[^\?]+)?(?:\?.*sslmode=(?[^&]+))?/ - const match = connectionString.match(regex) - if (match) { - validConnectionString = true - const { user, password, host, port, dbname, sslmode } = match.groups! - rawCode = JSON.stringify( - { - ...args, - user, - password: password || args?.password, - host, - port: (port ? Number(port) : undefined) || args?.port, - dbname: dbname || args?.dbname, - sslmode: sslmode || args?.sslmode - }, - null, - 2 - ) - rawCodeEditor?.setCode(rawCode) - close(null) - } else { + const parts = parsePostgresConnectionString(connectionString) + if (!parts) { validConnectionString = false + return } + validConnectionString = true + rawCode = JSON.stringify( + { + ...args, + user: parts.user, + password: parts.password || args?.password, + host: parts.host, + port: parts.port || args?.port, + dbname: parts.dbname || args?.dbname, + sslmode: parts.sslmode || args?.sslmode + }, + null, + 2 + ) + rawCodeEditor?.setCode(rawCode) + close(null) } let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined) let textFileContent: string | undefined = $state(undefined) let supabaseOpen = $state(false) - let supaStep: ReturnType | undefined = $state(undefined) - let supaResult: SupabasePick | undefined = $state(undefined) + let supaBusy = $state(false) + // Only the intent this form can act on. Creating a project is a billed action and belongs + // in the data table wizard, which can show what it is provisioning and record the result; + // a resource form has nowhere to put either. + let supaIntent = $state(newWizardState({ name: '', projectName: '', folder: '' }).supabase) // Authorizing is not something to present a dialog about first: the button goes straight // to the popup, and the dialog opens on the way back, already holding the projects. @@ -161,23 +165,34 @@ // The resource is being edited here rather than created for us, so the project's password // goes straight into the form as a value. The user can link it to a secret variable with // the same affordance every other password field has. - function applySupabasePick(pick: SupabasePick) { - args = { - ...(args ?? {}), - ...supabaseResourceValue(pick.project, ''), - password: pick.password + async function applySupabasePick() { + const project = supaIntent.project + if (!project || !supaIntent.password) return + supaBusy = true + try { + const pooler = + supaIntent.connectionMode === 'session' + ? await getSupabasePooler(supaOauth.token!, projectRef(project)) + : undefined + args = { + ...(args ?? {}), + ...supabaseResourceValue(project, '', { + mode: supaIntent.connectionMode, + pooler + }), + password: supaIntent.password + } + rawCode = JSON.stringify(args, null, 2) + rawCodeEditor?.setCode(rawCode) + supabaseOpen = false + sendUserToast(`Filled in the connection for ${project.name}`) + } catch (err) { + sendUserToast(String(err), true) + } finally { + supaBusy = false } - rawCode = JSON.stringify(args, null, 2) - rawCodeEditor?.setCode(rawCode) - supabaseOpen = false - supaResult = undefined - sendUserToast(`Filled in the connection for ${pick.project.name}`) } - $effect(() => { - if (supaResult) applySupabasePick(supaResult) - }) - function parseTextFileContent() { args = { content: textFileContent @@ -345,30 +360,24 @@ title="Connect Supabase" contentClasses="flex flex-col" fixedWidth="md" - fixedHeight="md" + fixedHeight="lg" >
- + {#if supaOauth.token} + + {/if} +
+
+
- {#if supaStep} - {@const action = supaStep.getAction()} -
- -
- {/if}
diff --git a/frontend/src/lib/components/SupabaseConnect.svelte b/frontend/src/lib/components/SupabaseConnect.svelte deleted file mode 100644 index d6a0ed406d..0000000000 --- a/frontend/src/lib/components/SupabaseConnect.svelte +++ /dev/null @@ -1,282 +0,0 @@ - - - - - - - {#if step === 'init' || selectedDatabase == undefined} -

Connect an existing database
-

-
- - {#if databases == undefined} - - {:else} -
- {#each databases as database} - - {/each} - {/if} - -

Create a new database

-

- 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'} - - -

Database Password

-

For security reasons from supabase, the password of the database cannot be retrieved - automatically. In a future update, a dedicated role for windmill will be created and the - password for it will be generated automatically. The password of the database is shown - during the project creation.

- - -

Description

- - -
-

A resource and a variable will be created at path: {path}. The content of the resource will - be:

- - {/if} - {#snippet actions()} -
- {#if step == 'resource' && selectedDatabase != undefined} - - - - {/if} -
- {/snippet} -
-
diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte index efccd554d3..2a8d52e334 100644 --- a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -1,8 +1,8 @@ opened, (v) => { if (!v && preventClose) return - opened = v - if (!v) reset() + if (!v) close() + else opened = v } } target="#content" title="Add a database" contentClasses="flex flex-col" fixedWidth="md" - fixedHeight="md" + fixedHeight="lg" >
- +
- {#if step === 1} + {#if run.steps.length} + + {#if run.running} +

+ You can leave this open. {#if rowCreated} — the data table is already saved, and shows as incomplete until this finishes.{/if} +

+ {/if} + {#if run.result} + + {/if} + {:else if wiz.step === 1} A data table runs on a database of your own. It stays yours — you can take it with you at any time. @@ -491,7 +434,7 @@ 'instance', instanceIcon, 'Windmill database', - 'Managed for you. No setup.' + 'Windmill creates and manages a database on this instance.' )} {/if} {#if supabaseAvailable} @@ -502,153 +445,64 @@ 'supabase', supabaseIcon, 'Supabase', - 'Connect a project you already have, or let Windmill create one.' + 'Create a project, or connect one you already have. Signing in is required, and connecting an existing project needs its database password.' )} {/if} {#snippet ownIcon()} {/snippet} {@render providerCard( - 'existing', + 'resource', ownIcon, 'Your own database', - 'Use a database resource, or add one with its connection string.' + 'Any Postgres — RDS, Neon, self-hosted. Pick a resource, or paste a connection string.' )}
- {:else if step === 2} - {#if provider === 'supabase'} - parkWizard({ name: dataTableName, ...s })} - hostBusy={checking} - extraSteps={supaResult - ? [ - { - title: 'Checking Windmill can store data', - status: checkPassed() - ? 'done' - : checking - ? 'running' - : checkError || checkReport - ? 'failed' - : 'pending' - } - ] - : undefined} - /> - {:else if provider === 'instance'} - {#if instanceDbs.length} - instanceMode, (v) => setInstanceMode(v)}> - {#snippet children({ item })} - - - {/snippet} - - {/if} - {#if instanceMode === 'existing'} - - {#if instanceDbName && otherWorkspaces(instanceDbName).length} - {@const shared = otherWorkspaces(instanceDbName)} - - This database is also used by workspace{shared.length > 1 ? 's' : ''} - {shared.join(', ')}. Any data written here will - be shared with {shared.length > 1 ? 'them' : 'it'}. - - {/if} - -
- {#each instanceDbs as { name, db } (name)} - {@const selected = instanceDbName === name} - {@const shared = otherWorkspaces(name)} - - {/each} -
+ {:else if wiz.step === 2} + {#if wiz.provider === 'supabase'} + {#if !supaOauth.authed} + + {#if supaOauth.pending} + Sign in and approve Windmill in the Supabase window, then come back here. + {:else} + Windmill needs your approval on Supabase to see your databases. + {/if} + {:else} -
- Database name - instanceDbName ?? '', (v) => (instanceDbName = v)} - inputProps={{ placeholder: defaultInstanceDbName() }} - /> -

- Created in the Windmill PostgreSQL instance. Windmill manages its credentials. -

-
- {/if} - {#if instanceSetupRunning || (instanceStatus && (instanceSetupAttempted || instanceStatus.error))} - clearProbe(wiz)} /> {/if} + {:else if wiz.provider === 'instance'} + {@render instanceStep()} {:else} -
- Database - -

- Pick one, or add a new one with its connection string. -

-
+ {@render ownStep()} {/if} - {@render checkResult()} + {:else} -
- Name this data table - -

- {#if nameTaken} - A data table called {dataTableName.trim()} already exists in this workspace. - {:else} - This is how your scripts will refer to it. main is used - by default when a script does not name one. - {/if} -

-
- - Once you finish, {dataTableName} is ready to use from any - script in this workspace. - + {@render reviewStep()} {/if}
- {#if step > 1 && !primary.busy} - + {#if wiz.step > 1 && !run.steps.length} + {/if}
- {#if provider === 'supabase' && !supaOauth.authed} + {#if wiz.provider === 'supabase' && !supaOauth.authed}

If you do not have a Supabase account you can {#snippet providerCard(key: Provider, icon: Snippet, title: string, subtitle: string)} - {@const selected = provider === key} + {@const selected = wiz.provider === key} + {/each} +

+ {:else} +
+ Database name + wiz.instance.dbName ?? '', (v) => (wiz.instance.dbName = v)} + inputProps={{ placeholder: defaultInstanceDbName() }} + /> +

+ Created in the Windmill PostgreSQL instance when you finish. Windmill manages its + credentials. +

+
+ {/if} +{/snippet} + +{#snippet ownStep()} + wiz.own.mode, + (v) => { + wiz.own.mode = v + clearProbe(wiz) + } + } + > + {#snippet children({ item })} + + + {/snippet} + + {#if wiz.own.mode === 'pick'} +
+ Database + wiz.own.resourcePath, + (v) => { + if (v !== wiz.own.resourcePath) clearProbe(wiz) + wiz.own.resourcePath = v + } + } + resourceType="postgresql" + /> +
+ {:else} +
+ Connection string + wiz.own.connectionString, + (v) => { + wiz.own.connectionString = v + clearProbe(wiz) + } + } + inputProps={{ placeholder: 'postgres://user:password@host:5432/database' }} + /> +

+ {#if wiz.own.connectionString && !parsePostgresConnectionString(wiz.own.connectionString)} + That is not a Postgres connection string. + {:else} + Saved as a Postgres resource in this workspace when you finish. + {/if} +

+
+ {/if} +{/snippet} + +{#snippet reviewStep()} +
+ Data table name + +

+ {#if nameTaken} + A data table called {wiz.review.name.trim()} already exists in this workspace. + {:else} + This is how your scripts will refer to it. main is used by default + when a script does not name one. + {/if} +

+
+ + {#if wiz.provider === 'supabase'} +
+
+ {wiz.supabase.mode === 'create' ? 'New Supabase project' : 'Supabase project'} +
+
+ {wiz.supabase.mode === 'create' + ? wiz.supabase.projectName + : (wiz.supabase.project?.name ?? '')} +
+
Organization
+
{originOf(wiz, $userStore?.username ?? '').org ?? '—'}
+
Region
+
{originOf(wiz, $userStore?.username ?? '').region ?? '—'}
+
Connection
+
+ {wiz.supabase.connectionMode === 'session' ? 'Session pooler' : 'Direct (IPv6)'} +
+
+ {:else if wiz.provider === 'instance'} +
+
Windmill database
+
{wiz.instance.dbName}
+
+ {:else if wiz.own.mode === 'pick'} +
+
Postgres resource
+
{wiz.own.resourcePath}
+
+ {/if} + + {#if mintsResource} +
+
+ Who can use this database + dataTable.database.resource_type, - (resource_type) => { - dataTable.database = { - resource_type, - resource_path: - resource_type === 'instance' ? defaultInstanceDbName() : undefined - } - } - } - id="database-type-select" - class="w-28" - /> -
-
- {#if dataTable.database.resource_type !== 'instance'} - - {:else} - - {/if} -
-
- - - -
- -
{/each} - {#if tempSettings.dataTables.length > 0} + {#if dataTableSettings.dataTables.length > 0}
@@ -430,79 +283,23 @@ -{#if connectionCheck && !connectionCheck.loading} - {@const report = connectionCheck.report} - {#if connectionCheck.error} - - {connectionCheck.error} - - {:else if report} - {@const fullyPrivileged = report.can_create_table && report.can_create_schema} - -
-
- Connects as {report.user}{#if report.schema}, resolving - unqualified statements to schema {report.schema}{/if}. -
- {#if report.suggested_search_path} -
- Its search_path resolves to no schema, so unqualified statements fail with - no schema has been selected to create in whatever - privileges the role holds. Point it at one, e.g. - {report.suggested_search_path}. -
- {/if} -
    -
  • - Create tables{report.schema ? ` in ${report.schema}` : ''}: - {report.can_create_table ? 'yes' : 'no'} -
  • -
  • - Create schemas: - {report.can_create_schema ? 'yes' : 'no'} -
  • -
  • - Migration bookkeeping table exists: - {report.migrations_table_exists ? 'yes' : 'no'} -
  • -
- {#if report.suggested_grants.length > 0} -
- Windmill connects as the role that lacks these privileges, so it cannot grant them - itself. Run as a schema owner or superuser on that database: -
-
{report.suggested_grants.map((g) => `${g};`).join('\n')}
- {#if report.schema && !report.can_create_table && !report.migrations_table_exists} -
- Alternatively, create the _wm_migrations bookkeeping table - yourself and grant only SELECT, INSERT, UPDATE, DELETE on it. -
- {/if} - {/if} -
-
- {/if} +{#if isCloudHosted()} + + On Windmill Cloud, data tables cannot use the Windmill instance database. Connect Supabase or + bring your own PostgreSQL instead. + {/if} - - + d.name)} + onChanged={reload} +/> + wizardOpen, @@ -513,9 +310,14 @@ if (!v) wizardResume = undefined } } - existingNames={tempSettings.dataTables.map((d) => d.name)} + existingNames={dataTableSettings.dataTables.map((d) => d.name)} + existingDataTables={dataTableSettings.dataTables.map((d) => ({ + name: d.name, + resourcePath: d.database.resource_path, + projectRef: d.origin?.project_ref + }))} resume={wizardResume} - onDone={reloadAfterWizard} + onDone={reload} {customInstanceDbs} {confirmationModal} {defaultInstanceDbName} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettingsPanel.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettingsPanel.svelte new file mode 100644 index 0000000000..dc6d812f2e --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettingsPanel.svelte @@ -0,0 +1,554 @@ + + + + drawer?.closeDrawer()}> + {#if dt} +
+ {#if dt.setup_incomplete} +
+ + {dt.name} is recorded but not usable yet. Finishing picks up where it stopped and will + not create a second project. + + {#if resume} + + {/if} +
+ +
+
+ {/if} + +
+
+ {#if dt.origin?.project_name} +
Supabase project
+
+ {dt.origin.project_name} + {#if supabaseProjectUrl(dt.origin)} + + {/if} +
+ {/if} + {#if dt.origin?.org} +
Organization
+
{dt.origin.org}
+ {/if} + {#if dt.origin?.region} +
Region
+
{dt.origin.region}
+ {/if} + {#if dt.origin?.connection_mode} +
Connection
+
+ {dt.origin.connection_mode === 'session' ? 'Session pooler' : 'Direct'} +
+ {/if} +
+ {dt.database.resource_type === 'instance' ? 'Windmill database' : 'Resource'} +
+
+ {#if dt.database.resource_type === 'postgresql' && dt.database.resource_path} + + + {dt.database.resource_path} + + {:else} + {dt.database.resource_path ?? '—'} + {/if} +
+ {#if resourceValue?.host} +
Host
+
{resourceValue.host}
+ {/if} + {#if dt.origin?.connected_by} +
Connected by
+
+ {dt.origin.connected_by}{dt.origin.connected_at + ? ` · ${new Date(dt.origin.connected_at).toLocaleDateString()}` + : ''} +
+ {/if} +
+
+ +
+ +
+ + {#if isSupabase} + + {/if} + +