fix(frontend): redesign instance settings (#7916)

* handle dirty config

* Move instance update in drawer actions

* Put windmill version in drawer header

* Use sidebar instead of tabs

* Rework user section

* improve user table

* Handle EE

* Add settings section header

* test-1

* option 2

* create new settings group

* harmonize all settings inputs

* improve members/users table styling

* refactor instance setup

* show user count

* nit

* Create setting card component

* harmonize instance settings and workspace settings

* fix python version loader

* nit

* clean code

* nit

* add email validation

* fix reactivity issue on default value

* fix dirty config check

* fix object storage dirty config check

* Fix object storage settings reactivity

* fix indexer dirty reactivity

* Add validation for indexer

* fix sso dirty issues

* clean

* nit
This commit is contained in:
Guilhem
2026-02-12 09:50:01 +00:00
committed by GitHub
parent 647316dbf2
commit dd419ade94
52 changed files with 2411 additions and 1357 deletions
@@ -7,6 +7,7 @@
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import TextInput from './text_input/TextInput.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
value: any
@@ -58,7 +59,7 @@
/></label
>
{#if enabled}
<div class="p-4 rounded-md border flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label>
<div class="flex gap-2 items-start">
<div>
@@ -88,6 +89,7 @@
<TextInput
inputProps={{ type: 'text', placeholder: 'Custom Name' }}
bind:value={value['display_name']}
class="max-w-lg"
/>
</label>
<label class="flex flex-col gap-1">
@@ -98,6 +100,7 @@
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Id' }}
bind:value={value['id']}
class="max-w-lg"
/>
</label>
<label class="flex flex-col gap-1">
@@ -107,6 +110,7 @@
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
class="max-w-lg"
/>
</label>
<CollapseLink text="Instructions">
@@ -158,6 +162,6 @@
</div>
</div>
</CollapseLink>
</div>
</SettingCard>
{/if}
</div>
+35 -40
View File
@@ -23,6 +23,7 @@
import Tooltip from './Tooltip.svelte'
import { tick } from 'svelte'
import { Popover } from './meltComponents'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
interface Props {
snowflakeAccountIdentifier?: string
@@ -30,6 +31,8 @@
requirePreexistingUserForOauth?: boolean
baseUrl?: string
scim?: import('svelte').Snippet
tab?: 'sso' | 'oauth' | 'scim'
hideTabs?: boolean
}
let {
@@ -37,7 +40,9 @@
oauths = $bindable(),
requirePreexistingUserForOauth = $bindable(),
baseUrl,
scim
scim,
tab = $bindable('sso'),
hideTabs = false
}: Props = $props()
$effect(() => {
@@ -87,8 +92,6 @@
let ssoClientName = $state('')
let ssoNameInput = $state<HTMLInputElement>()
let tab: 'sso' | 'oauth' | 'scim' = $state('sso')
function createOAuthClient(name: string) {
if (oauths && name) {
// Create a new object to ensure the new item is added at the end
@@ -212,34 +215,30 @@
}
</script>
<div>
<Tabs bind:selected={tab} class="mb-4">
<Tab value="sso" label="SSO" />
<Tab value="oauth" label="OAuth" />
<Tab value="scim" label="SCIM/SAML" />
</Tabs>
</div>
{#if !hideTabs}
<div>
<Tabs bind:selected={tab} class="mb-4">
<Tab value="sso" label="SSO" />
<Tab value="oauth" label="OAuth" />
<Tab value="scim" label="SCIM/SAML" />
</Tabs>
</div>
{/if}
<div class="mb-6">
{#if oauths}
{#if tab === 'sso'}
<SettingsPageHeader
title="Single Sign-On"
description="Configure SSO providers to let users authenticate using their existing identity provider credentials. To test SSO, save the settings and try to login in an incognito window."
link="https://www.windmill.dev/docs/misc/setup_oauth#sso"
/>
{#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
<Alert type="warning" title="Limited to 10 SSO users">
<Alert type="info" title="Limited to 10 SSO users">
Without EE, the number of SSO users is limited to 10. SCIM/SAML is available on EE
</Alert>
<div class="mb-2"></div>
{/if}
<div class="mb-2">
<div class="text-primary text-xs"
>When at least one of the below options is set, users will be able to login to Windmill
via their third-party account.
<br /> To test SSO, the recommended workflow is to to save the settings and try to login
in an incognito window.
<a target="_blank" href="https://www.windmill.dev/docs/misc/setup_oauth#sso">Learn more</a
>
</div>
</div>
<div class="flex gap-2 py-4">
<Toggle
options={{
@@ -284,8 +283,8 @@
}}
/>
</div>
<div class="p-4 border rounded">
<label class="block pb-2">
<div class="p-4 rounded bg-surface-tertiary shadow-sm">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Custom Name</span>
<input
type="text"
@@ -293,11 +292,11 @@
bind:value={oauths[k]['display_name']}
/>
</label>
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={oauths[k]['id']} />
</label>
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Client Secret</span>
<input
type="text"
@@ -354,20 +353,11 @@
</Popover>
</div>
{:else if tab === 'oauth'}
<div class="mb-2">
<div class="text-primary text-xs"
>Connect third-party services like Slack, Teams or Google to let users authenticate
directly from Windmill and automatically obtain access tokens. Once configured, users can
create resources of the corresponding type (e.g. a 'github' resource) and authenticate via
OAuth without manually handling credentials.
<a
target="_blank"
href="https://www.windmill.dev/docs/misc/setup_oauth#oauth"
class="inline-flex items-center whitespace-nowrap"
>Learn more&nbsp;<ExternalLink size={12} /></a
></div
>
</div>
<SettingsPageHeader
title="OAuth"
description="Connect third-party services like Slack, Teams or Google to let users authenticate directly from Windmill and automatically obtain access tokens."
link="https://www.windmill.dev/docs/misc/setup_oauth#oauth"
/>
<div class="h-1"></div>
<OAuthSetting login={false} name="slack" bind:value={oauths['slack']} />
<div class="h-6"></div>
@@ -544,6 +534,11 @@
</div>
{/if}
{:else if tab == 'scim'}
<SettingsPageHeader
title="SCIM/SAML"
description="Set up SAML and SCIM to authenticate users using your identity provider."
link="https://www.windmill.dev/docs/misc/saml_and_scim"
/>
{@render scim?.()}
{/if}
{/if}
@@ -2,6 +2,7 @@
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
export let value: any
@@ -46,7 +47,7 @@
/></label
>
{#if enabled}
<div class="border rounded p-4 flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Authelia Url</span>
<span class="text-secondary font-normal text-xs"
@@ -75,6 +76,6 @@
bind:value={value['secret']}
/>
</label>
</div>
</SettingCard>
{/if}
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import IconedResourceType from './IconedResourceType.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
export let value: any
@@ -46,7 +47,7 @@
/></label
>
{#if enabled}
<div class="border rounded p-4 flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label>
<span class="text-emphasis font-semibold text-xs">Authentik Url</span>
<span class="text-secondary font-normal text-xs"
@@ -66,6 +67,6 @@
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
</label>
</div>
</SettingCard>
{/if}
</div>
@@ -12,6 +12,7 @@
large?: boolean
centerVertically?: boolean
loading?: boolean
containOverflow?: boolean
children?: import('svelte').Snippet
}
@@ -22,6 +23,7 @@
large = false,
centerVertically = true,
loading = false,
containOverflow = false,
children
}: Props = $props()
@@ -31,12 +33,20 @@
</script>
<div
class="flex justify-center h-screen p-4 relative bg-surface-secondary overflow-auto"
class="flex justify-center h-screen p-4 relative bg-surface-secondary {containOverflow
? 'overflow-hidden'
: 'overflow-auto'}"
class:items-center={centerVertically}
style="scrollbar-gutter: stable both-edges;"
bind:clientHeight={height}
>
<div class={twMerge("flex flex-col gap-2 items-center w-full pb-8 h-fit", height > 1080 ? 'pt-28' : 'pt-12')} >
<div
class={twMerge(
'flex flex-col gap-2 items-center w-full pb-8',
containOverflow ? 'min-h-0' : 'h-fit',
containOverflow ? '' : height > 1080 ? 'pt-28' : 'pt-12'
)}
>
{#if (!disableLogo && !$enterpriseLicense) || !$whitelabelNameStore}
<div class="hidden lg:block">
<div>
@@ -62,7 +72,9 @@
<div
class="rounded-md bg-surface w-full {large
? 'max-w-5xl'
: 'max-w-[640px]'} p-4 sm:py-8 sm:px-10 z-10"
: 'max-w-[640px]'} p-4 sm:py-8 sm:px-10 z-10 {containOverflow
? 'flex-1 min-h-0 flex flex-col'
: ''}"
>
{@render children()}
</div>
@@ -9,6 +9,7 @@
import { sendUserToast } from '$lib/utils'
import TeamSelector from './TeamSelector.svelte'
import CollapseLink from './CollapseLink.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface TeamItem {
team_id: string
@@ -88,85 +89,80 @@
const capitalizedPlatform = $derived(platform.charAt(0).toUpperCase() + platform.slice(1))
</script>
<div class="flex flex-col gap-1">
<div class="text-xs font-semibold text-emphasis">{capitalizedPlatform} connection</div>
<div class="rounded-md border p-4 flex flex-col gap-6">
{#if workspaceConfig}
{@render workspaceConfig()}
{/if}
{#if teamName || workspaceSpecificConnection}
<div class="flex flex-col gap-2 max-w-sm">
<div class="flex flex-row gap-2 items-center">
{#if display_name}
<Badge color="green">
<Plug size={14} />
Workspace connected to {capitalizedPlatform} team '{display_name}'</Badge
>
<SettingCard label={capitalizedPlatform + ' connection'}>
{#if workspaceConfig}
{@render workspaceConfig()}
{/if}
{#if teamName || workspaceSpecificConnection}
<div class="flex flex-col gap-2 max-w-sm">
<div class="flex flex-row gap-2 items-center">
{#if display_name}
<Badge color="green">
<Plug size={14} />
Workspace connected to {capitalizedPlatform} team '{display_name}'</Badge
>
{/if}
<Button
unifiedSize="md"
startIcon={{ icon: Unplug }}
disabled={!$enterpriseLicense && platform === 'teams'}
onclick={onDisconnect}
destructive
variant="subtle"
>
Disconnect {capitalizedPlatform}
{!$enterpriseLicense && platform === 'teams' ? '(EE only)' : ''}
</Button>
</div>
</div>
{:else if !hideConnectButton}
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 items-center">
{#if platform === 'teams'}
{#if $enterpriseLicense && isOAuthEnabled}
<TeamSelector
bind:selectedTeam
minWidth="180px"
disabled={!$enterpriseLicense}
onError={(e) => {
const errorMsg =
typeof (e as any)?.body === 'string'
? (e as any).body
: e?.message || 'Unknown error'
sendUserToast('Failed to load teams: ' + errorMsg, true)
}}
/>
{/if}
<Button
unifiedSize="md"
startIcon={{ icon: Unplug }}
disabled={!$enterpriseLicense && platform === 'teams'}
onclick={onDisconnect}
destructive
variant="subtle"
variant="accent"
onclick={connectTeams}
endIcon={{ icon: MsTeamsIcon }}
disabled={!selectedTeam || !$enterpriseLicense}
>
Disconnect {capitalizedPlatform}
{!$enterpriseLicense && platform === 'teams' ? '(EE only)' : ''}
Connect to {platform.charAt(0).toUpperCase() + platform.slice(1)}
{$enterpriseLicense ? '' : '(EE only)'}
</Button>
</div>
{:else}
<Button
size="xs"
variant="accent"
href={connectHref}
startIcon={{ icon: Slack }}
disabled={!isOAuthEnabled}
>
Connect to {platform.charAt(0).toUpperCase() + platform.slice(1)}
</Button>
{/if}
</div>
{:else if !hideConnectButton}
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 items-center">
{#if platform === 'teams'}
{#if $enterpriseLicense && isOAuthEnabled}
<TeamSelector
bind:selectedTeam
minWidth="180px"
disabled={!$enterpriseLicense}
onError={(e) => {
const errorMsg =
typeof (e as any)?.body === 'string'
? (e as any).body
: e?.message || 'Unknown error'
sendUserToast('Failed to load teams: ' + errorMsg, true)
}}
/>
{/if}
<Button
unifiedSize="md"
variant="accent"
onclick={connectTeams}
endIcon={{ icon: MsTeamsIcon }}
disabled={!selectedTeam || !$enterpriseLicense}
>
Connect to {platform.charAt(0).toUpperCase() + platform.slice(1)}
{$enterpriseLicense ? '' : '(EE only)'}
</Button>
{:else}
<Button
size="xs"
variant="accent"
href={connectHref}
startIcon={{ icon: Slack }}
disabled={!isOAuthEnabled}
>
Connect to {platform.charAt(0).toUpperCase() + platform.slice(1)}
</Button>
{/if}
</div>
</div>
{/if}
</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-primary text-xs font-semibold"> Script or flow to run on /windmill command </div>
<span class="text-xs text-secondary mb-2"
>Pick a script or flow meant to be triggered when the `/windmill` command is invoked.</span
>
</div>
{/if}
</SettingCard>
<SettingCard
label="Script or flow to run on /windmill command"
description="Pick a script or flow meant to be triggered when the `/windmill` command is invoked."
>
<div class="flex flex-row gap-2">
<ScriptPicker
kinds={['script']}
@@ -228,4 +224,4 @@
<a href={documentationLink}>documentation</a>.
</div>
</CollapseLink>
</div>
</SettingCard>
+7 -7
View File
@@ -27,7 +27,7 @@
}
</script>
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Auth URL</span>
<input
type="text"
@@ -35,7 +35,7 @@
bind:value={login_config.auth_url}
/>
</label>
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Token URL</span>
<input
type="text"
@@ -43,7 +43,7 @@
bind:value={login_config.token_url}
/>
</label>
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Userinfo URL</span>
<input
type="text"
@@ -52,12 +52,12 @@
/>
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Scopes</span>
<OauthScopes bind:scopes={login_config.scopes} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs"
>Extra Query Args for Authorize Request&nbsp;<Tooltip
>Not needed in most cases. Examples of uses: google apis require the 2 extra args
@@ -67,14 +67,14 @@
<OauthExtraParams bind:extra_params={login_config.extra_params} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs"
>Extra Query Args for Token request <Tooltip>Not needed in most cases</Tooltip></span
>
<OauthExtraParams bind:extra_params={login_config.extra_params_callback} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block pb-2">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs"
>Payload <Tooltip
>Auth is passed in query most commonly. LinkedIn is an example of OAuth using
+113 -107
View File
@@ -11,6 +11,8 @@
import { validateDeployPathFilters } from '$lib/validators/workspaceSettings'
import Alert from './common/alert/Alert.svelte'
import SettingsFooter from './workspaceSettings/SettingsFooter.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
import Select from './select/Select.svelte'
let deployableWorkspaces = $derived(
$usersWorkspaceStore?.workspaces.map((w) => w.id).filter((w) => w != $workspaceStore)
@@ -151,122 +153,126 @@
}
</script>
<h3 class="mt-6 text-xs font-semibold text-emphasis">Workspace to link to</h3>
<div class="flex min-w-0 mt-1">
<select bind:value={workspaceToDeployTo}>
{#if deployableWorkspaces?.length == 0}
<option disabled>No workspace deployable to</option>
{/if}
<option value="">Disable deployment</option>
{#each deployableWorkspaces ?? [] as name}
<option value={name}>{name}</option>
{/each}
</select>
</div>
<h3 class="mt-6 mb-1 text-xs font-semibold text-emphasis">Deployable items</h3>
<div class="text-xs text-secondary mb-1">
You can filter which items can be deployed to the production workspace. By default everything is
deployable.
</div>
<div class="flex flex-wrap gap-6 p-4 rounded-md border">
<div class="max-w-md w-full">
{#if Array.isArray(deployUiSettings?.include_path)}
<SettingCard label="Workspace to link to" class="mt-6">
<Select
items={[
{ label: 'Disable deployment', value: '' },
...(deployableWorkspaces ?? []).map((w) => ({ label: w, value: w }))
]}
bind:value={workspaceToDeployTo}
placeholder={deployableWorkspaces?.length === 0
? 'No workspace deployable to'
: 'Select workspace'}
/>
</SettingCard>
<SettingCard
label="Deployable items"
description="You can filter which items can be deployed to the production workspace. By default everything is deployable."
class="mt-6"
>
<div class="flex flex-wrap gap-6 mt-2">
<div class="max-w-md w-full">
{#if Array.isArray(deployUiSettings?.include_path)}
<h4 class="flex gap-2 mb-2 text-xs font-semibold text-emphasis"
>Filter on path<Tooltip>
Only scripts, flows and apps with their path matching one of those filters will be
allowed to be deployed in the deploy UI. The filters allow '*'' and '**' characters,
with '*'' matching any character allowed in paths until the next slash (/) and '**'
matching anything including slashes.
</Tooltip></h4
>
{#each deployUiSettings.include_path ?? [] as _, idx}
<div class="flex flex-col mt-1">
<div class="flex items-center">
<input
type="text"
bind:value={deployUiSettings.include_path[idx]}
id="arg-input-array-{idx}"
class="flex-1 {pathValidationErrors[idx] ? 'border-red-500' : ''}"
placeholder="e.g., f/*, u/admin/**"
/>
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
aria-label="Clear"
onclick={() => {
deployUiSettings.include_path.splice(idx, 1)
deployUiSettings.include_path = [...deployUiSettings.include_path]
// Clear validation error for this index
delete pathValidationErrors[idx]
pathValidationErrors = { ...pathValidationErrors }
}}
>
<X size={14} />
</button>
</div>
{#if pathValidationErrors[idx]}
<div class="text-xs text-red-600 dark:text-red-400 mt-1"
>{pathValidationErrors[idx]}</div
>
{/if}
</div>
{/each}
{/if}
<div class="flex mt-2">
<Button
variant="default"
size="xs"
btnClasses="mt-1"
on:click={() => {
deployUiSettings.include_path = [...deployUiSettings.include_path, '']
}}
id="deploy-ui-add-path-filter"
startIcon={{ icon: Plus }}
>
Add filter
</Button>
</div>
</div>
<div class="max-w-md w-full">
<h4 class="flex gap-2 mb-2 text-xs font-semibold text-emphasis"
>Filter on path<Tooltip>
Only scripts, flows and apps with their path matching one of those filters will be allowed
to be deployed in the deploy UI. The filters allow '*'' and '**' characters, with '*''
matching any character allowed in paths until the next slash (/) and '**' matching
anything including slashes.
>Filter on type<Tooltip>
You can filter which types of item can be deployed to the production workspace. By default
everything is deployable.
</Tooltip></h4
>
{#each deployUiSettings.include_path ?? [] as _, idx}
<div class="flex flex-col mt-1">
<div class="flex items-center">
<input
type="text"
bind:value={deployUiSettings.include_path[idx]}
id="arg-input-array-{idx}"
class="flex-1 {pathValidationErrors[idx] ? 'border-red-500' : ''}"
placeholder="e.g., f/*, u/admin/**"
/>
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
aria-label="Clear"
onclick={() => {
deployUiSettings.include_path.splice(idx, 1)
deployUiSettings.include_path = [...deployUiSettings.include_path]
// Clear validation error for this index
delete pathValidationErrors[idx]
pathValidationErrors = { ...pathValidationErrors }
}}
>
<X size={14} />
</button>
</div>
{#if pathValidationErrors[idx]}
<div class="text-xs text-red-600 dark:text-red-400 mt-1"
>{pathValidationErrors[idx]}</div
>
{/if}
</div>
{/each}
{/if}
<div class="flex mt-2">
<Button
variant="default"
size="xs"
btnClasses="mt-1"
on:click={() => {
deployUiSettings.include_path = [...deployUiSettings.include_path, '']
}}
id="deploy-ui-add-path-filter"
startIcon={{ icon: Plus }}
>
Add filter
</Button>
</div>
</div>
<div class="max-w-md w-full">
<h4 class="flex gap-2 mb-2 text-xs font-semibold text-emphasis"
>Filter on type<Tooltip>
You can filter which types of item can be deployed to the production workspace. By default
everything is deployable.
</Tooltip></h4
>
<div class="flex flex-col gap-2 mt-1">
<Toggle bind:checked={deployUiSettings.include_type.scripts} options={{ right: 'Scripts' }} />
<Toggle bind:checked={deployUiSettings.include_type.flows} options={{ right: 'Flows' }} />
<Toggle bind:checked={deployUiSettings.include_type.apps} options={{ right: 'Apps' }} />
<Toggle
bind:checked={deployUiSettings.include_type.resources}
options={{ right: 'Resources' }}
/>
<div class="flex gap-3">
<div class="flex flex-col gap-2 mt-1">
<Toggle
bind:checked={deployUiSettings.include_type.variables}
on:change={(ev) => {
if (!ev.detail) {
deployUiSettings.include_type.secrets = false
}
}}
options={{ right: 'Variables ' }}
bind:checked={deployUiSettings.include_type.scripts}
options={{ right: 'Scripts' }}
/>
<span>-</span>
<Toggle bind:checked={deployUiSettings.include_type.flows} options={{ right: 'Flows' }} />
<Toggle bind:checked={deployUiSettings.include_type.apps} options={{ right: 'Apps' }} />
<Toggle
disabled={!deployUiSettings.include_type.variables}
bind:checked={deployUiSettings.include_type.secrets}
options={{ left: 'Include secrets' }}
bind:checked={deployUiSettings.include_type.resources}
options={{ right: 'Resources' }}
/>
<div class="flex gap-3">
<Toggle
bind:checked={deployUiSettings.include_type.variables}
on:change={(ev) => {
if (!ev.detail) {
deployUiSettings.include_type.secrets = false
}
}}
options={{ right: 'Variables ' }}
/>
<span>-</span>
<Toggle
disabled={!deployUiSettings.include_type.variables}
bind:checked={deployUiSettings.include_type.secrets}
options={{ left: 'Include secrets' }}
/>
</div>
<Toggle
bind:checked={deployUiSettings.include_type.triggers}
options={{ right: 'Trigger' }}
/>
</div>
<Toggle
bind:checked={deployUiSettings.include_type.triggers}
options={{ right: 'Trigger' }}
/>
</div>
</div>
</div>
</SettingCard>
{#if hasValidationErrors}
<Alert type="error" title="Validation Errors" class="mt-4">
Please fix the validation errors in the path filters before saving.
@@ -279,6 +285,6 @@
{onDiscard}
saveLabel="Save deployment UI"
disabled={workspaceToDeployTo == undefined || hasValidationErrors}
class="border-none"
class="mt-8"
/>
{/if}
@@ -8,6 +8,8 @@
<div class={twMerge('text-xs text-primary font-normal', $$props.class)}>
<slot />
{#if link}
<a href={link} target="_blank">Learn more <ExternalLink size={12} class="inline-block" /></a>
<a href={link} target="_blank" class="whitespace-nowrap"
>Learn more <ExternalLink size={12} class="inline-block" /></a
>
{/if}
</div>
@@ -366,7 +366,7 @@
{/snippet}
</ToggleButtonGroup>
<div class="flex flex-col gap-6 p-4 rounded-md border">
<div class="flex flex-col gap-6 p-4 rounded-md shadow-sm bg-surface-tertiary">
{#if handlerSelected === 'custom'}
<div class="flex flex-col gap-1">
<div class="flex flex-row">
@@ -0,0 +1,15 @@
<script lang="ts">
import { slide } from 'svelte/transition'
interface Props {
error: string
}
let { error }: Props = $props()
</script>
{#if error}
<div transition:slide={{ duration: 150 }} class="text-red-600 dark:text-red-400 text-xs mt-1">
{error}
</div>
{/if}
@@ -47,7 +47,9 @@
closeButton
>
<svelte:fragment slot="trigger">
<Button nonCaptureEvent={true} size="xs" color="light" endIcon={{ icon: Pencil }}>Edit</Button>
<Button unifiedSize="sm" nonCaptureEvent={true} variant="subtle" startIcon={{ icon: Pencil }}
>Edit</Button
>
</svelte:fragment>
<svelte:fragment slot="content">
<div class="flex flex-col gap-8 max-w-sm p-4">
+105 -367
View File
@@ -5,34 +5,28 @@
import type { Setting } from './instanceSettings'
import { OTEL_TRACING_PROXY_LANGUAGES } from './instanceSettings'
import { LanguageIcon } from './common/languageIcons'
import Tooltip from './Tooltip.svelte'
import ObjectStoreConfigSettings from './ObjectStoreConfigSettings.svelte'
import { sendUserToast } from '$lib/toast'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import {
ConfigService,
IndexSearchService,
SettingService,
type ListAvailablePythonVersionsResponse
} from '$lib/gen'
import { Button, SecondsInput, Section, Skeleton } from './common'
import { ConfigService, SettingService, type ListAvailablePythonVersionsResponse } from '$lib/gen'
import { Button, SecondsInput, Skeleton } from './common'
import Password from './Password.svelte'
import { classNames } from '$lib/utils'
import Popover from './Popover.svelte'
import PopoverMelt from './meltComponents/Popover.svelte'
import DropdownV2 from './DropdownV2.svelte'
import Toggle from './Toggle.svelte'
import type { Writable } from 'svelte/store'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, untrack } from 'svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import LoadingIcon from './apps/svelte-select/lib/LoadingIcon.svelte'
import EEOnly from './EEOnly.svelte'
import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte'
import SmtpSettings from './instanceSettings/SmtpSettings.svelte'
import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte'
import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte'
import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte'
import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte'
import TextInput from './text_input/TextInput.svelte'
import Label from './Label.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
setting: Setting
@@ -46,13 +40,6 @@
let { setting, version, values, loading = true, openSmtpSettings, oauths }: Props = $props()
const dispatch = createEventDispatcher()
if (
(setting.fieldType == 'select' || setting.fieldType == 'select_python') &&
$values[setting.key] == undefined
) {
$values[setting.key] = setting.defaultValue ? setting.defaultValue() : 'default'
}
let latestKeyRenewalAttempt: {
result: string
attempted_at: string
@@ -81,9 +68,11 @@
})
}
if (setting.key == 'license_key') {
reloadKeyrenewalAttemptInfo()
}
$effect(() => {
if (setting.key == 'license_key') {
untrack(() => reloadKeyrenewalAttemptInfo())
}
})
export async function renewLicenseKey() {
renewing = true
@@ -139,8 +128,6 @@
let pythonAvailableVersions: ListAvailablePythonVersionsResponse = $state([])
let isPyFetching = $state(false)
let clearJobsIndexModalOpen = $state(false)
let clearServiceLogsIndexModalOpen = $state(false)
async function fetch_available_python_versions() {
if (isPyFetching) return
isPyFetching = true
@@ -152,35 +139,29 @@
isPyFetching = false
}
}
if (setting.fieldType == 'select_python') {
fetch_available_python_versions()
}
</script>
{#snippet LabelSnippet()}
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="flex flex-col gap-1 mb-1">
<div class="flex gap-1">
<span class="text-emphasis font-semibold text-xs">{setting.label}</span>
{#if setting.ee_only != undefined && !$enterpriseLicense}
<EEOnly>
{#if setting.ee_only != ''}{setting.ee_only}{/if}
</EEOnly>
{/if}
</div>
{#if setting.description}
<span class="text-secondary text-xs font-normal">
{@html setting.description}
</span>
{/if}
</label>
{/snippet}
$effect(() => {
if (setting.fieldType == 'select_python') {
untrack(() => fetch_available_python_versions())
}
})
$effect(() => {
if (
(setting.fieldType == 'select' || setting.fieldType == 'select_python') &&
$values[setting.key] == undefined
) {
untrack(() => {
$values[setting.key] = setting.defaultValue ? setting.defaultValue() : 'default'
})
}
})
</script>
<!-- {JSON.stringify($values, null, 2)} -->
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key]) && !(setting.hiddenInEe && $enterpriseLicense)}
{#if setting.fieldType == 'select'}
<div>
{@render LabelSnippet()}
<SettingCard label={setting.label} description={setting.description} ee_only={setting.ee_only}>
<ToggleButtonGroup bind:selected={$values[setting.key]}>
{#snippet children({ item: toggleButton })}
{#each setting.select_items ?? [] as item}
@@ -193,116 +174,101 @@
{/each}
{/snippet}
</ToggleButtonGroup>
</div>
</SettingCard>
{:else if setting.fieldType == 'select_python'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
{@render LabelSnippet()}
<SettingCard label={setting.label} description={setting.description} ee_only={setting.ee_only}>
<ToggleButtonGroup bind:selected={$values[setting.key]}>
{#snippet children({ item: toggleButtonn })}
{#snippet children({ item: toggleButton })}
{#each setting.select_items ?? [] as item}
<ToggleButton
value={item.value ?? item.label}
label={item.label}
tooltip={item.tooltip}
item={toggleButtonn}
item={toggleButton}
/>
{/each}
<PopoverMelt closeButton={!isPyFetching} contentClasses="max-w-md">
{#snippet trigger()}
<DropdownV2
items={() =>
pythonAvailableVersions.map((v) => ({
displayName: v,
action: () => {
$values[setting.key] = v
}
}))}
>
{#snippet buttonReplacement()}
{#if setting.select_items?.some((e) => e.label == $values[setting.key] || e.value == $values[setting.key])}
<Button
variant="default"
btnClasses="px-1.5 py-1.5 text-2xs bg-surface-secondary border-0"
nonCaptureEvent={true}>Select Custom</Button
<Button variant="subtle" btnClasses="font-normal" nonCaptureEvent={true}
>Select Custom</Button
>
{:else}
<Button
variant="default"
btnClasses="px-1.5 py-1.5 text-2xs border-0 shadow-md"
btnClasses="font-normal bg-surface-input"
nonCaptureEvent={true}>Custom | {$values[setting.key]}</Button
>
{/if}
{/snippet}
{#snippet content()}
{#if isPyFetching}
<div class="p-4">
<LoadingIcon />
</div>
{:else}
<ToggleButtonGroup
bind:selected={$values[setting.key]}
class="mr-10 h-full"
tabListClass="flex-wrap p-2"
>
{#snippet children({ item: toggleButtonn })}
{#each pythonAvailableVersions as item}
<ToggleButton value={item} label={item} tooltip={item} item={toggleButtonn} />
{/each}
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
</PopoverMelt>
</DropdownV2>
{/snippet}
</ToggleButtonGroup>
</div>
{:else}
{#snippet settingContent()}
<div class="flex flex-col gap-2 mb-1">
<div class="flex items-center justify-between">
<div class="text-emphasis font-semibold text-xs flex flex-col gap-1 w-full">
<div class="flex items-center justify-between gap-2 w-full">
{#if setting.fieldType != 'smtp_connect'}
<div class="flex gap-1 items-baseline">
<span class="text-emphasis font-semibold text-xs pb-1">{setting.label}</span>
{#if setting.ee_only != undefined && !$enterpriseLicense}
{#if setting.ee_only != ''}
<EEOnly>{setting.ee_only}</EEOnly>
{:else}
<EEOnly />
{/if}
{/if}
</div>
{/if}
{#if setting.actionButton}
<Button
disabled={setting.ee_only != undefined && !$enterpriseLicense}
variant={setting.actionButton.variant ?? 'default'}
unifiedSize="sm"
onclick={async () => await setting.actionButton?.onclick($values)}
>
{setting.actionButton.label}
</Button>
{/if}
</div>
{#if setting.description}
<span class="text-secondary font-normal text-xs">
{@html setting.description}
</span>
{/if}
</div>
</SettingCard>
{:else if setting.fieldType == 'indexer_rates'}
{#if $values[setting.key]}
{@const fieldErrors = setting.validate?.($values[setting.key]) ?? {}}
<SettingCard
label="Memory"
description="Configure the memory budget for the indexer and manage index clearing."
ee_only=""
>
<div class="p-4 rounded-md border mt-2">
<IndexerMemorySettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
</div>
</div>
{#if setting.tooltip}
<Tooltip>{setting.tooltip}</Tooltip>
{/if}
</SettingCard>
<SettingCard
label="Completed Job Index"
description="Configure indexing parameters for completed jobs."
ee_only=""
>
<div class="p-4 rounded-md border mt-2">
<IndexerJobIndexSettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
</div>
</SettingCard>
<SettingCard
label="Service Logs Index"
description="Configure indexing parameters for service logs."
ee_only=""
>
<div class="p-4 rounded-md border mt-2">
<IndexerLogIndexSettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
</div>
</SettingCard>
{/if}
{:else}
<SettingCard
label={setting.fieldType != 'smtp_connect' ? setting.label : undefined}
description={setting.description}
ee_only={setting.ee_only}
tooltip={setting.tooltip}
actionButton={setting.actionButton}
values={$values}
>
{#if $values}
{@const hasError = setting.isValid && !setting.isValid($values[setting.key])}
<div class="h-1"></div>
{#if loading}
<Skeleton layout={[[2.5]]} />
{:else if setting.fieldType == 'text'}
<input
id={setting.key}
disabled={setting.ee_only != undefined && !$enterpriseLicense}
type="text"
placeholder={setting.placeholder}
class={hasError
? 'border !border-red-700 !border-opacity-30 !focus:border-red-700 !focus:border-opacity-30'
: ''}
<TextInput
inputProps={{
type: 'text',
id: setting.key,
disabled: setting.ee_only != undefined && !$enterpriseLicense,
placeholder: setting.placeholder
}}
bind:value={$values[setting.key]}
class="max-w-lg"
/>
{#if setting.advancedToggle}
<div class="mt-1">
@@ -369,7 +335,7 @@
bind:password={$values[setting.key]}
/>
<Button
variant="accent"
variant="default"
unifiedSize="md"
disabled={!$values[setting.key]}
on:click={async () => {
@@ -458,7 +424,7 @@
License key cannot be renewed during trial ({attemptedAt})
</span>
{:else}
<span class="text-red-300">
<span class="text-red-600 dark:text-red-400">
Latest key renewal failed on {attemptedAt}: {latestKeyRenewalAttempt?.result.replace(
'error: ',
''
@@ -475,7 +441,7 @@
{/if}
{#if licenseKeyChanged && !$enterpriseLicense}
{#if version.startsWith('CE')}
<div class="text-red-400"
<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
>
@@ -525,229 +491,8 @@
</div>
{:else if setting.fieldType == 'critical_error_channels'}
<CriticalAlertChannels {values} {openSmtpSettings} {oauths} />
{:else if setting.fieldType == 'indexer_rates'}
<div class="flex flex-col gap-16 mt-4">
{#if $values[setting.key]}
<Section label="Memory" class="space-y-6">
<div class="flex flex-col gap-1">
<label
for="writer_memory_budget"
class="block text-xs font-semibold text-emphasis"
>
Index writer memory budget (MB)
<Tooltip>
The allocated memory arena for the indexer. A bigger value means less writing
to disk and potentially higher indexing throughput
</Tooltip>
</label>
<TextInput
inputProps={{
type: 'number',
placeholder: '300',
id: 'writer_memory_budget',
disabled: !$enterpriseLicense,
oninput: (e) => {
if (e.target instanceof HTMLInputElement) {
if (e.target.valueAsNumber) {
$values[setting.key].writer_memory_budget =
e.target.valueAsNumber * (1024 * 1024)
}
}
}
}}
value={$values[setting.key].writer_memory_budget / (1024 * 1024)}
/>
</div>
<Label label="Clear index">
<span class="text-xs text-secondary"
>This buttons will clear the whole index, and the service will start reindexing
from scratch. Full text search might be down during this time.</span
>
<div class="flex flex-row gap-2">
<Button
variant="default"
unifiedSize="sm"
on:click={() => {
clearJobsIndexModalOpen = true
}}
>
Clear jobs index
</Button>
<Button
variant="default"
unifiedSize="sm"
on:click={() => {
clearServiceLogsIndexModalOpen = true
}}
>
Clear service logs index
</Button>
</div>
</Label>
<ConfirmationModal
title="Clear jobs index"
confirmationText="Clear"
open={clearJobsIndexModalOpen}
type="danger"
on:canceled={() => {
clearJobsIndexModalOpen = false
}}
on:confirmed={async () => {
const r = await IndexSearchService.clearIndex({
idxName: 'JobIndex'
})
sendUserToast(r)
clearJobsIndexModalOpen = false
}}
>
Are you sure you want to clear the jobs index? The service will start reindexing
from scratch. Full text search might be down during this time.
</ConfirmationModal>
<ConfirmationModal
title="Clear service logs index"
confirmationText="Clear"
open={clearServiceLogsIndexModalOpen}
type="danger"
on:canceled={() => {
clearServiceLogsIndexModalOpen = false
}}
on:confirmed={async () => {
const r = await IndexSearchService.clearIndex({
idxName: 'ServiceLogIndex'
})
sendUserToast(r)
clearServiceLogsIndexModalOpen = false
}}
>
Are you sure you want to clear the service logs index? The service will start
reindexing from scratch. Full text search might be down during this time.
</ConfirmationModal>
</Section>
<hr class="border-t -my-6" />
<Section label="Completed Job Index" class="space-y-6">
<div class="flex flex-col gap-1">
<label
for="commit_job_max_batch_size"
class="block text-xs font-semibold text-emphasis"
>
Commit max batch size <Tooltip>
The max amount of documents (here jobs) per commit. To optimize indexing
throughput, it is best to keep this as high as possible. However, especially
when reindexing the whole instance, it can be useful to have a limit on how
many jobs can be written without being committed. A commit will make the jobs
available for search, constitute a "checkpoint" state in the indexing and will
be logged.
</Tooltip>
</label>
<TextInput
inputProps={{
type: 'number',
placeholder: '100000',
id: 'commit_job_max_batch_size',
disabled: !$enterpriseLicense
}}
bind:value={$values[setting.key].commit_job_max_batch_size}
/>
</div>
<div class="flex flex-col gap-1">
<label
for="refresh_index_period"
class="block text-xs font-semibold text-emphasis"
>
Refresh index period (s) <Tooltip>
The index will query new jobs periodically and write them on the index. This
setting sets that period.
</Tooltip></label
>
<TextInput
inputProps={{
type: 'number',
placeholder: '300',
id: 'refresh_index_period',
disabled: !$enterpriseLicense
}}
bind:value={$values[setting.key].refresh_index_period}
/>
</div>
<div class="flex flex-col gap-1">
<label
for="max_indexed_job_log_size"
class="block text-xs font-semibold text-emphasis"
>
Max indexed job log size (KB) <Tooltip>
Job logs are included when indexing, but to avoid the index size growing
artificially, the logs will be truncated after a size has been reached.
</Tooltip>
</label>
<TextInput
inputProps={{
type: 'number',
placeholder: '1024',
id: 'max_indexed_job_log_size',
disabled: !$enterpriseLicense,
oninput: (e) => {
if (e.target instanceof HTMLInputElement) {
if (e.target.valueAsNumber) {
$values[setting.key].max_indexed_job_log_size =
e.target.valueAsNumber * 1024
}
}
}
}}
value={$values[setting.key].max_indexed_job_log_size / 1024}
/>
</div>
</Section>
<hr class="border-t -my-6" />
<Section label="Service logs index" class="space-y-6">
<div class="flex flex-col gap-1">
<label
for="commit_log_max_batch_size"
class="block text-xs font-semibold text-emphasis"
>Commit max batch size <Tooltip>
The max amount of documents per commit. In this case 1 document is one log
file representing all logs during 1 minute for a specific host. To optimize
indexing throughput, it is best to keep this as high as possible. However,
especially when reindexing the whole instance, it can be useful to have a
limit on how many logs can be written without being committed. A commit will
make the logs available for search, appear as a log line, and be a
"checkpoint" of the indexing progress.
</Tooltip>
</label>
<input
disabled={!$enterpriseLicense}
type="number"
id="commit_log_max_batch_size"
placeholder="10000"
bind:value={$values[setting.key].commit_log_max_batch_size}
/>
</div>
<div class="flex flex-col gap-1">
<label
for="refresh_log_index_period"
class="block text-xs font-semibold text-emphasis"
>
Refresh index period (s) <Tooltip>
The index will query new service logs peridically and write them on the index.
This setting sets that period.
</Tooltip>
</label>
<TextInput
inputProps={{
type: 'number',
placeholder: '300',
id: 'refresh_log_index_period',
disabled: !$enterpriseLicense
}}
bind:value={$values[setting.key].refresh_log_index_period}
/>
</div>
</Section>
{/if}
</div>
{:else if setting.fieldType == 'otel'}
<div class="flex flex-col gap-4 border rounded p-4">
<div class="flex flex-col gap-4 p-4 rounded-md border">
{#if $values[setting.key]}
<div class="flex gap-8">
<Toggle
@@ -871,7 +616,6 @@
</div>
{:else if setting.fieldType == 'object_store_config'}
<ObjectStoreConfigSettings bind:bucket_config={$values[setting.key]} />
<div class="mb-6"></div>
{:else if setting.fieldType == 'critical_alerts_on_db_oversize'}
{#if $values[setting.key]}
<div class="flex flex-row flex-wrap gap-2 p-0 items-center">
@@ -893,7 +637,6 @@
<span class="text-primary font-semibold text-sm">GB</span>
{/if}
</div>
<div class="mb-6"></div>
{/if}
{:else if setting.fieldType == 'number'}
<TextInput
@@ -903,6 +646,7 @@
id: setting.key
}}
bind:value={$values[setting.key]}
class="max-w-lg"
/>
{:else if setting.fieldType == 'password'}
<Password small placeholder={setting.placeholder} bind:password={$values[setting.key]} />
@@ -922,25 +666,19 @@
clearable
/>
</div>
{:else if setting.fieldType == 'select'}
TODO
{:else if setting.fieldType == 'smtp_connect'}
<SmtpSettings {values} disabled={loading} />
{:else if setting.fieldType == 'secret_backend'}
<SecretBackendConfig {values} disabled={loading} />
{/if}
{#if hasError}
<span class="text-red-500 dark:text-red-400 text-sm">
<span class="text-red-600 dark:text-red-400 text-xs">
{setting.error ?? ''}
</span>
{/if}
{:else}
<input disabled placeholder="Loading..." />
{/if}
{/snippet}
<div class="block">
{@render settingContent()}
</div>
</SettingCard>
{/if}
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import { scimSamlSetting, settings, settingsKeys, type SettingStorage } from './instanceSettings'
import { Button, Tab, TabContent, Tabs } from '$lib/components/common'
import { Alert, Button, Tab, TabContent, Tabs } from '$lib/components/common'
import { SettingService, SettingsService } from '$lib/gen'
import type { TeamsChannel } from '$lib/gen/types.gen'
@@ -15,20 +15,25 @@
import AuthSettings from './AuthSettings.svelte'
import InstanceSetting from './InstanceSetting.svelte'
import { writable, type Writable } from 'svelte/store'
import { ExternalLink } from 'lucide-svelte'
import SettingsFooter from './workspaceSettings/SettingsFooter.svelte'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
interface Props {
tab?: string
hideTabs?: boolean
hideSave?: boolean
closeDrawer?: (() => void) | undefined
authSubTab?: 'sso' | 'oauth' | 'scim'
onNavigateToTab?: (category: string) => void
quickSetup?: boolean
}
let {
tab = $bindable('Core'),
hideTabs = false,
hideSave = false,
closeDrawer = () => {}
closeDrawer = () => {},
authSubTab = $bindable('sso'),
onNavigateToTab,
quickSetup = false
}: Props = $props()
let values: Writable<Record<string, any>> = writable({})
@@ -75,6 +80,26 @@
)
).flat()
)
// Normalize null to the field type's default so inputs don't appear dirty on load
const allSettings = [...Object.values(settings), scimSamlSetting].flat()
for (const s of allSettings) {
if (initialValues[s.key] == null) {
if (s.fieldType === 'boolean') {
initialValues[s.key] = false
} else if (
s.fieldType === 'text' ||
s.fieldType === 'textarea' ||
s.fieldType === 'codearea' ||
s.fieldType === 'password'
) {
initialValues[s.key] = ''
} else if (s.fieldType === 'secret_backend') {
initialValues[s.key] = { type: 'Database' }
} else if (s.fieldType === 'select' || s.fieldType === 'select_python') {
initialValues[s.key] = s.defaultValue ? s.defaultValue() : 'default'
}
}
}
let nvalues = JSON.parse(JSON.stringify(initialValues))
if (nvalues['base_url'] == undefined) {
nvalues['base_url'] = window.location.origin
@@ -110,99 +135,6 @@
}
}
export async function saveSettings() {
if (
oauths?.snowflake_oauth &&
oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !==
snowflakeAccountIdentifier
) {
setupSnowflakeUrls()
}
// Remove empty or invalid entries for critical error channels
$values.critical_error_channels = $values.critical_error_channels.filter((entry) => {
if (!entry || typeof entry !== 'object') return false
if ('teams_channel' in entry) {
return isValidTeamsChannel(entry.teams_channel)
}
if ('slack_channel' in entry) {
return typeof entry.slack_channel === 'string' && entry.slack_channel.trim() !== ''
}
if ('email' in entry) {
return typeof entry.email === 'string' && entry.email.trim() !== ''
}
// Unknown shape
return false
})
let shouldReloadPage = false
if ($values) {
// Trim license key before saving
if ($values['license_key'] && typeof $values['license_key'] === 'string') {
$values['license_key'] = $values['license_key'].trim()
}
const allSettings = [...Object.values(settings), scimSamlSetting].flatMap((x) =>
Object.entries(x)
)
let licenseKeySet = false
await Promise.all(
allSettings
.filter((x) => {
return (
x[1].storage == 'setting' &&
!deepEqual(initialValues?.[x[1].key], $values?.[x[1].key]) &&
($values?.[x[1].key] != '' ||
initialValues?.[x[1].key] != undefined ||
initialValues?.[x[1].key] != null)
)
})
.map(async ([_, x]) => {
if (x.key == 'license_key') {
licenseKeySet = true
}
if (x.requiresReloadOnChange) {
shouldReloadPage = true
}
return await SettingService.setGlobal({
key: x.key,
requestBody: { value: $values?.[x.key] }
})
})
)
initialValues = JSON.parse(JSON.stringify($values))
if (!deepEqual(initialOauths, oauths)) {
await SettingService.setGlobal({
key: 'oauths',
requestBody: {
value: oauths
}
})
initialOauths = JSON.parse(JSON.stringify(oauths))
}
if (initialRequirePreexistingUserForOauth !== requirePreexistingUserForOauth) {
await SettingService.setGlobal({
key: 'require_preexisting_user_for_oauth',
requestBody: { value: requirePreexistingUserForOauth }
})
}
if (licenseKeySet) {
setLicense()
}
} else {
console.error('Values not loaded')
}
if (shouldReloadPage) {
sendUserToast('Settings updated, reloading page...')
await sleep(1000)
window.location.reload()
} else {
sendUserToast('Settings updated')
dispatch('saved')
}
}
function setupSnowflakeUrls() {
// strip all whitespaces from account identifier
snowflakeAccountIdentifier = snowflakeAccountIdentifier.replace(/\s/g, '')
@@ -269,14 +201,198 @@
}
function openSmtpSettings() {
tab = 'SMTP'
if (onNavigateToTab) {
onNavigateToTab('SMTP')
} else {
tab = 'SMTP'
}
}
// --- Per-category dirty state tracking ---
// Trigger to force re-derivation when initialValues changes (after save/load)
let dirtyCheckTrigger = $state(0)
function stripEmpty(obj: Record<string, any>): Record<string, any> {
return Object.fromEntries(
Object.entries(obj)
.filter(([_, v]) => v !== undefined && v !== '')
.map(([k, v]) =>
v != null && typeof v === 'object' && !Array.isArray(v)
? [k, stripEmpty(v)]
: [k, v]
)
)
}
function getSettingsForCategory(category: string) {
if (category === 'Auth/OAuth/SAML') {
return scimSamlSetting
}
return settings[category] ?? []
}
let dirtyCategories: Record<string, boolean> = $derived.by(() => {
void dirtyCheckTrigger
const currentValues = $values
const result: Record<string, boolean> = {}
for (const category of settingsKeys) {
if (category === 'Auth/OAuth/SAML') {
const scimDirty = scimSamlSetting.some(
(s) => !deepEqual(initialValues[s.key], currentValues?.[s.key])
)
const oauthsDirty = !deepEqual(stripEmpty(initialOauths), stripEmpty(oauths))
const requirePreexistingDirty =
initialRequirePreexistingUserForOauth !== requirePreexistingUserForOauth
result[category] = scimDirty || oauthsDirty || requirePreexistingDirty
} else {
const categorySettings = settings[category] ?? []
result[category] = categorySettings.some(
(s) => !deepEqual(initialValues[s.key], currentValues?.[s.key])
)
}
}
return result
})
let invalidCategories: Record<string, boolean> = $derived.by(() => {
const currentValues = $values
const result: Record<string, boolean> = {}
for (const category of settingsKeys) {
const categorySettings = getSettingsForCategory(category)
result[category] = categorySettings.some((s) => {
if (s.isValid && !s.isValid(currentValues?.[s.key])) return true
if (s.validate) {
const errors = s.validate(currentValues?.[s.key])
return Object.keys(errors).length > 0
}
return false
})
}
return result
})
export function isDirty(category: string): boolean {
return dirtyCategories[category] ?? false
}
export function discardCategory(category: string) {
if (category === 'Auth/OAuth/SAML') {
for (const s of scimSamlSetting) {
$values[s.key] = JSON.parse(JSON.stringify(initialValues[s.key]))
}
oauths = JSON.parse(JSON.stringify(initialOauths))
requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth
const account_identifier =
initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier
snowflakeAccountIdentifier = account_identifier ?? ''
} else {
const categorySettings = settings[category] ?? []
for (const s of categorySettings) {
$values[s.key] = JSON.parse(JSON.stringify(initialValues[s.key]))
}
}
}
export async function saveCategorySettings(category: string) {
// Category-specific pre-processing
if (category === 'Auth/OAuth/SAML') {
if (
oauths?.snowflake_oauth &&
oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !==
snowflakeAccountIdentifier
) {
setupSnowflakeUrls()
}
}
if (category === 'Alerts' && $values?.critical_error_channels) {
$values.critical_error_channels = $values.critical_error_channels.filter((entry: any) => {
if (!entry || typeof entry !== 'object') return false
if ('teams_channel' in entry) return isValidTeamsChannel(entry.teams_channel)
if ('slack_channel' in entry)
return typeof entry.slack_channel === 'string' && entry.slack_channel.trim() !== ''
if ('email' in entry) return typeof entry.email === 'string' && entry.email.trim() !== ''
return false
})
}
if (
category === 'Core' &&
$values?.['license_key'] &&
typeof $values['license_key'] === 'string'
) {
$values['license_key'] = $values['license_key'].trim()
}
let shouldReloadPage = false
const categorySettings = getSettingsForCategory(category)
let licenseKeySet = false
await Promise.all(
categorySettings
.filter((x) => {
return (
x.storage === 'setting' &&
!deepEqual(initialValues?.[x.key], $values?.[x.key]) &&
($values?.[x.key] !== '' ||
initialValues?.[x.key] !== undefined ||
initialValues?.[x.key] !== null)
)
})
.map(async (x) => {
if (x.key === 'license_key') licenseKeySet = true
if (x.requiresReloadOnChange) shouldReloadPage = true
return await SettingService.setGlobal({
key: x.key,
requestBody: { value: $values?.[x.key] }
})
})
)
// Update only the saved category's initial values
for (const s of categorySettings) {
initialValues[s.key] = JSON.parse(JSON.stringify($values[s.key]))
}
// Handle Auth/OAuth/SAML-specific saves
if (category === 'Auth/OAuth/SAML') {
if (!deepEqual(stripEmpty(initialOauths), stripEmpty(oauths))) {
await SettingService.setGlobal({
key: 'oauths',
requestBody: { value: oauths }
})
initialOauths = JSON.parse(JSON.stringify(oauths))
}
if (initialRequirePreexistingUserForOauth !== requirePreexistingUserForOauth) {
await SettingService.setGlobal({
key: 'require_preexisting_user_for_oauth',
requestBody: { value: requirePreexistingUserForOauth }
})
initialRequirePreexistingUserForOauth = requirePreexistingUserForOauth
}
}
if (licenseKeySet) setLicense()
// Force dirty state re-check
dirtyCheckTrigger++
if (shouldReloadPage) {
sendUserToast('Settings updated, reloading page...')
await sleep(1000)
window.location.reload()
} else {
sendUserToast('Settings updated')
dispatch('saved')
}
}
</script>
<div class="pb-12">
<!-- svelte-ignore a11y_label_has_associated_control -->
{#if hideTabs}
{@render tabsContent()}
{@render categoryContent(tab)}
{:else}
<Tabs bind:selected={tab}>
{#each settingsKeys as category}
@@ -285,138 +401,142 @@
{#snippet content()}
<div class="pt-4"></div>
{@render tabsContent()}
{#each Object.keys(settings) as category}
<TabContent value={category}>
{@render categoryContent(category)}
</TabContent>
{/each}
{/snippet}
</Tabs>
{/if}
{#snippet tabsContent()}
{#each Object.keys(settings) as category}
<TabContent value={category}>
{#if category == 'SMTP'}
<div class="text-secondary pb-4 text-xs">
Setting SMTP unlocks sending emails upon adding new users to the workspace or the
instance or sending critical alerts via email.
<a target="_blank" href="https://www.windmill.dev/docs/advanced/instance_settings#smtp"
>Learn more <ExternalLink size={12} class="inline-block" /></a
>
</div>
{:else if category == 'Indexer/Search'}
<div class="text-secondary pb-4 text-xs"
>The indexer service unlocks full text search across jobs and service logs. It requires
spinning up its own separate container
<a target="_blank" href="https://www.windmill.dev/docs/core_concepts/search_bar#setup"
>Learn how to <ExternalLink size={12} class="inline-block" /></a
></div
{#snippet categoryContent(category: string)}
{#if category == 'Core'}
<SettingsPageHeader
title="General"
description="Configure the core settings of your Windmill instance."
link="https://www.windmill.dev/docs/advanced/instance_settings"
/>
{:else if category == 'SMTP'}
<SettingsPageHeader
title="SMTP"
description="Setting SMTP unlocks sending emails upon adding new users to the workspace or the instance or sending critical alerts via email."
link="https://www.windmill.dev/docs/advanced/instance_settings#smtp"
/>
{:else if category == 'Registries'}
<SettingsPageHeader
title="Registries"
description="Add private registries for Pip, Bun and npm."
link="https://www.windmill.dev/docs/advanced/imports"
/>
{:else if category == 'Alerts'}
<SettingsPageHeader
title="Alerts"
description="Critical alerts automatically notify administrators about system events like job crashes, license issues, worker failures, and queue delays through email, Slack, or Teams."
link="https://www.windmill.dev/docs/core_concepts/critical_alerts"
/>
{:else if category == 'OTEL/Prom'}
<SettingsPageHeader
title="OTEL/Prometheus"
description="Configure OpenTelemetry and Prometheus metrics export for monitoring your Windmill instance."
link="https://www.windmill.dev/docs/core_concepts/otel"
/>
{:else if category == 'Indexer'}
<SettingsPageHeader
title="Indexer"
description="The indexer service unlocks full text search across jobs and service logs. It requires spinning up its own separate container."
link="https://www.windmill.dev/docs/core_concepts/search_bar#setup"
/>
{#if !$enterpriseLicense}
<Alert
type="info"
title="Full text search across jobs and service logs is an EE feature"
class="mb-2"
/>
{/if}
{:else if category == 'Telemetry'}
<SettingsPageHeader title="Telemetry" />
<div class="text-primary pb-4 text-xs">
Anonymous usage data is collected to help improve Windmill.
<br />The following information is collected:
<ul class="list-disc list-inside pl-2">
<li>version of your instances</li>
<li>instance base URL</li>
<li>job usage (language, total duration, count)</li>
<li>login type usage (login type, count)</li>
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
<li>user usage (author count, operator count)</li>
<li>superadmin email addresses</li>
<li>vCPU usage</li>
<li>memory usage</li>
<li>development instance status</li>
</ul>
</div>
{#if $enterpriseLicense}
<div class="text-primary pb-4 text-xs">
On Enterprise Edition, you must send data to check that usage is in line with the terms of
the subscription. You can either enable telemetry or regularly send usage data by clicking
the button below. For air-gapped instances, you can download the telemetry data and send
it manually.
</div>
<div class="flex gap-2 mb-4">
<Button
on:click={sendStats}
variant="default"
btnClasses="w-auto"
loading={sendingStats}
size="xs"
>
{:else if category == 'Alerts'}
<div class="text-secondary pb-4 text-xs">
Critical alerts automatically notify administrators about system events like job crashes,
license issues, worker failures, and queue delays through email, Slack, or Teams.
<a target="_blank" href="https://www.windmill.dev/docs/core_concepts/critical_alerts"
>Learn more <ExternalLink size={12} class="inline-block" /></a
>
</div>
{:else if category == 'Registries'}
<div class="text-secondary pb-4 text-xs">
Add private registries for Pip, Bun and npm. <a
target="_blank"
href="https://www.windmill.dev/docs/advanced/imports">Learn more</a
>
</div>
{:else if category == 'Slack'}
<div class="text-secondary pb-4 text-xs">
Connecting your instance to a Slack workspace enables critical alerts to be sent to a
Slack channel.
<a target="_blank" href="https://www.windmill.dev/docs/misc/saml_and_scim">Learn more</a
>
</div>
{:else if category == 'SCIM/SAML'}
<div class="text-secondary pb-4 text-xs">
Setting up SAML and SCIM allows you to authenticate users using your identity provider.
<a target="_blank" href="https://www.windmill.dev/docs/advanced/instance_settings#slack"
>Learn more</a
>
</div>
{:else if category == 'Debug'}
<div class="text-secondary pb-4 text-xs">
Enable debug mode to get more detailed logs.
</div>
{:else if category == 'Telemetry'}
<div class="text-primary pb-4 text-xs">
Anonymous usage data is collected to help improve Windmill.
<br />The following information is collected:
<ul class="list-disc list-inside pl-2">
<li>version of your instances</li>
<li>instance base URL</li>
<li>job usage (language, total duration, count)</li>
<li>login type usage (login type, count)</li>
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
<li>user usage (author count, operator count)</li>
<li>superadmin email addresses</li>
<li>vCPU usage</li>
<li>memory usage</li>
<li>development instance status</li>
</ul>
</div>
{#if $enterpriseLicense}
<div class="text-primary pb-4 text-xs">
On Enterprise Edition, you must send data to check that usage is in line with the
terms of the subscription. You can either enable telemetry or regularly send usage
data by clicking the button below. For air-gapped instances, you can download the
telemetry data and send it manually.
</div>
<div class="flex gap-2 mb-4">
<Button
on:click={sendStats}
variant="default"
btnClasses="w-auto"
loading={sendingStats}
size="xs"
>
Send usage
</Button>
<Button
on:click={downloadStats}
variant="default"
btnClasses="w-auto"
loading={downloadingStats}
size="xs"
>
Download usage
</Button>
</div>
{/if}
{:else if category == 'Auth/OAuth/SAML'}
<AuthSettings
bind:oauths
bind:snowflakeAccountIdentifier
bind:requirePreexistingUserForOauth
baseUrl={$values?.base_url}
Send usage
</Button>
<Button
on:click={downloadStats}
variant="default"
btnClasses="w-auto"
loading={downloadingStats}
size="xs"
>
{#snippet scim()}
<div class="flex-col flex gap-6 pb-4">
{#each scimSamlSetting as setting}
<InstanceSetting
on:closeDrawer={() => closeDrawer?.()}
{loading}
{setting}
{values}
{version}
{oauths}
/>
{/each}
</div>
{/snippet}
</AuthSettings>
{/if}
<div class="flex-col flex gap-6 pb-4">
{#each settings[category] as setting}
<!-- slack connect is handled with the alert channels settings, smtp_connect is handled in InstanceSetting -->
{#if setting.fieldType != 'slack_connect'}
Download usage
</Button>
</div>
{/if}
{:else if category == 'Jobs'}
<SettingsPageHeader
title="Jobs"
description="Configure default timeouts and retention policies for job execution."
link="https://www.windmill.dev/docs/advanced/instance_settings#jobs"
/>
{:else if category == 'Object Storage'}
<SettingsPageHeader
title="Object Storage"
description="Configure S3-compatible storage for large logs and distributed dependency caching."
link="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill"
/>
{:else if category == 'Private Hub'}
<SettingsPageHeader
title="Private Hub"
description="Connect to a Private Hub instance for sharing custom scripts and integrations."
link="https://www.windmill.dev/docs/core_concepts/private_hub"
/>
{:else if category == 'Secret Storage'}
<SettingsPageHeader
title="Secret Storage"
description="Configure where secrets (secret variables) are stored."
link="https://www.windmill.dev/docs/core_concepts/workspace_secret_encryption"
/>
{:else if category == 'Auth/OAuth/SAML'}
<AuthSettings
bind:oauths
bind:snowflakeAccountIdentifier
bind:requirePreexistingUserForOauth
baseUrl={$values?.base_url}
bind:tab={authSubTab}
{hideTabs}
>
{#snippet scim()}
<div class="flex-col flex gap-6 pb-4">
{#each scimSamlSetting as setting}
<InstanceSetting
{openSmtpSettings}
on:closeDrawer={() => closeDrawer?.()}
{loading}
{setting}
@@ -424,15 +544,38 @@
{version}
{oauths}
/>
{/if}
{/each}
</div>
</TabContent>
{/each}
{/each}
</div>
{/snippet}
</AuthSettings>
{/if}
<div class="flex-col flex gap-6 pb-6">
{#each settings[category] as setting}
<!-- slack connect is handled with the alert channels settings, smtp_connect is handled in InstanceSetting -->
{#if setting.fieldType != 'slack_connect' && !(quickSetup && setting.hideInQuickSetup)}
<InstanceSetting
{openSmtpSettings}
on:closeDrawer={() => closeDrawer?.()}
{loading}
{setting}
{values}
{version}
{oauths}
/>
{/if}
{/each}
</div>
{#if !loading && !quickSetup}
<SettingsFooter
hasUnsavedChanges={dirtyCategories[category] ?? false}
disabled={invalidCategories[category] ?? false}
onSave={() => saveCategorySettings(category)}
onDiscard={() => discardCategory(category)}
saveLabel={`Save ${category.toLowerCase()} settings`}
class="bg-surface"
/>
{/if}
{/snippet}
</div>
{#if !hideSave}
<Button on:click={saveSettings} variant="accent">Save settings</Button>
<div class="pb-8"></div>
{/if}
@@ -0,0 +1,86 @@
<script lang="ts">
import TextInput from './text_input/TextInput.svelte'
import type { ButtonType } from './common/button/model'
import { untrack } from 'svelte'
interface Props {
value?: number
oninput?: (value: number | undefined) => void
placeholder?: string
id?: string
disabled?: boolean
error?: string
class?: string
size?: ButtonType.UnifiedSize
}
let {
value,
oninput,
placeholder,
id,
disabled,
error = '',
class: className = '',
size
}: Props = $props()
let displayValue: string | number = $state('')
$effect(() => {
const incoming = value
const current = String(untrack(() => displayValue))
const currentNum = current === '' ? undefined : Number(current)
if (incoming !== currentNum) {
displayValue = incoming != null ? String(incoming) : ''
}
})
function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey) return
if (
!/[0-9]/.test(e.key) &&
![
'Backspace',
'Delete',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Tab',
'Enter'
].includes(e.key)
) {
e.preventDefault()
}
}
function handleInput(e: Event) {
if (e.target instanceof HTMLInputElement) {
const raw = e.target.value.replace(/[^0-9]/g, '')
e.target.value = raw
displayValue = raw
if (raw === '') {
oninput?.(undefined)
return
}
oninput?.(Number(raw))
}
}
</script>
<TextInput
{size}
class={className}
{error}
value={displayValue}
inputProps={{
type: 'number',
inputmode: 'numeric',
placeholder,
id,
disabled,
onkeydown: handleKeydown,
oninput: handleInput
}}
/>
@@ -3,10 +3,13 @@
import { createEventDispatcher } from 'svelte'
import { UserService } from '$lib/gen'
import { Button } from './common'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import { generateRandomString } from '$lib/utils'
import { globalEmailInvite } from '$lib/stores'
export let close: (() => void) | undefined = undefined
const dispatch = createEventDispatcher()
let is_super_admin = false
@@ -28,37 +31,35 @@
$globalEmailInvite = ''
password = generateRandomString(10)
dispatch('new')
close?.()
}
</script>
<h3 class="text-sm font-semibold text-emphasis">Add new user to instance</h3>
<div class="flex flex-row flex-wrap gap-2 mb-2 items-end">
<label class="block shrink min-w-0">
<div class="p-4 flex flex-col gap-3 w-80">
<h3 class="text-sm font-semibold text-emphasis">Add new user to instance</h3>
<label class="block">
<span class="text-xs font-semibold text-emphasis">Email</span>
<input type="email" placeholder="email" bind:value={$globalEmailInvite} />
<TextInput
inputProps={{ type: 'email', placeholder: 'email' }}
bind:value={$globalEmailInvite}
/>
</label>
<label class="block shrink min-w-0">
<label class="block">
<span class="text-xs font-semibold text-emphasis">Password</span>
<input bind:value={password} />
<TextInput bind:value={password} />
</label>
<div>
<label class="block">
<span class="text-xs font-semibold text-emphasis">Name (optional)</span>
<input type="text" placeholder="name (optional)" bind:value={name} />
</div>
<Toggle class="mx-2 mb-1" bind:checked={is_super_admin} options={{ right: 'Superadmin' }} />
<div class="flex flex-row-reverse grow">
<div class="flex">
<Button
variant="accent"
size="sm"
on:click={addUser}
disabled={$globalEmailInvite == '' || password == undefined}
>
Add user to instance
</Button>
</div>
</div>
</div>
<div class="flex gap-2 items-end">
<div class="text-2xs text-secondary grow text-right"> Email will be sent if SMTP configured </div>
<TextInput inputProps={{ type: 'text', placeholder: 'name (optional)' }} bind:value={name} />
</label>
<Toggle bind:checked={is_super_admin} options={{ right: 'Superadmin' }} />
<Button
variant="accent"
size="sm"
on:click={addUser}
disabled={$globalEmailInvite == '' || password == undefined}
>
Add user to instance
</Button>
<div class="text-2xs text-secondary text-right">Email will be sent if SMTP configured</div>
</div>
@@ -2,6 +2,7 @@
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
export let value: any
@@ -56,7 +57,7 @@
/></label
>
{#if enabled}
<div class="border rounded p-4 flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Kanidm Url</span>
<span class="text-secondary font-normal text-xs">{'KANIDM_URL/ui/oauth2'}</span>
@@ -83,6 +84,6 @@
bind:value={value['secret']}
/>
</label>
</div>
</SettingCard>
{/if}
</div>
@@ -2,6 +2,7 @@
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
export let value: any
@@ -46,7 +47,7 @@
/></label
>
{#if enabled}
<div class="border rounded p-4 flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Realm Url </span>
<span class="text-secondary font-normal text-xs"
@@ -75,6 +76,6 @@
bind:value={value['secret']}
/>
</label>
</div>
</SettingCard>
{/if}
</div>
@@ -3,6 +3,7 @@
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
value: any
@@ -59,7 +60,7 @@
/></label
>
{#if enabled}
<div class="p-4 rounded-md border flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Nextcloud Instance Domain</span>
<TextInput
@@ -105,6 +106,6 @@
5. Copy the Client ID and Client Secret to the fields above<br />
</div>
</CollapseLink>
</div>
</SettingCard>
{/if}
</div>
@@ -7,6 +7,7 @@
import { enterpriseLicense } from '$lib/stores'
import Button from './common/button/Button.svelte'
import TextInput from './text_input/TextInput.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
name: string
@@ -99,7 +100,7 @@
{/if}
</label>
{#if enabled}
<div class="p-4 rounded border mb-4 flex flex-col gap-6">
<SettingCard class="mb-4 flex flex-col gap-6">
{#if name != 'slack' && name != 'teams'}
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
@@ -274,7 +275,7 @@
</div>
</CollapseLink>
{/if}
</div>
</SettingCard>
{/if}
</div>
@@ -7,6 +7,8 @@
import TestConnection from './TestConnection.svelte'
import { enterpriseLicense } from '$lib/stores'
import SimpleEditor from './SimpleEditor.svelte'
import Label from './Label.svelte'
import TextInput from './text_input/TextInput.svelte'
type S3Config = {
type: 'S3'
@@ -39,16 +41,24 @@
type GcsConfig = {
type: 'Gcs'
bucket: string
serviceAccountKey: Record<string, string>
serviceAccountKey: Record<string, string> | undefined
}
export let bucket_config: S3Config | AzureConfig | AwsOidcConfig | GcsConfig | undefined =
undefined
interface Props {
bucket_config?: S3Config | AzureConfig | AwsOidcConfig | GcsConfig | undefined
}
$: bucket_config?.type == 'S3' &&
bucket_config.allow_http == undefined &&
(bucket_config.allow_http = true)
let loading = false
let {
bucket_config = $bindable<S3Config | AzureConfig | AwsOidcConfig | GcsConfig | undefined>(
undefined
)
}: Props = $props()
let effectiveAllowHttp = $derived(
bucket_config?.type === 'S3' ? (bucket_config.allow_http ?? true) : false
)
let loading = $state(false)
async function testConnection() {
loading = true
@@ -65,6 +75,27 @@
loading = false
}
}
let simpleEditor: SimpleEditor | undefined = $state(undefined)
let serviceAccountKeyCode = $state(
bucket_config?.type === 'Gcs'
? JSON.stringify(bucket_config.serviceAccountKey, null, '\t')
: '{}'
)
let lastEditorSyncedJson =
bucket_config?.type === 'Gcs' ? JSON.stringify(bucket_config.serviceAccountKey) : '{}'
$effect(() => {
if (bucket_config?.type === 'Gcs') {
const configJson = JSON.stringify(bucket_config.serviceAccountKey)
if (configJson !== lastEditorSyncedJson) {
lastEditorSyncedJson = configJson
const formatted = JSON.stringify(bucket_config.serviceAccountKey, null, '\t')
serviceAccountKeyCode = formatted
simpleEditor?.setCode(formatted)
}
}
})
</script>
<div class="my-0.5">
@@ -80,7 +111,8 @@
region: '',
access_key: '',
secret_key: '',
endpoint: ''
endpoint: '',
allow_http: true
}
} else {
bucket_config = undefined
@@ -89,7 +121,7 @@
/>
</div>
{#if bucket_config}
<div class="p-2">
<div class="">
<div class="flex gap-2 py-1">
<Button
spacingSize="sm"
@@ -123,7 +155,8 @@
region: '',
access_key: '',
secret_key: '',
endpoint: ''
endpoint: '',
allow_http: true
}
} else if (e.detail === 'Azure' && bucket_config?.type !== 'Azure') {
bucket_config = {
@@ -156,11 +189,17 @@
<Tab value="AwsOidc" label="AWS OIDC" />
<Tab value="Gcs" label="Google Cloud Storage" />
</Tabs>
<div class="flex flex-col gap-2 mt-2 p-2 border rounded-md">
<div class="flex flex-col gap-6 mt-2 p-4 border rounded-md">
{#if bucket_config.type === 'S3'}
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Bucket</span>
<input type="text" placeholder="bucket-name" bind:value={bucket_config.bucket} />
<TextInput
inputProps={{ placeholder: 'bucket-name' }}
bind:value={
() => (bucket_config as S3Config).bucket,
(v) => (bucket_config = { ...(bucket_config as S3Config), bucket: v })
}
/>
</label>
<label class="block pb-2">
@@ -168,7 +207,12 @@
<span class="text-primary text-2xs"
>If left empty, will be derived automatically from $AWS_REGION</span
>
<input type="text" bind:value={bucket_config.region} />
<TextInput
bind:value={
() => (bucket_config as S3Config).region,
(v) => (bucket_config = { ...(bucket_config as S3Config), region: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Access key ID</span>
@@ -176,17 +220,24 @@
>If left empty, will be derived automatically from $AWS_ACCESS_KEY_ID, pod or ec2
profile</span
>
<input type="text" bind:value={bucket_config.access_key} />
<TextInput
bind:value={
() => (bucket_config as S3Config).access_key,
(v) => (bucket_config = { ...(bucket_config as S3Config), access_key: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Secret key</span>
<span class="text-primary text-2xs"
>If left empty, will be derived automatically from $AWS_SECRET_KEY, pod or ec2 profile</span
>
<input
type="password"
autocomplete="new-password"
bind:value={bucket_config.secret_key}
<TextInput
inputProps={{ type: 'password', autocomplete: 'new-password' }}
bind:value={
() => (bucket_config as S3Config).secret_key,
(v) => (bucket_config = { ...(bucket_config as S3Config), secret_key: v })
}
/>
</label>
<label class="block pb-2">
@@ -194,42 +245,79 @@
<span class="text-primary text-2xs"
>Only needed for non AWS S3 providers like R2 or MinIo</span
>
<input type="text" bind:value={bucket_config.endpoint} />
<TextInput
bind:value={
() => (bucket_config as S3Config).endpoint,
(v) => (bucket_config = { ...(bucket_config as S3Config), endpoint: v })
}
/>
</label>
<div class="block pb-2">
<span class="text-primary text-2xs">Disable if using https only policy</span>
<div>
<Toggle bind:checked={bucket_config.allow_http} options={{ right: 'Allow http' }} />
<Toggle
checked={effectiveAllowHttp}
on:change={(e) => {
if (bucket_config?.type === 'S3') {
bucket_config = { ...bucket_config, allow_http: e.detail }
}
}}
options={{ right: 'Allow http' }}
/>
</div>
</div>
{:else if bucket_config.type === 'Azure'}
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Account name</span>
<input type="text" placeholder="account-name" bind:value={bucket_config.accountName} />
<TextInput
inputProps={{ placeholder: 'account-name' }}
bind:value={
() => (bucket_config as AzureConfig).accountName,
(v) => (bucket_config = { ...(bucket_config as AzureConfig), accountName: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Container name</span>
<input
type="text"
placeholder="container-name"
bind:value={bucket_config.containerName}
<TextInput
inputProps={{ placeholder: 'container-name' }}
bind:value={
() => (bucket_config as AzureConfig).containerName,
(v) => (bucket_config = { ...(bucket_config as AzureConfig), containerName: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Access key</span>
<input type="password" autocomplete="new-password" bind:value={bucket_config.accessKey} />
<TextInput
inputProps={{ type: 'password', autocomplete: 'new-password' }}
bind:value={
() => (bucket_config as AzureConfig).accessKey,
(v) => (bucket_config = { ...(bucket_config as AzureConfig), accessKey: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis"
>Tenant ID <span class="text-2xs text-primary">(optional)</span></span
>
<input type="text" bind:value={bucket_config.tenantId} />
<TextInput
bind:value={
() => (bucket_config as AzureConfig).tenantId,
(v) => (bucket_config = { ...(bucket_config as AzureConfig), tenantId: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis"
>Client ID <span class="text-2xs text-primary">(optional)</span></span
>
<input type="text" bind:value={bucket_config.clientId} />
<TextInput
bind:value={
() => (bucket_config as AzureConfig).clientId,
(v) => (bucket_config = { ...(bucket_config as AzureConfig), clientId: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis"
@@ -238,56 +326,78 @@
<span class="text-primary text-2xs"
>Only needed for non Azure Blob providers like Azurite</span
>
<input type="text" bind:value={bucket_config.endpoint} />
<TextInput
bind:value={
() => (bucket_config as AzureConfig).endpoint,
(v) => (bucket_config = { ...(bucket_config as AzureConfig), endpoint: v })
}
/>
</label>
{:else if bucket_config.type === 'AwsOidc'}
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Bucket</span>
<input type="text" placeholder="bucket-name" bind:value={bucket_config.bucket} />
<TextInput
inputProps={{ placeholder: 'bucket-name' }}
bind:value={
() => (bucket_config as AwsOidcConfig).bucket,
(v) => (bucket_config = { ...(bucket_config as AwsOidcConfig), bucket: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Region</span>
<input type="text" placeholder="region" bind:value={bucket_config.region} />
<TextInput
inputProps={{ placeholder: 'region' }}
bind:value={
() => (bucket_config as AwsOidcConfig).region,
(v) => (bucket_config = { ...(bucket_config as AwsOidcConfig), region: v })
}
/>
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Role ARN</span>
<input
type="text"
placeholder="arn:aws:iam::123456789012:role/test"
bind:value={bucket_config.roleArn}
<TextInput
inputProps={{ placeholder: 'arn:aws:iam::123456789012:role/test' }}
bind:value={
() => (bucket_config as AwsOidcConfig).roleArn,
(v) => (bucket_config = { ...(bucket_config as AwsOidcConfig), roleArn: v })
}
/>
</label>
{:else if bucket_config.type === 'Gcs'}
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Bucket</span>
<input type="text" placeholder="bucket-name" bind:value={bucket_config.bucket} />
</label>
<label class="block pb-2">
<span class="text-xs font-semibold text-emphasis">Service Account Key</span>
<Label label="Bucket">
<TextInput
inputProps={{ placeholder: 'bucket-name' }}
bind:value={
() => (bucket_config as GcsConfig).bucket,
(v) => (bucket_config = { ...(bucket_config as GcsConfig), bucket: v })
}
/>
</Label>
<Label label="Service Account Key">
<span class="text-primary text-2xs">JSON content of the service account key file</span>
<SimpleEditor
bind:this={simpleEditor}
lang="json"
bind:code={
() => {
if (bucket_config?.type === 'Gcs') {
return JSON.stringify(bucket_config.serviceAccountKey)
} else {
return '{}'
bind:code={serviceAccountKeyCode}
on:change={(e) => {
if (bucket_config?.type === 'Gcs') {
if (e.detail.code === undefined || e.detail.code === '') {
bucket_config = { ...bucket_config, serviceAccountKey: undefined }
return
}
},
(v) => {
if (bucket_config?.type === 'Gcs') {
try {
bucket_config.serviceAccountKey = JSON.parse(v ?? '{}')
} catch (_) {
bucket_config.serviceAccountKey = {}
}
try {
const parsed = JSON.parse(e.detail.code ?? '{}')
lastEditorSyncedJson = JSON.stringify(parsed)
bucket_config = { ...bucket_config, serviceAccountKey: parsed }
} catch (_) {
bucket_config = { ...bucket_config, serviceAccountKey: undefined }
}
}
}
}}
class="h-80"
/>
</label>
</Label>
{:else}
<div>Unknown bucket type {bucket_config['type']}</div>
{/if}
@@ -5,6 +5,7 @@
import Toggle from './Toggle.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
value: any
@@ -57,7 +58,7 @@
/></label
>
{#if enabled}
<div class="p-4 rounded border flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label>
<div class="flex gap-2 items-start">
<div>
@@ -155,6 +156,6 @@
</div>
</div>
</CollapseLink>
</div>
</SettingCard>
{/if}
</div>
@@ -3,6 +3,7 @@
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
let { value = $bindable() }: { value: any } = $props()
@@ -52,7 +53,7 @@
<Toggle checked={enabled} on:change={handleToggle} />
</label>
{#if enabled}
<div class="border rounded p-4 flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Pocket ID Url</span>
<span class="text-secondary font-normal text-xs">POCKET_ID_URL/authorize</span>
@@ -82,6 +83,6 @@
bind:value={value['secret']}
/>
</label>
</div>
</SettingCard>
{/if}
</div>
@@ -1,6 +1,13 @@
<script lang="ts">
import { Drawer, DrawerContent } from '$lib/components/common'
import { Drawer, DrawerContent, Button } from '$lib/components/common'
import SuperadminSettingsInner from './SuperadminSettingsInner.svelte'
import Version from './Version.svelte'
import MeltTooltip from './meltComponents/Tooltip.svelte'
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
import { ExternalLink } from 'lucide-svelte'
import { SettingsService } from '$lib/gen'
import { isCloudHosted } from '$lib/cloud'
interface Props {
disableChatOffset?: boolean
@@ -9,6 +16,18 @@
let { disableChatOffset = false }: Props = $props()
let drawer: Drawer | undefined = $state()
let uptodateVersion: string | undefined = $state(undefined)
async function loadUptodate() {
try {
const res = await SettingsService.backendUptodate()
if (res != 'yes') {
const parts = res.split(' -> ')
uptodateVersion = parts.length > 1 ? parts[parts.length - 1] : res
}
} catch {}
}
loadUptodate()
export function openDrawer() {
drawer?.openDrawer()
@@ -19,8 +38,39 @@
}
</script>
<Drawer bind:this={drawer} size="1000px" {disableChatOffset}>
<DrawerContent overflow_y={true} title="Instance settings" on:close={closeDrawer}>
<SuperadminSettingsInner {closeDrawer} />
<Drawer bind:this={drawer} size="1200px" {disableChatOffset}>
<DrawerContent noPadding overflow_y={false} title="Instance settings" on:close={closeDrawer}>
{#snippet actions()}
<MeltTooltip disablePopup={!uptodateVersion}>
<div class="text-xs text-secondary flex items-center gap-1">
Windmill <Version />
{#if uptodateVersion}
<span class="text-accent">{uptodateVersion}</span>
{/if}
</div>
<svelte:fragment slot="text">
{#if isCloudHosted()}
The cloud version is updated daily.
{:else}
How to update?<br />
- docker: <code>docker compose up -d</code><br />
- <a href="https://github.com/windmill-labs/windmill-helm-charts#install">helm</a>
{/if}
</svelte:fragment>
</MeltTooltip>
{#if $workspaceStore !== 'admins'}
<Button
variant="default"
size="xs"
target="_blank"
href="{base}/?workspace=admins"
endIcon={{ icon: ExternalLink }}
wrapperClasses="ml-2"
>
Admins workspace
</Button>
{/if}
{/snippet}
<SuperadminSettingsInner {closeDrawer} showHeaderInfo={false} />
</DrawerContent>
</Drawer>
@@ -1,8 +1,10 @@
<script lang="ts">
import { UserService, type GlobalUserInfo, SettingService } from '$lib/gen'
import TableCustom from '$lib/components/TableCustom.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import InviteGlobalUser from '$lib/components/InviteGlobalUser.svelte'
import { Button, Tab, Tabs } from '$lib/components/common'
import { Button } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import SearchItems from './SearchItems.svelte'
@@ -10,14 +12,14 @@
import { goto as gotoUrl } from '$app/navigation'
import Version from './Version.svelte'
import Uptodate from './Uptodate.svelte'
import TabContent from './common/tabs/TabContent.svelte'
import InstanceSettings from './InstanceSettings.svelte'
import { truncate } from '$lib/utils'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { ExternalLink } from 'lucide-svelte'
import { settingsKeys } from './instanceSettings'
import { ExternalLink, Pencil, UserMinus, UserPlus } from 'lucide-svelte'
import DropdownV2 from './DropdownV2.svelte'
import Popover from './meltComponents/Popover.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import ChangeInstanceUsername from './ChangeInstanceUsername.svelte'
import { isCloudHosted } from '$lib/cloud'
@@ -25,10 +27,19 @@
import Toggle from './Toggle.svelte'
import { instanceSettingsSelectedTab } from '$lib/stores'
import { onDestroy } from 'svelte'
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
import {
instanceSettingsNavigationGroups,
tabToCategoryMap,
tabToAuthSubTab,
categoryToTabMap
} from './instanceSettings'
import TextInput from './text_input/TextInput.svelte'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
let filter = $state('')
let { closeDrawer } = $props()
let { closeDrawer, showHeaderInfo = true } = $props()
function removeHash() {
const index = $page.url.href.lastIndexOf('#')
@@ -44,6 +55,8 @@
let users: GlobalUserInfo[] = $state([])
let filteredUsers: GlobalUserInfo[] = $state([])
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
let deleteUserEmail: string = $state('')
let editWrappers: Record<string, HTMLDivElement> = $state({})
let activeOnly = $state(false)
async function listUsers(activeOnly: boolean): Promise<void> {
@@ -54,7 +67,7 @@
listUsers(activeOnly)
})
let tab: 'users' | string = $state('users')
let tab: string = $state('users')
$effect(() => {
tab = $instanceSettingsSelectedTab
@@ -98,6 +111,31 @@
sendUserToast('Error updating user', true)
}
}
// The category name for InstanceSettings based on current sidebar tab
let instanceSettingsCategory = $derived(tabToCategoryMap[tab] ?? 'Core')
let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[tab] ?? 'sso')
function getCategoryForTab(tabId: string): string | undefined {
return tabToCategoryMap[tabId]
}
// --- Tab change interception for unsaved changes ---
let pendingTab: string | undefined = $state(undefined)
let showUnsavedChangesModal = $state(false)
function handleNavigate(newTab: string) {
if (newTab === tab) return
// Check if current tab (if it's a settings tab) has unsaved changes
const currentCategory = getCategoryForTab(tab)
if (currentCategory && instanceSettings?.isDirty(currentCategory)) {
pendingTab = newTab
showUnsavedChangesModal = true
} else {
tab = newTab
}
}
</script>
<SearchItems
@@ -108,135 +146,162 @@
/>
<div class="flex flex-col h-full w-full">
<div>
<div class="flex justify-between">
<div class="text-xs pt-1 text-primary flex flex-col">
<div>Windmill <Version /></div>
</div>
<div><Uptodate /></div></div
>
</div>
{#if $workspaceStore !== 'admins'}
<div class="flex flex-row-reverse">
<Button
variant="default"
target="_blank"
href="{base}/?workspace=admins"
endIcon={{ icon: ExternalLink }}
{#if showHeaderInfo}
<div>
<div class="flex justify-between">
<div class="text-xs pt-1 text-secondary flex flex-col">
<div>Windmill <Version /></div>
</div>
<div><Uptodate /></div></div
>
Admins workspace
</Button>
</div>
{#if $workspaceStore !== 'admins'}
<div class="flex flex-row-reverse">
<Button
variant="default"
target="_blank"
href="{base}/?workspace=admins"
endIcon={{ icon: ExternalLink }}
>
Admins workspace
</Button>
</div>
{/if}
{/if}
<div class="pt-4 h-full">
<Tabs bind:selected={tab}>
<Tab
value="users"
aiId="instance-settings-users"
aiDescription="Instance users settings"
label="Users"
<div class="{showHeaderInfo ? 'pt-4' : ''} flex grow min-h-0">
<!-- Sidebar Navigation -->
<div class="w-52 shrink-0 h-full overflow-auto p-4 bg-surface">
<SidebarNavigation
groups={instanceSettingsNavigationGroups}
selectedId={tab}
onNavigate={handleNavigate}
/>
</div>
{#each settingsKeys as category}
<Tab
value={category}
aiId={`instance-settings-${category}`}
aiDescription={`Instance ${category} settings`}
label={category}
/>
{/each}
{#snippet content()}
<div class="pt-4"></div>
<TabContent value="users">
<div class="h-full">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
<h3 class="mb-2"> Automatic username creation </h3>
<div class="mb-2">
<span class="text-primary text-sm"
>Automatically create a username for new users based on their email, shared
across workspaces. <a
target="_blank"
href="https://www.windmill.dev/docs/advanced/instance_settings#automatic-username-creation"
>Learn more</a
></span
<!-- Main Content -->
<div class="flex-1 min-w-0 h-full">
<div class="h-full overflow-auto bg-surface">
<div class="h-fit px-8 py-4">
{#if tab === 'users'}
<div class="h-full">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
<h3 class="mb-2"> Automatic username creation </h3>
<div class="mb-2">
<span class="text-primary text-sm"
>Automatically create a username for new users based on their email, shared
across workspaces. <a
target="_blank"
href="https://www.windmill.dev/docs/advanced/instance_settings#automatic-username-creation"
>Learn more</a
></span
>
</div>
<Button
btnClasses="w-auto"
size="sm"
variant="accent"
on:click={() => {
automateUsernameModalOpen = true
}}
>
Enable (recommended)
</Button>
<ConfirmationModal
open={automateUsernameModalOpen}
on:confirmed={() => {
automateUsernameModalOpen = false
enableAutomateUsernameCreationSetting()
}}
on:canceled={() => (automateUsernameModalOpen = false)}
title="Automatic username creation"
confirmationText="Enable"
>
Once activated, it will not be possible to disable this feature. In case
existing users have different usernames in different workspaces, you will have
to manually confirm the username for each user.
</ConfirmationModal>
</div>
<Button
btnClasses="w-auto"
size="sm"
variant="accent"
on:click={() => {
automateUsernameModalOpen = true
}}
>
Enable (recommended)
</Button>
<ConfirmationModal
open={automateUsernameModalOpen}
on:confirmed={() => {
automateUsernameModalOpen = false
enableAutomateUsernameCreationSetting()
}}
on:canceled={() => (automateUsernameModalOpen = false)}
title="Automatic username creation"
confirmationText="Enable"
>
Once activated, it will not be possible to disable this feature. In case existing
users have different usernames in different workspaces, you will have to manually
confirm the username for each user.
</ConfirmationModal>
</div>
{/if}
{/if}
<div class="py-2 mb-6">
<InviteGlobalUser on:new={() => listUsers(activeOnly)} />
</div>
<div class="flex flex-row justify-between">
<h3 class="text-sm font-semibold text-emphasis">All instance users</h3>
<Toggle
bind:checked={activeOnly}
options={{
left: 'Show active users only',
leftTooltip:
'An active user is a user who has performed at least one action in the last 30 days'
}}
<SettingsPageHeader
title="Instance users ({users.length})"
description="Manage all users across your Windmill instance."
link="https://www.windmill.dev/docs/advanced/instance_settings#global-users"
/>
</div>
<div class="pb-1"></div>
<div>
<input placeholder="Search users" bind:value={filter} class="input mt-1" />
</div>
<div class="mt-2 overflow-auto">
<TableCustom>
<!-- @migration-task: migrate this slot by hand, `header-row` is an invalid identifier -->
<tr slot="header-row" class="sticky top-0 bg-surface border-b">
<th>email</th>
<th>auth</th>
<th>name</th>
{#if automateUsernameCreation}
<th>username</th>
{/if}
{#if activeOnly}
<th>kind</th>
{/if}
<th></th>
<th></th>
</tr>
{#snippet body()}
<tbody class="overflow-y-auto w-full h-full max-h-full">
{#if filteredUsers && users}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only } (email)}
<tr class="border">
<td>{email}</td>
<td>{login_type}</td>
<td><span class="break-words">{truncate(name ?? '', 30)}</span></td>
<div class="flex flex-row gap-2 items-center">
<TextInput
inputProps={{ placeholder: 'Search users' }}
bind:value={filter}
class="w-60"
/><Toggle
bind:checked={activeOnly}
options={{
left: 'Show active users only',
leftTooltip:
'An active user is a user who has performed at least one action in the last 30 days'
}}
/>
<div class="flex-1"></div>
<Popover placement="bottom-end" disableFocusTrap closeButton>
{#snippet trigger()}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: UserPlus }}
nonCaptureEvent
wrapperClasses="w-fit shrink-0"
>
Add new user
</Button>
{/snippet}
{#snippet content()}
<InviteGlobalUser on:new={() => listUsers(activeOnly)} />
{/snippet}
</Popover>
</div>
<p class="text-hint text-2xs mt-2">
{filteredUsers.length} user{filteredUsers.length !== 1 ? 's' : ''} found
</p>
<div class="mt-1">
<DataTable
shouldLoadMore={(filteredUsers?.length ?? 0) > 50}
loadMore={50}
on:loadMore={() => {
nbDisplayed += 50
}}
>
<Head>
<tr>
<Cell head first>Email</Cell>
{#if automateUsernameCreation}
<Cell head>Username</Cell>
{/if}
<Cell head>Name</Cell>
<Cell head>Auth</Cell>
{#if activeOnly}
<Cell head>Kind</Cell>
{/if}
<Cell head>Role</Cell>
<Cell head last>
<span class="sr-only">Actions</span>
</Cell>
</tr>
</Head>
<tbody>
{#if filteredUsers && users}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only }, i (email)}
<tr class={i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'}>
<Cell first class="max-w-[200px]"
><a href="mailto:{email}" title={email} class="truncate block"
>{email}</a
></Cell
>
{#if automateUsernameCreation}
<td>
<Cell class="max-w-[150px]">
{#if username}
{username}
<span title={username} class="truncate block">{username}</span>
{:else}
{#key filteredUsers.map((u) => u.username).join()}
<ChangeInstanceUsername
@@ -249,18 +314,27 @@
/>
{/key}
{/if}
</td>
</Cell>
{/if}
<Cell class="max-w-[150px]"
><span title={name ?? ''} class="truncate block"
>{truncate(name ?? '', 30)}</span
></Cell
>
<Cell class="max-w-[100px]"
><span title={login_type} class="truncate block">{login_type}</span
></Cell
>
{#if activeOnly}
<td>
<Cell>
{#if operator_only}
Operator only
{:else}
Developer
{/if}
</td>
</Cell>
{/if}
<td>
<Cell>
<ToggleButtonGroup
selected={super_admin ? 'super_admin' : devops ? 'devops' : 'user'}
on:selected={async (e) => {
@@ -320,76 +394,111 @@
/>
{/snippet}
</ToggleButtonGroup>
</td>
<td>
<div class="flex flex-row gap-x-1 justify-end">
<InstanceNameEditor
{login_type}
value={name}
{username}
{email}
on:refresh={() => {
listUsers(activeOnly)
}}
on:save={(e) => {
updateName(e.detail, email)
}}
on:renamed={() => {
listUsers(activeOnly)
}}
{automateUsernameCreation}
/>
<Button
color="light"
variant="contained"
size="xs"
spacingSize="xs2"
btnClasses="text-red-500"
on:click={() => {
deleteConfirmedCallback = async () => {
await UserService.globalUserDelete({ email })
sendUserToast(`User ${email} removed`)
</Cell>
<Cell last>
<div class="flex items-center justify-end">
<div bind:this={editWrappers[email]} class="w-0 h-0 overflow-hidden">
<InstanceNameEditor
{login_type}
value={name}
{username}
{email}
on:refresh={() => {
listUsers(activeOnly)
}}
on:save={(e) => {
updateName(e.detail, email)
}}
on:renamed={() => {
listUsers(activeOnly)
}}
{automateUsernameCreation}
/>
</div>
<DropdownV2
items={[
{
displayName: 'Edit',
icon: Pencil,
action: () => {
const btn = editWrappers[email]?.querySelector(
'[aria-label="Popup button"]'
)
if (btn instanceof HTMLElement) btn.click()
}
},
{
displayName: 'Remove',
icon: UserMinus,
type: 'delete',
action: () => {
deleteUserEmail = email
deleteConfirmedCallback = async () => {
await UserService.globalUserDelete({
email
})
sendUserToast(`User ${email} removed`)
listUsers(activeOnly)
}
}
}
}}
>
Remove
</Button>
]}
/>
</div>
</td>
</Cell>
</tr>
{/each}
{/if}
</tbody>
{/snippet}
</TableCustom>
</DataTable>
</div>
</div>
{#if filteredUsers && filteredUsers?.length > 50 && nbDisplayed < filteredUsers.length}
<span class="text-xs"
>{nbDisplayed} users out of {filteredUsers.length}
<button class="ml-4" onclick={() => (nbDisplayed += 50)}>load 50 more</button></span
>
{/if}
</div>
</TabContent>
<TabContent value="" values={settingsKeys}>
<InstanceSettings bind:this={instanceSettings} hideTabs hideSave bind:tab {closeDrawer} />
</TabContent>
{/snippet}
</Tabs>
{:else}
<InstanceSettings
bind:this={instanceSettings}
hideTabs
tab={instanceSettingsCategory}
{authSubTab}
{closeDrawer}
onNavigateToTab={(category) => {
const targetTab = categoryToTabMap[category]
if (targetTab) {
handleNavigate(targetTab)
}
}}
/>
{/if}
</div>
</div>
</div>
</div>
</div>
{#if tab != 'users'}
<div class="absolute bottom-2 w-[95%] z-10">
<Button
variant="accent"
on:click={() => {
instanceSettings?.saveSettings()
}}
>
Save
</Button>
</div>{/if}
{#if showUnsavedChangesModal}
<ConfirmationModal
open={showUnsavedChangesModal}
title="Unsaved changes detected"
confirmationText="Discard changes"
on:canceled={() => {
showUnsavedChangesModal = false
pendingTab = undefined
}}
on:confirmed={() => {
if (pendingTab !== undefined) {
const currentCategory = getCategoryForTab(tab)
if (currentCategory) {
instanceSettings?.discardCategory(currentCategory)
}
tab = pendingTab
}
showUnsavedChangesModal = false
pendingTab = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>You have unsaved changes. Are you sure you want to discard them?</span>
</div>
</ConfirmationModal>
{/if}
<ConfirmationModal
open={Boolean(deleteConfirmedCallback)}
@@ -406,6 +515,6 @@
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove ?</span>
<span>Are you sure you want to remove <b>{deleteUserEmail}</b>?</span>
</div>
</ConfirmationModal>
+3 -2
View File
@@ -9,7 +9,8 @@
try {
const res = await SettingsService.backendUptodate()
if (res != 'yes') {
uptodate = res
const parts = res.split(' -> ')
uptodate = parts.length > 1 ? parts[parts.length - 1] : res
}
} catch (e) {
console.warn('Could not fetch latest version', e)
@@ -21,7 +22,7 @@
{#if uptodate}
<span class="text-accent text-xs">
{uptodate} &nbsp;
{uptodate} &nbsp;
<Tooltip>
{#if isCloudHosted()}
The cloud version is updated daily.
@@ -2,6 +2,7 @@
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
export let value: any
@@ -46,7 +47,7 @@
/></label
>
{#if enabled}
<div class="border rounded p-4 flex flex-col gap-6">
<SettingCard class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Zitadel Url</span>
<span class="text-secondary font-normal text-xs">{'ZITADEL_URL/oauth/v2/authorize'}</span>
@@ -73,6 +74,6 @@
bind:value={value['secret']}
/>
</label>
</div>
</SettingCard>
{/if}
</div>
@@ -1,27 +1,35 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import type { Snippet } from 'svelte'
import Button from '../button/Button.svelte'
export let items: string[]
export let selectedIndex: number
export let disabled: boolean = false
const dispatch = createEventDispatcher()
let {
items,
selectedIndex,
numbered = false,
separator,
onselect
}: {
items: string[]
selectedIndex: number
numbered?: boolean
separator?: Snippet
onselect?: (index: number) => void
} = $props()
</script>
<div class="flex items-center justify-center">
{#each items as item, index}
{#if index > 0}
<slot name="separator" />
{#if index > 0 && separator}
{@render separator()}
{/if}
<Button
size="sm"
color="light"
btnClasses={selectedIndex - 1 === index ? 'text-gray-800 !font-bold' : '!text-primary'}
on:click={() => dispatch('select', { index })}
disabled={selectedIndex - 1 === index ? disabled : false}
unifiedSize="sm"
variant="subtle"
selected={selectedIndex - 1 === index}
onClick={() => onselect?.(index)}
disabled={index > selectedIndex - 1}
>
{item}
{numbered ? `${index + 1}. ` : ''}{item}
</Button>
{/each}
</div>
@@ -86,7 +86,7 @@
<div
class={classNames(
noPadding ? '' : 'p-4',
'grow h-full max-h-full',
'grow min-h-0 max-h-full',
forceOverflowVisible ? '!overflow-visible' : ''
)}
class:overflow-y-auto={overflow_y}
@@ -24,7 +24,7 @@
</script>
<Button
size="xs"
unifiedSize="md"
variant="default"
{disabled}
{loading}
@@ -539,7 +539,7 @@
{#if shouldShowEmptyState}
<!-- Empty State for Primary Variants -->
<div class="rounded-lg border bg-surface p-4 mb-4">
<div class="rounded-md shadow-sm bg-surface-tertiary p-4 mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="text-xs font-semibold text-emphasis">{displayTitle}</h3>
@@ -580,7 +580,7 @@
{:else if repo}
{#if variant === 'primary-sync' || variant === 'primary-promotion'}
<!-- Primary Repository Layout -->
<div class="rounded-lg border bg-surface p-4 mb-4">
<div class="rounded-md shadow-sm bg-surface-tertiary p-4 mb-4">
<div class="flex flex-col mb-4 gap-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{displayTitle}</h3>
@@ -597,7 +597,7 @@
</div>
{:else}
<!-- Standard Repository Card Layout -->
<div class="rounded-lg shadow-sm border p-0 w-full mb-4">
<div class="rounded-md shadow-sm bg-surface-tertiary p-0 w-full mb-4">
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
<div class="flex items-center gap-2">
<span class="text-lg font-semibold">{displayTitle}</span>
@@ -56,7 +56,20 @@
title="Git Sync"
description="Connect the Windmill workspace to a Git repository to automatically commit and push scripts, flows, and apps to the repository on each deploy."
link="https://www.windmill.dev/docs/advanced/git_sync"
/>
>
{#snippet actions()}
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
<Button
variant="accent"
target="_blank"
endIcon={{ icon: ExternalLink }}
href={`/runs?job_kinds=deploymentcallbacks&workspace=${$workspaceStore}`}
>
See sync jobs
</Button>
{/if}
{/snippet}
</SettingsPageHeader>
<Alert type="info" title="Only new updates trigger git sync">
Only new changes matching the filters will trigger a git sync. You still need to initialize the
repo to the desired state first.
@@ -70,20 +83,8 @@
<div class="mb-2"></div>
{/if}
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
<div class="flex mt-5 mb-5 gap-8">
<Button
variant="accent"
target="_blank"
endIcon={{ icon: ExternalLink }}
href={`/runs?job_kinds=deploymentcallbacks&workspace=${$workspaceStore}`}
>
See sync jobs
</Button>
</div>
<div class="pt-2"></div>
<!-- Primary Sync Repository -->
<div class="space-y-4">
<div class="space-y-6 pt-6">
<GitSyncRepositoryCard
variant="primary-sync"
mode="sync"
+271 -60
View File
@@ -1,4 +1,5 @@
import type { ButtonType } from './common/button/model'
import { z } from 'zod'
// Languages that support HTTP request tracing via OTEL proxy
export const OTEL_TRACING_PROXY_LANGUAGES = [
@@ -59,8 +60,10 @@ export interface Setting {
hiddenIfNull?: boolean
hiddenIfEmpty?: boolean
hiddenInEe?: boolean
hideInQuickSetup?: boolean
requiresReloadOnChange?: boolean
isValid?: (value: any) => boolean
validate?: (value: any) => Record<string, string>
error?: string
defaultValue?: () => any
codeAreaLang?: string
@@ -73,6 +76,31 @@ export interface Setting {
export type SettingStorage = 'setting'
const positiveNumber = z.number().positive('Must be a positive number')
const indexerSettingsSchema = z
.object({
writer_memory_budget: positiveNumber.optional(),
commit_job_max_batch_size: positiveNumber.optional(),
refresh_index_period: positiveNumber.optional(),
max_indexed_job_log_size: positiveNumber.optional(),
commit_log_max_batch_size: positiveNumber.optional(),
refresh_log_index_period: positiveNumber.optional()
})
.passthrough()
function validateIndexerSettings(v: any): Record<string, string> {
if (!v) return {}
const result = indexerSettingsSchema.safeParse(v)
if (result.success) return {}
const errors: Record<string, string> = {}
for (const issue of result.error.issues) {
const field = issue.path[0]?.toString()
if (field) errors[field] = issue.message
}
return errors
}
export const scimSamlSetting: Setting[] = [
{
label: 'SCIM token',
@@ -119,7 +147,11 @@ export const settings: Record<string, Setting[]> = {
key: 'email_domain',
fieldType: 'text',
storage: 'setting',
placeholder: 'mail.windmill.com'
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',
@@ -130,32 +162,6 @@ export const settings: Record<string, Setting[]> = {
placeholder: '50',
storage: 'setting'
},
{
label: 'Default timeout',
key: 'job_default_timeout',
description:
'Default timeout for individual jobs. <a href="https://www.windmill.dev/docs/core_concepts/jobs#retention-policy">Learn more</a>',
fieldType: 'seconds',
storage: 'setting',
cloudonly: false
},
{
label: 'Keep job directories for debug',
key: 'keep_job_dir',
fieldType: 'boolean',
description: 'Keep Job directories after execution at /tmp/windmill/WORKER/JOB_ID',
storage: 'setting'
},
{
label: 'Max timeout for sync endpoints',
description:
'Maximum amount of time (measured in seconds) that a <a href="https://www.windmill.dev/docs/core_concepts/webhooks">sync endpoint</a> is allowed to run before it is forcibly stopped or timed out.',
key: 'timeout_wait_result',
cloudonly: true,
fieldType: 'seconds',
placeholder: '60',
storage: 'setting'
},
{
label: 'License key',
description:
@@ -172,7 +178,46 @@ export const settings: Record<string, Setting[]> = {
key: 'dev_instance',
fieldType: 'boolean',
storage: 'setting',
ee_only: ''
ee_only: '',
hideInQuickSetup: true
},
{
label: 'App workspace prefix',
description:
'When enabled apps will be accessible at /a/{workspace_id}/{custom_path} instead of /a/{custom_path} allowing you to define same custom path for apps in different workspace without conflict',
key: 'app_workspaced_route',
fieldType: 'boolean',
storage: 'setting',
ee_only: '',
hideInQuickSetup: true
}
],
Jobs: [
{
label: 'Default timeout',
key: 'job_default_timeout',
description:
'Default timeout for individual jobs. <a href="https://www.windmill.dev/docs/core_concepts/jobs#retention-policy">Learn more</a>',
fieldType: 'seconds',
storage: 'setting',
cloudonly: false
},
{
label: 'Max timeout for sync endpoints',
description:
'Maximum amount of time (measured in seconds) that a <a href="https://www.windmill.dev/docs/core_concepts/webhooks">sync endpoint</a> is allowed to run before it is forcibly stopped or timed out.',
key: 'timeout_wait_result',
cloudonly: true,
fieldType: 'seconds',
placeholder: '60',
storage: 'setting'
},
{
label: 'Keep job directories for debug',
key: 'keep_job_dir',
fieldType: 'boolean',
description: 'Keep Job directories after execution at /tmp/windmill/WORKER/JOB_ID',
storage: 'setting'
},
{
label: 'Retention period in secs',
@@ -184,6 +229,21 @@ export const settings: Record<string, Setting[]> = {
storage: 'setting',
ee_only: 'You can only adjust this setting to above 30 days in the EE version',
cloudonly: false
}
],
'Object Storage': [
{
label: 'Instance object storage',
description:
' S3/Azure bucket to store large logs and global cache for Python and Go. <a href="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#instance-object-storage">Learn more</a>',
key: 'object_store_cache_config',
fieldType: 'object_store_config',
storage: 'setting',
ee_only: '',
isValid: (v) => {
if (!v || v.type !== 'Gcs') return true
return v.serviceAccountKey !== undefined
}
},
{
label: 'Delete logs from s3 periodically',
@@ -193,28 +253,9 @@ export const settings: Record<string, Setting[]> = {
fieldType: 'boolean',
storage: 'setting',
ee_only: ''
},
{
label: 'Instance object storage',
description:
' S3/Azure bucket to store large logs and global cache for Python and Go. <a href="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#instance-object-storage">Learn more</a>',
key: 'object_store_cache_config',
fieldType: 'object_store_config',
storage: 'setting',
ee_only: ''
},
{
label: 'Azure OpenAI base path',
description:
'All workspaces using an OpenAI resource for Windmill AI will run on the specified deployed model. Format: https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}. <a href="https://www.windmill.dev/docs/core_concepts/ai_generation#azure-openai-advanced-models">Learn more</a>',
key: 'openai_azure_base_path',
fieldType: 'text',
storage: 'setting',
ee_only: '',
hiddenIfEmpty: true
},
}
],
'Private Hub': [
{
label: 'Private Hub base url',
description:
@@ -245,7 +286,6 @@ export const settings: Record<string, Setting[]> = {
key: 'hub_accessible_url',
fieldType: 'text',
hiddenIfNull: true,
storage: 'setting',
ee_only: '',
requiresReloadOnChange: true
@@ -260,13 +300,14 @@ export const settings: Record<string, Setting[]> = {
ee_only: ''
},
{
label: 'App workspace prefix',
label: 'Azure OpenAI base path',
description:
'When enabled apps will be accessible at /a/{workspace_id}/{custom_path} instead of /a/{custom_path} allowing you to define same custom path for apps in different workspace without conflict',
key: 'app_workspaced_route',
fieldType: 'boolean',
'All workspaces using an OpenAI resource for Windmill AI will run on the specified deployed model. Format: https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}. <a href="https://www.windmill.dev/docs/core_concepts/ai_generation#azure-openai-advanced-models">Learn more</a>',
key: 'openai_azure_base_path',
fieldType: 'text',
storage: 'setting',
ee_only: ''
ee_only: '',
hiddenIfEmpty: true
}
],
SMTP: [
@@ -336,7 +377,7 @@ export const settings: Record<string, Setting[]> = {
key: 'uv_index_strategy',
fieldType: 'select',
placeholder: 'unsafe-best-match',
defaultValue: () => "unsafe-best-match",
defaultValue: () => 'unsafe-best-match',
select_items: [
{
label: 'first-index',
@@ -515,7 +556,7 @@ export const settings: Record<string, Setting[]> = {
key: 'indexer_settings',
fieldType: 'indexer_rates',
storage: 'setting',
ee_only: 'Full text search across jobs and service logs is an EE feature'
validate: validateIndexerSettings
}
],
@@ -530,9 +571,9 @@ export const settings: Record<string, Setting[]> = {
],
'Secret Storage': [
{
label: 'Secret Storage Backend',
label: 'Backend type',
description:
'Configure where secrets (secret variables) are stored. By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault as an external secret store.',
'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault as an external secret store.',
key: 'secret_backend',
fieldType: 'secret_backend',
storage: 'setting',
@@ -542,3 +583,173 @@ export const settings: Record<string, Setting[]> = {
}
export const settingsKeys = Object.keys(settings)
// --- Sidebar navigation for instance settings ---
export const instanceSettingsNavigationGroups = [
{
items: [
{
id: 'users',
label: 'Users',
aiId: 'instance-settings-users',
aiDescription: 'Instance users settings'
},
{
id: 'general',
label: 'General',
aiId: 'instance-settings-general',
aiDescription: 'Instance general settings'
}
]
},
{
title: 'Authentication',
items: [
{
id: 'sso',
label: 'SSO',
aiId: 'instance-settings-sso',
aiDescription: 'Instance SSO settings'
},
{
id: 'oauth',
label: 'OAuth',
aiId: 'instance-settings-oauth',
aiDescription: 'Instance OAuth settings'
},
{
id: 'scim_saml',
label: 'SCIM/SAML',
aiId: 'instance-settings-scim-saml',
aiDescription: 'Instance SCIM/SAML settings',
isEE: true
}
]
},
{
title: 'Infrastructure',
items: [
{
id: 'smtp',
label: 'SMTP',
aiId: 'instance-settings-smtp',
aiDescription: 'Instance SMTP settings'
},
{
id: 'registries',
label: 'Registries',
aiId: 'instance-settings-registries',
aiDescription: 'Instance registries settings'
},
{
id: 'object_storage',
label: 'Object Storage',
aiId: 'instance-settings-object-storage',
aiDescription: 'Instance object storage settings',
isEE: true
}
]
},
{
title: 'Monitoring',
items: [
{
id: 'alerts',
label: 'Alerts',
aiId: 'instance-settings-alerts',
aiDescription: 'Instance alerts settings',
isEE: true
},
{
id: 'otel_prom',
label: 'OTEL/Prometheus',
aiId: 'instance-settings-otel-prom',
aiDescription: 'Instance OTEL/Prometheus settings',
isEE: true
},
{
id: 'indexer',
label: 'Indexer',
aiId: 'instance-settings-indexer',
aiDescription: 'Instance indexer settings',
isEE: true
}
]
},
{
title: 'Advanced',
items: [
{
id: 'jobs',
label: 'Jobs',
aiId: 'instance-settings-jobs',
aiDescription: 'Instance jobs settings'
},
{
id: 'private_hub',
label: 'Private Hub',
aiId: 'instance-settings-private-hub',
aiDescription: 'Instance private hub settings',
isEE: true
},
{
id: 'telemetry',
label: 'Telemetry',
aiId: 'instance-settings-telemetry',
aiDescription: 'Instance telemetry settings'
},
{
id: 'secret_storage',
label: 'Secret Storage',
aiId: 'instance-settings-secret-storage',
aiDescription: 'Instance secret storage settings'
}
]
}
]
export const tabToCategoryMap: Record<string, string> = {
general: 'Core',
sso: 'Auth/OAuth/SAML',
oauth: 'Auth/OAuth/SAML',
scim_saml: 'Auth/OAuth/SAML',
smtp: 'SMTP',
registries: 'Registries',
alerts: 'Alerts',
otel_prom: 'OTEL/Prom',
indexer: 'Indexer',
telemetry: 'Telemetry',
secret_storage: 'Secret Storage',
object_storage: 'Object Storage',
jobs: 'Jobs',
private_hub: 'Private Hub'
}
export const tabToAuthSubTab: Record<string, 'sso' | 'oauth' | 'scim'> = {
sso: 'sso',
oauth: 'oauth',
scim_saml: 'scim'
}
// Navigation groups for the initial setup flow (no Users tab)
export const setupNavigationGroups = instanceSettingsNavigationGroups
.map((group) => ({
...group,
items: group.items.filter((item) => item.id !== 'users')
}))
.filter((group) => group.items.length > 0)
export const categoryToTabMap: Record<string, string> = {
Core: 'general',
SMTP: 'smtp',
'Auth/OAuth/SAML': 'sso',
Registries: 'registries',
Alerts: 'alerts',
'OTEL/Prom': 'otel_prom',
Indexer: 'indexer',
Telemetry: 'telemetry',
'Secret Storage': 'secret_storage',
'Object Storage': 'object_storage',
Jobs: 'jobs',
'Private Hub': 'private_hub'
}
@@ -0,0 +1,103 @@
<script lang="ts">
import Tooltip from '../Tooltip.svelte'
import IntegerInput from '../IntegerInput.svelte'
import InputError from '../InputError.svelte'
import type { Writable } from 'svelte/store'
interface Props {
values: Writable<Record<string, any>>
disabled?: boolean
errors?: Record<string, string>
}
let { values, disabled = false, errors = {} }: Props = $props()
</script>
<div class="space-y-6">
<div class="flex flex-col gap-1">
<label for="commit_job_max_batch_size" class="block text-xs font-semibold text-emphasis">
Commit max batch size <Tooltip>
The max amount of documents (here jobs) per commit. To optimize indexing throughput, it is
best to keep this as high as possible. However, especially when reindexing the whole
instance, it can be useful to have a limit on how many jobs can be written without being
committed. A commit will make the jobs available for search, constitute a "checkpoint" state
in the indexing and will be logged.
</Tooltip>
</label>
<IntegerInput
placeholder="100000"
id="commit_job_max_batch_size"
{disabled}
error={errors.commit_job_max_batch_size ?? ''}
value={$values['indexer_settings'].commit_job_max_batch_size}
oninput={(v) => {
if (v == null) {
const { commit_job_max_batch_size: _, ...rest } = $values['indexer_settings']
$values['indexer_settings'] = rest
} else {
$values['indexer_settings'] = {
...$values['indexer_settings'],
commit_job_max_batch_size: v
}
}
}}
/>
<InputError error={errors.commit_job_max_batch_size ?? ''} />
</div>
<div class="flex flex-col gap-1">
<label for="refresh_index_period" class="block text-xs font-semibold text-emphasis">
Refresh index period (s) <Tooltip>
The index will query new jobs periodically and write them on the index. This setting sets
that period.
</Tooltip></label
>
<IntegerInput
placeholder="300"
id="refresh_index_period"
{disabled}
error={errors.refresh_index_period ?? ''}
value={$values['indexer_settings'].refresh_index_period}
oninput={(v) => {
if (v == null) {
const { refresh_index_period: _, ...rest } = $values['indexer_settings']
$values['indexer_settings'] = rest
} else {
$values['indexer_settings'] = {
...$values['indexer_settings'],
refresh_index_period: v
}
}
}}
/>
<InputError error={errors.refresh_index_period ?? ''} />
</div>
<div class="flex flex-col gap-1">
<label for="max_indexed_job_log_size" class="block text-xs font-semibold text-emphasis">
Max indexed job log size (KB) <Tooltip>
Job logs are included when indexing, but to avoid the index size growing artificially, the
logs will be truncated after a size has been reached.
</Tooltip>
</label>
<IntegerInput
placeholder="1024"
id="max_indexed_job_log_size"
{disabled}
error={errors.max_indexed_job_log_size ?? ''}
value={$values['indexer_settings'].max_indexed_job_log_size != null
? $values['indexer_settings'].max_indexed_job_log_size / 1024
: undefined}
oninput={(v) => {
if (v == null) {
const { max_indexed_job_log_size: _, ...rest } = $values['indexer_settings']
$values['indexer_settings'] = rest
} else {
$values['indexer_settings'] = {
...$values['indexer_settings'],
max_indexed_job_log_size: v * 1024
}
}
}}
/>
<InputError error={errors.max_indexed_job_log_size ?? ''} />
</div>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import Tooltip from '../Tooltip.svelte'
import IntegerInput from '../IntegerInput.svelte'
import InputError from '../InputError.svelte'
import type { Writable } from 'svelte/store'
interface Props {
values: Writable<Record<string, any>>
disabled?: boolean
errors?: Record<string, string>
}
let { values, disabled = false, errors = {} }: Props = $props()
</script>
<div class="space-y-6">
<div class="flex flex-col gap-1">
<label for="commit_log_max_batch_size" class="block text-xs font-semibold text-emphasis"
>Commit max batch size <Tooltip>
The max amount of documents per commit. In this case 1 document is one log file representing
all logs during 1 minute for a specific host. To optimize indexing throughput, it is best to
keep this as high as possible. However, especially when reindexing the whole instance, it
can be useful to have a limit on how many logs can be written without being committed. A
commit will make the logs available for search, appear as a log line, and be a "checkpoint"
of the indexing progress.
</Tooltip>
</label>
<IntegerInput
placeholder="10000"
id="commit_log_max_batch_size"
{disabled}
error={errors.commit_log_max_batch_size ?? ''}
value={$values['indexer_settings'].commit_log_max_batch_size}
oninput={(v) => {
if (v == null) {
const { commit_log_max_batch_size: _, ...rest } = $values['indexer_settings']
$values['indexer_settings'] = rest
} else {
$values['indexer_settings'] = {
...$values['indexer_settings'],
commit_log_max_batch_size: v
}
}
}}
/>
<InputError error={errors.commit_log_max_batch_size ?? ''} />
</div>
<div class="flex flex-col gap-1">
<label for="refresh_log_index_period" class="block text-xs font-semibold text-emphasis">
Refresh index period (s) <Tooltip>
The index will query new service logs peridically and write them on the index. This setting
sets that period.
</Tooltip>
</label>
<IntegerInput
placeholder="300"
id="refresh_log_index_period"
{disabled}
error={errors.refresh_log_index_period ?? ''}
value={$values['indexer_settings'].refresh_log_index_period}
oninput={(v) => {
if (v == null) {
const { refresh_log_index_period: _, ...rest } = $values['indexer_settings']
$values['indexer_settings'] = rest
} else {
$values['indexer_settings'] = {
...$values['indexer_settings'],
refresh_log_index_period: v
}
}
}}
/>
<InputError error={errors.refresh_log_index_period ?? ''} />
</div>
</div>
@@ -0,0 +1,119 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { IndexSearchService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Tooltip from '../Tooltip.svelte'
import IntegerInput from '../IntegerInput.svelte'
import InputError from '../InputError.svelte'
import Label from '../Label.svelte'
import type { Writable } from 'svelte/store'
interface Props {
values: Writable<Record<string, any>>
disabled?: boolean
errors?: Record<string, string>
}
let { values, disabled = false, errors = {} }: Props = $props()
let clearJobsIndexModalOpen = $state(false)
let clearServiceLogsIndexModalOpen = $state(false)
</script>
<div class="space-y-6">
<div class="flex flex-col gap-1">
<label for="writer_memory_budget" class="block text-xs font-semibold text-emphasis">
Index writer memory budget (MB)
<Tooltip>
The allocated memory arena for the indexer. A bigger value means less writing to disk and
potentially higher indexing throughput
</Tooltip>
</label>
<IntegerInput
placeholder="300"
id="writer_memory_budget"
{disabled}
error={errors.writer_memory_budget ?? ''}
value={$values['indexer_settings'].writer_memory_budget != null
? $values['indexer_settings'].writer_memory_budget / (1024 * 1024)
: undefined}
oninput={(v) => {
if (v == null) {
const { writer_memory_budget: _, ...rest } = $values['indexer_settings']
$values['indexer_settings'] = rest
} else {
$values['indexer_settings'] = {
...$values['indexer_settings'],
writer_memory_budget: v * (1024 * 1024)
}
}
}}
/>
<InputError error={errors.writer_memory_budget ?? ''} />
</div>
<Label label="Clear index">
<span class="text-xs text-secondary"
>This buttons will clear the whole index, and the service will start reindexing from scratch.
Full text search might be down during this time.</span
>
<div class="flex flex-row gap-2">
<Button
variant="default"
unifiedSize="sm"
on:click={() => {
clearJobsIndexModalOpen = true
}}
>
Clear jobs index
</Button>
<Button
variant="default"
unifiedSize="sm"
on:click={() => {
clearServiceLogsIndexModalOpen = true
}}
>
Clear service logs index
</Button>
</div>
</Label>
<ConfirmationModal
title="Clear jobs index"
confirmationText="Clear"
open={clearJobsIndexModalOpen}
type="danger"
on:canceled={() => {
clearJobsIndexModalOpen = false
}}
on:confirmed={async () => {
const r = await IndexSearchService.clearIndex({
idxName: 'JobIndex'
})
sendUserToast(r)
clearJobsIndexModalOpen = false
}}
>
Are you sure you want to clear the jobs index? The service will start reindexing from scratch.
Full text search might be down during this time.
</ConfirmationModal>
<ConfirmationModal
title="Clear service logs index"
confirmationText="Clear"
open={clearServiceLogsIndexModalOpen}
type="danger"
on:canceled={() => {
clearServiceLogsIndexModalOpen = false
}}
on:confirmed={async () => {
const r = await IndexSearchService.clearIndex({
idxName: 'ServiceLogIndex'
})
sendUserToast(r)
clearServiceLogsIndexModalOpen = false
}}
>
Are you sure you want to clear the service logs index? The service will start reindexing from
scratch. Full text search might be down during this time.
</ConfirmationModal>
</div>
@@ -70,7 +70,12 @@
}
function setAuthMethod(method: string | undefined) {
if (!method || !$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return
if (
!method ||
!$values['secret_backend'] ||
$values['secret_backend'].type !== 'HashiCorpVault'
)
return
if (method === 'token') {
// Clear JWT role when switching to token auth
@@ -194,12 +199,8 @@
<div class="space-y-6">
<!-- Backend Type Selector -->
<div class="flex flex-col gap-2">
<label class="block text-xs font-semibold text-emphasis">Backend Type</label>
<ToggleButtonGroup
selected={selectedType}
onSelected={(v) => setBackendType(v)}
>
<div class="flex flex-col gap-2 mt-1">
<ToggleButtonGroup selected={selectedType} onSelected={(v) => setBackendType(v)}>
{#snippet children({ item: toggleButton })}
<ToggleButton
value="Database"
@@ -289,10 +290,7 @@
<!-- Authentication Method Toggle -->
<div class="flex flex-col gap-2">
<label class="block text-xs font-semibold text-emphasis">Authentication Method</label>
<ToggleButtonGroup
selected={authMethod}
onSelected={(v) => setAuthMethod(v)}
>
<ToggleButtonGroup selected={authMethod} onSelected={(v) => setAuthMethod(v)}>
{#snippet children({ item: toggleButton })}
<ToggleButton
value="jwt"
@@ -347,8 +345,11 @@
>
<div class="mt-2 p-2 bg-surface rounded text-2xs text-secondary space-y-2">
<p>Configure Vault to accept JWTs from Windmill:</p>
<div class="bg-gray-100 dark:bg-gray-800 p-2 rounded font-mono text-2xs overflow-x-auto">
<pre># Enable JWT auth method
<div
class="bg-gray-100 dark:bg-gray-800 p-2 rounded font-mono text-2xs overflow-x-auto"
>
<pre
># Enable JWT auth method
vault auth enable jwt
# Configure JWT auth with Windmill's JWKS endpoint
@@ -372,7 +373,8 @@ vault write auth/jwt/role/windmill-secrets \
bound_audiences="{baseUrl}" \
user_claim="email" \
policies="windmill-secrets" \
ttl="1h"</pre>
ttl="1h"</pre
>
</div>
<p class="text-yellow-600 dark:text-yellow-400">
Replace <code>windmill-secrets</code> with your role name if different.
@@ -0,0 +1,71 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import { enterpriseLicense } from '$lib/stores'
import EEOnly from '../EEOnly.svelte'
import Tooltip from '../Tooltip.svelte'
import { Button } from '../common'
import type { ButtonType } from '../common/button/model'
interface Props {
label?: string
description?: string
ee_only?: string
tooltip?: string
actionButton?: {
label: string
onclick: (values: Record<string, any>) => Promise<void>
variant?: ButtonType.Variant
}
values?: Record<string, any>
children: import('svelte').Snippet
class?: string
}
let {
label,
description,
ee_only,
tooltip,
actionButton,
values,
children,
class: clazz
}: Props = $props()
</script>
<div class={twMerge('p-4 rounded-md bg-surface-tertiary shadow-sm flex flex-col gap-1', clazz)}>
{#if label}
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex gap-1 items-baseline">
<span class="text-emphasis font-semibold text-xs">{label}</span>
{#if ee_only != undefined && !$enterpriseLicense}
{#if ee_only != ''}
<EEOnly>{ee_only}</EEOnly>
{:else}
<EEOnly />
{/if}
{/if}
{#if tooltip}
<Tooltip>{tooltip}</Tooltip>
{/if}
</div>
{#if actionButton}
<Button
disabled={ee_only != undefined && !$enterpriseLicense}
variant={actionButton.variant ?? 'default'}
unifiedSize="sm"
onclick={async () => await actionButton?.onclick(values ?? {})}
>
{actionButton.label}
</Button>
{/if}
</div>
{#if description}
<span class="text-secondary font-normal text-xs">
{@html description}
</span>
{/if}
{/if}
{@render children()}
</div>
@@ -767,7 +767,7 @@
</Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface">
<tbody>
{#if filteredUsers}
{#each sortedUsers().slice(0, nbDisplayed) as user, index (user.email)}
{@const { email, username, is_admin, operator, disabled, added_via } = user}
@@ -775,11 +775,11 @@
{#if hasNonManualUsers && index > 0 && sortedUsers()[index - 1]?.added_via?.source !== 'instance_group' && added_via?.source === 'instance_group'}
<tr class="bg-surface-secondary">
<td colspan={hasNonManualUsers ? 8 : 7} class="px-4 py-2">
<div class="text-xs text-primary font-bold"> Instance group users </div>
<div class="text-xs text-emphasis font-semibold"> Instance group users </div>
</td>
</tr>
{/if}
<tr class="!hover:bg-surface-hover">
<tr class={index % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'}>
<Cell first><a href="mailto:{email}">{truncate(email, 20)}</a></Cell>
<Cell>{truncate(username, 30)}</Cell>
{#if hasNonManualUsers}
@@ -101,7 +101,7 @@
function goToCoreTab() {
goto('/#superadmin-settings')
instanceSettingsSelectedTab.set('Core')
instanceSettingsSelectedTab.set('general')
}
function onFiltersChange() {
@@ -21,6 +21,7 @@
import { Settings } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import SettingsFooter from './SettingsFooter.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
let {
aiProviders = $bindable(),
@@ -182,7 +183,7 @@
/>
<div class="flex flex-col gap-6 mt-4 pb-8">
<Label label="AI Providers">
<SettingCard label="AI Providers">
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
{#each Object.entries(AI_PROVIDERS) as [provider, details]}
<div class="flex flex-col">
@@ -291,9 +292,9 @@
</div>
{/each}
</div>
</Label>
</SettingCard>
<Label label="Default chat model">
<SettingCard label="Default chat model">
{#key Object.keys(aiProviders).length}
<Select
items={safeSelectItems(selectedAiModels)}
@@ -301,13 +302,14 @@
disabled={false}
placeholder="Select a default model"
size="sm"
class="max-w-lg"
/>
{/key}
</Label>
</SettingCard>
<!-- Code completion group for animation purposes -->
<div>
<Label label="Code completion">
<SettingCard label="Code completion">
<Toggle
on:change={(e) => {
if (e.detail) {
@@ -323,11 +325,11 @@
rightTooltip: 'We currently only support Mistral Codestral models for code completion.'
}}
/>
</Label>
</SettingCard>
{#if codeCompletionModel != undefined}
<div transition:slide|local={{ duration: 150 }} class="mt-6">
<Label label="Code completion model">
<SettingCard label="Code completion model">
<Select
items={safeSelectItems(autocompleteModels)}
bind:value={codeCompletionModel}
@@ -335,19 +337,18 @@
placeholder="Select a code completion model"
size="sm"
/>
</Label>
</SettingCard>
</div>
{/if}
</div>
<ModelTokenLimits {aiProviders} bind:maxTokensPerModel />
<Label label="Custom system prompts">
<p class="text-xs text-secondary">
Customize AI behavior with workspace-level system prompts. These apply to all workspace
members.
</p>
<SettingCard
label="Custom system prompts"
description="Customize AI behavior with workspace-level system prompts. These apply to all workspace
members."
>
<div class="flex items-center gap-2 pt-1">
<Button
onclick={() => (modalOpen = true)}
@@ -365,7 +366,7 @@
<Badge color="yellow">Unsaved changes</Badge>
{/if}
</div>
</Label>
</SettingCard>
</div>
<AIPromptsModal
@@ -190,7 +190,7 @@
{/each}
</tr>
</Head>
<tbody class="divide-y bg-surface">
<tbody class="divide-y bg-surface-tertiary">
{#if tempSettings.dataTables.length == 0}
<Row>
<Cell colspan={tableHeadNames.length} class="text-center py-6">
@@ -225,7 +225,7 @@
{/each}
</tr>
</Head>
<tbody class="divide-y bg-surface">
<tbody class="divide-y bg-surface-tertiary">
{#if ducklakeSettings.ducklakes.length == 0}
<Row>
<Cell colspan={tableHeadNames.length} class="text-center py-6">
@@ -4,6 +4,7 @@
import { getModelMaxTokens } from '../copilot/lib'
import { ChevronDown, ChevronUp } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import SettingCard from '../instanceSettings/SettingCard.svelte'
const MAX_TOKENS_LIMIT = 2000000
@@ -98,15 +99,10 @@
</script>
{#if Object.keys(aiProviders).length > 0}
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<p class="font-semibold text-xs text-emphasis">Model Output Limits</p>
<p class="text-xs text-secondary">
Configure maximum token limits for each model. These limits apply to all AI chat
interactions in the workspace.
</p>
</div>
<SettingCard
label="Model output limits"
description="Configure maximum token limits for each model. These limits apply to all AI chat interactions in the workspace."
>
<div class="flex flex-col gap-3">
{#each Object.entries(modelsByProvider).filter(([provider, models]) => models.length > 0) as [provider, models]}
{@const isExpanded = !collapsedProviders[provider]}
@@ -184,5 +180,5 @@
</div>
{/each}
</div>
</div>
</SettingCard>
{/if}
@@ -70,10 +70,7 @@
</script>
<div
class={twMerge(
inline ? 'w-full' : 'sticky bottom-0 z-10 w-full border-t bg-surface-tertiary',
className
)}
class={twMerge(inline ? 'w-full' : 'sticky bottom-0 z-10 w-full border-t bg-surface', className)}
>
<div class={inline ? 'flex items-center justify-end' : 'flex items-center justify-end pt-4 pb-8'}>
<div class="flex items-center gap-2">
@@ -86,7 +83,7 @@
onClick={onDiscard}
disabled={isSaving}
>
Discard
Discard changes
</Button>
</div>
{/if}
@@ -143,7 +143,7 @@
{/each}
</tr>
</Head>
<tbody class="divide-y bg-surface">
<tbody class="divide-y bg-surface-tertiary">
{#each tableRows as tableRow, idx}
<Row>
<Cell first class="w-48 relative">
@@ -203,7 +203,7 @@
})
</script>
<div class="flex flex-col gap-6">
<div class="flex flex-col">
<SettingsPageHeader
title="Native Triggers (Beta)"
description="Connect your workspace to external services for native triggers and enhanced functionality. These connections are shared across all workspace members and are required for native triggers to work."
@@ -230,6 +230,8 @@
</ul>
</Alert>
<div class="mt-6"></div>
{#if processingCallback}
<Alert type="info" title="Processing OAuth connection">
<p class="text-sm">Completing your OAuth connection, please wait...</p>
@@ -249,7 +251,7 @@
{@const isServiceConnected = integration && isConnected(integration)}
{@const isShowingConfig = showingConfig === serviceName}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-surface">
<div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 flex items-center justify-center">
@@ -83,7 +83,6 @@
</DrawerContent>
</Drawer>
{#if !$enterpriseLicense}
<Alert type="warning" title="Workspace Protection Rules is an EE feature">
Workspace Protection Rules is a Windmill Enterprise Edition feature. It enables granular
@@ -108,7 +107,7 @@
</div>
<div class="relative mb-20">
<DataTable>
<DataTable containerClass="bg-surface-tertiary">
<Head>
<tr>
<Cell head first>Name</Cell>
@@ -149,7 +148,9 @@
</div>
</Cell>
<Cell>
<span class="text-xs text-secondary">{getScopeSummary(rule.bypass_groups, rule.bypass_users)}</span>
<span class="text-xs text-secondary"
>{getScopeSummary(rule.bypass_groups, rule.bypass_users)}</span
>
</Cell>
<Cell>
<span class="text-xs text-secondary">
+1 -1
View File
@@ -171,7 +171,7 @@ export type DBSchemas = Partial<Record<string, DBSchema>>
export const dbSchemas = writable<DBSchemas>({})
export const instanceSettingsSelectedTab = writable('Core')
export const instanceSettingsSelectedTab = writable('users')
export const isCriticalAlertsUIOpen = writable(false)
@@ -5,9 +5,9 @@
import { Button } from '$lib/components/common'
import { workspaceStore } from '$lib/stores'
async function startSetup(): Promise<void> {
async function startSetup(advanced = false): Promise<void> {
$workspaceStore = 'admins'
goto('/user/instance_settings')
goto(advanced ? '/user/instance_settings?mode=full' : '/user/instance_settings')
}
async function decline(): Promise<void> {
@@ -16,12 +16,15 @@
</script>
<CenteredModal title="Welcome to Windmill">
<p class="text-center text-lg mt-4 mb-4">
This is a brand new instance. Setup the instance settings, then set the default superadmin user
and enable hub resource type sync
<p class="text-center text-secondary mt-4 mb-4">
Configure your instance settings to get started. You can use the quick setup for essential
settings or the advanced setup for full control.
</p>
<div class="flex flex-row justify-between pt-4 gap-x-1">
<Button color="light" size="xs2" variant="contained" on:click={decline}>Skip</Button>
<Button variant="accent" size="lg" on:click={startSetup}>Setup</Button>
<Button color="light" variant="contained" unifiedSize="md" on:click={decline}>Skip</Button>
<div class="flex items-center gap-2">
<Button variant="default" unifiedSize="md" on:click={() => startSetup(true)}>Advanced setup</Button>
<Button variant="accent" unifiedSize="md" on:click={() => startSetup()}>Quick setup</Button>
</div>
</div>
</CenteredModal>
@@ -1,22 +1,221 @@
<script lang="ts">
import { goto } from '$lib/navigation'
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 SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import {
setupNavigationGroups,
tabToCategoryMap,
tabToAuthSubTab,
categoryToTabMap
} from '$lib/components/instanceSettings'
import Breadcrumb from '$lib/components/common/breadcrumb/Breadcrumb.svelte'
import { ChevronRight, ArrowLeft } from 'lucide-svelte'
let saved = false
const wizardSteps = [
{ id: 'Core', label: 'General' },
{ id: 'Auth/OAuth/SAML', label: 'Authentication' }
] as const
const initialMode = $page.url.searchParams.get('mode') === 'full' ? 'full' : 'wizard'
let mode: 'wizard' | 'full' = $state(initialMode)
let wizardStep = $state(0)
let instanceSettings: InstanceSettings | undefined = $state()
let currentStepDirty = $derived(instanceSettings?.isDirty(wizardSteps[wizardStep].id) ?? false)
// --- Full settings mode state ---
let fullTab = $state('general')
let instanceSettingsCategory = $derived(tabToCategoryMap[fullTab] ?? 'Core')
let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso')
// --- Unsaved changes detection (full mode) ---
let pendingTab: string | undefined = $state(undefined)
let showUnsavedChangesModal = $state(false)
function handleNavigate(newTab: string) {
if (newTab === fullTab) return
const currentCategory = tabToCategoryMap[fullTab]
if (currentCategory && instanceSettings?.isDirty(currentCategory)) {
pendingTab = newTab
showUnsavedChangesModal = true
} else {
fullTab = newTab
}
}
/** 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)
}
callback()
}
function switchToFullMode() {
mode = 'full'
}
function switchToWizardMode() {
mode = 'wizard'
}
function finishSetup() {
goto('/apps/get/g/all/setup_app?nomenubar=true&workspace=admins')
}
</script>
<CenteredModal large title="Instance settings" centerVertically={false}>
<InstanceSettings on:saved={() => (saved = true)} />
<p class="text-secondary text-sm px-2 py-4">
You can change these settings later in the instance settings but finishing setup will leave this
page.
</p>
<Button
disabled={!saved}
on:click={() => {
goto('/apps/get/g/all/setup_app?nomenubar=true&workspace=admins')
}}>Finish Setup {!saved ? '(save settings at least once)' : ''}</Button
>
<CenteredModal large title="Instance settings" centerVertically={false} containOverflow>
<div class="flex flex-col flex-1 min-h-0 overflow-hidden">
{#if mode === 'wizard'}
<!-- Step indicator (pinned top) -->
<div class="pb-2 border-b shrink-0 flex justify-start">
<Breadcrumb
items={wizardSteps.map((s) => s.label)}
selectedIndex={wizardStep + 1}
numbered
onselect={(i) => {
if (i < wizardStep) saveAndProceed(() => (wizardStep = i))
}}
>
{#snippet separator()}
<ChevronRight size={16} class="text-tertiary shrink-0" />
{/snippet}
</Breadcrumb>
</div>
<!-- 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}
/>
{/key}
</div>
{:else}
<!-- Sidebar + Content -->
<div class="flex flex-1 min-h-0">
<div class="w-44 shrink-0 overflow-auto pb-4 pr-4">
<SidebarNavigation
groups={setupNavigationGroups}
selectedId={fullTab}
onNavigate={handleNavigate}
/>
</div>
<div class="flex-1 min-w-0 overflow-auto px-4">
<InstanceSettings
bind:this={instanceSettings}
hideTabs
tab={instanceSettingsCategory}
{authSubTab}
onNavigateToTab={(category) => {
const targetTab = categoryToTabMap[category]
if (targetTab) {
handleNavigate(targetTab)
}
}}
/>
</div>
</div>
{/if}
<!-- Navigation (pinned bottom) -->
<div class="flex items-center justify-between pt-4 border-t shrink-0">
{#if mode === 'wizard'}
<div class="flex items-center gap-2">
{#if wizardStep > 0}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: ArrowLeft }}
onClick={() => saveAndProceed(() => (wizardStep -= 1))}
>
Back
</Button>
{/if}
</div>
<div class="flex items-center gap-2">
<Button
variant="default"
unifiedSize="md"
onClick={() => saveAndProceed(switchToFullMode)}
>
Advanced setup
</Button>
{#if wizardStep < wizardSteps.length - 1}
<Button
variant="accent"
unifiedSize="md"
onClick={() => saveAndProceed(() => (wizardStep += 1))}
>
{currentStepDirty ? 'Save & Next' : 'Next'}
</Button>
{:else}
<Button variant="accent" unifiedSize="md" onClick={() => saveAndProceed(finishSetup)}>
{currentStepDirty ? 'Save & Continue' : 'Continue'}
</Button>
{/if}
</div>
{:else}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: ArrowLeft }}
onClick={switchToWizardMode}
>
Quick setup
</Button>
<Button variant="accent" unifiedSize="md" onClick={finishSetup}>Continue</Button>
{/if}
</div>
<div class="flex items-center justify-start gap-2 mt-2 shrink-0">
<p class="text-secondary text-xs">
You can change these settings later in the instance settings.
</p>
<Button variant="subtle" unifiedSize="sm" onClick={finishSetup}>Skip setup</Button>
</div>
</div>
</CenteredModal>
{#if showUnsavedChangesModal}
<ConfirmationModal
open={showUnsavedChangesModal}
title="Unsaved changes detected"
confirmationText="Discard changes"
on:canceled={() => {
showUnsavedChangesModal = false
pendingTab = undefined
}}
on:confirmed={() => {
if (pendingTab !== undefined) {
const currentCategory = tabToCategoryMap[fullTab]
if (currentCategory) {
instanceSettings?.discardCategory(currentCategory)
}
fullTab = pendingTab
}
showUnsavedChangesModal = false
pendingTab = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>You have unsaved changes. Are you sure you want to discard them?</span>
</div>
</ConfirmationModal>
{/if}
@@ -69,8 +69,8 @@
} from '$lib/components/workspaceSettings/DataTableSettings.svelte'
import WorkspaceDependenciesSettings from '$lib/components/workspaceSettings/WorkspaceDependenciesSettings.svelte'
import SettingsFooter from '$lib/components/workspaceSettings/SettingsFooter.svelte'
import Label from '$lib/components/Label.svelte'
import WorkspaceRulesets from '$lib/components/workspaceSettings/WorkspaceRulesets.svelte'
import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte'
let slackInitialPath: string = $state('')
let slackScriptPath: string = $state('')
@@ -1231,9 +1231,9 @@
</div>
<!-- Main Content -->
<div class="flex-1 min-w-0 h-full rounded-md">
<div class="h-full overflow-auto rounded-md bg-surface-tertiary">
<div class="h-fit px-8 py-6" style="scrollbar-gutter: stable both-edges;">
<div class="flex-1 min-w-0 h-full">
<div class="h-full overflow-auto">
<div class="h-fit px-6" style="scrollbar-gutter: stable both-edges;">
{#if !loadedSettings}
<Skeleton layout={[1, [40]]} />
{:else if tab == 'users'}
@@ -1552,35 +1552,29 @@
link="https://www.windmill.dev/docs/core_concepts/webhooks#workspace-webhook"
/>
<div class="flex flex-col gap-1 pb-8">
<div class="text-xs font-semibold text-emphasis"> URL to send requests to</div>
<div class="text-secondary text-xs">
This URL will be POSTed to with a JSON body depending on the type of event. The
type is indicated by the type field. The other fields are dependent on the type.
</div>
<div class="flex flex-col gap-2">
<TextInput
bind:value={webhook}
inputProps={{
placeholder: 'https://your-endpoint.com/webhook'
}}
error={webhookValidationError}
/>
{#if webhookValidationError}
<div class="text-xs text-red-600 dark:text-red-400"
>{webhookValidationError}</div
>
{/if}
</div>
</div>
<SettingCard
label="URL to send requests to"
description="This URL will be POSTed to with a JSON body depending on the type of event. The type is indicated by the type field. The other fields are dependent on the type."
>
<TextInput
bind:value={webhook}
inputProps={{
placeholder: 'https://your-endpoint.com/webhook'
}}
error={webhookValidationError}
class="max-w-lg"
/>
{#if webhookValidationError}
<div class="text-xs text-red-600 dark:text-red-400">{webhookValidationError}</div>
{/if}
</SettingCard>
<SettingsFooter
hasUnsavedChanges={hasWebhookChanges}
onSave={editWebhook}
onDiscard={discardWebhookSettingsChanges}
saveLabel="Save webhook"
disabled={!!webhookValidationError}
class="mt-8"
/>
{:else if tab == 'error_handler'}
<SettingsPageHeader
@@ -1635,7 +1629,7 @@
{/snippet}
</ErrorOrRecoveryHandler>
<div class="flex flex-col gap-6 items-start">
<SettingCard class="gap-2">
<Toggle
disabled={!$enterpriseLicense ||
((errorHandlerSelected === 'slack' || errorHandlerSelected === 'teams') &&
@@ -1652,7 +1646,7 @@
bind:checked={errorHandlerMutedOnUserPath}
options={{ right: 'Do not run error handler for u/ scripts and flows' }}
/>
</div>
</SettingCard>
</div>
<SettingsFooter
@@ -1752,7 +1746,7 @@ export async function main(
/>
<div class="flex flex-col gap-6 py-4">
{#if !$enterpriseLicense}
<Alert type="warning" title="Workspace critical alerts is an EE feature">
<Alert type="info" title="Workspace critical alerts is an EE feature">
Workspace critical alerts is a Windmill Enterprise Edition feature that sends
notifications to workspace admins when critical events occur.
</Alert>
@@ -1851,24 +1845,22 @@ export async function main(
before turning this feature on.
</Alert>
{/if}
<Label label="App" class="mt-6">
<SettingCard label="App" class="mt-6">
<ScriptPicker bind:scriptPath={workspaceDefaultAppPath} itemKind="app" clearable />
</Label>
</SettingCard>
<Label label="Rate limiting" class="mt-6">
<div class="text-xs text-secondary">
Limit the number of public (anonymous) app executions per minute per server. Set
to 0 or leave empty to disable. This is a per-server limit, not a global limit.
</div>
<div class="flex flex-row items-center gap-4">
<TextInput
inputProps={{ type: 'number', placeholder: '0 (disabled)' }}
bind:value={publicAppRateLimitPerMinute}
class="w-48"
/>
<span class="text-hint text-2xs">executions per minute per server</span>
</div>
</Label>
<SettingCard
label="Rate limiting"
description="Limit the number of public (anonymous) app executions per minute per server. Set to 0 or leave empty to disable. This is a per-server limit, not a global limit."
class="mt-6"
>
<TextInput
inputProps={{ type: 'number', placeholder: '0 (disabled)' }}
bind:value={publicAppRateLimitPerMinute}
class="w-48"
/>
<span class="text-hint text-2xs">executions per minute per server</span>
</SettingCard>
<SettingsFooter
class="mt-8"
@@ -1894,14 +1886,7 @@ export async function main(
description="When updating the encryption key of a workspace, all secrets will be re-encrypted with the new key and the previous key will be replaced by the new one. If you're manually updating the key to match another workspace key from another Windmill instance, make sure not to use the 'SECRET_SALT' environment variable or, if you're using it, make sure it the salt matches across both instances."
link="https://www.windmill.dev/docs/core_concepts/workspace_secret_encryption"
/>
<div class="mt-5 mb-6"></div>
<label
for="workspace-encryption-key"
class="text-xs font-semibold text-emphasis mt-1"
>
Workspace encryption key
</label>
<div class="flex flex-col gap-1">
<SettingCard label="Workspace encryption key" class="mt-6">
<div class="flex gap-2">
<TextInput
inputProps={{
@@ -1924,7 +1909,7 @@ export async function main(
{encryptionKeyValidationError}
</div>
{/if}
</div>
</SettingCard>
<SettingsFooter
class="mt-8"