From 4e38a4f1083d880b0814e336d5e27cb40187fc28 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 12 Feb 2026 23:01:58 +0000 Subject: [PATCH] fix: improve on-boarding experience --- .../20260212000000_remove_setup_app.down.sql | 3 + .../20260212000000_remove_setup_app.up.sql | 1 + backend/windmill-api-settings/src/lib.rs | 76 ++++ backend/windmill-api/src/health.rs | 3 - .../src/lib/components/InstanceSetting.svelte | 18 +- .../lib/components/InstanceSettings.svelte | 35 +- .../src/lib/components/instanceSettings.ts | 15 +- .../(user)/instance_settings/+page.svelte | 324 ++++++++++++++++-- .../user/(user)/workspaces/+page.svelte | 21 +- 9 files changed, 418 insertions(+), 78 deletions(-) create mode 100644 backend/migrations/20260212000000_remove_setup_app.down.sql create mode 100644 backend/migrations/20260212000000_remove_setup_app.up.sql diff --git a/backend/migrations/20260212000000_remove_setup_app.down.sql b/backend/migrations/20260212000000_remove_setup_app.down.sql new file mode 100644 index 0000000000..61a77a811a --- /dev/null +++ b/backend/migrations/20260212000000_remove_setup_app.down.sql @@ -0,0 +1,3 @@ +-- The setup_app was a complex app created via multiple migrations. +-- Restoring it would require re-running the original creation and update migrations. +-- This is a no-op down migration since the app is no longer needed. diff --git a/backend/migrations/20260212000000_remove_setup_app.up.sql b/backend/migrations/20260212000000_remove_setup_app.up.sql new file mode 100644 index 0000000000..9f663dcbc0 --- /dev/null +++ b/backend/migrations/20260212000000_remove_setup_app.up.sql @@ -0,0 +1 @@ +DELETE FROM app WHERE workspace_id = 'admins' AND path = 'g/all/setup_app'; diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index de234e33c2..3b7c1e74b2 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -89,6 +89,10 @@ pub fn global_service() -> Router { .route( "/critical_alerts/acknowledge_all", post(acknowledge_all_critical_alerts), + ) + .route( + "/sync_cached_resource_types", + post(sync_cached_resource_types), ); // Vault integration routes (EE only - requires both private and enterprise features) @@ -932,3 +936,75 @@ pub async fn get_jwks() -> JsonResult { Ok(Json(JwksResponse { keys: vec![] })) } } + +#[derive(serde::Deserialize, serde::Serialize)] +struct CachedResourceType { + #[allow(dead_code)] + id: i64, + name: String, + schema: Option, + #[allow(dead_code)] + app: String, + description: Option, +} + +async fn sync_cached_resource_types( + Extension(db): Extension, + authed: ApiAuthed, +) -> error::Result { + require_super_admin(&db, &authed.email).await?; + + use windmill_common::worker::HUB_RT_CACHE_DIR; + let cache_path = format!("{}/resource_types.json", HUB_RT_CACHE_DIR); + + let content = tokio::fs::read_to_string(&cache_path) + .await + .map_err(|e| { + error::Error::NotFound(format!( + "No cached resource types found at {}: {}", + cache_path, e + )) + })?; + + let cached_types: Vec = + serde_json::from_str(&content).map_err(|e| { + error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e)) + })?; + + let mut synced_count = 0; + + for rt in &cached_types { + let exists: Option = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)", + &rt.name, + rt.schema.as_ref(), + rt.description.as_deref(), + ) + .fetch_one(&db) + .await?; + + if exists.unwrap_or(false) { + continue; + } + + sqlx::query!( + "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at) + VALUES ('admins', $1, $2, $3, now()) + ON CONFLICT (workspace_id, name) DO UPDATE + SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()", + &rt.name, + rt.schema.as_ref(), + rt.description.as_deref(), + ) + .execute(&db) + .await?; + + synced_count += 1; + } + + Ok(format!( + "Synced {} resource types ({} unchanged)", + synced_count, + cached_types.len() - synced_count + )) +} diff --git a/backend/windmill-api/src/health.rs b/backend/windmill-api/src/health.rs index d146588ffe..d861f338ef 100644 --- a/backend/windmill-api/src/health.rs +++ b/backend/windmill-api/src/health.rs @@ -379,7 +379,6 @@ fn log_health_status(status: &HealthStatusResponse) { status = "healthy", database_healthy = status.database_healthy, workers_alive = status.workers_alive, - checked_at = %status.checked_at, "health check completed" ); } @@ -388,7 +387,6 @@ fn log_health_status(status: &HealthStatusResponse) { status = "degraded", database_healthy = status.database_healthy, workers_alive = status.workers_alive, - checked_at = %status.checked_at, "health check: degraded status (no workers alive)" ); } @@ -397,7 +395,6 @@ fn log_health_status(status: &HealthStatusResponse) { status = "unhealthy", database_healthy = status.database_healthy, workers_alive = status.workers_alive, - checked_at = %status.checked_at, "health check: unhealthy status" ); } diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 90c6719c51..6b676131da 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -54,7 +54,6 @@ return true } - let licenseKeyChanged = $state(false) let renewing = $state(false) let opening = $state(false) @@ -324,9 +323,6 @@ id={setting.key} small placeholder={setting.placeholder} - onKeyDown={() => { - licenseKeyChanged = true - }} onBlur={() => { if ($values[setting.key] && typeof $values[setting.key] === 'string') { $values[setting.key] = $values[setting.key].trim() @@ -439,13 +435,13 @@ {/if} - {#if licenseKeyChanged && !$enterpriseLicense} - {#if version.startsWith('CE')} -
License key is set but image used is the Community Edition {version}. Switch - image to EE.
- {/if} + {#if $values[setting.key]?.length > 0 && version.includes('CE')} +
+ + + License key is set but the current image is Community Edition ({version}). Switch to the EE image to finalize the upgrade. + +
{/if} {#if valid || expiration} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index b7be3d3759..4b6d82478e 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -101,15 +101,12 @@ } } let nvalues = JSON.parse(JSON.stringify(initialValues)) - if (nvalues['base_url'] == undefined) { + if (!nvalues['base_url']) { nvalues['base_url'] = window.location.origin } if (nvalues['retention_period_secs'] == undefined) { nvalues['retention_period_secs'] = 60 * 60 * 24 * 30 } - if (nvalues['base_url'] == undefined) { - nvalues['base_url'] = 'http://localhost' - } if (nvalues['smtp_settings'] == undefined) { nvalues['smtp_settings'] = {} } @@ -229,7 +226,16 @@ if (category === 'Auth/OAuth/SAML') { return scimSamlSetting } - return settings[category] ?? [] + const base = settings[category] ?? [] + // In quick setup, reorder Core: base settings (without license_key), then job_isolation, license_key, retention_period_secs + if (quickSetup && category === 'Core') { + const licenseKey = base.find((s) => s.key === 'license_key') + const baseWithout = base.filter((s) => s.key !== 'license_key') + const jobIsolation = settings['Jobs']?.find((s) => s.key === 'job_isolation') + const retentionPeriod = settings['Jobs']?.find((s) => s.key === 'retention_period_secs') + return [...baseWithout, ...(jobIsolation ? [jobIsolation] : []), ...(licenseKey ? [licenseKey] : []), ...(retentionPeriod ? [retentionPeriod] : [])] + } + return base } let dirtyCategories: Record = $derived.by(() => { @@ -246,7 +252,7 @@ initialRequirePreexistingUserForOauth !== requirePreexistingUserForOauth result[category] = scimDirty || oauthsDirty || requirePreexistingDirty } else { - const categorySettings = settings[category] ?? [] + const categorySettings = getSettingsForCategory(category) result[category] = categorySettings.some( (s) => !deepEqual(initialValues[s.key], currentValues?.[s.key]) ) @@ -287,7 +293,7 @@ initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier snowflakeAccountIdentifier = account_identifier ?? '' } else { - const categorySettings = settings[category] ?? [] + const categorySettings = getSettingsForCategory(category) for (const s of categorySettings) { $values[s.key] = JSON.parse(JSON.stringify(initialValues[s.key])) } @@ -413,7 +419,7 @@ {#snippet categoryContent(category: string)} {#if category == 'Core'} @@ -565,6 +571,19 @@ /> {/if} {/each} + {#if quickSetup && category === 'Core'} + {#each settings['Jobs'].filter((s) => s.key === 'job_isolation' || s.key === 'retention_period_secs') as setting} + closeDrawer?.()} + {loading} + {setting} + {values} + {version} + {oauths} + /> + {/each} + {/if} {#if !loading && !quickSetup} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index aad66331eb..c4b702e8f1 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -140,19 +140,6 @@ export const settings: Record = { !value?.endsWith('/') && !value?.endsWith(' ')) }, - { - label: 'Email domain', - description: - 'Domain to display in webhooks for email triggers (should match the MX record)', - key: 'email_domain', - fieldType: 'text', - storage: 'setting', - placeholder: 'mail.windmill.com', - error: - 'Email domain must be a valid domain (e.g. mail.windmill.com) without protocol or trailing slash', - isValid: (value: string | undefined) => - !value || /^(?!-)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/.test(value) - }, { label: 'Request size limit in MB', description: 'Maximum size of HTTP requests in MB.', @@ -168,7 +155,7 @@ export const settings: Record = { 'License key required to use the EE (switch image for windmill-ee). Learn more', key: 'license_key', fieldType: 'license_key', - placeholder: 'only needed to prepare upgrade to EE', + placeholder: 'only for EE', storage: 'setting' }, { diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index 3800d00d30..3716e1a4f8 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores' import CenteredModal from '$lib/components/CenteredModal.svelte' import InstanceSettings from '$lib/components/InstanceSettings.svelte' - import { Button } from '$lib/components/common' + import { Alert, Button } from '$lib/components/common' import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import { @@ -14,18 +14,107 @@ } from '$lib/components/instanceSettings' import Breadcrumb from '$lib/components/common/breadcrumb/Breadcrumb.svelte' import { ChevronRight, ArrowLeft } from 'lucide-svelte' + import { superadmin } from '$lib/stores' + import { UserService, ScheduleService, JobService } from '$lib/gen' + import { sendUserToast } from '$lib/toast' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' + import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' - const wizardSteps = [ - { id: 'Core', label: 'General' }, + const settingsSteps = [ + { id: 'Core', label: 'Core' }, { id: 'Auth/OAuth/SAML', label: 'Authentication' } ] as const + const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types'] + const initialMode = $page.url.searchParams.get('mode') === 'full' ? 'full' : 'wizard' + const initialStep = Math.max(0, Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1)) let mode: 'wizard' | 'full' = $state(initialMode) - let wizardStep = $state(0) + let wizardStep = $state(initialStep) + + $effect(() => { + const url = new URL(window.location.href) + if (mode === 'wizard') { + url.searchParams.set('step', String(wizardStep)) + } else { + url.searchParams.delete('step') + } + history.replaceState(history.state, '', url) + }) let instanceSettings: InstanceSettings | undefined = $state() - let currentStepDirty = $derived(instanceSettings?.isDirty(wizardSteps[wizardStep].id) ?? false) + + function isSettingsStep(step: number): boolean { + return step < settingsSteps.length + } + + let currentStepDirty = $derived( + isSettingsStep(wizardStep) + ? (instanceSettings?.isDirty(settingsSteps[wizardStep].id) ?? false) + : false + ) + + // --- Account step state --- + let newEmail = $state('') + let newPassword = $state('') + let enableHubSync = $state(true) + let accountSubmitting = $state(false) + let accountError = $state('') + + // --- Resource type sync (triggered on entering account step) --- + let rtSyncStatus: 'idle' | 'loading' | 'success' | 'error' = $state('idle') + let rtSyncMessage = $state('') + + async function syncCachedResourceTypes() { + rtSyncStatus = 'loading' + rtSyncMessage = '' + try { + const res = await fetch('/api/settings/sync_cached_resource_types', { method: 'POST' }) + if (!res.ok) { + const body = await res.text() + throw new Error(body || res.statusText) + } + rtSyncMessage = await res.text() + rtSyncStatus = 'success' + } catch (e: any) { + rtSyncMessage = e?.message ?? 'Failed to sync resource types' + rtSyncStatus = 'error' + } + } + + $effect(() => { + if (!isSettingsStep(wizardStep) && rtSyncStatus === 'idle') { + syncCachedResourceTypes() + } + }) + + // --- Live hub sync --- + let hubSyncStatus: 'idle' | 'loading' | 'success' | 'error' = $state('idle') + let hubSyncMessage = $state('') + + async function syncFromHub() { + hubSyncStatus = 'loading' + hubSyncMessage = '' + try { + await JobService.runWaitResultScriptByPath({ + workspace: 'admins', + path: 'u/admin/hub_sync', + requestBody: {} + }) + hubSyncStatus = 'success' + hubSyncMessage = 'Resource types synced from hub successfully' + } catch (e: any) { + hubSyncMessage = e?.body?.error?.message || e?.body?.message || (typeof e?.body === 'string' ? e.body : null) || e?.message || 'Failed to sync from hub' + hubSyncStatus = 'error' + } + } + + const emailPattern = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/ + let emailValid = $derived(emailPattern.test(newEmail)) + let passwordValid = $derived(newPassword.length >= 2) + let accountFormValid = $derived(emailValid && passwordValid) // --- Full settings mode state --- let fullTab = $state('general') @@ -49,9 +138,11 @@ /** Auto-save the current wizard step if dirty, then run the callback */ async function saveAndProceed(callback: () => void) { - const category = wizardSteps[wizardStep].id - if (instanceSettings?.isDirty(category)) { - await instanceSettings.saveCategorySettings(category) + if (isSettingsStep(wizardStep)) { + const category = settingsSteps[wizardStep].id + if (instanceSettings?.isDirty(category)) { + await instanceSettings.saveCategorySettings(category) + } } callback() } @@ -65,7 +156,68 @@ } function finishSetup() { - goto('/apps/get/g/all/setup_app?nomenubar=true&workspace=admins') + goto('/user/workspaces') + } + + async function submitAccount() { + accountError = '' + accountSubmitting = true + try { + const oldEmail = $superadmin + if (!oldEmail) { + throw new Error('Could not determine current admin email') + } + + await UserService.createUserGlobally({ + requestBody: { + email: newEmail, + password: newPassword, + super_admin: true + } + }) + + const token = await UserService.login({ + requestBody: { email: newEmail, password: newPassword } + }) + + // Update the client token for subsequent requests + const { OpenAPI } = await import('$lib/gen') + OpenAPI.TOKEN = token + + if (enableHubSync) { + try { + await ScheduleService.createSchedule({ + workspace: 'admins', + requestBody: { + path: 'g/all/hub_sync', + schedule: '0 0 0 * * *', + script_path: 'u/admin/hub_sync', + is_flow: false, + args: {}, + enabled: true, + timezone: 'Etc/UTC' + } + }) + } catch (e: any) { + console.warn('Schedule creation failed:', e?.body ?? e) + } + } + + try { + await UserService.globalUserDelete({ email: oldEmail }) + } catch (e: any) { + console.warn('Deleting old account failed:', e?.body ?? e) + } + + sendUserToast('Account setup complete') + goto( + '/user/logout?rd=' + encodeURIComponent('/user/login?email=' + encodeURIComponent(newEmail)) + ) + } catch (e: any) { + accountError = e?.body?.message || e?.body || e?.message || 'An error occurred' + } finally { + accountSubmitting = false + } } @@ -75,7 +227,7 @@
s.label)} + items={wizardStepLabels} selectedIndex={wizardStep + 1} numbered onselect={(i) => { @@ -90,20 +242,118 @@
- {#if wizardSteps[wizardStep].id === 'Auth/OAuth/SAML'} -

- Windmill uses its own authentication by default. SSO configuration is optional and can - be set up later. -

- {/if} - {#key wizardStep} - + Windmill uses its own authentication by default. SSO configuration is optional and can + be set up later. +

+ {/if} + {#key wizardStep} + + {/key} + {:else} + + - {/key} + +
+ +
+
+ Email + 0 && !emailValid ? 'Must be a valid email' : undefined} + size="md" + /> + {#if $superadmin} +

Current email: {$superadmin}

+ {/if} +
+
+ Password + 0 && !passwordValid + ? 'Must be at least 2 characters' + : undefined} + size="md" + /> +
+
+
+ + +
+ {#if rtSyncStatus === 'loading'} + + {:else if rtSyncStatus === 'success'} + + {rtSyncMessage} + + {:else if rtSyncStatus === 'error'} + + {rtSyncMessage} + + {/if} + +
+ +

+ Fetches the latest resource types directly from the Windmill Hub (requires + internet access). +

+
+ {#if hubSyncStatus === 'success'} + + {hubSyncMessage} + + {:else if hubSyncStatus === 'error'} + + {hubSyncMessage} + + {/if} + +

+ The daily schedule synchronizes resource types from the Hub every day at midnight + UTC. +

+
+
+ + {#if accountError} + + {accountError} + + {/if} +
+ {/if}
{:else} @@ -150,14 +400,16 @@
- - {#if wizardStep < wizardSteps.length - 1} + {#if isSettingsStep(wizardStep)} + + {/if} + {#if wizardStep < wizardStepLabels.length - 1} {/if}
diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index d5f03d699b..6658893a57 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -23,6 +23,7 @@ import { switchWorkspace } from '$lib/storeUtils' import { GitFork, Settings, User, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte' import { isCloudHosted } from '$lib/cloud' + import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte' import { emptyString } from '$lib/utils' import { getUserExt } from '$lib/user' import { refreshSuperadmin } from '$lib/refreshUser' @@ -93,6 +94,7 @@ $: allWorkspaces = workspaces || [] $: noWorkspaces = $superadmin && allWorkspaces.length == 0 + $: onlyAdminsWorkspace = allWorkspaces.length === 1 && allWorkspaces[0].id === 'admins' async function getCreateWorkspaceRequireSuperadmin() { const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin') @@ -254,15 +256,16 @@ {/if} {#if createWorkspace} -
- +
+ + +
{/if}