mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-09 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
521b9b5720
commit
bc18ebec16
@@ -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)}
|
||||
· {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>
|
||||
Reference in New Issue
Block a user