mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
fix: improve on-boarding experience
This commit is contained in:
@@ -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';
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>+ 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"
|
||||
>+ Create a new workspace
|
||||
</Button>
|
||||
</AnimatedButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user