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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-07 18:47:22 +02:00
co-authored by Claude Opus 5
parent 521b9b5720
commit bc18ebec16
10 changed files with 963 additions and 523 deletions
@@ -1,108 +0,0 @@
<script module lang="ts">
export function firstEmptyStepIsError<Step extends { status?: LoggedWizardStatus }>(
steps: Step[],
error: string | undefined
): (Step & { status: LoggedWizardStatus })[] {
let convertedSteps = [...steps]
let alreadyFoundEmpty = false
for (let step of convertedSteps) {
if (!step.status) {
if (!alreadyFoundEmpty) {
alreadyFoundEmpty = true
step.status = error !== undefined ? 'FAIL' : 'SKIP'
} else {
step.status = 'SKIP'
}
}
}
return convertedSteps as any
}
</script>
<script lang="ts">
import type { LoggedWizardStatus } from '$lib/gen'
import { CircleCheck, Circle, CircleX, ChevronDown } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
type Props = {
steps: { title?: string; status: LoggedWizardStatus; description?: string }[]
class?: string
}
let { steps, class: className = '' }: Props = $props()
let openedDescriptions: Record<number, true> = $state({})
$effect(() => {
for (let i = 0; i < steps.length; i++) {
let step = steps[i]
if (step.status == 'FAIL') {
openedDescriptions[i] = true
}
}
})
</script>
<div class={twMerge('flex flex-col gap-2', className)}>
{#each steps as step, i}
{@const descriptionOpened = openedDescriptions[i] ?? false}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col bg-surface rounded-md py-1.5 pr-2 cursor-pointer"
role=""
onclick={() => {
if (step.description) {
if (descriptionOpened) delete openedDescriptions[i]
else openedDescriptions[i] = true
}
}}
>
<div class="flex gap-3">
<span class="inline-flex w-5 h-10 shrink-0 justify-center items-center">
{#if step.status == 'SKIP'}
<Circle size={20} class="inline text-hint/50" />
{:else if step.status == 'FAIL'}
<CircleX size={20} class="inline text-red-500" />
{:else if step.status == 'OK'}
<CircleCheck size={20} class="inline text-green-500" />
{/if}
</span>
<div class="flex-1 my-2">
<span
class={twMerge(
'font-medium flex justify-between items-center',
{
SKIP: 'text-hint/75',
FAIL: 'text-red-400',
OK: 'text-green-600 dark:text-green-400'
}[step.status]
)}
>
{i + 1}. {step.title}
{#if step.description}
<ChevronDown
class={twMerge(
'text-hint transition-transform',
descriptionOpened ? 'rotate-180' : ''
)}
size={16}
/>
{/if}
</span>
<ResizeTransitionWrapper vertical class="text-xs text-secondary">
{#if descriptionOpened}
<div
class="whitespace-pre-wrap cursor-default mt-1.5"
onclick={(e) => e.stopPropagation()}
>
{step.description}
</div>
{/if}
</ResizeTransitionWrapper>
</div>
</div>
</div>
{/each}
</div>
@@ -0,0 +1,109 @@
<script lang="ts" module>
export type SetupStepStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
export type SetupStep = {
title: string
status: SetupStepStatus
/** Shown when the row is expanded, and opened automatically when the step fails. */
description?: string
}
/**
* A backend that only reports once it is done leaves every step blank while it works.
* Drive the list off that: the first unreported step is the one in flight.
*/
export function runningFrom(steps: SetupStep[], running: boolean): SetupStep[] {
if (!running) return steps
const next = steps.findIndex((s) => s.status === 'pending')
return steps.map((s, i) => (i === next ? { ...s, status: 'running' } : s))
}
</script>
<script lang="ts">
import { Circle, CircleCheck, CircleX, ChevronDown, Loader2 } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
type Props = {
steps: SetupStep[]
class?: string
}
let { steps, class: className = '' }: Props = $props()
let openedDescriptions: Record<number, true> = $state({})
$effect(() => {
for (let i = 0; i < steps.length; i++) {
if (steps[i].status === 'failed') openedDescriptions[i] = true
}
})
const titleClass: Record<SetupStepStatus, string> = {
pending: 'text-hint/75',
running: 'text-primary',
done: 'text-green-600 dark:text-green-400',
failed: 'text-red-400',
skipped: 'text-hint/75'
}
</script>
<div class={twMerge('flex flex-col gap-0.5', className)}>
{#each steps as step, i}
{@const descriptionOpened = openedDescriptions[i] ?? false}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col bg-surface rounded-md py-1 pr-2 cursor-pointer"
role=""
onclick={() => {
if (!step.description) return
if (descriptionOpened) delete openedDescriptions[i]
else openedDescriptions[i] = true
}}
>
<div class="flex gap-2">
<span class="inline-flex w-4 h-5 shrink-0 justify-center items-center">
{#if step.status === 'running'}
<Loader2 size={16} class="inline animate-spin text-blue-500" />
{:else if step.status === 'done'}
<CircleCheck size={16} class="inline text-green-500" />
{:else if step.status === 'failed'}
<CircleX size={16} class="inline text-red-500" />
{:else}
<Circle size={16} class="inline text-hint/50" />
{/if}
</span>
<div class="flex-1 my-0.5">
<span
class={twMerge(
'text-xs font-medium flex justify-between items-center',
titleClass[step.status]
)}
>
{step.title}
{#if step.description}
<ChevronDown
class={twMerge(
'text-hint transition-transform',
descriptionOpened ? 'rotate-180' : ''
)}
size={14}
/>
{/if}
</span>
<ResizeTransitionWrapper vertical class="text-2xs text-secondary">
{#if descriptionOpened}
<div
class="whitespace-pre-wrap cursor-default mt-1.5"
onclick={(e) => e.stopPropagation()}
>
{step.description}
</div>
{/if}
</ResizeTransitionWrapper>
</div>
</div>
</div>
{/each}
</div>
@@ -31,8 +31,7 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { base } from '$lib/base'
import { Database, Check, ArrowRight, Loader2 } from 'lucide-svelte'
import { Database, ArrowRight } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
@@ -40,27 +39,27 @@
import Stepper from '../common/stepper/Stepper.svelte'
import Alert from '../common/alert/Alert.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import SupabaseIcon from '../icons/SupabaseIcon.svelte'
import { OauthService, ResourceService, VariableService, WorkspaceService } from '$lib/gen'
import type { TestDataTableConnectionResponse } from '$lib/gen'
import { oauthStore, userStore, workspaceStore } from '$lib/stores'
import {
OauthService,
ResourceService,
SettingService,
VariableService,
WorkspaceService
} from '$lib/gen'
import type { ListCustomInstanceDbsResponse, TestDataTableConnectionResponse } from '$lib/gen'
import type { ResourceReturn } from 'runed'
import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import SetupChecklist from '../wizards/SetupChecklist.svelte'
import { instanceSetupSteps } from './instanceDbSteps'
import SupabaseProjectStep, { type SupabasePick } from './SupabaseProjectStep.svelte'
import { DEFAULT_SUPABASE_REGION, type SupabaseProject } from './supabaseProvisioning'
import { useSupabaseOauth } from './supabaseOauth.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { isCustomInstanceDbEnabled } from './utils.svelte'
import {
DEFAULT_SUPABASE_REGION,
SUPABASE_REGIONS,
createSupabaseProject,
generateDbPassword,
listSupabaseOrgs,
listSupabaseProjects,
orgSlug,
supabaseResourceValue,
waitUntilSupabaseHealthy,
type SupabaseOrg,
type SupabaseProject
} from './supabaseProvisioning'
import { supabaseResourceValue } from './supabaseProvisioning'
type Props = {
opened: boolean
@@ -68,52 +67,81 @@
/** Set when Supabase redirected the user back here mid-flow. */
resume?: WizardResume | undefined
onDone: () => void
/** Instance databases are provisioned by a superadmin from the row itself, so that
* branch hands back to the existing inline editor rather than duplicating it. */
onUseInstance: () => void
/** The instance database pool and its confirmation host are owned by the settings page,
* which already loads them for the rows; sharing them keeps one source of truth. */
customInstanceDbs: ResourceReturn<ListCustomInstanceDbsResponse>
confirmationModal: ConfirmationModalHandle
defaultInstanceDbName: () => string
}
let { opened = $bindable(), existingNames, resume, onDone, onUseInstance }: Props = $props()
let {
opened = $bindable(),
existingNames,
resume,
onDone,
customInstanceDbs,
confirmationModal,
defaultInstanceDbName
}: Props = $props()
type Provider = 'supabase' | 'existing' | 'instance'
let step = $state(1)
let provider: Provider | undefined = $state(undefined)
let supaMode: 'create' | 'existing' = $state('create')
let supaModeChosen = $state(false)
let orgs: SupabaseOrg[] | undefined = $state(undefined)
let selectedOrg: string | undefined = $state(undefined)
let region: string = $state(DEFAULT_SUPABASE_REGION)
let projectName = $state('')
let projects: SupabaseProject[] | undefined = $state(undefined)
let selectedProject: SupabaseProject | undefined = $state(undefined)
let existingPassword = $state('')
let supaStep: ReturnType<typeof SupabaseProjectStep> | undefined = $state(undefined)
let supaResult: SupabasePick | undefined = $state(undefined)
let resourcePath: string | undefined = $state(undefined)
let dataTableName = $state('main')
let provisioning = $state(0) // 0 idle, 1 created, 2 starting, 3 checking, 4 ready
let provisionStatus = $state('')
let instanceDbName: string | undefined = $state(undefined)
let instanceMode: 'existing' | 'create' = $state('create')
let instanceSetupRunning = $state(false)
// Selecting a database that is already healthy should not open with seven green ticks; the
// checks are worth the room once they have something to report.
let instanceSetupAttempted = $state(false)
let preventClose = false
let instanceStatus = $derived(
instanceDbName ? customInstanceDbs.current?.[instanceDbName] : undefined
)
// Instance databases are pooled across features; DuckLake catalogs are tagged separately
// and must not show up as somewhere to put a data table.
let instanceDbs = $derived(
Object.entries(customInstanceDbs.current ?? {})
.filter(([_, db]) => db.tag === 'datatable')
.map(([name, db]) => ({ name, db }))
)
function enterInstanceStep() {
instanceMode = 'create'
instanceDbName ??= defaultInstanceDbName()
}
function setInstanceMode(mode: 'existing' | 'create') {
if (mode === instanceMode) return
instanceMode = mode
selectInstanceDb(mode === 'create' ? defaultInstanceDbName() : undefined)
}
function selectInstanceDb(name: string | undefined) {
if (name === instanceDbName) return
instanceDbName = name
instanceSetupAttempted = false
}
function otherWorkspaces(name: string): string[] {
return (customInstanceDbs.current?.[name]?.used_by_workspaces ?? []).filter(
(w) => w !== $workspaceStore
)
}
let checking = $state(false)
let checkReport: TestDataTableConnectionResponse | undefined = $state(undefined)
let checkError = $state('')
let finishing = $state(false)
// Set only when a Supabase project was created but its credentials could not be saved.
let strandedPassword = $state('')
// Which password the saved resource holds, so a retry only re-creates it when it changed.
let savedPassword = $state('')
let token = $derived($oauthStore?.access_token)
let authed = $derived(!!token)
// Supabase statuses are SCREAMING_SNAKE; only worth showing when it is not the happy path,
// since a paused project (free tier pauses after a week idle) fails the connection check.
function projectStatus(p: SupabaseProject): string | undefined {
if (!p.status || p.status === 'ACTIVE_HEALTHY') return undefined
return p.status === 'INACTIVE' ? 'paused' : p.status.toLowerCase().replace(/_/g, ' ')
}
function defaultProjectName(): string {
return `windmill-${$workspaceStore ?? 'workspace'}`
}
@@ -134,16 +162,13 @@
}
if (!primed) {
primed = true
// Names are derived on open, not at construction: the wizard is mounted for the
// The name is derived on open, not at construction: the wizard is mounted for the
// lifetime of the page, so the existing data tables are not known until then.
dataTableName = resume?.name || defaultTableName()
projectName = resume?.projectName || defaultProjectName()
}
if (resume) {
provider = 'supabase'
supaMode = 'create'
step = 2
region = resume.region
dataTableName = resume.name
}
})
@@ -158,61 +183,18 @@
.catch(() => {})
})
$effect(() => {
if (opened && provider === 'supabase' && token && orgs === undefined) {
loadSupabase(token)
}
})
async function loadSupabase(t: string) {
try {
orgs = await listSupabaseOrgs(t)
if (orgs?.length && !selectedOrg) selectedOrg = orgSlug(orgs[0])
projects = await listSupabaseProjects(t)
// Someone who already has a Supabase database almost always means to connect it
// rather than make a second one. Only pre-empt the choice they have not made yet:
// a resumed run was already mid-creation, and a manual pick stands.
if (!resume && !supaModeChosen && projects?.length) supaMode = 'existing'
} catch (err) {
sendUserToast(String(err), true)
orgs = orgs ?? []
}
}
const OAUTH_WINDOW = 'windmill_supabase_oauth'
const SUPABASE_SIGNUP_URL = 'https://supabase.com/dashboard/sign-up'
let oauthWindow: Window | null = null
let oauthPending = $state(false)
/**
* A full-page redirect unmounts the wizard, so a user who stops to create a Supabase
* account lands on their dashboard with nothing left pointing back here. Driving the flow
* from a popup keeps this modal 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.
*/
function startOauth() {
const url = `${base}/api/oauth/connect/supabase_wizard`
oauthWindow = window.open(url, OAUTH_WINDOW, 'width=600,height=820')
if (!oauthWindow) {
// Popups blocked: fall back to the redirect, parking what the user had chosen.
parkWizard({ name: dataTableName, region, projectName })
window.location.href = url
return
}
oauthPending = true
step = 2
}
$effect(() => {
function onMessage(e: MessageEvent) {
if (e.origin !== window.location.origin || e.data?.type !== 'supabase_oauth') return
$oauthStore = e.data.res
oauthPending = false
oauthWindow?.close()
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
// The step component drives its own authorization once the user is on it, but signing in is
// the whole of that step when it has not happened yet -- so Continue does it directly rather
// than spending a screen telling the user what the next button will do.
const supaOauth = useSupabaseOauth({
onPopupBlocked: () =>
parkWizard({
name: dataTableName,
region: DEFAULT_SUPABASE_REGION,
projectName: defaultProjectName()
})
})
/**
@@ -258,63 +240,21 @@
return path
}
async function provision() {
if (!token || !selectedOrg || !projectName) return
provisioning = 1
provisionStatus = ''
checkError = ''
let createdPassword = ''
try {
const dbPass = generateDbPassword()
// Before the call, not after: a throw here can still leave a project behind, and
// Supabase will never show its password again.
createdPassword = dbPass
const created = await createSupabaseProject(token, {
name: projectName,
organizationSlug: selectedOrg,
region,
dbPass
})
provisioning = 2
const healthy = await waitUntilSupabaseHealthy(
token,
created.id ?? (created as any).ref,
(s) => (provisionStatus = s ?? '')
)
provisioning = 3
// Save the credentials before checking: Supabase never hands the password back, so
// losing it here would leave a project nobody can log into.
resourcePath = await saveSupabaseResource(healthy, dbPass)
await runCheck()
provisioning = 4
} catch (err) {
checkError = String(err)
provisioning = 0
// The project may already exist on Supabase by now, and its password cannot be read
// back from there. Show it rather than let the database become unusable.
if (createdPassword) strandedPassword = createdPassword
// The project may exist now; refresh so it can be picked up from the other tab
// instead of provisioning a second one.
listSupabaseProjects(token)
.then((p) => (projects = p))
.catch(() => {})
sendUserToast(String(err), true)
}
}
async function connectExistingSupabase() {
if (!token || !selectedProject || !existingPassword) return
/**
* The step component hands back a project and the password Windmill knows for it; from
* here it is the same work either way. Credentials are saved before the check because
* Supabase never hands a generated password back, and only re-saved when it changed, so a
* retry after fixing GRANTs does not leave another orphan variable behind.
*/
async function adoptSupabaseResult(pick: SupabasePick) {
checking = true
checkError = ''
try {
// Only create the credentials once: a retry after fixing GRANTs re-checks what is
// already saved rather than leaving another orphan variable behind each time.
if (!resourcePath || savedPassword !== existingPassword) {
resourcePath = await saveSupabaseResource(selectedProject, existingPassword)
savedPassword = existingPassword
if (!resourcePath || savedPassword !== pick.password) {
resourcePath = await saveSupabaseResource(pick.project, pick.password)
savedPassword = pick.password
}
await runCheck()
if (canAdvanceFromSetup()) step = 3
} catch (err) {
checkError = String(err)
sendUserToast(String(err), true)
@@ -323,6 +263,10 @@
}
}
$effect(() => {
if (supaResult) adoptSupabaseResult(supaResult)
})
async function runCheck() {
if (!resourcePath) return
checking = true
@@ -358,26 +302,66 @@
provider = key
}
/**
* setup_custom_instance_db both creates the database and re-runs every check, so one call
* serves the first attempt and every retry. Creation is destructive enough to confirm, but
* only the first time: once the database exists the call is a pure re-check.
*/
async function setupInstanceDb() {
if (!instanceDbName) return
const exists =
instanceStatus?.logs.created_database === 'OK' ||
instanceStatus?.logs.created_database === 'SKIP'
if (!exists) {
// The confirmation dialog takes focus from this modal, which Modal2 reads as a
// dismissal -- without the guard the wizard closes the moment setup is confirmed.
preventClose = true
const confirmed = await confirmationModal.ask({
title: 'Confirm setup',
children: `This will create a new database ${instanceDbName} in the Windmill PostgreSQL instance`,
confirmationText: 'Setup database'
})
preventClose = false
if (!confirmed) return
}
instanceSetupRunning = true
instanceSetupAttempted = true
try {
const result = await SettingService.setupCustomInstanceDb({
name: instanceDbName,
requestBody: { tag: 'datatable' }
})
await customInstanceDbs.refetch()
// Stay on the step even when everything passed: the checks are the point of this
// screen, and skipping past them hides what was just done to the database.
if (!result.success) sendUserToast(result.error ?? 'Setup failed', true)
} catch (err) {
sendUserToast(`Could not set up ${instanceDbName}: ${err}`, true)
} finally {
instanceSetupRunning = false
}
}
function checkPassed(): boolean {
return !!checkReport && checkReport.can_create_table && !checkError
}
function canAdvanceFromSetup(): boolean {
if (provider === 'supabase' && supaMode === 'create') return provisioning === 4 && checkPassed()
return checkPassed()
}
async function finish() {
if (!resourcePath && provider !== 'instance') return
if (provider === 'instance' ? !instanceDbName : !resourcePath) return
finishing = true
try {
// editDataTableConfig replaces the whole map, so the existing entries have to be
// read back and merged or they are silently dropped.
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
const datatables: Record<string, any> = { ...(settings.datatable?.datatables ?? {}) }
datatables[dataTableName] = {
database: { resource_type: 'postgresql', resource_path: resourcePath }
}
datatables[dataTableName] =
provider === 'instance'
? { database: { resource_type: 'instance', resource_path: instanceDbName } }
: { database: { resource_type: 'postgresql', resource_path: resourcePath } }
await WorkspaceService.editDataTableConfig({
workspace: $workspaceStore!,
requestBody: { settings: { datatables }, renames: [], deleted_datatables: [] }
@@ -396,21 +380,16 @@
function reset() {
step = 1
provider = undefined
supaMode = 'create'
supaModeChosen = false
orgs = undefined
projects = undefined
selectedOrg = undefined
selectedProject = undefined
existingPassword = ''
// The Supabase step keeps its own state; it is remounted with the modal, so closing is
// all it takes to clear it.
supaResult = undefined
resourcePath = undefined
provisioning = 0
provisionStatus = ''
instanceDbName = undefined
instanceMode = 'create'
instanceSetupAttempted = false
checkReport = undefined
checkError = ''
strandedPassword = ''
savedPassword = ''
projectName = ''
dataTableName = defaultTableName()
}
@@ -418,51 +397,46 @@
// moves the wizard on -- there is no separate "run the check" or "create" button.
let primary = $derived.by(() => {
if (step === 1) {
// Signing in is the whole of the Supabase setup step, so go straight there rather
// than spending a screen telling the user what the button is about to do.
if (provider === 'supabase' && !authed)
return { label: 'Connect to Supabase', disabled: false, act: startOauth }
if (provider === 'supabase' && !supaOauth.authed)
return {
label: 'Connect to Supabase',
disabled: false,
act: () => {
supaOauth.connect()
step = 2
}
}
return {
label: 'Continue',
disabled: !provider,
act: () => {
if (provider === 'instance') {
opened = false
reset()
onUseInstance()
} else {
step = 2
}
if (provider === 'instance') enterInstanceStep()
step = 2
}
}
}
if (step === 2) {
if (provider === 'supabase') {
// Reached while the popup is still open, or if the redirect came back without a
// token. Either way the action is the same: send the popup through consent again,
// which is immediate once the user has an account and is signed in.
if (!authed)
return {
label: oauthPending ? 'Continue' : 'Connect to Supabase',
disabled: false,
act: startOauth
}
if (supaMode === 'create') {
if (provisioning === 0)
return {
label: 'Create database',
disabled: !projectName || !selectedOrg,
act: provision
}
if (provisioning < 4) return { label: 'Setting it up', disabled: true, busy: true }
if (!checkPassed()) return { label: 'Try again', disabled: false, act: runCheck }
return { label: 'Continue', disabled: false, act: () => (step = 3) }
}
// Picking or creating the project is the step component's business; this only
// takes over once it has handed one back and the credentials have been checked.
if (!supaResult) return supaStep?.getAction() ?? { label: 'Continue', disabled: true }
if (checking) return { label: 'Checking', disabled: true, busy: true }
if (!checkPassed())
return {
label: 'Try again',
disabled: false,
act: () => adoptSupabaseResult(supaResult!)
}
return { label: 'Continue', disabled: false, act: () => (step = 3) }
}
if (provider === 'instance') {
if (instanceSetupRunning) return { label: 'Setting it up', disabled: true, busy: true }
if (instanceStatus?.success)
return { label: 'Continue', disabled: false, act: () => (step = 3) }
return {
label: checkReport && !checkPassed() ? 'Try again' : 'Continue',
disabled: !selectedProject || !existingPassword,
act: connectExistingSupabase
label: instanceStatus ? 'Try again' : 'Set up database',
disabled: !instanceDbName,
act: setupInstanceDb
}
}
if (checking) return { label: 'Checking', disabled: true, busy: true }
@@ -487,6 +461,7 @@
bind:isOpen={
() => opened,
(v) => {
if (!v && preventClose) return
opened = v
if (!v) reset()
}
@@ -541,124 +516,100 @@
)}
</div>
{:else if step === 2}
{#if provider === 'supabase' && !authed}
<Alert type="info" size="xs" bgClass="border-0" title="">
{#if oauthPending}
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 provider === 'supabase'}
<SupabaseProjectStep
bind:this={supaStep}
bind:result={supaResult}
defaultProjectName={defaultProjectName()}
resume={resume
? { region: resume.region, projectName: resume.projectName }
: undefined}
onPopupBlocked={(s) => 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}
<ToggleButtonGroup bind:selected={() => instanceMode, (v) => setInstanceMode(v)}>
{#snippet children({ item })}
<ToggleButton value="existing" label="Use an existing one" {item} small />
<ToggleButton value="create" label="Create a new one" {item} small />
{/snippet}
</ToggleButtonGroup>
{/if}
{#if instanceMode === 'existing'}
<!-- Above the list, not under it: the list scrolls, and a warning about sharing
another workspace's data is worthless if the user has to scroll to reach it. -->
{#if instanceDbName && otherWorkspaces(instanceDbName).length}
{@const shared = otherWorkspaces(instanceDbName)}
<Alert type="warning" size="xs" bgClass="border-0" title="">
This database is also used by workspace{shared.length > 1 ? 's' : ''}
<span class="font-semibold">{shared.join(', ')}</span>. Any data written here will
be shared with {shared.length > 1 ? 'them' : 'it'}.
</Alert>
{/if}
</Alert>
{:else if provider === 'supabase'}
<ToggleButtonGroup
bind:selected={
() => supaMode,
(v) => {
if (v !== supaMode) clearCheck()
supaMode = v
supaModeChosen = true
}
}
>
{#snippet children({ item })}
<ToggleButton value="existing" label="Use an existing one" {item} small />
<ToggleButton value="create" label="Create a new project" {item} small />
{/snippet}
</ToggleButtonGroup>
{#if supaMode === 'create'}
{#if provisioning === 0}
<div class="grid grid-cols-2 gap-2">
<div>
<span class="text-xs font-semibold text-emphasis">Organization</span>
<Select
items={(orgs ?? []).map((o) => ({ label: o.name, value: orgSlug(o) }))}
bind:value={selectedOrg}
placeholder={orgs === undefined ? 'Loading...' : 'Select'}
/>
</div>
<div>
<span class="text-xs font-semibold text-emphasis">Region</span>
<Select
items={SUPABASE_REGIONS.map((r) => ({ label: r, value: r }))}
bind:value={region}
placeholder="Region"
/>
</div>
</div>
<div>
<span class="text-xs font-semibold text-emphasis">Project name</span>
<TextInput
bind:value={projectName}
inputProps={{ placeholder: defaultProjectName() }}
/>
<p class="text-2xs text-secondary mt-1">
Named after your workspace. Change it if you like.
</p>
</div>
{:else}
<div class="flex flex-col gap-1.5">
{@render progress(provisioning >= 2, provisioning === 1, 'Created on Supabase')}
{@render progress(provisioning >= 3, provisioning === 2, 'Starting it up')}
{@render progress(
provisioning >= 4,
provisioning === 3,
'Checking Windmill can store data'
)}
</div>
{#if provisioning < 4}
<p class="text-xs text-secondary">
This usually takes a minute or two. You can leave this open.{provisionStatus
? ` (${provisionStatus})`
: ''}
</p>
{:else if checkPassed()}
<Alert
type="success"
size="xs"
bgClass="border-0"
title="{projectName} is ready"
/>
{/if}
{/if}
{:else}
<div class="flex flex-col gap-2">
{#each projects ?? [] as p}
{@const selected = selectedProject?.id === p.id}
<!-- The list takes the leftover height and is the only thing that scrolls, so the
toggle and the sharing warning stay put and the step itself never needs a second
scrollbar. A pooled instance can hold dozens of databases. -->
<div class="flex flex-col gap-2 overflow-y-auto flex-1 min-h-24 pr-1">
{#each instanceDbs as { name, db } (name)}
{@const selected = instanceDbName === name}
{@const shared = otherWorkspaces(name)}
<button
class="text-left border rounded-md p-3 flex gap-3 items-start transition-colors {selected
? 'border-border-selected/50 bg-surface-accent-selected'
: 'border-border-light hover:bg-surface-hover'}"
onclick={() => (selectedProject = p)}
onclick={() => selectInstanceDb(name)}
>
<span class="mt-0.5 shrink-0"
><Database size={18} class="text-secondary" /></span
>
<span class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs font-medium {selected ? 'text-accent' : 'text-emphasis'}"
>{p.name}</span
>
<span class="text-xs text-secondary font-normal"
>{p.region}{#if projectStatus(p)}
&middot; {projectStatus(p)}{/if}</span
>{name}</span
>
<span class="text-xs text-secondary font-normal">
{db.success ? 'Ready' : 'Needs setup'}{shared.length
? ` · shared with ${shared.length} other workspace${shared.length > 1 ? 's' : ''}`
: ''}
</span>
</span>
</button>
{/each}
{#if selectedProject}
<div>
<span class="text-xs font-semibold text-emphasis"
>Database password for {selectedProject.name}</span
>
<TextInput
bind:value={existingPassword}
inputProps={{ type: 'password', placeholder: '••••••••' }}
/>
<p class="text-2xs text-secondary mt-1">
Find it in your Supabase project settings, under Database.
</p>
</div>
{/if}
</div>
{:else}
<div>
<span class="text-xs font-semibold text-emphasis">Database name</span>
<TextInput
bind:value={() => instanceDbName ?? '', (v) => (instanceDbName = v)}
inputProps={{ placeholder: defaultInstanceDbName() }}
/>
<p class="text-2xs text-secondary mt-1">
Created in the Windmill PostgreSQL instance. Windmill manages its credentials.
</p>
</div>
{/if}
{#if instanceSetupRunning || (instanceStatus && (instanceSetupAttempted || instanceStatus.error))}
<SetupChecklist
steps={instanceSetupSteps(
instanceDbName ?? '',
instanceStatus,
instanceSetupRunning
)}
/>
{/if}
{:else}
<div>
@@ -711,7 +662,7 @@
{primary.label}
</Button>
</div>
{#if provider === 'supabase' && !authed}
{#if provider === 'supabase' && !supaOauth.authed}
<p class="text-2xs text-secondary text-right">
If you do not have a Supabase account you can <a
href={SUPABASE_SIGNUP_URL}
@@ -742,32 +693,7 @@
</button>
{/snippet}
{#snippet progress(done: boolean, running: boolean, label: string)}
<div class="flex items-center gap-2 text-xs bg-surface-secondary rounded-md px-2 py-1.5">
{#if done}
<Check size={14} class="text-green-500" />
{:else if running}
<Loader2 size={14} class="animate-spin text-blue-500" />
{:else}
<span class="w-3.5 h-3.5 rounded-full border border-gray-300"></span>
{/if}
<span>{label}</span>
</div>
{/snippet}
{#snippet checkResult()}
{#if strandedPassword}
<Alert type="error" size="xs" bgClass="border-0" title="Save this password">
<div class="flex flex-col gap-2">
<div>
The project was created on Supabase but Windmill could not store its credentials. Supabase
cannot show this password again - copy it now, or reset it from the project's database
settings.
</div>
<pre class="whitespace-pre-wrap select-all text-2xs">{strandedPassword}</pre>
</div>
</Alert>
{/if}
{#if checkError}
<Alert type="error" size="xs" bgClass="border-0" title="Could not connect">{checkError}</Alert>
{:else if checkReport && !checkReport.can_create_table}
@@ -8,7 +8,7 @@
import { slide } from 'svelte/transition'
import Modal2 from '../common/modal/Modal2.svelte'
import Alert from '../common/alert/Alert.svelte'
import LoggedWizardResult, { firstEmptyStepIsError } from '../wizards/LoggedWizardResult.svelte'
import SetupChecklist from '../wizards/SetupChecklist.svelte'
import Button from '../common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { isCustomInstanceDbEnabled } from './utils.svelte'
@@ -20,6 +20,7 @@
import { truncate } from '$lib/utils'
import Tooltip from '../meltComponents/Tooltip.svelte'
import { superadmin } from '$lib/stores'
import { instanceSetupSteps } from './instanceDbSteps'
type Props = {
customInstanceDbs: ResourceReturn<ListCustomInstanceDbsResponse>
@@ -59,7 +60,7 @@
<div class="basis-2/5 grow-0 shrink-0 flex flex-col">
<div class="flex-1 flex flex-col">
<span class="text-sm font-bold mb-2 overflow break-all">{dbname}</span>
<span class="text-sm">
<span class="text-xs font-normal text-secondary">
Custom instance databases are databases created in the Windmill PostgreSQL instance.
Their credentials are automatically managed by Windmill and are never exposed to users.
Only super admins can create them.
@@ -127,68 +128,8 @@
</div>
{/if}
<LoggedWizardResult
steps={firstEmptyStepIsError(
[
{
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}`
: '')
}
],
status?.error ?? undefined
)}
<SetupChecklist
steps={instanceSetupSteps(dbname, status, customInstanceDbSetupIsRunning)}
/>
</div>
{#if $superadmin}
@@ -67,7 +67,6 @@
import TextInput from '../text_input/TextInput.svelte'
import Tooltip from '../Tooltip.svelte'
import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte'
import { random_adj } from '../random_positive_adjetive'
import { sendUserToast } from '$lib/toast'
import {
SettingService,
@@ -162,20 +161,6 @@
return getUnusedInstanceDbName('dt', $workspaceStore ?? '', usedNames)
}
function onNewDataTable() {
const name = tempSettings.dataTables.some((d) => d.name === 'main')
? `${random_adj()}_datatable`
: 'main'
tempSettings.dataTables.push({
id: randomUUID(),
name,
database: {
resource_type: $isCustomInstanceDbEnabled ? 'instance' : 'postgresql',
resource_path: $isCustomInstanceDbEnabled ? defaultInstanceDbName() : undefined
}
})
}
async function onSave() {
try {
if (
@@ -531,5 +516,7 @@
existingNames={tempSettings.dataTables.map((d) => d.name)}
resume={wizardResume}
onDone={reloadAfterWizard}
onUseInstance={onNewDataTable}
{customInstanceDbs}
{confirmationModal}
{defaultInstanceDbName}
/>
@@ -0,0 +1,286 @@
<script lang="ts" module>
/** What the host's primary button should say and do right now. */
export type SupabaseAction = {
label: string
disabled: boolean
busy?: boolean
act?: () => void
}
export type SupabasePick = { project: SupabaseProject; password: string }
</script>
<script lang="ts">
import Alert from '../common/alert/Alert.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte'
import SetupChecklist, { type SetupStep } from '../wizards/SetupChecklist.svelte'
import { Database, Loader2 } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { useSupabaseOauth } from './supabaseOauth.svelte'
import {
DEFAULT_SUPABASE_REGION,
SUPABASE_REGIONS,
createSupabaseProject,
generateDbPassword,
listSupabaseOrgs,
listSupabaseProjects,
orgSlug,
supabaseSetupSteps,
waitUntilSupabaseHealthy,
type SupabaseOrg,
type SupabaseProject
} from './supabaseProvisioning'
type Props = {
/** Set once a project is usable and its password is known. */
result?: SupabasePick | undefined
defaultProjectName?: string
/** Resuming after a popup-blocked redirect. */
resume?: { region: string; projectName: string } | undefined
/** Called before the redirect fallback, so the host can park what the user had chosen. */
onPopupBlocked?: (state: { region: string; projectName: string }) => void
/** Appended below the provisioning steps, for work the host does after this one. */
extraSteps?: SetupStep[]
/** True while the host is busy with `result`, so the action reflects it. */
hostBusy?: boolean
/** Overrides the action label once everything here is done. */
continueLabel?: string
}
let {
result = $bindable(undefined),
defaultProjectName,
resume,
onPopupBlocked,
extraSteps,
hostBusy = false,
continueLabel = 'Continue'
}: Props = $props()
const oauth = useSupabaseOauth({
onPopupBlocked: () => onPopupBlocked?.({ region, projectName })
})
let mode: 'create' | 'existing' = $state('create')
let orgs: SupabaseOrg[] | undefined = $state(undefined)
let projects: SupabaseProject[] | undefined = $state(undefined)
let selectedOrg: string | undefined = $state(undefined)
let region = $state(resume?.region ?? DEFAULT_SUPABASE_REGION)
let projectName = $state(resume?.projectName ?? defaultProjectName ?? '')
let selectedProject: SupabaseProject | undefined = $state(undefined)
let existingPassword = $state('')
// 0 idle, 1 creating, 2 starting, 3 ready
let provisioning = $state(0)
let provisionStatus = $state('')
/** Set when a project was created but could not be handed over; the password is otherwise lost. */
let strandedPassword = $state('')
// Nothing but a spinner until *both* lists are in. Which mode to open on depends on the
// projects, so clearing this when only the orgs have landed is what makes the toggle flip
// under the user a moment later.
let loading = $state(false)
$effect(() => {
if (oauth.token && orgs === undefined) load(oauth.token)
})
async function load(t: string) {
loading = true
try {
orgs = await listSupabaseOrgs(t)
if (orgs?.length && !selectedOrg) selectedOrg = orgSlug(orgs[0])
projects = await listSupabaseProjects(t)
// Someone who already has a Supabase database almost always means to connect it
// rather than make a second one. Decided before anything renders, so the toggle
// never visibly flips under the user; a resumed run was already mid-creation.
if (!resume && projects?.length) mode = 'existing'
} catch (err) {
sendUserToast(String(err), true)
orgs = orgs ?? []
} finally {
loading = false
}
}
/** Supabase statuses are SCREAMING_SNAKE; only surface one that is not the happy path. */
function projectStatus(p: SupabaseProject): string | undefined {
if (!p.status || p.status === 'ACTIVE_HEALTHY') return undefined
return p.status === 'INACTIVE' ? 'paused' : p.status.toLowerCase().replace(/_/g, ' ')
}
async function provision() {
if (!oauth.token || !selectedOrg || !projectName) return
provisioning = 1
try {
const dbPass = generateDbPassword()
// Surface the password before waiting: Supabase never hands it back, so a failure
// after this point would leave a project whose password nobody holds.
strandedPassword = dbPass
const created = await createSupabaseProject(oauth.token, {
name: projectName,
organizationSlug: selectedOrg,
region,
dbPass
})
provisioning = 2
const healthy = await waitUntilSupabaseHealthy(
oauth.token,
created.id ?? (created as any).ref,
(st) => (provisionStatus = st ?? '')
)
provisioning = 3
strandedPassword = ''
result = { project: healthy, password: dbPass }
} catch (err) {
provisioning = 0
sendUserToast(`Could not create the Supabase project: ${err}`, true)
}
}
function useExisting() {
if (!selectedProject || !existingPassword) return
result = { project: selectedProject, password: existingPassword }
}
// Only the two stages this component drives; whatever the host does with the finished
// project is appended by the host as its own step.
let steps = $derived([...supabaseSetupSteps(provisioning).slice(0, 2), ...(extraSteps ?? [])])
let action = $derived.by((): SupabaseAction => {
if (!oauth.authed)
return {
label: oauth.pending ? 'Continue' : 'Connect to Supabase',
disabled: false,
act: () => oauth.connect()
}
if (loading) return { label: 'Loading', disabled: true, busy: true }
if (mode === 'create') {
if (provisioning === 0)
return {
label: 'Create database',
disabled: !projectName || !selectedOrg,
act: provision
}
if (provisioning < 3) return { label: 'Setting it up', disabled: true, busy: true }
return { label: continueLabel, disabled: false, busy: hostBusy }
}
return {
label: result ? continueLabel : continueLabel,
disabled: !selectedProject || !existingPassword,
busy: hostBusy,
act: useExisting
}
})
export function getAction(): SupabaseAction {
return action
}
export function isAuthed(): boolean {
return oauth.authed
}
</script>
{#if !oauth.authed}
<Alert type="info" size="xs" bgClass="border-0" title="">
{#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}
</Alert>
{:else if loading}
<div class="flex items-center gap-2 text-xs text-secondary py-2">
<Loader2 size={16} class="animate-spin" />
Loading your Supabase projects...
</div>
{:else}
<ToggleButtonGroup bind:selected={mode}>
{#snippet children({ item })}
<ToggleButton value="existing" label="Use an existing one" {item} small />
<ToggleButton value="create" label="Create a new project" {item} small />
{/snippet}
</ToggleButtonGroup>
{#if mode === 'create'}
{#if provisioning === 0}
<div class="grid grid-cols-2 gap-2">
<div>
<span class="text-xs font-semibold text-emphasis">Organization</span>
<Select
items={(orgs ?? []).map((o) => ({ label: o.name, value: orgSlug(o) }))}
bind:value={selectedOrg}
placeholder={orgs === undefined ? 'Loading...' : 'Select'}
/>
</div>
<div>
<span class="text-xs font-semibold text-emphasis">Region</span>
<Select
items={SUPABASE_REGIONS.map((r) => ({ label: r, value: r }))}
bind:value={region}
placeholder="Region"
/>
</div>
</div>
<div>
<span class="text-xs font-semibold text-emphasis">Project name</span>
<TextInput bind:value={projectName} inputProps={{ placeholder: defaultProjectName }} />
</div>
{:else}
<SetupChecklist {steps} />
{#if provisioning < 3}
<p class="text-xs text-secondary">
This usually takes a minute or two. You can leave this open.{provisionStatus
? ` (${provisionStatus})`
: ''}
</p>
{/if}
{/if}
{:else}
<div class="flex flex-col gap-2 overflow-y-auto flex-1 min-h-24 pr-1">
{#each projects ?? [] as p (p.id)}
{@const selected = selectedProject?.id === p.id}
<button
class="text-left border rounded-md p-3 flex gap-3 items-start transition-colors {selected
? 'border-border-selected/50 bg-surface-accent-selected'
: 'border-border-light hover:bg-surface-hover'}"
onclick={() => (selectedProject = p)}
>
<span class="mt-0.5 shrink-0"><Database size={18} class="text-secondary" /></span>
<span class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs font-medium {selected ? 'text-accent' : 'text-emphasis'}"
>{p.name}</span
>
<span class="text-xs text-secondary font-normal">
{p.region}{projectStatus(p) ? ` · ${projectStatus(p)}` : ''}
</span>
</span>
</button>
{/each}
</div>
{#if selectedProject}
<div>
<span class="text-xs font-semibold text-emphasis">Database password</span>
<TextInput
bind:value={existingPassword}
inputProps={{ type: 'password', placeholder: '••••••••' }}
/>
<p class="text-2xs text-secondary mt-1">
Find it in your Supabase project settings, under Database.
</p>
</div>
{/if}
{/if}
{#if strandedPassword}
<Alert type="warning" size="xs" bgClass="border-0" title="Save this password">
<span class="font-mono select-all">{strandedPassword}</span>
<br />
The project exists but Windmill could not finish. Supabase never shows this password again.
</Alert>
{/if}
{/if}
@@ -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}`
: '')
}
]
}
@@ -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
}
}
}
@@ -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
}
}
@@ -0,0 +1,132 @@
<script lang="ts">
import SetupChecklist, { type SetupStep } from '$lib/components/wizards/SetupChecklist.svelte'
import { instanceSetupSteps } from '$lib/components/workspaceSettings/instanceDbSteps'
import { supabaseSetupSteps } from '$lib/components/workspaceSettings/supabaseProvisioning'
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import type { CustomInstanceDb, LoggedWizardStatus } from '$lib/gen'
// Playground for the wizard setup checklist: the REAL SetupChecklist driven by fake
// progress, so the run-through animation and every failure position can be seen without
// a backend, a superadmin, or a Supabase account.
let stepMs = $state(700)
let failAt = $state(0) // 0 = never fail, otherwise the 1-based step that fails
// --- Supabase: stage 0 idle, 1 created, 2 starting, 3 checking, 4 ready ---
let supaStage = $state(0)
let supaRunning = $state(false)
async function runSupabase() {
if (supaRunning) return
supaRunning = true
supaStage = 0
for (let s = 1; s <= 4; s++) {
supaStage = s
await sleep(stepMs)
if (failAt === s) break
}
supaRunning = false
}
let supaFailed = $derived(failAt > 0 && failAt === supaStage && !supaRunning && supaStage < 4)
// --- Instance: the backend reports every check at once, so the fake mirrors that ---
const INSTANCE_LOG_KEYS = [
'super_admin',
'database_credentials',
'valid_dbname',
'created_database',
'db_connect',
'grant_permissions',
'replication_user'
] as const
let instanceRunning = $state(false)
let instanceStatus: CustomInstanceDb | undefined = $state(undefined)
async function runInstance() {
if (instanceRunning) return
instanceRunning = true
instanceStatus = undefined
// One call, one answer: the spinner sits on the first unreported step for the whole
// duration, exactly as it does against the real endpoint.
await sleep(stepMs * 3)
instanceStatus = buildStatus()
instanceRunning = false
}
function buildStatus(): CustomInstanceDb {
const logs: Record<string, LoggedWizardStatus> = {}
for (let i = 0; i < INSTANCE_LOG_KEYS.length; i++) {
if (failAt > 0 && i + 1 >= failAt) break
logs[INSTANCE_LOG_KEYS[i]] = 'OK'
}
return {
success: failAt === 0,
error: failAt === 0 ? undefined : `Simulated failure at step ${failAt}`,
logs
} as unknown as CustomInstanceDb
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms))
}
// --- A hand-rolled list, to see every status side by side ---
const allStates: SetupStep[] = [
{ title: 'Pending', status: 'pending', description: 'Not reached yet.' },
{ title: 'Running', status: 'running' },
{ title: 'Done', status: 'done' },
{
title: 'Failed',
status: 'failed',
description: 'Failures expand on their own so the reason is never hidden behind a click.'
},
{ title: 'Skipped', status: 'skipped', description: 'Nothing to do for this one.' }
]
</script>
<div class="p-6 flex flex-col gap-6 max-w-3xl mx-auto">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Setup checklist</h1>
<DarkModeToggle />
</div>
<div class="flex flex-wrap items-end gap-4 p-4 rounded-md bg-surface-secondary">
<label class="flex flex-col gap-1 text-xs">
<span class="font-semibold text-emphasis">Step duration (ms)</span>
<input type="number" bind:value={stepMs} min="100" step="100" class="w-32" />
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="font-semibold text-emphasis">Fail at step (0 = never)</span>
<input type="number" bind:value={failAt} min="0" max="7" class="w-32" />
</label>
<Toggle bind:checked={supaRunning} disabled options={{ right: 'Supabase running' }} />
<Toggle bind:checked={instanceRunning} disabled options={{ right: 'Instance running' }} />
</div>
<section class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-emphasis">Supabase provisioning</h2>
<Button size="xs" variant="accent" onClick={runSupabase} disabled={supaRunning}>Run</Button>
</div>
<SetupChecklist steps={supabaseSetupSteps(supaStage, supaFailed)} />
</section>
<section class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-emphasis">Instance database setup</h2>
<Button size="xs" variant="accent" onClick={runInstance} disabled={instanceRunning}>
Run
</Button>
</div>
<SetupChecklist steps={instanceSetupSteps('dt_playground', instanceStatus, instanceRunning)} />
</section>
<section class="flex flex-col gap-2">
<h2 class="text-sm font-semibold text-emphasis">Every status</h2>
<SetupChecklist steps={allStates} />
</section>
</div>