fix: improve on-boarding experience

This commit is contained in:
Ruben Fiszel
2026-02-12 23:01:58 +00:00
parent 9af1f9dd67
commit 4e38a4f108
9 changed files with 418 additions and 78 deletions
@@ -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.
@@ -0,0 +1 @@
DELETE FROM app WHERE workspace_id = 'admins' AND path = 'g/all/setup_app';
+76
View File
@@ -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<JwksResponse> {
Ok(Json(JwksResponse { keys: vec![] }))
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct CachedResourceType {
#[allow(dead_code)]
id: i64,
name: String,
schema: Option<serde_json::Value>,
#[allow(dead_code)]
app: String,
description: Option<String>,
}
async fn sync_cached_resource_types(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
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<CachedResourceType> =
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<bool> = 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
))
}
-3
View File
@@ -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"
);
}
@@ -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 @@
</Popover>
</div>
{/if}
{#if licenseKeyChanged && !$enterpriseLicense}
{#if version.startsWith('CE')}
<div class="text-red-600 dark:text-red-400"
>License key is set but image used is the Community Edition {version}. Switch
image to EE.</div
>
{/if}
{#if $values[setting.key]?.length > 0 && version.includes('CE')}
<div class="flex flex-row gap-1 items-center">
<Info size={12} class="text-blue-600" />
<span class="text-blue-600 dark:text-blue-400 text-xs">
License key is set but the current image is Community Edition ({version}). Switch to the EE image to finalize the upgrade.
</span>
</div>
{/if}
{#if valid || expiration}
@@ -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<string, boolean> = $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'}
<SettingsPageHeader
title="General"
title="Core"
description="Configure the core settings of your Windmill instance."
link="https://www.windmill.dev/docs/advanced/instance_settings"
/>
@@ -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}
<InstanceSetting
{openSmtpSettings}
on:closeDrawer={() => closeDrawer?.()}
{loading}
{setting}
{values}
{version}
{oauths}
/>
{/each}
{/if}
</div>
{#if !loading && !quickSetup}
@@ -140,19 +140,6 @@ export const settings: Record<string, Setting[]> = {
!value?.endsWith('/') &&
!value?.endsWith(' '))
},
{
label: 'Email domain',
description:
'Domain to display in webhooks for <a href="https://www.windmill.dev/docs/advanced/email_triggers">email triggers</a> (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<string, Setting[]> = {
'License key required to use the EE (switch image for windmill-ee). <a href="https://www.windmill.dev/docs/advanced/instance_settings#license-key">Learn more</a>',
key: 'license_key',
fieldType: 'license_key',
placeholder: 'only needed to prepare upgrade to EE',
placeholder: 'only for EE',
storage: 'setting'
},
{
@@ -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
}
}
</script>
@@ -75,7 +227,7 @@
<!-- Step indicator (pinned top) -->
<div class="pb-2 border-b shrink-0 flex justify-start">
<Breadcrumb
items={wizardSteps.map((s) => s.label)}
items={wizardStepLabels}
selectedIndex={wizardStep + 1}
numbered
onselect={(i) => {
@@ -90,20 +242,118 @@
<!-- Step content (scrollable) -->
<div class="flex-1 overflow-auto min-h-0 pt-4">
{#if wizardSteps[wizardStep].id === 'Auth/OAuth/SAML'}
<p class="text-secondary text-xs mb-4">
Windmill uses its own authentication by default. SSO configuration is optional and can
be set up later.
</p>
{/if}
{#key wizardStep}
<InstanceSettings
bind:this={instanceSettings}
hideTabs
quickSetup
tab={wizardSteps[wizardStep].id}
{#if isSettingsStep(wizardStep)}
{#if settingsSteps[wizardStep].id === 'Auth/OAuth/SAML'}
<p class="text-secondary text-xs mb-4">
Windmill uses its own authentication by default. SSO configuration is optional and can
be set up later.
</p>
{/if}
{#key wizardStep}
<InstanceSettings
bind:this={instanceSettings}
hideTabs
quickSetup
tab={settingsSteps[wizardStep].id}
/>
{/key}
{:else}
<!-- Account setup step -->
<SettingsPageHeader
title="Root login & Resource Types"
/>
{/key}
<div class="flex flex-col gap-6 pb-6">
<SettingCard
label="Superadmin login"
description="Replace the default superadmin account with a secure email and password."
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">Email</span>
<TextInput
bind:value={newEmail}
inputProps={{ type: 'email', placeholder: 'admin@company.com' }}
error={newEmail.length > 0 && !emailValid ? 'Must be a valid email' : undefined}
size="md"
/>
{#if $superadmin}
<p class="text-tertiary text-2xs mt-1">Current email: {$superadmin}</p>
{/if}
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">Password</span>
<TextInput
bind:value={newPassword}
inputProps={{ type: 'password', placeholder: 'Enter password' }}
error={newPassword.length > 0 && !passwordValid
? 'Must be at least 2 characters'
: undefined}
size="md"
/>
</div>
</div>
</SettingCard>
<SettingCard
label="Resource Types"
description="Resource types bundled with the Docker image are synced automatically. You can also fetch the latest from the hub."
>
<div class="flex flex-col gap-3 mt-1">
{#if rtSyncStatus === 'loading'}
<Alert type="info" title="Syncing cached resource types..." />
{:else if rtSyncStatus === 'success'}
<Alert type="success" title="Cached resource types synced">
{rtSyncMessage}
</Alert>
{:else if rtSyncStatus === 'error'}
<Alert type="error" title="Cached resource types sync failed">
{rtSyncMessage}
</Alert>
{/if}
<div class="flex items-center gap-2">
<Button
variant="accent"
unifiedSize="sm"
loading={hubSyncStatus === 'loading'}
onClick={syncFromHub}
>
Sync latest from hub
</Button>
<p class="text-tertiary text-2xs">
Fetches the latest resource types directly from the Windmill Hub (requires
internet access).
</p>
</div>
{#if hubSyncStatus === 'success'}
<Alert type="success" title="Hub sync complete">
{hubSyncMessage}
</Alert>
{:else if hubSyncStatus === 'error'}
<Alert type="error" title="Hub sync failed">
{hubSyncMessage}
</Alert>
{/if}
<Toggle
bind:checked={enableHubSync}
options={{ right: 'Sync resource types every day' }}
size="xs"
/>
<p class="text-tertiary text-2xs">
The daily schedule synchronizes resource types from the Hub every day at midnight
UTC.
</p>
</div>
</SettingCard>
{#if accountError}
<Alert type="error" title="Setup error">
{accountError}
</Alert>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Sidebar + Content -->
@@ -150,14 +400,16 @@
</div>
<div class="flex items-center gap-2">
<Button
variant="default"
unifiedSize="md"
onClick={() => saveAndProceed(switchToFullMode)}
>
Advanced setup
</Button>
{#if wizardStep < wizardSteps.length - 1}
{#if isSettingsStep(wizardStep)}
<Button
variant="default"
unifiedSize="md"
onClick={() => saveAndProceed(switchToFullMode)}
>
Advanced setup
</Button>
{/if}
{#if wizardStep < wizardStepLabels.length - 1}
<Button
variant="accent"
unifiedSize="md"
@@ -166,8 +418,14 @@
{currentStepDirty ? 'Save & Next' : 'Next'}
</Button>
{:else}
<Button variant="accent" unifiedSize="md" onClick={() => saveAndProceed(finishSetup)}>
{currentStepDirty ? 'Save & Continue' : 'Continue'}
<Button
variant="accent"
unifiedSize="md"
disabled={!accountFormValid}
loading={accountSubmitting}
onClick={submitAccount}
>
Set account & finish
</Button>
{/if}
</div>
@@ -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}
<div class="flex flex-row-reverse pt-4">
<Button
unifiedSize="sm"
btnClasses={noWorkspaces ? 'animate-bounce hover:animate-none' : ''}
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
variant={noWorkspaces ? 'accent' : 'default'}
wrapperClasses="w-full"
>+&nbsp;Create a new workspace
</Button>
<div class="flex flex-row-reverse pt-4 w-full">
<AnimatedButton animate={onlyAdminsWorkspace} baseRadius="6px" animationDuration="2s" marginWidth="2px" wrapperClasses="w-full">
<Button
unifiedSize="sm"
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
variant={onlyAdminsWorkspace || noWorkspaces ? 'accent' : 'default'}
wrapperClasses="w-full"
>+&nbsp;Create a new workspace
</Button>
</AnimatedButton>
</div>
{/if}