mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 <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>
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
→ {uptodate}
|
||||
<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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user