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