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

+ +
+