diff --git a/frontend/src/lib/components/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index 67c19b0fe7..8de484929e 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -6,7 +6,7 @@ import DrawerContent from './common/drawer/DrawerContent.svelte' import AppConnectInner from './AppConnectInner.svelte' - import DarkModeObserver from './DarkModeObserver.svelte' + import GoogleSigninButton from './GoogleSigninButton.svelte' import IconedResourceType from './IconedResourceType.svelte' import { addResourceTitle } from './resourceTypeDisplay' @@ -22,6 +22,10 @@ disableChatOffset = false }: Props = $props() + /** Set by `open(rt, fillPath)`, not by the parent: which resource this run fills is a + * property of the click, and a prop would go stale between two different rows. */ + let fillPath: string | undefined = $state(undefined) + let drawer: Drawer | undefined = $state() let resourceType = $state('') let step = $state(1) @@ -32,29 +36,46 @@ let appConnectInner: AppConnectInner | undefined = $state(undefined) let rtToLoad: string | undefined = $state('') - export async function open(rt?: string) { + /** `fill` connects into a resource that already exists, instead of creating one. */ + export async function open(rt?: string, fill?: string) { + fillPath = fill + handedOff = false rtToLoad = rt drawer?.openDrawer?.() } + /** + * Hand off to the inner component exactly once per opening. The reactive statement below + * re-runs both when `rtToLoad` changes and when `appConnectInner` binds — and it binds + * afresh on every opening, since the drawer destroys its content on close. A second + * `open()` runs `next()` a second time, which walks a drawer opened on a resource type + * straight past the Connect button and into `window.open`; a popup opened from a reactive + * effect rather than from the click is blocked, leaving "Finish connection in popup + * window" with no popup behind it. + * + * A flag rather than the last resource type: `open()` with no argument leaves `rtToLoad` + * undefined, which compares equal to the initial state and would skip the hand-off + * entirely — the resources page opens it that way. + */ + let handedOff = false function onRtToLoadChange(rtToLoad: string | undefined) { + if (handedOff) return + handedOff = true appConnectInner?.open(rtToLoad) } const dispatch = createEventDispatcher() - let darkMode: boolean = $state(false) run(() => { appConnectInner && onRtToLoadChange(rtToLoad) }) - - { step = 1 + handedOff = false dispatch('close') }} size="700px" @@ -83,22 +104,26 @@ on:refresh express={expressOAuthSetup} {workspace} + {fillPath} /> {#snippet actions()}
- {#if step > 1} - + + {#if step > 1 && !rtToLoad} + {/if} {#if isGoogleSignin} - + appConnectInner?.next()} /> {:else} -
{:else} -
+ +
{#if step > 2} - + {/if} - + {#if isGoogleSignin} + appConnect?.next()} /> + {:else} + + {/if}
{/if} @@ -64,6 +72,7 @@ bind:resourceType bind:disabled bind:manual + bind:isGoogleSignin on:error on:refresh /> diff --git a/frontend/src/lib/components/CenteredModal.svelte b/frontend/src/lib/components/CenteredModal.svelte index e3f8ea1604..3250b3a16a 100644 --- a/frontend/src/lib/components/CenteredModal.svelte +++ b/frontend/src/lib/components/CenteredModal.svelte @@ -6,6 +6,9 @@ interface Props { subtitle?: string | undefined + /** Rendered under the title, for a subtitle that needs markup (a link, say). + * Sits below `subtitle` when both are given. */ + subtitleSnippet?: import('svelte').Snippet title?: string large?: boolean centerVertically?: boolean @@ -16,6 +19,7 @@ let { subtitle = undefined, + subtitleSnippet = undefined, title = 'Windmill', large = false, centerVertically = true, @@ -60,6 +64,9 @@ {subtitle}

{/if} + {#if subtitleSnippet} +
{@render subtitleSnippet()}
+ {/if}
{#if children} diff --git a/frontend/src/lib/components/FolderPicker.svelte b/frontend/src/lib/components/FolderPicker.svelte index 69c219d0a1..325d36346d 100644 --- a/frontend/src/lib/components/FolderPicker.svelte +++ b/frontend/src/lib/components/FolderPicker.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/lib/components/GoogleSigninButton.svelte b/frontend/src/lib/components/GoogleSigninButton.svelte new file mode 100644 index 0000000000..d7d2ab102c --- /dev/null +++ b/frontend/src/lib/components/GoogleSigninButton.svelte @@ -0,0 +1,21 @@ + + + + diff --git a/frontend/src/lib/components/ImportProjectCard.svelte b/frontend/src/lib/components/ImportProjectCard.svelte new file mode 100644 index 0000000000..26bb3f7b18 --- /dev/null +++ b/frontend/src/lib/components/ImportProjectCard.svelte @@ -0,0 +1,115 @@ + + + + +
+ +
+
+ +
+ {#if project.logoUrl} + + {:else if icons[0]} + {@const Icon = icons[0]} + + + + {:else} + + {/if} +
+ +
+ + + {project.name} + + +

{project.summary}

+

+ by {project.author} + · {project.slug} +

+ + +
+ +
+
+ + + {#if restIcons.length > 0} +
+ {#each restIcons as Icon, i (i)} + + + + {/each} +
+ {/if} +
+
+ + +
diff --git a/frontend/src/lib/components/ImportProjectStep.svelte b/frontend/src/lib/components/ImportProjectStep.svelte new file mode 100644 index 0000000000..04c169d41c --- /dev/null +++ b/frontend/src/lib/components/ImportProjectStep.svelte @@ -0,0 +1,524 @@ + + +
+ + {#if existingWorkspace} +
+ + + Folder inside {existingWorkspace} + + +

+ Everything the project ships is imported into this folder. +

+ + + {#if folder.trim() && !folderValid} +

Letters, digits, dashes and underscores only.

+ {/if} +
+ {/if} + + +

What this will do

+ + + + + {#if execution?.error} + + {execution.error} + + {/if} + + + + Resources are imported as empty stubs — set their values after import; one whose path is + already in the workspace is left exactly as it is and reported as already there, so a value + you have since filled in is never overwritten. Trigger kinds are + recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at creation + and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP and Azure + triggers all require Enterprise. Triggers that reference a resource depend on stubs imported + empty, so fill in the resource value before re-enabling the trigger. + + +
+ + {#if !execution?.done} + + {:else} + + {/if} + +
+ + {#if execution?.createdWorkspace && !execution.done} + + {/if} + + {#if execution?.done} + + {#if execution.error} + + {/if} + + + {:else} + + {/if} +
+
+
+ + + + + + + closeMigrationReview(false)}> + closeMigrationReview(false)}> +
+ + + {reviewList.length === 1 ? 'This data table is' : 'These data tables are'} already set up{existingWorkspace + ? ` in ${existingWorkspace}` + : ''} and may already hold data. These migrations were written to create the project's tables, + so running them here can alter or drop what is in them. Read the SQL before you run it, and skip + anything you are unsure of. + +

+ Review and edit the SQL, then choose which to run. A migration runs against the data table + of the same name in the destination workspace; if that data table has migrations enabled it + is recorded, otherwise it runs once as a preview job. +

+ {#each reviewList as m (m.datatable_name)} +
+
+ {m.datatable_name} + +
+ {#if m.run} + + {/if} +
+ {/each} +
+ {#snippet actions()} + + + {/snippet} +
+
diff --git a/frontend/src/lib/components/ImportSetupRow.svelte b/frontend/src/lib/components/ImportSetupRow.svelte new file mode 100644 index 0000000000..11f95b333c --- /dev/null +++ b/frontend/src/lib/components/ImportSetupRow.svelte @@ -0,0 +1,48 @@ + + + +
  • +
    +
    {@render icon()}
    + +
    + {@render title()} + {@render detail?.()} +
    + +
    + {@render action()} + {#if flash} +
    + +
    + {/if} +
    +
    + {@render extra?.()} +
  • diff --git a/frontend/src/lib/components/ImportSetupStep.svelte b/frontend/src/lib/components/ImportSetupStep.svelte new file mode 100644 index 0000000000..86670b0817 --- /dev/null +++ b/frontend/src/lib/components/ImportSetupStep.svelte @@ -0,0 +1,861 @@ + + +
    +
    +

    Finish setting up

    + +

    + Your project is imported. For its apps and flows to actually run, they need a place to store + data and credentials for the services they use — the import can't supply those for you. +

    +
    + + {#if loading} +
    + Checking what this project needs… +
    + {:else if loadError} + + {loadError}. You can finish and configure them later in Workspace settings → Data tables. + + {:else} + {#if rows.length > 0} + +
    + + Data table{rows.length === 1 ? '' : 's'} to set up ({rows.length}) + +

    + Where apps and flows keep the data they read and write. +

    +
    + {/if} +
      + {#each rows as row (row.name)} + {@const sql = row.migrations + .map((m) => m.sql) + .filter(Boolean) + .join('\n\n')} + {@const hasTable = configuredNames.some((c) => c.name === row.name)} + + {#snippet icon()} + {#if row.status === 'done'} + + {:else if row.status === 'running'} + + {:else if row.status === 'failed'} + + {:else if row.status === 'unknown'} + + {:else} + + {/if} + {/snippet} + {#snippet title()} + {row.name} + {/snippet} + {#snippet detail()} + + {#if row.status === 'done'} + {row.migrations.length} migration{row.migrations.length === 1 ? '' : 's'} run + {:else if row.status === 'running'} + running migrations… + {:else if row.status === 'failed'} + {row.error} + {:else if row.status === 'unknown'} + set up, but its tables could not be read — the database may be unreachable + {:else} + not configured yet + {/if} + + {/snippet} + {#snippet extra()} + + {#if sql && row.status !== 'done'} +
      + + {row.status === 'unknown' + ? 'Show the SQL this project ships' + : 'Show the SQL this will run'} + +
      {sql}
      +
      + {/if} + {/snippet} + {#snippet action()} + + {#if row.status === 'unknown'} + + + {:else if hasTable && row.status !== 'done' && row.status !== 'running'} + + + {:else} + + + {/if} + {/snippet} +
      + {/each} +
    + + {#if blanks.length > 0} +
    + Credentials to fill ({blanks.length}) +
      + {#each blanks as b (b.path)} + {@const blocked = !!b.occupiedBy || !!b.unreadable} + {@const canConnect = !b.done && !blocked && canConnectType(b.resourceType)} + + + {#snippet icon()} + {#if b.done} + + {:else if blocked} + + {:else} + + {/if} + {/snippet} + {#snippet title()} +
      + + {resourceTypeDisplayName(b.resourceType)} + + + {b.path} + +
      + {/snippet} + {#snippet detail()} + {#if b.occupiedBy} + + a {resourceTypeDisplayName(b.occupiedBy)} resource already holds this path — the + project did not get this one + + {:else if b.unreadable} + + could not be read, so whether it needs filling is unknown + + {:else if !b.done && b.missing.length > 0} + + Missing {b.missing.join(', ')} + + {/if} + {/snippet} + {#snippet action()} + + {#if blocked} + + + {b.occupiedBy ? 'Resolve in the workspace' : 'Check the workspace'} + + {:else} + + {/if} + {/snippet} +
      + {/each} +
    +
    + {/if} + + + {#if outstanding === 0} + + Everything this project needs is configured. Finish, and it is ready to run. + + {:else if pendingTables.length > 0} + 0 + ? 'The project will not run without this' + : 'This could not be checked'} + size="xs" + > + {#if missingTables.length > 0} + The tables {missingTables.length === 1 ? 'this data table holds' : 'these data tables hold'} + do not exist, and the project's apps and flows read them. Every one of those fails as soon + as it opens. + {/if} + {#if uncheckedTables.length > 0} + {#if missingTables.length > 0}

    {/if} + {uncheckedTables.length === 1 ? 'One data table is' : 'Some data tables are'} set up, but + {uncheckedTables.length === 1 ? 'its' : 'their'} schema could not be read, so whether the + project's tables are there is unknown. Check again once the database is reachable. + {/if} +
    + {:else} + + The project's apps and flows will fail wherever they read a credential that is still + missing. Everything else it imported works either way, and you can fill these in from the + workspace at any time. + + {/if} + {/if} + +
    + {#if onBack} + + {:else} + + {/if} +
    + + {#if outstanding > 0 && !loading && !loadError} + + {/if} + +
    +
    +
    + +{#if wizardOpen || wizardFor} + runMigrationsFor(retryTarget ?? '')} + existingNames={configuredNames.map((c) => c.name)} + existingDataTables={configuredNames} + onDone={() => void afterWizard()} + {customInstanceDbs} + {confirmationModal} + {defaultInstanceDbName} + /> +{/if} + + + + + + + void refreshBlanks()} + onRestored={() => void refreshBlanks()} +/> + + + void refreshBlanks()} /> diff --git a/frontend/src/lib/components/ImportWizardSteps.svelte b/frontend/src/lib/components/ImportWizardSteps.svelte new file mode 100644 index 0000000000..5712ede180 --- /dev/null +++ b/frontend/src/lib/components/ImportWizardSteps.svelte @@ -0,0 +1,71 @@ + + + + +
    + + onStepClick(e.detail.index)} + /> +
    diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index d1dc3ec7a1..10f996a33f 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -329,7 +329,11 @@ } else { goto(resolvedRd ?? '/') } - } else if (resolvedRd?.startsWith('/user/workspaces')) { + // See (root)/+layout.svelte for why /projects/import skips the picker. + } else if ( + resolvedRd?.startsWith('/user/workspaces') || + resolvedRd?.startsWith(`${base}/projects/import`) + ) { goto(resolvedRd) } else if (resolvedRd == '/#user-settings') { goto(`/user/workspaces#user-settings`) diff --git a/frontend/src/lib/components/ProjectContentBadges.svelte b/frontend/src/lib/components/ProjectContentBadges.svelte new file mode 100644 index 0000000000..dfb8e0eb90 --- /dev/null +++ b/frontend/src/lib/components/ProjectContentBadges.svelte @@ -0,0 +1,73 @@ + + + + +
    + {#each shown as c (c.label)} + + {c.count} + {c.label}{c.count === 1 ? '' : 's'} + + {/each} +
    diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index eec4c7579b..296ab693a8 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -22,8 +22,16 @@ let { workspace = undefined, disableChatOffset = false, - onRestored = undefined - }: { workspace?: string; disableChatOffset?: boolean; onRestored?: () => void } = $props() + onRestored = undefined, + onSaved = undefined + }: { + workspace?: string + disableChatOffset?: boolean + onRestored?: () => void + /** Fires after Save has written, for a caller showing state derived from the + * resource — `onRestored` only covers restoring an old version. */ + onSaved?: () => void + } = $props() let drawer: Drawer | undefined = $state() let historyDrawer: Drawer | undefined = $state() @@ -149,9 +157,14 @@ variant="accent" unifiedSize="md" startIcon={{ icon: Save }} - on:click={() => { - resourceEditor?.save() + on:click={async () => { + // Closed before the write is awaited, the way it always was: `save()` toasts its + // own failures and never rejects, so waiting would only add visible lag to every + // caller of this drawer. `onSaved` still fires after the write lands. + const saved = resourceEditor?.save() drawer?.closeDrawer() + await saved + onSaved?.() }} disabled={!canSave} > diff --git a/frontend/src/lib/components/common/radioCard/RadioCard.svelte b/frontend/src/lib/components/common/radioCard/RadioCard.svelte index 6cbb9c78a0..f9b7f675a4 100644 --- a/frontend/src/lib/components/common/radioCard/RadioCard.svelte +++ b/frontend/src/lib/components/common/radioCard/RadioCard.svelte @@ -10,12 +10,14 @@ onSelect, disabled = false, icon = undefined, + showRadio = true, class: className = '' }: { /** Title shown in bold at the top of the card */ label: string - /** Optional supporting line under the label */ - description?: string + /** Optional supporting line under the label. A snippet when it needs markup + * of its own — an emphasised name, a count — rather than plain text. */ + description?: string | Snippet /** Whether this card is the selected option */ selected?: boolean /** Called when the card is clicked */ @@ -23,35 +25,55 @@ disabled?: boolean /** Optional leading icon, rendered after the radio */ icon?: Snippet + /** Draw the radio glyph. Turn it off where the card itself is the only + * control and the border and tint already say which one is picked — the dot + * is then a second, redundant answer to the same question. The group still + * reads as radios to a screen reader, which is what `role` carries. */ + showRadio?: boolean class?: string } = $props() + + // A snippet is a function; a description string is not. Checked rather than + // requiring callers to pick between two props. + const describedBySnippet = $derived(typeof description === 'function') + + + + {:else if step === 2} +
    + {#if !choiceIsExisting} +
    +

    Name the new workspace

    +
    + +
    + + +
    + + {#if !automateUsername} + + {/if} + {:else} +
    +

    Pick a workspace

    +

    The project is imported into this one.

    +
    + + {#if workspaceList.loading} +
    + Loading your workspaces… +
    + {:else if workspaceList.error} +

    + Could not list your workspaces. Reload the page, or go back and create a new one. +

    + {:else if workspaces.length === 0} +

    + You are not a member of any workspace yet. Go back and create one, or ask an admin to + invite you. +

    + {:else} + + {#if workspaces.length > 1} +
    +
    + + +
    + {#if hasForks} + + {/if} +
    + {/if} + + + + {/if} + {/if} + +
    + + {#if !choiceIsExisting} + + {/if} +
    +
    + {:else if step === 3} + go({ folder }, 3, { replace: true })} + onFinish={() => + setupNeeded + ? // Replaces rather than pushes: after a reload on step 4 the run is gone, and + // a step-3 entry in history is a browser-Back route to the same fresh import + // the stepper is now blocked from reaching. + go({}, 4, { replace: true }) + : finish()} + onBack={() => go({}, 2)} + onExecution={(e) => (execution = e)} + resume={execution} + /> + {:else} + go({}, 3) : undefined} + /> + {/if} + +{/if} diff --git a/frontend/src/routes/(root)/(logged)/projects/install/+page.svelte b/frontend/src/routes/(root)/(logged)/projects/install/+page.svelte deleted file mode 100644 index 06f2344356..0000000000 --- a/frontend/src/routes/(root)/(logged)/projects/install/+page.svelte +++ /dev/null @@ -1,374 +0,0 @@ - - -
    - {#if !slug} -

    Missing ?hub=<slug>.

    - {:else if loading} -
    - Loading project… -
    - {:else if loadError} -

    Failed to load project: {loadError}

    - {:else if data} -

    Add “{data.project.name}” to workspace

    -

    {data.project.summary}

    - -
    -

    - Folder in {workspace} -

    - -

    - Items import under f/{folderName.trim() || data.project.slug}/. -

    -
    - -
    - {counts?.scripts} scripts - {counts?.flows} flows - {counts?.apps} apps - {counts?.resources} resources - {counts?.triggers} triggers - {#if counts && counts.migrations > 0} - {counts.migrations} data table migrations - {/if} -
    - -
    - Resources are imported as empty stubs — set their values after import; a resource whose path - already exists is reported as failed (existing values are never overwritten). Trigger kinds - are recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at - creation and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP - and Azure triggers all require Enterprise. Triggers that reference a resource depend on stubs - imported empty, so fill in the resource value before re-enabling the trigger. -
    - -
    - - {#if done} - - {/if} -
    - - {#if results.length} -
      - {#each results as r} -
    • - {r.ok ? '✓' : '✗'} - {r.path} - {#if !r.ok}— {r.error}{/if} -
    • - {/each} -
    - {/if} - {/if} -
    - - - - - - closeMigrationReview(false)}> - closeMigrationReview(false)}> -
    -

    - This project ships migrations that recreate the data tables it uses. Review and edit the - SQL, then choose which to run. A migration runs against the data table of the same name in - {workspace}; if that data table has migrations enabled it is - recorded, otherwise it runs once as a preview job. -

    - {#each reviewList as m (m.datatable_name)} -
    -
    - {m.datatable_name} - -
    - {#if m.run} - - {/if} -
    - {/each} -
    - {#snippet actions()} - - - {/snippet} -
    -
    diff --git a/frontend/src/routes/(root)/(logged)/projects/install/+page.ts b/frontend/src/routes/(root)/(logged)/projects/install/+page.ts new file mode 100644 index 0000000000..718b624bd4 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/projects/install/+page.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit' +import { base } from '$app/paths' +import type { PageLoad } from './$types' + +/** + * `/projects/install?hub=` was where the hub's "Add to workspace" button + * pointed before the import wizard existed. Hubs upgrade on their own schedule — + * a self-hosted one may keep sending people here for a long time — so the old + * entry point forwards to the wizard rather than 404ing, query string intact. + */ +export const load: PageLoad = ({ url }) => { + redirect(307, `${base}/projects/import${url.search}`) +} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte index d7520bd873..df85c1f1c1 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte @@ -1,4 +1,5 @@ diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index c4d2168d26..ed9bf2b11e 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -26,6 +26,7 @@ import { switchWorkspace } from '$lib/storeUtils' import { GitFork, Settings, User, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte' import { isCloudHosted } from '$lib/cloud' + import { canCreateWorkspace } from '$lib/workspaceCreation' import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte' import { emptyString } from '$lib/utils' import { getUserExt } from '$lib/user' @@ -104,9 +105,7 @@ let onlyAdminsWorkspace = $derived(allWorkspaces.length === 1 && allWorkspaces[0].id === 'admins') async function getCreateWorkspaceRequireSuperadmin() { - const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin') - const t = await r.text() - createWorkspace = t != 'true' + createWorkspace = await canCreateWorkspace(false) } let createWorkspace = $state($superadmin || isCloudHosted()) diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 1f7a813eac..0e404afbcc 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -1,4 +1,5 @@