mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
feat: a wizard for importing a hub project, and finishing what the import cannot (#10729)
* feat(frontend): guided setup wizard for data tables
On Cloud a data table cannot use the Windmill instance database, so a new
workspace hit a dead end: an alert telling the user to go find a PostgreSQL
resource somewhere else. Setting one up meant three disconnected places, and the
connection could only be tested after the config had already been saved.
Adds a three-step wizard (choose a database -> set it up -> name it) reached from
the data tables settings page:
- Supabase: signs in via the existing supabase_wizard OAuth client and creates
the project from inside Windmill. Because db_pass is an input to project
creation, Windmill sets the password and the user never visits a dashboard.
- Your own database: picks an existing postgresql resource, or adds one with a
connection string through the form that already supports it.
- Windmill database: hands back to the inline row editor, since instance
databases are provisioned by a superadmin.
Verifying access is no longer a step the user takes: Continue runs the check and
passing it is what advances the wizard, so a database that cannot create tables
never reaches the workspace config.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: pin ee-repo-ref to the Supabase provisioning endpoints
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): do not claim the database is ready when its check failed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review findings on the data table wizard
- The Supabase create branch advanced on `provisioning === 4` without consulting
the check it had just run, so a role that cannot create tables could reach
Finish. It now blocks and offers Try again.
- Retrying no longer mints a fresh secret variable + resource each time: the
credentials are only re-created when the password actually changed.
- The generated password is captured before the create call rather than after,
since a throw there can still leave a project behind.
- On a failed provision the project list is refreshed, so the just-created
project can be picked up from the other tab instead of provisioning a second.
- Finish refuses a name that already belongs to another data table, which
previously repointed it at the new database.
- Secrets go to the acting user's namespace instead of a literal `u/admin/`.
- The progress list no longer ticks "Created on Supabase" before the request is
sent, and does not claim the database is ready when its check failed.
- The wizard's resume state is cleared when it closes, so reopening after an
abandoned OAuth round trip is not stuck on step 2.
- The OAuth callback shares the session-storage key rather than repeating it.
- SupabaseConnect uses the shared provisioning helpers instead of a fork.
- Restores the doc comment displaced onto TestDataTableResourceQuery.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): simplify Alert layout and balance its vertical padding
The body was rendered by two near-duplicate branches, each wrapping the text in an
extra div only to hang a margin on it, and the margins disagreed: the collapsible
branch spaced above with mt-2, the static one below with mb-2. Since isCollapsed
defaults to true, every non-collapsible alert took the static branch, so titled
alerts read as 24px of space below the text against 16px above -- visibly
off-centre -- with the title and body flush against each other.
Collapse both branches into one and drop the margins; the container's own padding
now sets top and bottom equally, with a small gap under the title row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): only offer Supabase when its OAuth client is configured
The wizard offered the Supabase card unconditionally, so on an instance whose
superadmin never configured a supabase_wizard client -- or whose backend is built
without the oauth2 feature, which compiles the whole /api/oauth router out -- the
card dead-ended at a 404. Gate it on listOauthConnects, the same check
ApiConnectForm already makes, fetched on open so configuring the client mid-session
does not require a reload.
Also drop the Supabase project ref from the existing-project cards: it is an opaque
identifier that means nothing outside Supabase's own dashboard URLs. Show the region
instead, plus a status word when the project is not healthy, since a paused project
is the one case where the connection check fails for a reason unrelated to the
password.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): run the Supabase OAuth leg in a popup
A full-page redirect unmounts the wizard, so anything the user does on Supabase's
side -- signing in, confirming an email, browsing their dashboard -- leaves them
with nothing pointing back at Windmill, and the wizard had to park its state in
sessionStorage to survive the trip.
Open the connect endpoint in a popup instead. The modal stays on screen throughout
and the callback hands the token back through postMessage rather than navigating.
The parked-state path stays as the fallback for browsers that block the popup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): scope the connection check to the choice that produced it
A failed check stayed on screen when the user switched Supabase mode or picked a
different provider, so a fresh tab opened showing an error about a database it had
nothing to do with. Clear the report and the error on both switches; re-clicking the
tab already selected leaves an error the user is reading in place.
Also polish the Supabase step: project cards get the provider-card treatment (icon,
p-3, flex column) instead of a hand-rolled variant whose block layout left more
padding above the name than below; form labels settle on text-emphasis; and the
signup link sits under the primary button for anyone who does not have an account
yet.
Drop the "free" badge and the "Free on Supabase" line -- every option in the wizard
is free, so neither told the user anything -- and say what the Supabase card
actually does now that connecting an existing project is the default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 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>
* fix(frontend): tidy the resource drawer around the Supabase entry point
Connect Supabase was a hand-styled anchor carrying Supabase's brand hex values
rather than a Button, and it sat in a row whose other controls had settled on
unifiedSize md. Making it a Button meant SupabaseIcon had to satisfy IconType, so it
now takes `size` (deriving height/width from it) alongside the string props its other
callers pass.
The manual resource form spaced every field 32px apart and WhitelistIp added another
16px of its own, which read as a gap rather than a rhythm. One gap of 16px, with the
form itself given a little more separation from the description above it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): stop Supabase resources coming up modified when first opened
Resource forms fill in every unset property from the schema as soon as they render,
so a postgresql resource saved without region, root_certificate_pem and use_iam_auth
was dirty -- and had saved a draft -- the first time anyone looked at it. Write them
with the rest of the value.
SupabaseConnect also rebuilt the resource shape by hand instead of using the shared
helper, which is how the pooler host format ended up in two places.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend): record where a data table came from and whether setup finished
edit_datatable_config replaces the whole datatables map and DataTable does not deny
unknown fields, so anything the request omits is dropped without a word. origin and
setup_incomplete would have been erased by any unrelated save;
preserve_unmanaged_datatable_fields carries them -- and migrations_enabled, which had
the same problem inline -- forward for entries that already exist, following renames.
setup_incomplete is what lets a row be recorded before the resource it points at
exists, so the wizard can write nothing until the user finishes. There is deliberately
no intermediate state: the setup runs entirely in the browser, so nothing server-side
could advance one.
datatable_health probes every data table at once for the settings page and skips the
incomplete ones, whose resource_path resolves to nothing yet. set_datatable_setup
patches a single entry instead of resending the map. test_datatable_connection_value
checks a connection the caller has not saved anywhere, which the wizard needs before
it has written a resource.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): make destructive default and subtle buttons read red
Both variants were neutral until the pointer arrived, then filled solid red: nothing
marked the button as destructive until you were already on it. They now carry red text
at rest, with a faded red border on default and a light red wash on hover, which is
what the legacy red border style in the same file had always done.
Three call sites passed color="red" alongside a design-system variant. getStyleClass
returns before colour is read for accent, accent-secondary, default and subtle, so the
delete-migration control, its modal confirm and the import-database button had all been
rendering neutral. They pass destructive now.
The dropdown variant strips the button's own border, and matched border-border-light
literally -- a class the destructive style no longer contains.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): rebuild data table setup around a read-only row
The wizard gathers intent over two steps, reviews it on a third and writes nothing
until Finish, so a billable Supabase project is created only once the user has seen
what will happen. runSetup is also the retry: every step probes for its own result
before doing anything, so running it again on a half-finished data table resumes
instead of duplicating. Its steps are keyed rather than dispatched on their titles,
where rewording one changed what it did.
The settings row stops being an editable form with a dirty/save cycle. It carries the
name, where the database came from, a health dot and two actions; everything rare
moved into the gear panel, which also offers Finish setup for a data table whose
wizard never completed. Manage is ExploreAssetButton, the control the ducklake list
already uses, and the row and panel both link out to the underlying resource.
supabaseResourceValue no longer assembles the pooler host from the region.
aws-0-<region>.pooler.supabase.com is wrong for any project Supabase allocated
elsewhere, so the host, user and port come from the pooler config endpoint.
Two data tables sharing one database also share _wm_migrations, which is probed
unqualified, so the review step warns when the database being connected is already
behind another data table.
SupabaseConnect is deleted. The resource drawer uses the shared project step
restricted to existing projects: creating one is a billed action and belongs in the
wizard, which has somewhere to report what it did. The kitchen_sink checklist
playground goes with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): fall back to a direct Supabase connection when the pooler cannot be read
Reading a project's Supavisor config needs the database_pooling_config_read scope, which
an instance's Supabase OAuth app may never have been granted. No retry recovers from
that, and the wizard treated it as fatal: the user was left with an error and no way to
finish connecting a project that was otherwise fine.
resolveSupabaseConnection replaces the bare pooler read everywhere it happened. Asking
for session pooling and failing now yields a direct connection plus the reason, which
supabaseResourceValue already knew how to write. Nothing about the fallback is silent --
direct is IPv6-only, which is the whole reason session pooling is the default -- so the
wizard warns on its review step and the resource drawer says so in its toast.
The row is recorded before credentials are saved, so an origin claiming session pooling
has to be corrected once a direct host is what gets written; the run patches it through
set_datatable_setup rather than leaving the panel to report a mode nothing uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): open the database behind a data table, and say when it cannot write
Every database in the list now opens the surface that owns its credentials. A postgres
one opens its resource in the editor drawer; a Windmill instance one opens the instance
modal, which is where its setup checks, password rotation and drop already lived. Both
are reachable from the row and from the panel's provenance list, and the provider icon
moved inside the button so the whole thing is one target.
CustomInstanceDbWizardModal targeted #content unconditionally, which put it underneath
the panel drawer that now opens it. It takes a target, and the panel portals it to the
body.
The status column gains a third state. The probe reports privileges but nothing gated
the dot on them, so a data table whose role cannot create tables showed as Connected and
only failed when someone ran a migration. It reads "Limited permissions" instead, and
opens the panel on the report carrying the GRANTs that fix it -- the settings page has
already probed, so the panel takes that report rather than asking the user to run Test
connection over work already done. fullyPrivileged is exported from the report component
so the dot and the report cannot disagree about what counts as healthy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert(frontend): keep the data tables settings table as it was
The settings table and the setup wizard are two changes that only shared a file. Splitting
them makes each reviewable: this branch keeps the wizard, and the read-only row, gear
panel, health probe and clickable databases move to their own branch.
The rows go back to the editable form with its pickers and save footer, still opening the
wizard from Add a database. DataTableSettingsPanel, dataTableHealth and dataTableOrigin
had no other consumers and go with them; the connection report stays, because the wizard
shows it too.
DataTableSettingsType keeps `origin`: the wizard writes it, and the review step reads it
back to warn when two data tables would share one database and therefore one
_wm_migrations table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): confirm before dismissing the data table wizard mid-setup
Closing was guarded while a run was in flight and unguarded before one, which is backwards:
a run leaves a row to resume from, whereas a backdrop click on the review step threw away
the project, the pasted password and the folder with nothing to recover them from.
Backdrop, Escape and the close button now go through one path that asks first. It only asks
when there is something to lose -- no provider chosen yet, or a run that already produced a
result, closes immediately -- so the dialog does not become something to click through.
Continue in the background still leaves in one click; that exit was always the deliberate
one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): stop the wizard claiming the resource folder controls who can use a data table
"Who can use this database" was wrong. Every path that resolves a datatable:// reference --
both executors and the agent-worker endpoint -- reads the resource unchecked, by workspace
and name. A resource in u/admin is usable by everyone's scripts. The folder governs who can
see and edit the connection, and who can reference the resource directly in a SQL step;
neither is who can use the data table. The wizard was contradicting the tab's own
description two screens later.
The folder select and name field become one Path picker, the same one the resource,
variable and script forms use, so the review step reads as a resource path rather than a
permission choice. Its initialPath is snapshotted when the step opens: Path seeds itself
from it, and a live value fights the typing. Finish now also gates on Path's error, so a
taken or malformed path stops the run before it writes anything.
The button that opens all this says "Add a data table" -- the data table is what you get;
the database is a detail chosen along the way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert(frontend): move the destructive button restyle out of the wizard PR
This reverts 3881e4d8ea. Making default and subtle destructive buttons red at rest changes
every existing caller of the prop -- the workspace integrations, AI skills, workspace
creation and the instance database drop -- so it is a design-system change, and the call
sites it fixed are the migrations list and the database manager. None of that is the setup
wizard.
Nothing on this branch passes destructive any more, so it leaves with no loose ends.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): make the wizard stepper navigate the steps it already offers
Stepper dispatches a click and paints cursor-pointer on every reached step, but the wizard
never listened, so the breadcrumbs invited a click and did nothing.
They now reach any step already passed, in either direction: going back to check something
should not cost the progress, which means tracking the furthest step reached rather than
the current one. Forward movement still only happens through the primary action, so a step
is never reachable without having been validated -- and changing the intent revokes the
steps ahead of it, or Finish could run against a review built from something the user has
since edited. The five places that cleared the probe on an edit now do both through one
call.
During a run nothing is reachable, and the stepper says so rather than showing a pointer
over steps that will not respond.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): restore the data tables description lost in the branch split
The rewritten description went into DataTableSettings.svelte shortly before that file was
restored wholesale to its pre-rebuild state, so it left with the row rework it had nothing
to do with. The tab went back to describing the plumbing -- a fully managed PostgreSQL
database, reachable from the SDK -- which never answered the question a new user actually
has: why this rather than a Postgres resource.
It leads with what a data table is, then the two things a resource cannot do -- nobody
needs the credentials to query it, and the name can be pointed at another database without
editing anything that uses it -- and closes with what Windmill runs on top. Both middle
claims are the ones every resolution path backs up: datatable:// resolves by workspace and
name, unchecked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(backend): say what is missing when a $res: or $var: reference does not resolve
Both interpolations fetched with fetch_one and mapped the error through to_anyhow, so a
reference to something deleted surfaced as "no rows returned by a query that expected to
return at least one row @workspaces.rs:2169". It names neither the kind of thing that was
missing nor its path, and it is what a data table pointing at a deleted resource reports.
They now fetch_optional and return NotFound naming the path, and datatable resolution adds
the data table on the way out: the caller asked for one by name, and a bare "resource
f/x/y does not exist" leaves them to work out which of them points at it. The health probe
is new, so this string had only just become something users read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): gate the data table wizard behind a dev flag
The wizard only appears with `dataTableWizard` set in localStorage; without it the
settings page keeps the inline-row flow it had before this branch, down to the empty-state
copy and the "New Data Table" button, and the wizard component is not mounted at all. The
existing e2e suite drives that button, so the default-off flag is also what keeps it green.
Step 2 of "your own database" becomes one list rather than a segmented control: the
workspace's Postgres resources, then a New resource card that expands in place. A
connection string is not an alternative to a resource, it is how one is written, and the
old layout taught otherwise. The card holds the same connection as a string or as fields
and carries values across when you switch, so `parse` and `compose` have to be inverses --
hence the percent-encoding on both sides, which also fixes a password containing `@`
silently corrupting in the resource form. The Supabase step now uses the same shape.
Names and paths are checked as they are typed rather than at the end of a run that may
have created a billed project first: the data table name against the charset
`edit_datatable_config` enforces, the instance database name against what
`setup_custom_instance_db` will accept, and the resource path against both the resource
and variable namespaces, since the run writes to both and both writes upsert.
`test_datatable_connection_value` refuses `$var:`/`$res:` in its body. It feeds
`transform_json_value_unchecked`, which resolves references with no permission check of its
own, so an admin could otherwise have had the API server decrypt any workspace secret and
hand it to a host the same request chose -- without the audit trail a variable read leaves.
Callers testing something unsaved hold the literal value already.
Alert, SetupChecklist and postgresConnectionString change for everyone, not just behind the
flag: body-only alerts no longer reserve an empty title row, the checklist can nest the
checks a step is made of, and the connection-string parser is shared with the resource form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: pin ee-repo-ref to the EE branch merged with EE main
The Supabase proxies the wizard calls are still unmerged, so the ref cannot be an EE
main commit yet; it now names that branch merged with EE main rather than the branch
alone, which was nine commits behind and would have been built against a CE main it
never saw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): gate the supabase resource path behind the dev flag
* test(frontend): pin connection string parsing to libpq behaviour
* fix(frontend): keep the supabase resource link off the popup callback path
* refactor(frontend): load the supabase resource dialog only behind the flag
* fix(frontend): refuse a resource path the wizard run does not own
* fix(frontend): let a failed data table setup be corrected without losing what it made
* fix(frontend): let a failed setup reuse the resource path it claimed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(backend): record the two data table connection tests in the audit log
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): use Section for the data table wizard advanced group
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): read connection strings the way libpq does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(backend): pin the ee ref back to a commit this branch can build
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): keep a failed setup's claims across the redirect and rollback
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(backend): probe a data table with the auth mode the worker will use
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): keep every part of a connection string through the round trip
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): give a setup run one record of what it created
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): mark a resource claim by edited_at, not its creator
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): mark every claim by revision, and keep an unconfirmed project's secret
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): refuse to test or save behind a connection string that will not parse
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): refuse a connection string carrying options the resource cannot hold
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): allowlist the connection-string parameters a resource can honour
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): guard every created Supabase project, not just the last one
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: three-step wizard for importing a hub project
Importing used to be a single page that inherited whatever workspace happened to
be active, with no way to say where the project should go — the hub cannot know,
since it only ever links to *an* instance. `/projects/import` now asks: which
kind of destination, which workspace, then imports.
Nothing is created, switched or written until the last step runs. The wizard's
state is a plain value in the URL (`importWizard/plan.ts`), so the back button,
the stepper and the Back control are the same operation, and none of them can
strand a half-created workspace — there is no state anywhere else to unwind.
`importWizard/execution.svelte.ts` is the only code that acts on a plan: it runs
create → fetch → import as an observable task list, reuses what already
succeeded when retried, and offers to delete the workspace it created if the run
stops early. Its UI needs — the data table migration review — are injected, so
it holds no components.
The old `/projects/install` becomes a redirect: hubs upgrade on their own
schedule and a self-hosted one may keep pointing at it for a long time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: let the import wizard survive sign-in and a missing workspace
Signing in with `rd=/projects/import?hub=...` dropped the destination: the login
redirect only honours `rd` verbatim for `/user/workspaces`, so anyone with more
than one workspace landed on the workspace picker instead — the page the wizard
exists to replace, asking the question it was about to ask. Both copies of that
logic now allow the wizard through.
The root layout's "no workspace selected" redirect skips the wizard too. It
picks the destination itself and may end in a workspace that does not exist yet,
so bouncing it to the picker forces the very choice it is there to make.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: bench page for the import project card
/kitchen_sink/import_project_card renders the card against fixtures — a real
project, an oversized one, a minimal one — so its layout can be judged without a
hub running or an import in flight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): do not warn about renaming an item that does not exist yet
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): make the review step read as one list of what will exist
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): keep the picked Supabase project across the redirect, reject connect_timeout
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: check the data table connection from a worker, not the API server
The wizard's connection check ran on the API server through two endpoints added
for it. That server is a different machine with a different identity, so the
answer was about the API server rather than about the worker that will run the
queries: a host reachable from one is not necessarily reachable from the other,
and IAM RDS and Azure workload identity authenticate as whichever process opens
the connection.
Run the privilege query as a preview job instead. A job goes through the
worker's Postgres executor, which is where `PgAuthMode::of` already picks the
authentication mode, and it takes either a resource value or a `$res:` path
exactly as a Postgres step does. Postgres composes the suggested GRANT
statements through `format('%I')`, so identifier quoting stays where it is
already implemented.
Removes `test_datatable_resource_connection` and
`test_datatable_connection_value`, and `connect_as_the_worker_would` with them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: fold check_datatable_connection back into its only caller
The helper was split out so the two connection-test endpoints could share a
body. Those endpoints are gone, leaving one caller.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert: keep the data table connection check schema inline
It was lifted into components so three endpoints could share it. Two of those
are gone, so it is back to one user and the extraction changes nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: restore openapi.yaml to the branch point
The previous commit restored main's tip rather than the merge base, which
carried three unrelated main-only changes into this branch: the resource
mcp_tools truncation fields, the execution_mode description, and a version bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): drop four effects from the data table wizard
Each was doing work a derived, a load callback or a real entry point does
better.
- The name conflict is kept with the name it was raised for and derived from
it. As an effect it was correct only because it never read what it wrote:
the pre-flight sets the message and the effect does not re-trigger, so adding
a read would have cleared it the instant it appeared. The message now also
comes back if the taken name is retyped, which is what the server will say.
- The default resource selection is seeded inside the fetcher that loads the
list, where "has the fetch settled" cannot be asked wrong.
- Reset-on-open becomes an exported open(), called by the settings page, so a
fresh run is set up by the act of opening rather than by a flag emulating
mount.
- The OAuth connects and the folder list become resources; supabaseAvailable
and folders are derived from them. defaultFolder takes the list rather than
reading it, so the fetch can seed off its own result.
Leaves the debounced path check, which is async with an out-of-order guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): drop three effects from the Supabase branch
- useSupabaseOauth reports success as onAuthed, alongside the failures it
already reported. SupabaseResourceConnect was watching `authed` to find out;
it takes the callback instead, keeping the guard that stops an authorization
started elsewhere on the page from opening its dialog.
- SupabaseProjectStep loads its orgs and projects through a resource keyed on
the token, so the `loaded` latch goes and re-authorizing reloads rather than
keeping the lists from the expired session.
- SetupChecklist records what the user toggled and derives the open state from
it, a failed step defaulting to open. Recording the open state instead needed
an effect to force it, and that effect re-ran on every progress update, so a
description closed while anything was still ticking reopened. A close now
holds for the life of the checklist, including across Try again.
Leaves the message listener, which subscribes to another window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): confine the modal restyle to the wizard, and trim the comments
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>
* fix(frontend): use the accent token for the wizard's links
`text-blue-500` is the marketing blue `#3B82F6`, which brand-guidelines.md
rules out in the app interface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: point ee-repo-ref at the EE branch head
Picks up EE main, which the branch now needs, and the Supabase proxy auth fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): read sslmode by name, and stop decrypting a secret to date it
- `sslmode` was found by searching the query text, so it also matched inside
another parameter's value: `?application_name=sslmode=disable` passed the
allowlist on the parameter name and then parsed as a request to turn TLS off,
which both the wizard and the resource form saved and probed. Parsed with
`URLSearchParams` by exact name, with a test.
- `secretMark` read the variable with `decryptSecret` defaulted to true, so
every write decrypted a secret nothing reads and recorded the decryption --
including someone else's on the retry about to refuse it. It wants only
`edited_at`, which is returned either way.
- The probe gave up at 15s while the worker allows its Postgres connect 20s, so
a host that accepts the connection and never answers was cancelled and
reported as a missing worker rather than a failed connection.
- The create-mode region and project name did not report an intent change, so
renaming a project after a name collision left the failure naming the old one.
- Two comments described the code as it was before the claim mark became a
revision, and a doc comment outlived the field it documented.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): read connection parameters the way libpq does
One reader for both the parser and the allowlist, since they disagreed about
what a string says in two ways that both ended in a weaker connection than was
pasted:
- `URLSearchParams.get` takes the first of a repeated parameter and libpq takes
the last, so `?sslmode=disable&sslmode=require` was read as `disable`.
- The allowlist folded the parameter name and the parser did not, so
`?SslMode=verify-full` was refused by neither and honoured by neither, and
saved as the `require` default.
The parked Supabase run is now handed to `open()` rather than read back off the
`resume` prop it was just assigned to, so restoring it does not depend on when
that prop reaches the component.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): keep connection parameter names case-sensitive
libpq does not fold them: `?SslMode=disable` is rejected as an invalid URI
query parameter rather than read as `sslmode`, which a local server confirms.
Folding made Windmill accept and honour a string Postgres itself refuses;
naming the parameter instead tells the user why it cannot be stored.
The last-value-wins rule for a repeated parameter is unchanged, and matches
what the same server does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): seed the Supabase organization from the project it selects
The loader took `orgs[0]` independently of the project it seeded, so an account
whose first project sits outside its first organization had the review step name
an organization the database does not belong to. Picking a project by hand
already derives it; the seeding now does the same.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): let the probe report an empty search_path instead of failing on it
`format('%I', NULL)` raises rather than returning NULL, so a role whose
search_path names no valid schema failed the whole privilege query and was
reported as an unreachable database. That is the one case `fix_search_path`
exists to name, and it never reached the user. Verified against a local server
with `SET search_path = ''`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): say which of the two refusals a connection string hit
Making parameter names case-sensitive gave `unsupportedConnectionParam` two
reasons to refuse, and the single message explained only one. `?SslMode=` was
answered with "Windmill cannot store SslMode on a Postgres resource", which is
false twice over: sslmode is exactly what the resource stores, and the string
asks for nothing because Postgres rejects the URI. It now names the spelling
when the parameter is one we keep, and the storage limit otherwise.
The folder-list guard also still read the `resume` prop that `open(parked)` was
changed to stop trusting, so the resumed path now comes from whatever `reset`
was handed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): leave the Supabase organization unset when the lookup misses
Falling back to the first organization named one the seeded project is not in,
since `supabaseSummary` prefers `intent.org` over the project's own. Unset, it
falls through to the project's organization identifier — the right one, spelled
as a slug rather than a name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: harden the import wizard and put it on the design system
Review fixes, then the parts of the wizard that were hand-built where the
design system already had an answer.
Correctness:
- Hub SVGs are sanitised with DOMPurify before `{@html}`. The earlier comment
claimed the markup came from the hub's own icon package rather than user
input, which the custom-URL feature makes false: the hub is whatever address
the user typed.
- The run owns navigation while it is in flight. The stepper refuses to move,
`beforeNavigate` cancels browser back/forward, and unmounting resolves a
pending migration review so the executor cannot hang waiting on a component
that is gone.
- The folder edited on the last step reaches the executor, so a retry after
changing it imports where the field now says.
- `validateWorkspaceId` and the workspace-entry pair (`listUserWorkspaces` then
`switchWorkspace`) are extracted, so the wizard and the real create form
cannot drift on what an id is or on what entering a workspace means.
Design system:
- The destination tiles are `RadioCard`, which gains `showRadio` and a snippet
`description`; the wizard turns the glyph off because the border and tint
already say which one is picked. `RadioCard` now also carries `role="radio"`
and `aria-checked`, which it had neither of, and marks its selection with
`surface-accent-selected` — the token `FileExplorer`, `TriggersTable` and
`RunnableRow` all use for the chosen row.
- Form labels follow `brand-guidelines.md` — sentence case, real `<label>`
elements so the text focuses the field, Caption-styled errors — rather than
one-off 11px uppercase tertiary text. They use the lighter secondary weight,
since the fields arrive prefilled and the value carries the meaning.
Folder choice, restored and merged:
- Picking an existing folder came back for an existing-workspace destination.
`FolderPicker` takes a `workspace` prop so it can list a workspace without
switching to it, and resolves `whoami` there — its write flags came from
`$userStore`, i.e. the wrong workspace, which rendered every real folder
read-only and unselectable. A new workspace has no folders to choose between,
so it is not asked.
- The progress list and the imported paths are one component: the paths hang
off the import task that produces them instead of forming a second list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review — history, workspace restore, SVG styles
Two blockers and the findings around them.
- The wizard pushed history entries for navigations the user did not ask for.
`folder` initialises to the project slug while the plan holds none, so the
mirroring effect fired on mount and pushed a URL differing only by `&folder=`;
pressing Back returned to the entry without it, which re-fired and re-pushed.
Back could never leave step 3. `go` now takes `{ replace: true }`, used by that
effect and by the step guard — the two navigations the page decides on its own.
The comment claiming `go` replaced was written without checking that `goto`
forwards to SvelteKit, which defaults `replaceState` to false.
- Undoing a run left the app pointing at the workspace it had just deleted:
`#ensureWorkspace` switches in, `deleteCreatedWorkspace` deleted without
switching out. The dead id was persisted on the next navigation, `getUserExt`
then returned undefined, and the following reload logged the user out. The
executor now remembers where the app pointed before it started and puts it back.
- `FORBID_TAGS: ['style', 'image']` on the hub SVGs. The profile allows both; an
inline `<svg><style>` is document-scoped, so a hostile hub could restyle this
page — including moving the wizard's own Import and Delete controls — and
`<image href>` is a beacon. The doc comment asserted a guarantee the config did
not deliver.
- The existing-workspace id is validated like the new one and encoded where it is
interpolated into `/api/w/<ws>/...`; it arrives from the URL exactly as the new
one does and ends up in `workspaceStore`.
- `AppConnectInner`'s two RadioCards get a `role="radiogroup"` wrapper, since they
now carry `role="radio"` and a screen reader cannot place a radio without one.
- `FolderPicker` records a created folder against the membership it is reading, and
before reloading, so a non-admin can re-pick the folder they just made in another
workspace instead of finding it `(read-only)`.
- Step 3 shows trigger and data table migration counts once the export is fetched.
The page this replaced showed them, and the warning underneath talks about
triggers the user was never told about.
- First tests for the two pure modules: the workspace-id contract the wizard and
the create form must not drift on, and the plan/URL round trip the whole wizard
rests on.
- Doc fixes: the retry claim (the granularity is the task, not the item), the bench
header, a fractional `?step=`, an empty name in the destination card, and the
three copies of one rationale AGENTS.md asks to state once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(frontend): pin which refusal a connection string gets
The two messages differ in what they ask the user to do, and the condition
choosing between them — whether the lowercased name is one the resource keeps —
is not visible from either call site. `Connect_Timeout` is the case that keeps
them honest: miscased *and* unstorable, so respelling it would not help and the
message must not suggest it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): hand a failed Supabase leg back to the page holding its run
Denial, a token error and a malformed callback all sent the user to
/resources whether or not a run was parked. Nothing else consumes the park, so
the run stayed in sessionStorage and sprang the wizard open on an unrelated
later visit instead. A parked run now lands on the data tables tab, where the
wizard resumes on the setup step and can authorize again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): let a run reuse the name of a row it could not take back out
`removeRow` reports `kept` when the undo cannot reach the server, so the row
this run wrote stays in the workspace config and comes back in `existingNames`.
The client-side name check then refused the retry on the run's own name, with
no way forward but a rename. The instance database name has carried the same
exemption since it was written; this is the data table name catching up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): discard a variable check the wizard has moved on from
The post-await guard compared only the path, and the path is built from the
review step's fields -- so picking an existing resource stops the wizard minting
one without changing it. A check already in flight then answered for a branch
nobody was on, and a `true` disabled Finish over a path the run no longer
writes. The cleanup cannot help: it cancels a pending timer, not a live request.
Both sides of the await now ask the same question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to 483513b70979aa9497cab869837108d948449984
This commit updates the EE repository reference after PR #715 was merged in windmill-ee-private.
Previous ee-repo-ref: 8604b30a740c5620069208801a7ae50937b61977
New ee-repo-ref: 483513b70979aa9497cab869837108d948449984
Automated by sync-ee-ref workflow.
* feat: a setup step for what the import cannot bring with it
A project's data tables and credentials cannot travel with it: a data table is a
named database connection the workspace owns, and resource values are secrets the
hub never publishes — `importResourceStub` creates every one of them empty. The
wizard used to state that as a dead end. Mid-import it asked the user to cancel,
create the data tables by hand and start over, which for a *new* workspace was
every single time, since a new workspace has no data tables at all.
Step 4 replaces that. It appears only when the run leaves something undone, lists
what that is, and does it in place: a Postgres resource per missing data table
(one merged `editDataTableConfig` write, then the migrations), and the existing
resource editor for each credential. Skipping is allowed and says plainly which
parts of the project will not run.
It is self-sufficient from `workspace` + `slug` — it re-fetches the export rather
than reading the executor — so reloading on it works and the plan in the URL stays
the whole state. Rows are marked done rather than removed, with SaveButton's
confirmation flash, because a checklist line that vanishes when completed reads as
something going wrong.
Two things the step needed from elsewhere:
- `ResourceEditorDrawer` gained `onSaved`. `onRestored` fires only when an old
version is restored, so a caller showing state derived from the resource had no
way to know a save had happened — the row kept saying "missing token" after the
token was filled in.
- The run now loads the destination's membership into `userStore`. The wizard's
page is reparented out of `(logged)` and never gets that layout's `getUserExt`,
so anything asking what the user may do reads "no user" and refuses.
`applyOneMigration` is exported for the same reason the step exists: the import
skips a migration whose data table is not configured, and this is where it is not
skipped any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: set up data tables through the wizard, not a hand-rolled form
The setup step drove `editDataTableConfig` itself, which meant it could
name a table and record migrations but could not create the database
behind it — the case a brand-new workspace is always in. It now opens
`AddDataTableWizard`, which owns that whole path.
Four additive props carry what the import flow needs and nothing else,
so `DataTableSettings` is unchanged:
- `initialName` — the migrations only apply to a table of the name they
target, so the wizard opens on it. Still editable.
- `modalTarget` — `#content` is the `(logged)` shell's scroll container,
and the import page reparents out of it, so the portal would find
nothing and the dialog never appear.
- `finishAlso` / `onFinishAlso` — running the migrations was invisible
until it had already happened. It is now named on the final button
("Create data table and run migrations") and reported as the last row
of the wizard's own checklist, failing there rather than silently.
Rows are marked done rather than removed, so the list still says what
was set up. Resources keep their card and swap "Fill in" for "Saved".
`Finish` is the primary and stays disabled until nothing is outstanding;
`Skip for now` sits beside it, and the info alert explaining the skip
turns into a success one when everything is configured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: show each credential's own integration icon, and cut the data table blurb
The credentials list marked every row with the same key glyph, so the only
thing distinguishing them was the path. `IconedResourceType` renders the
provider's own mark from the resource type already on the row, falling back
to a generic box for types with no icon.
The data table explanation said "a data table is a database this workspace
owns" directly under a label reading "Data tables to set up", and "this
project ships with one it expects to find" directly next to the count that
says so. Both halves went; what a data table is *for* and what to do next
are what a first-time reader needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: build the Google sign-in button from the design system
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: qualify a data table FK target with its schema
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: confirm before skipping an unconfigured data table
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: show a loader while the wizard hands off to the workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: resume an import whose workspace was already created
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* refactor: draw the import run with SetupChecklist and ask before leaving it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: portal the setup step's confirmation above the data table wizard
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: supply the three APIs the import wizard already calls
`AppConnectDrawer`, `ImportProjectStep` and `execution.svelte.ts` landed
calling into props and exports that were never committed alongside them,
so the branch did not type-check. Each half is here now:
- `AppConnectInner.fillPath` — connect into a resource that already
exists instead of refusing the path. The import creates every resource
as an empty stub, so without it the connect flow can only ever say
"already exists, delete it or pick another path". Opt-in: unset, the
flow still refuses to write over anything, which is what `ResourcePicker`
and the resources page rely on.
- `ProjectContentBadges.contentSummary` — the badge counts as one line of
text, for the import step's task row. Shares `kinds()` with the badges
so a project cannot be counted two ways.
- `installProject.onMigrationsStart` — fires before the reviewed
migrations run, which is the only signal that phase has begun; the
import step draws them as their own checklist row off the back of it.
Also fixes the wizard wedging itself shut: `requestClose` set `dismissing`
and cleared it after awaiting the confirmation, so an `ask` that threw left
the flag set — and the backdrop, Escape and the close button all return
early on it, leaving a reload as the only way out. Now `finally`, plus a
reset on open, since a promise that never settles never reaches `finally`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: offer Connect wherever the connect dialog would actually work
The setup step decided a resource was connectable by looking only at the
instance's configured OAuth clients, while the dialog it opens also accepts
a provider the registry marks client-credentials-capable — those carry their
credentials per resource, so no superadmin has to configure anything. The
two disagreed for bitbucket, coupa, linkedin, servicenow, spotify, visma,
xero and zoho: the step showed "Fill in" where the dialog would have
connected.
Rather than copy the predicate, `oauthRegistry.ts` now owns it, and
`AppConnectInner` reads it from there. That folds in three lookups of the
same registry that had drifted apart inside the component — `registryEntry`,
`isCcCapable`, and a raw index at the connect-template site — so the sandbox
suffix rule is written once instead of twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: draw the project card's icons from the ones we already ship
The card fetched each integration icon from the hub as SVG markup, sanitized
it and injected it with `{@html}`. The hub renders those icons out of
`@windmill-labs/components` — this frontend's own package — so it was a
cross-origin round trip to get our own assets back, and it made the card
depend on a read that a hub with `API_SECRET` set refuses outright.
`hubAppIcon` resolves them through `appIconComponent` instead, so they are
components again: no fetch, no DOMPurify, no `{@html}`, and they paint on
first render rather than after a round trip. Integration icons now show even
against a gated hub; only the summary and the uploaded logo still need it.
The one thing the hub was doing for us was resolving `postgres` to the
`postgresql` mark, which its `aliasApp` bridges and our icon map does not —
so that single alias comes along, next to a note pointing at its counterpart.
`ImportProjectSummary.hub` goes with it: it existed to build icon URLs and
nothing read it afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: two regressions this branch introduced into shared drawers
Found auditing the files here that are used elsewhere in the app.
`AppConnectDrawer`: the guard added to stop the inner component being opened
twice compared the last resource type against the current one, and reset it
to `undefined` on close. The resources page opens the drawer with no resource
type, so both sides were `undefined`, the guard matched, and the second
opening never handed off — the type list came up empty. The drawer destroys
its content on close, so this hit every reopen. Now a flag armed per `open()`
call, which cannot collide with a resource type.
`ResourceEditorDrawer`: adding `onSaved` had turned the Save handler into
`await save(); closeDrawer()`, so the drawer stopped closing immediately and
waited for the write. `save()` catches its own errors and never rejects, so
that was pure added latency for all ten callers. It now starts the save,
closes as it always did, and awaits only to fire `onSaved`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep the destination through a first-time signup
Someone who follows a shared hub project without an account signs up, and the
OAuth callback sends a first-time user to onboarding — dropping the `rd` it
had already read out of localStorage. They finish onboarding in an empty
workspace with no sign of what they came to import, and have to go back to the
hub and click again. That is the path this feature exists for.
The callback now passes `rd` on, and onboarding's two exits honour it instead
of hardcoding `/user/workspaces`. Same-origin relative paths only: `//host` is
a valid URL that leaves the origin while still starting with `/`, so the guard
rejects it rather than bouncing a fresh account off-site.
Nothing changes for a signup without `rd`, which is every existing one.
Gets the user to the wizard with the project in hand; they still pick a
destination on step 1. Having onboarding create the workspace and hand into
step 3 is the larger version, not done here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review — username, name length, leaving mid-run
**The new-workspace username was never validated.** Step 2 shows the field when
the instance does not derive one, but neither the Continue gate nor
`planProblem` looked at it. `create_workspace` does not close that hole:
`nw.username.ok_or(...)` accepts `Some("")` and never runs the `VALID_USERNAME`
check `join_workspace` does, so a cleared field created a workspace whose owner
has an empty username, and a digit-first one was stored verbatim. Both now
refuse, using the same `validateUsername` the sibling creator has always run.
**The name length was unchecked**, so a >50-char name walked through two more
steps and failed at create. `WORKSPACE_NAME_MAX_LENGTH` sits next to the id
limit and `planProblem` checks it.
**Leaving mid-run did not stop the run.** The dialog promised "The import stops
where it is. Coming back to this link picks it up again", but navigating away
only unmounted the UI: the executor kept going, reached `done`, and called
`clearParkedImport()` — so returning to the link tried to create the workspace
again and failed with "already exists". Worse, the review drawer's teardown
resolved the pending review to `false`, meaning "skip the migrations", and the
orphan imported every item without the tables they need.
Nothing can abort a request already in flight — `installProject` takes no
signal — so `abandon()` stops the run at the next phase boundary and leaves the
workspace parked, and the teardown now resolves `'abort'`, which stops the
import rather than silently dropping the migrations.
Also drops a stale JSDoc above `hubAppIcon` still describing the fetch-and-
sanitize implementation that `ea31f73ed3` replaced.
Adds the coverage the review asked for: the parking decision at the end of a
run, and the two validation gates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review round 2 — XSS, retargeting, abandonment, redirects
**Hub data-table names were inlined as raw HTML.** `skip()` built the
confirmation body as an HTML string, and `createAsyncConfirmationModal` renders
`children` through `createRawSnippet`. A `datatable_name` comes straight from
the hub export, and a hub is not necessarily ours — `hub_base_url` is an
instance setting — so one carrying an event-bearing element ran script in this
authenticated origin. Escaped. Same class as the round-1 SVG finding, in a
different sink.
**The setup step read unretargeted resource paths.** `installProject` rewrites
every resource into `f/<folder>/`, but the step re-fetched the raw export and
used its paths verbatim. Importing into a folder other than the slug made
`getResource` throw for every stub, the catch skipped them, and the step
reported "You're all set" over credentials nobody had filled. It now retargets
the same way the import did, and filters to the import folder — the containment
guard the installer applies, so a crafted export cannot name a path outside it
and get offered for editing.
**Abandoning only stopped between phases.** `installProject` takes a `stopped`
callback now, checked before every write loop, so leaving mid-run stops the
remaining items instead of just the remaining phases.
**A failed setup migration reported success.** `runMigrationsFor` swallowed the
error, so the wizard marked its "Run migrations" step done and closed over a
failure — leaving the data table name taken and no way back to retry. Rethrown,
which is what the wizard's checklist reads.
**`onboardingDestination` used a weaker redirect check.** `/\evil.com` passes
`startsWith('/') && !startsWith('//')` but WHATWG URL parsing resolves it to
another origin. Replaced with `toSameOriginRelativePath`, which already rejects
that, control characters and oversized values.
**Two workspace ids reached step 3 that the backend refuses:** a blank one (the
Continue gate never required `id.trim()`) and `global`, which
`check_w_id_conflict` rejects outright while `existsWorkspace` reports it free.
Also: `size="xs2"` → `unifiedSize="2xs"`, and two doc comments reattached to the
functions they describe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review round 3 — both regressions from round 2
**`resume` never rejected a run from a different plan.** The seed computed its
tag from the plan being rendered, so `run.key === planKey` was true by
construction and the guard could not fire — the comment claimed the opposite.
Finishing an import into workspace X, stepping back to pick workspace Y, then
returning showed X's finished checklist against Y's plan, with a Continue
button, over an import into Y that never happened. `ImportExecution.planTag`
now carries the plan the run was made for, and the seed uses that.
**Abandoning mid-import still reported `done`.** `installProject` returns early
when `stopped` goes true, and it returns exactly as it does on success, so the
tail of `#import` could not tell the two apart: a run stopped after 3 of 10
items wrote `import: done — 3 items`, no error, `done = true`. Since the page
hands that run back on return, the primary button became Continue rather than
Retry and the seven skipped items were silently lost — breaking the promise the
leave dialog makes. The tail now checks the flag and leaves the run failed and
retryable.
`abandon.test.ts` was a hand-written copy of the parking decision, which is why
it guarded neither. It now drives a real `ImportExecution` with the install seam
mocked, abandons from inside the write loop (the only way it happens — `run()`
clears the flag on entry so a retry can proceed), and asserts `done`, the error,
and both parking outcomes. `planTag` is covered too: different destination,
different project, and that the editable folder does not change it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review round 4 — unreviewed SQL, premature finish, stale Back
**Setup ran hub SQL nobody had seen.** Step 3 reviews the migrations it can run
there, but the ones deferred to setup went straight to `applyOneMigration`
against whatever database the wizard was pointed at — which can be an existing
resource holding unrelated objects. Each unconfigured row now carries a
disclosure showing exactly what will run, before "Set up" runs it.
**Finish was live while the setup decision was still outstanding.** For a project
with migrations but no resources, `execution.done` exposed the button while
`listDataTables` was still in flight and `setupNeeded` was still false — clicking
in that window left for the workspace and skipped a step the answer, a moment
later, said was needed. It now reads "Checking…" and is disabled until the check
settles.
**A reload on step 4 turned Back into a re-import.** `resume` only carries the
page's in-memory execution, so after a reload Back mounted a fresh step 3
offering Import over a bundle already in — and on a new workspace, a create that
now fails because the finished run cleared its parking. Back exists only while
the page still holds the run, which excludes exactly that case.
**`validateWorkspaceId` over-rejected a fork named `global`.** It reaches the
backend as `wm-fork-global`, which is accepted; only the effective id is checked
now, so a plain `global` is still refused. Covered by a test.
**An abandoned run left the migrate row spinning.** It is appended once the
review settles and set running by `onMigrationsStart`; stopping before its loop
left it on `running` forever, reading as work still in progress on a run that
had stopped.
Also moves the `run()` contract back onto `run()`, and gives `ImportSetupRow` an
optional `extra` snippet for detail that does not fit on one line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: close the other two routes back into a run-less import step
Round 4 gated the setup step's own Back button when the completed run was no
longer in memory, but that is the least used of three ways back into step 3, and
both reviewers landed on the same gap.
The stepper renders every earlier step as reachable, and `importIsRunning()` is
false after a reload, so its "Import" tab walked straight there. And `onFinish`
pushed step 4 over step 3, leaving the browser's own Back pointing at the same
place.
After a reload there is nothing to hand back: the executor was in memory, and a
clean finish clears the parking, so step 3 mounted with `resume` undefined and
offered a fresh run — re-importing a bundle already in (a wall of path
conflicts), or on a new workspace re-running a create that now fails as already
existing, with no Delete offered because that execution never made it.
`ImportWizardSteps` takes a `lowestStep`, which the page raises to 4 exactly
when the run is gone, and the step-3 → 4 transition replaces rather than pushes.
Verified against a real reload: the stepper stays on step 4 and says why, and
browser Back lands on step 2 with no runnable import.
Also adds the migration-phase abandonment assertion the review asked for — that
no task is left on `running` when a run stops.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: make the migration-phase abandonment test actually reach it
It asserted over a branch it never ran. The mock `installProject` never called
`onMigrationsStart`, and with `migrations: []` in the export and
`reviewMigrations` returning nothing, `#import` never appended the `migrate` row
at all — so "no task is left running" was true because no task existed. The
comment was wrong too: the real `onMigrationsStart` fires at the head of the
migration loop, past every item loop, not at the start of the writes.
The mock now mirrors that order — item loops, then `onMigrationsStart`, then the
migrations, with `stopped` checked before each write — and a second hook lets a
test abandon after the row is running. The export ships a migration and
`reviewMigrations` returns it, so the row exists to be pinned, and the test
asserts it exists before asserting its status.
Checked by removing the fix: it fails with `expected 'running' not to be
'running'`, and passes with it restored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: ask the instance what exists instead of remembering it
The wizard kept a note in `sessionStorage` — "this run created workspace X" —
so a reload could tell that a create had already happened. A note is a second
copy of a fact the instance already holds, and it could outlive the workspace
it named: the comment on `createdWorkspace` said a parked id might point at a
workspace someone else made at that id afterwards, and that there was no way to
tell, because a workspace carries no discriminator.
It carries `owner`. It is set to the creator's email at `INSERT INTO workspace`,
`listWorkspaces` already selects it, and the generated `Workspace` type already
has it. So the question the note was answering can simply be asked:
`probeWorkspace` returns whether a workspace with the plan's id exists among
the caller's, and whether they own it. Ownership is what makes adopting one
safe — an id that exists but belongs to someone else is not this run's work.
`parking.ts` and its test are gone. Nothing in the wizard writes storage now:
the plan is in the URL, what exists is in the instance, and what is in flight is
in memory, which is where in-flight things belong.
`probe.ts` also carries the two reads the follow-up needs — which of the paths
an import would write are already there, and whether a migration's tables exist.
The second is the ground truth for "did this migration run", covering both paths
`applyOneMigration` takes: it records a migration when the data table has them
enabled, and otherwise runs the SQL as a job nothing remembers. The tables
outlive both. It returns `undefined` rather than `false` when it cannot tell,
since "not there" invites a caller to run the migration and "cannot tell" does
not.
Verified against a real reload mid-run: the second attempt makes no
`createWorkspace` call, one `workspaces/list` call, and carries on to the fetch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: import only what the destination does not already have
A retry resent the whole bundle. Everything that had already landed came back
as "already exists" — nine failures over work that had succeeded, with no way
to tell those from the ones that genuinely failed. The same thing happened
importing into a workspace that already held some of the project.
`installProject` now takes `alreadyPresent`, checked after retargeting because
that is what the items will actually be called, and `probeImportedPaths` fills
it from the destination on every run. On a workspace the run just created the
answer is empty and nothing is skipped, so this costs four scoped reads and
changes nothing about a first import.
Skipping is not replacing. An item that is there is left exactly as it is —
the same promise `updateIfExists: false` already makes for a resource whose
value someone has since filled in.
`InstallResult` gains `skipped`, because "already there" is neither an import
nor a failure and reporting it as either is a lie. The checklist still lists
every item the project ships; a skipped one shows as skipped and says why. The
import row now counts the three outcomes separately — `8 already there` rather
than a green tick over `2 apps, 4 scripts, 2 resources` it did not write. That
last part needed the pre-run breakdown to stand down once the run has an
outcome of its own, or it went on claiming the import had happened.
Checked by removing the gate: two of the four new tests fail. Verified against
a real backend by re-importing Calendly into a workspace that already had it —
0 failures, 0 create requests, and the row reads "8 already there", where the
same run previously produced 9 conflicts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: skip triggers that are already in the destination
probeImportedPaths asked about scripts, flows, apps and resources but not
triggers, so a retry replayed every trigger create into an API that rejects
an existing path — reporting a failure for something already there, which
is the wall the presence probe exists to remove.
Triggers have no prefix-filtered list endpoint, so they cost one call per
kind; the probe only asks when the project actually ships triggers.
The presence set is now keyed by kind as well as path. The five kinds share
one f/<folder>/ namespace, so a trigger and a script may both be called
f/cal/sync, and a flat path set would let either one mask the other.
Also drops expectedPaths, which was exported and tested but never called.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: don't let a failed migration read as a finished setup
Three linked gaps around the step-4 data table setup:
Finish setup was clickable while the step was still loading. Empty rows and
blanks made outstanding === 0, which reads the same as having nothing to do,
so a quick click left the wizard before the missing data table was even
discovered. Skip already guarded on loading; Finish now does too.
When the data table wizard's appended migration step failed, run.result kept
runSetup's successful verdict, so the primary action offered Done over a
failed row and closing raised no warning. The failure is now tracked apart
from run.result, and Try again re-runs only the appended step — re-running
the setup would ask for the table name it just took and be refused.
A failed row in the import step reopened the full wizard, which rejected the
name it had itself created, leaving no way back to the migration that
actually failed. Such a row now offers "Run migrations again" instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: read the destination's real state instead of inferring it
Three ways step 4 could report work that had not happened:
AddDataTableWizard wrote through $workspaceStore while ImportSetupStep used
the workspace from the URL plan. The import page is reparented out of
(logged), so nothing re-runs the layout's workspace persistence; after a
reload the store still named the workspace the user came from. "Set up"
would then create the data table there and run the migrations in the
destination. The workspace is now a prop, defaulting to the store so every
other call site is unchanged.
load() marked a row done whenever the data table name existed. The wizard
creates the table and the migrations run after it, so a table can be there
with none of the project's tables inside it — and a reload rebuilds rows
from scratch, hiding the failure. It now asks probeMigrationApplied, which
already existed for exactly this question. An undefined answer ("cannot
tell") keeps whatever the row said rather than inventing an outstanding row.
A reviewed migration could fail in step 3 while the run still reported a
clean finish: the migrate row said failed, but `error` was set only from
item failures, and `error` is what offers Retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: keep the migration retry reachable after a reload
The retry-only action needed two things the reload did not have. load()
read the destination's data tables into a local set and dropped them, so
configuredNames was empty and the branch could not fire; it now seeds
configuredNames from the call it already makes.
And the branch keyed on the row saying `failed`, which only holds while the
failure is still in memory. A reload rebuilds every row from scratch, so the
same situation reads as `unconfigured`. It now keys on the data table
existing while its tables do not, which is the same state either way.
Without both, a reloaded failure sent the user back into the wizard, which
refuses the name it created — no way to reach the migration that failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* docs: state the constraint, not how the code got here
AGENTS.md: "Describe the code as it is, never its drafting history". Nine
comments across the wizard narrated what an earlier iteration did — "used
to remember", "The regression:", "would otherwise warn" — which says
nothing to a reader who never saw it. Each now states the durable reason
directly: what the code must hold to, and what breaks without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: page the presence probe, and keep a failed run retryable
probeImportedPaths called each list endpoint once. They paginate at 30 rows
by default, so it answered correctly for a small project and silently
under-reported a large one — every item past the first page went back
through a create call that rejects an existing path. It now pages at 100
until a short page, with a 100-page stop so an endpoint that never returns
one cannot loop.
And a run that finished with failures offered only Finish. `done` is what
the step reads as terminal, not `error`, so a failed migration left no way
to run the SQL again. Retry now sits beside Finish whenever the run reports
an error — beside rather than instead, so a migration that fails every time
cannot trap the user short of step 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: don't offer to discard a data table that was created
Closing the wizard after a failed appended step asked "Leave without adding
a data table?" and warned that what ran had left things behind. Every part
of that is false when the setup itself succeeded: the data table exists and
works, and only its migrations did not run.
hasUnfinishedIntent() now asks only whether the setup succeeded. The import
step is the only caller that passes onFinishAlso, and it shows that failure
on its own row with a way to run it again, and will not let Finish through
while it stands — so closing loses nothing.
The in-dialog "Try again" is unchanged; it is still the direct retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: address round 8 — trigger kinds, migration retry target, unknown state
Four findings, two of them real defects in this branch's own work.
The presence key flattened every trigger kind into `trigger`. Each kind is a
separate table keyed on (path, workspace_id), so a workspace can hold a
schedule and an HTTP trigger both called f/cal/sync; whichever existed
answered for the other and the second was reported "already there" without
being imported. The key now carries the kind, which both sides already had.
projectInstall's own doc makes this argument for the five top-level kinds —
it just stopped one level short.
The wizard's in-dialog "Try again" ran runMigrationsFor(wizardFor), but
afterWizard() clears wizardFor as soon as the failed run reports, while the
dialog stays up. It resolved against no row and the step was marked done
over SQL that never ran. The target is now held separately, and an unknown
name throws rather than resolving — a resolved promise is what the appended
step reads as success.
settle() resolved "cannot tell" to done exactly on the reload it was written
for. A data table whose database is unreachable read as Configured and the
step said "You're all set" over a project whose apps fail on open. There is
now an `unknown` state that says so and still counts as outstanding. It also
asked for one full schema per migration; migrations for one data table all
target the same schema, so probeMigrationsApplied reads it once.
run()'s doc still described the pre-probe retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: address round 9 — abandon during the probe, and copy that outlived it
Abandoning while probeImportedPaths was in flight returned without settling
anything. `import` goes running before the probe is asked, so the checklist
kept a spinner on a run that had stopped, beside an enabled Retry and with
no explanation. The settling the post-installProject path already did is now
a helper both paths call.
Three pieces of copy still described the behaviour this branch replaced:
the resource alert said an existing path is "reported as failed" when the
probe now leaves it alone and reports it as already there; and the step-4
footer and skip confirmation both told the user to set up a data table that
the new `unknown` state means they already set up — only its schema could
not be read. Those two now branch, so the strong warning stays strong for a
data table that genuinely does not exist.
The presence-key doc named `trigger:http_trigger`; WorkspaceTriggerKind has
no such value. It is `http`, in the comment and in the two test mocks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: never rerun SQL whose applied state is unknown
`unknown` covers two different unknowns, and this treated them as one. The
schema could not be read, or the SQL names no table `expectedTables` can
resolve — and the second is arbitrary published SQL, which may carry a
non-idempotent INSERT or ALTER. The row offered "Run migrations" and the
footer claimed rerunning was safe; both were claims this code cannot make.
An unknown row now offers "Check again", which re-reads and executes
nothing. That settles the case which actually recovers — a database briefly
unreachable — and leaves Skip, which states the uncertainty, as the way past
one that does not.
The partitions behind the copy also missed `failed` rows entirely: the
footer rendered a title with no body, and Skip described them as unreadable.
Both now group by what it costs the project — tables that are missing
(never created, or a migration that failed) against tables that could not be
verified — which is also what makes the sentences true: a failed row is
configured, so "this data table does not exist yet" was wrong about it.
Skip and the footer now read the same partition instead of each computing
one, so they cannot disagree again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* docs: the SQL disclosure should not promise a run that cannot happen
An unknown row's only action re-reads the schema; nothing executes its SQL.
The summary still said "Show the SQL this will run", which is the sentence
the previous commit removed from the footer for the same reason. On those
rows it now says what the SQL is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: a row whose migrations are running does not offer "Set up"
Found by walking every branch on row.status rather than the ones I
remembered: `running` falls through to the catch-all action, which labelled
itself "Set up" in accent. Disabled, so nothing could come of it, but it is
the same label-outruns-state mistake the last rounds were spent on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: never fill a path a resource of another type already holds
The presence probe matches on path, and a path says nothing about type. A
workspace resource of another kind sitting where the project wanted one of
ours was skipped as "already there", then read for missing fields against
the *project's* expected schema — so it looked like an empty stub, offered
Connect, and had its value replaced with credentials for a different
provider while keeping its own type. A working resource unrelated to the
import, destroyed.
Guarded at both ends. AppConnectInner checks the occupant's type before
updating, because `fillPath` only says "write into this path" and a caller
cannot be trusted to have checked. And the setup step records the conflict,
so the row explains that the project did not get the resource it shipped and
offers no action at all — every action there writes to that path.
Such a row is always listed, however full the occupant's value looks: it is
the only thing that tells the user something is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: fail closed everywhere the import cannot tell
Codex was right that the occupant-type guard failed open: a getResource
that threw became `undefined`, which passed the mismatch test and left
filling enabled — so a transient read failure still overwrote the resource
the guard exists to protect. Only a read that succeeds and answers with
exactly this type now permits the write; a failed read, a missing type and
any other type all refuse.
That was the same "cannot tell, so proceed" this branch already fixed once
in settle(), so the rest of the wizard was swept for it. Two more:
findBlankResources dropped a row whenever getResource threw, on the
assumption that meant absent. Only a 404 means absent — and that failure the
import already reported. Any other error is a read that did not complete,
which says nothing about whether the credential needs filling; dropping the
row reports "all set" over one nobody filled. The row now stays and offers
no action, since none of them can be safe about a path this cannot read.
A resource type whose schema would not load left `required` empty, which
reads as "nothing missing" — so a half-filled resource passed as done. It
stays on the checklist; it just cannot name which fields are short.
The other four catches were checked and are already closed in the right
direction: probeWorkspace reports absent so the caller creates rather than
adopts, probeMigrationsApplied answers undefined which settles to a
non-actionable row, and afterWizard keeps whatever the run last said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: a refresh takes every field the fresh read decides
`refreshBlanks` merged only `missing` out of the new scan, so the two fields
added alongside it were left at whatever the row said before. Both reviewers
found the same seam from opposite ends: a resource that had just become
unreadable kept its old readable-looking row, and one that had come back
stayed blocked until a reload.
These fields describe what is at the path now, so the fresh read owns all of
them — and the branch that marks a row done clears them, because a row that
has left the blank list was read and is filled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: resolve the username in the destination, and lock a targeted name
Two P1s, both from the same earlier fix being half-done. Routing this
wizard's API calls through an explicit workspace left `$userStore` behind,
and that store describes the workspace the app is in. After a reload on
step 4 it names the workspace the user came from, so a resource path built
from it lands on `u/<someone-else>` inside the destination — failing an
ownership check, or for an admin, quietly putting database credentials in
another member's namespace. The membership is now resolved for the target
workspace, the way FolderPicker already did it.
And `initialName` was documented as "a starting point, not a lock" while
`onFinishAlso` targets that exact name. Renaming `main` to `other` created
`other`, ran the migrations against `main`, failed, and left a data table
nobody asked for. The field is locked when a caller passes follow-up work
bound to the name, and says why; without one it stays editable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: resolve membership before seeding, and hold the name lock for the dialog
Two follow-ons to the previous commit, both where a value is read live that
should have been settled once.
The username was fetched in an effect while `open()` reset the wizard
immediately, so `defaultFolder()` ran against an empty username and seeded
`u/admin`. It was corrected only if `whoami` happened to win a race against
the folder list, and never if `whoami` failed — which is the case that
matters, since an admin would then save database credentials in another
member's namespace. `open()` now awaits the membership before reset, and a
destination whose membership cannot be read blocks setup outright rather
than guessing a path.
And the name lock read the live `initialName`, which is the caller's
`wizardFor` — cleared from `onDone`, which fires after a *failed* run too,
while the dialog stays up offering Back. The lock released exactly when the
user was most likely to edit the name, so the rename-then-retry path still
diverged from the migration target. It is captured at reset, for the life of
the dialog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: do not show the data table dialog before it knows the destination
`open()` became async so it could resolve the destination's membership
before seeding a resource path from it. But `openWizard` still set
`wizardOpen` first, and that is bound to the dialog's `opened` — so the
dialog was mounted, visible and clickable for the whole lookup, with the
username unresolved and `membershipFailed` not yet set. Setup reached in
that window writes exactly the wrong-namespace path the await was added to
prevent, and a late response could reset a dialog the user had already
touched or closed.
`open()` sets `opened` itself, once it has an answer. `wizardFor` alone
mounts the component, which is all `wizard?.open()` needs to exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: honour the configured base path, and guard a second Set up click
Windmill can be served under a prefix (`paths.base`, from VITE_BASE_URL),
and four import entrypoints compared or emitted `/projects/import` without
it. Under a base of `/windmill` the real pathname is
`/windmill/projects/import`, so the layout's picker exemption and both login
redirect checks stopped matching and sent people through the workspace
picker — and the compatibility redirect emitted a path outside the base
entirely, which is a 404. All four are now built from `base`.
`Login.svelte` takes it from `$lib/base` rather than `$app/paths` because it
already did; both read VITE_BASE_URL, and importing the second name into
that file collides with the first.
And the previous commit left Set up clickable while `open()` resolves the
destination membership, deliberately — but with no guard, a second click
starts a second lookup whose `reset()` lands on the dialog the first one
opened, wiping fields already filled. The action is disabled while a dialog
is opening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
windmill-internal-app[bot]
parent
52ca19e9ae
commit
29133398f9
@@ -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)
|
||||
})
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
on:close={() => {
|
||||
step = 1
|
||||
handedOff = false
|
||||
dispatch('close')
|
||||
}}
|
||||
size="700px"
|
||||
@@ -83,22 +104,26 @@
|
||||
on:refresh
|
||||
express={expressOAuthSetup}
|
||||
{workspace}
|
||||
{fillPath}
|
||||
/>
|
||||
{#snippet actions()}
|
||||
<div class="flex gap-1">
|
||||
{#if step > 1}
|
||||
<Button variant="default" on:click={appConnectInner?.back ?? (() => {})}>Back</Button>
|
||||
<!-- Only when the user came through the type picker: opening the drawer for one
|
||||
resource type skips step 1, so Back would land on a list they never chose from. -->
|
||||
{#if step > 1 && !rtToLoad}
|
||||
<Button variant="default" unifiedSize="md" onClick={() => appConnectInner?.back()}>
|
||||
Back
|
||||
</Button>
|
||||
{/if}
|
||||
{#if isGoogleSignin}
|
||||
<button {disabled} onclick={appConnectInner?.next}>
|
||||
<img
|
||||
class="h-10 w-auto object-contain"
|
||||
src={darkMode ? '/google_signin_dark.png' : '/google_signin_light.png'}
|
||||
alt="Google sign-in"
|
||||
/>
|
||||
</button>
|
||||
<GoogleSigninButton {disabled} onClick={() => appConnectInner?.next()} />
|
||||
{:else}
|
||||
<Button variant="accent" {disabled} on:click={appConnectInner?.next ?? (() => {})}>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
{disabled}
|
||||
onClick={() => appConnectInner?.next()}
|
||||
>
|
||||
{#if step == 2 && !manual}
|
||||
Connect
|
||||
{:else if step == 1}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
type ResourceType
|
||||
} from '$lib/gen'
|
||||
import { emptyString, truncateRev, urlize } from '$lib/utils'
|
||||
import oauthConnectRegistry from '$oauth_connect_registry'
|
||||
import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry'
|
||||
import { createEventDispatcher, onDestroy, tick, untrack } from 'svelte'
|
||||
import Path from './Path.svelte'
|
||||
import { Button, RadioCard, Skeleton } from './common'
|
||||
@@ -52,6 +52,15 @@
|
||||
manual?: boolean
|
||||
express?: boolean
|
||||
workspace?: string
|
||||
/**
|
||||
* Fill an existing resource instead of creating one. The path is fixed to it and the
|
||||
* "already exists" guard becomes an update, so a caller holding a resource that is
|
||||
* already there — the import wizard's empty stubs — can connect into it rather than
|
||||
* making the user delete it first and retype the path.
|
||||
*
|
||||
* Opt-in: without it this flow still refuses to write over anything.
|
||||
*/
|
||||
fillPath?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -61,7 +70,8 @@
|
||||
disabled = $bindable(false),
|
||||
manual = $bindable(true),
|
||||
express = false,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
fillPath = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
||||
@@ -98,10 +108,6 @@
|
||||
return connectsInfo[key]?.has_shared_credentials ?? false
|
||||
}
|
||||
|
||||
const SANDBOX_SUFFIX = '_sandbox'
|
||||
function stripSandboxSuffix(name: string): string {
|
||||
return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name
|
||||
}
|
||||
// `resourceType` is always the canonical type (e.g. `docusign`) so resource
|
||||
// rows are uniform. `connectClient` carries the suffixed OAuth client name
|
||||
// (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime
|
||||
@@ -195,16 +201,16 @@
|
||||
let resourceTypeInfo: ResourceType | undefined = $state(undefined)
|
||||
let resourceTypeNotFound = $state(false)
|
||||
|
||||
// Both resolve `_sandbox` clients to their parent entry (e.g. salesforce_sandbox ->
|
||||
// salesforce) so sandbox connections see the same metadata. Shared with callers that
|
||||
// decide whether to open this dialog at all, so the two cannot disagree.
|
||||
function registryEntry(): any {
|
||||
const reg = oauthConnectRegistry as Record<string, any>
|
||||
// Resolve `_sandbox` clients to their parent registry entry (e.g.
|
||||
// salesforce_sandbox -> salesforce) so sandbox connections see CC metadata.
|
||||
return reg[stripSandboxSuffix(connectClient)] ?? reg[stripSandboxSuffix(resourceType)]
|
||||
return registryEntryFor(connectClient, resourceType)
|
||||
}
|
||||
|
||||
/** The static registry declares this provider supports client credentials */
|
||||
function registryCcCapable(): boolean {
|
||||
return registryEntry()?.grant_types?.includes('client_credentials') ?? false
|
||||
return registryCcCapableFor(connectClient, resourceType)
|
||||
}
|
||||
|
||||
/** Instance-name metadata for providers whose token URL is instance-templated
|
||||
@@ -289,11 +295,7 @@
|
||||
|
||||
/** Static registry declares client-credentials support for `key`. */
|
||||
function isCcCapable(key: string): boolean {
|
||||
return (
|
||||
(oauthConnectRegistry as Record<string, any>)[stripSandboxSuffix(key)]?.grant_types?.includes(
|
||||
'client_credentials'
|
||||
) ?? false
|
||||
)
|
||||
return registryCcCapableFor(key)
|
||||
}
|
||||
|
||||
/** Step-1 "Others" selection: CC-capable resource types open the client-
|
||||
@@ -553,8 +555,9 @@
|
||||
valueToken = data.res
|
||||
responseExtra = data.extra ?? {}
|
||||
step = 4
|
||||
if (express) {
|
||||
path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
||||
// `fillPath` decides the path as surely as express does, so neither stops here.
|
||||
if (fillPath || express) {
|
||||
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
||||
next()
|
||||
}
|
||||
}
|
||||
@@ -689,8 +692,8 @@
|
||||
grant_type: 'client_credentials' // Mark this token as client_credentials
|
||||
}
|
||||
step = 4
|
||||
if (express) {
|
||||
path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
||||
if (fillPath || express) {
|
||||
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
||||
next()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -749,7 +752,41 @@
|
||||
path
|
||||
})
|
||||
|
||||
if (exists) {
|
||||
// Filling one names its path up front; anything else reaching an occupied path got
|
||||
// there by the user typing it, which is the case worth refusing.
|
||||
//
|
||||
// The type is checked here and not only by the caller: `fillPath` says "write into
|
||||
// this path", and a path says nothing about what lives at it. A workspace resource
|
||||
// of another type sitting where the project wanted one of ours would otherwise have
|
||||
// its value replaced with credentials for a different provider, while keeping its
|
||||
// own type — destroying a working resource that has nothing to do with the import.
|
||||
const filling = exists && !!fillPath && path === fillPath
|
||||
if (filling) {
|
||||
// Fails closed. Only a read that succeeds and answers with exactly this type
|
||||
// permits the write — a failed read, a missing type, or any other type all
|
||||
// refuse. Letting "could not tell" through is how the overwrite this guard
|
||||
// exists to stop would happen anyway, on the one occasion the check was needed
|
||||
// and could not run.
|
||||
let occupantType: string | undefined
|
||||
try {
|
||||
occupantType = (
|
||||
await ResourceService.getResource({ workspace: effectiveWorkspace, path })
|
||||
)?.resource_type
|
||||
} catch (e: any) {
|
||||
throw Error(
|
||||
`Could not read what is already at ${path} (${e?.body ?? e?.message ?? e}), ` +
|
||||
`so it will not be written over. Try again.`
|
||||
)
|
||||
}
|
||||
if (occupantType !== resourceType) {
|
||||
throw Error(
|
||||
`Resource at path ${path} is ${
|
||||
occupantType ? `a ${occupantType} resource` : 'of an unknown type'
|
||||
}, not ${resourceType}. Move or rename it, then import again.`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (exists && !filling) {
|
||||
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
|
||||
}
|
||||
|
||||
@@ -760,8 +797,7 @@
|
||||
// the user entered in `ccInstance` (raw, possibly a full host); the shared
|
||||
// path carries it (already normalized) in the connect entry's extra_params.
|
||||
// Prefer the user-entered one so the saved resource matches the exchange.
|
||||
const connectTemplate = (oauthConnectRegistry as Record<string, any>)[resourceType]
|
||||
?.connect_config_template
|
||||
const connectTemplate = registryEntryFor(resourceType)?.connect_config_template
|
||||
if (connectTemplate?.resource_mapping) {
|
||||
const instanceKey = connectTemplate.extra_params_key ?? 'instance'
|
||||
let instanceValue = extra_params.find(([key, _]) => key === instanceKey)?.[1] ?? ''
|
||||
@@ -895,17 +931,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
await ResourceService.createResource({
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: {
|
||||
resource_type: resourceType,
|
||||
if (filling) {
|
||||
// The stub the import made carries no description, so this is the one chance to
|
||||
// give it one; its resource_type and path are already what we want.
|
||||
await ResourceService.updateResource({
|
||||
workspace: effectiveWorkspace,
|
||||
path,
|
||||
value: resourceValue,
|
||||
description,
|
||||
labels,
|
||||
ws_specific: wsSpecific
|
||||
}
|
||||
})
|
||||
requestBody: { value: resourceValue, description }
|
||||
})
|
||||
} else {
|
||||
await ResourceService.createResource({
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: {
|
||||
resource_type: resourceType,
|
||||
path,
|
||||
value: resourceValue,
|
||||
description,
|
||||
labels,
|
||||
ws_specific: wsSpecific
|
||||
}
|
||||
})
|
||||
}
|
||||
dispatch('refresh', path)
|
||||
dispatch('close')
|
||||
sendUserToast(
|
||||
@@ -1395,7 +1441,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2 mb-2">
|
||||
<!-- role=radiogroup: the cards below carry `role="radio"`, which a screen
|
||||
reader can only place ("2 of 2") inside a named group. -->
|
||||
<div
|
||||
class="flex flex-col gap-2 mb-2"
|
||||
role="radiogroup"
|
||||
aria-label="How to authenticate"
|
||||
>
|
||||
<RadioCard
|
||||
label={`Sign in through ${resourceType}`}
|
||||
description="Opens a browser window to log in and authorize. Connects as you."
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import AppConnectInner from '$lib/components/AppConnectInner.svelte'
|
||||
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import GoogleSigninButton from '$lib/components/GoogleSigninButton.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
|
||||
@@ -16,6 +17,7 @@
|
||||
let step = $state(1)
|
||||
let disabled = $state(false)
|
||||
let manual = $state(true)
|
||||
let isGoogleSignin = $state(false)
|
||||
|
||||
let appConnect: AppConnectInner | undefined = $state(undefined)
|
||||
|
||||
@@ -41,18 +43,24 @@
|
||||
<div class="flex flex-row-reverse w-full pb-2 shrink-0">
|
||||
<div class="flex gap-2">
|
||||
{#if step > 2}
|
||||
<Button variant="default" on:click={appConnect?.back ?? (() => {})}>Back</Button>
|
||||
<Button variant="default" unifiedSize="md" onClick={() => appConnect?.back()}>
|
||||
Back
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button variant="accent" {disabled} on:click={appConnect?.next ?? (() => {})}>
|
||||
{#if step == 2 && !manual}
|
||||
Connect
|
||||
{:else if step == 1}
|
||||
Next
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
{#if isGoogleSignin}
|
||||
<GoogleSigninButton {disabled} onClick={() => appConnect?.next()} />
|
||||
{:else}
|
||||
<Button variant="accent" unifiedSize="md" {disabled} onClick={() => appConnect?.next()}>
|
||||
{#if step == 2 && !manual}
|
||||
Connect
|
||||
{:else if step == 1}
|
||||
Next
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -64,6 +72,7 @@
|
||||
bind:resourceType
|
||||
bind:disabled
|
||||
bind:manual
|
||||
bind:isGoogleSignin
|
||||
on:error
|
||||
on:refresh
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
</p>
|
||||
{/if}
|
||||
{#if subtitleSnippet}
|
||||
<div class="text-center mt-2">{@render subtitleSnippet()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if children}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { FolderService } from '$lib/gen'
|
||||
import { FolderService, UserService, type User } from '$lib/gen'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { isDemoWorkspaceRestricted } from '$lib/cloud'
|
||||
import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte'
|
||||
@@ -14,10 +14,6 @@
|
||||
|
||||
const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/
|
||||
|
||||
const restricted = $derived(
|
||||
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
|
||||
)
|
||||
|
||||
let folders: { name: string; write: boolean }[] = $state([])
|
||||
let filterText: string = $state('')
|
||||
let selectOpen: boolean = $state(false)
|
||||
@@ -38,6 +34,10 @@
|
||||
size?: 'sm' | 'md'
|
||||
drawerOffset?: number
|
||||
selectInputClass?: string
|
||||
/** List and create folders in this workspace instead of the active one. For a
|
||||
* screen that targets a workspace it has not switched to — the project import
|
||||
* wizard picks a destination and only enters it when the import runs. */
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -47,9 +47,24 @@
|
||||
disableEditing = $bindable(undefined),
|
||||
size = 'md',
|
||||
drawerOffset = 0,
|
||||
selectInputClass
|
||||
selectInputClass,
|
||||
workspace
|
||||
}: Props = $props()
|
||||
|
||||
const targetWorkspace = $derived(workspace ?? $workspaceStore ?? '')
|
||||
|
||||
// `$userStore` describes the workspace the app is *in*. When this picker is aimed
|
||||
// somewhere else, those memberships answer the wrong question — and since a folder
|
||||
// without write access renders disabled, a stale answer makes the real folders
|
||||
// unpickable. Resolve the membership for the workspace actually being listed.
|
||||
let targetUser: User | undefined = $state(undefined)
|
||||
const aimedElsewhere = $derived(!!workspace && workspace !== $workspaceStore)
|
||||
const membership = $derived(aimedElsewhere ? targetUser : ($userStore ?? undefined))
|
||||
|
||||
const restricted = $derived(
|
||||
isDemoWorkspaceRestricted(targetWorkspace, membership?.is_admin, membership?.is_super_admin)
|
||||
)
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
loadingFolders = true
|
||||
try {
|
||||
@@ -65,16 +80,16 @@
|
||||
folders = initialFolders.concat(
|
||||
(
|
||||
await FolderService.listFolderNames({
|
||||
workspace: $workspaceStore!
|
||||
workspace: targetWorkspace
|
||||
})
|
||||
)
|
||||
.filter((x) => !excludedFolders.includes(x))
|
||||
.map((x) => ({
|
||||
name: x,
|
||||
write:
|
||||
$userStore?.folders?.includes(x) == true ||
|
||||
($userStore?.is_admin ?? false) ||
|
||||
($userStore?.is_super_admin ?? false)
|
||||
membership?.folders?.includes(x) == true ||
|
||||
(membership?.is_admin ?? false) ||
|
||||
(membership?.is_super_admin ?? false)
|
||||
}))
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -97,20 +112,27 @@
|
||||
creating = true
|
||||
try {
|
||||
await FolderService.createFolder({
|
||||
workspace: $workspaceStore ?? '',
|
||||
workspace: targetWorkspace,
|
||||
requestBody: { name: newFolderName }
|
||||
})
|
||||
folderCreated = newFolderName
|
||||
await loadFolders()
|
||||
folderName = newFolderName
|
||||
|
||||
// Writing $userStore.folders = [...] would call userStore.set(),
|
||||
// which re-triggers Path.svelte's $effect.pre and calls initPath()/reset(),
|
||||
// switching the owner toggle from "Folder" back to "User".
|
||||
if ($userStore) {
|
||||
// The creator owns what they just created. Recorded on whichever membership
|
||||
// this picker is reading, and *before* reloading, so the new folder comes
|
||||
// back selectable rather than `(read-only)` — `loadFolders` derives `write`
|
||||
// from exactly this.
|
||||
if (aimedElsewhere) {
|
||||
if (targetUser) targetUser.folders = [...(targetUser.folders ?? []), newFolderName]
|
||||
} else if ($userStore) {
|
||||
// Writing $userStore.folders = [...] would call userStore.set(),
|
||||
// which re-triggers Path.svelte's $effect.pre and calls initPath()/reset(),
|
||||
// switching the owner toggle from "Folder" back to "User".
|
||||
if (!$userStore.folders) $userStore.folders = []
|
||||
$userStore.folders.push(newFolderName)
|
||||
}
|
||||
|
||||
await loadFolders()
|
||||
folderName = newFolderName
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not create folder: ${e}`, true)
|
||||
} finally {
|
||||
@@ -149,7 +171,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
loadFolders()
|
||||
async function loadTargetUser(): Promise<void> {
|
||||
if (!workspace || workspace === $workspaceStore) return
|
||||
try {
|
||||
targetUser = await UserService.whoami({ workspace })
|
||||
} catch {
|
||||
// Not a member, or the call failed: every folder stays read-only, which is
|
||||
// the safe reading — the import would be refused anyway.
|
||||
targetUser = undefined
|
||||
}
|
||||
}
|
||||
|
||||
loadTargetUser().then(loadFolders)
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={newFolder} name="newFolder" offset={drawerOffset}>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Button, type ButtonType } from '$lib/components/common'
|
||||
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
unifiedSize?: ButtonType.UnifiedSize
|
||||
onClick?: ((e?: Event) => void) | undefined
|
||||
}
|
||||
|
||||
let { disabled = false, unifiedSize = 'md', onClick = undefined }: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- developers.google.com/identity/branding-guidelines requires Google's own branding on the
|
||||
control that starts a Google sign-in, and allows a custom button for it as long as the G
|
||||
mark is unaltered and the label is one of theirs. So this is a design-system Button rather
|
||||
than the PNG Google ships, which carried no hover, focus or disabled state and stood at a
|
||||
different height than the buttons beside it. -->
|
||||
<Button variant="default" {unifiedSize} {disabled} startIcon={{ icon: GoogleIcon }} {onClick}>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts" module>
|
||||
export interface ImportProjectSummary {
|
||||
slug: string
|
||||
name: string
|
||||
summary: string
|
||||
author: string
|
||||
/** Integration slugs, for the chips and the fallback icons. */
|
||||
apps: string[]
|
||||
/** The project's uploaded logo, when it has one. */
|
||||
logoUrl?: string
|
||||
/** Integration slugs to draw, most representative first. */
|
||||
iconApps: string[]
|
||||
counts: { apps: number; flows: number; scripts: number; resources: number }
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ExternalLink, LayoutGrid } from 'lucide-svelte'
|
||||
import ProjectContentBadges from '$lib/components/ProjectContentBadges.svelte'
|
||||
import { hubAppIcon } from '$lib/hubProject'
|
||||
|
||||
interface Props {
|
||||
project: ImportProjectSummary
|
||||
/** Where the project is coming from, shown next to the author. */
|
||||
hubHost?: string
|
||||
}
|
||||
|
||||
let { project, hubHost = 'hub.windmill.dev' }: Props = $props()
|
||||
|
||||
// Protocol-relative on purpose: the same hub is https in production and plain
|
||||
// http when it's a local dev instance, and this way the link follows whichever
|
||||
// scheme the page itself was served over.
|
||||
const hubProjectUrl = $derived(`//${hubHost}/projects/${project.slug}`)
|
||||
|
||||
// Resolved locally rather than fetched: these are Windmill's own bundled icons, so the
|
||||
// card draws them synchronously instead of waiting on the hub — and keeps working on a
|
||||
// hub that refuses uncredentialed reads.
|
||||
const icons = $derived(
|
||||
project.iconApps
|
||||
.slice(0, 4)
|
||||
.map(hubAppIcon)
|
||||
.filter((c): c is NonNullable<typeof c> => !!c)
|
||||
)
|
||||
|
||||
// The icon row shows the integrations the tile is not already showing: with an
|
||||
// uploaded logo the tile shows none of them, so the row shows them all.
|
||||
const restIcons = $derived(project.logoUrl ? icons : icons.slice(1))
|
||||
</script>
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<!-- No border: the subject of the page, not one card among the choices below,
|
||||
which are the things with edges because they are selectable. -->
|
||||
<div class="w-full">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- The project's own logo when it has one, otherwise the icon of the
|
||||
integration it is filed under, otherwise a neutral placeholder. -->
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center">
|
||||
{#if project.logoUrl}
|
||||
<img src={project.logoUrl} alt="" class="max-h-10 max-w-10 object-contain" />
|
||||
{:else if icons[0]}
|
||||
{@const Icon = icons[0]}
|
||||
<span class="inline-flex h-7 w-7 text-primary [&>svg]:h-full [&>svg]:w-full">
|
||||
<Icon size={28} />
|
||||
</span>
|
||||
{:else}
|
||||
<LayoutGrid size={22} class="text-secondary" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- The title carries the link back to the hub page, so the card can state
|
||||
where the project comes from without spending a line on the URL. The
|
||||
arrow only appears on hover — at rest the title reads as a title. -->
|
||||
<a
|
||||
href={hubProjectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group inline-flex max-w-full items-center gap-1 text-xs font-semibold text-emphasis hover:underline"
|
||||
>
|
||||
<span class="truncate">{project.name}</span>
|
||||
<ExternalLink
|
||||
size={13}
|
||||
class="shrink-0 text-tertiary opacity-0 transition group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-secondary">{project.summary}</p>
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
by <span class="font-medium text-secondary">{project.author}</span>
|
||||
· <span class="font-mono">{project.slug}</span>
|
||||
</p>
|
||||
|
||||
<!-- What the import will create, aligned under the title rather than in a
|
||||
band of its own: the counts belong to the project above them. -->
|
||||
<div class="mt-3">
|
||||
<ProjectContentBadges counts={project.counts} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The integrations, minus whichever one is already standing in as the logo. -->
|
||||
{#if restIcons.length > 0}
|
||||
<div class="flex shrink-0 items-center gap-1.5 pt-0.5">
|
||||
{#each restIcons as Icon, i (i)}
|
||||
<span class="inline-flex h-4 w-4 text-primary opacity-80 [&>svg]:h-full [&>svg]:w-full">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The connector to what follows lives in the page, not here: what comes next is
|
||||
either the new-workspace offer or the workspace list, and only the page knows
|
||||
which. -->
|
||||
</div>
|
||||
@@ -0,0 +1,524 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import FolderPicker from '$lib/components/FolderPicker.svelte'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { contentSummary } from '$lib/components/ProjectContentBadges.svelte'
|
||||
import type { ProjectMigration } from '$lib/components/workspaceSettings/projectBundle'
|
||||
import MigrationSqlEditor from '$lib/components/workspaceSettings/MigrationSqlEditor.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { ImportExecution, plannedTasks } from '$lib/importWizard/execution.svelte'
|
||||
import SetupChecklist, { type SetupStep } from '$lib/components/wizards/SetupChecklist.svelte'
|
||||
import { beforeNavigate, goto } from '$app/navigation'
|
||||
import { untrack } from 'svelte'
|
||||
import { FOLDER_NAME_RE, planProblem, type ImportPlan } from '$lib/importWizard/plan'
|
||||
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
|
||||
import { ArrowLeft, Download, Loader2 } from 'lucide-svelte'
|
||||
|
||||
// The last step: it shows the plan, and it is the only step that can act on it.
|
||||
// All the acting lives in ImportExecution — this file decides what the user sees
|
||||
// and supplies the one interaction the run needs (the migration review).
|
||||
interface Props {
|
||||
plan: ImportPlan
|
||||
/** From the hub, for the counts — the export is only fetched during the run. */
|
||||
project?: ImportProjectSummary
|
||||
onFolderChange: (folder: string) => void
|
||||
onFinish: () => void
|
||||
/** True once the run reveals data tables the destination has yet to configure. */
|
||||
setupPending?: boolean
|
||||
/** The page has not settled whether a setup step follows. Finishing now would skip it. */
|
||||
setupUndecided?: boolean
|
||||
/** Hands the run to the page, which needs the export's data tables to know
|
||||
* whether a setup step follows this one. */
|
||||
onExecution?: (execution: ImportExecution | undefined) => void
|
||||
/**
|
||||
* The run this step already made, handed back when it is remounted. Step 4 unmounts
|
||||
* this component, so returning from it would otherwise arrive at a fresh step with no
|
||||
* run — offering Import again over a bundle that is already in, and on a new
|
||||
* workspace failing at create because the finished run cleared its parking.
|
||||
*/
|
||||
resume?: ImportExecution | undefined
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
plan,
|
||||
project,
|
||||
onFolderChange,
|
||||
onFinish,
|
||||
onBack,
|
||||
setupPending = false,
|
||||
setupUndecided = false,
|
||||
onExecution,
|
||||
resume
|
||||
}: Props = $props()
|
||||
|
||||
let folder = $state(plan.folder ?? plan.slug)
|
||||
// The workspace the import will land in, when it is one that already exists. A
|
||||
// `new` destination has no workspace to list folders from until the run creates it.
|
||||
const existingWorkspace = $derived(
|
||||
plan.destination?.kind === 'existing' ? plan.destination.workspaceId : undefined
|
||||
)
|
||||
|
||||
// The picker binds `folder`, so there is no blur event to report on. Mirror every
|
||||
// settled change into the plan. The page replaces rather than pushes for this
|
||||
// (`go(..., { replace: true })`), because a mirrored field is not a step the Back
|
||||
// button should have to walk through; a value equal to what the plan already holds
|
||||
// is skipped so this cannot loop.
|
||||
$effect(() => {
|
||||
const next = folder.trim()
|
||||
if (next && next !== (plan.folder ?? '')) onFolderChange(next)
|
||||
})
|
||||
const folderValid = $derived(FOLDER_NAME_RE.test(folder.trim()))
|
||||
const problem = $derived(planProblem({ ...plan, folder: folder.trim() }))
|
||||
|
||||
// --- the run ---------------------------------------------------------------
|
||||
let reviewDrawer = $state<Drawer | undefined>()
|
||||
let reviewList = $state<
|
||||
{ datatable_name: string; sql: string; sql_down: string; run: boolean }[]
|
||||
>([])
|
||||
// Bumped per review session so the Monaco editors re-mount with the new SQL.
|
||||
let reviewGeneration = $state(0)
|
||||
/** `abort` stops the whole import; `false` only skips the migrations. */
|
||||
let reviewResolve: ((run: boolean | 'abort') => void) | undefined
|
||||
|
||||
function openMigrationReview(migs: ProjectMigration[]): Promise<boolean | 'abort'> {
|
||||
reviewList = migs.map((m) => ({
|
||||
datatable_name: m.datatable_name,
|
||||
sql: m.sql,
|
||||
sql_down: m.sql_down ?? '',
|
||||
run: true
|
||||
}))
|
||||
reviewGeneration++
|
||||
reviewDrawer?.openDrawer()
|
||||
return new Promise<boolean | 'abort'>((resolve) => (reviewResolve = resolve))
|
||||
}
|
||||
function closeMigrationReview(run: boolean) {
|
||||
// Capture + clear first so the `on:close` fired by closeDrawer() (which would
|
||||
// call this again with run=false) can't override an explicit Run/Skip choice.
|
||||
const resolve = reviewResolve
|
||||
reviewResolve = undefined
|
||||
reviewDrawer?.closeDrawer()
|
||||
resolve?.(run)
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrations are keyed by data table name and only apply to a target table of
|
||||
* the same name. Returns what to run, or null to abort the import.
|
||||
*/
|
||||
async function reviewMigrations(
|
||||
workspace: string,
|
||||
migrations: ProjectMigration[]
|
||||
): Promise<ProjectMigration[] | null> {
|
||||
const enabled = migrations.filter((m) => m.enabled && (m.sql ?? '').trim() !== '')
|
||||
if (enabled.length === 0) return []
|
||||
|
||||
let present: Set<string>
|
||||
try {
|
||||
present = new Set((await WorkspaceService.listDataTables({ workspace })).map((d) => d.name))
|
||||
} catch {
|
||||
// Can't read the target's data tables — skip migrations rather than guess.
|
||||
return []
|
||||
}
|
||||
// A migration whose data table does not exist here is not a reason to stop: the
|
||||
// setup step after this one configures those tables and runs them. Only the
|
||||
// ones that can run now are worth reviewing.
|
||||
const runnable = enabled.filter((m) => present.has(m.datatable_name))
|
||||
if (runnable.length === 0) return []
|
||||
const run = await openMigrationReview(runnable)
|
||||
// `abort` is the teardown case: the step is gone, so stop rather than import the
|
||||
// items without the tables the review was about.
|
||||
if (run === 'abort') return null
|
||||
if (!run) return []
|
||||
return reviewList
|
||||
.filter((r) => r.run && r.sql.trim() !== '')
|
||||
.map((r) => ({
|
||||
datatable_name: r.datatable_name,
|
||||
sql: r.sql,
|
||||
sql_down: r.sql_down,
|
||||
enabled: true
|
||||
}))
|
||||
}
|
||||
|
||||
// One execution per plan, tagged with the plan it belongs to. Going back and
|
||||
// choosing a different destination leaves the old run behind the tag rather than
|
||||
// clearing it from an effect, so a previous run's outcome can never be shown
|
||||
// against another plan. The folder is deliberately not part of the tag: it is
|
||||
// pushed onto the existing run instead (see `start`).
|
||||
// Seeded from the handed-back run under *its own* tag, so the `planKey` guard below
|
||||
// still rejects it when the destination changed while this component was unmounted —
|
||||
// tagging it with the current plan would make that check pass by construction and show
|
||||
// one destination's finished checklist against another's plan.
|
||||
// `untrack`, because this is a mount-time snapshot on purpose.
|
||||
let run = $state<{ key: string; execution: ImportExecution } | undefined>(
|
||||
untrack(() => (resume ? { key: resume.planTag, execution: resume } : undefined))
|
||||
)
|
||||
const planKey = $derived(JSON.stringify(plan.destination) + plan.slug)
|
||||
const execution = $derived(run?.key === planKey ? run.execution : undefined)
|
||||
$effect(() => onExecution?.(execution))
|
||||
|
||||
// What the import will bring, named on the row that brings it. Triggers and
|
||||
// migrations only become known once the export is fetched, so the phrase grows
|
||||
// mid-run rather than starting complete.
|
||||
const importSummary = $derived(
|
||||
project ? contentSummary({ ...project.counts, ...(execution?.extraCounts ?? {}) }) : ''
|
||||
)
|
||||
|
||||
// The same rows before and during the run: the step states what it is about to do,
|
||||
// and the run fills those rows in rather than replacing a paragraph with a list.
|
||||
const tasks = $derived(execution?.tasks ?? plannedTasks(plan))
|
||||
|
||||
// `SetupStep` carries no detail field, so what the row reports goes in the title
|
||||
// beside the label. The import row says what it is importing; every other row keeps
|
||||
// whatever the run reported. The breakdown supersedes the run's own "N items" here,
|
||||
// being the same total said in a more useful way.
|
||||
const checklist = $derived<SetupStep[]>(
|
||||
tasks.map((task) => {
|
||||
// The breakdown says what the import *will* bring, so it belongs to the row only
|
||||
// until the run has an outcome of its own. Left in place it would go on claiming
|
||||
// "2 apps, 4 scripts" over a run that wrote none of them because they were
|
||||
// already there.
|
||||
const detail = task.key === 'import' ? task.detail || importSummary : task.detail
|
||||
return {
|
||||
title: detail ? `${task.label} — ${detail}` : task.label,
|
||||
status: task.status,
|
||||
// Only under the row that wrote them. A failed item carries its error as the
|
||||
// description, which the checklist opens by itself.
|
||||
substeps:
|
||||
task.key === 'import'
|
||||
? execution?.itemResults.map((r) => ({
|
||||
title: r.path,
|
||||
// `skipped`, not `done`: nothing was written, and a green tick over an
|
||||
// item this run left alone claims an import that did not happen.
|
||||
status: !r.ok
|
||||
? ('failed' as const)
|
||||
: r.skipped
|
||||
? ('skipped' as const)
|
||||
: ('done' as const),
|
||||
description: r.skipped ? 'Already in the workspace — left as it is.' : r.error
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
)
|
||||
/** A run that has been attempted — what makes the button read Retry rather than Import. */
|
||||
const attempted = $derived(!!execution)
|
||||
|
||||
function start() {
|
||||
const current =
|
||||
execution ??
|
||||
new ImportExecution(
|
||||
{ ...plan, folder: folder.trim() },
|
||||
{ reviewMigrations, hasEeLicense: !!$enterpriseLicense }
|
||||
)
|
||||
// A retry reuses the execution — that is what keeps a created workspace and a
|
||||
// fetched export from being redone — so the folder, the one field still
|
||||
// editable after a failure, has to be pushed onto it before running again.
|
||||
current.setFolder(folder.trim())
|
||||
run = { key: planKey, execution: current }
|
||||
void current.run()
|
||||
}
|
||||
|
||||
const leaveModal = createAsyncConfirmationModal()
|
||||
/** The question is on screen; a second attempt must not stack another one. */
|
||||
let askingToLeave = false
|
||||
/** The navigation the question approved, which has to get past this guard. */
|
||||
let leaveApproved = false
|
||||
|
||||
// The browser's own back/forward, which the stepper's guard cannot see. Leaving
|
||||
// mid-run unmounts the migration review the executor may be awaiting, so it is
|
||||
// worth stopping for — but silently refusing reads as a broken back button, so
|
||||
// cancel, ask, and re-navigate if the answer is yes.
|
||||
beforeNavigate((nav) => {
|
||||
if (leaveApproved) return
|
||||
// Nothing in flight has anything to lose.
|
||||
if (!execution?.running) return
|
||||
if (askingToLeave) {
|
||||
nav.cancel()
|
||||
return
|
||||
}
|
||||
// Leaving the app entirely cannot be resumed from here — the browser owns that
|
||||
// prompt — so there is nothing to ask and nowhere to navigate back to.
|
||||
const to = nav.to?.url
|
||||
if (!to) {
|
||||
nav.cancel()
|
||||
return
|
||||
}
|
||||
nav.cancel()
|
||||
void confirmLeave(to)
|
||||
})
|
||||
|
||||
async function confirmLeave(to: URL): Promise<void> {
|
||||
askingToLeave = true
|
||||
// `finally`, because this flag is what blocks a second attempt: an `ask` that threw
|
||||
// would otherwise leave the step permanently unleavable, since every path above
|
||||
// returns early on it.
|
||||
try {
|
||||
const landed = execution?.itemResults.length ?? 0
|
||||
const confirmed = await leaveModal.ask({
|
||||
title: 'Leave while the import is running?',
|
||||
confirmationText: 'Leave',
|
||||
type: 'danger',
|
||||
// A run that has already written items leaves them behind, so promising
|
||||
// otherwise would be a lie exactly when it matters most.
|
||||
children:
|
||||
(landed === 0
|
||||
? 'Nothing has been imported into the workspace yet.'
|
||||
: `${landed} item${landed === 1 ? '' : 's'} already imported into the workspace will stay there.`) +
|
||||
'<br /><br />The import stops where it is. Coming back to this link picks it up ' +
|
||||
'again without redoing what finished.'
|
||||
})
|
||||
if (!confirmed) return
|
||||
// Deliberately not re-read against `running`: the answer was about leaving, and a
|
||||
// run that finished in the meantime only makes leaving safer.
|
||||
leaveApproved = true
|
||||
// Stop the run before navigating. Nothing can abort a request already in flight,
|
||||
// so this stops it at the next phase boundary and keeps the workspace parked, so
|
||||
// the link the message promises actually resumes instead of failing on create.
|
||||
execution?.abandon()
|
||||
await goto(to)
|
||||
} finally {
|
||||
askingToLeave = false
|
||||
}
|
||||
}
|
||||
|
||||
// Torn down with the review drawer open, the executor is still awaiting an answer.
|
||||
// Abort rather than resolve: resolving to `false` means "skip the migrations", which
|
||||
// would let the orphaned run import every item *without* the tables they need — the
|
||||
// opposite of leaving it where it was.
|
||||
$effect(() => () => reviewResolve?.('abort'))
|
||||
|
||||
const deleteModal = createAsyncConfirmationModal()
|
||||
async function deleteWorkspace() {
|
||||
const id = execution?.workspaceId
|
||||
if (!id) return
|
||||
const ok = await deleteModal.ask({
|
||||
title: `Delete workspace ${id}?`,
|
||||
confirmationText: 'Delete it',
|
||||
children: 'It was created for this import. Deleting it cannot be undone.'
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await execution?.deleteCreatedWorkspace()
|
||||
sendUserToast(`Deleted workspace ${id}`)
|
||||
onBack()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not delete ${id}: ${e?.body ?? e}`, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Only when the destination already exists. A workspace created by this run is
|
||||
empty, so there is nothing for the project to sit next to and nothing to
|
||||
choose between — asking would be a question with one answer. It lands in
|
||||
f/<slug>/ either way; `installProject` creates the folder as it imports. -->
|
||||
{#if existingWorkspace}
|
||||
<div class="max-w-sm">
|
||||
<!-- The workspace rides on the field label rather than getting a line of its own:
|
||||
the folder is the only thing being chosen, and naming its container is what
|
||||
the label is for. Step 2 chose the workspace a screen ago, so this is a
|
||||
reminder, not a control. -->
|
||||
<span class="block text-xs font-semibold text-emphasis">
|
||||
Folder inside <span class="font-mono">{existingWorkspace}</span>
|
||||
</span>
|
||||
<!-- Says where the items land, now that the path hint under the picker is gone. -->
|
||||
<p class="mb-1 text-xs font-normal text-secondary">
|
||||
Everything the project ships is imported into this folder.
|
||||
</p>
|
||||
<!-- Pointed at the destination rather than the active workspace: the run is
|
||||
what enters it, and that has not happened yet on this step. -->
|
||||
<FolderPicker
|
||||
bind:folderName={folder}
|
||||
workspace={existingWorkspace}
|
||||
disabled={execution?.running || execution?.done}
|
||||
size="sm"
|
||||
/>
|
||||
{#if folder.trim() && !folderValid}
|
||||
<p class="mt-1 text-2xs font-normal text-red-500"
|
||||
>Letters, digits, dashes and underscores only.</p
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Heads the step the way the others do ("Where should it go?", "Name the new
|
||||
workspace"), and in the same voice: a sentence, not a label. -->
|
||||
<h2 class="text-sm font-semibold text-emphasis">What this will do</h2>
|
||||
|
||||
<!-- The run, task by task, so a failure says which part failed. Shown before the
|
||||
run too, as the plan: every row starts pending and turns green in place. The
|
||||
paths the import writes hang off the import task rather than forming a second
|
||||
list: they are that task's output, not a parallel account of the same run.
|
||||
`substepsClass` caps that list: a project ships tens of items where a data
|
||||
table wizard step has a handful of checks. -->
|
||||
<SetupChecklist steps={checklist} substepsClass="max-h-52 overflow-y-auto" />
|
||||
|
||||
{#if execution?.error}
|
||||
<Alert type="error" title="The import did not finish cleanly" size="xs">
|
||||
{execution.error}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<!-- `info`, not `warning`: nothing here has gone wrong, it is what import does. Borderless
|
||||
so the collapsed row sits under the checklist as a note rather than competing with it
|
||||
— `bgClass` is the only lever, the border is baked into each type's classes. -->
|
||||
<Alert
|
||||
type="info"
|
||||
title="What import does to resources and triggers"
|
||||
size="xs"
|
||||
bgClass="border-0"
|
||||
collapsible
|
||||
>
|
||||
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.
|
||||
</Alert>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<!-- Back is disabled mid-run, and gone once the import has landed: at that point
|
||||
the plan has already happened and re-answering it would say nothing. -->
|
||||
{#if !execution?.done}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
disabled={execution?.running}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Deleting is offered only while the run has not finished: once the items are
|
||||
in, removing the workspace is not a cancel, it is a different decision. -->
|
||||
{#if execution?.createdWorkspace && !execution.done}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
disabled={execution.running}
|
||||
onClick={deleteWorkspace}
|
||||
>
|
||||
Delete workspace
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if execution?.done}
|
||||
<!-- A finished run that reports failures is still finished — what landed is
|
||||
real — but it must stay actionable: without this the only way out of a
|
||||
failed migration or a failed item is to leave, and nothing downstream
|
||||
can run the SQL. Offered beside Finish rather than instead of it, so a
|
||||
migration that fails every time cannot trap the user short of step 4. -->
|
||||
{#if execution.error}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
disabled={execution.running}
|
||||
startIcon={{ icon: execution.running ? Loader2 : Download }}
|
||||
onClick={start}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- Disabled while the page is still deciding whether a setup step follows:
|
||||
finishing in that window leaves for the workspace and skips a step that
|
||||
the answer, a moment later, says was needed. -->
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={setupUndecided}
|
||||
startIcon={setupUndecided ? { icon: Loader2 } : undefined}
|
||||
onClick={onFinish}
|
||||
>
|
||||
{setupUndecided ? 'Checking…' : setupPending ? 'Continue →' : 'Finish setup →'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: execution?.running ? Loader2 : Download }}
|
||||
disabled={!!problem || !folderValid || execution?.running}
|
||||
title={problem}
|
||||
onClick={start}
|
||||
>
|
||||
{#if execution?.running}
|
||||
Importing…
|
||||
{:else if attempted}
|
||||
Retry
|
||||
{:else if plan.destination?.kind === 'new'}
|
||||
Create workspace and import
|
||||
{:else}
|
||||
Import
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Portal>
|
||||
<ConfirmationModal {...deleteModal.props} />
|
||||
<ConfirmationModal {...leaveModal.props} />
|
||||
</Portal>
|
||||
|
||||
<Drawer bind:this={reviewDrawer} size="700px" on:close={() => closeMigrationReview(false)}>
|
||||
<DrawerContent title="Data table migrations" on:close={() => closeMigrationReview(false)}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Unconditional, because this drawer cannot open for anything else: `reviewMigrations`
|
||||
keeps only migrations whose data table is already present in the destination, and a
|
||||
workspace this run just created has none. Everything listed here therefore targets a
|
||||
table that already exists and may already hold rows. -->
|
||||
<Alert type="warning" title="These run against data tables that already exist" size="xs">
|
||||
{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.
|
||||
</Alert>
|
||||
<p class="text-xs text-secondary">
|
||||
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.
|
||||
</p>
|
||||
{#each reviewList as m (m.datatable_name)}
|
||||
<div class="flex flex-col gap-1.5 rounded border bg-surface-secondary p-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-primary">{m.datatable_name}</span>
|
||||
<Toggle bind:checked={m.run} size="xs" options={{ right: 'Run' }} />
|
||||
</div>
|
||||
{#if m.run}
|
||||
<MigrationSqlEditor
|
||||
bind:up={m.sql}
|
||||
bind:down={m.sql_down}
|
||||
generation={reviewGeneration}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="subtle" unifiedSize="sm" onClick={() => closeMigrationReview(false)}>
|
||||
Skip migrations
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!reviewList.some((m) => m.run && m.sql.trim() !== '')}
|
||||
onClick={() => closeMigrationReview(true)}
|
||||
>
|
||||
Run selected
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition'
|
||||
import { CheckCircle2 } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
/** Sized by the row, not the caller: 20px is what an integration logo needs to stay legible. */
|
||||
icon: import('svelte').Snippet
|
||||
title: import('svelte').Snippet
|
||||
detail?: import('svelte').Snippet
|
||||
action: import('svelte').Snippet
|
||||
/** Rendered full-width under the row, for detail that does not fit on one line. */
|
||||
extra?: import('svelte').Snippet
|
||||
/** Plays the confirmation flash over the action once. */
|
||||
flash?: boolean
|
||||
}
|
||||
|
||||
let { icon, title, detail, action, extra, flash = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- One row for both lists on the setup step. Data tables and credentials are the same
|
||||
thing to the reader — something the import could not configure, with an action that
|
||||
configures it — so they get the same icon size, spacing and text block. -->
|
||||
<li class="flex flex-col rounded-md border border-border-light px-3 py-2 text-xs">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="shrink-0">{@render icon()}</div>
|
||||
<!-- No gap and no leading override: `text-xs` already carries `leading-4`, and the two
|
||||
lines are one block of text, not two stacked items. -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{@render title()}
|
||||
{@render detail?.()}
|
||||
</div>
|
||||
<!-- The confirmation flash is the one from SaveButton: the work itself happens
|
||||
elsewhere — a drawer, a wizard — so only the overlay is reused here. The button
|
||||
stays live underneath either way; being configured is a state, not a dead end. -->
|
||||
<div class="relative shrink-0 overflow-hidden rounded-md">
|
||||
{@render action()}
|
||||
{#if flash}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center rounded-md bg-green-200 dark:bg-green-800"
|
||||
transition:fly={{ y: -10, duration: 300 }}
|
||||
>
|
||||
<CheckCircle2 class="h-5 w-5 text-green-700 dark:text-green-300" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{@render extra?.()}
|
||||
</li>
|
||||
@@ -0,0 +1,861 @@
|
||||
<script lang="ts">
|
||||
import { ResourceService, WorkspaceService } from '$lib/gen'
|
||||
import { ArrowLeft, Check, Database, Loader2, TriangleAlert, X } from 'lucide-svelte'
|
||||
import { tick } from 'svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import AddDataTableWizard from '$lib/components/workspaceSettings/AddDataTableWizard.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import { SettingService } from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
|
||||
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
|
||||
import ImportSetupRow from '$lib/components/ImportSetupRow.svelte'
|
||||
import AppConnectDrawer from '$lib/components/AppConnectDrawer.svelte'
|
||||
import { OauthService } from '$lib/gen'
|
||||
import { registryCcCapableFor } from '$lib/components/oauthRegistry'
|
||||
import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay'
|
||||
import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall'
|
||||
import { probeMigrationsApplied } from '$lib/importWizard/probe'
|
||||
import {
|
||||
retargetProjectExport,
|
||||
type ProjectExport,
|
||||
type ProjectMigration
|
||||
} from '$lib/components/workspaceSettings/projectBundle'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { escapeHtml } from '$lib/utils'
|
||||
|
||||
// The last step, and the only optional one: it exists when the project's data
|
||||
// tables are not configured in the destination. The import has already run —
|
||||
// everything here is the part it could not do, because a data table is a named
|
||||
// database connection the workspace owns, not something an import can invent.
|
||||
//
|
||||
// Self-sufficient from `workspace` + `slug`: it re-fetches the export rather than
|
||||
// reading the executor, so reloading the page on this step still works. The plan
|
||||
// in the URL stays the whole state.
|
||||
|
||||
interface Props {
|
||||
workspace: string
|
||||
slug: string
|
||||
/** The folder the import wrote into. The export names resources under the project's
|
||||
* own slug and `installProject` retargets them, so reading the raw paths here would
|
||||
* look for stubs that are not where they landed. */
|
||||
folder?: string
|
||||
onSkip: () => void
|
||||
onFinish: () => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let { workspace, slug, folder, onSkip, onFinish, onBack }: Props = $props()
|
||||
|
||||
type Row = {
|
||||
name: string
|
||||
migrations: ProjectMigration[]
|
||||
status: 'unconfigured' | 'running' | 'done' | 'failed' | 'unknown'
|
||||
error?: string
|
||||
/** Plays the confirmation flash once, right after the run that configured it. */
|
||||
justSaved: boolean
|
||||
}
|
||||
|
||||
/** A resource the project shipped that needed filling in. */
|
||||
type Blank = {
|
||||
path: string
|
||||
resourceType: string
|
||||
/** Required fields the type declares and the value does not have yet. */
|
||||
missing: string[]
|
||||
/** Filled in since this step opened. The row stays — it is a checklist, and a
|
||||
* line that vanishes when you complete it reads as something going wrong. */
|
||||
done: boolean
|
||||
/** Plays the confirmation flash once, right after the save that flipped it. */
|
||||
justSaved: boolean
|
||||
/**
|
||||
* Something of another type already holds this path, named here. The import skipped it
|
||||
* on the path alone, so this row exists to say the project did not get the resource it
|
||||
* shipped — and to make sure nothing offers to write over what is there.
|
||||
*/
|
||||
occupiedBy?: string
|
||||
/**
|
||||
* The resource could not be read, so nothing here knows whether it needs filling. Kept
|
||||
* on the checklist rather than dropped: a read that fails is not evidence the resource
|
||||
* is absent, and removing the row reports "all set" over a credential nobody filled.
|
||||
*/
|
||||
unreadable?: boolean
|
||||
}
|
||||
|
||||
let loading = $state(true)
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
let rows = $state<Row[]>([])
|
||||
let blanks = $state<Blank[]>([])
|
||||
let projectResources: { path: string; resource_type: string }[] = []
|
||||
let working = $state(false)
|
||||
let resourceEditor: ResourceEditorDrawer | undefined = $state(undefined)
|
||||
|
||||
const pendingTables = $derived(rows.filter((r) => r.status !== 'done'))
|
||||
// Split because the two say different things to the user: one data table was never
|
||||
// created, the other exists and could not be read. Telling someone to set up what they
|
||||
// have already set up is how a warning stops being believed.
|
||||
// Grouped by what it costs the project, not by row status. A row whose migrations failed is
|
||||
// configured, but its tables are as absent as one that was never created and the remedy is
|
||||
// the same — so they share a message. An `unknown` row is the odd one: it is set up, and
|
||||
// whether its tables are there is precisely what could not be established.
|
||||
const missingTables = $derived(
|
||||
rows.filter((r) => r.status === 'unconfigured' || r.status === 'failed')
|
||||
)
|
||||
const uncheckedTables = $derived(rows.filter((r) => r.status === 'unknown'))
|
||||
/** Rows the user has not dealt with, of either kind. */
|
||||
const outstanding = $derived(pendingTables.length + blanks.filter((b) => !b.done).length)
|
||||
|
||||
// The wizard needs the instance-database pool and a confirmation host; the settings
|
||||
// page owns them there, so this step owns them here.
|
||||
// Which resource types this instance has an OAuth client for. A resource whose type is
|
||||
// in here can be connected instead of hand-filled, which for an OAuth type is the
|
||||
// difference between clicking Connect and pasting a token that expires in an hour.
|
||||
// Empty when no superadmin has configured any client — then every row falls back to
|
||||
// the editor, which is the only thing that would work anyway.
|
||||
const oauthConnects = resource(
|
||||
() => workspace,
|
||||
async () => {
|
||||
try {
|
||||
return (await OauthService.listOauthConnects()).map((c) => c.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
)
|
||||
const instanceConnects = $derived(new Set(oauthConnects.current ?? []))
|
||||
/**
|
||||
* Matches what the dialog itself decides (`AppConnectInner.open`: `manual = !inConnects &&
|
||||
* !registryCcCapable()`). An instance client is the usual route, but a provider the
|
||||
* registry marks client-credentials-capable is connectable without one, because those
|
||||
* credentials are entered per resource rather than held by a superadmin. Test only the
|
||||
* first half and Connect disappears on the eight such providers, where it would work.
|
||||
*/
|
||||
const canConnectType = (rt: string) => instanceConnects.has(rt) || registryCcCapableFor(rt)
|
||||
let appConnect: AppConnectDrawer | undefined = $state(undefined)
|
||||
|
||||
const customInstanceDbs = resource([() => workspace], SettingService.listCustomInstanceDbs)
|
||||
const confirmationModal = createAsyncConfirmationModal()
|
||||
let wizardOpen = $state(false)
|
||||
let wizard = $state<AddDataTableWizard | undefined>(undefined)
|
||||
let wizardFor = $state<string | undefined>(undefined)
|
||||
let configuredNames = $state<{ name: string; resourcePath: string | undefined }[]>([])
|
||||
|
||||
/**
|
||||
* Which row the open dialog runs migrations for. Separate from `wizardFor`, which
|
||||
* `afterWizard()` clears as soon as the run reports — while the dialog stays up offering
|
||||
* "Try again" for exactly that row. Read by `onFinishAlso`, so it has to outlive the run
|
||||
* rather than the opening.
|
||||
*/
|
||||
let retryTarget = $state<string | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* A dialog is being opened. `open()` resolves the destination membership before it shows
|
||||
* anything, so the button stays clickable during that wait — and a second click starts a
|
||||
* second lookup whose `reset()` lands on the dialog the first one opened, wiping fields
|
||||
* the user has already filled.
|
||||
*/
|
||||
let opening = $state(false)
|
||||
|
||||
function openWizard(name: string) {
|
||||
wizardFor = name
|
||||
retryTarget = name
|
||||
// `open()`, not `opened = true`: only the method runs the wizard's own reset, which
|
||||
// is what applies `initialName` and clears whatever a previous run left behind.
|
||||
// `wizardOpen` is deliberately not set here. `open()` sets it once it has resolved the
|
||||
// destination's membership, and it is bound to the dialog's `opened` — so setting it
|
||||
// now shows a live, clickable dialog while that lookup is still in flight, with the
|
||||
// username unresolved. Setup reached in that window writes the credential path this
|
||||
// whole chain exists to get right. `wizardFor` alone mounts the component, which is
|
||||
// all `wizard?.open()` needs to exist.
|
||||
opening = true
|
||||
void tick()
|
||||
.then(() => wizard?.open())
|
||||
.finally(() => (opening = false))
|
||||
}
|
||||
|
||||
function defaultInstanceDbName(): string {
|
||||
const used = Object.keys(customInstanceDbs.current ?? {})
|
||||
let n = 1
|
||||
while (used.includes(`dt${n}`)) n++
|
||||
return `dt${n}`
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row's state actually is, asked of the destination rather than inferred.
|
||||
*
|
||||
* The data table existing is not the question — the wizard creates it and the migrations
|
||||
* run afterwards, so a table can be there with none of the project's tables inside it.
|
||||
* That gap is invisible in memory after a reload, which rebuilds every row from scratch;
|
||||
* reading it as "done" would let the step say "You're all set" over a project whose apps
|
||||
* all fail on open.
|
||||
*
|
||||
* `probeMigrationsApplied` answers `undefined` when it cannot tell — an unreadable schema
|
||||
* (the data table's database is down, or its credentials have gone bad) or SQL naming no
|
||||
* tables it can resolve. That is neither done nor missing, and claiming either goes beyond
|
||||
* what the code knows: reading it as done is how this step ends up reporting "You're all
|
||||
* set" over a project whose apps fail on open. `unknown` says what is true, and still
|
||||
* counts as outstanding so nothing is finished on top of it.
|
||||
*/
|
||||
async function settle(
|
||||
name: string,
|
||||
ms: ProjectMigration[],
|
||||
absent: boolean,
|
||||
prev: Row | undefined
|
||||
): Promise<Row['status']> {
|
||||
if (absent) return 'unconfigured'
|
||||
const applied = await probeMigrationsApplied(workspace, name, ms)
|
||||
if (applied === true) return 'done'
|
||||
if (applied === false) return prev?.status === 'failed' ? 'failed' : 'unconfigured'
|
||||
return prev?.status === 'failed' ? 'failed' : 'unknown'
|
||||
}
|
||||
|
||||
/** Which data tables the project needs that the destination does not have yet. */
|
||||
async function load() {
|
||||
loading = true
|
||||
loadError = undefined
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/w/${encodeURIComponent(workspace)}/hub/projects/${encodeURIComponent(slug)}/export`
|
||||
)
|
||||
if (!res.ok) throw new Error(`the hub proxy answered ${res.status}`)
|
||||
const exportData = (await res.json()) as ProjectExport
|
||||
const enabled = (exportData.migrations ?? []).filter(
|
||||
(m) => m.enabled && (m.sql ?? '').trim() !== ''
|
||||
)
|
||||
// Kept, not just counted: which data tables the destination has decides whether a
|
||||
// row can retry its migrations or has to go back through the wizard, and after a
|
||||
// reload this call is the only thing that knows. Drop it and such a row offers the
|
||||
// wizard, which then refuses the name it created itself.
|
||||
const tables = await WorkspaceService.listDataTables({ workspace })
|
||||
configuredNames = tables.map((t) => ({ name: t.name, resourcePath: t.resource_path }))
|
||||
const present = new Set(tables.map((d) => d.name))
|
||||
const missing = [...new Set(enabled.map((m) => m.datatable_name))].filter(
|
||||
(n) => !present.has(n)
|
||||
)
|
||||
const previous = new Map(rows.map((r) => [r.name, r]))
|
||||
rows = await Promise.all(
|
||||
[...new Set(enabled.map((m) => m.datatable_name))].map(async (name) => {
|
||||
const ms = enabled.filter((m) => m.datatable_name === name)
|
||||
const prev = previous.get(name)
|
||||
const status = await settle(name, ms, missing.includes(name), prev)
|
||||
return prev
|
||||
? { ...prev, migrations: ms, status, error: status === 'done' ? undefined : prev.error }
|
||||
: { name, migrations: ms, status, justSaved: false }
|
||||
})
|
||||
)
|
||||
// Retargeted the same way the import was, so these are where the stubs actually
|
||||
// landed. `retargetProjectExport` is a no-op when the folder is the slug, which is
|
||||
// every new-workspace import.
|
||||
const target = folder?.trim() || slug
|
||||
const retargeted = retargetProjectExport(exportData, exportData.project?.slug ?? slug, target)
|
||||
// Contained for the same reason the import contains: a crafted export can name a
|
||||
// path outside the folder, and offering that for editing would reach a resource
|
||||
// this import was never allowed to create.
|
||||
projectResources = (retargeted.resources ?? [])
|
||||
.map((r) => ({ path: String(r.path), resource_type: String((r as any).resource_type) }))
|
||||
.filter((r) => r.path.startsWith(`f/${target}/`))
|
||||
await refreshBlanks()
|
||||
} catch (e: any) {
|
||||
loadError = e?.body ?? e?.message ?? String(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resources the import created but could not fill. Every shipped resource arrives
|
||||
* as a stub — the hub never publishes resource values, they are credentials — so
|
||||
* this is not "which ones are empty" but "which ones are *still* empty": a
|
||||
* re-import leaves an already-filled resource alone.
|
||||
*
|
||||
* The type's schema names the required fields, so the row can say what is missing
|
||||
* rather than just that something is. A type we cannot read still counts as blank
|
||||
* when the value is empty; it just lists no field names.
|
||||
*/
|
||||
async function findBlankResources(
|
||||
resources: { path: string; resource_type: string }[]
|
||||
): Promise<Blank[]> {
|
||||
const out: Blank[] = []
|
||||
for (const r of resources) {
|
||||
let value: any
|
||||
let occupiedBy: string | undefined
|
||||
try {
|
||||
const found = await ResourceService.getResource({ workspace, path: r.path })
|
||||
value = found?.value
|
||||
// The presence probe matches on path, and a path says nothing about type. A
|
||||
// resource of another kind sitting here is not this project's stub, however
|
||||
// empty it looks — treating it as one offers to fill somebody else's resource.
|
||||
if (found?.resource_type && found.resource_type !== r.resource_type) {
|
||||
occupiedBy = found.resource_type
|
||||
}
|
||||
} catch (e: any) {
|
||||
// A 404 is the import having failed to create it, which it reported itself.
|
||||
// Any other failure is a read this could not complete, which says nothing about
|
||||
// whether the resource is there or needs filling — so the row stays.
|
||||
if (e?.status === 404) continue
|
||||
out.push({
|
||||
path: r.path,
|
||||
resourceType: r.resource_type,
|
||||
missing: [],
|
||||
done: false,
|
||||
justSaved: false,
|
||||
unreadable: true
|
||||
})
|
||||
continue
|
||||
}
|
||||
const filled = new Set(
|
||||
value && typeof value === 'object'
|
||||
? Object.entries(value)
|
||||
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
.map(([k]) => k)
|
||||
: []
|
||||
)
|
||||
let required: string[] = []
|
||||
// A type whose schema will not load leaves `required` empty, which reads as "nothing
|
||||
// missing" — and a half-filled resource would drop off the checklist as done. The
|
||||
// row is kept instead; it just cannot name which fields are short.
|
||||
let requirementsUnknown = false
|
||||
try {
|
||||
const schema = (await ResourceService.getResourceType({ workspace, path: r.resource_type }))
|
||||
?.schema as { required?: string[] } | undefined
|
||||
required = schema?.required ?? []
|
||||
} catch {
|
||||
requirementsUnknown = true
|
||||
}
|
||||
const missing = required.filter((k) => !filled.has(k))
|
||||
// A conflicting occupant is always listed, however full its value looks: the row is
|
||||
// what tells the user the project is missing a resource it shipped.
|
||||
if (occupiedBy || requirementsUnknown || missing.length > 0 || filled.size === 0) {
|
||||
out.push({
|
||||
path: r.path,
|
||||
resourceType: r.resource_type,
|
||||
missing,
|
||||
done: false,
|
||||
justSaved: false,
|
||||
occupiedBy
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the resources and settle each row's state. Rows are never dropped once
|
||||
* listed: the first pass decides what the checklist contains, and every pass after
|
||||
* it only moves a row from outstanding to done.
|
||||
*/
|
||||
async function refreshBlanks(): Promise<void> {
|
||||
const fresh = await findBlankResources(projectResources)
|
||||
const stillBlank = new Map(fresh.map((b) => [b.path, b]))
|
||||
if (blanks.length === 0) {
|
||||
blanks = fresh
|
||||
return
|
||||
}
|
||||
blanks = blanks.map((b) => {
|
||||
const f = stillBlank.get(b.path)
|
||||
// Every field the fresh read decides is taken from it, not merged selectively: these
|
||||
// describe what is at the path *now*. Keeping a stale `unreadable` leaves a resource
|
||||
// that has since come back blocked until a reload, and keeping a stale absence hides
|
||||
// one that has just become unreadable.
|
||||
if (f) {
|
||||
return {
|
||||
...b,
|
||||
missing: f.missing,
|
||||
unreadable: f.unreadable,
|
||||
occupiedBy: f.occupiedBy,
|
||||
done: false,
|
||||
justSaved: false
|
||||
}
|
||||
}
|
||||
// Gone from the blank list entirely: it was read, and it is filled.
|
||||
return {
|
||||
...b,
|
||||
missing: [],
|
||||
unreadable: undefined,
|
||||
occupiedBy: undefined,
|
||||
done: true,
|
||||
justSaved: !b.done
|
||||
}
|
||||
})
|
||||
// The flash is a one-shot; clear it so a later refresh does not replay it.
|
||||
for (const b of blanks) {
|
||||
if (!b.justSaved) continue
|
||||
setTimeout(() => {
|
||||
const row = blanks.find((x) => x.path === b.path)
|
||||
if (row) row.justSaved = false
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
/**
|
||||
* The data table now exists — run the migrations that were skipped for it during the
|
||||
* import, which is the whole reason this step waits for the configuration.
|
||||
*/
|
||||
async function runMigrationsFor(name: string): Promise<void> {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
// Thrown, not returned: this also runs as the wizard's appended step, which reads a
|
||||
// resolved promise as "the migrations ran". Resolving for a name that matches no row
|
||||
// would report success over SQL that never executed.
|
||||
if (!row) throw new Error(`No data table named '${name}' in this project`)
|
||||
working = true
|
||||
row.status = 'running'
|
||||
try {
|
||||
for (const m of row.migrations) await applyOneMigration(workspace, slug, m)
|
||||
row.status = 'done'
|
||||
row.error = undefined
|
||||
// One-shot, cleared by name rather than by reference: `load()` rebuilds the row
|
||||
// objects, so the one holding the flag when it fires may not be this one.
|
||||
row.justSaved = true
|
||||
setTimeout(() => {
|
||||
const current = rows.find((r) => r.name === name)
|
||||
if (current) current.justSaved = false
|
||||
}, 1500)
|
||||
} catch (e: any) {
|
||||
row.status = 'failed'
|
||||
row.error = e?.body ?? e?.message ?? String(e)
|
||||
sendUserToast(`Could not run the migrations for ${name}: ${row.error}`, true)
|
||||
// Rethrown, because this also runs as the wizard's last checklist step
|
||||
// (`onFinishAlso`). Swallowing it there makes the wizard report a clean finish
|
||||
// over a failed migration, and close — leaving the data table name taken and no
|
||||
// way back to retry it.
|
||||
throw e
|
||||
} finally {
|
||||
working = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaving a credential unfilled costs the project the parts that read it. Leaving a
|
||||
* data table unconfigured costs it everything: the apps query tables that do not
|
||||
* exist, so they fail on open rather than degrading. Only the second is worth
|
||||
* stopping for, and the wizard is the only place that can still run the migration —
|
||||
* nothing in the workspace knows the project shipped one.
|
||||
*/
|
||||
async function skip(): Promise<void> {
|
||||
if (pendingTables.length > 0) {
|
||||
// Escaped: `confirmationModal.ask` renders `children` through `createRawSnippet`,
|
||||
// so this string is HTML, and the name is a `datatable_name` straight out of the
|
||||
// hub export. A hub is not ours — `hub_base_url` is an instance setting and the
|
||||
// wizard can be pointed at any of them — so a name carrying an event-bearing
|
||||
// element would otherwise run script in this authenticated origin.
|
||||
// One block per outcome, the way the footer alert does it. A single sentence over a
|
||||
// mixed list has to be wrong about half of it: an `unknown` data table is set up —
|
||||
// only its schema could not be read — so naming it under "not set up" tells the user
|
||||
// to do something they have already done.
|
||||
const missing = missingTables
|
||||
const unverified = uncheckedTables
|
||||
const listOf = (rs: Row[]) => rs.map((r) => escapeHtml(r.name)).join(', ')
|
||||
const blocks: string[] = []
|
||||
if (missing.length > 0) {
|
||||
const one = missing.length === 1
|
||||
blocks.push(
|
||||
`The tables ${one ? 'the data table' : 'the data tables'} <b>${listOf(missing)}</b> ` +
|
||||
`${one ? 'holds' : 'hold'} do not exist, and this project's apps and flows read ` +
|
||||
`them. Every one of those fails as soon as it opens.<br /><br />` +
|
||||
`Setting ${one ? 'it' : 'them'} up later from workspace settings creates the ` +
|
||||
`connection but not the tables — only this step runs the project's migration.`
|
||||
)
|
||||
}
|
||||
if (unverified.length > 0) {
|
||||
const one = unverified.length === 1
|
||||
blocks.push(
|
||||
`${one ? 'The data table' : 'The data tables'} <b>${listOf(unverified)}</b> ` +
|
||||
`${one ? 'is' : 'are'} set up, but ${one ? 'its' : 'their'} schema could not be ` +
|
||||
`read, so whether this project's tables exist is unknown. Its apps and flows will ` +
|
||||
`fail wherever they query a table that is missing.`
|
||||
)
|
||||
}
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: missing.length > 0 ? 'The project will not run' : 'This has not been verified',
|
||||
confirmationText: 'Skip anyway',
|
||||
type: missing.length > 0 ? 'danger' : 'info',
|
||||
children: blocks.join('<br /><br />')
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
onSkip()
|
||||
}
|
||||
|
||||
/**
|
||||
* After the wizard closes. The migrations already ran inside its checklist, via
|
||||
* `onFinishAlso`, so this only re-reads what exists now — including the case where
|
||||
* the wizard was cancelled, or made a table under a different name than the row
|
||||
* asked for, which leaves the row outstanding rather than falsely done.
|
||||
*/
|
||||
async function afterWizard(): Promise<void> {
|
||||
const name = wizardFor
|
||||
wizardFor = undefined
|
||||
try {
|
||||
const tables = await WorkspaceService.listDataTables({ workspace })
|
||||
configuredNames = tables.map((t) => ({ name: t.name, resourcePath: t.resource_path }))
|
||||
const present = new Set(tables.map((t) => t.name))
|
||||
const row = name ? rows.find((r) => r.name === name) : undefined
|
||||
if (row && row.status !== 'done' && row.status !== 'failed' && !present.has(name!)) {
|
||||
row.status = 'unconfigured'
|
||||
}
|
||||
} catch {
|
||||
// Nothing to correct with; the row keeps whatever the run left it saying.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-emphasis">Finish setting up</h2>
|
||||
<!-- Reads as what the user gets out of it, not as what the import failed to do:
|
||||
the step is skippable, so it has to say why finishing is worth their time. -->
|
||||
<p class="mt-0.5 text-xs text-secondary">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary">
|
||||
<Loader2 size={14} class="animate-spin" /> Checking what this project needs…
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<Alert type="warning" title="Could not check the project's data tables" size="xs">
|
||||
{loadError}. You can finish and configure them later in Workspace settings → Data tables.
|
||||
</Alert>
|
||||
{:else}
|
||||
{#if rows.length > 0}
|
||||
<!-- Named and explained: the row underneath is a table called `main`, which
|
||||
says nothing to someone meeting the concept for the first time. -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">
|
||||
Data table{rows.length === 1 ? '' : 's'} to set up ({rows.length})
|
||||
</span>
|
||||
<p class="text-xs font-normal text-secondary">
|
||||
Where apps and flows keep the data they read and write.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
{#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)}
|
||||
<ImportSetupRow flash={row.justSaved}>
|
||||
{#snippet icon()}
|
||||
{#if row.status === 'done'}
|
||||
<Check size={20} class="text-emerald-600" />
|
||||
{:else if row.status === 'running'}
|
||||
<Loader2 size={20} class="animate-spin text-blue-500" />
|
||||
{:else if row.status === 'failed'}
|
||||
<X size={20} class="text-red-500" />
|
||||
{:else if row.status === 'unknown'}
|
||||
<TriangleAlert size={20} class="text-yellow-600" />
|
||||
{:else}
|
||||
<Database size={20} class="text-secondary" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span class="min-w-0 truncate font-mono text-emphasis">{row.name}</span>
|
||||
{/snippet}
|
||||
{#snippet detail()}
|
||||
<span class="truncate text-secondary">
|
||||
{#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'}
|
||||
<span class="text-red-500">{row.error}</span>
|
||||
{:else if row.status === 'unknown'}
|
||||
set up, but its tables could not be read — the database may be unreachable
|
||||
{:else}
|
||||
not configured yet
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet extra()}
|
||||
<!-- The SQL, before anything runs it. Step 3 reviews the migrations it can
|
||||
run there; the ones deferred to here were never shown, and "Set up"
|
||||
executes them against whatever database the wizard is pointed at —
|
||||
which can be one that already holds unrelated objects.
|
||||
|
||||
On an `unknown` row nothing here will run — its only action re-reads —
|
||||
so the summary says what the SQL is rather than promising to run it. -->
|
||||
{#if sql && row.status !== 'done'}
|
||||
<details class="mt-1.5">
|
||||
<summary class="cursor-pointer text-2xs text-secondary hover:text-primary">
|
||||
{row.status === 'unknown'
|
||||
? 'Show the SQL this project ships'
|
||||
: 'Show the SQL this will run'}
|
||||
</summary>
|
||||
<pre
|
||||
class="mt-1.5 max-h-52 overflow-auto whitespace-pre-wrap rounded border border-border-light bg-surface-secondary p-2 font-mono text-2xs text-secondary"
|
||||
>{sql}</pre
|
||||
>
|
||||
</details>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<!-- The wizard owns creating a data table: picking or provisioning the
|
||||
database, writing the config, and reporting the connection. This step
|
||||
only says which name it needs and runs the migrations afterwards. -->
|
||||
{#if row.status === 'unknown'}
|
||||
<!-- Never runs the SQL. `unknown` covers two different unknowns — the schema
|
||||
could not be read, or the SQL names no table this can resolve — and the
|
||||
second is arbitrary published SQL that may carry a non-idempotent INSERT
|
||||
or ALTER. Applying it a second time on the chance it never applied once
|
||||
is a worse outcome than saying so. Reading again is free and settles the
|
||||
case that actually recovers: a database that was briefly unreachable. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
disabled={working || loading}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
Check again
|
||||
</Button>
|
||||
{:else if hasTable && row.status !== 'done' && row.status !== 'running'}
|
||||
<!-- The data table is there and its tables are not, so the thing left
|
||||
to do is run the migrations. Reopening the wizard would ask for a
|
||||
name it now holds itself, which it rejects as taken — leaving no
|
||||
way back to the step that actually failed.
|
||||
|
||||
Keyed on the data table existing rather than on the row saying
|
||||
`failed`, because a reload rebuilds every row from scratch: the
|
||||
same situation then reads as `unconfigured`, with nothing left in
|
||||
memory to say a migration was ever attempted. -->
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={working}
|
||||
onClick={() => void runMigrationsFor(row.name).catch(() => {})}
|
||||
>
|
||||
{row.status === 'failed' ? 'Run migrations again' : 'Run migrations'}
|
||||
</Button>
|
||||
{:else}
|
||||
<!-- Everything the branches above do not claim: a configured row, one whose
|
||||
migrations are running, and one whose data table does not exist yet. -->
|
||||
<Button
|
||||
variant={row.status === 'done' ? 'subtle' : 'accent'}
|
||||
unifiedSize="sm"
|
||||
disabled={working || opening}
|
||||
onClick={() => openWizard(row.name)}
|
||||
>
|
||||
{#if row.status === 'done'}
|
||||
Configured
|
||||
{:else if row.status === 'running'}
|
||||
Setting up…
|
||||
{:else}
|
||||
Set up
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ImportSetupRow>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if blanks.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis"
|
||||
>Credentials to fill ({blanks.length})</span
|
||||
>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
{#each blanks as b (b.path)}
|
||||
{@const blocked = !!b.occupiedBy || !!b.unreadable}
|
||||
{@const canConnect = !b.done && !blocked && canConnectType(b.resourceType)}
|
||||
<!-- Laid out like the resource type rows in the Add-a-resource drawer: the
|
||||
integration's own icon, its product name, and the raw identifier demoted
|
||||
beside it. The path only matters when two resources share a type, so it
|
||||
stops being the thing the eye lands on. -->
|
||||
<ImportSetupRow flash={b.justSaved}>
|
||||
{#snippet icon()}
|
||||
{#if b.done}
|
||||
<Check size={20} class="text-emerald-600" />
|
||||
{:else if blocked}
|
||||
<TriangleAlert size={20} class="text-yellow-600" />
|
||||
{:else}
|
||||
<IconedResourceType name={b.resourceType} silent width="20px" height="20px" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div class="flex min-w-0 flex-row items-baseline gap-2">
|
||||
<span class="min-w-0 truncate text-emphasis">
|
||||
{resourceTypeDisplayName(b.resourceType)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate font-mono text-2xs font-normal text-hint">
|
||||
{b.path}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet detail()}
|
||||
{#if b.occupiedBy}
|
||||
<span class="truncate text-secondary">
|
||||
a {resourceTypeDisplayName(b.occupiedBy)} resource already holds this path — the
|
||||
project did not get this one
|
||||
</span>
|
||||
{:else if b.unreadable}
|
||||
<span class="truncate text-secondary">
|
||||
could not be read, so whether it needs filling is unknown
|
||||
</span>
|
||||
{:else if !b.done && b.missing.length > 0}
|
||||
<span class="truncate text-secondary">
|
||||
Missing {b.missing.join(', ')}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<!-- Connect where the instance has a client for this type: asking for an
|
||||
OAuth resource by hand means pasting an access token that dies within
|
||||
the hour, since only a token Windmill obtained itself gets refreshed. -->
|
||||
{#if blocked}
|
||||
<!-- No action: every one here writes to the path, and this code does not
|
||||
know what is at it — either something of another type, or a read that
|
||||
failed. Opening the editor would invite exactly the overwrite these
|
||||
rows exist to prevent. -->
|
||||
<span class="whitespace-nowrap text-2xs text-hint">
|
||||
{b.occupiedBy ? 'Resolve in the workspace' : 'Check the workspace'}
|
||||
</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={b.done ? 'subtle' : 'accent'}
|
||||
unifiedSize="sm"
|
||||
disabled={working}
|
||||
onClick={() =>
|
||||
canConnect
|
||||
? appConnect?.open(b.resourceType, b.path)
|
||||
: resourceEditor?.initEdit(b.path)}
|
||||
>
|
||||
{b.done ? 'Saved' : canConnect ? 'Connect' : 'Fill in'}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ImportSetupRow>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Three different things to say, and which one depends on what is left. A missing
|
||||
credential degrades the project; a missing data table ends it, because every app
|
||||
queries tables that do not exist. Only the credential case is offered as
|
||||
skippable — saying "you can skip this" above a missing data table would be
|
||||
telling the user something that is not true. -->
|
||||
{#if outstanding === 0}
|
||||
<Alert type="success" title="You're all set" size="xs">
|
||||
Everything this project needs is configured. Finish, and it is ready to run.
|
||||
</Alert>
|
||||
{:else if pendingTables.length > 0}
|
||||
<Alert
|
||||
type="warning"
|
||||
title={missingTables.length > 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}<br /><br />{/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}
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert type="info" title="You can skip this" size="xs" collapsible>
|
||||
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.
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
{#if onBack}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
disabled={working}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Every row carries its own action, so the footer only offers the way out —
|
||||
twice, because leaving work undone is a different decision from having
|
||||
finished it. Finish stays disabled until nothing is outstanding, and Skip
|
||||
is the subtle escape beside it. A load that failed cannot tell what is
|
||||
outstanding, so it offers Finish rather than blocking on an unknown — but a
|
||||
load still *running* has the same empty lists as a step with nothing to do,
|
||||
so Finish waits for it rather than reading that emptiness as "all done". -->
|
||||
{#if outstanding > 0 && !loading && !loadError}
|
||||
<Button variant="subtle" unifiedSize="sm" disabled={working} onClick={skip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={working || loading || (outstanding > 0 && !loadError)}
|
||||
onClick={onFinish}
|
||||
>
|
||||
Finish setup →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if wizardOpen || wizardFor}
|
||||
<AddDataTableWizard
|
||||
bind:this={wizard}
|
||||
bind:opened={wizardOpen}
|
||||
initialName={wizardFor}
|
||||
modalTarget="body"
|
||||
{workspace}
|
||||
finishAlso="run migrations"
|
||||
onFinishAlso={() => runMigrationsFor(retryTarget ?? '')}
|
||||
existingNames={configuredNames.map((c) => c.name)}
|
||||
existingDataTables={configuredNames}
|
||||
onDone={() => void afterWizard()}
|
||||
{customInstanceDbs}
|
||||
{confirmationModal}
|
||||
{defaultInstanceDbName}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Portalled to the body, not left in place: this step renders inside the wizard page's
|
||||
CenteredModal, which is its own stacking context, while the data table wizard it shares
|
||||
this handle with portals to the body. In place, the confirmation's z-index is capped by
|
||||
that context and the wizard paints over it — leaving its backdrop swallowing every click
|
||||
with nothing visible to answer. -->
|
||||
<Portal>
|
||||
<ConfirmationModal {...confirmationModal.props} />
|
||||
</Portal>
|
||||
|
||||
<!-- The destination is not the workspace the app is in until the run switches to it,
|
||||
so the editor is told which one explicitly.
|
||||
|
||||
Saving re-reads only the resources, never `load()`: a credential cannot change which
|
||||
data tables the project ships or which ones the workspace has, and `load()` raises
|
||||
`loading`, which replaces both lists with the spinner — so every save looked like the
|
||||
whole step had reloaded. -->
|
||||
<ResourceEditorDrawer
|
||||
bind:this={resourceEditor}
|
||||
{workspace}
|
||||
onSaved={() => void refreshBlanks()}
|
||||
onRestored={() => void refreshBlanks()}
|
||||
/>
|
||||
|
||||
<!-- `on:refresh` fires once the connection has been written into the stub — the same moment
|
||||
a save is — so the rows settle the same way either route was taken. -->
|
||||
<AppConnectDrawer bind:this={appConnect} {workspace} on:refresh={() => void refreshBlanks()} />
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts" module>
|
||||
export const IMPORT_WIZARD_LABELS = ['Destination', 'Workspace', 'Import']
|
||||
/** The optional fourth: shown only for a project whose data tables need configuring. */
|
||||
export const IMPORT_WIZARD_SETUP_LABEL = 'Set up'
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$lib/navigation'
|
||||
import Stepper from '$lib/components/common/stepper/Stepper.svelte'
|
||||
import { importIsRunning } from '$lib/importWizard/execution.svelte'
|
||||
import type { WizardStep } from '$lib/importWizard/plan'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
interface Props {
|
||||
/** 1-based; Stepper is 0-based, hence the -1 below. */
|
||||
step: WizardStep
|
||||
/** Whether this import has a setup step at all — most projects do not. */
|
||||
hasSetup?: boolean
|
||||
/**
|
||||
* The lowest step still worth returning to. Defaults to the first. The page raises it
|
||||
* past the import once that import has landed and the run behind it can no longer be
|
||||
* recovered — after a reload on the setup step, where the executor was in memory and
|
||||
* the parking a clean finish cleared. Step 3 would otherwise mount with nothing to
|
||||
* resume and offer to run the whole bundle again.
|
||||
*/
|
||||
lowestStep?: number
|
||||
}
|
||||
|
||||
let { step, hasSetup = false, lowestStep = 1 }: Props = $props()
|
||||
|
||||
// Most projects ship no data table migrations, so the wizard is three steps and
|
||||
// says so. A fourth appears only once there is something to configure.
|
||||
const tabs = $derived(
|
||||
hasSetup ? [...IMPORT_WIZARD_LABELS, IMPORT_WIZARD_SETUP_LABEL] : IMPORT_WIZARD_LABELS
|
||||
)
|
||||
|
||||
// `maxReachedIndex` is the current step, so Stepper renders everything past it as
|
||||
// unreachable and only the steps behind it as clickable — the wizard has no way to
|
||||
// skip ahead, since each step decides what the next one asks.
|
||||
function onStepClick(index: number) {
|
||||
if (index >= step - 1) return
|
||||
if (index + 1 < lowestStep) {
|
||||
sendUserToast('The project is already imported. There is nothing to go back to.', true)
|
||||
return
|
||||
}
|
||||
// An import in flight owns the page: stepping back unmounts the step that is
|
||||
// awaiting the migration review, which would leave the run with no controls
|
||||
// and no way to resolve.
|
||||
if (importIsRunning()) {
|
||||
sendUserToast('Wait for the import to finish before going back.', true)
|
||||
return
|
||||
}
|
||||
// Every step shares one route, so going back is a `step` rewrite that leaves
|
||||
// the rest of the wizard's state in the URL alone.
|
||||
const params = new URLSearchParams($page.url.search)
|
||||
params.set('step', String(index + 1))
|
||||
goto(`/projects/import?${params}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-5 flex justify-center">
|
||||
<!-- `small`: this steers a dialog, not a page. -->
|
||||
<Stepper
|
||||
{tabs}
|
||||
small
|
||||
selectedIndex={step - 1}
|
||||
maxReachedIndex={step - 1}
|
||||
on:click={(e) => onStepClick(e.detail.index)}
|
||||
/>
|
||||
</div>
|
||||
@@ -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`)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts" module>
|
||||
export interface ProjectContentCounts {
|
||||
apps: number
|
||||
flows: number
|
||||
scripts: number
|
||||
resources: number
|
||||
/** Only the import step counts these. */
|
||||
triggers?: number
|
||||
migrations?: number
|
||||
}
|
||||
|
||||
/** The kinds, in the order a project is read. Shared by the badges and the sentence
|
||||
* below so the same project can never be counted two ways. */
|
||||
function kinds(counts: ProjectContentCounts) {
|
||||
return [
|
||||
{ label: 'app', count: counts.apps },
|
||||
{ label: 'flow', count: counts.flows },
|
||||
{ label: 'script', count: counts.scripts },
|
||||
{ label: 'resource', count: counts.resources },
|
||||
{ label: 'trigger', count: counts.triggers ?? 0 },
|
||||
{ label: 'data table migration', count: counts.migrations ?? 0 }
|
||||
].filter((c) => c.count > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same counts as one line of text, for callers with a row to sit on rather than
|
||||
* a space for chips — the import step names them beside the task that imports them.
|
||||
* Empty when a project has nothing in it, so a caller can drop the whole phrase.
|
||||
*/
|
||||
export function contentSummary(counts: ProjectContentCounts): string {
|
||||
return kinds(counts)
|
||||
.map((c) => `${c.count} ${c.label}${c.count === 1 ? '' : 's'}`)
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Code2, Database, LayoutDashboard, Table2, Zap } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
|
||||
// What a project contains, as one row of badges. Shared by the wizard's project
|
||||
// card and its import step so the same project never gets counted two ways.
|
||||
interface Props {
|
||||
counts: ProjectContentCounts
|
||||
}
|
||||
|
||||
let { counts }: Props = $props()
|
||||
|
||||
// Transparent badges throughout: six kinds in six colours turned a summary into
|
||||
// a paint chart. The icon carries the kind, the colour carries nothing.
|
||||
//
|
||||
// Zero counts are dropped rather than shown: a project with no apps should read
|
||||
// as "no apps", not as a "0 apps" chip the eye has to discount.
|
||||
const ICONS: Record<string, any> = {
|
||||
app: LayoutDashboard,
|
||||
flow: BarsStaggered,
|
||||
script: Code2,
|
||||
resource: Database,
|
||||
trigger: Zap,
|
||||
'data table migration': Table2
|
||||
}
|
||||
const shown = $derived(kinds(counts).map((c) => ({ ...c, icon: ICONS[c.label] })))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#each shown as c (c.label)}
|
||||
<Badge color="transparent" small icon={{ icon: c.icon, position: 'left' }}>
|
||||
{c.count}
|
||||
{c.label}{c.count === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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')
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onclick={onSelect}
|
||||
class={twMerge(
|
||||
'w-full text-left rounded-md border p-3 transition-colors',
|
||||
// `surface-accent-selected`, not the neutral `surface-selected`: this is the
|
||||
// token the rest of the app uses to say "this is the one you picked"
|
||||
// (FileExplorer, TriggersTable, RunnableRow, ArtifactVersionPicker).
|
||||
selected
|
||||
? 'border-border-selected bg-surface-selected'
|
||||
? 'border-border-selected bg-surface-accent-selected'
|
||||
: 'border-border-light hover:bg-surface-hover',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
{#if selected}
|
||||
<CircleDot size={16} class="text-accent shrink-0 mt-0.5" />
|
||||
{:else}
|
||||
<Circle size={16} class="text-hint shrink-0 mt-0.5" />
|
||||
{#if showRadio}
|
||||
{#if selected}
|
||||
<CircleDot size={16} class="text-accent shrink-0 mt-0.5" />
|
||||
{:else}
|
||||
<Circle size={16} class="text-hint shrink-0 mt-0.5" />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if icon}
|
||||
<div class="shrink-0 mt-0.5">{@render icon()}</div>
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-semibold text-emphasis">{label}</div>
|
||||
{#if description}
|
||||
{#if describedBySnippet}
|
||||
<div class="text-xs font-normal text-secondary mt-0.5">
|
||||
{@render (description as Snippet)()}
|
||||
</div>
|
||||
{:else if description}
|
||||
<div class="text-xs font-normal text-secondary mt-0.5">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import oauthConnectRegistry from '$oauth_connect_registry'
|
||||
|
||||
/**
|
||||
* Reads of the static OAuth connect registry (`backend/oauth_connect.json`), shared by the
|
||||
* connect dialog and by anything deciding whether to offer connecting at all.
|
||||
*
|
||||
* Kept here rather than inside `AppConnectInner` because two places have to agree on the
|
||||
* answer: the dialog decides whether a type gets the OAuth flow, and a caller deciding
|
||||
* whether to open the dialog has to reach the same verdict — or it offers Connect where the
|
||||
* dialog would fall back to a manual form, or hides it where the dialog would have worked.
|
||||
*/
|
||||
|
||||
const SANDBOX_SUFFIX = '_sandbox'
|
||||
|
||||
export function stripSandboxSuffix(name: string): string {
|
||||
return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry entry for the first of `names` that has one. Callers pass the client name and
|
||||
* the resource type, which differ for sandbox clients (`salesforce_sandbox` vs `salesforce`);
|
||||
* both are resolved to the parent entry so a sandbox connection sees the same metadata.
|
||||
*/
|
||||
export function registryEntryFor(...names: (string | undefined)[]): any {
|
||||
const reg = oauthConnectRegistry as Record<string, any>
|
||||
for (const n of names) {
|
||||
if (!n) continue
|
||||
const entry = reg[stripSandboxSuffix(n)]
|
||||
if (entry) return entry
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry declares this provider supports client credentials — which is what makes it
|
||||
* connectable with no OAuth client configured on the instance, since the credentials are
|
||||
* entered per resource rather than held by a superadmin.
|
||||
*/
|
||||
export function registryCcCapableFor(...names: (string | undefined)[]): boolean {
|
||||
return registryEntryFor(...names)?.grant_types?.includes('client_credentials') ?? false
|
||||
}
|
||||
@@ -31,9 +31,12 @@
|
||||
type Props = {
|
||||
steps: SetupStep[]
|
||||
class?: string
|
||||
/** Applied to each step's substep block, for a caller whose substeps are a long
|
||||
* list rather than a handful of checks and need their own scroll. */
|
||||
substepsClass?: string
|
||||
}
|
||||
|
||||
let { steps, class: className = '' }: Props = $props()
|
||||
let { steps, class: className = '', substepsClass = '' }: Props = $props()
|
||||
|
||||
/**
|
||||
* Only the steps the user has actually toggled. A failed step opens itself, so recording
|
||||
@@ -108,8 +111,8 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if step.substeps?.length}
|
||||
<div class="ml-6">
|
||||
<Self steps={step.substeps} />
|
||||
<div class={twMerge('ml-6', substepsClass)}>
|
||||
<Self steps={step.substeps} {substepsClass} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
FolderService,
|
||||
OauthService,
|
||||
ResourceService,
|
||||
UserService,
|
||||
type User,
|
||||
VariableService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
@@ -85,6 +87,38 @@
|
||||
customInstanceDbs: ResourceReturn<ListCustomInstanceDbsResponse>
|
||||
confirmationModal: ConfirmationModalHandle
|
||||
defaultInstanceDbName: () => string
|
||||
/** Name to open with, when the caller needs a table of a particular name rather
|
||||
* than whatever the user picks — the import wizard configures the one a project's
|
||||
* migrations target.
|
||||
*
|
||||
* Locked when `onFinishAlso` is also given, because that work targets this name and
|
||||
* nothing carries an edit through to it: renaming `main` to `other` would create
|
||||
* `other`, then run the migrations against `main`, fail, and leave a data table
|
||||
* nobody asked for. Editable without one, where the name is only a name. */
|
||||
initialName?: string
|
||||
/** Where the dialog portals to. `#content` is the app shell's scroll container,
|
||||
* which only exists inside the `(logged)` layout; a page reparented out of it
|
||||
* (the hub import wizard) has to say `body` or the portal finds nothing and the
|
||||
* dialog never appears. */
|
||||
modalTarget?: string
|
||||
/** What the caller does once the table exists, named on the final button so the
|
||||
* user is told before pressing it — the import wizard runs the project's
|
||||
* migrations, which is otherwise invisible until it has already happened. */
|
||||
finishAlso?: string
|
||||
/** The work `finishAlso` names. Run as the last checklist step, so it reports
|
||||
* where the rest of the run does instead of starting after the dialog closes.
|
||||
* Throwing marks that step failed; the table itself is already made either way. */
|
||||
onFinishAlso?: () => Promise<void>
|
||||
/** The workspace everything here is created in and checked against.
|
||||
*
|
||||
* Defaults to `$workspaceStore`, which is right for the settings page — it is the
|
||||
* workspace being looked at. The import wizard is the exception: its page is
|
||||
* reparented out of `(logged)`, so nothing re-runs the layout's workspace
|
||||
* persistence, and after a reload the store still names whatever workspace the
|
||||
* user came from while the plan in the URL names the destination. Left ambient,
|
||||
* this would create the data table in one workspace and run the project's
|
||||
* migrations in the other. */
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -95,9 +129,62 @@
|
||||
onDone,
|
||||
customInstanceDbs,
|
||||
confirmationModal,
|
||||
defaultInstanceDbName
|
||||
defaultInstanceDbName,
|
||||
initialName,
|
||||
modalTarget = '#content',
|
||||
finishAlso,
|
||||
onFinishAlso,
|
||||
workspace: workspaceProp
|
||||
}: Props = $props()
|
||||
|
||||
/**
|
||||
* The caller needs this exact table, and has follow-up work bound to its name.
|
||||
*
|
||||
* Captured when the dialog opens rather than read live: `initialName` is the caller's
|
||||
* `wizardFor`, which it clears from `onDone` — and that fires after a *failed* run too,
|
||||
* while the dialog stays up offering Back. Reading it live releases the lock exactly when
|
||||
* the user is most likely to edit, which is the divergence the lock exists to stop.
|
||||
*/
|
||||
let nameLocked = $state(false)
|
||||
|
||||
/** Every write and every check goes through this, never `$workspaceStore` directly. */
|
||||
const targetWorkspace = $derived(workspaceProp ?? $workspaceStore ?? '')
|
||||
|
||||
/**
|
||||
* Who the caller is *in the destination*, which is not who `$userStore` describes.
|
||||
*
|
||||
* `$userStore` is the membership of the workspace the app is in. Routing the API calls
|
||||
* elsewhere without routing this leaves the username behind: after a reload on the import
|
||||
* wizard's step 4 it names the workspace the user came from, and a resource path built
|
||||
* from it lands on `u/<someone-else>` inside the destination — failing an ownership check,
|
||||
* or for an admin, quietly putting database credentials in another member's namespace.
|
||||
*/
|
||||
let targetUser = $state<User | undefined>(undefined)
|
||||
const aimedElsewhere = $derived(!!workspaceProp && workspaceProp !== $workspaceStore)
|
||||
const ambientUsername = $derived($userStore?.username ?? '')
|
||||
const targetUsername = $derived(aimedElsewhere ? (targetUser?.username ?? '') : ambientUsername)
|
||||
/** The destination's membership could not be read, so nothing here knows who the user is. */
|
||||
let membershipFailed = $state(false)
|
||||
|
||||
async function loadTargetUser(): Promise<void> {
|
||||
const ws = workspaceProp
|
||||
if (!ws || ws === $workspaceStore) {
|
||||
targetUser = undefined
|
||||
membershipFailed = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
targetUser = await UserService.whoami({ workspace: ws })
|
||||
membershipFailed = false
|
||||
} catch {
|
||||
// Recorded rather than swallowed: an unknown username silently becomes `admin` in
|
||||
// the default path, which is the wrong namespace to write credentials into. Setup
|
||||
// is blocked instead.
|
||||
targetUser = undefined
|
||||
membershipFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
const STEPS = ['Choose a database', 'Set it up', 'Review']
|
||||
|
||||
let wiz: WizardState = $state(
|
||||
@@ -146,7 +233,7 @@
|
||||
}
|
||||
clearTimeout(variableCheck)
|
||||
variableCheck = setTimeout(async () => {
|
||||
const taken = await VariableService.existsVariable({ workspace: $workspaceStore!, path })
|
||||
const taken = await VariableService.existsVariable({ workspace: targetWorkspace, path })
|
||||
// Two checks can be in flight at once and resolve out of order. A `false` for a path
|
||||
// nobody is on any more would clear the error guarding the one about to be written;
|
||||
// a `true` would disable Finish over a path this run stopped caring about.
|
||||
@@ -163,7 +250,7 @@
|
||||
* in flight when Finish is pressed.
|
||||
*/
|
||||
async function pathConflictMessage(path: string): Promise<string | undefined> {
|
||||
const workspace = $workspaceStore!
|
||||
const workspace = targetWorkspace
|
||||
// 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([
|
||||
@@ -178,11 +265,14 @@
|
||||
let maxStep = $state(1)
|
||||
|
||||
function defaultProjectName(): string {
|
||||
return `windmill-${$workspaceStore ?? 'workspace'}`
|
||||
return `windmill-${targetWorkspace || 'workspace'}`
|
||||
}
|
||||
|
||||
function defaultTableName(): string {
|
||||
return existingNames.includes('main') ? `${$workspaceStore ?? 'data'}_datatable` : 'main'
|
||||
// A caller that needs a specific name wins over the usual "main, unless taken":
|
||||
// the import wizard's migrations only apply to a table of the name they target.
|
||||
if (initialName) return initialName
|
||||
return existingNames.includes('main') ? `${targetWorkspace || 'data'}_datatable` : 'main'
|
||||
}
|
||||
|
||||
// Takes the list rather than reading it, so the fetch that loads it can seed off its own
|
||||
@@ -190,7 +280,7 @@
|
||||
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'}`
|
||||
return list.length ? `f/${list[0]}` : `u/${targetUsername || 'admin'}`
|
||||
}
|
||||
|
||||
// A row this run wrote and could not take back out is still its own: `removeRow` reports
|
||||
@@ -236,7 +326,7 @@
|
||||
)
|
||||
|
||||
const pgResources = resource(
|
||||
() => (opened && wiz.provider === 'resource' ? ($workspaceStore ?? '') : ''),
|
||||
() => (opened && wiz.provider === 'resource' ? targetWorkspace : ''),
|
||||
async (workspace) => {
|
||||
if (!workspace) return undefined
|
||||
const list = await ResourceService.listResource({ workspace, resourceType: 'postgresql' })
|
||||
@@ -330,7 +420,7 @@
|
||||
)
|
||||
|
||||
const folderNames = resource(
|
||||
() => (opened ? ($workspaceStore ?? '') : ''),
|
||||
() => (opened ? targetWorkspace : ''),
|
||||
async (workspace) => {
|
||||
if (!workspace) return []
|
||||
const all = await FolderService.listFolderNames({ workspace })
|
||||
@@ -374,7 +464,11 @@
|
||||
let resumedPath = $state<string | undefined>(undefined)
|
||||
|
||||
function reset(from: WizardResume | undefined) {
|
||||
nameLocked = !!initialName && !!onFinishAlso
|
||||
resumedPath = from?.resourcePath
|
||||
// A pending confirmation that never settled leaves `dismissing` true, and `finally`
|
||||
// cannot clear what never resolves — so a fresh open always starts dismissable.
|
||||
dismissing = false
|
||||
wiz = newWizardState({
|
||||
name: from?.name || defaultTableName(),
|
||||
projectName: from?.projectName || defaultProjectName(),
|
||||
@@ -391,6 +485,7 @@
|
||||
createdProjects = []
|
||||
nameConflictFor = undefined
|
||||
lastFailure = ''
|
||||
finishAlsoFailed = false
|
||||
pathTakenError = ''
|
||||
poolerUnavailable = undefined
|
||||
if (from) {
|
||||
@@ -424,7 +519,11 @@
|
||||
* what keeps the restore independent of when the prop it was assigned to reaches this
|
||||
* component.
|
||||
*/
|
||||
export function open(parked?: WizardResume) {
|
||||
export async function open(parked?: WizardResume) {
|
||||
// Awaited before `reset`, which seeds the resource path from `defaultFolder()` and so
|
||||
// needs the destination's username. Seeding first and correcting later loses whenever
|
||||
// the folder list resolves first, and never corrects at all if `whoami` fails.
|
||||
await loadTargetUser()
|
||||
reset(parked ?? resume)
|
||||
opened = true
|
||||
}
|
||||
@@ -472,12 +571,14 @@
|
||||
// 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.
|
||||
// Read off one attempt against one project, so it does not survive a change of inputs:
|
||||
// the review step would otherwise warn about a limitation that does not apply to what
|
||||
// it is describing, 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 = ''
|
||||
finishAlsoFailed = false
|
||||
if (maxStep > wiz.step) maxStep = wiz.step
|
||||
}
|
||||
|
||||
@@ -527,7 +628,7 @@
|
||||
settle({ checking: false, report: undefined, error: undefined })
|
||||
return
|
||||
}
|
||||
const report = await probeDatatableConnection($workspaceStore!, database)
|
||||
const report = await probeDatatableConnection(targetWorkspace, database)
|
||||
settle({ checking: false, report, error: undefined })
|
||||
} catch (err: any) {
|
||||
settle({
|
||||
@@ -579,6 +680,13 @@
|
||||
)
|
||||
/** Why the last run failed, kept on the review step after the checklist is dropped. */
|
||||
let lastFailure = $state('')
|
||||
/**
|
||||
* The appended `onFinishAlso` step failed while `runSetup` itself succeeded. Tracked apart
|
||||
* from `run.result`, which stays the setup's own verdict: the data table really was
|
||||
* created, so a retry must re-run only this last step. Re-running the setup would ask for
|
||||
* the table name it has just taken, and be refused as a duplicate.
|
||||
*/
|
||||
let finishAlsoFailed = $state(false)
|
||||
|
||||
/**
|
||||
* A refused pre-flight means nothing ran, so the checklist from a previous attempt has to
|
||||
@@ -631,7 +739,7 @@
|
||||
const name = wiz.review.name.trim()
|
||||
try {
|
||||
if (claimedName !== name) {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
const settings = await WorkspaceService.getSettings({ workspace: targetWorkspace })
|
||||
if (settings.datatable?.datatables?.[name]) {
|
||||
nameConflictFor = {
|
||||
name,
|
||||
@@ -666,6 +774,7 @@
|
||||
}
|
||||
run = { steps: planSteps(wiz), running: true }
|
||||
lastFailure = ''
|
||||
finishAlsoFailed = false
|
||||
// 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') {
|
||||
@@ -675,7 +784,7 @@
|
||||
let result: RunResult | undefined = undefined
|
||||
try {
|
||||
result = await runSetup(wiz, {
|
||||
workspace: $workspaceStore!,
|
||||
workspace: targetWorkspace,
|
||||
supabaseToken: supaOauth.token,
|
||||
onInstanceDbsChanged: async () => {
|
||||
await customInstanceDbs.refetch()
|
||||
@@ -684,9 +793,27 @@
|
||||
onPoolerUnavailable: (reason) => (poolerUnavailable = reason),
|
||||
createdProjects,
|
||||
claims,
|
||||
username: $userStore?.username ?? ''
|
||||
username: targetUsername
|
||||
})
|
||||
} finally {
|
||||
// The caller's own finishing work, appended to the same checklist. It only runs
|
||||
// on a clean setup: there is no table for it to act on otherwise.
|
||||
if (result?.ok && onFinishAlso && finishAlso) {
|
||||
const title = finishAlso.charAt(0).toUpperCase() + finishAlso.slice(1)
|
||||
run.steps = [...run.steps, { title, status: 'running' }]
|
||||
try {
|
||||
await onFinishAlso()
|
||||
run.steps = run.steps.map((s, i) =>
|
||||
i === run.steps.length - 1 ? { ...s, status: 'done' as const } : s
|
||||
)
|
||||
} catch (err: any) {
|
||||
const description = err?.body ?? err?.message ?? String(err)
|
||||
run.steps = run.steps.map((s, i) =>
|
||||
i === run.steps.length - 1 ? { ...s, status: 'failed' as const, description } : s
|
||||
)
|
||||
finishAlsoFailed = true
|
||||
}
|
||||
}
|
||||
// `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
|
||||
@@ -713,7 +840,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* that made its data table has nothing left to lose — including when `onFinishAlso` failed
|
||||
* afterwards, because the table is real and working and the caller owns what is left. The
|
||||
* import step, the only caller that passes one, shows that failure on its own row with a
|
||||
* way to run it again and will not let Finish through while it stands.
|
||||
*/
|
||||
function hasUnfinishedIntent(): boolean {
|
||||
return wiz.provider !== undefined && !run.running && !run.result?.ok
|
||||
@@ -730,19 +860,52 @@
|
||||
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()
|
||||
// `finally`, because the flag is what blocks a second attempt: an `ask` that throws
|
||||
// would otherwise leave the dialog permanently undismissable — the backdrop, Escape
|
||||
// and the close button all return early here, so the only way out would be a reload.
|
||||
try {
|
||||
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'
|
||||
})
|
||||
// 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()
|
||||
} finally {
|
||||
dismissing = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-runs only the appended step, which is the only thing that failed. */
|
||||
async function retryFinishAlso() {
|
||||
if (!onFinishAlso || !finishAlso) return
|
||||
const title = finishAlso.charAt(0).toUpperCase() + finishAlso.slice(1)
|
||||
finishAlsoFailed = false
|
||||
run = {
|
||||
...run,
|
||||
running: true,
|
||||
steps: [...run.steps.slice(0, -1), { title, status: 'running' as const }]
|
||||
}
|
||||
try {
|
||||
await onFinishAlso()
|
||||
run.steps = run.steps.map((s, i) =>
|
||||
i === run.steps.length - 1 ? { ...s, status: 'done' as const } : s
|
||||
)
|
||||
} catch (err: any) {
|
||||
const description = err?.body ?? err?.message ?? String(err)
|
||||
run.steps = run.steps.map((s, i) =>
|
||||
i === run.steps.length - 1 ? { ...s, status: 'failed' as const, description } : s
|
||||
)
|
||||
finishAlsoFailed = true
|
||||
} finally {
|
||||
run = { ...run, running: false }
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -752,10 +915,18 @@
|
||||
// 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(() => {
|
||||
// Ahead of everything: without the destination's membership the resource path would be
|
||||
// guessed, and a guess here writes database credentials into somebody else's namespace.
|
||||
if (aimedElsewhere && membershipFailed)
|
||||
return { label: 'Cannot read your access to this workspace', disabled: true }
|
||||
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 }
|
||||
// Before the `ok` check: the setup succeeded and the step after it did not, so
|
||||
// "Done" would be offered over a failed row.
|
||||
if (finishAlsoFailed)
|
||||
return { label: 'Try again', disabled: false, act: retryFinishAlso }
|
||||
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.
|
||||
@@ -809,11 +980,12 @@
|
||||
act: enterReview
|
||||
}
|
||||
}
|
||||
const created =
|
||||
wiz.provider === 'supabase' && wiz.supabase.mode === 'create'
|
||||
? 'Create project and data table'
|
||||
: 'Create data table'
|
||||
return {
|
||||
label:
|
||||
wiz.provider === 'supabase' && wiz.supabase.mode === 'create'
|
||||
? 'Create project and data table'
|
||||
: 'Create data table',
|
||||
label: finishAlso ? `${created} and ${finishAlso}` : created,
|
||||
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.
|
||||
@@ -842,7 +1014,7 @@
|
||||
else opened = v
|
||||
}
|
||||
}
|
||||
target="#content"
|
||||
target={modalTarget}
|
||||
formStyling
|
||||
title="Add a data table"
|
||||
contentClasses="flex flex-col"
|
||||
@@ -1034,7 +1206,7 @@
|
||||
{#if wiz.instance.mode === 'existing'}
|
||||
{@const shared = (
|
||||
customInstanceDbs.current?.[wiz.instance.dbName ?? '']?.used_by_workspaces ?? []
|
||||
).filter((w) => w !== $workspaceStore)}
|
||||
).filter((w) => w !== targetWorkspace)}
|
||||
<!-- 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}
|
||||
@@ -1047,7 +1219,7 @@
|
||||
<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)}
|
||||
{@const others = (db.used_by_workspaces ?? []).filter((w) => w !== targetWorkspace)}
|
||||
<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'
|
||||
@@ -1325,8 +1497,14 @@
|
||||
<TextInput
|
||||
bind:value={wiz.review.name}
|
||||
error={!!nameError || !!nameConflict}
|
||||
inputProps={{ placeholder: 'main' }}
|
||||
inputProps={{ placeholder: 'main', disabled: nameLocked }}
|
||||
/>
|
||||
{#if nameLocked}
|
||||
<p class="text-2xs text-secondary">
|
||||
Fixed: the project's migrations target this name, and they run against it whatever
|
||||
this table ends up called.
|
||||
</p>
|
||||
{/if}
|
||||
<InputError error={nameError ?? (nameConflict || undefined)} />
|
||||
</Label>
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import {
|
||||
JobService,
|
||||
ResourceService,
|
||||
SettingService,
|
||||
UserService,
|
||||
VariableService,
|
||||
WorkspaceService,
|
||||
@@ -47,7 +46,12 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import TestAIKey from '$lib/components/copilot/TestAIKey.svelte'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import {
|
||||
enterNewWorkspace,
|
||||
loadUsernamePolicy,
|
||||
refreshWorkspaceList
|
||||
} from '$lib/workspaceCreation'
|
||||
import { validateWorkspaceId } from '$lib/utils/workspaceId'
|
||||
import { deleteSessionsForWorkspace } from '$lib/components/sessions/sessionState.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
@@ -270,13 +274,10 @@
|
||||
errorId = forkIdTaken
|
||||
? `A workspace with id '${effectiveId}' already exists. It may be an archived fork: archiving keeps the id reserved.`
|
||||
: 'ID already exists'
|
||||
} else if (id != '' && !/^\w+(-\w+)*$/.test(id)) {
|
||||
errorId = 'ID can only contain letters, numbers and dashes and must not finish by a dash'
|
||||
} else if (effectiveId.length > 50) {
|
||||
// `wm-fork-` prefix included: matches the backend's 50-char (git-branch / DB) limit.
|
||||
errorId = `ID '${effectiveId}' is too long (${effectiveId.length} chars). Maximum is 50.`
|
||||
} else {
|
||||
errorId = ''
|
||||
// `effectiveId` carries the `wm-fork-` prefix into the length check, since
|
||||
// that is what the backend stores.
|
||||
errorId = (id != '' && validateWorkspaceId(id, effectiveId)) || ''
|
||||
}
|
||||
checking = false
|
||||
}
|
||||
@@ -482,8 +483,7 @@
|
||||
: `Successfully forked workspace ${baseWorkspaceId} as: wm-fork-${id}`
|
||||
)
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(prefixed_id)
|
||||
await enterNewWorkspace(prefixed_id)
|
||||
|
||||
onFinish?.()
|
||||
}
|
||||
@@ -552,15 +552,11 @@
|
||||
}
|
||||
: {}
|
||||
})
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(id)
|
||||
}
|
||||
|
||||
sendUserToast(`Created workspace id: ${id}`)
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(id)
|
||||
await enterNewWorkspace(id)
|
||||
onFinish?.()
|
||||
}
|
||||
|
||||
@@ -575,7 +571,7 @@
|
||||
async function loadWorkspaces() {
|
||||
if (!$usersWorkspaceStore) {
|
||||
try {
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
await refreshWorkspaceList()
|
||||
} catch {}
|
||||
}
|
||||
if (!$usersWorkspaceStore) {
|
||||
@@ -587,20 +583,10 @@
|
||||
|
||||
let automateUsernameCreation = $state(true)
|
||||
async function getAutomateUsernameCreationSetting() {
|
||||
automateUsernameCreation =
|
||||
((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? true
|
||||
|
||||
if (!automateUsernameCreation) {
|
||||
UserService.globalWhoami().then((x) => {
|
||||
let uname = ''
|
||||
if (x.name) {
|
||||
uname = x.name.split(' ')[0]
|
||||
} else {
|
||||
uname = x.email.split('@')[0]
|
||||
}
|
||||
uname = uname.replace(/\./gi, '')
|
||||
username = uname.toLowerCase()
|
||||
})
|
||||
const policy = await loadUsernamePolicy()
|
||||
automateUsernameCreation = policy.automate
|
||||
if (policy.suggested) {
|
||||
username = policy.suggested
|
||||
}
|
||||
}
|
||||
getAutomateUsernameCreationSetting()
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface InstallResult {
|
||||
path: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
/**
|
||||
* Already in the destination, so nothing was written. Not a failure and not an import —
|
||||
* reporting it as either would be a lie, and the difference is what a retry is for.
|
||||
*/
|
||||
skipped?: boolean
|
||||
}
|
||||
|
||||
// Guarding an item's own path is not enough: the `$res:`/script/flow refs baked
|
||||
@@ -201,7 +206,11 @@ async function importApp(workspace: string, a: ExportItem): Promise<unknown> {
|
||||
// Apply one migration to the target data table. If the data table opted into
|
||||
// migrations, record it (datatable_migrations + _wm_migrations, run only this
|
||||
// version); otherwise run the SQL once as a preview job (unrecorded).
|
||||
async function applyOneMigration(
|
||||
//
|
||||
// Exported because the import can leave migrations unapplied: a data table the
|
||||
// project needs may not be configured in the destination yet, and the wizard's setup
|
||||
// step runs them once it is.
|
||||
export async function applyOneMigration(
|
||||
workspace: string,
|
||||
projectSlug: string,
|
||||
m: ProjectMigration
|
||||
@@ -254,15 +263,73 @@ async function applyOneMigration(
|
||||
* reviewed) migrations. Each item's outcome is reported through `onResult`;
|
||||
* failures never abort the remaining items.
|
||||
*/
|
||||
/**
|
||||
* The kinds an import writes that carry a path and can therefore already be there.
|
||||
*
|
||||
* Triggers carry their own kind too (`trigger:schedule`, `trigger:http`, … — the values of
|
||||
* `WorkspaceTriggerKind`): each trigger kind is a separate table keyed on
|
||||
* `(path, workspace_id)`, so one workspace can hold a schedule and an HTTP trigger both
|
||||
* called `f/cal/sync`. Flattening them to `trigger` would let whichever exists answer for
|
||||
* the other.
|
||||
*/
|
||||
export type ImportedKind =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'resource'
|
||||
| `trigger:${string}`
|
||||
|
||||
/**
|
||||
* The key `alreadyPresent` is built and read with. Kind and path together, because the kinds
|
||||
* share one path namespace and a bare path cannot say which of them is already there.
|
||||
*/
|
||||
export function presenceKey(kind: ImportedKind, path: string): string {
|
||||
return `${kind}:${path}`
|
||||
}
|
||||
|
||||
export async function installProject(args: {
|
||||
workspace: string
|
||||
exportData: ProjectExport
|
||||
folder: string
|
||||
migrations: ProjectMigration[]
|
||||
/** Called once, before the reviewed migrations are applied, when there are any. Lets a
|
||||
* caller show them as their own step rather than folding them into the item import. */
|
||||
onMigrationsStart?: () => void
|
||||
/**
|
||||
* Asked before each write. Returning true stops the run where it is — the writes already
|
||||
* made stay, the rest never start. Nothing here can cancel a request already in flight,
|
||||
* so this is the granularity available without threading an `AbortSignal` through every
|
||||
* service call: the import wizard uses it when the user confirms leaving mid-run.
|
||||
*/
|
||||
stopped?: () => boolean
|
||||
/**
|
||||
* What is already in the destination, as `presenceKey` keys — so a retry writes only what
|
||||
* is missing instead of replaying the bundle into a wall of "already exists". Built from
|
||||
* retargeted paths, because that is what these items will actually be called.
|
||||
*
|
||||
* Keyed by kind and not by path alone: the five kinds share one `f/<folder>/` namespace, so
|
||||
* a trigger and a script may legitimately both be called `f/cal/sync`. A flat path set
|
||||
* would let either one mask the other and silently skip an item that was never imported.
|
||||
*
|
||||
* Never a way to *replace* anything: an item that is there is left exactly as it is,
|
||||
* which is the same promise `updateIfExists: false` makes for a resource whose value
|
||||
* someone has since filled in.
|
||||
*/
|
||||
alreadyPresent?: Set<string>
|
||||
hasEeLicense: boolean
|
||||
onResult: (r: InstallResult) => void
|
||||
}): Promise<void> {
|
||||
const { workspace, exportData, folder, migrations, hasEeLicense, onResult } = args
|
||||
const {
|
||||
workspace,
|
||||
exportData,
|
||||
folder,
|
||||
migrations,
|
||||
hasEeLicense,
|
||||
onResult,
|
||||
onMigrationsStart,
|
||||
stopped,
|
||||
alreadyPresent
|
||||
} = args
|
||||
|
||||
const record = (path: string, p: Promise<unknown>): Promise<void> =>
|
||||
p.then(
|
||||
@@ -270,6 +337,20 @@ export async function installProject(args: {
|
||||
(e: any) => onResult({ path, ok: false, error: errorMessage(e) })
|
||||
)
|
||||
|
||||
/** Every write goes through here, so one check covers items, variables and migrations. */
|
||||
const halted = () => stopped?.() === true
|
||||
|
||||
/**
|
||||
* True when the destination already has this path, so the write is not attempted. Reported
|
||||
* rather than dropped: the checklist has to account for every item the project ships, and
|
||||
* "already there" is a different thing from "imported".
|
||||
*/
|
||||
const present = (kind: ImportedKind, path: string): boolean => {
|
||||
if (!alreadyPresent?.has(presenceKey(kind, path))) return false
|
||||
onResult({ path, ok: true, skipped: true })
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await FolderService.createFolder({ workspace, requestBody: { name: folder } })
|
||||
} catch {}
|
||||
@@ -309,6 +390,8 @@ export async function installProject(args: {
|
||||
}
|
||||
|
||||
for (const s of proj.scripts) {
|
||||
if (halted()) return
|
||||
if (present('script', s.path)) continue
|
||||
// `$var:` is resolved in job args (flow inputs, schedule args, trigger config),
|
||||
// not in script source, so there is no variable arg to contain here.
|
||||
await checkedItem(s.path, extractScriptRefs(s.content ?? ''), undefined, () =>
|
||||
@@ -316,19 +399,26 @@ export async function installProject(args: {
|
||||
)
|
||||
}
|
||||
for (const f of proj.flows) {
|
||||
if (halted()) return
|
||||
if (present('flow', f.path)) continue
|
||||
await checkedItem(f.path, extractFlowRefs(f.value), f.value, () => importFlow(workspace, f))
|
||||
}
|
||||
for (const r of proj.resources) {
|
||||
if (halted()) return
|
||||
if (present('resource', r.path)) continue
|
||||
await checked(r.path, () => importResourceStub(workspace, r))
|
||||
}
|
||||
// Placeholders for the project's internal `$var:`/`$jsonvar:` refs (retargeted
|
||||
// into this folder). External refs are rejected per-item, so only stub in-folder
|
||||
// ones; guard again in case an out-of-folder ref slipped through retargeting.
|
||||
for (const p of collectExportVarPaths(proj)) {
|
||||
if (halted()) return
|
||||
if (!p.startsWith(prefix)) continue
|
||||
await record(`variable: ${p}`, importVariablePlaceholder(workspace, p))
|
||||
}
|
||||
for (const a of proj.apps) {
|
||||
if (halted()) return
|
||||
if (present('app', a.path)) continue
|
||||
const isRaw = a.app_type === 'raw'
|
||||
const refs = isRaw ? extractRawAppRefs(a.value?.raw ?? '') : extractAppRefs(a.value)
|
||||
// Raw apps hold their runnables in the `value.raw` JSON string; parse it so the
|
||||
@@ -368,6 +458,8 @@ export async function installProject(args: {
|
||||
return varContainmentViolation(cfg, folder)
|
||||
}
|
||||
for (const t of proj.triggers) {
|
||||
if (halted()) return
|
||||
if (present(`trigger:${t.kind}`, String(t.path))) continue
|
||||
const violation = guard(t.path, t.runnable_path) ?? triggerConfigViolation(t)
|
||||
await record(
|
||||
String(t.path),
|
||||
@@ -389,7 +481,10 @@ export async function installProject(args: {
|
||||
}
|
||||
|
||||
// Apply the reviewed data table migrations after items exist.
|
||||
if (halted()) return
|
||||
if (migrations.length) onMigrationsStart?.()
|
||||
for (const m of migrations) {
|
||||
if (halted()) return
|
||||
await record(
|
||||
`data table: ${m.datatable_name}`,
|
||||
applyOneMigration(workspace, exportData.project.slug, m)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* A retry must not re-write what the destination already has. The interesting kind is the
|
||||
* trigger: it is the one item the import creates through an API that rejects an existing
|
||||
* path outright, so replaying it turns "already there" into a reported failure.
|
||||
*/
|
||||
|
||||
const created = vi.hoisted(() => ({ triggers: [] as string[] }))
|
||||
|
||||
vi.mock('$lib/gen', () => {
|
||||
const nothing = vi.fn(async () => [])
|
||||
return {
|
||||
AppService: { createApp: vi.fn(), listApps: nothing },
|
||||
FlowService: { createFlow: vi.fn(), listFlows: nothing },
|
||||
FolderService: { createFolder: vi.fn() },
|
||||
ResourceService: { createResource: vi.fn(), listResource: nothing },
|
||||
ScriptService: { createScript: vi.fn(), listScripts: nothing },
|
||||
VariableService: { createVariable: vi.fn(), listVariable: nothing },
|
||||
WorkspaceService: { listDataTables: vi.fn(async () => []) }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../triggers/workspaceTriggersList', () => ({
|
||||
TRIGGER_KINDS: {
|
||||
schedule: { badge: 'Schedule', resourceField: undefined },
|
||||
http: { badge: 'HTTP', resourceField: undefined }
|
||||
},
|
||||
createWorkspaceTriggerDisabled: vi.fn(async (_ws: string, t: { path: string; kind: string }) => {
|
||||
created.triggers.push(`${t.kind}:${t.path}`)
|
||||
}),
|
||||
triggerHandlerRefs: () => []
|
||||
}))
|
||||
|
||||
import { installProject, presenceKey } from './projectInstall'
|
||||
|
||||
const exportData = {
|
||||
project: { slug: 'calendly', name: 'Calendly', summary: '', readme: null },
|
||||
scripts: [],
|
||||
flows: [],
|
||||
apps: [],
|
||||
resources: [],
|
||||
variables: [],
|
||||
triggers: [
|
||||
{
|
||||
kind: 'schedule',
|
||||
path: 'f/calendly/nightly',
|
||||
runnable_path: 'f/calendly/sync',
|
||||
runnable_kind: 'script',
|
||||
summary: null,
|
||||
config: {}
|
||||
}
|
||||
],
|
||||
migrations: []
|
||||
} as any
|
||||
|
||||
async function run(alreadyPresent?: Set<string>) {
|
||||
const results: any[] = []
|
||||
await installProject({
|
||||
workspace: 'w',
|
||||
exportData,
|
||||
folder: 'calendly',
|
||||
migrations: [],
|
||||
hasEeLicense: true,
|
||||
alreadyPresent,
|
||||
onResult: (r) => results.push(r)
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
describe('installProject presence', () => {
|
||||
beforeEach(() => {
|
||||
created.triggers = []
|
||||
})
|
||||
|
||||
it('creates a trigger the destination does not have', async () => {
|
||||
const results = await run(new Set())
|
||||
expect(created.triggers).toEqual(['schedule:f/calendly/nightly'])
|
||||
expect(results).toContainEqual({ path: 'f/calendly/nightly', ok: true })
|
||||
})
|
||||
|
||||
// Without the skip the retry calls the create API again, which rejects the existing
|
||||
// path, and the row reads as a failure for something that is already there.
|
||||
it('skips a trigger that is already there instead of re-creating it', async () => {
|
||||
const results = await run(new Set([presenceKey('trigger:schedule', 'f/calendly/nightly')]))
|
||||
expect(created.triggers).toEqual([])
|
||||
expect(results).toContainEqual({ path: 'f/calendly/nightly', ok: true, skipped: true })
|
||||
})
|
||||
|
||||
// Kinds share one `f/<folder>/` namespace, so the key has to carry the kind: a script of
|
||||
// the same name is not this trigger and must not stand in for it.
|
||||
it('does not let another kind at the same path mask the trigger', async () => {
|
||||
const results = await run(new Set([presenceKey('script', 'f/calendly/nightly')]))
|
||||
expect(created.triggers).toEqual(['schedule:f/calendly/nightly'])
|
||||
expect(results).toContainEqual({ path: 'f/calendly/nightly', ok: true })
|
||||
})
|
||||
|
||||
// Each trigger kind is its own table keyed on (path, workspace_id), so a workspace can
|
||||
// hold a schedule and an HTTP trigger both called `f/calendly/nightly`. The one that
|
||||
// exists must not answer for the one that does not.
|
||||
it('does not let another trigger kind at the same path mask this one', async () => {
|
||||
const results = await run(new Set([presenceKey('trigger:http', 'f/calendly/nightly')]))
|
||||
expect(created.triggers).toEqual(['schedule:f/calendly/nightly'])
|
||||
expect(results).toContainEqual({ path: 'f/calendly/nightly', ok: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Component } from 'svelte'
|
||||
import { appIconComponent } from '$lib/components/icons'
|
||||
import { SettingService } from '$lib/gen'
|
||||
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
|
||||
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
|
||||
|
||||
/**
|
||||
* The browser-reachable hub. `hub_accessible_url` exists precisely for this: on a
|
||||
* private instance `hub_base_url` may be an address only the server can resolve.
|
||||
* The (logged) layout does the same lookup, but the import wizard renders outside
|
||||
* it, so it has to ask for itself.
|
||||
*/
|
||||
export async function hubBrowserUrl(): Promise<string> {
|
||||
try {
|
||||
const accessible = (await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string
|
||||
if (accessible) return accessible.replace(/\/+$/, '')
|
||||
const base = (await SettingService.getGlobal({ key: 'hub_base_url' })) as string
|
||||
if (base) return base.replace(/\/+$/, '')
|
||||
} catch {
|
||||
// Unset or unreadable — the public hub is the right default either way.
|
||||
}
|
||||
return DEFAULT_HUB_BASE_URL.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** Shape of `GET <hub>/projects/<slug>` — the hub's own summary endpoint. */
|
||||
interface HubProject {
|
||||
slug: string
|
||||
name: string
|
||||
summary: string
|
||||
author: string
|
||||
apps: string[]
|
||||
logoApp: string | null
|
||||
hasLogo: boolean
|
||||
counts: { scripts: number; flows: number; apps: number; resources: number; total: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one project's presentation straight from the hub. Cross-origin and
|
||||
* unauthenticated by design: this runs before the wizard has a workspace, so the
|
||||
* workspace-scoped `/api/w/<ws>/hub/...` proxy is not available yet. The endpoint
|
||||
* is public and sends `Access-Control-Allow-Origin: *`.
|
||||
*/
|
||||
export async function fetchHubProject(slug: string): Promise<ImportProjectSummary> {
|
||||
const hub = await hubBrowserUrl()
|
||||
const res = await fetch(`${hub}/projects/${encodeURIComponent(slug)}`, {
|
||||
headers: { accept: 'application/json' }
|
||||
})
|
||||
if (!res.ok) throw new Error(`hub returned ${res.status}`)
|
||||
const p = (await res.json()) as HubProject
|
||||
return {
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
summary: p.summary,
|
||||
author: p.author,
|
||||
apps: p.apps ?? [],
|
||||
// A project with an uploaded logo shows that; otherwise the icon of the
|
||||
// integration it is filed under, otherwise its first integration.
|
||||
logoUrl: p.hasLogo ? `${hub}/projects/${encodeURIComponent(p.slug)}/logo` : undefined,
|
||||
iconApps: [p.logoApp, ...(p.apps ?? [])].filter(
|
||||
(a, i, all): a is string => !!a && all.indexOf(a) === i
|
||||
),
|
||||
counts: {
|
||||
apps: p.counts?.apps ?? 0,
|
||||
flows: p.counts?.flows ?? 0,
|
||||
scripts: p.counts?.scripts ?? 0,
|
||||
resources: p.counts?.resources ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon for a hub integration slug, resolved from the icons Windmill already bundles.
|
||||
*
|
||||
* Not fetched from the hub: the hub renders these out of `@windmill-labs/components`, which
|
||||
* is this frontend's own package, so asking it over HTTP is a round trip to get our own
|
||||
* assets back — and it made the card depend on a cross-origin request that an `API_SECRET`
|
||||
* hub refuses anyway.
|
||||
*
|
||||
* The alias exists because the two repos disagree on one slug: the hub files Postgres scripts
|
||||
* under `postgres`, the icon set ships the mark as `postgresql`. The hub bridges it in
|
||||
* `aliasApp`; this is the same bridge on the consuming side.
|
||||
*/
|
||||
const HUB_APP_ICON_ALIAS: Record<string, string> = { postgres: 'postgresql' }
|
||||
|
||||
export function hubAppIcon(app: string): Component | undefined {
|
||||
return appIconComponent(HUB_APP_ICON_ALIAS[app] ?? app)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Everything the executor reaches over the network, stubbed. The two behaviours under test
|
||||
// are decisions it makes around those calls, not the calls themselves.
|
||||
vi.mock('$lib/gen', () => ({
|
||||
WorkspaceService: {
|
||||
createWorkspace: vi.fn(),
|
||||
listDataTables: vi.fn(async () => []),
|
||||
// No workspace of ours at that id: the existing-workspace plan these tests use never
|
||||
// reaches the create, and an empty list is the honest answer for a fresh instance.
|
||||
listWorkspaces: vi.fn(async () => [])
|
||||
},
|
||||
UserService: {
|
||||
whoami: vi.fn(async () => ({ username: 'u' })),
|
||||
globalWhoami: vi.fn(async () => ({ email: 'u@example.com' }))
|
||||
}
|
||||
}))
|
||||
vi.mock('$lib/storeUtils', () => ({ switchWorkspace: vi.fn() }))
|
||||
// What the destination already holds. A test sets this to stand in for a workspace that has
|
||||
// some of the bundle in it — a half-finished run, or an existing workspace.
|
||||
const present = vi.hoisted(() => ({
|
||||
paths: new Set<string>(),
|
||||
/** Fires when the probe is entered, so a test can abandon while it is in flight. */
|
||||
onProbe: undefined as (() => void) | undefined
|
||||
}))
|
||||
vi.mock('./probe', async (orig) => ({
|
||||
...(await orig<typeof import('./probe')>()),
|
||||
probeWorkspace: vi.fn(async () => ({ exists: false, ours: false })),
|
||||
probeImportedPaths: vi.fn(async () => {
|
||||
present.onProbe?.()
|
||||
return present.paths
|
||||
})
|
||||
}))
|
||||
vi.mock('$lib/user', () => ({ getUserExt: vi.fn(async () => ({ username: 'u' })) }))
|
||||
// Let a test abandon *during* a write loop, which is the only way it happens for real:
|
||||
// `run()` clears the flag on entry so a retry can proceed. Two hooks, because the item and
|
||||
// migration phases stop in different places and the second is what pins the migrate row.
|
||||
const hooks = vi.hoisted(() => ({
|
||||
afterFirstItem: undefined as (() => void) | undefined,
|
||||
afterMigrationsStart: undefined as (() => void) | undefined
|
||||
}))
|
||||
|
||||
vi.mock('$lib/components/workspaceSettings/projectInstall', () => ({
|
||||
installProject: vi.fn(async (args: any) => {
|
||||
// Ordered as the real one is: every item loop, then `onMigrationsStart`, then the
|
||||
// migrations — and `stopped` checked before each write, returning the same way it
|
||||
// returns on success.
|
||||
for (const path of ['a', 'b', 'c']) {
|
||||
if (args.stopped?.() === true) return
|
||||
// Keyed exactly as the real `installProject` keys it, so this stand-in cannot drift
|
||||
// into testing a contract the production code does not have.
|
||||
if (args.alreadyPresent?.has(`script:${path}`)) {
|
||||
args.onResult({ path, ok: true, skipped: true })
|
||||
continue
|
||||
}
|
||||
args.onResult({ path, ok: true })
|
||||
hooks.afterFirstItem?.()
|
||||
hooks.afterFirstItem = undefined
|
||||
}
|
||||
if (args.stopped?.() === true) return
|
||||
if (args.migrations?.length) {
|
||||
args.onMigrationsStart?.()
|
||||
hooks.afterMigrationsStart?.()
|
||||
hooks.afterMigrationsStart = undefined
|
||||
for (const m of args.migrations) {
|
||||
if (args.stopped?.() === true) return
|
||||
args.onResult({ path: `data table: ${m.datatable_name}`, ok: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// The export the run fetches from the hub proxy. Two items so an abandoned run can stop
|
||||
// partway through, which is the case under test.
|
||||
const EXPORT = {
|
||||
project: { slug: 'calendly', name: 'Calendly', summary: '', readme: null },
|
||||
scripts: [],
|
||||
flows: [],
|
||||
apps: [],
|
||||
resources: [],
|
||||
triggers: [],
|
||||
migrations: []
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, status: 200, text: async () => JSON.stringify(EXPORT) }))
|
||||
)
|
||||
|
||||
import { ImportExecution } from './execution.svelte'
|
||||
|
||||
const PLAN = { slug: 'calendly', destination: { kind: 'existing' as const, workspaceId: 'ws-a' } }
|
||||
const deps = { reviewMigrations: async () => [], hasEeLicense: false }
|
||||
|
||||
/** A project that ships one, so `#import` appends the `migrate` row at all. */
|
||||
const MIGRATION = {
|
||||
datatable_name: 'main',
|
||||
sql: 'CREATE TABLE IF NOT EXISTS "calendly"."config" (id int)',
|
||||
sql_down: '',
|
||||
enabled: true
|
||||
}
|
||||
const depsWithMigration = { reviewMigrations: async () => [MIGRATION], hasEeLicense: false }
|
||||
|
||||
describe('planTag', () => {
|
||||
// A run handed back after a remount is checked against the plan being rendered. Tagging
|
||||
// it with that plan instead of its own would make the check pass by construction.
|
||||
it('identifies the plan the run belongs to', () => {
|
||||
const a = new ImportExecution(PLAN, deps)
|
||||
const b = new ImportExecution(
|
||||
{ ...PLAN, destination: { kind: 'existing', workspaceId: 'ws-b' } },
|
||||
deps
|
||||
)
|
||||
expect(a.planTag).not.toBe(b.planTag)
|
||||
})
|
||||
|
||||
it('ignores the folder, which stays editable after the run is made', () => {
|
||||
const a = new ImportExecution(PLAN, deps)
|
||||
const tag = a.planTag
|
||||
a.setFolder('somewhere-else')
|
||||
expect(a.planTag).toBe(tag)
|
||||
})
|
||||
|
||||
it('separates two projects going to the same workspace', () => {
|
||||
const a = new ImportExecution(PLAN, deps)
|
||||
const b = new ImportExecution({ ...PLAN, slug: 'bitly' }, deps)
|
||||
expect(a.planTag).not.toBe(b.planTag)
|
||||
})
|
||||
})
|
||||
|
||||
describe('abandoning mid-import', () => {
|
||||
beforeEach(() => {
|
||||
hooks.afterFirstItem = undefined
|
||||
hooks.afterMigrationsStart = undefined
|
||||
present.paths = new Set()
|
||||
})
|
||||
|
||||
it('does not report done, so the resumed step offers Retry rather than Continue', async () => {
|
||||
const run = new ImportExecution(PLAN, deps)
|
||||
hooks.afterFirstItem = () => run.abandon()
|
||||
await run.run()
|
||||
// `installProject` returned early exactly as it does on success; calling that done
|
||||
// would report a clean import over items that never started.
|
||||
expect(run.done).toBe(false)
|
||||
expect(run.error).toMatch(/stopped/i)
|
||||
})
|
||||
|
||||
it('reports done when nothing abandoned it', async () => {
|
||||
const run = new ImportExecution(PLAN, deps)
|
||||
await run.run()
|
||||
expect(run.done).toBe(true)
|
||||
expect(run.itemResults.length).toBe(3)
|
||||
})
|
||||
|
||||
it('stops the migrate row spinning when it is abandoned mid-migration', async () => {
|
||||
const run = new ImportExecution(PLAN, depsWithMigration)
|
||||
// After `onMigrationsStart`, which is where the row is actually set to running —
|
||||
// the real one fires it at the head of the migration loop, past every item loop.
|
||||
hooks.afterMigrationsStart = () => run.abandon()
|
||||
await run.run()
|
||||
const migrate = run.tasks.find((t) => t.key === 'migrate')
|
||||
// Guards the test itself: without a migration in the export there is no row, and the
|
||||
// assertion below would pass over a branch that never ran.
|
||||
expect(migrate).toBeDefined()
|
||||
// A row left on `running` reads as work still in progress on a run that has stopped.
|
||||
expect(migrate?.status).not.toBe('running')
|
||||
expect(run.done).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A retry must write only what is missing. Resending the whole bundle turns every item that
|
||||
* already landed into an "already exists" failure — a wall of red over work that succeeded.
|
||||
* What is already there is skipped, and reported as skipped rather than as imported, because
|
||||
* the checklist has to account for every item the project ships.
|
||||
*/
|
||||
describe('retrying over what is already there', () => {
|
||||
beforeEach(() => {
|
||||
present.paths = new Set()
|
||||
})
|
||||
|
||||
it('writes nothing for a path the destination already holds', async () => {
|
||||
present.paths = new Set(['script:a', 'script:b'])
|
||||
const run = new ImportExecution(PLAN, deps)
|
||||
await run.run()
|
||||
const byPath = new Map(run.itemResults.map((r) => [r.path, r]))
|
||||
expect(byPath.get('a')?.skipped).toBe(true)
|
||||
expect(byPath.get('b')?.skipped).toBe(true)
|
||||
expect(byPath.get('c')?.skipped).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still accounts for every item, so the checklist stays complete', async () => {
|
||||
present.paths = new Set(['script:a', 'script:b'])
|
||||
const run = new ImportExecution(PLAN, deps)
|
||||
await run.run()
|
||||
expect(run.itemResults.length).toBe(3)
|
||||
expect(run.done).toBe(true)
|
||||
})
|
||||
|
||||
it('says what it did rather than claiming to have imported all of it', async () => {
|
||||
present.paths = new Set(['script:a', 'script:b'])
|
||||
const run = new ImportExecution(PLAN, deps)
|
||||
await run.run()
|
||||
const importRow = run.tasks.find((t) => t.key === 'import')
|
||||
expect(importRow?.detail).toMatch(/1 imported/)
|
||||
expect(importRow?.detail).toMatch(/2 already there/)
|
||||
})
|
||||
|
||||
it('imports everything when the destination is empty', async () => {
|
||||
const run = new ImportExecution(PLAN, deps)
|
||||
await run.run()
|
||||
expect(run.itemResults.every((r) => !r.skipped)).toBe(true)
|
||||
expect(run.tasks.find((t) => t.key === 'import')?.detail).toMatch(/3 imported/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('abandoning while the presence probe is in flight', () => {
|
||||
beforeEach(() => {
|
||||
present.paths = new Set()
|
||||
present.onProbe = undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* `import` goes `running` before the probe is asked, so returning straight out of an
|
||||
* abandon here leaves a spinner on a run that has stopped — next to an enabled Retry and
|
||||
* with no explanation of why it stopped.
|
||||
*/
|
||||
it('leaves no task running', async () => {
|
||||
const run = new ImportExecution(PLAN, deps as any)
|
||||
// Abandoned from inside the probe: `import` is already `running` by then, and the
|
||||
// executor's next look at the flag is the early return under test.
|
||||
present.onProbe = () => run.abandon()
|
||||
await run.run()
|
||||
expect(run.tasks.find((t) => t.status === 'running')).toBeUndefined()
|
||||
expect(run.tasks.find((t) => t.key === 'import')?.status).toBe('failed')
|
||||
expect(run.error).toBeTruthy()
|
||||
expect(run.done).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,517 @@
|
||||
import { UserService, WorkspaceService } from '$lib/gen'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { get } from 'svelte/store'
|
||||
import { enterNewWorkspace, refreshWorkspaceList } from '$lib/workspaceCreation'
|
||||
import {
|
||||
installProject,
|
||||
type InstallResult
|
||||
} from '$lib/components/workspaceSettings/projectInstall'
|
||||
import type {
|
||||
ProjectExport,
|
||||
ProjectMigration
|
||||
} from '$lib/components/workspaceSettings/projectBundle'
|
||||
import { planWorkspaceId, type ImportPlan } from './plan'
|
||||
import { probeImportedPaths, probeWorkspace } from './probe'
|
||||
|
||||
/**
|
||||
* The only thing in the wizard that changes anything. It takes a finished plan and
|
||||
* runs it as an ordered, observable list of tasks, so the last step can show what
|
||||
* is happening and exactly where it stopped.
|
||||
*
|
||||
* Everything the run needs from the outside — reviewing data table migrations,
|
||||
* the EE licence — is injected, so the wizard's UI decisions stay in the wizard
|
||||
* and this file stays testable without a browser.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether an import is mid-flight, readable by anything that can navigate away
|
||||
* from it. The run outlives no component: leaving the last step unmounts the
|
||||
* migration review the executor is awaiting, so a run in progress has to block
|
||||
* the stepper and the browser rather than be silently detached.
|
||||
*/
|
||||
const runState = $state({ active: false })
|
||||
export function importIsRunning(): boolean {
|
||||
return runState.active
|
||||
}
|
||||
|
||||
export type TaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
|
||||
|
||||
export interface TaskView {
|
||||
key: string
|
||||
label: string
|
||||
status: TaskStatus
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What a plan will do, as the same task list the run reports against. Exported so the
|
||||
* last step can show it before the run starts: the checklist is what the step says it
|
||||
* is going to do, and the run then fills in the same rows rather than replacing them.
|
||||
*
|
||||
* Derived from the plan alone — no network — so it is safe to call while rendering.
|
||||
*/
|
||||
export function plannedTasks(plan: ImportPlan): TaskView[] {
|
||||
const d = plan.destination
|
||||
const tasks: TaskView[] = []
|
||||
if (d?.kind === 'new') {
|
||||
tasks.push({ key: 'create', label: `Create workspace ${d.id}`, status: 'pending' })
|
||||
}
|
||||
tasks.push({ key: 'fetch', label: 'Fetch the project from the hub', status: 'pending' })
|
||||
// The destination rides on this row when nothing creates it, so the list still says
|
||||
// where the items are going in the existing-workspace case.
|
||||
tasks.push({
|
||||
key: 'import',
|
||||
label: d?.kind === 'existing' ? `Import the items into ${d.workspaceId}` : 'Import the items',
|
||||
status: 'pending'
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
export interface ExecutionDeps {
|
||||
/**
|
||||
* Chooses which data table migrations to run. Returns the migrations to apply,
|
||||
* or null to abort the whole import (the user backed out at the warning).
|
||||
*/
|
||||
reviewMigrations: (
|
||||
workspace: string,
|
||||
migrations: ProjectMigration[]
|
||||
) => Promise<ProjectMigration[] | null>
|
||||
hasEeLicense: boolean
|
||||
}
|
||||
|
||||
export class ImportExecution {
|
||||
#plan: ImportPlan
|
||||
#deps: ExecutionDeps
|
||||
|
||||
/** Carried between attempts so a retry does not redo finished work. */
|
||||
// The hub's summary endpoint counts scripts/flows/apps/resources; only the export
|
||||
// carries triggers and data table migrations. Surfaced so the last step can say
|
||||
// what it is about to create — the warning below it talks about triggers, and the
|
||||
// page this replaced did show both.
|
||||
#export = $state<ProjectExport | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* Data tables the project's migrations target. The wizard compares these with the
|
||||
* destination's configured tables to decide whether a setup step is needed —
|
||||
* `installProject` skips a migration whose data table does not exist.
|
||||
*/
|
||||
get datatableNames(): string[] {
|
||||
const e = this.#export
|
||||
if (!e) return []
|
||||
return [
|
||||
...new Set(
|
||||
(e.migrations ?? [])
|
||||
.filter((m) => m.enabled && (m.sql ?? '').trim() !== '')
|
||||
.map((m) => m.datatable_name)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* How many resources the project shipped. Every one arrives as an empty stub —
|
||||
* the hub never publishes resource values — so a non-zero count means the setup
|
||||
* step has something to offer.
|
||||
*/
|
||||
get resourceCount(): number {
|
||||
return this.#export?.resources?.length ?? 0
|
||||
}
|
||||
|
||||
get extraCounts(): { triggers: number; migrations: number } | undefined {
|
||||
const e = this.#export
|
||||
if (!e) return undefined
|
||||
return {
|
||||
triggers: e.triggers?.length ?? 0,
|
||||
migrations: (e.migrations ?? []).filter((m) => m.enabled && (m.sql ?? '').trim() !== '')
|
||||
.length
|
||||
}
|
||||
}
|
||||
// $state, not a plain field: the UI offers to delete the workspace this run
|
||||
// created, and a plain field would never re-render that button.
|
||||
#workspaceCreated = $state(false)
|
||||
|
||||
tasks = $state<TaskView[]>([])
|
||||
results = $state<InstallResult[]>([])
|
||||
running = $state(false)
|
||||
/** Set when a run stopped early; cleared when a retry starts. */
|
||||
error = $state<string | undefined>(undefined)
|
||||
done = $state(false)
|
||||
|
||||
// Where the app pointed before this run switched away from it, so undoing the run
|
||||
// can put it back. Captured at construction rather than at switch time: by then
|
||||
// `$workspaceStore` already holds the workspace being entered.
|
||||
#priorWorkspace = get(workspaceStore)
|
||||
|
||||
constructor(plan: ImportPlan, deps: ExecutionDeps) {
|
||||
this.#plan = plan
|
||||
this.#deps = deps
|
||||
this.tasks = this.#initialTasks()
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the plan this run belongs to — destination and project, not the folder,
|
||||
* which stays editable on the last step and is pushed onto the run instead. A caller
|
||||
* handing a run back after a remount compares this against the plan it is rendering;
|
||||
* computing the tag from *that* plan would make the check pass by construction.
|
||||
*/
|
||||
get planTag(): string {
|
||||
return JSON.stringify(this.#plan.destination) + this.#plan.slug
|
||||
}
|
||||
|
||||
get workspaceId(): string | undefined {
|
||||
return planWorkspaceId(this.#plan)
|
||||
}
|
||||
|
||||
/**
|
||||
* True once this run created a workspace — the only case where deleting is ours to offer.
|
||||
*
|
||||
* Deliberately not satisfied by having *adopted* one. `workspace.owner` is enough to know
|
||||
* a create can be skipped — the id is one this user made — but not enough to offer to
|
||||
* delete it, because `owner` is an identity, not a run: a second import by the same person
|
||||
* into the same id looks identical. Skipping a create wrongly is recoverable; deleting a
|
||||
* workspace is not, so an adopted run finishes the import and leaves the undo to the run
|
||||
* that did the creating.
|
||||
*/
|
||||
get createdWorkspace(): boolean {
|
||||
return this.#workspaceCreated
|
||||
}
|
||||
|
||||
get failedCount(): number {
|
||||
return this.results.filter((r) => !r.ok).length
|
||||
}
|
||||
|
||||
/** `installProject` reports migrations through the same channel as items, tagged by this
|
||||
* prefix. Split so the import row counts what it imported and the migrate row counts
|
||||
* what it migrated — one failure should not be attributed to both. */
|
||||
static readonly MIGRATION_PREFIX = 'data table: '
|
||||
get itemResults(): InstallResult[] {
|
||||
return this.results.filter((r) => !r.path.startsWith(ImportExecution.MIGRATION_PREFIX))
|
||||
}
|
||||
get migrationResults(): InstallResult[] {
|
||||
return this.results.filter((r) => r.path.startsWith(ImportExecution.MIGRATION_PREFIX))
|
||||
}
|
||||
|
||||
#initialTasks(): TaskView[] {
|
||||
return plannedTasks(this.#plan)
|
||||
}
|
||||
|
||||
#set(key: string, status: TaskStatus, detail?: string) {
|
||||
this.tasks = this.tasks.map((t) => (t.key === key ? { ...t, status, detail } : t))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set when the user confirms leaving mid-run. Nothing here can abort a request already
|
||||
* in flight — `installProject` takes no signal — so this stops the run at the next phase
|
||||
* boundary instead, which is as far as "stops where it is" can honestly go.
|
||||
*
|
||||
* The workspace it created stays, and the run stays resumable: coming back to the link
|
||||
* re-probes the instance, finds the workspace, and carries on rather than trying to create
|
||||
* it a second time.
|
||||
*/
|
||||
#abandoned = false
|
||||
|
||||
/** The user has left. Stop at the next phase boundary and leave the run resumable. */
|
||||
abandon() {
|
||||
this.#abandoned = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every task that has not already succeeded. Safe to call again after a failure: the
|
||||
* destination is asked what it already holds, so a workspace that exists is entered rather
|
||||
* than recreated and an item that landed is skipped rather than rewritten. What a second
|
||||
* run costs is the reads, not the writes.
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
if (this.running) return
|
||||
this.#abandoned = false
|
||||
this.running = true
|
||||
runState.active = true
|
||||
this.error = undefined
|
||||
try {
|
||||
const workspace = await this.#ensureWorkspace()
|
||||
if (!workspace || this.#abandoned) return
|
||||
const exportData = await this.#ensureExport(workspace)
|
||||
if (!exportData || this.#abandoned) return
|
||||
await this.#import(workspace, exportData)
|
||||
} finally {
|
||||
this.running = false
|
||||
runState.active = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder is the one part of the plan still editable on the last step, so a
|
||||
* retry after changing it must import where the field now says — not where the
|
||||
* first attempt was told to.
|
||||
*/
|
||||
setFolder(folder: string) {
|
||||
this.#plan = { ...this.#plan, folder }
|
||||
}
|
||||
|
||||
async #ensureWorkspace(): Promise<string | undefined> {
|
||||
const d = this.#plan.destination
|
||||
if (!d) {
|
||||
this.error = 'No destination'
|
||||
return undefined
|
||||
}
|
||||
if (d.kind === 'existing') {
|
||||
if (!d.workspaceId) {
|
||||
this.error = 'No destination workspace'
|
||||
return undefined
|
||||
}
|
||||
switchWorkspace(d.workspaceId)
|
||||
await this.#adoptUser(d.workspaceId)
|
||||
return d.workspaceId
|
||||
}
|
||||
// Asked of the instance, not remembered. A retry after entering it failed must not
|
||||
// run the create again — that would only report the id as taken by the workspace this
|
||||
// run just made — and after a reload the field is false again while the workspace is
|
||||
// still there. `ours` is what makes adopting it safe: an id that exists but belongs to
|
||||
// someone else is not this run's work, and importing into it would be importing into
|
||||
// a stranger's workspace.
|
||||
const already = await probeWorkspace(d.id, await this.#email())
|
||||
if (!this.#workspaceCreated && !(already.exists && already.ours)) {
|
||||
this.#set('create', 'running')
|
||||
try {
|
||||
await WorkspaceService.createWorkspace({
|
||||
requestBody: { id: d.id, name: d.name, username: d.username }
|
||||
})
|
||||
} catch (e: any) {
|
||||
const detail = e?.body?.toString?.() ?? String(e)
|
||||
this.#set('create', 'failed', detail)
|
||||
this.error = `Could not create the workspace: ${detail}`
|
||||
return undefined
|
||||
}
|
||||
this.#workspaceCreated = true
|
||||
}
|
||||
try {
|
||||
await enterNewWorkspace(d.id)
|
||||
await this.#adoptUser(d.id)
|
||||
} catch (e: any) {
|
||||
const detail = e?.body?.toString?.() ?? String(e)
|
||||
this.#set('create', 'failed', `created, but could not be entered: ${detail}`)
|
||||
this.error = `Created ${d.id}, but could not enter it: ${detail}`
|
||||
return undefined
|
||||
}
|
||||
this.#set('create', 'done')
|
||||
return d.id
|
||||
}
|
||||
|
||||
async #ensureExport(workspace: string): Promise<ProjectExport | undefined> {
|
||||
if (this.#export) {
|
||||
this.#set('fetch', 'done')
|
||||
return this.#export
|
||||
}
|
||||
this.#set('fetch', 'running')
|
||||
try {
|
||||
// Workspace-scoped on purpose: this is the same proxy the rest of the app
|
||||
// uses, so a private hub reachable only from the server still works.
|
||||
const res = await fetch(
|
||||
`/api/w/${encodeURIComponent(workspace)}/hub/projects/${encodeURIComponent(this.#plan.slug)}/export`,
|
||||
{ credentials: 'include', headers: { accept: 'application/json' } }
|
||||
)
|
||||
const text = await res.text()
|
||||
if (!res.ok) throw new Error(`export ${res.status}: ${text}`)
|
||||
this.#export = JSON.parse(text) as ProjectExport
|
||||
this.#set('fetch', 'done', `${itemCount(this.#export)} items`)
|
||||
return this.#export
|
||||
} catch (e: any) {
|
||||
const detail = e?.message ?? String(e)
|
||||
this.#set('fetch', 'failed', detail)
|
||||
this.error = `Could not read the project: ${detail}`
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the checklist saying what actually happened, from wherever the run stopped.
|
||||
*
|
||||
* Reached from every point after `import` goes `running`, so nothing is left spinning on a
|
||||
* run that has ended. A partial import is failed rather than done: calling it done reports
|
||||
* a clean import over items that never started, and the resumed step offers Continue where
|
||||
* it should offer Retry.
|
||||
*/
|
||||
#settleAbandoned() {
|
||||
const landed = this.itemResults.length
|
||||
this.#set('import', 'failed', `stopped after ${landed} item${landed === 1 ? '' : 's'}`)
|
||||
// The migrate row is appended once the review settles and set running by
|
||||
// `onMigrationsStart`. Stopping before its loop leaves it spinning forever, which
|
||||
// reads as work still in progress on a run that has stopped.
|
||||
if (this.tasks.some((t) => t.key === 'migrate' && t.status === 'running')) {
|
||||
this.#set('migrate', 'pending')
|
||||
}
|
||||
this.error = 'Import stopped. Retry to import what is left.'
|
||||
}
|
||||
|
||||
async #import(workspace: string, exportData: ProjectExport): Promise<void> {
|
||||
this.#set('import', 'running')
|
||||
const folder = this.#plan.folder?.trim() || exportData.project.slug
|
||||
|
||||
let migrations: ProjectMigration[] | null
|
||||
try {
|
||||
migrations = await this.#deps.reviewMigrations(workspace, exportData.migrations ?? [])
|
||||
} catch (e: any) {
|
||||
this.#set('import', 'failed', String(e))
|
||||
this.error = `Could not plan the data table migrations: ${e}`
|
||||
return
|
||||
}
|
||||
if (migrations === null) {
|
||||
// The user backed out at the missing-data-table warning.
|
||||
this.#set('import', 'skipped', 'cancelled at the data table warning')
|
||||
this.error = 'Import cancelled.'
|
||||
return
|
||||
}
|
||||
|
||||
// Appended only once the review has settled: until then nothing knows whether any
|
||||
// migration is runnable here, and a row that might not apply is worse than none.
|
||||
if (migrations.length && !this.tasks.some((t) => t.key === 'migrate')) {
|
||||
const n = migrations.length
|
||||
this.tasks = [
|
||||
...this.tasks,
|
||||
{
|
||||
key: 'migrate',
|
||||
label: `Run ${n} data table migration${n === 1 ? '' : 's'}`,
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
this.results = []
|
||||
// Asked every run, not only on a retry: the destination may be a workspace that already
|
||||
// holds some of these paths, and a run interrupted halfway is indistinguishable from
|
||||
// one that never started. On a workspace this run just created the answer is empty and
|
||||
// nothing is skipped.
|
||||
const alreadyPresent = await probeImportedPaths(workspace, folder, {
|
||||
triggers: exportData.triggers.length > 0,
|
||||
hasEeLicense: this.#deps.hasEeLicense
|
||||
})
|
||||
// Settled, not just returned: `import` has been `running` since before the probe, and
|
||||
// leaving it there shows a spinner on a run that has stopped, next to a Retry button.
|
||||
if (this.#abandoned) {
|
||||
this.#settleAbandoned()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await installProject({
|
||||
alreadyPresent,
|
||||
workspace,
|
||||
exportData,
|
||||
folder,
|
||||
migrations,
|
||||
hasEeLicense: this.#deps.hasEeLicense,
|
||||
onResult: (r) => (this.results = [...this.results, r]),
|
||||
onMigrationsStart: () => this.#set('migrate', 'running'),
|
||||
// Checked before every write, so leaving mid-run stops the remaining items
|
||||
// rather than only the phases. What already landed stays and is listed.
|
||||
stopped: () => this.#abandoned
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.#set('import', 'failed', String(e))
|
||||
this.error = `The import stopped: ${e}`
|
||||
return
|
||||
}
|
||||
|
||||
// `installProject` returns early when `stopped` goes true, and it returns the same way
|
||||
// it does on success — so the tail has to ask why. An abandoned run has written only
|
||||
// what it got through; calling that `done` reports a clean import over items that
|
||||
// never started, and the resumed step would offer Continue instead of Retry.
|
||||
if (this.#abandoned) {
|
||||
this.#settleAbandoned()
|
||||
return
|
||||
}
|
||||
|
||||
const items = this.itemResults
|
||||
const failed = items.filter((r) => !r.ok).length
|
||||
const skipped = items.filter((r) => r.skipped).length
|
||||
// Three outcomes, so the row says which: written, left alone because it was already
|
||||
// there, and failed. Rolling the second into the first would report an import that
|
||||
// did not happen.
|
||||
const wrote = items.length - failed - skipped
|
||||
const parts: string[] = []
|
||||
if (wrote > 0 || (failed === 0 && skipped === 0)) parts.push(`${wrote} imported`)
|
||||
if (skipped > 0) parts.push(`${skipped} already there`)
|
||||
if (failed > 0) parts.push(`${failed} failed`)
|
||||
this.#set('import', failed > 0 ? 'failed' : 'done', parts.join(', '))
|
||||
|
||||
const migrated = this.migrationResults
|
||||
const badMigrations = migrated.filter((r) => !r.ok).length
|
||||
if (migrated.length) {
|
||||
const badly = migrated.filter((r) => !r.ok)
|
||||
this.#set(
|
||||
'migrate',
|
||||
badly.length ? 'failed' : 'done',
|
||||
badly.length ? badly.map((r) => r.error).join('; ') : undefined
|
||||
)
|
||||
}
|
||||
// A partial import is finished, not broken: the items that landed are real,
|
||||
// and the failures are listed. Only a hard stop leaves `done` false.
|
||||
this.done = true
|
||||
// Both kinds of failure, because `error` is what offers Retry. A migration that fails
|
||||
// against an existing data table is as retryable as a failed item; leaving it out here
|
||||
// would present the run as a clean finish with no way to run it again.
|
||||
const problems: string[] = []
|
||||
if (failed > 0) problems.push(`${failed} item${failed === 1 ? '' : 's'} failed to import`)
|
||||
if (badMigrations > 0) {
|
||||
problems.push(
|
||||
`${badMigrations} data table migration${badMigrations === 1 ? '' : 's'} failed`
|
||||
)
|
||||
}
|
||||
if (problems.length) this.error = `${problems.join(', ')}.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the membership for the workspace this run just entered.
|
||||
*
|
||||
* The wizard's page is reparented out of `(logged)`, so it never gets that
|
||||
* layout's `getUserExt` call and `$userStore` stays undefined. Anything deciding
|
||||
* what the user may do then reads "no user" and refuses: `canWrite` returns false
|
||||
* without one, which renders every field of the resource editor disabled — the
|
||||
* setup step could show the credentials to fill and then not let anyone fill them.
|
||||
*/
|
||||
async #adoptUser(workspace: string): Promise<void> {
|
||||
try {
|
||||
userStore.set(await getUserExt(workspace))
|
||||
} catch {
|
||||
// Leave it unset; the step degrades to read-only rather than failing the run.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who we are, for the ownership check. `$userStore` is workspace-scoped and this page is
|
||||
* reparented out of `(logged)`, so it is unset until a run adopts one — `globalWhoami` is
|
||||
* the identity that exists before any workspace does.
|
||||
*/
|
||||
async #email(): Promise<string | undefined> {
|
||||
const known = get(userStore)?.email
|
||||
if (known) return known
|
||||
try {
|
||||
return (await UserService.globalWhoami()).email
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Undoes the one thing this run created, when the user asks for it. */
|
||||
async deleteCreatedWorkspace(): Promise<void> {
|
||||
const d = this.#plan.destination
|
||||
if (!this.#workspaceCreated || d?.kind !== 'new') return
|
||||
await WorkspaceService.deleteWorkspace({ workspace: d.id })
|
||||
// Leave the app pointing somewhere that exists. The run switched into the
|
||||
// workspace it created; without this the store keeps the deleted id, the
|
||||
// layout persists it to local/sessionStorage, and the next full page load
|
||||
// fails `getUserExt` and logs the user out.
|
||||
switchWorkspace(this.#priorWorkspace)
|
||||
await refreshWorkspaceList()
|
||||
this.#workspaceCreated = false
|
||||
// The id is free again; the next `#ensureWorkspace` asks the instance and finds it
|
||||
// gone, so a retry creates rather than adopts.
|
||||
this.#set('create', 'pending')
|
||||
this.done = false
|
||||
this.results = []
|
||||
}
|
||||
}
|
||||
|
||||
function itemCount(e: ProjectExport): number {
|
||||
return e.scripts.length + e.flows.length + e.apps.length + e.resources.length
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* A run that imported every item but failed a data table migration is not a clean finish.
|
||||
* `error` is what offers Retry, so this is about whether the user can act on the failure —
|
||||
* the `migrate` row already says it happened.
|
||||
*/
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
WorkspaceService: {
|
||||
createWorkspace: vi.fn(),
|
||||
listDataTables: vi.fn(async () => []),
|
||||
listWorkspaces: vi.fn(async () => [])
|
||||
},
|
||||
UserService: {
|
||||
whoami: vi.fn(async () => ({ username: 'u' })),
|
||||
globalWhoami: vi.fn(async () => ({ email: 'u@example.com' }))
|
||||
}
|
||||
}))
|
||||
vi.mock('$lib/storeUtils', () => ({ switchWorkspace: vi.fn() }))
|
||||
vi.mock('./probe', async (orig) => ({
|
||||
...(await orig<typeof import('./probe')>()),
|
||||
probeWorkspace: vi.fn(async () => ({ exists: false, ours: false })),
|
||||
probeImportedPaths: vi.fn(async () => new Set<string>())
|
||||
}))
|
||||
vi.mock('$lib/user', () => ({ getUserExt: vi.fn(async () => ({ username: 'u' })) }))
|
||||
|
||||
/** Whether the migration this run applies succeeds. The item writes always do. */
|
||||
const outcome = vi.hoisted(() => ({ migrationOk: true }))
|
||||
|
||||
vi.mock('$lib/components/workspaceSettings/projectInstall', () => ({
|
||||
installProject: vi.fn(async (args: any) => {
|
||||
args.onResult({ path: 'f/calendly/one', ok: true })
|
||||
if (args.migrations?.length) {
|
||||
args.onMigrationsStart?.()
|
||||
for (const m of args.migrations) {
|
||||
args.onResult({
|
||||
path: `data table: ${m.datatable_name}`,
|
||||
ok: outcome.migrationOk,
|
||||
error: outcome.migrationOk ? undefined : 'relation already exists'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const EXPORT = {
|
||||
project: { slug: 'calendly', name: 'Calendly', summary: '', readme: null },
|
||||
scripts: [],
|
||||
flows: [],
|
||||
apps: [],
|
||||
resources: [],
|
||||
triggers: [],
|
||||
migrations: []
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, status: 200, text: async () => JSON.stringify(EXPORT) }))
|
||||
)
|
||||
|
||||
import { ImportExecution } from './execution.svelte'
|
||||
|
||||
const PLAN = { slug: 'calendly', destination: { kind: 'existing' as const, workspaceId: 'ws-a' } }
|
||||
const MIGRATION = {
|
||||
datatable_name: 'main',
|
||||
sql: 'CREATE TABLE IF NOT EXISTS "calendly"."config" (id int)',
|
||||
sql_down: '',
|
||||
enabled: true
|
||||
}
|
||||
const deps = { reviewMigrations: async () => [MIGRATION], hasEeLicense: false }
|
||||
|
||||
describe('a failed migration', () => {
|
||||
beforeEach(() => {
|
||||
outcome.migrationOk = true
|
||||
})
|
||||
|
||||
it('marks the migrate row failed and leaves the run retryable', async () => {
|
||||
outcome.migrationOk = false
|
||||
const run = new ImportExecution(PLAN, deps as any)
|
||||
await run.run()
|
||||
expect(run.tasks.find((t) => t.key === 'migrate')?.status).toBe('failed')
|
||||
// `error` is what the page reads to offer Retry: a `migrate` row saying failed with
|
||||
// `error` unset sends it down the finished-run path with no way to run it again.
|
||||
expect(run.error).toBeTruthy()
|
||||
expect(run.error).toContain('migration')
|
||||
})
|
||||
|
||||
it('says nothing went wrong when the migration succeeds', async () => {
|
||||
const run = new ImportExecution(PLAN, deps as any)
|
||||
await run.run()
|
||||
expect(run.tasks.find((t) => t.key === 'migrate')?.status).toBe('done')
|
||||
expect(run.error).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
planProblem,
|
||||
planToSearch,
|
||||
planWorkspaceId,
|
||||
readPlan,
|
||||
type ImportPlan,
|
||||
type WizardStep
|
||||
} from './plan'
|
||||
|
||||
// The wizard keeps no state of its own — the plan *is* the URL. Back, forward, the
|
||||
// stepper and a pasted link are all the same operation, which only holds if this
|
||||
// round trip is lossless.
|
||||
|
||||
const read = (search: string) => readPlan(new URL(`https://x.dev/projects/import${search}`))
|
||||
const roundTrip = (plan: ImportPlan, step: WizardStep) => read(planToSearch(plan, step))
|
||||
|
||||
describe('readPlan / planToSearch round trip', () => {
|
||||
const cases: Array<[string, ImportPlan, WizardStep]> = [
|
||||
['bare slug', { slug: 'support-automation' }, 1],
|
||||
['new destination', { slug: 's', destination: { kind: 'new', id: 'w', name: 'W' } }, 2],
|
||||
[
|
||||
'new with username',
|
||||
{ slug: 's', destination: { kind: 'new', id: 'w', name: 'W', username: 'ada' } },
|
||||
2
|
||||
],
|
||||
[
|
||||
'existing destination',
|
||||
{ slug: 's', destination: { kind: 'existing', workspaceId: 'admins' } },
|
||||
3
|
||||
],
|
||||
[
|
||||
'folder carried',
|
||||
{ slug: 's', destination: { kind: 'existing', workspaceId: 'admins' }, folder: 'finance' },
|
||||
3
|
||||
]
|
||||
]
|
||||
|
||||
for (const [name, plan, step] of cases) {
|
||||
it(name, () => {
|
||||
expect(roundTrip(plan, step)).toEqual({ plan, step })
|
||||
})
|
||||
}
|
||||
|
||||
it('survives characters that need encoding', () => {
|
||||
const plan: ImportPlan = {
|
||||
slug: 'a b&c=d',
|
||||
destination: { kind: 'new', id: 'w', name: 'Name & Co = 100%' }
|
||||
}
|
||||
expect(roundTrip(plan, 2)).toEqual({ plan, step: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readPlan', () => {
|
||||
it('reads legacy links that carry only a workspace', () => {
|
||||
expect(read('?hub=s&workspace=admins').plan.destination).toEqual({
|
||||
kind: 'existing',
|
||||
workspaceId: 'admins'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads legacy links that carry only new_workspace_id, falling the name back to the id', () => {
|
||||
expect(read('?hub=s&new_workspace_id=w').plan.destination).toEqual({
|
||||
kind: 'new',
|
||||
id: 'w',
|
||||
name: 'w',
|
||||
username: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps and rounds the step', () => {
|
||||
expect(read('?hub=s&step=0').step).toBe(1)
|
||||
// 4 is the optional setup step, so that is the ceiling.
|
||||
expect(read('?hub=s&step=9').step).toBe(4)
|
||||
expect(read('?hub=s&step=nope').step).toBe(1)
|
||||
// A fractional step would match neither `=== 2` nor `=== 3`.
|
||||
expect(read('?hub=s&step=2.5').step).toBe(3)
|
||||
expect(read('?hub=s&step=2.4').step).toBe(2)
|
||||
})
|
||||
|
||||
it('has no destination when nothing names one', () => {
|
||||
expect(read('?hub=s').plan.destination).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* `create_workspace` takes the username it is given and writes it to `usr.username` without
|
||||
* checking it — `Some("")` passes its only guard — so a blank or malformed one becomes a
|
||||
* workspace whose owner has no usable name. The wizard is the last thing that can refuse it.
|
||||
*/
|
||||
describe('planProblem — the new-workspace username', () => {
|
||||
const dest = (username?: string) => ({
|
||||
slug: 'calendly',
|
||||
destination: { kind: 'new' as const, id: 'calendly', name: 'Calendly', username }
|
||||
})
|
||||
|
||||
it('asks for nothing when the instance derives the username', () => {
|
||||
expect(planProblem(dest(undefined))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a blank or whitespace-only username', () => {
|
||||
expect(planProblem(dest(''))).toMatch(/needs a username/i)
|
||||
expect(planProblem(dest(' '))).toMatch(/needs a username/i)
|
||||
})
|
||||
|
||||
it('refuses one the backend would store verbatim but never accept elsewhere', () => {
|
||||
expect(planProblem(dest('1bad'))).toMatch(/letters and numbers/i)
|
||||
expect(planProblem(dest('a b'))).toMatch(/letters and numbers/i)
|
||||
})
|
||||
|
||||
it('accepts a valid one', () => {
|
||||
expect(planProblem(dest('guilhem'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/** `validate_workspace_name` refuses > 50 chars, and only at creation — two steps later. */
|
||||
describe('planProblem — the new-workspace name length', () => {
|
||||
const named = (name: string) => ({
|
||||
slug: 'calendly',
|
||||
destination: { kind: 'new' as const, id: 'calendly', name }
|
||||
})
|
||||
|
||||
it('accepts the longest name the backend takes', () => {
|
||||
expect(planProblem(named('x'.repeat(50)))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses one character more, rather than failing at create', () => {
|
||||
expect(planProblem(named('x'.repeat(51)))).toMatch(/too long/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planProblem', () => {
|
||||
it('names what is missing, in the order the wizard asks for it', () => {
|
||||
expect(planProblem({ slug: '' })).toMatch(/No project/)
|
||||
expect(planProblem({ slug: 's' })).toMatch(/Pick a destination/)
|
||||
expect(planProblem({ slug: 's', destination: { kind: 'new', id: 'w', name: '' } })).toMatch(
|
||||
/needs a name/
|
||||
)
|
||||
expect(planProblem({ slug: 's', destination: { kind: 'existing' } })).toMatch(
|
||||
/Pick the workspace/
|
||||
)
|
||||
})
|
||||
|
||||
it('validates the id of either destination kind', () => {
|
||||
expect(
|
||||
planProblem({ slug: 's', destination: { kind: 'new', id: 'not valid', name: 'W' } })
|
||||
).toMatch(/letters, numbers and dashes/)
|
||||
// An existing id arrives from the URL just as a new one does.
|
||||
expect(
|
||||
planProblem({ slug: 's', destination: { kind: 'existing', workspaceId: '../admins' } })
|
||||
).toMatch(/not a valid workspace id/)
|
||||
})
|
||||
|
||||
it('rejects a folder name the import could not create', () => {
|
||||
const base: ImportPlan = { slug: 's', destination: { kind: 'existing', workspaceId: 'admins' } }
|
||||
expect(planProblem({ ...base, folder: 'ok_folder-1' })).toBeUndefined()
|
||||
expect(planProblem({ ...base, folder: 'not ok' })).toMatch(/Folder/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planWorkspaceId', () => {
|
||||
it('is the id either kind of destination will end up in', () => {
|
||||
expect(planWorkspaceId({ slug: 's', destination: { kind: 'new', id: 'w', name: 'W' } })).toBe(
|
||||
'w'
|
||||
)
|
||||
expect(
|
||||
planWorkspaceId({ slug: 's', destination: { kind: 'existing', workspaceId: 'admins' } })
|
||||
).toBe('admins')
|
||||
expect(planWorkspaceId({ slug: 's' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The import wizard's state, and the only thing the first two steps produce.
|
||||
*
|
||||
* Nothing is created, switched or written while the wizard is being filled in:
|
||||
* steps 1 and 2 only ever describe *what should happen*, and the plan travels in
|
||||
* the URL. That is what makes the back button and the stepper safe — going back
|
||||
* is a URL change and cannot leave a half-made workspace behind, because there is
|
||||
* no state anywhere else to unwind.
|
||||
*
|
||||
* `execution.svelte.ts` is the only thing that acts on a plan, and only when the
|
||||
* user asks it to on the last step.
|
||||
*/
|
||||
|
||||
import { validateUsername } from '$lib/utils'
|
||||
import { validateWorkspaceId, WORKSPACE_NAME_MAX_LENGTH } from '$lib/utils/workspaceId'
|
||||
|
||||
/**
|
||||
* `existing` carries no workspace until one is picked: step 1 answers *which kind*
|
||||
* of destination and step 2 answers *which one*, and the plan has to be able to
|
||||
* hold the state in between. Encoding that gap as an absent destination would make
|
||||
* "chose an existing workspace, has not picked it" and "answered nothing yet" the
|
||||
* same URL.
|
||||
*/
|
||||
export type ImportDestination =
|
||||
| { kind: 'new'; name: string; id: string; username?: string }
|
||||
| { kind: 'existing'; workspaceId?: string }
|
||||
|
||||
export interface ImportPlan {
|
||||
/** The hub project being imported. */
|
||||
slug: string
|
||||
/** Undefined until step 1 has been answered. */
|
||||
destination?: ImportDestination
|
||||
/** Folder the items land in; defaults to the project slug at execution time. */
|
||||
folder?: string
|
||||
}
|
||||
|
||||
/** 4 is the optional setup step, reached only when the import leaves work to do. */
|
||||
export type WizardStep = 1 | 2 | 3 | 4
|
||||
|
||||
export const FOLDER_NAME_RE = /^[a-zA-Z_0-9-]+$/
|
||||
|
||||
export function readPlan(url: URL): { plan: ImportPlan; step: WizardStep } {
|
||||
const params = url.searchParams
|
||||
const slug = params.get('hub') ?? ''
|
||||
|
||||
const newId = params.get('new_workspace_id')
|
||||
const existing = params.get('workspace')
|
||||
// `destination` records the step 1 answer on its own; the older links that only
|
||||
// carried `workspace` or `new_workspace_id` still read as the kind they imply.
|
||||
const kind = params.get('destination')
|
||||
const destination: ImportDestination | undefined =
|
||||
newId || kind === 'new'
|
||||
? {
|
||||
kind: 'new',
|
||||
id: newId ?? '',
|
||||
name: params.get('new_workspace_name') || newId || '',
|
||||
username: params.get('username') || undefined
|
||||
}
|
||||
: existing || kind === 'existing'
|
||||
? { kind: 'existing', workspaceId: existing || undefined }
|
||||
: undefined
|
||||
|
||||
// Rounded as well as clamped: the steps are compared with `>` and `===`, so a
|
||||
// fractional `?step=2.5` would clamp to 2.5 and match neither.
|
||||
const raw = Number(params.get('step') ?? 1)
|
||||
const step = (Number.isFinite(raw) ? Math.min(4, Math.max(1, Math.round(raw))) : 1) as WizardStep
|
||||
|
||||
return { plan: { slug, destination, folder: params.get('folder') || undefined }, step }
|
||||
}
|
||||
|
||||
export function planToSearch(plan: ImportPlan, step: WizardStep): string {
|
||||
const params = new URLSearchParams({ hub: plan.slug })
|
||||
if (step !== 1) params.set('step', String(step))
|
||||
if (plan.destination) params.set('destination', plan.destination.kind)
|
||||
if (plan.destination?.kind === 'new') {
|
||||
params.set('new_workspace_id', plan.destination.id)
|
||||
params.set('new_workspace_name', plan.destination.name)
|
||||
if (plan.destination.username) params.set('username', plan.destination.username)
|
||||
} else if (plan.destination?.workspaceId) {
|
||||
params.set('workspace', plan.destination.workspaceId)
|
||||
}
|
||||
if (plan.folder) params.set('folder', plan.folder)
|
||||
return `?${params}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the plan can be executed. Returned as a reason rather than a boolean so
|
||||
* the button can say why it is disabled instead of just being grey.
|
||||
*/
|
||||
export function planProblem(plan: ImportPlan): string | undefined {
|
||||
if (!plan.slug) return 'No project to import'
|
||||
const d = plan.destination
|
||||
if (!d) return 'Pick a destination first'
|
||||
if (d.kind === 'new') {
|
||||
if (!d.name.trim()) return 'The new workspace needs a name'
|
||||
// The backend refuses a longer one (`validate_workspace_name`), and only at
|
||||
// creation — by then the wizard has already walked the user through two more steps.
|
||||
if (d.name.trim().length > WORKSPACE_NAME_MAX_LENGTH) {
|
||||
return `The name is too long (${d.name.trim().length} chars). Maximum is ${WORKSPACE_NAME_MAX_LENGTH}.`
|
||||
}
|
||||
const idProblem = validateWorkspaceId(d.id)
|
||||
if (idProblem) return idProblem
|
||||
// Only asked for when the instance does not derive it. `create_workspace` takes
|
||||
// whatever it is given here — `Some("")` passes its only check — so a blank or
|
||||
// malformed username is written to `usr.username` verbatim rather than refused.
|
||||
// The sibling creator validates it; this is the same check.
|
||||
if (d.username !== undefined) {
|
||||
if (!d.username.trim()) return 'The new workspace needs a username'
|
||||
const bad = validateUsername(d.username.trim())
|
||||
if (bad) return bad
|
||||
}
|
||||
} else if (!d.workspaceId) {
|
||||
return 'Pick the workspace to import into'
|
||||
} else if (validateWorkspaceId(d.workspaceId)) {
|
||||
// The id arrives from the URL exactly as the new-workspace one does, so it gets
|
||||
// the same check. Downstream it is interpolated into a credentialed same-origin
|
||||
// API path and pushed into `workspaceStore`; an id that cannot name a workspace
|
||||
// has no business reaching either.
|
||||
return 'That is not a valid workspace id'
|
||||
}
|
||||
if (plan.folder && !FOLDER_NAME_RE.test(plan.folder)) {
|
||||
return 'Folder: letters, digits, dashes and underscores'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The workspace the plan lands in, once one has been named or picked. */
|
||||
export function planWorkspaceId(plan: ImportPlan): string | undefined {
|
||||
const d = plan.destination
|
||||
return (d?.kind === 'new' ? d.id : d?.workspaceId) || undefined
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { expectedTables } from './probe'
|
||||
|
||||
/**
|
||||
* `expectedTables` is inference over what the export happens to say, so it is the part of the
|
||||
* probe that can be wrong while everything still type-checks. The network reads around it
|
||||
* either answer or throw.
|
||||
*/
|
||||
|
||||
describe('expectedTables', () => {
|
||||
// The shape `datatableSchemaSql.ts` emits, and what every published project carries.
|
||||
const REAL = `BEGIN;
|
||||
CREATE SCHEMA IF NOT EXISTS "calendly";
|
||||
CREATE TABLE IF NOT EXISTS "calendly"."config" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"host_name" text NOT NULL DEFAULT ''::text
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "calendly"."bookings" ( "id" SERIAL NOT NULL );
|
||||
COMMIT;`
|
||||
|
||||
it('reads every table a migration creates', () => {
|
||||
expect(expectedTables(REAL)).toEqual(['calendly.config', 'calendly.bookings'])
|
||||
})
|
||||
|
||||
it('does not mistake the schema for a table', () => {
|
||||
expect(expectedTables(REAL)).not.toContain('calendly')
|
||||
})
|
||||
|
||||
it('reads the form without IF NOT EXISTS', () => {
|
||||
expect(expectedTables('CREATE TABLE "bitly"."links" (id int)')).toEqual(['bitly.links'])
|
||||
})
|
||||
|
||||
it('is case- and whitespace-insensitive the way SQL is', () => {
|
||||
expect(expectedTables('create table\n "a" . "b" (x int)')).toEqual(['a.b'])
|
||||
})
|
||||
|
||||
it('reports each table once, however many times it is named', () => {
|
||||
const sql = 'CREATE TABLE "a"."b" (x int); CREATE TABLE IF NOT EXISTS "a"."b" (x int);'
|
||||
expect(expectedTables(sql)).toEqual(['a.b'])
|
||||
})
|
||||
|
||||
/**
|
||||
* The answer that keeps a caller honest. An unquoted or unqualified `CREATE TABLE` is
|
||||
* something this cannot resolve — the schema would come from `search_path` at run time —
|
||||
* so it reads as nothing expected, and the caller treats that as "cannot tell" rather than
|
||||
* as "no tables, so the migration must have run".
|
||||
*/
|
||||
it('claims nothing about SQL it cannot resolve', () => {
|
||||
expect(expectedTables('CREATE TABLE links (id int)')).toEqual([])
|
||||
expect(expectedTables('CREATE TABLE bitly.links (id int)')).toEqual([])
|
||||
expect(expectedTables('')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* What is already true in the destination, read from the destination itself.
|
||||
*
|
||||
* Nothing here is remembered between runs, and nothing may be: a note saying "this run
|
||||
* created workspace X" outlives the reload it was written for, but it also outlives the
|
||||
* workspace it names, and the two are indistinguishable when it is read back. The plan in
|
||||
* the URL says what should exist; these functions ask the instance what does.
|
||||
*
|
||||
* The one thing the instance cannot answer is *which tables a migration was supposed to
|
||||
* create*. That is inferred from the SQL the project ships (`expectedTables`), because the
|
||||
* export states only what to run, never what running it should produce.
|
||||
*/
|
||||
|
||||
import { ResourceService, ScriptService, FlowService, AppService, WorkspaceService } from '$lib/gen'
|
||||
import type { ProjectMigration } from '$lib/components/workspaceSettings/projectBundle'
|
||||
import {
|
||||
presenceKey,
|
||||
type ImportedKind
|
||||
} from '$lib/components/workspaceSettings/projectInstall'
|
||||
import { listAllWorkspaceTriggers } from '$lib/components/triggers/workspaceTriggersList'
|
||||
|
||||
/**
|
||||
* The tables a migration creates, as `schema.table`, read off its `CREATE TABLE` statements.
|
||||
*
|
||||
* Inference, not a contract: the export ships SQL and nothing else, so this is the only way
|
||||
* to check a migration's work without a record of it having run. It deliberately reads only
|
||||
* the shape this project's generator emits (`datatableSchemaSql.ts` always writes the
|
||||
* schema-qualified, quoted form) — anything hand-edited into a different shape simply reads
|
||||
* as no expected tables, which makes the caller fall back to "cannot tell" rather than to a
|
||||
* confident wrong answer.
|
||||
*/
|
||||
export function expectedTables(sql: string): string[] {
|
||||
const out: string[] = []
|
||||
const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"([^"]+)"\s*\.\s*"([^"]+)"/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(sql)) !== null) out.push(`${m[1]}.${m[2]}`)
|
||||
return [...new Set(out)]
|
||||
}
|
||||
|
||||
export interface WorkspaceState {
|
||||
/** The user is a member of a workspace with this id. */
|
||||
exists: boolean
|
||||
/** …and it is one they own, so a run of theirs is what made it. */
|
||||
ours: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the destination workspace is there, and whether it is the caller's.
|
||||
*
|
||||
* `listWorkspaces` answers both in one call: it returns only workspaces the caller is a
|
||||
* member of, each carrying the `owner` email set at creation. Ownership is what makes
|
||||
* skipping a create safe — an id that exists but belongs to someone else is not a workspace
|
||||
* this run made, and importing into it would be importing into a stranger's.
|
||||
*/
|
||||
export async function probeWorkspace(
|
||||
id: string,
|
||||
email: string | undefined
|
||||
): Promise<WorkspaceState> {
|
||||
try {
|
||||
const mine = await WorkspaceService.listWorkspaces()
|
||||
const found = mine.find((w) => w.id === id)
|
||||
if (!found) return { exists: false, ours: false }
|
||||
return { exists: true, ours: !!email && found.owner === email }
|
||||
} catch {
|
||||
// Cannot tell. Reported as absent so the caller creates rather than adopts: a failed
|
||||
// create is a clear error, adopting the wrong workspace is a silent one.
|
||||
return { exists: false, ours: false }
|
||||
}
|
||||
}
|
||||
|
||||
const PROBE_PAGE_SIZE = 100
|
||||
/**
|
||||
* A stop, not a limit on what may be imported: 100 pages is 10,000 items in one folder, far
|
||||
* past any project, and a paginating endpoint that never returns a short page would otherwise
|
||||
* loop forever. Hitting it under-reports, which only ever means "still to do".
|
||||
*/
|
||||
const MAX_PROBE_PAGES = 100
|
||||
|
||||
/**
|
||||
* Which of the items the import would write are already there, as `presenceKey` keys.
|
||||
*
|
||||
* Scoped by `pathStart` to the import's own folder, so the four path-bearing kinds are four
|
||||
* small reads rather than a workspace scan. Presence is not provenance — importing into an
|
||||
* existing workspace that already held a path reads the same as having imported it — so
|
||||
* callers use this to decide what is left to do, never to claim credit for what is there.
|
||||
*
|
||||
* Triggers are asked for separately and only when the project ships some: they have no
|
||||
* prefix-filtered list endpoint, so answering for them means one call per trigger kind, and a
|
||||
* project without triggers should not pay for that.
|
||||
*/
|
||||
export async function probeImportedPaths(
|
||||
workspace: string,
|
||||
folder: string,
|
||||
opts?: { triggers?: boolean; hasEeLicense?: boolean }
|
||||
): Promise<Set<string>> {
|
||||
const pathStart = `f/${folder}/`
|
||||
const found = new Set<string>()
|
||||
/**
|
||||
* Every page, not the first one. These endpoints paginate and default to 30 rows, so a
|
||||
* single call answers for a small project and quietly under-reports a large one — leaving
|
||||
* everything past the first page to be created again, and rejected as already existing.
|
||||
*/
|
||||
const collectAll = async (
|
||||
kind: ImportedKind,
|
||||
list: (page: number) => Promise<unknown>
|
||||
): Promise<void> => {
|
||||
for (let page = 1; page <= MAX_PROBE_PAGES; page++) {
|
||||
const rows = ((await list(page)) as { path?: string }[] | undefined) ?? []
|
||||
for (const r of rows) if (r.path) found.add(presenceKey(kind, r.path))
|
||||
if (rows.length < PROBE_PAGE_SIZE) return
|
||||
}
|
||||
}
|
||||
const calls: Promise<unknown>[] = [
|
||||
collectAll('script', (page) =>
|
||||
ScriptService.listScripts({ workspace, pathStart, page, perPage: PROBE_PAGE_SIZE })
|
||||
),
|
||||
collectAll('flow', (page) =>
|
||||
FlowService.listFlows({ workspace, pathStart, page, perPage: PROBE_PAGE_SIZE })
|
||||
),
|
||||
collectAll('app', (page) =>
|
||||
AppService.listApps({ workspace, pathStart, page, perPage: PROBE_PAGE_SIZE })
|
||||
),
|
||||
collectAll('resource', (page) =>
|
||||
ResourceService.listResource({ workspace, pathStart, page, perPage: PROBE_PAGE_SIZE })
|
||||
)
|
||||
]
|
||||
if (opts?.triggers) {
|
||||
// `failedKinds` is deliberately ignored: a kind that could not be listed leaves its
|
||||
// triggers out of the set, and a missing key only ever means "still to do".
|
||||
calls.push(
|
||||
listAllWorkspaceTriggers(workspace, {
|
||||
includeEeOnly: opts.hasEeLicense === true
|
||||
}).then(({ triggers }) => {
|
||||
for (const t of triggers) {
|
||||
if (t.path?.startsWith(pathStart)) found.add(presenceKey(`trigger:${t.kind}`, t.path))
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
// One kind failing should narrow the answer, not lose the others: a missing key only ever
|
||||
// means "still to do", which is the safe direction.
|
||||
await Promise.allSettled(calls)
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every table a data table's migrations create is in it.
|
||||
*
|
||||
* The ground truth for "did this run", and the only one that covers both paths
|
||||
* `applyOneMigration` takes: it records a migration when the data table has migrations
|
||||
* enabled, and otherwise runs the SQL once as a job that nothing remembers. The tables
|
||||
* outlive both.
|
||||
*
|
||||
* Every migration for one data table at once: they all target the same schema, so asking
|
||||
* per migration would introspect the same database N times for one answer.
|
||||
*
|
||||
* `undefined` means the question could not be answered — the schema was unreadable, or the
|
||||
* SQL named no tables this can recognise. Distinct from `false`, because "not there" invites
|
||||
* a caller to run the migration and "cannot tell" does not.
|
||||
*/
|
||||
export async function probeMigrationsApplied(
|
||||
workspace: string,
|
||||
datatableName: string,
|
||||
migrations: ProjectMigration[]
|
||||
): Promise<boolean | undefined> {
|
||||
const wanted = [...new Set(migrations.flatMap((m) => expectedTables(m.sql ?? '')))]
|
||||
if (wanted.length === 0) return undefined
|
||||
try {
|
||||
const schema = (await WorkspaceService.getDatatableFullSchema({
|
||||
workspace,
|
||||
requestBody: { source: `datatable://${datatableName}` }
|
||||
})) as Record<string, Record<string, unknown>>
|
||||
const present = new Set<string>()
|
||||
for (const [schemaName, tables] of Object.entries(schema ?? {})) {
|
||||
for (const table of Object.keys(tables ?? {})) present.add(`${schemaName}.${table}`)
|
||||
}
|
||||
return wanted.every((t) => present.has(t))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* The list endpoints paginate. A probe that reads one page answers correctly for a small
|
||||
* project and under-reports a large one, which puts every item past the first page back
|
||||
* through a create call that rejects it.
|
||||
*/
|
||||
|
||||
const calls = vi.hoisted(() => ({ script: [] as { page?: number; perPage?: number }[] }))
|
||||
/** What the destination's trigger listing answers with, per test. */
|
||||
const triggerRows = vi.hoisted(() => ({ rows: [] as { kind: string; path: string }[] }))
|
||||
|
||||
/** 150 scripts in the folder — more than one page at any page size the probe might pick. */
|
||||
const ALL = Array.from({ length: 150 }, (_, i) => ({ path: `f/calendly/s${i}` }))
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
ScriptService: {
|
||||
listScripts: vi.fn(async (args: any) => {
|
||||
calls.script.push({ page: args.page, perPage: args.perPage })
|
||||
const per = args.perPage ?? 30
|
||||
return ALL.slice((args.page - 1) * per, args.page * per)
|
||||
})
|
||||
},
|
||||
FlowService: { listFlows: vi.fn(async () => []) },
|
||||
AppService: { listApps: vi.fn(async () => []) },
|
||||
ResourceService: { listResource: vi.fn(async () => []) },
|
||||
WorkspaceService: { listWorkspaces: vi.fn(async () => []), getDatatableFullSchema: vi.fn() }
|
||||
}))
|
||||
vi.mock('$lib/components/triggers/workspaceTriggersList', () => ({
|
||||
listAllWorkspaceTriggers: vi.fn(async () => ({ triggers: triggerRows.rows, failedKinds: [] }))
|
||||
}))
|
||||
|
||||
import { probeImportedPaths } from './probe'
|
||||
import { presenceKey } from '$lib/components/workspaceSettings/projectInstall'
|
||||
|
||||
describe('probeImportedPaths paging', () => {
|
||||
beforeEach(() => {
|
||||
calls.script = []
|
||||
})
|
||||
|
||||
it('reads every page, not just the first', async () => {
|
||||
const found = await probeImportedPaths('w', 'calendly')
|
||||
expect(found.size).toBe(150)
|
||||
expect(found.has(presenceKey('script', 'f/calendly/s0'))).toBe(true)
|
||||
// The one that a single-page probe misses, and would then try to create again.
|
||||
expect(found.has(presenceKey('script', 'f/calendly/s149'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stops on the first short page rather than asking forever', async () => {
|
||||
await probeImportedPaths('w', 'calendly')
|
||||
const pages = calls.script.map((c) => c.page)
|
||||
expect(pages).toEqual([1, 2])
|
||||
expect(new Set(calls.script.map((c) => c.perPage))).toEqual(new Set([100]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('trigger presence keys', () => {
|
||||
beforeEach(() => {
|
||||
triggerRows.rows = []
|
||||
})
|
||||
|
||||
/**
|
||||
* Each trigger kind is its own table keyed on `(path, workspace_id)`, so a workspace can
|
||||
* hold a schedule and an HTTP trigger both called `f/calendly/sync`. The probe builds the
|
||||
* keys and `installProject` reads them, so they have to agree that those are two things —
|
||||
* key on the path alone and the one that exists reports the other as already imported.
|
||||
*/
|
||||
it('keys a trigger by its kind, so one kind cannot answer for another', async () => {
|
||||
triggerRows.rows = [{ kind: 'http', path: 'f/calendly/sync' }]
|
||||
const found = await probeImportedPaths('w', 'calendly', { triggers: true })
|
||||
expect(found.has(presenceKey('trigger:http', 'f/calendly/sync'))).toBe(true)
|
||||
// The schedule the project also ships at that path has not been imported.
|
||||
expect(found.has(presenceKey('trigger:schedule', 'f/calendly/sync'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { WORKSPACE_ID_MAX_LENGTH, toWorkspaceId, validateWorkspaceId } from './workspaceId'
|
||||
|
||||
// This module was extracted so the import wizard and the create-workspace form
|
||||
// could not drift on what a workspace id is. These pin the contract they share.
|
||||
|
||||
describe('validateWorkspaceId', () => {
|
||||
it('accepts dash-separated groups of word characters', () => {
|
||||
for (const id of ['a', 'prod', 'my_ws', 'a-b-c', 'a1-2b', 'wm-fork-x']) {
|
||||
expect(validateWorkspaceId(id), id).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects leading, trailing and doubled dashes, and anything outside \\w', () => {
|
||||
for (const id of ['-a', 'a-', 'a--b', '', 'a b', 'a.b', 'a/b', 'é']) {
|
||||
expect(validateWorkspaceId(id), id).toMatch(/letters, numbers and dashes/)
|
||||
}
|
||||
})
|
||||
|
||||
it('measures length against effectiveId, but the character rule against what was typed', () => {
|
||||
const typed = 'a'.repeat(45)
|
||||
// The typed id is fine on its own...
|
||||
expect(validateWorkspaceId(typed)).toBeUndefined()
|
||||
// ...but a fork submits it prefixed, and that is what the backend stores.
|
||||
const prefixed = `wm-fork-${typed}`
|
||||
expect(prefixed.length).toBeGreaterThan(WORKSPACE_ID_MAX_LENGTH)
|
||||
expect(validateWorkspaceId(typed, prefixed)).toMatch(/too long/)
|
||||
})
|
||||
|
||||
it('reports the character problem before the length one', () => {
|
||||
expect(validateWorkspaceId('a b'.padEnd(80, 'c'))).toMatch(/letters, numbers and dashes/)
|
||||
})
|
||||
|
||||
it('allows exactly the maximum length', () => {
|
||||
const id = 'a'.repeat(WORKSPACE_ID_MAX_LENGTH)
|
||||
expect(validateWorkspaceId(id)).toBeUndefined()
|
||||
expect(validateWorkspaceId(id + 'a')).toMatch(/too long/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toWorkspaceId', () => {
|
||||
it('produces something validateWorkspaceId accepts', () => {
|
||||
for (const raw of [
|
||||
'Support automation',
|
||||
' GitHub Release Dashboard ',
|
||||
'a//b__c',
|
||||
'Ünïcødé nåme',
|
||||
'---leading and trailing---',
|
||||
'MiXeD CaSe'
|
||||
]) {
|
||||
const id = toWorkspaceId(raw)
|
||||
expect(validateWorkspaceId(id), `${raw} -> ${id}`).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('lowercases, collapses runs into single dashes, and trims them', () => {
|
||||
expect(toWorkspaceId('Support automation')).toBe('support-automation')
|
||||
expect(toWorkspaceId('a//b')).toBe('a-b')
|
||||
expect(toWorkspaceId(' spaced out ')).toBe('spaced-out')
|
||||
})
|
||||
|
||||
it('clips to the maximum length without leaving a trailing dash', () => {
|
||||
// Slicing at 50 lands mid-separator here; the result must still be valid.
|
||||
const raw = `${'a'.repeat(WORKSPACE_ID_MAX_LENGTH - 1)} tail`
|
||||
const id = toWorkspaceId(raw)
|
||||
expect(id.length).toBeLessThanOrEqual(WORKSPACE_ID_MAX_LENGTH)
|
||||
expect(id.endsWith('-')).toBe(false)
|
||||
expect(validateWorkspaceId(id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateWorkspaceId — the reserved id', () => {
|
||||
// `check_w_id_conflict` refuses it, and `existsWorkspace` reports it free, so without
|
||||
// this the wizard walks the user to the last step before the create fails.
|
||||
it('refuses `global`', () => {
|
||||
expect(validateWorkspaceId('global')).toMatch(/not allowed/i)
|
||||
})
|
||||
|
||||
it('refuses it as the effective id too', () => {
|
||||
expect(validateWorkspaceId('wm-fork-x', 'global')).toMatch(/not allowed/i)
|
||||
})
|
||||
|
||||
it('allows a fork named `global`, which reaches the backend as `wm-fork-global`', () => {
|
||||
expect(validateWorkspaceId('global', 'wm-fork-global')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still accepts ids that merely contain it', () => {
|
||||
expect(validateWorkspaceId('global-ops')).toBeUndefined()
|
||||
expect(validateWorkspaceId('my-global')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* What the backend accepts as a workspace id, in one place. Every screen that lets
|
||||
* someone name a workspace validates against this — a laxer copy elsewhere only
|
||||
* moves the rejection from the form to the create call, after the user has
|
||||
* finished the whole flow.
|
||||
*/
|
||||
|
||||
/** Letters, digits and underscores in dash-separated groups: no leading, trailing or doubled dash. */
|
||||
export const WORKSPACE_ID_RE = /^\w+(-\w+)*$/
|
||||
/** The DB column and the git branch name derived from it both stop here. */
|
||||
export const WORKSPACE_ID_MAX_LENGTH = 50
|
||||
|
||||
/** `validate_workspace_name` (windmill-common/src/workspaces.rs:246) refuses a longer name. */
|
||||
export const WORKSPACE_NAME_MAX_LENGTH = 50
|
||||
|
||||
/** `check_w_id_conflict` (windmill-api-workspaces/src/workspaces.rs:5111) rejects this id. */
|
||||
const RESERVED_WORKSPACE_ID = 'global'
|
||||
|
||||
/**
|
||||
* The reason `id` is not a usable workspace id, or undefined when it is.
|
||||
*
|
||||
* `effectiveId` is what actually reaches the backend: a fork's id is submitted
|
||||
* with a `wm-fork-` prefix, so the length limit applies to the prefixed form while
|
||||
* the character rule still applies to what the user typed.
|
||||
*/
|
||||
export function validateWorkspaceId(id: string, effectiveId: string = id): string | undefined {
|
||||
if (!WORKSPACE_ID_RE.test(id)) {
|
||||
return 'ID can only contain letters, numbers and dashes and must not finish by a dash'
|
||||
}
|
||||
// `check_w_id_conflict` refuses it outright, and `existsWorkspace` reports it free —
|
||||
// so without this the wizard walks the user to the last step before the create fails.
|
||||
// Only the effective id, which is what the backend receives: it defaults to the raw one,
|
||||
// so a plain `global` is still caught, while a fork named `global` — submitted as
|
||||
// `wm-fork-global`, which the backend accepts — is not.
|
||||
if (effectiveId === RESERVED_WORKSPACE_ID) {
|
||||
return `'${RESERVED_WORKSPACE_ID}' is not allowed as a workspace ID`
|
||||
}
|
||||
if (effectiveId.length > WORKSPACE_ID_MAX_LENGTH) {
|
||||
return `ID '${effectiveId}' is too long (${effectiveId.length} chars). Maximum is ${WORKSPACE_ID_MAX_LENGTH}.`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Slugifies free text into something `validateWorkspaceId` accepts, for a prefill. */
|
||||
export function toWorkspaceId(raw: string): string {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, WORKSPACE_ID_MAX_LENGTH)
|
||||
.replace(/-+$/, '')
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The instance policy around creating a workspace, and what the client has to do
|
||||
* once one exists. Shared by every screen that creates workspaces — the workspace
|
||||
* settings creator and the hub import wizard — so the two cannot drift apart on
|
||||
* who is allowed to create, what username the backend expects, or what state the
|
||||
* client is left in afterwards.
|
||||
*
|
||||
* Id validation lives next door in `$lib/utils/workspaceId`, which has no imports
|
||||
* and so can be used from anywhere.
|
||||
*/
|
||||
|
||||
import { SettingService, UserService, WorkspaceService } from '$lib/gen'
|
||||
import { usersWorkspaceStore } from '$lib/stores'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
/**
|
||||
* Whether this user may create a workspace at all. Self-hosted instances default
|
||||
* `CREATE_WORKSPACE_REQUIRE_SUPERADMIN` to true, so offering the choice to
|
||||
* everyone ends in a 403 at the last step.
|
||||
*
|
||||
* Superadmin arrives asynchronously in most callers, so pass the current value and
|
||||
* call again when it flips. When the gate is on, the server is asked directly
|
||||
* rather than trusting that value: `refreshSuperadmin` skips its fetch once the
|
||||
* store holds anything, so a page loaded logged out leaves it `false` for the rest
|
||||
* of the session — including right after signing in.
|
||||
*/
|
||||
export async function canCreateWorkspace(isSuperadmin: boolean): Promise<boolean> {
|
||||
if (isSuperadmin || isCloudHosted()) return true
|
||||
try {
|
||||
const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin')
|
||||
if ((await r.text()) != 'true') return true
|
||||
return !!(await UserService.globalWhoami()).super_admin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface UsernamePolicy {
|
||||
/** When true the backend derives the username and rejects one sent explicitly. */
|
||||
automate: boolean
|
||||
/** A username to prefill with, when the caller has to ask for one. */
|
||||
suggested?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `createWorkspace` rejects a username when the instance automates them and
|
||||
* requires one when it does not, so the field only exists in the second case.
|
||||
*/
|
||||
export async function loadUsernamePolicy(): Promise<UsernamePolicy> {
|
||||
const automate =
|
||||
((await SettingService.getGlobal({
|
||||
key: 'automate_username_creation'
|
||||
})) as boolean | null) ?? true
|
||||
if (automate) return { automate: true }
|
||||
try {
|
||||
const me = await UserService.globalWhoami()
|
||||
const from = me.name ? me.name.split(' ')[0] : me.email.split('@')[0]
|
||||
return { automate: false, suggested: from.replace(/\./g, '').toLowerCase() }
|
||||
} catch {
|
||||
return { automate: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the workspaces this user belongs to. Anything that creates or deletes a
|
||||
* workspace owes the client this call: `usersWorkspaceStore` is what the picker and
|
||||
* every derived workspace list read from, and nothing else refreshes it until a
|
||||
* full page load.
|
||||
*/
|
||||
export async function refreshWorkspaceList(): Promise<void> {
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
}
|
||||
|
||||
/** Enters a workspace that was just created, with the list refreshed to match. */
|
||||
export async function enterNewWorkspace(id: string): Promise<void> {
|
||||
await refreshWorkspaceList()
|
||||
switchWorkspace(id)
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { Button } from '$lib/components/common'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import RadioCard from '$lib/components/common/radioCard/RadioCard.svelte'
|
||||
import ImportProjectCard, {
|
||||
type ImportProjectSummary
|
||||
} from '$lib/components/ImportProjectCard.svelte'
|
||||
import { fetchHubProject, hubBrowserUrl } from '$lib/hubProject'
|
||||
import ImportProjectStep from '$lib/components/ImportProjectStep.svelte'
|
||||
import ImportSetupStep from '$lib/components/ImportSetupStep.svelte'
|
||||
import ImportWizardSteps from '$lib/components/ImportWizardSteps.svelte'
|
||||
import type { ImportExecution } from '$lib/importWizard/execution.svelte'
|
||||
import WorkspaceTreeView from '$lib/components/workspace/WorkspaceTreeView.svelte'
|
||||
import { superadmin, usersWorkspaceStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
import { resource } from 'runed'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { WorkspaceService, type UserWorkspaceList } from '$lib/gen'
|
||||
import { canCreateWorkspace, loadUsernamePolicy } from '$lib/workspaceCreation'
|
||||
import { toWorkspaceId, validateWorkspaceId } from '$lib/utils/workspaceId'
|
||||
import { validateUsername } from '$lib/utils'
|
||||
import {
|
||||
readPlan,
|
||||
planToSearch,
|
||||
planWorkspaceId,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type WizardStep
|
||||
} from '$lib/importWizard/plan'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
Plus,
|
||||
Search
|
||||
} from 'lucide-svelte'
|
||||
|
||||
// This page only ever *describes* an import. The plan lives in the URL, so the
|
||||
// browser's back button and the stepper are the same operation — a URL change —
|
||||
// and neither can strand a half-created workspace, because nothing is created
|
||||
// until the last step runs it. See lib/importWizard/plan.ts.
|
||||
const parsed = $derived(readPlan($page.url))
|
||||
const plan = $derived(parsed.plan)
|
||||
const step = $derived(parsed.step)
|
||||
const slug = $derived(plan.slug)
|
||||
const logoutReturnTo = $derived($page.url.pathname + $page.url.search)
|
||||
|
||||
// `replace` for anything the page decides on its own — a correction, or mirroring
|
||||
// a field into the plan. Those must not become history entries: the user did not
|
||||
// ask for them, and a pushed entry is one the Back button has to walk through
|
||||
// before it reaches the step they actually came from. `goto` forwards its options
|
||||
// to SvelteKit, which defaults `replaceState` to false.
|
||||
function go(next: Partial<ImportPlan>, step: WizardStep, opts?: { replace?: boolean }) {
|
||||
goto(`/projects/import${planToSearch({ ...plan, ...next }, step)}`, {
|
||||
replaceState: opts?.replace ?? false
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- permissions
|
||||
let canCreate = $state($superadmin || isCloudHosted())
|
||||
$effect(() => {
|
||||
if ($superadmin) canCreate = true
|
||||
})
|
||||
if (!canCreate) {
|
||||
void canCreateWorkspace(false).then((c) => (canCreate = c))
|
||||
}
|
||||
|
||||
let automateUsername = $state(true)
|
||||
let username = $state('')
|
||||
void loadUsernamePolicy()
|
||||
.then((p) => {
|
||||
automateUsername = p.automate
|
||||
if (p.suggested && !username) username = p.suggested
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
// ------------------------------------------------------------------- the project
|
||||
// Straight from the hub, cross-origin: this runs before there is a workspace to
|
||||
// proxy through. A failure is not fatal — the wizard still works, the card just
|
||||
// shows the slug and the choices drop their item counts.
|
||||
const projectResource = resource(
|
||||
() => slug || undefined,
|
||||
async (s): Promise<ImportProjectSummary | undefined> =>
|
||||
s ? await fetchHubProject(s) : undefined
|
||||
)
|
||||
const project = $derived(projectResource.current)
|
||||
const projectError = $derived(!!projectResource.error)
|
||||
|
||||
let hubHost = $state('hub.windmill.dev')
|
||||
void hubBrowserUrl()
|
||||
.then((u) => (hubHost = new URL(u).host))
|
||||
.catch(() => {})
|
||||
|
||||
const itemCount = $derived(
|
||||
project ? Object.values(project.counts).reduce((a: number, b: number) => a + b, 0) : 0
|
||||
)
|
||||
const itemsLabel = $derived(itemCount > 0 ? `the ${itemCount} items` : 'everything in it')
|
||||
|
||||
// --------------------------------------------------------------------- step 1
|
||||
let chosen = $state<'new' | 'existing' | undefined>(undefined)
|
||||
// The plan is consulted before the default so coming back from step 2 shows the
|
||||
// choice that was made there. `canCreate` starts false and only turns true once
|
||||
// the superadmin refresh or the settings fetch lands, so the fallback is derived
|
||||
// rather than written at init.
|
||||
const choice = $derived(chosen ?? plan.destination?.kind ?? (canCreate ? 'new' : 'existing'))
|
||||
|
||||
// --------------------------------------------------------------------- step 2
|
||||
let name = $state(plan.destination?.kind === 'new' ? plan.destination.name : '')
|
||||
let id = $state(plan.destination?.kind === 'new' ? plan.destination.id : toWorkspaceId(plan.slug))
|
||||
let idTaken = $state(false)
|
||||
let checkingId = $state(false)
|
||||
// The hub's own name for the project, once it arrives, unless the user has typed.
|
||||
$effect(() => {
|
||||
const p = projectResource.current
|
||||
if (p && !name) name = p.name
|
||||
})
|
||||
|
||||
/** Free id nearest the prefill: `-2`, `-3`, … so re-importing a project works. */
|
||||
async function freeId(candidate: string): Promise<string> {
|
||||
for (let n = 1; n <= 20; n++) {
|
||||
const next = n === 1 ? candidate : `${candidate}-${n}`
|
||||
if (validateWorkspaceId(next)) break
|
||||
if (!(await WorkspaceService.existsWorkspace({ requestBody: { id: next } }))) return next
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
async function checkId() {
|
||||
const candidate = id.trim()
|
||||
if (!candidate) return
|
||||
checkingId = true
|
||||
try {
|
||||
idTaken = await WorkspaceService.existsWorkspace({ requestBody: { id: candidate } })
|
||||
} catch {
|
||||
idTaken = false
|
||||
} finally {
|
||||
checkingId = false
|
||||
}
|
||||
}
|
||||
|
||||
const idProblem = $derived(id.trim() ? validateWorkspaceId(id.trim()) : undefined)
|
||||
// Only when the instance does not derive it, which is the only case the field is shown.
|
||||
// `create_workspace` accepts whatever it is sent — `Some("")` passes its one check and is
|
||||
// written to `usr.username` verbatim — so this is the only thing standing between a
|
||||
// cleared field and a workspace whose owner has no username.
|
||||
const usernameProblem = $derived(
|
||||
automateUsername
|
||||
? undefined
|
||||
: !username.trim()
|
||||
? 'A username is required'
|
||||
: validateUsername(username.trim()) || undefined
|
||||
)
|
||||
// Step 2 shows the workspace list when step 1 chose "one I already have".
|
||||
const choiceIsExisting = $derived(plan.destination?.kind === 'existing')
|
||||
|
||||
let filter = $state('')
|
||||
let allExpanded = $state(false)
|
||||
let hasForks = $state(false)
|
||||
let expandCollapseAll = $state<(() => void) | undefined>(undefined)
|
||||
|
||||
// Loaded once step 2 actually asks for a workspace. The store is read through
|
||||
// `get` rather than `$usersWorkspaceStore`: the fetcher writes that store, and a
|
||||
// tracked read of it here would make the resource re-run its own result.
|
||||
const workspaceList = resource(
|
||||
() => (step === 2 && choiceIsExisting ? true : undefined),
|
||||
async (needed) => {
|
||||
if (!needed) return undefined
|
||||
const list = get(usersWorkspaceStore) ?? (await WorkspaceService.listUserWorkspaces())
|
||||
usersWorkspaceStore.set(list)
|
||||
return list.workspaces.filter((w) => !w.disabled)
|
||||
}
|
||||
)
|
||||
const workspaces = $derived<UserWorkspaceList['workspaces']>(workspaceList.current ?? [])
|
||||
|
||||
// ----------------------------------------------------------------- transitions
|
||||
// Steps 2 and 3 both refine an answer step 1 gives, so a URL that reaches them
|
||||
// without a destination is missing that answer rather than holding a default.
|
||||
$effect(() => {
|
||||
if (step > 1 && !plan.destination) go({}, 1, { replace: true })
|
||||
})
|
||||
|
||||
// Suffixing happens here rather than reactively on step 2: it is a consequence of
|
||||
// choosing to create a workspace, and `-2`, `-3`, … let a project be imported
|
||||
// twice. Checking is a read — the wizard still creates nothing at this point.
|
||||
async function step1Continue() {
|
||||
if (choice === 'new') {
|
||||
checkingId = true
|
||||
try {
|
||||
id = await freeId(id.trim() || toWorkspaceId(slug))
|
||||
idTaken = false
|
||||
} finally {
|
||||
checkingId = false
|
||||
}
|
||||
}
|
||||
const destination: ImportDestination =
|
||||
choice === 'new'
|
||||
? { kind: 'new', name: name.trim() || slug, id: id.trim(), username: username || undefined }
|
||||
: // Which workspace is step 2's question; the plan records the kind now so
|
||||
// the two answers stay distinguishable in the URL.
|
||||
{
|
||||
kind: 'existing',
|
||||
workspaceId:
|
||||
plan.destination?.kind === 'existing' ? plan.destination.workspaceId : undefined
|
||||
}
|
||||
go({ destination }, 2)
|
||||
}
|
||||
|
||||
/** Step 2, new workspace: records the name. Still creates nothing. */
|
||||
function confirmNewWorkspace() {
|
||||
go(
|
||||
{
|
||||
destination: {
|
||||
kind: 'new',
|
||||
name: name.trim(),
|
||||
id: id.trim(),
|
||||
username: automateUsername ? undefined : username.trim()
|
||||
}
|
||||
},
|
||||
3
|
||||
)
|
||||
}
|
||||
|
||||
/** Step 2, existing workspace: records the choice, exactly like the real picker's click. */
|
||||
async function pickExisting(workspaceId: string) {
|
||||
go({ destination: { kind: 'existing', workspaceId } }, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* The wizard sits outside the `(logged)` layout, so leaving it mounts that layout
|
||||
* and the workspace home for the first time — a second or more of loading with the
|
||||
* finished wizard still on screen, which reads as a dead button. Hand the screen
|
||||
* over to a loader on the way out; the navigation unmounts it.
|
||||
*/
|
||||
let leaving = $state(false)
|
||||
function finish() {
|
||||
leaving = true
|
||||
// The run has already switched to the destination workspace.
|
||||
goto('/')
|
||||
}
|
||||
|
||||
// Whether a fourth step exists. Known only once the run has fetched the export and
|
||||
// the destination's data tables can be compared against it, so it is false for the
|
||||
// whole wizard until the import finishes — which is exactly when it is first read.
|
||||
let execution = $state<ImportExecution | undefined>(undefined)
|
||||
let setupNeeded = $state(false)
|
||||
// True while the answer is still being fetched. Without it the run reads as finished
|
||||
// with no fourth step, and Finish leaves for the workspace before the check comes back
|
||||
// and discovers a data table that is missing.
|
||||
let setupUndecided = $state(false)
|
||||
$effect(() => {
|
||||
const names = execution?.datatableNames ?? []
|
||||
const workspace = planWorkspaceId(plan)
|
||||
if (!execution?.done || !workspace) {
|
||||
setupNeeded = false
|
||||
setupUndecided = false
|
||||
return
|
||||
}
|
||||
// Every resource the project ships arrives as an empty stub, so any project with
|
||||
// resources has something to fill in. The step itself re-checks and shows only
|
||||
// what is genuinely outstanding, which is what makes a re-import quiet.
|
||||
if (execution.resourceCount > 0) {
|
||||
setupNeeded = true
|
||||
setupUndecided = false
|
||||
return
|
||||
}
|
||||
if (names.length === 0) {
|
||||
setupNeeded = false
|
||||
setupUndecided = false
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setupUndecided = true
|
||||
void WorkspaceService.listDataTables({ workspace })
|
||||
.then((tables) => {
|
||||
if (cancelled) return
|
||||
const present = new Set(tables.map((t) => t.name))
|
||||
setupNeeded = names.some((n) => !present.has(n))
|
||||
})
|
||||
.catch(() => {
|
||||
// Can't tell — don't invent a step the user then cannot complete.
|
||||
if (!cancelled) setupNeeded = false
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setupUndecided = false
|
||||
})
|
||||
return () => (cancelled = true)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if leaving}
|
||||
<CenteredModal title="Opening your workspace" centerVertically={false}>
|
||||
<div class="flex items-center gap-2 text-xs text-secondary">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
Taking you to {planWorkspaceId(plan) ?? 'your workspace'}…
|
||||
</div>
|
||||
</CenteredModal>
|
||||
{:else if !slug}
|
||||
<CenteredModal title="Nothing to import" centerVertically={false}>
|
||||
<p class="text-xs text-secondary">
|
||||
This page needs a <span class="font-mono">?hub=<slug></span> to know which project to import.
|
||||
Open it from a project on the hub.
|
||||
</p>
|
||||
</CenteredModal>
|
||||
{:else}
|
||||
<!-- One width for all three steps: `large` on step 3 alone made the modal jump
|
||||
from 640px to 1024px mid-wizard, which reads as a different page. -->
|
||||
<!-- Same configuration as the workspace picker page: the modal sizes to its
|
||||
content and the workspace tree scrolls inside its own max-h-[50vh].
|
||||
`containOverflow` would stretch the modal to the full screen height. -->
|
||||
<CenteredModal title="Import a project" centerVertically={false}>
|
||||
{#snippet subtitleSnippet()}
|
||||
<span class="text-xs text-tertiary">
|
||||
Connected as <span class="font-medium text-secondary">{$usersWorkspaceStore?.email}</span>
|
||||
{#if step === 1}
|
||||
·
|
||||
<a
|
||||
class="text-blue-500 hover:underline"
|
||||
href="{base}/user/logout?rd={encodeURIComponent(logoutReturnTo)}"
|
||||
>
|
||||
Switch account
|
||||
</a>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
<!-- Once the run is gone, the import step behind us has nothing to resume and would
|
||||
offer to run the whole bundle again — over items already in, or over a create
|
||||
whose workspace now exists. That is only reachable after a reload on step 4, so
|
||||
the floor rises exactly then. -->
|
||||
<ImportWizardSteps
|
||||
{step}
|
||||
hasSetup={setupNeeded || step === 4}
|
||||
lowestStep={step === 4 && !execution ? 4 : 1}
|
||||
/>
|
||||
|
||||
{#if step === 1}
|
||||
<div class="flex flex-col gap-6">
|
||||
{#if project}
|
||||
<ImportProjectCard {project} {hubHost} />
|
||||
{:else if projectError}
|
||||
<p class="text-xs text-secondary">
|
||||
Could not read <span class="font-mono">{slug}</span> from the hub. You can still choose a
|
||||
destination — the project is fetched again before it is imported.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary">
|
||||
<Loader2 size={14} class="animate-spin" /> Loading {slug}…
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-sm font-semibold text-emphasis">Where should it go?</h2>
|
||||
|
||||
<!-- The whole card is the control: one choice at a time, shown by the
|
||||
border and tint rather than a dot, hence `showRadio={false}`. -->
|
||||
<div role="radiogroup" aria-label="Where should it go?" class="flex flex-col gap-3">
|
||||
{#if canCreate}
|
||||
<RadioCard
|
||||
label="A new workspace"
|
||||
showRadio={false}
|
||||
selected={choice === 'new'}
|
||||
onSelect={() => (chosen = 'new')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Plus size={14} class="text-secondary" />
|
||||
{/snippet}
|
||||
{#snippet description()}
|
||||
Creates <span class="font-medium text-primary">{name || slug}</span> and imports
|
||||
{itemsLabel} into it.
|
||||
{/snippet}
|
||||
</RadioCard>
|
||||
{/if}
|
||||
|
||||
<RadioCard
|
||||
label="A workspace I already have"
|
||||
description="Imports {itemsLabel} into a workspace you already use."
|
||||
showRadio={false}
|
||||
selected={choice === 'existing'}
|
||||
onSelect={() => (chosen = 'existing')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Building size={14} class="text-secondary" />
|
||||
{/snippet}
|
||||
</RadioCard>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center justify-end">
|
||||
<Button unifiedSize="sm" variant="accent" onClick={step1Continue}>Continue →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if step === 2}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !choiceIsExisting}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-emphasis">Name the new workspace</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-normal text-secondary">Workspace name</span>
|
||||
<TextInput size="sm" bind:value={name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-normal text-secondary">Workspace ID</span>
|
||||
<TextInput size="sm" bind:value={id} inputProps={{ onblur: checkId }} />
|
||||
{#if idProblem}
|
||||
<span class="text-2xs font-normal text-red-500">{idProblem}</span>
|
||||
{:else if idTaken}
|
||||
<span class="text-2xs font-normal text-red-500">ID already exists.</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !automateUsername}
|
||||
<label class="flex max-w-[50%] flex-col gap-1">
|
||||
<span class="text-xs font-normal text-secondary">Your username in it</span>
|
||||
<TextInput size="sm" bind:value={username} />
|
||||
{#if usernameProblem && username.trim()}
|
||||
<span class="text-2xs font-normal text-red-500">{usernameProblem}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{:else}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-emphasis">Pick a workspace</h2>
|
||||
<p class="mt-0.5 text-xs text-secondary">The project is imported into this one.</p>
|
||||
</div>
|
||||
|
||||
{#if workspaceList.loading}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary">
|
||||
<Loader2 size={14} class="animate-spin" /> Loading your workspaces…
|
||||
</div>
|
||||
{:else if workspaceList.error}
|
||||
<p class="text-xs text-red-500">
|
||||
Could not list your workspaces. Reload the page, or go back and create a new one.
|
||||
</p>
|
||||
{:else if workspaces.length === 0}
|
||||
<p class="text-xs text-secondary">
|
||||
You are not a member of any workspace yet. Go back and create one, or ask an admin to
|
||||
invite you.
|
||||
</p>
|
||||
{:else}
|
||||
<!-- The same tree the workspace picker renders: forks nested under their
|
||||
parent, search, colours, roles. Clicking a row is the choice, exactly as
|
||||
clicking one there enters the workspace — so this branch has no Continue. -->
|
||||
{#if workspaces.length > 1}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1 text-primary">
|
||||
<TextInput
|
||||
size="sm"
|
||||
bind:value={filter}
|
||||
inputProps={{ placeholder: 'Search workspaces...' }}
|
||||
class="!pr-8"
|
||||
/>
|
||||
<Search size={14} class="absolute right-2 top-2 text-secondary" />
|
||||
</div>
|
||||
{#if hasForks}
|
||||
<Button
|
||||
onClick={() => expandCollapseAll?.()}
|
||||
title={allExpanded ? 'Collapse all' : 'Expand all'}
|
||||
startIcon={{ icon: allExpanded ? ChevronsDownUp : ChevronsUpDown }}
|
||||
unifiedSize="2xs"
|
||||
variant="default"
|
||||
>
|
||||
{allExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- No scroll wrapper here: the tree's own root is `max-h-[50vh]
|
||||
overflow-auto` and doubles as its keyboard scroll container, so
|
||||
wrapping it produced two scrollbars, the outer one tighter. -->
|
||||
<WorkspaceTreeView
|
||||
{workspaces}
|
||||
onEnterWorkspace={pickExisting}
|
||||
bind:searchFilter={filter}
|
||||
bind:allExpanded
|
||||
bind:hasForks
|
||||
bind:onExpandCollapseAll={expandCollapseAll}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
onClick={() => go({}, 1)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{#if !choiceIsExisting}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
disabled={!name.trim() ||
|
||||
!id.trim() ||
|
||||
!!idProblem ||
|
||||
!!usernameProblem ||
|
||||
idTaken ||
|
||||
checkingId}
|
||||
onClick={confirmNewWorkspace}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if step === 3}
|
||||
<ImportProjectStep
|
||||
{plan}
|
||||
{project}
|
||||
setupPending={setupNeeded}
|
||||
{setupUndecided}
|
||||
onFolderChange={(folder) => 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}
|
||||
<ImportSetupStep
|
||||
workspace={planWorkspaceId(plan) ?? ''}
|
||||
{slug}
|
||||
folder={plan.folder}
|
||||
onSkip={finish}
|
||||
onFinish={finish}
|
||||
onBack={// Only while this page still holds the run. After a reload it does not, and a
|
||||
// step 3 with no run offers Import again — over a bundle that is already in,
|
||||
// and on a new workspace over a create that would now fail, because the
|
||||
// finished run cleared its parking.
|
||||
execution ? () => go({}, 3) : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</CenteredModal>
|
||||
{/if}
|
||||
@@ -1,374 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { workspaceStore, enterpriseLicense } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import FolderPicker from '$lib/components/FolderPicker.svelte'
|
||||
import type {
|
||||
ProjectExport,
|
||||
ProjectMigration
|
||||
} from '$lib/components/workspaceSettings/projectBundle'
|
||||
import {
|
||||
installProject,
|
||||
type InstallResult
|
||||
} from '$lib/components/workspaceSettings/projectInstall'
|
||||
import MigrationSqlEditor from '$lib/components/workspaceSettings/MigrationSqlEditor.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { Cloud, Download, Loader2 } from 'lucide-svelte'
|
||||
|
||||
let slug = $derived($page.url.searchParams.get('hub') ?? '')
|
||||
let workspace = $derived($workspaceStore)
|
||||
|
||||
let loading = $state(true)
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
let data = $state<ProjectExport | undefined>(undefined)
|
||||
let installing = $state(false)
|
||||
// True while the migration review/missing-datatable modals are open, before the
|
||||
// import spinner starts — keeps the Import button from launching a second import.
|
||||
let planningMigrations = $state(false)
|
||||
let results = $state<InstallResult[]>([])
|
||||
let done = $state(false)
|
||||
let folderName = $state('')
|
||||
|
||||
// When the target lacks a needed data table, "import without that migration".
|
||||
const missingDatatableModal = createAsyncConfirmationModal()
|
||||
|
||||
// Migration review drawer: preview + edit each runnable migration's SQL and
|
||||
// choose which to run, resolved linearly via `reviewResolve`.
|
||||
let reviewDrawer = $state<Drawer | undefined>()
|
||||
let reviewList = $state<
|
||||
{ datatable_name: string; sql: string; sql_down: string; run: boolean }[]
|
||||
>([])
|
||||
// Bumped per review session so the Monaco editors re-mount with the new SQL.
|
||||
let reviewGeneration = $state(0)
|
||||
let reviewResolve: ((run: boolean) => void) | undefined
|
||||
function openMigrationReview(migs: ProjectMigration[]): Promise<boolean> {
|
||||
reviewList = migs.map((m) => ({
|
||||
datatable_name: m.datatable_name,
|
||||
sql: m.sql,
|
||||
sql_down: m.sql_down ?? '',
|
||||
run: true
|
||||
}))
|
||||
reviewGeneration++
|
||||
reviewDrawer?.openDrawer()
|
||||
return new Promise((resolve) => (reviewResolve = resolve))
|
||||
}
|
||||
function closeMigrationReview(run: boolean) {
|
||||
// Capture + clear first so the `on:close` fired by closeDrawer() (which would
|
||||
// call this again with run=false) can't override an explicit Run/Skip choice.
|
||||
const resolve = reviewResolve
|
||||
reviewResolve = undefined
|
||||
reviewDrawer?.closeDrawer()
|
||||
resolve?.(run)
|
||||
}
|
||||
|
||||
let loadSeq = 0
|
||||
|
||||
$effect(() => {
|
||||
if (slug && workspace) void load()
|
||||
})
|
||||
|
||||
async function load() {
|
||||
// Token + captured slug/workspace: a slow /export for an old ?hub= must not
|
||||
// overwrite the data of a newer one once we've navigated away.
|
||||
const reqSeq = ++loadSeq
|
||||
const reqSlug = slug
|
||||
const reqWorkspace = workspace
|
||||
loading = true
|
||||
loadError = undefined
|
||||
// New slug/workspace = a fresh import session: drop the previous project's
|
||||
// outcome, otherwise project B stays disabled as "Imported" with A's
|
||||
// results, and keeps A's folder.
|
||||
data = undefined
|
||||
done = false
|
||||
results = []
|
||||
folderName = ''
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/w/${reqWorkspace}/hub/projects/${encodeURIComponent(reqSlug)}/export`,
|
||||
{ credentials: 'include', headers: { accept: 'application/json' } }
|
||||
)
|
||||
const text = await res.text()
|
||||
if (reqSeq !== loadSeq) return // a newer load() superseded this one
|
||||
if (!res.ok) throw new Error(`export ${res.status}: ${text}`)
|
||||
data = JSON.parse(text)
|
||||
if (data && !folderName) folderName = data.project.slug
|
||||
} catch (e: any) {
|
||||
if (reqSeq !== loadSeq) return
|
||||
loadError = e?.message ?? String(e)
|
||||
} finally {
|
||||
if (reqSeq === loadSeq) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const counts = $derived(
|
||||
data
|
||||
? {
|
||||
scripts: data.scripts.length,
|
||||
flows: data.flows.length,
|
||||
apps: data.apps.length,
|
||||
resources: data.resources.length,
|
||||
triggers: data.triggers.length,
|
||||
migrations: (data.migrations ?? []).filter(
|
||||
(m) => m.enabled && (m.sql ?? '').trim() !== ''
|
||||
).length
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Decide which data table migrations to run. Migrations are keyed by data table
|
||||
// name and applied only to a target data table of the same name. Returns the
|
||||
// migrations to run (with any edits the user made), an empty array when there's
|
||||
// nothing to run, or `null` when the user backs out of the whole import at the
|
||||
// missing-data-table warning.
|
||||
async function planMigrations(
|
||||
workspace: string,
|
||||
migrations: ProjectMigration[]
|
||||
): Promise<ProjectMigration[] | null> {
|
||||
const enabled = migrations.filter((m) => m.enabled && (m.sql ?? '').trim() !== '')
|
||||
if (enabled.length === 0) return []
|
||||
|
||||
let present: Set<string>
|
||||
try {
|
||||
const dts = await WorkspaceService.listDataTables({ workspace })
|
||||
present = new Set(dts.map((d) => d.name))
|
||||
} catch {
|
||||
// Can't read the target's data tables — skip migrations rather than guess.
|
||||
return []
|
||||
}
|
||||
const runnable = enabled.filter((m) => present.has(m.datatable_name))
|
||||
const missingNames = [
|
||||
...new Set(enabled.filter((m) => !present.has(m.datatable_name)).map((m) => m.datatable_name))
|
||||
]
|
||||
|
||||
// Warn about missing data tables first: confirming imports without their
|
||||
// migrations, cancelling backs out of the whole import so the user can create
|
||||
// the data table(s) and re-run.
|
||||
if (missingNames.length > 0) {
|
||||
const proceed = await missingDatatableModal.ask({
|
||||
title: 'Some data tables are missing',
|
||||
confirmationText: 'Import without them',
|
||||
children: `This project uses data table(s) "${missingNames.join(
|
||||
'", "'
|
||||
)}" that don't exist in this workspace, so their migrations will be skipped. To apply them, cancel, create the data table(s) with the same name in Workspace settings → Data tables, then re-run this import.`
|
||||
})
|
||||
if (!proceed) return null
|
||||
}
|
||||
|
||||
let toRun: ProjectMigration[] = []
|
||||
if (runnable.length > 0) {
|
||||
const run = await openMigrationReview(runnable)
|
||||
if (run) {
|
||||
toRun = reviewList
|
||||
.filter((r) => r.run && r.sql.trim() !== '')
|
||||
.map((r) => ({
|
||||
datatable_name: r.datatable_name,
|
||||
sql: r.sql,
|
||||
sql_down: r.sql_down,
|
||||
enabled: true
|
||||
}))
|
||||
}
|
||||
}
|
||||
return toRun
|
||||
}
|
||||
|
||||
async function install() {
|
||||
// Snapshot reactive state up-front: `workspace` ($derived) and `data`
|
||||
// ($state, replaced by load()) can both change mid-import on a workspace
|
||||
// switch, which would split items or mix two exports. Pin both.
|
||||
// Guard against a second click while the review modal is open (the Import
|
||||
// button isn't `installing` yet during planning, so it would otherwise be
|
||||
// clickable and start a concurrent import).
|
||||
if (installing || planningMigrations) return
|
||||
const workspace = $workspaceStore
|
||||
const exportData = data
|
||||
if (!exportData || !workspace) return
|
||||
const folder = folderName.trim() || exportData.project.slug
|
||||
// A slug/workspace switch mid-import bumps loadSeq and resets the view for
|
||||
// the new project; this import's UI writes (results/done/toast) must then
|
||||
// be dropped so they can't mark the new project as imported.
|
||||
const sessionSeq = loadSeq
|
||||
const sessionSlug = slug
|
||||
// loadSeq only advances when a NEW load starts; navigating away (workspace
|
||||
// or ?hub becoming empty) never bumps it, so also require the live
|
||||
// identity to still match the captured one.
|
||||
const current = () =>
|
||||
sessionSeq === loadSeq && slug === sessionSlug && $workspaceStore === workspace
|
||||
|
||||
// Review data table migrations first (before the import spinner), so the user
|
||||
// previews/edits and decides, then the whole import runs uninterrupted.
|
||||
planningMigrations = true
|
||||
let migrationsToRun: ProjectMigration[] | null
|
||||
try {
|
||||
migrationsToRun = await planMigrations(workspace, exportData.migrations ?? [])
|
||||
} finally {
|
||||
planningMigrations = false
|
||||
}
|
||||
// User backed out at the missing-data-table warning — abort the whole import.
|
||||
if (migrationsToRun === null) return
|
||||
// The migration review can stay open indefinitely; if the project or
|
||||
// workspace changed underneath it, confirming the stale dialog must not
|
||||
// write the old export into the old workspace (with all feedback
|
||||
// suppressed by the session guard).
|
||||
if (!current()) {
|
||||
sendUserToast('Import cancelled — the project or workspace changed during review.', true)
|
||||
return
|
||||
}
|
||||
|
||||
installing = true
|
||||
results = []
|
||||
done = false
|
||||
try {
|
||||
await installProject({
|
||||
workspace,
|
||||
exportData,
|
||||
folder,
|
||||
migrations: migrationsToRun,
|
||||
hasEeLicense: !!$enterpriseLicense,
|
||||
onResult: (r) => {
|
||||
if (current()) results = [...results, r]
|
||||
}
|
||||
})
|
||||
|
||||
if (current()) {
|
||||
done = true
|
||||
const failed = results.filter((r) => !r.ok).length
|
||||
sendUserToast(
|
||||
failed > 0
|
||||
? `Imported with ${failed} item(s) failed.`
|
||||
: `Project imported into ${workspace}.`,
|
||||
failed > 0
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
installing = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto w-full max-w-screen-md px-4 py-10">
|
||||
{#if !slug}
|
||||
<p class="text-sm text-secondary">Missing <span class="font-mono">?hub=<slug></span>.</p>
|
||||
{:else if loading}
|
||||
<div class="flex items-center gap-2 text-sm text-secondary">
|
||||
<Loader2 size={16} class="animate-spin" /> Loading project…
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<p class="text-sm text-red-600">Failed to load project: {loadError}</p>
|
||||
{:else if data}
|
||||
<h1 class="text-2xl font-semibold text-primary">Add “{data.project.name}” to workspace</h1>
|
||||
<p class="mt-1 text-sm text-secondary">{data.project.summary}</p>
|
||||
|
||||
<div class="mt-4 max-w-xs">
|
||||
<p class="mb-1 text-xs text-tertiary">
|
||||
Folder in <span class="font-mono">{workspace}</span>
|
||||
</p>
|
||||
<FolderPicker bind:folderName disabled={installing || done} size="sm" />
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
Items import under <span class="font-mono">f/{folderName.trim() || data.project.slug}/</span
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-2 text-xs">
|
||||
<span class="rounded border px-2 py-1">{counts?.scripts} scripts</span>
|
||||
<span class="rounded border px-2 py-1">{counts?.flows} flows</span>
|
||||
<span class="rounded border px-2 py-1">{counts?.apps} apps</span>
|
||||
<span class="rounded border px-2 py-1">{counts?.resources} resources</span>
|
||||
<span class="rounded border px-2 py-1">{counts?.triggers} triggers</span>
|
||||
{#if counts && counts.migrations > 0}
|
||||
<span class="rounded border px-2 py-1">{counts.migrations} data table migrations</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="accent"
|
||||
startIcon={{ icon: done ? Cloud : Download }}
|
||||
disabled={installing || done || planningMigrations}
|
||||
onclick={install}
|
||||
>
|
||||
{#if installing}
|
||||
<Loader2 size={16} class="animate-spin mr-1" /> Importing…
|
||||
{:else if done}
|
||||
Imported
|
||||
{:else}
|
||||
Import to {workspace}
|
||||
{/if}
|
||||
</Button>
|
||||
{#if done}
|
||||
<Button variant="border" onclick={() => goto(`/`)}>Go to workspace</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if results.length}
|
||||
<ul class="mt-6 flex flex-col gap-1 text-xs">
|
||||
{#each results as r}
|
||||
<li class="flex items-center gap-2">
|
||||
<span class={r.ok ? 'text-emerald-600' : 'text-red-600'}>{r.ok ? '✓' : '✗'}</span>
|
||||
<span class="font-mono">{r.path}</span>
|
||||
{#if !r.ok}<span class="text-red-600">— {r.error}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Portal>
|
||||
<ConfirmationModal {...missingDatatableModal.props} />
|
||||
</Portal>
|
||||
|
||||
<Drawer bind:this={reviewDrawer} size="700px" on:close={() => closeMigrationReview(false)}>
|
||||
<DrawerContent title="Data table migrations" on:close={() => closeMigrationReview(false)}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-secondary">
|
||||
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
|
||||
<span class="font-mono">{workspace}</span>; if that data table has migrations enabled it is
|
||||
recorded, otherwise it runs once as a preview job.
|
||||
</p>
|
||||
{#each reviewList as m (m.datatable_name)}
|
||||
<div class="flex flex-col gap-1.5 rounded border bg-surface-secondary p-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-primary">{m.datatable_name}</span>
|
||||
<Toggle bind:checked={m.run} size="xs" options={{ right: 'Run' }} />
|
||||
</div>
|
||||
{#if m.run}
|
||||
<MigrationSqlEditor
|
||||
bind:up={m.sql}
|
||||
bind:down={m.sql_down}
|
||||
generation={reviewGeneration}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="border" onclick={() => closeMigrationReview(false)}>Skip migrations</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!reviewList.some((m) => m.run && m.sql.trim() !== '')}
|
||||
onclick={() => closeMigrationReview(true)}
|
||||
>
|
||||
Run selected
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
import { base } from '$app/paths'
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
/**
|
||||
* `/projects/install?hub=<slug>` 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}`)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
|
||||
@@ -109,7 +110,8 @@
|
||||
} else {
|
||||
goto(rd ?? '/')
|
||||
}
|
||||
} else if (rd?.startsWith('/user/workspaces')) {
|
||||
// See (root)/+layout.svelte for why /projects/import skips the picker.
|
||||
} else if (rd?.startsWith('/user/workspaces') || rd?.startsWith(`${base}/projects/import`)) {
|
||||
goto(rd)
|
||||
} else if (rd == '/#user-settings') {
|
||||
goto(`/user/workspaces#user-settings`)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ArrowLeft } from 'lucide-svelte'
|
||||
import { UserService } from '$lib/gen/services.gen'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
import { toSameOriginRelativePath } from '$lib/logoutRedirect'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
@@ -16,7 +18,7 @@
|
||||
Building2,
|
||||
Twitter,
|
||||
Youtube,
|
||||
Bot,
|
||||
Bot,
|
||||
MessageCircleCode
|
||||
} from 'lucide-svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -96,10 +98,23 @@
|
||||
sendUserToast('Failed to save information: ' + (error?.body || error?.message || error), true)
|
||||
} finally {
|
||||
// do not block users from accessing windmill even if there is an error
|
||||
goto('/user/workspaces')
|
||||
goto(onboardingDestination())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to go once onboarding is done. `/user/workspaces` unless the sign-in carried a
|
||||
* destination — a hub project import, say — in which case that is what the user came for.
|
||||
* Same-origin relative paths only, so a crafted `?rd=` cannot bounce them off-site.
|
||||
*/
|
||||
function onboardingDestination(): string {
|
||||
// `toSameOriginRelativePath` rather than a local check: it already rejects `//host`,
|
||||
// `/\\host` (which WHATWG URL parsing resolves to a different origin), control
|
||||
// characters and oversized values. A second, weaker copy of this is how one of those
|
||||
// gets missed.
|
||||
return toSameOriginRelativePath(page.url.searchParams.get('rd')) ?? '/user/workspaces'
|
||||
}
|
||||
|
||||
async function skip() {
|
||||
isSubmitting = true
|
||||
try {
|
||||
@@ -110,7 +125,7 @@
|
||||
console.error('Error skipping onboarding:', error)
|
||||
} finally {
|
||||
// do not block users from accessing windmill even if there is an error
|
||||
goto('/user/workspaces')
|
||||
goto(onboardingDestination())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
import { UserService, WorkspaceService } from '$lib/gen'
|
||||
@@ -146,7 +147,11 @@
|
||||
} else {
|
||||
if (
|
||||
(!page.url.pathname.startsWith('/user/') || page.url.pathname.startsWith('/user/cli')) &&
|
||||
!page.url.pathname.startsWith('/oauth/mcp_authorize')
|
||||
!page.url.pathname.startsWith('/oauth/mcp_authorize') &&
|
||||
// The hub import wizard asks for the destination itself, and may end in a
|
||||
// workspace that does not exist yet — bouncing it to the picker would
|
||||
// force the very choice it exists to make.
|
||||
!page.url.pathname.startsWith(`${base}/projects/import`)
|
||||
) {
|
||||
goto(
|
||||
`/user/workspaces?rd=${encodeURIComponent(page.url.href.replace(page.url.origin, ''))}`
|
||||
|
||||
@@ -64,7 +64,10 @@
|
||||
try {
|
||||
const globalUserInfo = await UserService.globalWhoami()
|
||||
if (globalUserInfo.first_time_user) {
|
||||
goto('/user/onboarding')
|
||||
// `rd` rides along: someone arriving from a shared hub project signs up with a
|
||||
// destination already in hand, and dropping it here strands them in an empty
|
||||
// workspace with no sign of what they came to import.
|
||||
goto(`/user/onboarding${rd ? `?rd=${encodeURIComponent(rd)}` : ''}`)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user