diff --git a/frontend/src/lib/components/DeployToSetting.svelte b/frontend/src/lib/components/DeployToSetting.svelte index 7add91c982..e4e56bd6b1 100644 --- a/frontend/src/lib/components/DeployToSetting.svelte +++ b/frontend/src/lib/components/DeployToSetting.svelte @@ -8,10 +8,13 @@ import { Button } from './common' import Toggle from './Toggle.svelte' import { emptyString } from '$lib/utils' + import { validateDeployPathFilters } from '$lib/validators/workspaceSettings' + import Alert from './common/alert/Alert.svelte' + import SettingsFooter from './workspaceSettings/SettingsFooter.svelte' - $: deployableWorkspaces = $usersWorkspaceStore?.workspaces - .map((w) => w.id) - .filter((w) => w != $workspaceStore) + let deployableWorkspaces = $derived( + $usersWorkspaceStore?.workspaces.map((w) => w.id).filter((w) => w != $workspaceStore) + ) type DeployUITypeMap = { scripts: boolean @@ -34,14 +37,39 @@ triggers: true } - export let workspaceToDeployTo: string | undefined - export let deployUiSettings: { - include_path: string[] - include_type: DeployUITypeMap - } = { - include_path: [], - include_type: all_ok - } + let { + workspaceToDeployTo = $bindable(), + deployUiSettings = $bindable({ + include_path: [], + include_type: all_ok + }), + hasUnsavedChanges = false, + onSave, + onDiscard, + onWorkspaceToDeployToSave + }: { + workspaceToDeployTo: string | undefined + deployUiSettings: { + include_path: string[] + include_type: DeployUITypeMap + } + hasUnsavedChanges?: boolean + onSave?: () => void + onDiscard: () => void + onWorkspaceToDeployToSave?: (workspaceToDeployTo: string | undefined) => void + } = $props() + + // Validation state + let pathValidationErrors: Record = $state({}) + let hasValidationErrors = $derived(Object.keys(pathValidationErrors).length > 0) + + // Validate path filters whenever they change + $effect(() => { + if (deployUiSettings?.include_path) { + const validationResult = validateDeployPathFilters(deployUiSettings.include_path) + pathValidationErrors = validationResult.errors + } + }) function deployUITypeMapToArray( typesMap: DeployUITypeMap, expectedValue: boolean @@ -71,39 +99,61 @@ return result } + async function editWorkspaceToDeployTo() { + try { + await WorkspaceService.editDeployTo({ + workspace: $workspaceStore ?? '', + requestBody: { deploy_to: workspaceToDeployTo === '' ? undefined : workspaceToDeployTo } + }) + + if (workspaceToDeployTo === '' || workspaceToDeployTo === undefined) { + sendUserToast('Disabled setting deployable workspace') + onWorkspaceToDeployToSave?.(undefined) + } else { + sendUserToast('Set deployable workspace to ' + workspaceToDeployTo) + onWorkspaceToDeployToSave?.(workspaceToDeployTo) + } + } catch (error) { + sendUserToast(`Failed to save workspace deployment setting: ${error}`, true) + } + } + async function editWindmillDeploymentUISettings() { + // Validate before saving + const validationResult = validateDeployPathFilters(deployUiSettings.include_path) + if (!validationResult.isValid) { + sendUserToast('Please fix validation errors before saving', true) + return + } + let include_path = deployUiSettings.include_path.filter((elmt) => !emptyString(elmt)) let include_type = deployUITypeMapToArray(deployUiSettings.include_type, true) - await WorkspaceService.editWorkspaceDeployUiSettings({ - workspace: $workspaceStore!, - requestBody: { - deploy_ui_settings: { - include_path: include_path, - include_type: include_type + + try { + // Save workspace to deploy to first + await editWorkspaceToDeployTo() + + // Then save deployment UI settings + await WorkspaceService.editWorkspaceDeployUiSettings({ + workspace: $workspaceStore!, + requestBody: { + deploy_ui_settings: { + include_path: include_path, + include_type: include_type + } } - } - }) - sendUserToast('Workspace Deployment UI settings updated') + }) + sendUserToast('Workspace Deployment UI settings updated') + onSave?.() + } catch (error) { + sendUserToast(`Failed to save deployment settings: ${error}`, true) + } }

Workspace to link to

- {#if deployableWorkspaces?.length == 0} {/if} @@ -113,8 +163,12 @@ {/each}
-

Deployable items

-
+

Deployable items

+
+ You can filter which items can be deployed to the production workspace. By default everything is + deployable. +
+
{#if Array.isArray(deployUiSettings?.include_path)}

- {#each deployUiSettings.include_path ?? [] as regexpPath, idx} -
- - + {#each deployUiSettings.include_path ?? [] as _, idx} +
+
+ + +
+ {#if pathValidationErrors[idx]} +
{pathValidationErrors[idx]}
+ {/if}
{/each} {/if} @@ -197,14 +267,18 @@
-{#if $enterpriseLicense} -
- -
+{#if hasValidationErrors} + + Please fix the validation errors in the path filters before saving. + +{/if} +{#if $enterpriseLicense} + {/if} diff --git a/frontend/src/lib/components/EEOnly.svelte b/frontend/src/lib/components/EEOnly.svelte index 267795f9b2..c92377031c 100644 --- a/frontend/src/lib/components/EEOnly.svelte +++ b/frontend/src/lib/components/EEOnly.svelte @@ -1,27 +1,16 @@ - + EE only {#snippet text()} {#if children} {@render children()} diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index 8ed2a7a192..a3ddc1db6a 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -33,7 +33,7 @@ import { base } from '$lib/base' import { enterpriseLicense, workspaceStore } from '$lib/stores' import MsTeamsIcon from '$lib/components/icons/MSTeamsIcon.svelte' - import { emptySchema, emptyString, sendUserToast, tryEvery } from '$lib/utils' + import { classNames, emptySchema, emptyString, sendUserToast, tryEvery } from '$lib/utils' import MultiSelect from '$lib/components/select/MultiSelect.svelte' import { FlowService, @@ -80,6 +80,7 @@ customScriptTemplate: string customHandlerKind?: 'flow' | 'script' customTabTooltip?: import('svelte').Snippet + noMargin?: boolean } let { @@ -92,7 +93,8 @@ handlerExtraArgs = $bindable(), customScriptTemplate, customHandlerKind = $bindable('script'), - customTabTooltip + customTabTooltip, + noMargin = false }: Props = $props() let customHandlerSchema: Schema | undefined = $state() @@ -297,21 +299,26 @@ teams: undefined as string | undefined, email: undefined as string[] | undefined }) + let handlerPathCache: Partial> = $state({}) $effect(() => { if (lastHandlerSelected !== handlerSelected && lastHandlerSelected !== undefined) { if (lastHandlerSelected != 'custom') { const key = lastHandlerSelected === 'email' ? EMAIL_RECIPIENTS_KEY : CHANNEL_KEY handlerCache[lastHandlerSelected] = handlerExtraArgs[key] } + handlerPathCache[lastHandlerSelected] = handlerPath if (handlerSelected === 'custom') { - handlerExtraArgs[CHANNEL_KEY] = '' - handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [] - handlerPath = undefined + delete handlerExtraArgs[CHANNEL_KEY] + delete handlerExtraArgs[EMAIL_RECIPIENTS_KEY] + handlerPath = handlerPathCache['custom'] } else if (handlerSelected === 'email') { handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = handlerCache[handlerSelected] ?? [] + delete handlerExtraArgs[CHANNEL_KEY] } else { handlerExtraArgs[CHANNEL_KEY] = handlerCache[handlerSelected] ?? '' + delete handlerExtraArgs[EMAIL_RECIPIENTS_KEY] + handlerPath = handlerPathCache[handlerSelected] } } @@ -343,7 +350,7 @@ }) -
+
{#snippet children({ item })} @@ -361,56 +368,60 @@
{#if handlerSelected === 'custom'} -
- +
+
+ - {#if !handlerPath} - + {#if !handlerPath} + + {/if} +
+ {#if showScriptHelpText} +
+ Example of error handler scripts can be found on + Windmill Hub +
{/if}
- {#if showScriptHelpText} -
- Example of error handler scripts can be found on - Windmill Hub -
- {/if} {#if handlerPath} -

Extra arguments

- {#await import('$lib/components/SchemaForm.svelte')} - - {:then Module} - - {/await} - {#if customHandlerSchema && customHandlerSchema.properties && Object.keys(customHandlerSchema.properties).length === 0} -
This error handler takes no extra arguments
- {/if} +
+

Extra arguments

+ {#await import('$lib/components/SchemaForm.svelte')} + + {:then Module} + + {/await} + {#if customHandlerSchema && customHandlerSchema.properties && Object.keys(customHandlerSchema.properties).length === 0} +
This error handler takes no extra arguments
+ {/if} +
{/if} {:else if handlerSelected === 'slack'} diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 470c187b91..9425d3be59 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -255,7 +255,7 @@
{#if setting.fieldType != 'smtp_connect'} -
+
{setting.label} {#if setting.ee_only != undefined && !$enterpriseLicense} {#if setting.ee_only != ''} @@ -834,7 +834,10 @@ {/if}
{:else if setting.fieldType == 'otel_tracing_proxy'} - {@const tracingProxyVal = $values[setting.key] ?? { enabled: false, enabled_languages: [...OTEL_TRACING_PROXY_LANGUAGES] }} + {@const tracingProxyVal = $values[setting.key] ?? { + enabled: false, + enabled_languages: [...OTEL_TRACING_PROXY_LANGUAGES] + }}
{#if description} -
{@html description}
+
{@html description}
{/if} -
+
{@render children?.()}
diff --git a/frontend/src/lib/components/common/sidebar/SidebarNavigation.svelte b/frontend/src/lib/components/common/sidebar/SidebarNavigation.svelte new file mode 100644 index 0000000000..260e368e2e --- /dev/null +++ b/frontend/src/lib/components/common/sidebar/SidebarNavigation.svelte @@ -0,0 +1,77 @@ + + +
+ {#each groups as group (group.title)} +
+ {#if group.title} +
+ {group.title} +
+ {/if} + +
+ {/each} +
diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index a9282c24c5..a95f5c6d8e 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -361,7 +361,7 @@
-
Resource:
+
Resource:
{#if !repo.isUnsavedConnection && repo.git_repo_resource_path} -
+
{#if loadingResourceInfo}
@@ -392,7 +392,7 @@
{:else if resourceInfo?.url}
- Git URL: + Git URL: {resourceInfo.url} @@ -544,12 +544,13 @@

{displayTitle}

{#if displayDescription} -

{displayDescription} - {#if mode === 'promotion'} - Learn more about Git Promotion - {/if} +

{displayDescription} + {#if mode === 'promotion'} + Learn more about Git Promotion + {/if}

{/if}
@@ -580,16 +581,17 @@ {#if variant === 'primary-sync' || variant === 'primary-promotion'}
-
-
-

{displayTitle}

- {#if displayDescription} -

{displayDescription}

- {/if} -
-
- {@render headerActions()} +
+
+

{displayTitle}

+ +
+ {@render headerActions()} +
+ {#if displayDescription} +

{displayDescription}

+ {/if}
{@render repositoryContent()}
diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 52b4b4326d..7db74affd9 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -1,7 +1,7 @@ {#if !gitSyncContext} @@ -50,19 +52,15 @@
Loading git sync settings...
{:else} -
-
-
Git Sync
- - Connect the Windmill workspace to a Git repository to automatically commit and push scripts, - flows, and apps to the repository on each deploy. - -
- - Only new changes matching the filters will trigger a git sync. You still need to initialize - the repo to the desired state first. - -
+ + + Only new changes matching the filters will trigger a git sync. You still need to initialize the + repo to the desired state first. + {#if !$enterpriseLicense}
diff --git a/frontend/src/lib/components/settings/ChangeWorkspaceColor.svelte b/frontend/src/lib/components/settings/ChangeWorkspaceColor.svelte index e40b6293aa..f0aaa942a0 100644 --- a/frontend/src/lib/components/settings/ChangeWorkspaceColor.svelte +++ b/frontend/src/lib/components/settings/ChangeWorkspaceColor.svelte @@ -52,11 +52,14 @@
-

Workspace color

+

Workspace color

+

+ Color to identify the current workspace in the list of workspaces +

{#if $workspaceColor}
{:else} @@ -66,18 +69,14 @@ on:click={() => { open = true }} - size="xs" - spacingSize="xs2" - color="light" + unifiedSize="sm" + variant="subtle" iconOnly startIcon={{ icon: Pen }} />
-

- Color to identify the current workspace in the list of workspaces -

diff --git a/frontend/src/lib/components/settings/ChangeWorkspaceId.svelte b/frontend/src/lib/components/settings/ChangeWorkspaceId.svelte index a83e7d067f..3c46007c8b 100644 --- a/frontend/src/lib/components/settings/ChangeWorkspaceId.svelte +++ b/frontend/src/lib/components/settings/ChangeWorkspaceId.svelte @@ -57,7 +57,8 @@
-

Workspace ID

+

Workspace ID

+

Slug to uniquely identify your workspace

{$workspaceStore ?? ''}

{#if !isCloudHosted() || $superadmin} @@ -65,9 +66,8 @@ on:click={() => { open = true }} - size="xs" - spacingSize="xs2" - color="light" + unifiedSize="sm" + variant="subtle" iconOnly startIcon={{ icon: Pen @@ -75,7 +75,6 @@ /> {/if}
-

Slug to uniquely identify your workspace

@@ -106,7 +105,6 @@ {#snippet actions()}
- -

Displayable name

diff --git a/frontend/src/lib/components/settings/SettingsPageHeader.svelte b/frontend/src/lib/components/settings/SettingsPageHeader.svelte new file mode 100644 index 0000000000..361a4a876f --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsPageHeader.svelte @@ -0,0 +1,32 @@ + + +
+
+
+

{title}

+
+ {#if actions} +
+ {@render actions()} +
+ {/if} +
+ {#if description} + + {@html description} + + {/if} +
diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index ab0d7bc788..615b8dfb0c 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -6,6 +6,7 @@ import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import WorkspaceOperatorSettings from '$lib/components/settings/WorkspaceOperatorSettings.svelte' import InviteUser from '$lib/components/InviteUser.svelte' + import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import DataTable from '$lib/components/table/DataTable.svelte' import Head from '$lib/components/table/Head.svelte' @@ -92,14 +93,16 @@ async function loadSettings(): Promise { const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) - const autoInvite = settings.auto_invite as { - enabled?: boolean - domain?: string - operator?: boolean - mode?: string - instance_groups?: string[] - instance_groups_roles?: Record - } | undefined + const autoInvite = settings.auto_invite as + | { + enabled?: boolean + domain?: string + operator?: boolean + mode?: string + instance_groups?: string[] + instance_groups_roles?: Record + } + | undefined auto_invite_domain = autoInvite?.enabled ? (autoInvite?.domain ?? '*') : undefined operatorOnly = autoInvite?.operator ?? false autoAdd = autoInvite?.mode === 'add' @@ -399,30 +402,19 @@ bind:filteredItems={filteredUsers} f={(x) => x.email + ' ' + x.name + ' ' + x.company} /> -
-
-
- Add members to your workspace and manage their roles. You can also auto-add users to join your - workspace. - Learn more. -
-
-
-
+ description="Add members to your workspace and manage their roles. You can also auto-add users to join your workspace." + link="https://www.windmill.dev/docs/core_concepts/roles_and_permissions" +/> + +
{#snippet action()} -
- +
+ This runnable takes no arguments
+
This runnable takes no arguments
{/if} {:else} diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index fedcbf3ee9..c3a50fa820 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -638,7 +638,7 @@ /> {/await} {#if schema && schema.properties && Object.keys(schema.properties).length === 0} -
This runnable takes no arguments
+
This runnable takes no arguments
{/if} {:else} diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index d8fb7299f5..11e81763ab 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -5,8 +5,8 @@ import { AI_PROVIDERS, fetchAvailableModels } from '../copilot/lib' import { supportsAutocomplete } from '../copilot/utils' import TestAiKey from '../copilot/TestAIKey.svelte' - import Description from '../Description.svelte' import Label from '../Label.svelte' + import SettingsPageHeader from '../settings/SettingsPageHeader.svelte' import ResourcePicker from '../ResourcePicker.svelte' import Toggle from '../Toggle.svelte' import Select from '../select/Select.svelte' @@ -18,8 +18,9 @@ import ModelTokenLimits from './ModelTokenLimits.svelte' import { setCopilotInfo } from '$lib/aiStore' import AIPromptsModal from '../settings/AIPromptsModal.svelte' - import { Save, Settings } from 'lucide-svelte' + import { Settings } from 'lucide-svelte' import { slide } from 'svelte/transition' + import SettingsFooter from './SettingsFooter.svelte' let { aiProviders = $bindable(), @@ -28,7 +29,9 @@ customPrompts = $bindable(), maxTokensPerModel = $bindable(), usingOpenaiClientCredentialsOauth = $bindable(), - onSave + onSave, + onDiscard, + hasUnsavedChanges = false }: { aiProviders: Exclude codeCompletionModel: string | undefined @@ -37,6 +40,8 @@ maxTokensPerModel: Record usingOpenaiClientCredentialsOauth: boolean onSave?: () => void + onDiscard?: () => void + hasUnsavedChanges?: boolean } = $props() let fetchedAiModels = $state(false) @@ -170,29 +175,13 @@ const autocompleteModels = $derived(selectedAiModels.filter(supportsAutocomplete)) -
-
-
- Windmill AI
- - Windmill AI integrates with your favorite AI providers and models. - -
-
+ -
+
- -
+ + onDiscard?.()} + saveLabel="Save AI settings" + disabled={!Object.values(aiProviders).every((p) => p.resource_path) || + (codeCompletionModel != undefined && codeCompletionModel.length === 0) || + (Object.keys(aiProviders).length > 0 && !defaultModel)} +/> diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte index d6d069dbee..82f95a03c6 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte @@ -424,11 +424,11 @@
{/each} diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte index ec827a9533..c61449e24c 100644 --- a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte @@ -3,7 +3,7 @@ import { sendUserToast } from '$lib/utils' import { Button, Alert } from '$lib/components/common' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' - import Description from '$lib/components/Description.svelte' + import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import { Check, X, ExternalLink, Cog, Plug } from 'lucide-svelte' import { NextcloudIcon } from '$lib/components/icons' import { WorkspaceIntegrationService, type NativeServiceName } from '$lib/gen' @@ -203,18 +203,12 @@ }) -
-
-
Native Triggers (Beta)
- - 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. - - - Learn more about native triggers and workspace integrations. - -
+
+

Native Triggers is currently in beta. Nextcloud integration requires:

@@ -230,7 +224,9 @@ to be enabled on your Nextcloud instance. -
  • The Windmill integration app to be installed on your Nextcloud instance (not yet released).
  • +
  • The Windmill integration app to be installed on your Nextcloud instance (not yet released).
  • diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 9f585e41c2..a7de8a5424 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1297,6 +1297,15 @@ export function cleanValueProperties(obj: Value) { } } +export function hasUnsavedChanges(saved: Value, modified: Value): boolean { + const normalizedSaved = cleanValueProperties({ ...saved, path: undefined }) + const normalizedModified = cleanValueProperties({ ...modified, path: undefined }) + return ( + orderedJsonStringify(replaceFalseWithUndefined(normalizedSaved)) !== + orderedJsonStringify(replaceFalseWithUndefined(normalizedModified)) + ) +} + export function orderedJsonStringify(obj: any, space?: string | number) { const allKeys = new Set() JSON.stringify( diff --git a/frontend/src/lib/validators/workspaceSettings.ts b/frontend/src/lib/validators/workspaceSettings.ts new file mode 100644 index 0000000000..036a03aff6 --- /dev/null +++ b/frontend/src/lib/validators/workspaceSettings.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' + +// Deploy path filter validation +const deployPathFilterSchema = z + .string() + .min(1, 'Path filter cannot be empty') + .refine( + (val) => { + // Allow alphanumeric characters, underscores, slashes, asterisks, and hyphens + // But don't allow consecutive slashes or ending with slash + const validChars = /^[a-zA-Z0-9/_*-]+$/ + const noConsecutiveSlashes = !/\/\//.test(val) + const noEndingSlash = !val.endsWith('/') + const noEndingDash = !val.endsWith('-') + + return validChars.test(val) && noConsecutiveSlashes && noEndingSlash && noEndingDash + }, + { + message: + 'Path filter contains invalid characters or format. Allowed: letters, numbers, /, _, -, *. Cannot end with / or -' + } + ) + .refine( + (val) => { + // Validate glob patterns - * and ** are allowed, but *** is not + const invalidGlobPattern = /\*{3,}/.test(val) + return !invalidGlobPattern + }, + { + message: + 'Invalid glob pattern. Use * for single level wildcard or ** for multi-level wildcard' + } + ) + +// Webhook URL validation +const webhookUrlSchema = z + .string() + .refine( + (val) => { + if (!val || val.trim() === '') return true // Allow empty + try { + new URL(val) + return true + } catch { + return false + } + }, + { + message: 'Please enter a valid URL (e.g., https://example.com/webhook)' + } + ) + .optional() + .or(z.literal('')) + +// Workspace encryption key validation +const encryptionKeySchema = z.string().regex(/^[a-zA-Z0-9]{64}$/, { + message: 'Encryption key must be exactly 64 characters long and contain only letters and numbers' +}) + +export function validateDeployPathFilters(paths: string[]): { + isValid: boolean + errors: Record +} { + const errors: Record = {} + + paths.forEach((path, index) => { + const result = deployPathFilterSchema.safeParse(path) + if (!result.success) { + errors[index] = result.error.issues[0]?.message || 'Invalid path filter' + } + }) + + return { + isValid: Object.keys(errors).length === 0, + errors + } +} + +export function validateWebhookUrl(url: string): { + isValid: boolean + error?: string +} { + const result = webhookUrlSchema.safeParse(url) + return { + isValid: result.success, + error: result.success ? undefined : result.error.issues[0]?.message || 'Invalid URL' + } +} + +export function validateEncryptionKey(key: string): { + isValid: boolean + error?: string +} { + const result = encryptionKeySchema.safeParse(key) + return { + isValid: result.success, + error: result.success ? undefined : result.error.issues[0]?.message || 'Invalid encryption key' + } +} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index b59dcaaccf..f8f4c643e8 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -14,6 +14,7 @@ import Tooltip from '$lib/components/Tooltip.svelte' import WorkspaceUserSettings from '$lib/components/settings/WorkspaceUserSettings.svelte' + import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import { WORKSPACE_SHOW_SLACK_CMD, WORKSPACE_SHOW_WEBHOOK_CLI_SYNC } from '$lib/consts' import { OauthService, @@ -32,8 +33,9 @@ isCriticalAlertsUIOpen } from '$lib/stores' import { sendUserToast } from '$lib/toast' - import { clone, emptyString, encodeState } from '$lib/utils' - import { RotateCw, Save, Slack } from 'lucide-svelte' + import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils' + import { Slack } from 'lucide-svelte' + import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte' import Toggle from '$lib/components/Toggle.svelte' @@ -46,7 +48,6 @@ type S3ResourceSettings } from '$lib/workspace_settings' import { base } from '$lib/base' - import Description from '$lib/components/Description.svelte' import ConnectionSection from '$lib/components/ConnectionSection.svelte' import AISettings from '$lib/components/workspaceSettings/AISettings.svelte' import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte' @@ -61,11 +62,14 @@ import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import CollapseLink from '$lib/components/CollapseLink.svelte' + import { validateWebhookUrl, validateEncryptionKey } from '$lib/validators/workspaceSettings' import DataTableSettings, { convertDataTableSettingsFromBackend, type DataTableSettingsType } 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' let slackInitialPath: string = $state('') let slackScriptPath: string = $state('') @@ -118,6 +122,49 @@ let initialCustomPrompts: Record = $state({}) let initialMaxTokensPerModel: Record = $state({}) + // Track initial deploy settings for unsaved changes detection + let initialWorkspaceToDeployTo: string | undefined = $state(undefined) + let initialDeployUiSettings: { + include_path: string[] + include_type: { + scripts: boolean + flows: boolean + apps: boolean + resources: boolean + variables: boolean + secrets: boolean + triggers: boolean + } + } = $state({ + include_path: [], + include_type: { + scripts: true, + flows: true, + apps: true, + resources: true, + variables: true, + secrets: true, + triggers: true + } + }) + + // Track initial webhook for unsaved changes detection + let initialWebhook: string | undefined = $state(undefined) + + // Track initial encryption key for unsaved changes detection + let initialEditedWorkspaceEncryptionKey: string | undefined = $state(undefined) + + // Track initial error handler settings for unsaved changes detection + let initialErrorHandlerSelected: ErrorHandler = $state('slack') + let initialErrorHandlerScriptPath: string | undefined = $state(undefined) + let initialErrorHandlerItemKind: 'flow' | 'script' = $state('script') + let initialErrorHandlerExtraArgs: Record = $state({}) + let initialErrorHandlerMutedOnCancel: boolean | undefined = $state(undefined) + let initialErrorHandlerMutedOnUserPath: boolean | undefined = $state(undefined) + + // Track initial success handler for unsaved changes detection + let initialSuccessHandlerScriptPath: string | undefined = $state(undefined) + let s3ResourceSettings: S3ResourceSettings = $state({ resourceType: 's3', resourcePath: undefined, @@ -138,10 +185,97 @@ let ducklakeSavedSettings: DucklakeSettingsType = $state(untrack(() => ducklakeSettings)) let workspaceDefaultAppPath: string | undefined = $state(undefined) + let initialWorkspaceDefaultAppPath: string | undefined = $state(undefined) let workspaceEncryptionKey: string | undefined = $state(undefined) let editedWorkspaceEncryptionKey: string | undefined = $state(undefined) let workspaceReencryptionInProgress: boolean = $state(false) - let encryptionKeyRegex = /^[a-zA-Z0-9]{64}$/ + + // Validation state + let webhookValidationError: string | undefined = $state(undefined) + let encryptionKeyValidationError: string | undefined = $state(undefined) + + // Derived state for checking unsaved changes in error handler + let hasErrorHandlerChanges = $derived.by(() => { + if (tab !== 'error_handler') return false + const changes = getErrorHandlerSettingsInitialAndModifiedValues() + if (!changes.savedValue || !changes.modifiedValue) return false + return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) + }) + + // Derived state for checking unsaved changes in success handler + let hasSuccessHandlerChanges = $derived.by(() => { + if (tab !== 'error_handler') return false + return hasUnsavedChanges( + { successHandlerScriptPath: initialSuccessHandlerScriptPath }, + { successHandlerScriptPath: successHandlerScriptPath } + ) + }) + + // Derived state for checking unsaved changes in critical alert mute setting + let hasCriticalAlertMuteChanges = $derived.by(() => { + if (tab !== 'critical_alerts') return false + + // Normalize undefined to false for comparison + const currentValue = criticalAlertUIMuted ?? false + const initialValue = initialCriticalAlertUIMuted ?? false + + return currentValue !== initialValue + }) + + // Derived state for checking unsaved changes in AI settings + let hasAiSettingsChanges = $derived.by(() => { + if (tab !== 'ai') return false + const changes = getAiSettingsInitialAndModifiedValues() + if (!changes.savedValue || !changes.modifiedValue) return false + return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) + }) + + // Derived state for checking unsaved changes in deployment settings + let hasDeploySettingsChanges = $derived.by(() => { + if (tab !== 'deploy_to') return false + const changes = getDeploySettingsInitialAndModifiedValues() + if (!changes.savedValue || !changes.modifiedValue) return false + return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) + }) + + // Derived state for checking unsaved changes in webhook settings + let hasWebhookChanges = $derived.by(() => { + if (tab !== 'webhook') return false + const changes = getWebhookSettingsInitialAndModifiedValues() + if (!changes.savedValue || !changes.modifiedValue) return false + return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) + }) + + // Derived state for checking unsaved changes in encryption key settings + let hasEncryptionKeyChanges = $derived.by(() => { + if (tab !== 'encryption') return false + const changes = getEncryptionKeySettingsInitialAndModifiedValues() + if (!changes.savedValue || !changes.modifiedValue) return false + return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) + }) + + // Derived state for checking unsaved changes in default app settings + let hasDefaultAppChanges = $derived.by(() => { + if (tab !== 'default_app') return false + const changes = getDefaultAppSettingsInitialAndModifiedValues() + if (!changes.savedValue || !changes.modifiedValue) return false + return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) + }) + + // Validation effects + $effect(() => { + if (webhook !== undefined) { + const validation = validateWebhookUrl(webhook) + webhookValidationError = validation.error + } + }) + + $effect(() => { + if (editedWorkspaceEncryptionKey !== undefined) { + const validation = validateEncryptionKey(editedWorkspaceEncryptionKey) + encryptionKeyValidationError = validation.error + } + }) // All state derived from URL - no local state needed let tab = $derived.by(() => { const selectedTab = $page.url.searchParams.get('tab') as @@ -153,6 +287,8 @@ | 'webhook' | 'deploy_to' | 'error_handler' + | 'success_handler' + | 'critical_alerts' | 'ai' | 'windmill_data_tables' | 'windmill_lfs' @@ -165,6 +301,10 @@ if (selectedTab === 'teams') { return 'slack' } + // Both 'success_handler' and 'error_handler' URLs map to 'error_handler' tab + if (selectedTab === 'success_handler') { + return 'error_handler' + } return selectedTab || 'users' }) @@ -232,39 +372,63 @@ } async function editWebhook(): Promise { - // in JS, an empty string is also falsy - if (webhook) { - await WorkspaceService.editWebhook({ - workspace: $workspaceStore!, - requestBody: { webhook } - }) - sendUserToast(`webhook set to ${webhook}`) - } else { - await WorkspaceService.editWebhook({ - workspace: $workspaceStore!, - requestBody: { webhook: undefined } - }) - sendUserToast(`webhook removed`) + // Validate webhook URL if provided + if (webhook && webhook.trim() !== '') { + const validation = validateWebhookUrl(webhook) + if (!validation.isValid) { + sendUserToast(`Invalid webhook URL: ${validation.error}`, true) + return + } + } + + try { + if (webhook && webhook.trim() !== '') { + await WorkspaceService.editWebhook({ + workspace: $workspaceStore!, + requestBody: { webhook } + }) + sendUserToast(`webhook set to ${webhook}`) + initialWebhook = webhook + } else { + await WorkspaceService.editWebhook({ + workspace: $workspaceStore!, + requestBody: { webhook: undefined } + }) + sendUserToast(`webhook removed`) + initialWebhook = '' + webhook = '' + } + } catch (error) { + sendUserToast(`Failed to save webhook: ${error}`, true) } } - async function editWorkspaceDefaultApp(appPath: string | undefined): Promise { - if (emptyString(appPath)) { + async function editWorkspaceDefaultApp(): Promise { + if (emptyString(workspaceDefaultAppPath)) { await WorkspaceService.editWorkspaceDefaultApp({ workspace: $workspaceStore!, requestBody: { default_app_path: undefined } }) - sendUserToast('Workspace default app reset') } else { await WorkspaceService.editWorkspaceDefaultApp({ workspace: $workspaceStore!, requestBody: { - default_app_path: appPath + default_app_path: workspaceDefaultAppPath } }) - sendUserToast('Workspace default app set') + } + sendUserToast('Default app settings saved') + initialWorkspaceDefaultAppPath = workspaceDefaultAppPath + } + + async function saveDefaultAppSettings(): Promise { + if (workspaceDefaultAppPath !== initialWorkspaceDefaultAppPath) { + await editWorkspaceDefaultApp() + } + if (publicAppRateLimitPerMinute !== initialPublicAppRateLimitPerMinute) { + await editPublicAppRateLimit() } } @@ -274,6 +438,7 @@ }) workspaceEncryptionKey = resp.key editedWorkspaceEncryptionKey = resp.key + initialEditedWorkspaceEncryptionKey = resp.key } async function setWorkspaceEncryptionKey(): Promise { @@ -283,23 +448,36 @@ ) { return } - const timeStart = new Date().getTime() - workspaceReencryptionInProgress = true - await WorkspaceService.setWorkspaceEncryptionKey({ - workspace: $workspaceStore!, - requestBody: { - new_key: editedWorkspaceEncryptionKey ?? '' // cannot be undefined at this point - } - }) - await loadWorkspaceEncryptionKey() - const timeEnd = new Date().getTime() - sendUserToast('All workspace secrets have been re-encrypted with the new key') - setTimeout( - () => { - workspaceReencryptionInProgress = false - }, - 1000 - (timeEnd - timeStart) - ) + + // Validate encryption key + const validation = validateEncryptionKey(editedWorkspaceEncryptionKey!) + if (!validation.isValid) { + sendUserToast(`Invalid encryption key: ${validation.error}`, true) + return + } + + try { + const timeStart = new Date().getTime() + workspaceReencryptionInProgress = true + await WorkspaceService.setWorkspaceEncryptionKey({ + workspace: $workspaceStore!, + requestBody: { + new_key: editedWorkspaceEncryptionKey ?? '' // cannot be undefined at this point + } + }) + await loadWorkspaceEncryptionKey() + const timeEnd = new Date().getTime() + sendUserToast('All workspace secrets have been re-encrypted with the new key') + setTimeout( + () => { + workspaceReencryptionInProgress = false + }, + 1000 - (timeEnd - timeStart) + ) + } catch (error) { + workspaceReencryptionInProgress = false + sendUserToast(`Failed to set encryption key: ${error}`, true) + } } async function loadSettings(): Promise { @@ -339,7 +517,9 @@ initialCodeCompletionModel = codeCompletionModel initialCustomPrompts = clone(customPrompts) initialMaxTokensPerModel = clone(maxTokensPerModel) - const errorHandler = settings.error_handler as { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean } | undefined + const errorHandler = settings.error_handler as + | { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean } + | undefined const errorHandlerPath = errorHandler?.path ?? '' errorHandlerItemKind = errorHandlerPath ? (errorHandlerPath.split('/')[0] as 'flow' | 'script') @@ -357,9 +537,12 @@ errorHandlerSelected = getHandlerType(errorHandlerScriptPath) } errorHandlerExtraArgs = errorHandler?.extra_args ?? {} - const successHandler = settings.success_handler as { path?: string; extra_args?: any } | undefined + const successHandler = settings.success_handler as + | { path?: string; extra_args?: any } + | undefined successHandlerScriptPath = (successHandler?.path ?? '').split('/').slice(1).join('/') workspaceDefaultAppPath = settings.default_app + initialWorkspaceDefaultAppPath = settings.default_app s3ResourceSettings = convertBackendSettingsToFrontendSettings( settings.large_file_storage, @@ -388,6 +571,27 @@ } } + // Store initial deploy settings state for unsaved changes detection + initialWorkspaceToDeployTo = workspaceToDeployTo + initialDeployUiSettings = clone(deployUiSettings) + + // Store initial webhook state for unsaved changes detection + initialWebhook = webhook + + // Store initial encryption key state for unsaved changes detection + initialEditedWorkspaceEncryptionKey = editedWorkspaceEncryptionKey + + // Store initial error handler state for unsaved changes detection + initialErrorHandlerSelected = errorHandlerSelected + initialErrorHandlerScriptPath = errorHandlerScriptPath + initialErrorHandlerItemKind = errorHandlerItemKind + initialErrorHandlerExtraArgs = clone(errorHandlerExtraArgs) + initialErrorHandlerMutedOnCancel = errorHandlerMutedOnCancel + initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath + + // Store initial success handler state for unsaved changes detection + initialSuccessHandlerScriptPath = successHandlerScriptPath + // check openai_client_credentials_oauth usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({ workspace: $workspaceStore!, @@ -478,20 +682,29 @@ } } - let deployUiSettings: - | { - include_path: string[] - include_type: { - scripts: boolean - flows: boolean - apps: boolean - resources: boolean - variables: boolean - secrets: boolean - triggers: boolean - } - } - | undefined = $state() + let deployUiSettings: { + include_path: string[] + include_type: { + scripts: boolean + flows: boolean + apps: boolean + resources: boolean + variables: boolean + secrets: boolean + triggers: boolean + } + } = $state({ + include_path: [], + include_type: { + scripts: true, + flows: true, + apps: true, + resources: true, + variables: true, + secrets: true, + triggers: true + } + }) $effect(() => { if ($workspaceStore) { @@ -527,6 +740,14 @@ }) sendUserToast(`workspace error handler removed`) } + + // Update initial values for dirty detection + initialErrorHandlerSelected = errorHandlerSelected + initialErrorHandlerScriptPath = errorHandlerScriptPath + initialErrorHandlerItemKind = errorHandlerItemKind + initialErrorHandlerExtraArgs = clone(errorHandlerExtraArgs) + initialErrorHandlerMutedOnCancel = errorHandlerMutedOnCancel + initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath } async function editSuccessHandler() { @@ -547,6 +768,9 @@ }) sendUserToast(`workspace success handler removed`) } + + // Update initial value for dirty detection + initialSuccessHandlerScriptPath = successHandlerScriptPath } async function editCriticalAlertMuteSetting() { @@ -559,6 +783,10 @@ sendUserToast( `Critical alert UI mute setting for workspace is set to ${criticalAlertUIMuted}\nreloading page...` ) + + // Update initial value for dirty detection + initialCriticalAlertUIMuted = criticalAlertUIMuted + // reload page after change of setting setTimeout(() => { window.location.reload() @@ -582,14 +810,6 @@ // Function to check if there are unsaved changes in AI settings function getAiSettingsInitialAndModifiedValues() { - // Only check for unsaved changes when on the AI tab - if (tab !== 'ai') { - return { - savedValue: undefined, - modifiedValue: undefined - } - } - const savedValue = { aiProviders: initialAiProviders, defaultModel: initialDefaultModel, @@ -620,14 +840,6 @@ // Function to check if there are unsaved changes in storage settings function getStorageSettingsInitialAndModifiedValues() { - // Only check for unsaved changes when on the windmill_lfs tab - if (tab !== 'windmill_lfs') { - return { - savedValue: undefined, - modifiedValue: undefined - } - } - const savedValue = { s3ResourceSettings: s3ResourceSavedSettings, ducklakeSettings: ducklakeSavedSettings @@ -647,41 +859,345 @@ ducklakeSettings = clone(ducklakeSavedSettings) } + // Function to check if there are unsaved changes in deploy settings + function getDeploySettingsInitialAndModifiedValues() { + // Normalize empty strings to undefined for consistent comparison + const normalizeWorkspaceValue = (value: string | undefined) => + value === '' ? undefined : value + + const savedValue = { + workspaceToDeployTo: normalizeWorkspaceValue(initialWorkspaceToDeployTo), + deployUiSettings: initialDeployUiSettings + } + + const modifiedValue = { + workspaceToDeployTo: normalizeWorkspaceValue(workspaceToDeployTo), + deployUiSettings: deployUiSettings + } + + return { savedValue, modifiedValue } + } + + // Function to discard unsaved deploy settings changes + function discardDeploySettingsChanges() { + workspaceToDeployTo = initialWorkspaceToDeployTo + deployUiSettings = clone(initialDeployUiSettings) + } + + // Function to check if there are unsaved changes in webhook settings + function getWebhookSettingsInitialAndModifiedValues() { + // Normalize empty strings to undefined for consistent comparison + const normalizeWebhookValue = (value: string | undefined) => + value && value.trim() !== '' ? value : undefined + + const savedValue = { + webhook: normalizeWebhookValue(initialWebhook) + } + + const modifiedValue = { + webhook: normalizeWebhookValue(webhook) + } + + return { savedValue, modifiedValue } + } + + // Function to discard unsaved webhook settings changes + function discardWebhookSettingsChanges() { + webhook = initialWebhook || '' + } + + // Function to check if there are unsaved changes in encryption key settings + function getEncryptionKeySettingsInitialAndModifiedValues() { + const savedValue = { + editedWorkspaceEncryptionKey: initialEditedWorkspaceEncryptionKey + } + + const modifiedValue = { + editedWorkspaceEncryptionKey: editedWorkspaceEncryptionKey + } + + return { savedValue, modifiedValue } + } + + // Function to discard unsaved encryption key settings changes + function discardEncryptionKeySettingsChanges() { + editedWorkspaceEncryptionKey = initialEditedWorkspaceEncryptionKey + } + + // Function to check if there are unsaved changes in default app settings + function getDefaultAppSettingsInitialAndModifiedValues() { + return { + savedValue: { + defaultAppPath: initialWorkspaceDefaultAppPath, + publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute + }, + modifiedValue: { + defaultAppPath: workspaceDefaultAppPath, + publicAppRateLimitPerMinute: publicAppRateLimitPerMinute + } + } + } + + // Function to discard unsaved default app settings changes + function discardDefaultAppSettingsChanges() { + workspaceDefaultAppPath = initialWorkspaceDefaultAppPath + publicAppRateLimitPerMinute = initialPublicAppRateLimitPerMinute + } + + // Strip keys from extraArgs that are auto-managed by child components: + // - 'slack': computed by ErrorOrRecoveryHandler's $effect based on handler type + // - 'channel_name': display metadata stripped by SchemaForm's removeExtraKey() + function normalizeHandlerExtraArgs(args: Record): Record { + const { slack: _, channel_name: __, ...rest } = args + return rest + } + + // Function to check if there are unsaved changes in error handler settings + function getErrorHandlerSettingsInitialAndModifiedValues() { + const savedValue = { + errorHandlerSelected: initialErrorHandlerSelected, + errorHandlerScriptPath: initialErrorHandlerScriptPath, + errorHandlerItemKind: initialErrorHandlerItemKind, + errorHandlerExtraArgs: normalizeHandlerExtraArgs(initialErrorHandlerExtraArgs), + errorHandlerMutedOnCancel: initialErrorHandlerMutedOnCancel, + errorHandlerMutedOnUserPath: initialErrorHandlerMutedOnUserPath + } + + const modifiedValue = { + errorHandlerSelected: errorHandlerSelected, + errorHandlerScriptPath: errorHandlerScriptPath, + errorHandlerItemKind: errorHandlerItemKind, + errorHandlerExtraArgs: normalizeHandlerExtraArgs(errorHandlerExtraArgs), + errorHandlerMutedOnCancel: errorHandlerMutedOnCancel, + errorHandlerMutedOnUserPath: errorHandlerMutedOnUserPath + } + + return { savedValue, modifiedValue } + } + + // Function to discard unsaved error handler settings changes + function discardErrorHandlerSettingsChanges() { + errorHandlerSelected = initialErrorHandlerSelected + errorHandlerScriptPath = initialErrorHandlerScriptPath + errorHandlerItemKind = initialErrorHandlerItemKind + errorHandlerExtraArgs = clone(initialErrorHandlerExtraArgs) + errorHandlerMutedOnCancel = initialErrorHandlerMutedOnCancel + errorHandlerMutedOnUserPath = initialErrorHandlerMutedOnUserPath + } + // Combined function to check for unsaved changes across all tabs function getAllUnsavedChanges() { - if (dataTableSettingsComponent) { - return dataTableSettingsComponent.unsavedChanges() - } - - // Check AI settings - const aiChanges = getAiSettingsInitialAndModifiedValues() - if (aiChanges.savedValue && aiChanges.modifiedValue) { - return aiChanges - } - - // Check storage settings - const storageChanges = getStorageSettingsInitialAndModifiedValues() - if (storageChanges.savedValue && storageChanges.modifiedValue) { - return storageChanges - } - - return { - savedValue: {}, - modifiedValue: {} + switch (tab) { + case 'windmill_data_tables': + return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } + case 'ai': + return getAiSettingsInitialAndModifiedValues() + case 'windmill_lfs': + return getStorageSettingsInitialAndModifiedValues() + case 'deploy_to': + return getDeploySettingsInitialAndModifiedValues() + case 'webhook': + return getWebhookSettingsInitialAndModifiedValues() + case 'encryption': + return getEncryptionKeySettingsInitialAndModifiedValues() + case 'error_handler': { + const errorValues = getErrorHandlerSettingsInitialAndModifiedValues() + return { + savedValue: { + ...(errorValues.savedValue ?? {}), + successHandlerScriptPath: initialSuccessHandlerScriptPath + }, + modifiedValue: { + ...(errorValues.modifiedValue ?? {}), + successHandlerScriptPath: successHandlerScriptPath + } + } + } + case 'critical_alerts': + return { + savedValue: { criticalAlertUIMuted: initialCriticalAlertUIMuted }, + modifiedValue: { criticalAlertUIMuted: criticalAlertUIMuted } + } + case 'default_app': + return getDefaultAppSettingsInitialAndModifiedValues() + default: + return { savedValue: {}, modifiedValue: {} } } } // Combined function to discard changes based on current tab function discardAllChanges() { - if (tab === 'ai') { - discardAiSettingsChanges() - } else if (tab === 'windmill_lfs') { - discardStorageSettingsChanges() + switch (tab) { + case 'ai': + discardAiSettingsChanges() + break + case 'windmill_lfs': + discardStorageSettingsChanges() + break + case 'deploy_to': + discardDeploySettingsChanges() + break + case 'webhook': + discardWebhookSettingsChanges() + break + case 'encryption': + discardEncryptionKeySettingsChanges() + break + case 'error_handler': + discardErrorHandlerSettingsChanges() + successHandlerScriptPath = initialSuccessHandlerScriptPath + break + case 'critical_alerts': + criticalAlertUIMuted = initialCriticalAlertUIMuted + break + case 'windmill_data_tables': + dataTableSettingsComponent?.discard() + break + case 'default_app': + discardDefaultAppSettingsChanges() + break } } + + // Navigation groups for sidebar + const navigationGroups = $derived([ + { + items: [ + { + id: 'general', + label: 'General', + aiId: 'workspace-settings-general', + aiDescription: 'General workspace settings' + }, + { + id: 'users', + label: 'Users', + aiId: 'workspace-settings-users', + aiDescription: 'Users workspace settings' + }, + { + id: 'ai', + label: 'Windmill AI', + aiId: 'workspace-settings-ai', + aiDescription: 'Windmill AI workspace settings' + }, + { + id: 'premium', + label: 'Premium plans', + aiId: 'workspace-settings-premium', + aiDescription: 'Premium plans workspace settings', + showIf: isCloudHosted() + } + ] + }, + { + title: 'Git & deployment', + items: [ + { + id: 'git_sync', + label: 'Git sync', + aiId: 'workspace-settings-git-sync', + aiDescription: 'Git sync workspace settings', + isEE: true + }, + { + id: 'deploy_to', + label: 'Deployment UI', + aiId: 'workspace-settings-deploy-to', + aiDescription: 'Deployment UI workspace settings', + isEE: true + } + ] + }, + { + title: 'Integrations', + items: [ + { + id: 'slack', + label: 'Slack / Teams', + aiId: 'workspace-settings-slack', + aiDescription: 'Slack / Teams workspace settings', + showIf: WORKSPACE_SHOW_SLACK_CMD + }, + { + id: 'webhook', + label: 'Webhook', + aiId: 'workspace-settings-webhook', + aiDescription: 'Webhook workspace settings', + showIf: WORKSPACE_SHOW_WEBHOOK_CLI_SYNC + }, + { + id: 'native_triggers', + label: 'Native triggers (Beta)', + aiId: 'workspace-settings-integrations', + aiDescription: 'Workspace integrations for native triggers' + } + ] + }, + { + title: 'Hooks', + items: [ + { + id: 'error_handler', + label: 'Error / success handler', + aiId: 'workspace-settings-error-handler', + aiDescription: 'Error and success handler workspace settings', + isEE: true + }, + { + id: 'critical_alerts', + label: 'Critical alerts', + aiId: 'workspace-settings-critical-alerts', + aiDescription: 'Critical alerts workspace settings', + isEE: true + } + ] + }, + { + title: 'Data & storage', + items: [ + { + id: 'windmill_data_tables', + label: 'Data tables', + aiId: 'workspace-settings-windmill-data-tables', + aiDescription: 'Data tables workspace settings' + }, + { + id: 'windmill_lfs', + label: 'Object storage (S3)', + aiId: 'workspace-settings-windmill-lfs', + aiDescription: 'Object Storage (S3) workspace settings' + } + ] + }, + { + title: 'Advanced', + items: [ + { + id: 'default_app', + label: 'Apps', + aiId: 'workspace-settings-apps', + aiDescription: 'Apps workspace settings', + isEE: true + }, + { + id: 'dependencies', + label: 'Dependencies', + aiId: 'workspace-settings-dependencies', + aiDescription: 'Workspace dependencies settings' + }, + { + id: 'encryption', + label: 'Encryption', + aiId: 'workspace-settings-encryption', + aiDescription: 'Encryption workspace settings' + } + ] + } + ]) - + {#if $userStore?.is_admin || $superadmin} {#if $superadmin} @@ -691,560 +1207,478 @@ {/if} -
    - { - // setQueryWithoutLoad($page.url, [{ key: 'tab', value: tab }], 0) - const params = new URLSearchParams($page.url.searchParams) - const newTab = e.detail - params.set('tab', newTab) - goto(`?${params.toString()}`) - }} - > - - - - - {#if WORKSPACE_SHOW_SLACK_CMD} - - {/if} - {#if isCloudHosted()} - - {/if} - {#if WORKSPACE_SHOW_WEBHOOK_CLI_SYNC} - - {/if} - - - - - - - - - - - - -
    - {#if !loadedSettings} - - {:else if tab == 'users'} - - {:else if tab == 'deploy_to'} -
    -
    -
    - Link this Workspace to another Staging / Prod Workspace -
    - - Connecting this workspace with another staging/production workspace enables web-based - deployment to that workspace. - -
    -
    - {#if $enterpriseLicense} - - {:else} -
    Deploy to staging/prod from the web UI is only available with an enterprise license
    - {/if} - {:else if tab == 'premium'} - - {:else if tab == 'slack'} -
    -
    - { +
    + +
    + { const params = new URLSearchParams($page.url.searchParams) - if (e.detail === 'teams_commands') { - params.set('tab', 'teams') - } else { - params.set('tab', 'slack') - } + params.set('tab', id) goto(`?${params.toString()}`) }} - > - - - + /> +
    - {#if slack_tabs === 'slack_commands'} - { - if (slackOAuthConfigLoaded) { - deleteSlackOAuthConfig() - } else { - await OauthService.disconnectSlack({ workspace: $workspaceStore ?? '' }) - loadSettings() - sendUserToast('Disconnected Slack') - } - }} - onSelect={editSlackCommand} - connectHref="{base}/api/oauth/connect_slack" - createScriptHref="{base}/scripts/add?hub=hub%2F28071%2Fslack%2Fexample_of_responding_to_a_slack_command_slack" - createFlowHref="{base}/flows/add?hub=28" - documentationLink="https://www.windmill.dev/docs/integrations/slack" - onLoadSettings={loadSettings} - display_name={slack_team_name} - hideConnectButton={useCustomSlackApp && !slackOAuthConfigLoaded} - isOAuthEnabled={isSlackOAuthEnabled} - workspaceSpecificConnection={slackOAuthConfigLoaded} - > - {#snippet workspaceConfig()} - - {#if !slack_team_name} -
    - - - {#snippet children({ item })} - - - {/snippet} - -
    Use the Slack app configured at the instance level if you want to use the same - Slack app for all workspaces. Configure your Slack app here if you want to use a - specific Slack app for this workspace.
    -
    + +
    +
    +
    + {#if !loadedSettings} + + {:else if tab == 'users'} + + {:else if tab == 'deploy_to'} + + {#if $enterpriseLicense} + { + // Update initial state after successful save + initialWorkspaceToDeployTo = workspaceToDeployTo + initialDeployUiSettings = clone(deployUiSettings) + }} + onDiscard={discardDeploySettingsChanges} + onWorkspaceToDeployToSave={(newWorkspaceToDeployTo) => { + // Update initial state after workspace to deploy to is saved + initialWorkspaceToDeployTo = newWorkspaceToDeployTo + }} + /> + {:else} +
    Deploy to staging/prod from the web UI is only available with an enterprise + license
    {/if} - {#if slackOAuthConfigLoaded} - -
    -
    Client ID
    - + {:else if tab == 'slack'} + +
    + { + const params = new URLSearchParams($page.url.searchParams) + if (e.detail === 'teams_commands') { + params.set('tab', 'teams') + } else { + params.set('tab', 'slack') + } + goto(`?${params.toString()}`) + }} + > + + + + + {#if slack_tabs === 'slack_commands'} + { + if (slackOAuthConfigLoaded) { + deleteSlackOAuthConfig() + } else { + await OauthService.disconnectSlack({ workspace: $workspaceStore ?? '' }) + loadSettings() + sendUserToast('Disconnected Slack') + } }} - value={slackOAuthClientId} - /> -
    Client ID for the Slack app configured at the workspace level
    -
    - {:else if slackAppType === 'workspace'} -
    - - - - - -
    - Create a Slack app at{' '} - - Slack API - . Set the redirect URI to:{' '} - - {window.location.origin}{base}/oauth/callback_slack - -
    -
    - -
    - -
    + {#snippet workspaceConfig()} + {#if !isTeamsOAuthEnabled} + + Teams OAuth is not configured at the instance level. Please ask your + administrator to configure Teams OAuth settings in the instance settings + before you can use Teams features. + + {/if} + {/snippet} + + {/if} + {/if} +
    + {:else if tab == 'general'} + + +
    + + + +
    + +
    Export workspace
    +
    + +
    + +
    + Delete workspace + {#if !$superadmin} +

    + Only instance superadmins can delete a workspace. +

    + {/if} + {#if $workspaceStore === 'admins' || $workspaceStore === 'starter'} +

    + This workspace cannot be deleted as it has a special function. Consult the + documentation for more information. +

    + {/if} +
    + + + {#if $superadmin} + + {/if} +
    + {:else if tab == 'webhook'} + + +
    +
    URL to send requests to
    +
    + 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.
    - {:else if !isSlackOAuthEnabled} - - Slack OAuth is not configured at the instance level. Please ask your administrator - to configure Slack OAuth settings in the instance settings before you can use - Slack features. + +
    + + {#if webhookValidationError} +
    {webhookValidationError}
    + {/if} +
    +
    + + + {:else if tab == 'error_handler'} + + {#if !$enterpriseLicense} + + Workspace error and success handlers are Windmill EE features. {/if} - {/snippet} - - {:else if slack_tabs === 'teams_commands'} - {#if !$enterpriseLicense} -
    - - Workspace Teams commands is a Windmill EE feature. It enables using your current Slack - / Teams connection to run a custom script and send notifications. - -
    - {:else} - { - await OauthService.disconnectTeams({ workspace: $workspaceStore ?? '' }) - loadSettings() - sendUserToast('Disconnected Teams') - }} - onSelect={editTeamsCommand} - connectHref={undefined} - createScriptHref="{base}/scripts/add?hub=hub%2F11591%2Fteams%2FExample%20of%20responding%20to%20a%20Microsoft%20Teams%20command" - createFlowHref="{base}/flows/add?hub=58" - documentationLink="https://www.windmill.dev/docs/integrations/teams" - onLoadSettings={loadSettings} - display_name={teams_team_name} - isOAuthEnabled={isTeamsOAuthEnabled} - > - {#snippet workspaceConfig()} - {#if !isTeamsOAuthEnabled} - - Teams OAuth is not configured at the instance level. Please ask your - administrator to configure Teams OAuth settings in the instance settings before - you can use Teams features. - - {/if} - {/snippet} - - {/if} - {/if} -
    - {:else if tab == 'general'} -
    -
    -
    General
    - - Configure general workspace settings. - -
    -
    -
    - - - -
    +
    +
    + + {#snippet customTabTooltip()} + +
    +
    + The following args will be passed to the error handler: +
      +
    • path: The path of the script or flow that errored.
    • +
    • + email: The email of the user who ran the script or flow that + errored. +
    • +
    • error: The error details.
    • +
    • job_id: The job id.
    • +
    • is_flow: Whether the error comes from a flow.
    • +
    • workspace_id: The workspace id of the failed script or flow.
    • +
    +
    + The error handler will be executed by the automatically created group g/error_handler. + If your error handler requires variables or resources, you need to add them + to the group. +
    +
    +
    + {/snippet} +
    -
    Export workspace
    -
    - -
    - -
    - Delete workspace - {#if !$superadmin} -

    Only instance superadmins can delete a workspace.

    - {/if} - {#if $workspaceStore === 'admins' || $workspaceStore === 'starter'} -

    - This workspace cannot be deleted as it has a special function. Consult the documentation - for more information. -

    - {/if} -
    - - - {#if $superadmin} - - {/if} -
    - {:else if tab == 'webhook'} -
    -
    -
    Workspace Webhook
    - - Connect your Windmill workspace to an external service to sync or get notified about any - change. - -
    -
    -
    -
    -
    URL to send requests to
    -
    - 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. -
    -
    -
    -
    - - -
    - {:else if tab == 'error_handler'} - {#if !$enterpriseLicense} -
    - - Workspace error handler is a Windmill EE feature. It enables using your current Slack - connection or a custom script to send notifications anytime any job would fail. - - {/if} -
    -
    - - {#snippet customTabTooltip()} - -
    -
    - The following args will be passed to the error handler: -
      -
    • path: The path of the script or flow that errored.
    • -
    • - email: The email of the user who ran the script or flow that errored. -
    • -
    • error: The error details.
    • -
    • job_id: The job id.
    • -
    • is_flow: Whether the error comes from a flow.
    • -
    • workspace_id: The workspace id of the failed script or flow.
    • -
    -
    - The error handler will be executed by the automatically created group g/error_handler. - If your error handler requires variables or resources, you need to add them to the - group. +
    + +
    - - {/snippet} - -
    - - - -
    -
    + +
    -
    -
    - {#if !$enterpriseLicense} - - Workspace success handler is a Windmill Enterprise Edition feature that allows you to - run a script whenever any job in the workspace completes successfully. - - {/if} -
    -
    - { - successHandlerScriptPath = ev?.detail?.path - }} - /> -
    + +
    +
    +
    +
    + { + successHandlerScriptPath = ev?.detail?.path + }} + clearable + /> + -
    -
    + language: 'bun', + kind: 'script' + })} + target="_blank" + > + Create from template + +
    +
    +
    -
    - - {#if successHandlerScriptPath} -
    + {:else if tab == 'critical_alerts'} + +
    + {#if !$enterpriseLicense} + + Workspace critical alerts is a Windmill Enterprise Edition feature that sends + notifications to workspace admins when critical events occur. + + {/if} + + +
    + +
    +
    + + { + criticalAlertUIMuted = initialCriticalAlertUIMuted }} + saveLabel="Save mute setting" + disabled={!$enterpriseLicense} + /> + {:else if tab == 'ai'} + { + // Update initial state after successful save + initialAiProviders = clone(aiProviders) + initialDefaultModel = defaultModel + initialCodeCompletionModel = codeCompletionModel + initialCustomPrompts = clone(customPrompts) + initialMaxTokensPerModel = clone(maxTokensPerModel) + }} + /> + {:else if tab == 'windmill_data_tables'} + + {:else if tab == 'windmill_lfs'} + { + s3ResourceSavedSettings = clone(s3ResourceSettings) + }} + onDiscard={() => { + s3ResourceSettings = clone(s3ResourceSavedSettings) + }} + /> + { + ducklakeSavedSettings = clone(ducklakeSettings) + }} + onDiscard={() => { + ducklakeSettings = clone(ducklakeSavedSettings) + }} + /> + {:else if tab == 'git_sync'} + {#if $workspaceStore} + + {:else} +
    +
    Loading workspace...
    +
    + {/if} + {:else if tab == 'dependencies'} + + {:else if tab == 'default_app'} + + {#if !$enterpriseLicense} + + Default app can only be set on Windmill Enterprise Edition. + + {:else} + + Make sure the default app is shared with all the operators of this workspace + before turning this feature on. + + {/if} + + + + + + {:else if tab == 'native_triggers'} + {#if $workspaceStore} + {#await import('$lib/components/workspaceSettings/WorkspaceIntegrations.svelte') then { default: WorkspaceIntegrations }} + + {/await} + {:else} +
    +
    Loading workspace...
    +
    + {/if} + {:else if tab == 'encryption'} + +
    + +
    +
    + + +
    + {#if encryptionKeyValidationError} +
    + {encryptionKeyValidationError} +
    + {/if} +
    + + {/if}
    - - -
    -
    - {#if !$enterpriseLicense} - - Workspace critical alerts is a Windmill Enterprise Edition feature that sends - notifications to workspace admins when critical events occur. - - {/if} - - - - - -
    -
    - {:else if tab == 'ai'} - { - // Update initial state after successful save - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) - }} - /> - {:else if tab == 'windmill_data_tables'} - - {:else if tab == 'windmill_lfs'} - { - s3ResourceSavedSettings = clone(s3ResourceSettings) - }} - /> - { - ducklakeSavedSettings = clone(ducklakeSettings) - }} - /> - {:else if tab == 'git_sync'} - {#if $workspaceStore} - - {:else} -
    -
    Loading workspace...
    -
    - {/if} - {:else if tab == 'dependencies'} - - {:else if tab == 'default_app'} -
    -
    -
    Workspace Default App
    - - If configured, users who are operators in this workspace will be redirected to this app - automatically when logging into this workspace. - - - Make sure the default app is shared with all the operators of this workspace before - turning this feature on. -
    - {#if !$enterpriseLicense} - - Default app can only be set on Windmill Enterprise Edition. - - {/if} - - Make sure the default app is shared with all the operators of this workspace before turning - this feature on. - -
    - {#key workspaceDefaultAppPath} - { - editWorkspaceDefaultApp(ev?.detail?.path) - }} - /> - {/key} -
    - -
    -
    -
    - - executions per minute per server -
    - - -
    - {:else if tab == 'native_triggers'} - {#if $workspaceStore} - {#await import('$lib/components/workspaceSettings/WorkspaceIntegrations.svelte') then { default: WorkspaceIntegrations }} - - {/await} - {:else} -
    -
    Loading workspace...
    -
    - {/if} - {:else if tab == 'encryption'} -
    -
    -
    Workspace Secret Encryption
    - - 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. - -
    -
    -
    - -
    - -
    - - -
    - {#if !emptyString(editedWorkspaceEncryptionKey) && !encryptionKeyRegex.test(editedWorkspaceEncryptionKey ?? '')} -
    - Key invalid - it should be 64 characters long and only contain letters and numbers. -
    - {/if} - {/if} +
    {:else}