From bc18ebec16112b5772a99525d081a635665f793a Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Fri, 7 Aug 2026 18:47:22 +0200 Subject: [PATCH] refactor(frontend): one setup checklist and one Supabase step for every host The data table wizard, the instance database modal and the resource drawer each had their own version of the same two interactions, and they had already begun to drift: the wizard's Supabase resource shape was rebuilt by hand in the drawer, and the instance checks rendered with no notion of a step being in flight. SetupChecklist replaces LoggedWizardResult, whose only consumer was the instance modal. It adds the running state that component lacked, so a list driven by an endpoint that reports nothing until it returns still shows where it is. Both the instance checks and the Supabase provisioning stages render through it. SupabaseProjectStep owns picking or creating a project, and useSupabaseOauth owns the popup leg. Each host keeps only what is genuinely its own: the wizard saves a variable and resource then verifies the connection, the resource drawer fills in its own form. Both trigger authorization themselves, so a host can offer it a screen earlier than the step does. The lists load behind a spinner because which mode to open on depends on whether the account has projects; deciding that after rendering flipped the toggle under the user. Adds a kitchen_sink playground for the checklist so the animation and every failure position can be exercised without a backend, a superadmin, or a Supabase account. Co-Authored-By: Claude Opus 5 (1M context) --- .../wizards/LoggedWizardResult.svelte | 108 ---- .../components/wizards/SetupChecklist.svelte | 109 ++++ .../AddDataTableWizard.svelte | 594 ++++++++---------- .../CustomInstanceDbWizardModal.svelte | 69 +- .../DataTableSettings.svelte | 19 +- .../SupabaseProjectStep.svelte | 286 +++++++++ .../workspaceSettings/instanceDbSteps.ts | 89 +++ .../workspaceSettings/supabaseOauth.svelte.ts | 53 ++ .../workspaceSettings/supabaseProvisioning.ts | 27 +- .../kitchen_sink/setup_checklist/+page.svelte | 132 ++++ 10 files changed, 963 insertions(+), 523 deletions(-) delete mode 100644 frontend/src/lib/components/wizards/LoggedWizardResult.svelte create mode 100644 frontend/src/lib/components/wizards/SetupChecklist.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/SupabaseProjectStep.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts create mode 100644 frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts create mode 100644 frontend/src/routes/kitchen_sink/setup_checklist/+page.svelte diff --git a/frontend/src/lib/components/wizards/LoggedWizardResult.svelte b/frontend/src/lib/components/wizards/LoggedWizardResult.svelte deleted file mode 100644 index e924ae4ea7..0000000000 --- a/frontend/src/lib/components/wizards/LoggedWizardResult.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - - - -
- {#each steps as step, i} - {@const descriptionOpened = openedDescriptions[i] ?? false} - - -
{ - if (step.description) { - if (descriptionOpened) delete openedDescriptions[i] - else openedDescriptions[i] = true - } - }} - > -
- - {#if step.status == 'SKIP'} - - {:else if step.status == 'FAIL'} - - {:else if step.status == 'OK'} - - {/if} - -
- - {i + 1}. {step.title} - {#if step.description} - - {/if} - - - {#if descriptionOpened} -
e.stopPropagation()} - > - {step.description} -
- {/if} -
-
-
-
- {/each} -
diff --git a/frontend/src/lib/components/wizards/SetupChecklist.svelte b/frontend/src/lib/components/wizards/SetupChecklist.svelte new file mode 100644 index 0000000000..1864186fb5 --- /dev/null +++ b/frontend/src/lib/components/wizards/SetupChecklist.svelte @@ -0,0 +1,109 @@ + + + + +
+ {#each steps as step, i} + {@const descriptionOpened = openedDescriptions[i] ?? false} + + +
{ + if (!step.description) return + if (descriptionOpened) delete openedDescriptions[i] + else openedDescriptions[i] = true + }} + > +
+ + {#if step.status === 'running'} + + {:else if step.status === 'done'} + + {:else if step.status === 'failed'} + + {:else} + + {/if} + +
+ + {step.title} + {#if step.description} + + {/if} + + + {#if descriptionOpened} +
e.stopPropagation()} + > + {step.description} +
+ {/if} +
+
+
+
+ {/each} +
diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte index 3c98eca230..efccd554d3 100644 --- a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -31,8 +31,7 @@ + + + +{#if !oauth.authed} + + {#if oauth.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 if loading} +
+ + Loading your Supabase projects... +
+{:else} + + {#snippet children({ item })} + + + {/snippet} + + + {#if mode === 'create'} + {#if provisioning === 0} +
+
+ Organization + ({ label: r, value: r }))} + bind:value={region} + placeholder="Region" + /> +
+
+
+ Project name + +
+ {:else} + + {#if provisioning < 3} +

+ This usually takes a minute or two. You can leave this open.{provisionStatus + ? ` (${provisionStatus})` + : ''} +

+ {/if} + {/if} + {:else} +
+ {#each projects ?? [] as p (p.id)} + {@const selected = selectedProject?.id === p.id} + + {/each} +
+ {#if selectedProject} +
+ Database password + +

+ Find it in your Supabase project settings, under Database. +

+
+ {/if} + {/if} + + {#if strandedPassword} + + {strandedPassword} +
+ The project exists but Windmill could not finish. Supabase never shows this password again. +
+ {/if} +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts b/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts new file mode 100644 index 0000000000..dd465626f4 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts @@ -0,0 +1,89 @@ +import type { CustomInstanceDb } from '$lib/gen' +import { runningFrom, type SetupStep } from '../wizards/SetupChecklist.svelte' + +/** + * The same checks as [`instanceDbSteps`], in the vocabulary the wizard's checklist speaks. + * Nothing is reported until the call returns, so an unreported step is either the failure + * (when the call errored) or simply not reached yet. + */ +export function instanceSetupSteps( + dbname: string, + status: CustomInstanceDb | undefined, + running: boolean +): SetupStep[] { + let firstUnreported = true + const steps = instanceDbSteps(dbname, status).map((step): SetupStep => { + if (step.status === 'OK') return { ...step, status: 'done' } + if (step.status === 'FAIL') return { ...step, status: 'failed' } + if (step.status === 'SKIP') return { ...step, status: 'skipped' } + const failed = firstUnreported && !!status?.error + firstUnreported = false + return { ...step, status: failed ? 'failed' : 'pending' } + }) + return runningFrom(steps, running) +} + +/** + * The checks `setup_custom_instance_db` reports, in the order it runs them. Shared so the + * setup modal and the data table wizard describe the same failure the same way. + */ +export function instanceDbSteps(dbname: string, status: CustomInstanceDb | undefined) { + return [ + { + title: 'Super admin required', + status: status?.logs.super_admin, + description: + 'You need to be a super admin to create a new database in the Windmill PostgreSQL instance' + }, + { + title: 'Retrieve and parse database credentials', + status: status?.logs.database_credentials, + description: + 'Windmill uses the DATABASE_URL or DATABASE_URL_FILE environment variable to connect to the PostgreSQL instance. Make sure it is correctly set' + }, + { + title: 'Database name is valid', + status: status?.logs.valid_dbname, + description: + 'The database name must be alphanumeric (underscores and hyphens allowed) and cannot be named the same as the Windmill database (usually "windmill")' + }, + { + title: + 'Create database' + + (status?.logs.created_database === 'SKIP' ? ' (already exists, skipped)' : ''), + status: status?.logs.created_database, + description: `In the Windmill PostgreSQL instance, run: CREATE DATABASE "${dbname}".` + }, + { + title: `Connect to the ${dbname} database`, + status: status?.logs.db_connect, + description: + "Connect to the newly created database with the default admin user (the one in DATABASE_URL, usually 'postgres') to run the next commands" + }, + { + title: 'Grant permissions to custom_instance_user', + status: status?.logs.grant_permissions, + description: + 'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. These are the commands : \n\n' + + `GRANT CONNECT ON DATABASE "${dbname}" TO custom_instance_user;\n` + + 'GRANT USAGE ON SCHEMA public TO custom_instance_user;\n' + + 'GRANT CREATE ON SCHEMA public TO custom_instance_user;\n' + + `GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` + + 'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' + + ' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' + + 'ALTER ROLE custom_instance_user CREATEROLE;' + }, + { + title: 'Grant replication to custom_instance_replication_user', + status: status?.logs.replication_user, + description: + 'Postgres triggers on custom-instance datatables connect as custom_instance_replication_user, whose password is stored in global_settings.custom_instance_replication_pwd. The role is cluster-wide, so it is created on the Windmill PostgreSQL instance rather than on this database : \n\n' + + 'ALTER ROLE custom_instance_replication_user REPLICATION;\n' + + 'GRANT custom_instance_user TO custom_instance_replication_user;\n\n' + + 'Setting REPLICATION requires a superuser on PostgreSQL 15 and older. Managed instances never grant one, so on AWS RDS Windmill falls back to GRANT rds_replication TO custom_instance_replication_user. The database stays usable for datatables if this step fails, but postgres triggers on them do not.' + + (status?.logs.replication_user_error + ? `\n\nError: ${status.logs.replication_user_error}` + : '') + } + ] +} diff --git a/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts b/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts new file mode 100644 index 0000000000..2fed2f2947 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts @@ -0,0 +1,53 @@ +import { fromStore } from 'svelte/store' +import { base } from '$lib/base' +import { oauthStore } from '$lib/stores' + +const OAUTH_WINDOW = 'windmill_supabase_oauth' +const CONNECT_URL = `${base}/api/oauth/connect/supabase_wizard` + +/** + * The Supabase authorization leg, driven from a popup. + * + * A full-page redirect unmounts whatever opened it, so a user who stops to create a Supabase + * account lands on their dashboard with nothing left pointing back. Keeping the flow in a + * popup keeps the host on screen, and keeps the window ours to steer: after they sign up we + * send the same popup back through the connect endpoint and consent follows. + */ +export function useSupabaseOauth(opts: { onPopupBlocked?: () => void } = {}) { + const oauth = fromStore(oauthStore) + let pending = $state(false) + let win: Window | null = null + + $effect(() => { + function onMessage(e: MessageEvent) { + if (e.origin !== window.location.origin || e.data?.type !== 'supabase_oauth') return + oauthStore.set(e.data.res) + pending = false + win?.close() + } + window.addEventListener('message', onMessage) + return () => window.removeEventListener('message', onMessage) + }) + + return { + get token(): string | undefined { + return oauth.current?.access_token + }, + get authed(): boolean { + return !!oauth.current?.access_token + }, + get pending(): boolean { + return pending + }, + /** Opens (or re-points) the popup. Falls back to a redirect where popups are blocked. */ + connect() { + win = window.open(CONNECT_URL, OAUTH_WINDOW, 'width=600,height=820') + if (!win) { + opts.onPopupBlocked?.() + window.location.href = CONNECT_URL + return + } + pending = true + } + } +} diff --git a/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts b/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts index ed8c4e9490..3b724896d5 100644 --- a/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts +++ b/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts @@ -6,6 +6,8 @@ * access token. */ +import type { SetupStep } from '../wizards/SetupChecklist.svelte' + export type SupabaseOrg = { id: string; name: string; slug?: string } export type SupabaseProject = { @@ -16,6 +18,22 @@ export type SupabaseProject = { database?: { host: string } } +/** + * The provisioning stages, as a checklist. Each entry is driven only by its own index, so a + * host that stops at the Supabase side can take the first two and leave the rest. + * `stage` is 0 idle, 1 creating, 2 starting, 3 checking, 4 ready. + */ +export function supabaseSetupSteps(stage: number, failed = false): SetupStep[] { + const titles = ['Created on Supabase', 'Starting it up', 'Checking Windmill can store data'] + return titles.map((title, i) => { + const done = stage >= i + 2 + const running = stage === i + 1 + if (done) return { title, status: 'done' } + if (running) return { title, status: failed ? 'failed' : 'running' } + return { title, status: 'pending' } + }) +} + /** Region codes accepted by region_selection. */ export const SUPABASE_REGIONS = [ 'us-east-1', @@ -126,6 +144,13 @@ export function supabaseResourceValue(project: SupabaseProject, passwordVarPath: port: 5432, dbname: 'postgres', sslmode: 'prefer', - password: `$var:${passwordVarPath}` + password: `$var:${passwordVarPath}`, + // Resource forms fill in every unset property from the schema as soon as they render, + // so a postgresql resource saved without these comes up already modified -- and saves a + // draft -- the first time anyone opens it. Write them here so opening one is a no-op. + // (accept_invalid_certs renders conditionally and is not seeded, so it stays out.) + region: '', + root_certificate_pem: '', + use_iam_auth: false } } diff --git a/frontend/src/routes/kitchen_sink/setup_checklist/+page.svelte b/frontend/src/routes/kitchen_sink/setup_checklist/+page.svelte new file mode 100644 index 0000000000..7c4aefc51c --- /dev/null +++ b/frontend/src/routes/kitchen_sink/setup_checklist/+page.svelte @@ -0,0 +1,132 @@ + + +
+
+

Setup checklist

+ +
+ +
+ + + + +
+ +
+
+

Supabase provisioning

+ +
+ +
+ +
+
+

Instance database setup

+ +
+ +
+ +
+

Every status

+ +
+