mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 16:02:24 +00:00
0b33bb67c6
The wider side padding and lighter dialog heading were changing all 17 Modal2 dialogs to suit this one flow. They move behind an opt-in `formStyling`, taken by the three dialogs this branch owns; every other Modal2 renders as it did. Also drops two comments that cited a design approval rather than a constraint, and shortens the blocks that had grown past the four lines AGENTS.md asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1414 lines
51 KiB
Svelte
1414 lines
51 KiB
Svelte
<script lang="ts">
|
|
import { parkWizard, type WizardResume } from './wizardParking'
|
|
import type { Snippet } from 'svelte'
|
|
import { Database, ArrowRight, Plus } 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'
|
|
import Modal2 from '../common/modal/Modal2.svelte'
|
|
import Stepper from '../common/stepper/Stepper.svelte'
|
|
import Alert from '../common/alert/Alert.svelte'
|
|
import TextInput from '../text_input/TextInput.svelte'
|
|
import InputError from '../InputError.svelte'
|
|
import Password from '../Password.svelte'
|
|
import Select from '../select/Select.svelte'
|
|
import Toggle from '../Toggle.svelte'
|
|
import Path from '../Path.svelte'
|
|
import Label from '../Label.svelte'
|
|
import Section from '../Section.svelte'
|
|
import SupabaseIcon from '../icons/SupabaseIcon.svelte'
|
|
import {
|
|
FolderService,
|
|
OauthService,
|
|
ResourceService,
|
|
VariableService,
|
|
WorkspaceService
|
|
} from '$lib/gen'
|
|
import type { ListCustomInstanceDbsResponse } from '$lib/gen'
|
|
import { resource, type ResourceReturn } from 'runed'
|
|
import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
|
import SetupChecklist, { type SetupStep } from '../wizards/SetupChecklist.svelte'
|
|
import SupabaseProjectStep from './SupabaseProjectStep.svelte'
|
|
import DataTableConnectionReport from './DataTableConnectionReport.svelte'
|
|
import { useSupabaseOauth } from './supabaseOauth.svelte'
|
|
import { probeDatatableConnection } from './datatableProbe'
|
|
import {
|
|
anythingClaimed,
|
|
claimOf,
|
|
claimsFromJSON,
|
|
claimsToJSON,
|
|
noClaims,
|
|
release,
|
|
type Claims
|
|
} from './setupClaims'
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import { isCustomInstanceDbEnabled } from './utils.svelte'
|
|
import {
|
|
composePostgresConnectionString,
|
|
parsePostgresConnectionString,
|
|
unsupportedConnectionParam
|
|
} from '$lib/utils/postgresConnectionString'
|
|
import {
|
|
clearProbe,
|
|
datatableNameError,
|
|
hasAdvanced,
|
|
instanceDbNameError,
|
|
intentComplete,
|
|
newResourceParts,
|
|
newWizardState,
|
|
supabaseSummary,
|
|
planSteps,
|
|
probeValue,
|
|
resourcePathOf,
|
|
runSetup,
|
|
type CreatedProject,
|
|
type Provider,
|
|
type RunResult,
|
|
type WizardState
|
|
} from './addDataTableModel'
|
|
import {
|
|
resolveSupabaseConnection,
|
|
supabaseResourceValue,
|
|
DEFAULT_SUPABASE_REGION
|
|
} from './supabaseProvisioning'
|
|
|
|
type Props = {
|
|
opened: boolean
|
|
existingNames: string[]
|
|
/** Every data table already configured, so the review step can warn about sharing one. */
|
|
existingDataTables: { name: string; resourcePath: string | undefined }[]
|
|
/** Set when Supabase redirected the user back here mid-flow. */
|
|
resume?: WizardResume | undefined
|
|
onDone: () => 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,
|
|
existingDataTables,
|
|
resume,
|
|
onDone,
|
|
customInstanceDbs,
|
|
confirmationModal,
|
|
defaultInstanceDbName
|
|
}: Props = $props()
|
|
|
|
const STEPS = ['Choose a database', 'Set it up', 'Review']
|
|
|
|
let wiz: WizardState = $state(
|
|
newWizardState({ name: 'main', projectName: 'windmill-data', folder: '' })
|
|
)
|
|
let preventClose = $state(false)
|
|
/** A dismissal dialog is up, so a second one must not stack on it. */
|
|
let dismissing = false
|
|
|
|
/** Set once Finish has been pressed; the modal shows the checklist from then on. */
|
|
let run: { steps: SetupStep[]; running: boolean; result?: Awaited<ReturnType<typeof runSetup>> } =
|
|
$state({ steps: [], running: false })
|
|
/** Why the session pooler could not be used, once something has tried to read it. */
|
|
let poolerUnavailable: string | undefined = $state(undefined)
|
|
let initialResourcePath = $state('')
|
|
let resourcePathError = $state('')
|
|
/**
|
|
* Whichever namespace refused this path. The run writes a resource *and* a secret variable
|
|
* at the one path, and both writes upsert, so anything already sitting at either would be
|
|
* overwritten. `Path` reports the resource namespace itself; the effect below covers the
|
|
* variable one, and the check in `finish()` covers both.
|
|
*/
|
|
let pathTakenError = $state('')
|
|
let variableCheck: ReturnType<typeof setTimeout> | undefined = undefined
|
|
$effect(() => {
|
|
const path = resourcePath
|
|
if (wiz.step !== 3 || !mintsResource || !path) {
|
|
pathTakenError = ''
|
|
return
|
|
}
|
|
// A path this wizard already wrote is not somebody else's to protect -- the same
|
|
// exemption the hard check in `finish()` makes, or a retry would refuse its own secret
|
|
// and leave Finish permanently disabled.
|
|
if (path === claimedPath) {
|
|
pathTakenError = ''
|
|
return
|
|
}
|
|
clearTimeout(variableCheck)
|
|
variableCheck = setTimeout(async () => {
|
|
const taken = await VariableService.existsVariable({ workspace: $workspaceStore!, path })
|
|
// Two checks can be in flight at once and resolve out of order. Answering for a path
|
|
// that is no longer the one on screen is not merely stale: a `false` for an older
|
|
// path would clear the error guarding the path actually about to be written.
|
|
if (path !== resourcePath) return
|
|
pathTakenError = taken ? 'a variable already exists at this path' : ''
|
|
}, 500)
|
|
return () => clearTimeout(variableCheck)
|
|
})
|
|
|
|
/**
|
|
* Both namespaces at once, for the check that has to be right rather than quick. While the
|
|
* user is still typing the two halves are reported separately -- `Path` does the resource
|
|
* one, the debounced effect above does the variable one -- and either debounce can still be
|
|
* in flight when Finish is pressed.
|
|
*/
|
|
async function pathConflictMessage(path: string): Promise<string | undefined> {
|
|
const workspace = $workspaceStore!
|
|
// Each namespace answers to its own claim. Holding the secret says nothing about who owns
|
|
// the resource beside it, so one claim must not wave the other's check through.
|
|
const [variable, resource] = await Promise.all([
|
|
claimedPath === path ? false : VariableService.existsVariable({ workspace, path }),
|
|
claimedResourcePath === path ? false : ResourceService.existsResource({ workspace, path })
|
|
])
|
|
if (variable) return 'a variable already exists at this path'
|
|
if (resource) return 'a resource already exists at this path'
|
|
return undefined
|
|
}
|
|
/** Furthest step reached, so going back to check something does not cost the progress. */
|
|
let maxStep = $state(1)
|
|
|
|
function defaultProjectName(): string {
|
|
return `windmill-${$workspaceStore ?? 'workspace'}`
|
|
}
|
|
|
|
function defaultTableName(): string {
|
|
return existingNames.includes('main') ? `${$workspaceStore ?? 'data'}_datatable` : 'main'
|
|
}
|
|
|
|
// Takes the list rather than reading it, so the fetch that loads it can seed off its own
|
|
// result before the resource has settled.
|
|
function defaultFolder(list: string[] = folders): string {
|
|
// The first folder this admin can write to, so the resource lands somewhere the team
|
|
// can find and repair. A workspace with no folders falls back to the personal space.
|
|
return list.length ? `f/${list[0]}` : `u/${$userStore?.username ?? 'admin'}`
|
|
}
|
|
|
|
let nameError = $derived(datatableNameError(wiz.review.name, existingNames))
|
|
// Every database on the instance, not just the data table ones: the name has to be free
|
|
// in PostgreSQL, and a collision with a database created for something else still fails.
|
|
let instanceNameError = $derived(
|
|
wiz.provider === 'instance' && wiz.instance.mode === 'create'
|
|
? instanceDbNameError(
|
|
wiz.instance.dbName ?? '',
|
|
// `setup_custom_instance_db` registers the name whether it succeeds or fails, so
|
|
// after a failed attempt the instance holds a database this very wizard asked
|
|
// for. Counting it as taken would refuse the retry, on the step the user cannot
|
|
// see it from.
|
|
Object.keys(customInstanceDbs.current ?? {}).filter((n) => n !== claimedInstanceDb)
|
|
)
|
|
: undefined
|
|
)
|
|
let connectionStringError = $derived.by(() => {
|
|
const text = wiz.own.connectionString
|
|
if (!text) return undefined
|
|
if (!parsePostgresConnectionString(text)) return 'That is not a Postgres connection string.'
|
|
const unsupported = unsupportedConnectionParam(text)
|
|
return unsupported
|
|
? `Windmill cannot store ${unsupported} on a Postgres resource, and ignoring it would connect differently from what this string asks for. Remove it, or set the connection with the fields.`
|
|
: undefined
|
|
})
|
|
let resourcePath = $derived(resourcePathOf(wiz))
|
|
|
|
/**
|
|
* Another data table on the same database. They would share one schema, so their tables are
|
|
* visible to each other and two of the same name collide. Migration bookkeeping is keyed by
|
|
* data table, so that part stays separate.
|
|
*/
|
|
let sharesDatabaseWith = $derived(
|
|
wiz.provider === 'resource' && !wiz.own.creating && wiz.own.resourcePath
|
|
? existingDataTables.find((d) => d.resourcePath === wiz.own.resourcePath)
|
|
: undefined
|
|
)
|
|
|
|
const pgResources = resource(
|
|
() => (opened && wiz.provider === 'resource' ? ($workspaceStore ?? '') : ''),
|
|
async (workspace) => {
|
|
if (!workspace) return undefined
|
|
const list = await ResourceService.listResource({ workspace, resourceType: 'postgresql' })
|
|
// The step opens on a choice rather than on nothing: the first resource where the
|
|
// workspace has any, the creation form where it has none. Only ever fills an empty
|
|
// selection, so it cannot overwrite what the user picked.
|
|
if (!wiz.own.creating && !wiz.own.resourcePath) {
|
|
if (list.length) wiz.own.resourcePath = list[0].path
|
|
else wiz.own.creating = true
|
|
}
|
|
return list
|
|
}
|
|
)
|
|
|
|
/** Exclusive: one list, and a card is either the selection or it is not. */
|
|
function selectResource(path: string) {
|
|
if (wiz.own.resourcePath !== path || wiz.own.creating) invalidate()
|
|
wiz.own.resourcePath = path
|
|
wiz.own.creating = false
|
|
}
|
|
|
|
function selectNewResource() {
|
|
if (!wiz.own.creating) invalidate()
|
|
wiz.own.creating = true
|
|
wiz.own.resourcePath = undefined
|
|
}
|
|
|
|
/**
|
|
* A connection string is a way of writing the fields down, so typing one fills them in as it
|
|
* is read. Unparseable text leaves the last good values alone to be corrected; `sslmode` is
|
|
* kept when the URI names none, since not mentioning it is not the same as clearing it.
|
|
*/
|
|
function absorbConnectionString(text: string) {
|
|
const parts = parsePostgresConnectionString(text)
|
|
if (parts) wiz.own.fields = { ...parts, sslmode: parts.sslmode ?? wiz.own.fields.sslmode }
|
|
}
|
|
|
|
function useFields() {
|
|
wiz.own.form = 'fields'
|
|
}
|
|
|
|
/** Composed for the box to start from; the fields stay the connection. */
|
|
function useConnectionString() {
|
|
const parts = newResourceParts(wiz)
|
|
if (parts) wiz.own.connectionString = composePostgresConnectionString(parts)
|
|
wiz.own.form = 'string'
|
|
}
|
|
|
|
const SSL_MODES = ['disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full']
|
|
|
|
// `accept_invalid_certs` is a tri-state, and its unset state is not a default anyone would
|
|
// guess -- name all three rather than offer a checkbox that cannot express one of them.
|
|
const CERT_VERIFICATION = [
|
|
{ value: 'default', label: 'Verify only when a root certificate is set' },
|
|
{ value: 'enforce', label: 'Always verify' },
|
|
{ value: 'accept', label: 'Accept any certificate' }
|
|
]
|
|
|
|
let certVerification = $derived(
|
|
wiz.own.advanced.accept_invalid_certs === undefined
|
|
? 'default'
|
|
: wiz.own.advanced.accept_invalid_certs
|
|
? 'accept'
|
|
: 'enforce'
|
|
)
|
|
|
|
function setField<K extends keyof typeof wiz.own.fields>(
|
|
key: K,
|
|
value: (typeof wiz.own.fields)[K]
|
|
) {
|
|
wiz.own.fields = { ...wiz.own.fields, [key]: value }
|
|
invalidate()
|
|
}
|
|
|
|
function setAdvanced<K extends keyof typeof wiz.own.advanced>(
|
|
key: K,
|
|
value: (typeof wiz.own.advanced)[K]
|
|
) {
|
|
wiz.own.advanced = { ...wiz.own.advanced, [key]: value }
|
|
invalidate()
|
|
}
|
|
|
|
// The Supabase branch goes through the instance's `supabase_wizard` OAuth client; where a
|
|
// superadmin has not configured one, the connect endpoint dead-ends, so do not offer it.
|
|
const oauthConnects = resource(
|
|
() => opened,
|
|
async (isOpen) => (isOpen ? await OauthService.listOauthConnects() : [])
|
|
)
|
|
let supabaseAvailable = $derived(
|
|
(oauthConnects.current ?? []).some((c) => c.name === 'supabase_wizard')
|
|
)
|
|
|
|
const folderNames = resource(
|
|
() => (opened ? ($workspaceStore ?? '') : ''),
|
|
async (workspace) => {
|
|
if (!workspace) return []
|
|
const all = await FolderService.listFolderNames({ workspace })
|
|
const usable = all.filter((x) => !['app_groups', 'app_custom', 'app_themes'].includes(x))
|
|
// The list lands after `open()` has already seeded the folder, so the first open would
|
|
// otherwise always fall back to the personal space. Only re-seed what the user has not
|
|
// reached yet: the review step is where the folder becomes theirs. A resumed run has
|
|
// already chosen one -- reseeding would move its secret out from under the path claim
|
|
// it came back to finish.
|
|
if (wiz.step < 3 && !resume?.resourcePath) wiz.review.folder = defaultFolder(usable)
|
|
return usable
|
|
}
|
|
)
|
|
let folders = $derived(folderNames.current ?? [])
|
|
|
|
const SUPABASE_SIGNUP_URL = 'https://supabase.com/dashboard/sign-up'
|
|
|
|
const supaOauth = useSupabaseOauth({
|
|
// Safe to navigate this tab away: what the wizard had collected is parked first, and the
|
|
// settings page picks it back up when Supabase sends the user home.
|
|
redirectIfBlocked: true,
|
|
onPopupBlocked: () =>
|
|
parkWizard({
|
|
name: wiz.review.name,
|
|
region: wiz.supabase.region,
|
|
projectName: wiz.supabase.projectName,
|
|
resourcePath,
|
|
claims: claimsToJSON(claims),
|
|
createdProjects,
|
|
mode: wiz.supabase.mode,
|
|
org: wiz.supabase.org,
|
|
project: wiz.supabase.project,
|
|
connectionMode: wiz.supabase.connectionMode
|
|
})
|
|
})
|
|
|
|
function reset() {
|
|
wiz = newWizardState({
|
|
name: resume?.name || defaultTableName(),
|
|
projectName: resume?.projectName || defaultProjectName(),
|
|
folder: defaultFolder()
|
|
})
|
|
wiz.supabase.region = resume?.region ?? DEFAULT_SUPABASE_REGION
|
|
run = { steps: [], running: false }
|
|
maxStep = 1
|
|
// What the last run claimed belongs to the data table it created; a fresh one has to
|
|
// earn the name and the path again, or it would write over its predecessor's secret.
|
|
claims = noClaims
|
|
claimedInstanceDb = undefined
|
|
leftBehind = false
|
|
createdProjects = []
|
|
nameConflictFor = undefined
|
|
lastFailure = ''
|
|
pathTakenError = ''
|
|
poolerUnavailable = undefined
|
|
if (resume) {
|
|
wiz.provider = 'supabase'
|
|
// The clears above are for a fresh run. This one is the same run coming back from the
|
|
// redirect, so what it had already created is still its own to write over.
|
|
claims = claimsFromJSON(resume.claims)
|
|
createdProjects = resume.createdProjects ?? []
|
|
leftBehind = anythingClaimed(claims) || createdProjects.length > 0
|
|
// Which side of the toggle it was on, and the organization it was pointed at. Left to
|
|
// default, a run that died mid-create comes back asking for the password it generated.
|
|
if (resume.mode) wiz.supabase.mode = resume.mode
|
|
if (resume.org) wiz.supabase.org = resume.org
|
|
// The password is deliberately not parked -- it is a secret and sessionStorage is not
|
|
// the place for one. Carrying the project is what stops the resume landing on a
|
|
// different database with an empty password field and no sign anything moved.
|
|
if (resume.project) wiz.supabase.project = resume.project
|
|
if (resume.connectionMode) wiz.supabase.connectionMode = resume.connectionMode
|
|
const cut = resume.resourcePath?.lastIndexOf('/') ?? -1
|
|
if (resume.resourcePath && cut > 0) {
|
|
wiz.review.folder = resume.resourcePath.slice(0, cut)
|
|
wiz.review.resourceName = resume.resourcePath.slice(cut + 1)
|
|
}
|
|
enterStep(2)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opened by the settings page. Reopening after the Supabase redirect drops the user back on
|
|
* the setup step with what they had already chosen, so authorizing does not feel like
|
|
* starting over.
|
|
*/
|
|
export function open() {
|
|
reset()
|
|
opened = true
|
|
}
|
|
|
|
function selectProvider(key: Provider) {
|
|
if (key === wiz.provider) return
|
|
wiz.provider = key
|
|
invalidate()
|
|
if (key === 'instance') wiz.instance.dbName ??= defaultInstanceDbName()
|
|
}
|
|
|
|
function suggestedResourceName(): string {
|
|
const base =
|
|
wiz.provider === 'supabase'
|
|
? wiz.supabase.mode === 'create'
|
|
? wiz.supabase.projectName
|
|
: (wiz.supabase.project?.name ?? 'supabase')
|
|
: wiz.review.name
|
|
return `${wiz.provider === 'supabase' ? 'supabase_' : 'pg_'}${base
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/^_|_$/g, '')}`
|
|
}
|
|
|
|
/** Moving forward through the primary action, which is what validated the step. */
|
|
function enterStep(step: 1 | 2 | 3) {
|
|
wiz.step = step
|
|
if (step > maxStep) maxStep = step
|
|
}
|
|
|
|
/**
|
|
* The stepper reaches any step already passed, in either direction -- going back to check
|
|
* something should not cost the progress. It cannot reach past `maxStep`: the primary
|
|
* action is what validates a step, and on step 2 what probes the connection.
|
|
*/
|
|
function goToStep(index: number) {
|
|
const target = index + 1
|
|
if (run.steps.length || target > maxStep || target === wiz.step) return
|
|
wiz.step = target as 1 | 2 | 3
|
|
}
|
|
|
|
/** Intent changed, so the review built from it is stale and stops being reachable. */
|
|
function invalidate() {
|
|
// Also retires any check still in flight, so its answer cannot land on the edited value.
|
|
probeToken++
|
|
clearProbe(wiz)
|
|
// Read off one attempt against one project; the review step would otherwise warn about
|
|
// a limitation that no longer applies while claiming session pooling right above it.
|
|
poolerUnavailable = undefined
|
|
// Same for the failure carried back to the review step: it names inputs that have since
|
|
// been edited, so it would describe a run nobody can still act on.
|
|
lastFailure = ''
|
|
if (maxStep > wiz.step) maxStep = wiz.step
|
|
}
|
|
|
|
function enterReview() {
|
|
if (!wiz.review.resourceName) wiz.review.resourceName = suggestedResourceName()
|
|
// Path seeds itself from initialPath, so it has to be the suggestion as it stood when
|
|
// the step opened, not a live view of it -- a moving initialPath fights the typing.
|
|
initialResourcePath = resourcePathOf(wiz)
|
|
enterStep(3)
|
|
}
|
|
|
|
/**
|
|
* Proves the connection before anything is written. Supabase reads the pooler first: the
|
|
* value under test has to be the value that will be saved.
|
|
*/
|
|
// Fields stay editable while a check is out. Without a token the older answer lands after
|
|
// an edit and unlocks Continue for a connection nobody proved.
|
|
let probeToken = 0
|
|
async function probe() {
|
|
const token = ++probeToken
|
|
const settle = (probe: WizardState['probe']) => {
|
|
if (token === probeToken) wiz.probe = probe
|
|
}
|
|
wiz.probe = { checking: true, report: undefined, error: undefined }
|
|
try {
|
|
let value = probeValue(wiz)
|
|
if (!value && wiz.provider === 'supabase' && wiz.supabase.project) {
|
|
const connection = await resolveSupabaseConnection(
|
|
supaOauth.token!,
|
|
wiz.supabase.project,
|
|
wiz.supabase.connectionMode
|
|
)
|
|
// Retired while the pooler lookup was out: the user has changed something since,
|
|
// and writing the resolved mode back would silently undo their choice.
|
|
if (token !== probeToken) return
|
|
wiz.supabase.connectionMode = connection.mode
|
|
poolerUnavailable = connection.unavailable
|
|
value = {
|
|
...supabaseResourceValue(wiz.supabase.project, '', connection),
|
|
password: wiz.supabase.password
|
|
}
|
|
}
|
|
// An existing resource goes as a reference the worker resolves, exactly as a Postgres
|
|
// step would take it; anything unsaved goes as the value itself.
|
|
const database = value ?? (wiz.own.resourcePath ? `$res:${wiz.own.resourcePath}` : undefined)
|
|
if (!database) {
|
|
settle({ checking: false, report: undefined, error: undefined })
|
|
return
|
|
}
|
|
const report = await probeDatatableConnection($workspaceStore!, database)
|
|
settle({ checking: false, report, error: undefined })
|
|
} catch (err: any) {
|
|
settle({
|
|
checking: false,
|
|
report: undefined,
|
|
error: err?.body ?? err?.message ?? String(err)
|
|
})
|
|
}
|
|
}
|
|
|
|
/** True when step 2 has nothing to prove before Finish, so Continue is the only action. */
|
|
let probeable = $derived(
|
|
wiz.provider === 'resource' || (wiz.provider === 'supabase' && wiz.supabase.mode === 'existing')
|
|
)
|
|
let probePassed = $derived(!!wiz.probe.report?.can_create_table && !wiz.probe.error)
|
|
|
|
/**
|
|
* A run in this session left something behind -- a row, a secret, a database, a project.
|
|
* It outlives the checklist, which `backToReview` clears, because what was created does too.
|
|
*/
|
|
let leftBehind = $state(false)
|
|
/**
|
|
* Every Supabase project this session created, each guarding the path holding its only
|
|
* password. Held here rather than read off `run.result`: the checklist is cleared by every
|
|
* return to review, and the projects are not. A set, because two attempts at two paths each
|
|
* leave a password nothing later may write over.
|
|
*/
|
|
let createdProjects = $state<CreatedProject[]>([])
|
|
/** The instance database this session asked for, which is registered even when it failed. */
|
|
let claimedInstanceDb = $state<string | undefined>(undefined)
|
|
/**
|
|
* What this run created and may write over again. `writeRow` merges into whatever the
|
|
* server holds under the name, so a name free in the table on screen but taken on the
|
|
* server would repoint someone else's data table at this database.
|
|
*/
|
|
let claims = $state<Claims>(noClaims)
|
|
/** The path whose secret and resource this run holds, for the gates that ask by path. */
|
|
let claimedPath = $derived(claimOf(claims, 'secret', resourcePath)?.path)
|
|
let claimedResourcePath = $derived(claimOf(claims, 'resource', resourcePath)?.path)
|
|
let claimedName = $derived(claims.find((c) => c.kind === 'row')?.path)
|
|
/**
|
|
* The name the pre-flight refused, kept with the message so editing the name retires it.
|
|
* Storing only the message would need something to clear it, and the check that raises it
|
|
* runs where a clear cannot see the edit that follows.
|
|
*/
|
|
let nameConflictFor = $state<{ name: string; message: string } | undefined>(undefined)
|
|
let nameConflict = $derived(
|
|
nameConflictFor?.name === wiz.review.name.trim() ? nameConflictFor.message : ''
|
|
)
|
|
/** Why the last run failed, kept on the review step after the checklist is dropped. */
|
|
let lastFailure = $state('')
|
|
|
|
/**
|
|
* A refused pre-flight means nothing ran, so the checklist from a previous attempt has to
|
|
* give way -- it is the only thing rendered while it exists, and the refusal is shown on
|
|
* the review step.
|
|
*/
|
|
function backToReview() {
|
|
// The checklist is the only place a failure is reported, and this drops it -- so what it
|
|
// said has to come with us. It is the whole reason the user is on their way back: which
|
|
// project to rename to, which path not to write over.
|
|
lastFailure = run.result?.ok ? '' : (run.result?.error ?? '')
|
|
run = { steps: [], running: false }
|
|
enterStep(3)
|
|
}
|
|
|
|
/**
|
|
* Latched before the pre-flight's first await. `run.running` -- what otherwise disables the
|
|
* button -- is only set a round trip later, and on the Supabase create branch two clicks in
|
|
* that window bill two projects and leave the first one's password overwritten, which
|
|
* Supabase can never give back.
|
|
*/
|
|
let submitting = $state(false)
|
|
|
|
/**
|
|
* A failed run can always be sent back to be corrected. What a run already created is
|
|
* protected by `runSetup` refusing to mint a second Supabase password over the first one's
|
|
* variable, not by keeping the fields out of reach -- a lock here would only take away the
|
|
* rename that undoes the mistake.
|
|
*/
|
|
let canEditAfterFailure = $derived(
|
|
!!run.steps.length && !run.running && !submitting && !run.result?.ok
|
|
)
|
|
|
|
async function finish() {
|
|
if (submitting) return
|
|
submitting = true
|
|
// Held from here, not from where the run starts: the pre-flight awaits twice first, and
|
|
// a dismissal landing in that window would otherwise let the modal close under a setup
|
|
// that is about to create things.
|
|
preventClose = true
|
|
try {
|
|
await runFinish()
|
|
} finally {
|
|
submitting = false
|
|
preventClose = false
|
|
}
|
|
}
|
|
|
|
async function runFinish() {
|
|
const name = wiz.review.name.trim()
|
|
try {
|
|
if (claimedName !== name) {
|
|
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
|
if (settings.datatable?.datatables?.[name]) {
|
|
nameConflictFor = {
|
|
name,
|
|
message: `A data table called ${name} already exists in this workspace.`
|
|
}
|
|
backToReview()
|
|
return
|
|
}
|
|
nameConflictFor = undefined
|
|
}
|
|
// The debounced path check is advisory -- it can still be in flight when Finish is
|
|
// pressed -- and the writes that follow replace a secret and a resource in place. Ask
|
|
// once more here, where refusing costs nothing; what this run already holds is exempt
|
|
// per namespace inside, so Try again can repair its own half-finished attempt.
|
|
if (mintsResource) {
|
|
const conflict = await pathConflictMessage(resourcePath)
|
|
if (conflict) {
|
|
pathTakenError = conflict
|
|
backToReview()
|
|
return
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
// Everything inside the run reports through the checklist; this runs before there is
|
|
// one, so it has to speak for itself rather than fail silently.
|
|
nameConflictFor = {
|
|
name,
|
|
message: `Could not check the name: ${err?.body ?? err?.message ?? String(err)}`
|
|
}
|
|
backToReview()
|
|
return
|
|
}
|
|
run = { steps: planSteps(wiz), running: true }
|
|
lastFailure = ''
|
|
// The database is registered by the call whatever it answers, so asking for one is
|
|
// already leaving something behind.
|
|
if (wiz.provider === 'instance' && wiz.instance.mode === 'create') {
|
|
claimedInstanceDb = wiz.instance.dbName?.trim()
|
|
leftBehind = true
|
|
}
|
|
let result: RunResult | undefined = undefined
|
|
try {
|
|
result = await runSetup(wiz, {
|
|
workspace: $workspaceStore!,
|
|
supabaseToken: supaOauth.token,
|
|
onInstanceDbsChanged: async () => {
|
|
await customInstanceDbs.refetch()
|
|
},
|
|
onProgress: (steps) => (run.steps = steps),
|
|
onPoolerUnavailable: (reason) => (poolerUnavailable = reason),
|
|
createdProjects,
|
|
claims,
|
|
username: $userStore?.username ?? ''
|
|
})
|
|
} finally {
|
|
// `runSetup` catches per step, but anything escaping it would otherwise leave the
|
|
// button spinning with a page reload the only way out.
|
|
// Kept, not replaced: what an earlier attempt wrote is still out there, so a later
|
|
// one failing sooner must not hand its own objects back to the collision checks.
|
|
createdProjects = result?.createdProjects ?? createdProjects
|
|
claims = result?.claims ?? claims
|
|
// A row taken back out frees its name again, and free is somebody else's to take.
|
|
if (result?.rowRolledBack) claims = release(claims, 'row', name)
|
|
leftBehind = anythingClaimed(claims) || createdProjects.length > 0 || !!claimedInstanceDb
|
|
run = {
|
|
...run,
|
|
running: false,
|
|
result: result ?? {
|
|
ok: false,
|
|
error: 'The setup stopped unexpectedly.',
|
|
claims,
|
|
createdProjects
|
|
}
|
|
}
|
|
onDone()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether closing would throw away work. A failed run counts: its inputs are still editable
|
|
* and it may have left something behind. A run in flight cannot be closed at all, and one
|
|
* that succeeded has nothing left to lose.
|
|
*/
|
|
function hasUnfinishedIntent(): boolean {
|
|
return wiz.provider !== undefined && !run.running && !run.result?.ok
|
|
}
|
|
|
|
/** Backdrop, Escape and the close button all arrive here. */
|
|
async function requestClose() {
|
|
// `dismissing` is its own flag rather than a second use of `preventClose`: a run started
|
|
// while the dialog was up would have its hold cleared here, and the modal would become
|
|
// closable in the middle of creating things.
|
|
if (preventClose || dismissing) return
|
|
if (!hasUnfinishedIntent()) {
|
|
close()
|
|
return
|
|
}
|
|
dismissing = true
|
|
const confirmed = await confirmationModal.ask({
|
|
title: 'Leave without adding a data table?',
|
|
// A run that failed and was sent back to be edited leaves whatever it got through
|
|
// behind it, so promising otherwise would be a lie exactly when it matters most.
|
|
children: leftBehind
|
|
? 'The setup that already ran left what it created behind, and what you have filled in here will be lost.'
|
|
: 'Nothing has been created yet, and what you have filled in here will be lost.',
|
|
confirmationText: 'Discard'
|
|
})
|
|
dismissing = false
|
|
// Re-read rather than trust the entry check: a run can start while the dialog is up, and
|
|
// answering Discard would otherwise tear the modal down in the middle of it.
|
|
if (confirmed && !preventClose) close()
|
|
}
|
|
|
|
function close() {
|
|
opened = false
|
|
}
|
|
|
|
// The single primary action. Its label says what it is about to do, and doing it is what
|
|
// moves the wizard on.
|
|
let primary = $derived.by(() => {
|
|
if (submitting && !run.running)
|
|
return { label: 'Setting things up', disabled: true, busy: true }
|
|
if (run.steps.length) {
|
|
if (run.running) return { label: 'Setting things up', disabled: true, busy: true }
|
|
if (run.result?.ok) return { label: 'Done', disabled: false, act: close }
|
|
// A run that died because the Supabase token expired would retry into the same 401
|
|
// forever; authorizing again is the only thing that can move it on.
|
|
if (wiz.provider === 'supabase' && !supaOauth.authed)
|
|
return {
|
|
label: 'Connect to Supabase',
|
|
disabled: false,
|
|
busy: supaOauth.pending,
|
|
act: () => supaOauth.connect()
|
|
}
|
|
return { label: 'Try again', disabled: false, act: finish }
|
|
}
|
|
if (wiz.step === 1) {
|
|
if (wiz.provider === 'supabase' && !supaOauth.authed)
|
|
return {
|
|
label: 'Connect to Supabase',
|
|
disabled: false,
|
|
busy: supaOauth.pending,
|
|
act: () => {
|
|
supaOauth.connect()
|
|
enterStep(2)
|
|
}
|
|
}
|
|
return {
|
|
label: 'Continue',
|
|
disabled: !wiz.provider,
|
|
act: () => enterStep(2)
|
|
}
|
|
}
|
|
if (wiz.step === 2) {
|
|
// Reachable by closing the Supabase window without consenting -- which this flow
|
|
// invites, since creating an account happens over there. Without a way back in, the
|
|
// step offers only a Test connection that can never enable.
|
|
if (wiz.provider === 'supabase' && !supaOauth.authed)
|
|
return {
|
|
label: 'Connect to Supabase',
|
|
disabled: false,
|
|
busy: supaOauth.pending,
|
|
act: () => supaOauth.connect()
|
|
}
|
|
if (wiz.probe.checking) return { label: 'Checking', disabled: true, busy: true }
|
|
if (probeable && !probePassed)
|
|
return {
|
|
label: wiz.probe.error || wiz.probe.report ? 'Try again' : 'Test connection',
|
|
disabled: !intentComplete(wiz),
|
|
act: probe
|
|
}
|
|
return {
|
|
label: 'Continue',
|
|
disabled: !intentComplete(wiz) || !!instanceNameError,
|
|
act: enterReview
|
|
}
|
|
}
|
|
return {
|
|
label:
|
|
wiz.provider === 'supabase' && wiz.supabase.mode === 'create'
|
|
? 'Create project and data table'
|
|
: 'Create data table',
|
|
disabled:
|
|
// Guards the way back as well as the way forward: the stepper can return to step 2,
|
|
// and not every control there invalidates the review it just made stale.
|
|
!intentComplete(wiz) ||
|
|
!wiz.review.name.trim() ||
|
|
!!nameError ||
|
|
!wiz.review.resourceName.trim() ||
|
|
!!resourcePathError ||
|
|
!!pathTakenError ||
|
|
!!instanceNameError,
|
|
act: finish
|
|
}
|
|
})
|
|
|
|
/** The review step only mints a resource when the wizard is the one creating it. */
|
|
let mintsResource = $derived(
|
|
wiz.provider === 'supabase' || (wiz.provider === 'resource' && wiz.own.creating)
|
|
)
|
|
</script>
|
|
|
|
<Modal2
|
|
bind:isOpen={
|
|
() => opened,
|
|
(v) => {
|
|
if (!v) requestClose()
|
|
else opened = v
|
|
}
|
|
}
|
|
target="#content"
|
|
formStyling
|
|
title="Add a data table"
|
|
contentClasses="flex flex-col"
|
|
fixedWidth="md"
|
|
fixedHeight="lg"
|
|
>
|
|
<div class="flex h-full flex-col gap-4">
|
|
<Stepper
|
|
tabs={STEPS}
|
|
selectedIndex={wiz.step - 1}
|
|
maxReachedIndex={run.steps.length ? -1 : maxStep - 1}
|
|
small
|
|
on:click={(e) => goToStep(e.detail.index)}
|
|
/>
|
|
|
|
<div class="flex-1 flex flex-col min-h-0">
|
|
<div class="flex-1 overflow-y-auto flex flex-col gap-3">
|
|
{#if run.steps.length}
|
|
<SetupChecklist steps={run.steps} />
|
|
{#if run.running}
|
|
<p class="text-xs text-secondary">
|
|
Setting up. This can take a few minutes — leave this open until it finishes.
|
|
</p>
|
|
{/if}
|
|
{#if run.result}
|
|
{@render poolerWarning()}
|
|
<DataTableConnectionReport
|
|
name={wiz.review.name}
|
|
report={run.result.report}
|
|
error={run.result.error}
|
|
bgClass="border-0"
|
|
/>
|
|
{/if}
|
|
{:else if wiz.step === 1}
|
|
<Alert type="info" size="xs" bgClass="border-0" title="">
|
|
A data table runs on a database of your own. It stays yours — you can take it with
|
|
you at any time.
|
|
</Alert>
|
|
<div class="flex flex-col gap-2">
|
|
{#if $isCustomInstanceDbEnabled}
|
|
{#snippet instanceIcon()}
|
|
<Database size={18} class="text-secondary" />
|
|
{/snippet}
|
|
{@render providerCard(
|
|
'instance',
|
|
instanceIcon,
|
|
'Windmill database',
|
|
'Windmill creates and manages a database on this instance.'
|
|
)}
|
|
{/if}
|
|
{#if supabaseAvailable}
|
|
{#snippet supabaseIcon()}
|
|
<SupabaseIcon height="18px" width="18px" />
|
|
{/snippet}
|
|
{@render providerCard(
|
|
'supabase',
|
|
supabaseIcon,
|
|
'Supabase',
|
|
'Create a project, or connect one you already have. Signing in is required, and connecting an existing project needs its database password.'
|
|
)}
|
|
{/if}
|
|
{#snippet ownIcon()}
|
|
<Database size={18} class="text-secondary" />
|
|
{/snippet}
|
|
{@render providerCard(
|
|
'resource',
|
|
ownIcon,
|
|
'Your own database',
|
|
'Any Postgres — RDS, Neon, self-hosted. Pick a resource, or paste a connection string.'
|
|
)}
|
|
</div>
|
|
{:else if wiz.step === 2}
|
|
{#if wiz.provider === 'supabase'}
|
|
{#if !supaOauth.authed}
|
|
<Alert type="info" size="xs" bgClass="border-0" title="">
|
|
{#if supaOauth.pending}
|
|
Sign in and approve Windmill in the Supabase window, then come back here.
|
|
{:else}
|
|
Windmill needs your approval on Supabase to see your databases.
|
|
{/if}
|
|
</Alert>
|
|
{:else}
|
|
<SupabaseProjectStep
|
|
bind:intent={wiz.supabase}
|
|
token={supaOauth.token!}
|
|
onIntentChange={() => invalidate()}
|
|
/>
|
|
{/if}
|
|
{:else if wiz.provider === 'instance'}
|
|
{@render instanceStep()}
|
|
{:else}
|
|
{@render ownStep()}
|
|
{/if}
|
|
|
|
<DataTableConnectionReport
|
|
name={wiz.review.name}
|
|
report={wiz.probe.report}
|
|
error={wiz.probe.error}
|
|
bgClass="border-0"
|
|
/>
|
|
{:else}
|
|
<div class="flex flex-col gap-8">
|
|
{@render reviewStep()}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1 pt-3">
|
|
<div class="flex justify-between items-center gap-2">
|
|
<div>
|
|
{#if wiz.step > 1 && !run.steps.length}
|
|
<Button
|
|
size="xs"
|
|
variant="default"
|
|
onClick={() => (wiz.step = wiz.step === 3 ? 2 : 1)}
|
|
>
|
|
Back
|
|
</Button>
|
|
{:else if canEditAfterFailure}
|
|
<!-- Try again repeats the same inputs, so it cannot help a run that failed on
|
|
one of them -- a database name already taken on the instance, a project name
|
|
Supabase refuses. Dropping the checklist puts the earlier steps back within
|
|
reach, which is the only way to edit those. -->
|
|
<Button size="xs" variant="default" onClick={backToReview}>Back</Button>
|
|
{/if}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="accent"
|
|
disabled={primary.disabled}
|
|
loading={primary.busy}
|
|
endIcon={primary.busy ? undefined : { icon: ArrowRight }}
|
|
onClick={() => primary.act?.()}
|
|
>
|
|
{primary.label}
|
|
</Button>
|
|
</div>
|
|
{#if wiz.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}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
class="text-blue-500 hover:underline">create one for free</a
|
|
>.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal2>
|
|
|
|
{#snippet providerCard(key: Provider, icon: Snippet, title: string, subtitle: string)}
|
|
{@const selected = wiz.provider === key}
|
|
<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={() => selectProvider(key)}
|
|
>
|
|
<span class="mt-0.5 shrink-0">{@render icon()}</span>
|
|
<span class="flex flex-col gap-0.5 min-w-0">
|
|
<span class="text-xs font-medium {selected ? 'text-accent' : 'text-emphasis'}">{title}</span>
|
|
<span class="text-xs text-secondary font-normal">{subtitle}</span>
|
|
</span>
|
|
</button>
|
|
{/snippet}
|
|
|
|
{#snippet instanceStep()}
|
|
{@const instanceDbs = Object.entries(customInstanceDbs.current ?? {})
|
|
.filter(([_, db]) => db.tag === 'datatable')
|
|
.map(([name, db]) => ({ name, db }))}
|
|
{#if instanceDbs.length}
|
|
<ToggleButtonGroup
|
|
bind:selected={
|
|
() => wiz.instance.mode,
|
|
(v) => {
|
|
wiz.instance.mode = v
|
|
wiz.instance.dbName = v === 'create' ? defaultInstanceDbName() : undefined
|
|
}
|
|
}
|
|
>
|
|
{#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 wiz.instance.mode === 'existing'}
|
|
{@const shared = (
|
|
customInstanceDbs.current?.[wiz.instance.dbName ?? '']?.used_by_workspaces ?? []
|
|
).filter((w) => w !== $workspaceStore)}
|
|
<!-- 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 shared.length}
|
|
<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}
|
|
<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 = wiz.instance.dbName === name}
|
|
{@const others = (db.used_by_workspaces ?? []).filter((w) => w !== $workspaceStore)}
|
|
<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={() => (wiz.instance.dbName = 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'}"
|
|
>{name}</span
|
|
>
|
|
<span class="text-xs text-secondary font-normal">
|
|
{db.success ? 'Ready' : 'Needs setup'}{others.length
|
|
? ` · shared with ${others.length} other workspace${others.length > 1 ? 's' : ''}`
|
|
: ''}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div>
|
|
<span class="text-xs font-semibold text-emphasis">Database name</span>
|
|
<TextInput
|
|
bind:value={() => wiz.instance.dbName ?? '', (v) => (wiz.instance.dbName = v)}
|
|
error={!!instanceNameError}
|
|
inputProps={{ placeholder: defaultInstanceDbName() }}
|
|
/>
|
|
<InputError error={instanceNameError} />
|
|
{#if !instanceNameError}
|
|
<p class="text-2xs text-secondary mt-1">
|
|
Created in the Windmill PostgreSQL instance when you finish. Windmill manages its
|
|
credentials.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet ownStep()}
|
|
{@const resources = pgResources.loading ? undefined : pgResources.current}
|
|
{#if resources === undefined}
|
|
<p class="text-xs text-secondary">Loading resources...</p>
|
|
{:else}
|
|
{#if resources.length}
|
|
<span class="text-xs font-semibold text-emphasis">Postgres resources in this workspace</span>
|
|
{:else}
|
|
<p class="text-xs text-secondary">A resource is a saved connection your scripts can use.</p>
|
|
{/if}
|
|
<div class="flex flex-col gap-2 overflow-y-auto flex-1 min-h-24 pr-1">
|
|
{#each resources as r (r.path)}
|
|
{@const selected = !wiz.own.creating && wiz.own.resourcePath === r.path}
|
|
<button
|
|
class="shrink-0 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={() => selectResource(r.path)}
|
|
>
|
|
<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 font-mono truncate {selected
|
|
? 'text-accent'
|
|
: 'text-emphasis'}">{r.path}</span
|
|
>
|
|
{#if r.description}
|
|
<span class="text-xs text-secondary font-normal truncate">{r.description}</span>
|
|
{/if}
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
<!-- shrink-0 or the flex column squeezes the cards to fit instead of letting the list
|
|
scroll, and the selected one loses its expanded form to the clip. -->
|
|
<div
|
|
class="shrink-0 border rounded-md overflow-hidden transition-colors {wiz.own.creating
|
|
? 'border-border-selected/50 bg-surface-accent-selected'
|
|
: 'border-border-light'}"
|
|
>
|
|
<button
|
|
class="w-full text-left p-3 flex gap-3 items-start {wiz.own.creating
|
|
? ''
|
|
: 'hover:bg-surface-hover'}"
|
|
onclick={selectNewResource}
|
|
>
|
|
<span class="mt-0.5 shrink-0"><Plus size={18} class="text-secondary" /></span>
|
|
<span class="flex flex-col gap-0.5 min-w-0">
|
|
<span class="text-xs font-medium {wiz.own.creating ? 'text-accent' : 'text-emphasis'}"
|
|
>New resource</span
|
|
>
|
|
<span class="text-xs text-secondary font-normal"
|
|
>Windmill saves it as a Postgres resource</span
|
|
>
|
|
</span>
|
|
</button>
|
|
{#if wiz.own.creating}
|
|
<div class="px-3 pb-3">{@render newResourceForm()}</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet newResourceForm()}
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<span class="text-xs font-semibold text-emphasis">
|
|
{wiz.own.form === 'string' ? 'Connection string' : 'Connection'}
|
|
</span>
|
|
<Button
|
|
variant="subtle"
|
|
size="xs2"
|
|
onclick={() => (wiz.own.form === 'string' ? useFields() : useConnectionString())}
|
|
>
|
|
{wiz.own.form === 'string'
|
|
? 'Enter fields individually'
|
|
: 'Paste a connection string instead'}
|
|
</Button>
|
|
</div>
|
|
{#if wiz.own.form === 'string'}
|
|
<TextInput
|
|
bind:value={
|
|
() => wiz.own.connectionString,
|
|
(v) => {
|
|
wiz.own.connectionString = v
|
|
absorbConnectionString(v)
|
|
invalidate()
|
|
}
|
|
}
|
|
error={!!connectionStringError}
|
|
inputProps={{ placeholder: 'postgres://user:password@host:5432/database' }}
|
|
/>
|
|
<InputError error={connectionStringError} />
|
|
{:else}
|
|
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
|
<div>
|
|
<span class="text-2xs text-secondary">Host</span>
|
|
<TextInput
|
|
bind:value={() => wiz.own.fields.host, (v) => setField('host', v)}
|
|
inputProps={{ placeholder: 'db.example.com' }}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span class="text-2xs text-secondary">Port</span>
|
|
<TextInput
|
|
bind:value={
|
|
() => wiz.own.fields.port ?? '',
|
|
(v) => setField('port', v === '' ? undefined : Number(v))
|
|
}
|
|
inputProps={{ placeholder: '5432', type: 'number' }}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span class="text-2xs text-secondary">Database</span>
|
|
<TextInput
|
|
bind:value={() => wiz.own.fields.dbname ?? '', (v) => setField('dbname', v)}
|
|
inputProps={{ placeholder: 'postgres' }}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span class="text-2xs text-secondary">SSL mode</span>
|
|
<Select
|
|
items={SSL_MODES.map((value) => ({ value }))}
|
|
bind:value={() => wiz.own.fields.sslmode ?? '', (v) => setField('sslmode', v)}
|
|
clearable={false}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span class="text-2xs text-secondary">User</span>
|
|
<TextInput
|
|
bind:value={() => wiz.own.fields.user, (v) => setField('user', v)}
|
|
inputProps={{ placeholder: 'postgres' }}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span class="text-2xs text-secondary">Password</span>
|
|
<Password
|
|
bind:password={
|
|
() => wiz.own.fields.password ?? '', (v) => setField('password', v ?? '')
|
|
}
|
|
placeholder="••••••••"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{@render advancedGroup()}
|
|
{/if}
|
|
{#if wiz.own.form === 'string' && !connectionStringError && hasAdvanced(wiz.own.advanced)}
|
|
<p class="text-2xs text-secondary">
|
|
Certificate and IAM settings from the fields view still apply — a connection string
|
|
cannot show them.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet advancedGroup()}
|
|
<div class="border-t border-border-light pt-2">
|
|
<Section label="Advanced" small collapsable>
|
|
<div class="flex flex-col gap-2">
|
|
<div>
|
|
<span class="text-2xs text-secondary">Root certificate</span>
|
|
<TextInput
|
|
underlyingInputEl="textarea"
|
|
bind:value={
|
|
() => wiz.own.advanced.root_certificate_pem,
|
|
(v) => setAdvanced('root_certificate_pem', String(v ?? ''))
|
|
}
|
|
class="min-h-16 font-mono text-2xs resize-y"
|
|
inputProps={{ placeholder: '-----BEGIN CERTIFICATE-----', rows: 3 }}
|
|
/>
|
|
<p class="text-2xs text-secondary mt-1">
|
|
The CA to check the server against. Needed for <span class="font-mono">verify-ca</span>
|
|
and <span class="font-mono">verify-full</span> when the certificate is not signed by a public
|
|
authority.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-2xs text-secondary">Certificate verification</span>
|
|
<Select
|
|
items={CERT_VERIFICATION}
|
|
bind:value={
|
|
() => certVerification,
|
|
(v) =>
|
|
setAdvanced('accept_invalid_certs', v === 'default' ? undefined : v === 'accept')
|
|
}
|
|
clearable={false}
|
|
/>
|
|
</div>
|
|
<Toggle
|
|
size="xs"
|
|
checked={wiz.own.advanced.use_iam_auth}
|
|
on:change={(e) => setAdvanced('use_iam_auth', e.detail)}
|
|
options={{ right: 'Authenticate with AWS IAM' }}
|
|
/>
|
|
{#if wiz.own.advanced.use_iam_auth}
|
|
<div>
|
|
<span class="text-2xs text-secondary">Region</span>
|
|
<TextInput
|
|
bind:value={() => wiz.own.advanced.region, (v) => setAdvanced('region', v)}
|
|
inputProps={{ placeholder: 'us-east-1' }}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet poolerWarning()}
|
|
{#if poolerUnavailable}
|
|
<Alert type="warning" size="xs" title="Session pooling is not available">
|
|
<div class="flex flex-col gap-1">
|
|
<span>{poolerUnavailable}</span>
|
|
<span>
|
|
Windmill connects directly instead, which needs IPv6 from the workers, or the IPv4 add-on
|
|
on the project. Granting the Supabase OAuth app
|
|
<span class="font-mono">database_pooling_config_read</span> and connecting again restores the
|
|
pooler.
|
|
</span>
|
|
</div>
|
|
</Alert>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet reviewStep()}
|
|
{#if lastFailure}
|
|
<Alert type="error" size="xs" bgClass="border-0" title="">{lastFailure}</Alert>
|
|
{/if}
|
|
<Label label="Data table name" class="gap-1">
|
|
<p class="text-2xs text-secondary">
|
|
This is how your scripts reach the database behind it:
|
|
<span class="font-mono">datatable://{wiz.review.name.trim() || 'main'}</span>
|
|
reads and writes it, whatever it is connected to.
|
|
<span class="font-mono">main</span> is used by default when a script does not name one.
|
|
</p>
|
|
<TextInput
|
|
bind:value={wiz.review.name}
|
|
error={!!nameError || !!nameConflict}
|
|
inputProps={{ placeholder: 'main' }}
|
|
/>
|
|
<InputError error={nameError ?? (nameConflict || undefined)} />
|
|
</Label>
|
|
|
|
{#if wiz.provider === 'supabase'}
|
|
<!-- A card carrying Supabase's own mark rather than a disabled field: this is the one
|
|
thing on the step that will not live in Windmill, and it should not read as a greyed-out
|
|
Windmill input. -->
|
|
<Label
|
|
label={wiz.supabase.mode === 'create' ? 'New Supabase project' : 'Supabase project'}
|
|
class="gap-1"
|
|
>
|
|
<p class="text-2xs text-secondary">
|
|
{#if wiz.supabase.mode === 'create'}
|
|
It does not exist yet — Windmill creates it on Supabase when you finish. The project is
|
|
yours, and you can open and manage it from the Supabase dashboard.
|
|
{:else}
|
|
A project already in your Supabase account. Windmill saves its connection and changes
|
|
nothing about the project itself.
|
|
{/if}
|
|
</p>
|
|
<div class="border border-border-light rounded-md p-3 flex gap-3 items-start">
|
|
<span class="mt-0.5 shrink-0"><SupabaseIcon height="18px" width="18px" /></span>
|
|
<span class="flex flex-col gap-0.5 min-w-0">
|
|
<span class="text-xs font-medium text-emphasis">
|
|
{wiz.supabase.mode === 'create'
|
|
? wiz.supabase.projectName
|
|
: (wiz.supabase.project?.name ?? '')}
|
|
</span>
|
|
<span class="text-xs text-secondary">
|
|
{[
|
|
supabaseSummary(wiz).org,
|
|
supabaseSummary(wiz).region,
|
|
wiz.supabase.connectionMode === 'session' ? 'session pooler' : 'direct (IPv6)'
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</Label>
|
|
{@render poolerWarning()}
|
|
{:else if wiz.provider === 'instance'}
|
|
<Label label="Windmill database" class="gap-1">
|
|
<p class="text-2xs text-secondary">
|
|
{#if wiz.instance.mode === 'create'}
|
|
It does not exist yet — Windmill creates it on this instance's PostgreSQL server when you
|
|
finish, and manages its credentials.
|
|
{:else}
|
|
A database already on this instance's PostgreSQL server, managed by Windmill.
|
|
{/if}
|
|
</p>
|
|
<TextInput value={wiz.instance.dbName ?? ''} inputProps={{ disabled: true }} />
|
|
</Label>
|
|
{:else if !wiz.own.creating}
|
|
<Label label="Postgres resource" class="gap-1">
|
|
<p class="text-2xs text-secondary">
|
|
The connection already in this workspace. The data table points at it; nothing is written to
|
|
it here.
|
|
</p>
|
|
<TextInput value={wiz.own.resourcePath ?? ''} inputProps={{ disabled: true }} />
|
|
</Label>
|
|
{/if}
|
|
|
|
{#if mintsResource}
|
|
<Label label="Resource path" class="gap-1">
|
|
<p class="text-2xs text-secondary">
|
|
The connection {wiz.provider === 'supabase' ? 'to the Supabase project ' : ''}is saved here
|
|
as a Postgres resource, with its password in a secret variable beside it.
|
|
</p>
|
|
<Path
|
|
bind:path={
|
|
() => resourcePath,
|
|
(p) => {
|
|
const cut = p.lastIndexOf('/')
|
|
wiz.review.folder = cut > 0 ? p.slice(0, cut) : p
|
|
wiz.review.resourceName = cut > 0 ? p.slice(cut + 1) : ''
|
|
}
|
|
}
|
|
bind:error={resourcePathError}
|
|
initialPath={initialResourcePath}
|
|
checkInitialPathExistence
|
|
allowedExistingPath={claimedResourcePath}
|
|
namePlaceholder="database"
|
|
kind="resource"
|
|
autofocus={false}
|
|
/>
|
|
<InputError error={pathTakenError} />
|
|
</Label>
|
|
{/if}
|
|
|
|
{#if sharesDatabaseWith}
|
|
<Alert type="warning" size="xs" bgClass="border-0" title="">
|
|
<span class="font-semibold">{sharesDatabaseWith.name}</span> already uses this database. Both data
|
|
tables would write to the same schema, so each one's tables are visible to the other and two tables
|
|
of the same name collide. Migrations are tracked per data table, so those stay separate.
|
|
</Alert>
|
|
{/if}
|
|
|
|
{#if wiz.provider === 'supabase'}
|
|
<Alert type="info" size="xs" bgClass="border-0" title="">
|
|
Your Supabase sign-in is not stored. If the database password ever changes, anyone with access
|
|
to the project can sign in and reconnect it. Deleting the data table never deletes the
|
|
Supabase project.
|
|
</Alert>
|
|
{/if}
|
|
{/snippet}
|