fix(frontend): reorganize workspace settings (#7788)

* Add vertical nav bar to workspace settings

* harmonize settings content titles

* remove sidebar icons

* add background to sidebar

* nit user section

* EEonly display

* Workspace settings general design

* Add schema validation and dirty detection

* Put critical alerts in a separated tab

* separate error success handler

* only enable save when there is some changes

* Fix dirty detection for deployment UI

* Only enable save button when changes for datatables ws storage

* Add setting footer component

* Use new footer setting for saving configs

* nit

* apply setting footer

* improve save button

* nit

* nit

* nit

* make ws app use same pattern as other tabs

* Separate scrolling between sidebar and content

* Gather error handlers

* use universal save button for object storage

* Title sentence case

* nit

* nit

* improve dirty config logic

* nit

* nit

* clean dead code

* Use settings footer for deployment settings

* Git sync settings

* move tabs

* fix dirty stats of error handlers

* nit

* nit
This commit is contained in:
Guilhem
2026-02-09 18:22:22 +00:00
committed by GitHub
parent b1d6ac91bd
commit dd421845ba
27 changed files with 2116 additions and 1278 deletions
@@ -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<number, string> = $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)
}
}
</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}
on:change={async (e) => {
await WorkspaceService.editDeployTo({
workspace: $workspaceStore ?? '',
requestBody: { deploy_to: workspaceToDeployTo == '' ? undefined : workspaceToDeployTo }
})
if (workspaceToDeployTo == '') {
workspaceToDeployTo = undefined
sendUserToast('Disabled setting deployable workspace')
} else {
sendUserToast('Set deployable workspace to ' + workspaceToDeployTo)
}
}}
>
<select bind:value={workspaceToDeployTo}>
{#if deployableWorkspaces?.length == 0}
<option disabled>No workspace deployable to</option>
{/if}
@@ -113,8 +163,12 @@
{/each}
</select>
</div>
<h3 class="mt-6 mb-3 text-sm font-semibold text-emphasis">Deployable items</h3>
<div class="flex flex-wrap gap-20">
<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)}
<h4 class="flex gap-2 mb-2 text-xs font-semibold text-emphasis"
@@ -125,20 +179,36 @@
anything including slashes.
</Tooltip></h4
>
{#each deployUiSettings.include_path ?? [] as regexpPath, idx}
<div class="flex mt-1 items-center">
<input type="text" bind:value={regexpPath} id="arg-input-array" />
<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"
on:click={() => {
deployUiSettings.include_path.splice(idx, 1)
deployUiSettings.include_path = [...deployUiSettings.include_path]
}}
>
<X size={14} />
</button>
{#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}
@@ -197,14 +267,18 @@
</div>
</div>
</div>
{#if $enterpriseLicense}
<div class="flex mt-5 mb-5 gap-1">
<Button
variant="accent"
disabled={workspaceToDeployTo == undefined}
on:click={() => {
editWindmillDeploymentUISettings()
}}>Save Deployment UI settings</Button
>
</div>
{#if hasValidationErrors}
<Alert type="error" title="Validation Errors" class="mt-4">
Please fix the validation errors in the path filters before saving.
</Alert>
{/if}
{#if $enterpriseLicense}
<SettingsFooter
{hasUnsavedChanges}
onSave={editWindmillDeploymentUISettings}
{onDiscard}
saveLabel="Save deployment UI"
disabled={workspaceToDeployTo == undefined || hasValidationErrors}
class="border-none"
/>
{/if}
+4 -15
View File
@@ -1,27 +1,16 @@
<script lang="ts">
import { Building } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { Tooltip } from './meltComponents'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import Badge from './common/badge/Badge.svelte'
interface Props {
class?: string
children?: import('svelte').Snippet
}
let { class: className = '', children = undefined }: Props = $props()
let { children = undefined }: Props = $props()
</script>
<Tooltip>
<div
class={twMerge(
'flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap px-1',
className
)}
aria-label="Enterprise Edition only feature"
role="tooltip"
>
EE only <Building size={16} />
</div>
<Badge verySmall color="blue" class="px-2">EE only</Badge>
{#snippet text()}
{#if children}
{@render children()}
@@ -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<Record<ErrorHandler, string | undefined>> = $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 @@
})
</script>
<div class="mt-2 space-y-2">
<div class={classNames('space-y-2', noMargin ? '' : 'mt-2')}>
<ToggleButtonGroup bind:selected={handlerSelected} disabled={!isEditable}>
{#snippet children({ item })}
<ToggleButton label="Slack" value="slack" {item} disabled={!isEditable} />
@@ -361,56 +368,60 @@
<div class="flex flex-col gap-6 p-4 rounded-md border">
{#if handlerSelected === 'custom'}
<div class="flex flex-row mb-6">
<ScriptPicker
disabled={!isEditable || !$enterpriseLicense}
kinds={['script', 'failure']}
allowFlow={true}
bind:scriptPath={handlerPath}
bind:itemKind={customHandlerKind}
allowRefresh={isEditable}
clearable
/>
<div class="flex flex-col gap-1">
<div class="flex flex-row">
<ScriptPicker
disabled={!isEditable || !$enterpriseLicense}
kinds={['script', 'failure']}
allowFlow={true}
bind:scriptPath={handlerPath}
bind:itemKind={customHandlerKind}
allowRefresh={isEditable}
clearable
/>
{#if !handlerPath}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
size="xs"
href={customScriptTemplate}
disabled={!isEditable}
target="_blank"
>
Create from template
</Button>
{#if !handlerPath}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
size="xs"
href={customScriptTemplate}
disabled={!isEditable}
target="_blank"
>
Create from template
</Button>
{/if}
</div>
{#if showScriptHelpText}
<div class="text-2xs text-secondary">
Example of error handler scripts can be found on <a
target="_blank"
href="{$hubBaseUrlStore}/failures"
>
Windmill Hub</a
>
</div>
{/if}
</div>
{#if showScriptHelpText}
<div class="text-2xs text-secondary">
Example of error handler scripts can be found on <a
target="_blank"
href="{$hubBaseUrlStore}/failures"
>
Windmill Hub</a
>
</div>
{/if}
{#if handlerPath}
<p class="font-semibold text-xs mt-6 mb-1">Extra arguments</p>
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!isEditable}
schema={customHandlerSchema}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{#if customHandlerSchema && customHandlerSchema.properties && Object.keys(customHandlerSchema.properties).length === 0}
<div class="text-xs texg-gray-700">This error handler takes no extra arguments</div>
{/if}
<div>
<p class="font-semibold text-xs mb-1">Extra arguments</p>
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!isEditable}
schema={customHandlerSchema}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{#if customHandlerSchema && customHandlerSchema.properties && Object.keys(customHandlerSchema.properties).length === 0}
<div class="text-xs text-secondary">This error handler takes no extra arguments</div>
{/if}
</div>
{/if}
{:else if handlerSelected === 'slack'}
<!-- Slack Connection Status -->
@@ -255,7 +255,7 @@
<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">
<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 != ''}
@@ -834,7 +834,10 @@
{/if}
</div>
{: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]
}}
<div class="flex flex-col gap-4">
<Toggle
id="otel_tracing_proxy_enabled"
+2 -2
View File
@@ -102,9 +102,9 @@
transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }}
>
{#if description}
<div class="text-xs text-primary mt-1">{@html description}</div>
<div class="text-xs text-primary mt-1 mb-2">{@html description}</div>
{/if}
<div class="flex flex-col gap-6 grow min-h-0 mt-6">
<div class="flex flex-col gap-6 grow min-h-0 mt-4">
<div class={twMerge('grow min-h-0', clazz)}>
{@render children?.()}
</div>
@@ -0,0 +1,77 @@
<script lang="ts">
import type { ComponentType } from 'svelte'
import { twMerge } from 'tailwind-merge'
import Button from '$lib/components/common/button/Button.svelte'
import EEOnly from '$lib/components/EEOnly.svelte'
import { enterpriseLicense } from '$lib/stores'
interface NavigationItem {
id: string
label: string
icon?: ComponentType
disabled?: boolean
count?: number
aiId?: string
aiDescription?: string
showIf?: boolean
isEE?: boolean
}
interface NavigationGroup {
title?: string
items: NavigationItem[]
}
interface Props {
groups: NavigationGroup[]
selectedId: string
onNavigate: (id: string) => void
class?: string
}
let { groups, selectedId, onNavigate, class: className = '' }: Props = $props()
</script>
<div class={twMerge('flex flex-col gap-6', className)}>
{#each groups as group (group.title)}
<div class="flex flex-col gap-1">
{#if group.title}
<div class="text-sm font-semibold text-emphasis px-2 mb-1">
{group.title}
</div>
{/if}
<nav class="flex flex-col gap-0.5">
{#each group.items as item (item.id)}
{#if item.showIf !== false}
{@const isSelected = selectedId === item.id}
<Button
variant="subtle"
unifiedSize="sm"
selected={isSelected}
disabled={item.disabled}
aiId={item.aiId}
aiDescription={item.aiDescription}
startIcon={item.icon ? { icon: item.icon } : undefined}
btnClasses={'!justify-start text-left !w-full'}
onClick={() => onNavigate(item.id)}
>
<span class="truncate">{item.label}</span>
<div class="ml-auto flex items-center gap-1">
{#if item.isEE && !$enterpriseLicense}
<EEOnly />
{/if}
{#if item.count !== undefined}
<span
class="text-2xs text-secondary bg-surface-secondary px-1.5 py-0.5 rounded-full"
>
{item.count}
</span>
{/if}
</div>
</Button>
{/if}
{/each}
</nav>
</div>
{/each}
</div>
@@ -361,7 +361,7 @@
<div class="space-y-4">
<!-- Resource Picker -->
<div class="flex gap-2 items-center">
<div class="font-semibold">Resource:</div>
<div class="font-semibold text-xs text-emphasis">Resource:</div>
<div class="flex-1">
<ResourcePicker
bind:value={repo.git_repo_resource_path}
@@ -384,7 +384,7 @@
<!-- Display resource info when disabled (saved connection) -->
{#if !repo.isUnsavedConnection && repo.git_repo_resource_path}
<div class="ml-2 text-xs">
<div class="text-xs">
{#if loadingResourceInfo}
<div class="flex items-center gap-1 text-secondary">
<RotateCw size={12} class="animate-spin" />
@@ -392,7 +392,7 @@
</div>
{:else if resourceInfo?.url}
<div class="flex items-center gap-2 text-secondary">
<span class="font-medium">Git URL:</span>
<span class="text-xs text-secondary">Git URL:</span>
<code class="bg-surface-secondary px-2 py-1 rounded text-primary"
>{resourceInfo.url}</code
>
@@ -544,12 +544,13 @@
<div class="flex flex-col">
<h3 class="text-xs font-semibold text-emphasis">{displayTitle}</h3>
{#if displayDescription}
<p class="text-2xs text-secondary">{displayDescription}
{#if mode === 'promotion'}
<a target="_blank" href="https://www.windmill.dev/docs/advanced/deploy_gh_gl"
>Learn more about Git Promotion</a
>
{/if}
<p class="text-2xs text-secondary"
>{displayDescription}
{#if mode === 'promotion'}
<a target="_blank" href="https://www.windmill.dev/docs/advanced/deploy_gh_gl"
>Learn more about Git Promotion</a
>
{/if}
</p>
{/if}
</div>
@@ -580,16 +581,17 @@
{#if variant === 'primary-sync' || variant === 'primary-promotion'}
<!-- Primary Repository Layout -->
<div class="rounded-lg border bg-surface p-4 mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="text-xl font-semibold">{displayTitle}</h3>
{#if displayDescription}
<p class="text-sm text-secondary">{displayDescription}</p>
{/if}
</div>
<div class="flex items-center gap-2">
{@render headerActions()}
<div class="flex flex-col mb-4 gap-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{displayTitle}</h3>
<div class="flex items-center gap-2">
{@render headerActions()}
</div>
</div>
{#if displayDescription}
<p class="text-xs text-secondary">{displayDescription}</p>
{/if}
</div>
{@render repositoryContent()}
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import { ExternalLink, ChevronDown, ChevronRight, Plus } from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
import { setGitSyncContext } from './GitSyncContext.svelte'
import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte'
import GitSyncModalManager from './GitSyncModalManager.svelte'
@@ -38,7 +38,9 @@
// Check if any secondary repositories are unsaved
const hasUnsavedSecondary = $derived(secondarySync.some((s) => s.repo.isUnsavedConnection))
const hasUnsavedSecondaryPromotion = $derived(secondaryPromotion.some((s) => s.repo.isUnsavedConnection))
const hasUnsavedSecondaryPromotion = $derived(
secondaryPromotion.some((s) => s.repo.isUnsavedConnection)
)
</script>
{#if !gitSyncContext}
@@ -50,19 +52,15 @@
<div class="text-sm text-secondary">Loading git sync settings...</div>
</div>
{:else}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-emphasis text-sm font-semibold">Git Sync</div>
<Description link="https://www.windmill.dev/docs/advanced/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.
</Description>
</div>
<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.
</Alert>
</div>
<SettingsPageHeader
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"
/>
<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.
</Alert>
{#if !$enterpriseLicense}
<div class="mb-2"></div>
@@ -52,11 +52,14 @@
</script>
<div class="flex flex-col gap-1">
<p class="font-medium text-xs text-emphasis">Workspace color</p>
<p class="font-semibold text-xs text-emphasis">Workspace color</p>
<p class="text-xs text-secondary font-normal">
Color to identify the current workspace in the list of workspaces
</p>
<div class="flex flex-row gap-0.5 items-center">
{#if $workspaceColor}
<div
class="w-5 h-5 rounded-full border border-gray-300 dark:border-gray-600"
class="w-10 h-6 rounded-md border border-gray-300 dark:border-gray-600"
style="background-color: {$workspaceColor}"
></div>
{:else}
@@ -66,18 +69,14 @@
on:click={() => {
open = true
}}
size="xs"
spacingSize="xs2"
color="light"
unifiedSize="sm"
variant="subtle"
iconOnly
startIcon={{
icon: Pen
}}
/>
</div>
<p class="text-2xs text-secondary font-normal">
Color to identify the current workspace in the list of workspaces
</p>
</div>
<Modal bind:open title="Change workspace color">
@@ -57,7 +57,8 @@
</script>
<div class="flex flex-col gap-1">
<p class="font-medium text-xs text-emphasis">Workspace ID</p>
<p class="font-semibold text-xs text-emphasis">Workspace ID</p>
<p class="text-xs text-secondary font-normal">Slug to uniquely identify your workspace</p>
<div class="flex flex-row gap-0.5 items-center">
<p class="text-xs font-normal text-primary">{$workspaceStore ?? ''}</p>
{#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}
</div>
<p class="text-xs text-secondary font-normal">Slug to uniquely identify your workspace</p>
</div>
<Modal bind:open title="Change workspace ID">
@@ -106,7 +105,6 @@
{#snippet actions()}
<Button
size="sm"
variant="accent"
disabled={checking || errorId.length > 0 || !newName || !newId}
{loading}
@@ -38,24 +38,22 @@
</script>
<div class="flex flex-col gap-1">
<p class="font-medium text-xs text-emphasis">Workspace name</p>
<div class="flex flex-row gap-0.5 items-center">
<p class="text-xs font-normal text-primary">{currentName}</p>
<p class="font-semibold text-xs text-emphasis">Workspace name</p>
<p class="text-xs text-secondary font-normal">Displayable name</p>
<div class="flex flex-row gap-2 items-center">
<p class="text-primary text-xs">{currentName}</p>
<Button
on:click={() => {
open = true
}}
size="xs"
spacingSize="xs2"
color="light"
unifiedSize="sm"
iconOnly
variant="subtle"
startIcon={{
icon: Pen
}}
/>
</div>
<p class="text-2xs text-secondary font-normal"> Displayable name </p>
</div>
<Modal bind:open title="Change workspace name">
@@ -0,0 +1,32 @@
<script lang="ts">
import Description from '$lib/components/Description.svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
title: string
description?: string
link?: string
actions?: import('svelte').Snippet
class?: string
}
let { title, description, link, actions, class: className = '' }: Props = $props()
</script>
<div class={twMerge('flex flex-col gap-2 mb-6', className)}>
<div class="flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<h2 class="text-lg font-semibold text-emphasis">{title}</h2>
</div>
{#if actions}
<div class="flex items-center gap-2 shrink-0">
{@render actions()}
</div>
{/if}
</div>
{#if description}
<Description {link}>
{@html description}
</Description>
{/if}
</div>
@@ -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<void> {
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<string, string>
} | undefined
const autoInvite = settings.auto_invite as
| {
enabled?: boolean
domain?: string
operator?: boolean
mode?: string
instance_groups?: string[]
instance_groups_roles?: Record<string, string>
}
| 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}
/>
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-primary text-xs">
Add members to your workspace and manage their roles. You can also auto-add users to join your
workspace.
<a
href="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
target="_blank"
class="text-blue-500">Learn more</a
>.
</div>
</div>
</div>
<Section
label="Members {(filteredUsers?.length ?? users?.length) != undefined
<SettingsPageHeader
title="Members {(filteredUsers?.length ?? users?.length) != undefined
? `(${filteredUsers?.length ?? users?.length})`
: ''}"
tooltip="Manage users manually or enable SSO authentication."
documentationLink="https://www.windmill.dev/docs/core_concepts/authentification"
>
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"
/>
<Section>
{#snippet action()}
<div class="flex flex-row items-center gap-2 relative whitespace-nowrap">
<input placeholder="Filter members" bind:value={userFilter} class="input !pl-8" />
<div class="flex flex-row items-center gap-2 relative whitespace-nowrap w-full">
<input placeholder="Filter members" bind:value={userFilter} class="input !pl-8 !w-56" />
<Search class="absolute left-2" size={14} />
<Popover
@@ -161,7 +161,7 @@
{/key}
{/await}
{#if urlRunnableSchema.properties && Object.keys(urlRunnableSchema.properties).length === 0}
<div class="text-xs texg-gray-700">This runnable takes no arguments</div>
<div class="text-xs text-secondary">This runnable takes no arguments</div>
{/if}
{:else}
<Loader2 class="animate-spin mt-2" />
@@ -638,7 +638,7 @@
/>
{/await}
{#if schema && schema.properties && Object.keys(schema.properties).length === 0}
<div class="text-xs texg-gray-700">This runnable takes no arguments</div>
<div class="text-xs text-secondary">This runnable takes no arguments</div>
{/if}
{:else}
<Loader2 class="animate-spin mt-2" />
@@ -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<AIConfig['providers'], undefined>
codeCompletionModel: string | undefined
@@ -37,6 +40,8 @@
maxTokensPerModel: Record<string, number>
usingOpenaiClientCredentialsOauth: boolean
onSave?: () => void
onDiscard?: () => void
hasUnsavedChanges?: boolean
} = $props()
let fetchedAiModels = $state(false)
@@ -170,29 +175,13 @@
const autocompleteModels = $derived(selectedAiModels.filter(supportsAutocomplete))
</script>
<div class="flex flex-col gap-4 mt-4">
<div class="flex flex-col gap-1">
<div class="text-emphasis text-sm font-semibold flex flex-row gap-2 justify-between">
Windmill AI <Button
variant="accent"
unifiedSize="md"
wrapperClasses="self-start"
disabled={!Object.values(aiProviders).every((p) => p.resource_path) ||
(codeCompletionModel != undefined && codeCompletionModel.length === 0) ||
(Object.keys(aiProviders).length > 0 && !defaultModel)}
onClick={editCopilotConfig}
startIcon={{ icon: Save }}
>
Save AI settings
</Button></div
>
<Description link="https://www.windmill.dev/docs/core_concepts/ai_generation">
Windmill AI integrates with your favorite AI providers and models.
</Description>
</div>
</div>
<SettingsPageHeader
title="Windmill AI"
description="Windmill AI integrates with your favorite AI providers and models."
link="https://www.windmill.dev/docs/core_concepts/ai_generation"
/>
<div class="flex flex-col gap-8 mt-4">
<div class="flex flex-col gap-6 mt-4 pb-8">
<Label 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]}
@@ -377,8 +366,6 @@
{/if}
</div>
</Label>
<div class="py-6"></div>
</div>
<AIPromptsModal
@@ -388,3 +375,13 @@
hasChanges={hasPromptsChanges}
isWorkspaceSettings={true}
/>
<SettingsFooter
{hasUnsavedChanges}
onSave={editCopilotConfig}
onDiscard={() => 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)}
/>
@@ -424,11 +424,11 @@
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
{#if isFork}
<span class="text-2xs text-secondary"
<span class="text-xs text-secondary"
>Slug to uniquely identify your fork (this will also set the branch name)</span
>
{:else}
<span class="text-2xs text-secondary">Slug to uniquely identify your workspace</span>
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
{/if}
{#if isFork}
@@ -49,8 +49,8 @@
import CloseButton from '../common/CloseButton.svelte'
import Description from '../Description.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
import Select from '../select/Select.svelte'
import Cell from '../table/Cell.svelte'
import DataTable from '../table/DataTable.svelte'
@@ -71,6 +71,7 @@
import ExploreAssetButton from '../ExploreAssetButton.svelte'
import { deepEqual } from 'fast-equals'
import { clone } from '$lib/utils'
import SettingsFooter from './SettingsFooter.svelte'
type Props = {
dataTableSettings: DataTableSettingsType
@@ -135,6 +136,7 @@
} catch (e) {
sendUserToast(e, true)
console.error('Error saving data table settings', e)
throw e
}
}
@@ -150,20 +152,28 @@
return map
})
function onDiscard() {
tempSettings.dataTables = $state.snapshot(dataTableSettings.dataTables)
}
export function discard() {
onDiscard()
}
export function unsavedChanges(): { savedValue: any; modifiedValue: any } {
return { savedValue: dataTableSettings, modifiedValue: tempSettings }
}
let hasUnsavedChanges = $derived.by(() => {
return !deepEqual(dataTableSettings, tempSettings)
})
</script>
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis">Data tables</div>
<Description link="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables">
Store relational data out of the box. Interact with a fully managed PostgreSQL database
directly from the Windmill SDK.
</Description>
</div>
</div>
<SettingsPageHeader
title="Data tables"
description="Store relational data out of the box. Interact with a fully managed PostgreSQL database directly from the Windmill SDK."
link="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables"
/>
<DataTable>
<Head>
@@ -286,6 +296,12 @@
</tbody>
</DataTable>
<Button wrapperClasses="mt-4 mb-16 max-w-fit" on:click={onSave} variant="accent">Save</Button>
<SettingsFooter
class="mt-8"
{hasUnsavedChanges}
{onSave}
{onDiscard}
saveLabel="Save data table settings"
/>
<ConfirmationModal {...confirmationModal.props} />
@@ -55,6 +55,7 @@
import { Plus, SettingsIcon } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import SettingsFooter from './SettingsFooter.svelte'
import Description from '../Description.svelte'
import { random_adj } from '../random_positive_adjetive'
@@ -90,11 +91,13 @@
ducklakeSettings: DucklakeSettingsType
ducklakeSavedSettings: DucklakeSettingsType
onSave?: () => void
onDiscard?: () => void
}
let {
ducklakeSettings = $bindable(),
ducklakeSavedSettings = $bindable(),
onSave: onSaveProp = undefined
onSave: onSaveProp = undefined,
onDiscard = undefined
}: Props = $props()
function onNewDucklake() {
@@ -127,6 +130,11 @@
)
)
let hasUnsavedChanges = $derived(
ducklakeSavedSettings.ducklakes.length !== ducklakeSettings.ducklakes.length ||
!Object.values(ducklakeIsDirty).every((v) => v === false)
)
const customInstanceDbs = resource([], SettingService.listCustomInstanceDbs)
async function onSave() {
@@ -157,6 +165,7 @@
} catch (e) {
sendUserToast(e, true)
console.error('Error saving ducklake settings', e)
throw e
}
}
@@ -384,14 +393,13 @@
</Row>
</tbody>
</DataTable>
<Button
wrapperClasses="mt-4 mb-16 max-w-fit"
variant="accent"
on:click={onSave}
disabled={ducklakeSavedSettings.ducklakes.length === ducklakeSettings.ducklakes.length &&
Object.values(ducklakeIsDirty).every((v) => v === false)}
>
Save ducklake settings
</Button>
<SettingsFooter
class="mt-6 mb-16"
inline
{hasUnsavedChanges}
{onSave}
onDiscard={() => onDiscard?.()}
saveLabel="Save ducklake settings"
/>
<ConfirmationModal {...confirmationModal.props} />
@@ -1,11 +1,13 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import { Filter, Terminal, ChevronDown, ChevronUp, Edit3 } from 'lucide-svelte'
import { Filter, ChevronDown } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import FilterList from './FilterList.svelte'
import { Tabs, Tab } from '$lib/components/common'
import { Tabs, Tab, Button, Section } from '$lib/components/common'
import type { GitSyncObjectType } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
type GitSyncTypeMap = {
scripts: boolean
@@ -40,7 +42,6 @@
// Component state
let collapsed = $state(false)
let showCliInstructions = $state(false)
// Determine if component should be editable or read-only
const isEditable = $derived(isInitialSetup || requiresMigration)
@@ -113,8 +114,8 @@
<!-- Card Header -->
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
<div class="flex items-center gap-2">
<Filter size={18} class="text-primary" />
<span class="font-semibold text-sm">Git Sync filter settings</span>
<Filter size={14} class="text-primary" />
<span class="font-semibold text-xs text-emphasis">Git Sync filter settings</span>
{#if isLegacyRepo}
<Tooltip>
This repository uses legacy configuration format and inherits settings from
@@ -128,50 +129,21 @@
</Tooltip>
{/if}
</div>
<div class="flex items-center gap-2">
<button
class="text-secondary hover:text-primary focus:outline-none"
onclick={() => (collapsed = !collapsed)}
aria-label="Toggle collapse"
>
{#if collapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
{/if}
</button>
</div>
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{
icon: ChevronDown,
classes: twMerge('transition duration-150', collapsed ? '' : 'rotate-180')
}}
onClick={() => (collapsed = !collapsed)}
iconOnly
/>
</div>
{#if !collapsed}
{#if isEditable}
<!-- Editable mode -->
<div class="px-4 py-2">
<div class="px-4 py-2" transition:slide={{ duration: 150 }}>
<div class="grid grid-cols-1 md:grid-cols-2 md:gap-32">
<div class="flex flex-col gap-2">
<Tabs bind:selected={filtersTab}>
@@ -347,7 +319,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 md:gap-8">
<div class="flex flex-col gap-3">
<div>
<h4 class="font-semibold text-sm mb-1">Include Paths</h4>
<h4 class="font-semibold text-xs text-emphasis mb-1">Include Paths</h4>
{#if include_path.length > 0}
<div class="flex flex-wrap gap-1 text-xs">
{#each include_path as path}
@@ -362,7 +334,7 @@
</div>
<div>
<h4 class="font-semibold text-sm mb-1">Exclude Paths</h4>
<h4 class="font-semibold text-xs text-emphasis mb-1">Exclude Paths</h4>
{#if excludes.length > 0}
<div class="flex flex-wrap gap-1 text-xs">
{#each excludes as path}
@@ -376,7 +348,7 @@
</div>
<div class="flex flex-col gap-2">
<h4 class="font-semibold text-sm">Included Types</h4>
<h4 class="font-semibold text-xs text-emphasis">Included Types</h4>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{#each Object.entries(typeToggles) as [key, enabled]}
<div class="flex items-center gap-1">
@@ -401,21 +373,7 @@
<!-- CLI Instructions (collapsible) -->
<div class="border-t pt-2 mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (showCliInstructions = !showCliInstructions)}
>
<Terminal size={16} />
<span>Update settings with CLI</span>
<Edit3 size={14} class="text-primary" />
{#if showCliInstructions}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if showCliInstructions}
<Section label="Update settings with CLI" collapsable={true} collapsed={true}>
<div class="mt-3 bg-surface-secondary rounded-lg p-3">
<div class="text-xs text-primary mb-2">
These filter settings are sourced from the <code
@@ -443,8 +401,8 @@ git commit
git push
# Push changes to workspace or click the pull settings button above{#if useIndividualBranch}
wmill gitsync-settings push --workspace {$workspaceStore} --repository {git_repo_resource_path} --promotion main{:else}
wmill gitsync-settings push --workspace {$workspaceStore} --repository {git_repo_resource_path}{/if}</pre
wmill gitsync-settings push --workspace {$workspaceStore} --repository {git_repo_resource_path} --promotion main{:else}
wmill gitsync-settings push --workspace {$workspaceStore} --repository {git_repo_resource_path}{/if}</pre
>
{#if useIndividualBranch}
<div class="text-xs text-primary mt-3">
@@ -463,7 +421,7 @@ wmill gitsync-settings push --workspace {$workspaceStore} --repository {git_repo
</div>
{/if}
</div>
{/if}
</Section>
</div>
</div>
{/if}
@@ -0,0 +1,127 @@
<script lang="ts">
import { Button } from '../common'
import { Save, X, CheckCircle2, AlertCircle, Loader2 } from 'lucide-svelte'
import { fade, fly } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
let {
hasUnsavedChanges = false,
onSave,
onDiscard,
saveLabel = 'Save settings',
disabled = false,
inline = false,
class: className
}: {
hasUnsavedChanges?: boolean
onSave: () => void | Promise<void>
onDiscard: () => void
saveLabel?: string
disabled?: boolean
inline?: boolean
class?: string
} = $props()
let saveStatus: 'success' | 'error' | null = $state(null)
let isSaving = $state(false)
let statusTimeout: ReturnType<typeof setTimeout> | null = null
function clearStatusTimeout() {
if (statusTimeout !== null) {
clearTimeout(statusTimeout)
statusTimeout = null
}
}
async function handleSave() {
if (isSaving) return
isSaving = true
saveStatus = null
clearStatusTimeout() // Clear any existing timeout
try {
await onSave()
saveStatus = 'success'
// Auto-hide success status after 3 seconds
statusTimeout = setTimeout(() => {
saveStatus = null
statusTimeout = null
}, 3000)
} catch (error) {
console.error('Save failed:', error)
saveStatus = 'error'
// Auto-hide error status after 5 seconds (longer for errors)
statusTimeout = setTimeout(() => {
saveStatus = null
statusTimeout = null
}, 5000)
} finally {
isSaving = false
}
}
// Cleanup timeout on component unmount
$effect(() => {
return () => {
clearStatusTimeout()
}
})
</script>
<div
class={twMerge(
inline ? 'w-full' : 'sticky bottom-0 z-10 w-full border-t bg-surface-tertiary',
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">
{#if hasUnsavedChanges}
<div transition:fade={{ duration: 150 }}>
<Button
variant="default"
size="sm"
startIcon={{ icon: X }}
onClick={onDiscard}
disabled={isSaving}
>
Discard
</Button>
</div>
{/if}
<div class="relative">
<Button
variant="accent"
unifiedSize="md"
startIcon={{
icon: isSaving ? Loader2 : Save,
classes: isSaving ? 'animate-spin' : ''
}}
disabled={!hasUnsavedChanges || disabled || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : saveLabel}
</Button>
<!-- Success icon overlay -->
{#if saveStatus === 'success'}
<div
class="absolute inset-0 flex items-center justify-center bg-green-200 dark:bg-green-800 rounded-md"
transition:fly={{ y: -10, duration: 300 }}
>
<CheckCircle2 class="w-5 h-5 text-green-700 dark:text-green-300" />
</div>
{:else if saveStatus === 'error'}
<div
class="absolute inset-0 flex items-center justify-center bg-red-500 dark:bg-red-700 rounded-md"
transition:fly={{ y: -10, duration: 300 }}
>
<AlertCircle class="w-5 h-5 text-white" />
</div>
{/if}
</div>
</div>
</div>
</div>
@@ -4,7 +4,8 @@
import { ChevronDown, Plus, Shield } from 'lucide-svelte'
import Alert from '../common/alert/Alert.svelte'
import Button from '../common/button/Button.svelte'
import Description from '../Description.svelte'
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
import SettingsFooter from './SettingsFooter.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import Toggle from '../Toggle.svelte'
import Tooltip from '../Tooltip.svelte'
@@ -34,11 +35,13 @@
let {
s3ResourceSettings = $bindable(),
s3ResourceSavedSettings,
onSave = undefined
onSave = undefined,
onDiscard = undefined
}: {
s3ResourceSettings: S3ResourceSettings
s3ResourceSavedSettings: S3ResourceSettings
onSave?: () => void
onDiscard?: () => void
} = $props()
let advancedPermissionModalState:
@@ -95,26 +98,21 @@
const defaultPerms = defaultS3AdvancedPermissions(!!$enterpriseLicense)
return !deepEqual(storage.advancedPermissions, defaultPerms)
}
let hasUnsavedChanges = $derived.by(() => {
return !deepEqual(s3ResourceSettings, s3ResourceSavedSettings)
})
</script>
<Portal name="workspace-settings">
<S3FilePicker bind:this={s3FileViewer} readOnlyMode={false} fromWorkspaceSettings={true} />
</Portal>
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis"
>Workspace Object Storage (S3/Azure Blob/GCS)</div
>
<Description
link="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#workspace-object-storage"
>
Connect your Windmill workspace to your S3 bucket, Azure Blob storage, or Google Cloud Storage
to enable users to read and write from object storage without having to have access to the
credentials.
</Description>
</div>
</div>
<SettingsPageHeader
title="Workspace object storage (S3/Azure Blob/GCS)"
description="Connect your Windmill workspace to your S3 bucket, Azure Blob storage, or Google Cloud Storage to enable users to read and write from object storage without having to have access to the credentials."
link="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#workspace-object-storage"
/>
{#if !$enterpriseLicense}
<Alert type="info" title="S3 storage is limited to 20 files in Windmill CE">
Windmill S3 bucket browser will not work for buckets containing more than 20 files and uploads
@@ -297,16 +295,14 @@
</tbody>
</DataTable>
<div class="flex mt-5 mb-5 gap-1">
<Button
variant="accent"
size="xl"
on:click={() => {
editWindmillLFSSettings()
console.log('Saving S3 settings', s3ResourceSettings)
}}>Save storage settings</Button
>
</div>
<SettingsFooter
class="mt-5 mb-5"
inline
{hasUnsavedChanges}
onSave={editWindmillLFSSettings}
onDiscard={() => onDiscard?.()}
saveLabel="Save storage settings"
/>
{/if}
<Modal2
@@ -18,7 +18,7 @@
import { untrack } from 'svelte'
import { sendUserToast } from '$lib/toast'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import Description from '$lib/components/Description.svelte'
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
let filter = $state('')
let workspaceDependencies: WorkspaceDependencies[] | undefined = $state()
@@ -95,7 +95,10 @@
// Archive workspace dependencies
async function archiveWorkspaceDependencies(deps: WorkspaceDependencies): Promise<void> {
const importedPath = workspaceDependenciesEditor?.getWorkspaceDependenciesPath(deps.name ?? null, deps.language)
const importedPath = workspaceDependenciesEditor?.getWorkspaceDependenciesPath(
deps.name ?? null,
deps.language
)
if (!importedPath) {
sendUserToast('Unable to determine enforced dependencies path', true)
return
@@ -115,7 +118,9 @@
language: deps.language as any,
name: deps.name
})
sendUserToast(`Archived enforced dependencies: ${workspaceDependenciesEditor?.getDisplayName(deps)}`)
sendUserToast(
`Archived enforced dependencies: ${workspaceDependenciesEditor?.getDisplayName(deps)}`
)
loadWorkspaceDependencies() // Reload the list
} catch (error) {
console.error('Error archiving workspace dependencies:', error)
@@ -125,7 +130,10 @@
// Delete workspace dependencies
async function deleteWorkspaceDependencies(deps: WorkspaceDependencies): Promise<void> {
const importedPath = workspaceDependenciesEditor?.getWorkspaceDependenciesPath(deps.name ?? null, deps.language)
const importedPath = workspaceDependenciesEditor?.getWorkspaceDependenciesPath(
deps.name ?? null,
deps.language
)
if (!importedPath) {
sendUserToast('Unable to determine enforced dependencies path', true)
return
@@ -145,7 +153,9 @@
language: deps.language as any,
name: deps.name
})
sendUserToast(`Deleted enforced dependencies: ${workspaceDependenciesEditor?.getDisplayName(deps)}`)
sendUserToast(
`Deleted enforced dependencies: ${workspaceDependenciesEditor?.getDisplayName(deps)}`
)
loadWorkspaceDependencies() // Reload the list
} catch (error) {
console.error('Error deleting workspace dependencies:', error)
@@ -155,7 +165,10 @@
async function viewReferencedFrom(deps: WorkspaceDependencies): Promise<void> {
try {
const path = workspaceDependenciesEditor?.getWorkspaceDependenciesPath(deps.name ?? null, deps.language)
const path = workspaceDependenciesEditor?.getWorkspaceDependenciesPath(
deps.name ?? null,
deps.language
)
if (!path) {
sendUserToast('Unable to determine enforced dependencies path', true)
return
@@ -171,7 +184,9 @@
} else {
// Show dependents in a modal or navigate to a detailed view
console.log('Dependents:', dependents)
sendUserToast(`Found ${dependents.length} dependent runnable${dependents.length !== 1 ? 's' : ''}`)
sendUserToast(
`Found ${dependents.length} dependent runnable${dependents.length !== 1 ? 's' : ''}`
)
}
} catch (error) {
console.error('Error fetching dependent runnables:', error)
@@ -179,7 +194,6 @@
}
}
async function handleWarningConfirm(): Promise<void> {
if (pendingAction) {
showDependencyWarning = false
@@ -195,8 +209,7 @@
currentImportedPath = null
}
function getLanguageForHighlighting(language: ScriptLang): ScriptLang | 'json' | undefined{
function getLanguageForHighlighting(language: ScriptLang): ScriptLang | 'json' | undefined {
// Map our requirement languages to syntax highlighting languages
switch (language) {
case 'python3':
@@ -211,7 +224,10 @@
}
</script>
<WorkspaceDependenciesEditor bind:this={workspaceDependenciesEditor} on:create={loadWorkspaceDependencies} />
<WorkspaceDependenciesEditor
bind:this={workspaceDependenciesEditor}
on:create={loadWorkspaceDependencies}
/>
<SearchItems
{filter}
@@ -220,20 +236,22 @@
f={(x) => (x.name || 'Default') + ' ' + (x.language || '') + ' ' + (x.content || '')}
/>
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis">Enforced Dependencies</div>
<Description link="https://www.windmill.dev/docs/">
Enforced Dependencies define dependency specifications for scripts by language. Unnamed dependencies serve as workspace defaults, while named dependencies can be referenced by scripts using #raw_reqs annotations.
</Description>
</div>
<div class="flex flex-row justify-end">
<Button size="md" startIcon={{ icon: Plus }} on:click={createNewWorkspaceDependencies}>
<SettingsPageHeader
title="Enforced Dependencies"
description="Enforced Dependencies define dependency specifications for scripts by language. Unnamed dependencies serve as workspace defaults, while named dependencies can be referenced by scripts using #raw_reqs annotations."
link="https://www.windmill.dev/docs/core_concepts/workspace_dependencies"
>
{#snippet actions()}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
onClick={createNewWorkspaceDependencies}
>
New&nbsp;enforced&nbsp;dependencies
</Button>
</div>
</div>
{/snippet}
</SettingsPageHeader>
<div class="pt-2">
<div class="relative text-tertiary">
@@ -295,11 +313,14 @@
{#if deps.marked}
{@html deps.marked}
{:else}
{workspaceDependenciesEditor?.getDisplayName(deps) || (deps.name || `Default (${deps.language})`)}
{workspaceDependenciesEditor?.getDisplayName(deps) ||
deps.name ||
`Default (${deps.language})`}
{/if}
</button>
<span class="text-xs text-tertiary font-mono">
{workspaceDependenciesEditor?.getFullFilename(deps.language, deps.name ?? null)}{deps.language}
{workspaceDependenciesEditor?.getFullFilename(deps.language, deps.name ?? null)}
{deps.language}
</span>
</div>
</div>
@@ -318,11 +339,12 @@
</span>
</Cell>
<Cell>
<span class="text-xs px-1.5 py-0.5 rounded bg-opacity-50 font-medium"
class:bg-blue-100="{deps.name === null}"
class:text-blue-700="{deps.name === null}"
class:bg-gray-100="{deps.name !== null}"
class:text-gray-600="{deps.name !== null}"
<span
class="text-xs px-1.5 py-0.5 rounded bg-opacity-50 font-medium"
class:bg-blue-100={deps.name === null}
class:text-blue-700={deps.name === null}
class:bg-gray-100={deps.name !== null}
class:text-gray-600={deps.name !== null}
>
{deps.name === null ? 'Default' : 'Named'}
</span>
@@ -334,23 +356,53 @@
</Cell>
<Cell last>
<div class="flex gap-1 flex-wrap">
<Button size="xs" variant="border" color="light" startIcon={{ icon: Eye }} on:click={() => viewWorkspaceDependencies(deps)}>
<Button
size="xs"
variant="border"
color="light"
startIcon={{ icon: Eye }}
on:click={() => viewWorkspaceDependencies(deps)}
>
View
</Button>
<Button size="xs" variant="border" color="light" startIcon={{ icon: Edit }} on:click={() => editWorkspaceDependencies(deps)}>
<Button
size="xs"
variant="border"
color="light"
startIcon={{ icon: Edit }}
on:click={() => editWorkspaceDependencies(deps)}
>
Edit
</Button>
<!-- Placeholder buttons -->
<Button size="xs" variant="border" color="gray" on:click={() => archiveWorkspaceDependencies(deps)} title="Archive">
<Button
size="xs"
variant="border"
color="gray"
on:click={() => archiveWorkspaceDependencies(deps)}
title="Archive"
>
Archive
</Button>
<Button size="xs" variant="border" color="red" on:click={() => deleteWorkspaceDependencies(deps)} title="Delete">
<Button
size="xs"
variant="border"
color="red"
on:click={() => deleteWorkspaceDependencies(deps)}
title="Delete"
>
Delete
</Button>
<Button size="xs" variant="border" color="gray" on:click={() => viewReferencedFrom(deps)} title="Referenced From">
<Button
size="xs"
variant="border"
color="gray"
on:click={() => viewReferencedFrom(deps)}
title="Referenced From"
>
Refs
</Button>
</div>
</div>
</Cell>
</Row>
{/each}
@@ -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 @@
})
</script>
<div class="flex flex-col gap-6 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis">Native Triggers (Beta)</div>
<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.
</Description>
<Description link="https://www.windmill.dev/docs/integrations/native-triggers">
Learn more about native triggers and workspace integrations.
</Description>
</div>
<div class="flex flex-col gap-6">
<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."
link="https://www.windmill.dev/docs/integrations/native-triggers"
/>
<Alert type="warning" title="Beta Feature">
<p>Native Triggers is currently in beta. Nextcloud integration requires:</p>
@@ -230,7 +224,9 @@
</a>
to be enabled on your Nextcloud instance.
</li>
<li>The Windmill integration app to be installed on your Nextcloud instance (not yet released).</li>
<li
>The Windmill integration app to be installed on your Nextcloud instance (not yet released).</li
>
</ul>
</Alert>
+9
View File
@@ -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(
@@ -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<number, string>
} {
const errors: Record<number, string> = {}
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'
}
}
File diff suppressed because it is too large Load Diff