mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
An instance with the hub turned off still got the catalogue preloaded on every empty home and an "Import a hub project" entry in the create menu — an outbound request the operator has said not to make, and a door to somewhere unreachable. Both now observe `disableHubStore`, the store the script and flow hub pickers already read. With the hub off the caption reads "Create a new one." rather than continuing a sentence whose first half is gone. The telemetry disclosure also scoped the create menu and picker counters to the empty home, when both fire from the toolbar in a populated one, and said a creation was recorded when what is recorded is the menu opening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
1308 lines
44 KiB
Svelte
1308 lines
44 KiB
Svelte
<script lang="ts">
|
||
import {
|
||
scimSamlSetting,
|
||
settings,
|
||
settingsKeys,
|
||
instanceSettingsSaved
|
||
} from './instanceSettings'
|
||
import { Alert, Button, Tab, TabContent, Tabs } from '$lib/components/common'
|
||
import { SettingService, SettingsService } from '$lib/gen'
|
||
import type { TeamsChannel } from '$lib/gen/types.gen'
|
||
|
||
import { sendUserToast } from '$lib/toast'
|
||
import { deepEqual } from 'fast-equals'
|
||
|
||
import { sleep } from '$lib/utils'
|
||
import { enterpriseLicense } from '$lib/stores'
|
||
|
||
import { createEventDispatcher } from 'svelte'
|
||
import { setLicense } from '$lib/enterpriseUtils'
|
||
import AuthSettings from './AuthSettings.svelte'
|
||
import oauthConnectRegistry from '$oauth_connect_registry'
|
||
import InstanceSetting from './InstanceSetting.svelte'
|
||
import { writable, type Writable } from 'svelte/store'
|
||
import { ExternalLink, Loader2 } from 'lucide-svelte'
|
||
import YAML from 'yaml'
|
||
import Toggle from './Toggle.svelte'
|
||
import SettingsFooter from './workspaceSettings/SettingsFooter.svelte'
|
||
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
|
||
import WorkspaceRegistries from './instanceSettings/WorkspaceRegistries.svelte'
|
||
import DbHealth from './instanceSettings/DbHealth.svelte'
|
||
|
||
interface Props {
|
||
tab?: string
|
||
hideTabs?: boolean
|
||
closeDrawer?: (() => void) | undefined
|
||
authSubTab?: 'sso' | 'oauth' | 'scim'
|
||
onNavigateToTab?: (category: string) => void
|
||
quickSetup?: boolean
|
||
yamlMode?: boolean
|
||
hasUnsavedChanges?: boolean
|
||
hasAnyInvalid?: boolean
|
||
}
|
||
|
||
let {
|
||
tab = $bindable('Core'),
|
||
hideTabs = false,
|
||
closeDrawer = () => {},
|
||
authSubTab = $bindable('sso'),
|
||
onNavigateToTab,
|
||
quickSetup = false,
|
||
yamlMode = $bindable(false),
|
||
hasUnsavedChanges = $bindable(false),
|
||
hasAnyInvalid = $bindable(false)
|
||
}: Props = $props()
|
||
|
||
let values: Writable<Record<string, any>> = writable({})
|
||
let initialOauths: Record<string, any> = $state({})
|
||
let initialRequirePreexistingUserForOauth: boolean = $state(false)
|
||
let requirePreexistingUserForOauth: boolean = $state(false)
|
||
|
||
let initialValues: Record<string, any> = $state({})
|
||
let baseUrlIsFallback = $state(false)
|
||
// Per-instance OAuth providers (Snowflake, ServiceNow, …): instance name
|
||
// keyed by provider, used to build their per-instance connect_config URLs.
|
||
let instanceInputs: Record<string, string> = $state({})
|
||
let version: string = $state('')
|
||
let loading = $state(true)
|
||
|
||
export function getVersion(): string {
|
||
return version
|
||
}
|
||
|
||
export function getLicenseKey(): string {
|
||
return $values?.['license_key'] ?? ''
|
||
}
|
||
|
||
loadSettings()
|
||
loadVersion()
|
||
|
||
// When the user enables object storage for the first time, default
|
||
// `monitor_logs_on_s3` to true so S3 log files get cleaned up with their
|
||
// jobs. Backend still defaults to false for backwards compat with
|
||
// operators who never touched the setting.
|
||
$effect(() => {
|
||
if ($values['object_store_cache_config'] && $values['monitor_logs_on_s3'] === undefined) {
|
||
values.update((v) => ({ ...v, monitor_logs_on_s3: true }))
|
||
}
|
||
})
|
||
|
||
const dispatch = createEventDispatcher()
|
||
|
||
async function loadVersion() {
|
||
version = await SettingsService.backendVersion()
|
||
}
|
||
let oauths: Record<string, any> = $state({})
|
||
|
||
/** Ensure object/array-typed settings have a non-null default for the form UI */
|
||
const formDefaults: Record<string, any> = {
|
||
smtp_settings: {},
|
||
otel: {},
|
||
indexer_settings: {},
|
||
critical_error_channels: [],
|
||
github_enterprise_app: {}
|
||
}
|
||
|
||
function applyFormDefaults(vals: Record<string, any>): void {
|
||
for (const [key, defaultVal] of Object.entries(formDefaults)) {
|
||
if (vals[key] == undefined) {
|
||
vals[key] =
|
||
typeof defaultVal === 'object' ? JSON.parse(JSON.stringify(defaultVal)) : defaultVal
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadSettings() {
|
||
loading = true
|
||
|
||
// Bulk-load all settings in a single API call
|
||
const config = await SettingService.getInstanceConfig()
|
||
const gs = (config.global_settings ?? {}) as Record<string, any>
|
||
|
||
initialOauths = gs['oauths'] ?? {}
|
||
requirePreexistingUserForOauth = gs['require_preexisting_user_for_oauth'] ?? false
|
||
initialRequirePreexistingUserForOauth = requirePreexistingUserForOauth
|
||
oauths = JSON.parse(JSON.stringify(initialOauths))
|
||
|
||
let nvalues: Record<string, any> = { ...gs }
|
||
|
||
baseUrlIsFallback = !nvalues['base_url']
|
||
if (nvalues['retention_period_secs'] == undefined) {
|
||
nvalues['retention_period_secs'] = 60 * 60 * 24 * 30
|
||
}
|
||
applyFormDefaults(nvalues)
|
||
|
||
// Apply select/select_python defaults so initialValues matches what InstanceSetting's $effect does
|
||
for (const category of settingsKeys) {
|
||
for (const s of settings[category]) {
|
||
if (
|
||
(s.fieldType === 'select' || s.fieldType === 'select_python') &&
|
||
nvalues[s.key] == undefined &&
|
||
s.defaultValue
|
||
) {
|
||
nvalues[s.key] = s.defaultValue()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Snapshot initialValues before applying the base_url fallback so that
|
||
// the dirty-check detects the unsaved default and enables the Save button.
|
||
initialValues = JSON.parse(JSON.stringify(nvalues))
|
||
|
||
if (baseUrlIsFallback) {
|
||
nvalues['base_url'] = window.location.origin
|
||
}
|
||
$values = nvalues
|
||
loading = false
|
||
|
||
// populate per-instance OAuth provider inputs (snowflake, servicenow, …) from db
|
||
loadInstanceInputs(oauths)
|
||
}
|
||
|
||
export async function saveSettings() {
|
||
if (yamlMode) {
|
||
if (!syncYamlToForm()) {
|
||
return
|
||
}
|
||
}
|
||
|
||
setupTemplatedOauthUrls()
|
||
|
||
// Remove empty or invalid entries for critical error channels
|
||
$values.critical_error_channels = $values.critical_error_channels.filter((entry: any) => {
|
||
if (!entry || typeof entry !== 'object') return false
|
||
if ('teams_channel' in entry) {
|
||
return isValidTeamsChannel(entry.teams_channel)
|
||
}
|
||
if ('slack_channel' in entry) {
|
||
return typeof entry.slack_channel === 'string' && entry.slack_channel.trim() !== ''
|
||
}
|
||
if ('email' in entry) {
|
||
return typeof entry.email === 'string' && entry.email.trim() !== ''
|
||
}
|
||
// Unknown shape
|
||
return false
|
||
})
|
||
|
||
let shouldReloadPage = false
|
||
let willRestart = false
|
||
if ($values) {
|
||
// Trim license key before saving
|
||
if ($values['license_key'] && typeof $values['license_key'] === 'string') {
|
||
$values['license_key'] = $values['license_key'].trim()
|
||
}
|
||
|
||
// Check which settings require a page reload or server restart
|
||
const allSettings = [...Object.values(settings), scimSamlSetting].flat()
|
||
let licenseKeySet = false
|
||
for (const s of allSettings) {
|
||
if (s.storage === 'setting' && !deepEqual(initialValues?.[s.key], $values?.[s.key])) {
|
||
if (s.key === 'license_key') {
|
||
licenseKeySet = true
|
||
}
|
||
if (s.requiresReloadOnChange) {
|
||
shouldReloadPage = true
|
||
}
|
||
if (s.triggersRestart) {
|
||
willRestart = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build the full global_settings object for the bulk PUT
|
||
const globalSettings: Record<string, any> = { ...$values }
|
||
|
||
// Send explicit null for keys that were set on load but are now
|
||
// missing/cleared, so the Merge-mode bulk endpoint deletes them
|
||
// instead of silently preserving the old DB value. Covers both:
|
||
// - YAML mode: user removed a line / set it to null / set it to {}.
|
||
// - Form mode: user toggled a setting off (e.g.
|
||
// object_store_cache_config), making the value undefined.
|
||
const isClearedValue = (v: any) => {
|
||
if (v === undefined || v === null) return true
|
||
if (typeof v === 'object') {
|
||
return Array.isArray(v) ? v.length === 0 : Object.keys(v).length === 0
|
||
}
|
||
return false
|
||
}
|
||
for (const key of Object.keys(initialValues ?? {})) {
|
||
if (excludedKeys.has(key)) continue
|
||
if (key === 'oauths' || key === 'require_preexisting_user_for_oauth') continue
|
||
if (!isClearedValue(globalSettings[key])) continue
|
||
// Only flag for deletion if it was non-empty before — otherwise
|
||
// every save would delete-then-recreate harmless `{}`/`[]` defaults.
|
||
if (isClearedValue(initialValues[key])) continue
|
||
globalSettings[key] = null
|
||
}
|
||
|
||
// Include oauths and require_preexisting_user_for_oauth
|
||
if (!deepEqual(initialOauths, oauths)) {
|
||
globalSettings['oauths'] = oauths
|
||
}
|
||
if (initialRequirePreexistingUserForOauth !== requirePreexistingUserForOauth) {
|
||
globalSettings['require_preexisting_user_for_oauth'] = requirePreexistingUserForOauth
|
||
}
|
||
|
||
await SettingService.setInstanceConfig({
|
||
requestBody: { global_settings: globalSettings }
|
||
})
|
||
|
||
initialValues = JSON.parse(JSON.stringify($values))
|
||
initialOauths = JSON.parse(JSON.stringify(oauths))
|
||
instanceSettingsSaved.update((n) => n + 1)
|
||
initialRequirePreexistingUserForOauth = requirePreexistingUserForOauth
|
||
baseUrlIsFallback = false
|
||
|
||
if (yamlMode) {
|
||
yamlCodeInitial = yamlCode
|
||
}
|
||
|
||
if (licenseKeySet) {
|
||
setLicense()
|
||
}
|
||
} else {
|
||
console.error('Values not loaded')
|
||
}
|
||
if (shouldReloadPage) {
|
||
sendUserToast('Settings updated, reloading page...')
|
||
await sleep(1000)
|
||
window.location.reload()
|
||
} else if (willRestart) {
|
||
sendUserToast(
|
||
'Settings updated. Servers are restarting and changes may take up to a minute to fully propagate.',
|
||
false,
|
||
[],
|
||
undefined,
|
||
8000
|
||
)
|
||
dispatch('saved')
|
||
} else {
|
||
sendUserToast('Settings updated')
|
||
dispatch('saved')
|
||
}
|
||
}
|
||
|
||
// Per-instance OAuth providers (Snowflake, ServiceNow, …) keyed by name ->
|
||
// their registry connect_config_template. Adding a new one needs only a
|
||
// registry entry — no code here.
|
||
// Every per-instance templated provider is configurable here: authorization-code
|
||
// ones (ServiceNow, Snowflake) provide an `auth_url`, client-credentials-only
|
||
// ones (Coupa) provide only a `token_url`. Both need the admin to enter their
|
||
// instance host so the shared credentials point at the right endpoint.
|
||
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
|
||
Object.entries(oauthConnectRegistry)
|
||
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
|
||
.map(([name, cfg]) => [name, (cfg as any).connect_config_template])
|
||
)
|
||
|
||
function normalizeInstanceInput(tmpl: any, raw: string): string {
|
||
let v = (raw ?? '').replace(/\s/g, '')
|
||
if (tmpl.strip_suffix) {
|
||
// accept a full host/URL or a bare name -> reduce to the bare instance
|
||
v = v.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
||
if (v.endsWith(tmpl.strip_suffix)) {
|
||
v = v.slice(0, -tmpl.strip_suffix.length)
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
// Build each per-instance provider's connect_config from the admin-entered
|
||
// instance name + its registry template (substituting {instance} into the
|
||
// URLs). Replaces the old per-provider setup functions.
|
||
function setupTemplatedOauthUrls() {
|
||
for (const [name, tmpl] of Object.entries(connectConfigTemplates)) {
|
||
if (!oauths?.[name]) continue
|
||
const key = tmpl.extra_params_key ?? 'instance'
|
||
const v = normalizeInstanceInput(tmpl, instanceInputs[name] ?? '')
|
||
instanceInputs[name] = v
|
||
if (oauths[name].connect_config?.extra_params?.[key] === v) continue
|
||
oauths[name].connect_config = {
|
||
scopes: tmpl.scopes ?? [],
|
||
// CC-only templated providers have no auth_url; store an empty string
|
||
// (not omitted) so the instance-config parser still types the entry.
|
||
// The backend treats an empty auth_url as the unused placeholder for
|
||
// the client-credentials grant.
|
||
auth_url: tmpl.auth_url ? tmpl.auth_url.replaceAll('{instance}', v) : '',
|
||
token_url: tmpl.token_url.replaceAll('{instance}', v),
|
||
req_body_auth: tmpl.req_body_auth ?? false,
|
||
extra_params: { [key]: v },
|
||
extra_params_callback: {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Recover the instance-name inputs from a saved oauths config (for load/discard).
|
||
function loadInstanceInputs(savedOauths: Record<string, any>) {
|
||
for (const [name, tmpl] of Object.entries(connectConfigTemplates)) {
|
||
const key = tmpl.extra_params_key ?? 'instance'
|
||
instanceInputs[name] = savedOauths?.[name]?.connect_config?.extra_params?.[key] ?? ''
|
||
}
|
||
}
|
||
|
||
let sendingStats = $state(false)
|
||
async function sendStats() {
|
||
try {
|
||
sendingStats = true
|
||
await SettingService.sendStats()
|
||
sendUserToast('Usage sent')
|
||
} catch (err) {
|
||
throw err
|
||
} finally {
|
||
sendingStats = false
|
||
}
|
||
}
|
||
|
||
let downloadingStats = $state(false)
|
||
async function downloadStats() {
|
||
try {
|
||
downloadingStats = true
|
||
const result = await SettingService.getStats()
|
||
const blob = new Blob([result.data ?? ''], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
const date = new Date().toISOString().split('T')[0]
|
||
a.download = `windmill-telemetry-${date}-${result.signature}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
sendUserToast('Telemetry data downloaded')
|
||
} catch (err) {
|
||
throw err
|
||
} finally {
|
||
downloadingStats = false
|
||
}
|
||
}
|
||
|
||
function isValidTeamsChannel(value: any): value is TeamsChannel {
|
||
return (
|
||
typeof value === 'object' &&
|
||
value !== null &&
|
||
typeof value.team_id === 'string' &&
|
||
value.team_id.trim() !== '' &&
|
||
typeof value.team_name === 'string' &&
|
||
value.team_name.trim() !== '' &&
|
||
typeof value.channel_id === 'string' &&
|
||
value.channel_id.trim() !== '' &&
|
||
typeof value.channel_name === 'string' &&
|
||
value.channel_name.trim() !== ''
|
||
)
|
||
}
|
||
|
||
function openSmtpSettings() {
|
||
if (onNavigateToTab) {
|
||
onNavigateToTab('SMTP')
|
||
} else {
|
||
tab = 'SMTP'
|
||
}
|
||
}
|
||
|
||
// --- Dirty state tracking (YAML-based) ---
|
||
|
||
function stripEmpty(obj: Record<string, any>): Record<string, any> {
|
||
return Object.fromEntries(
|
||
Object.entries(obj)
|
||
.filter(([_, v]) => v !== undefined && v !== '')
|
||
.map(([k, v]) =>
|
||
v != null && typeof v === 'object' && !Array.isArray(v) ? [k, stripEmpty(v)] : [k, v]
|
||
)
|
||
)
|
||
}
|
||
|
||
function getSettingsForCategory(category: string) {
|
||
if (category === 'Auth/OAuth/SAML') {
|
||
return [...(settings[category] ?? []), ...scimSamlSetting]
|
||
}
|
||
const base = settings[category] ?? []
|
||
// In quick setup, reorder Core: base settings (without license_key), then extras from Jobs
|
||
if (quickSetup && category === 'Core') {
|
||
const licenseKey = base.find((s) => s.key === 'license_key')
|
||
const baseWithout = base.filter((s) => s.key !== 'license_key')
|
||
const jobSettings = settings['Jobs'] ?? []
|
||
const jobIsolation = jobSettings.find((s) => s.key === 'job_isolation')
|
||
const retentionPeriod = jobSettings.find((s) => s.key === 'retention_period_secs')
|
||
const objectStorage = settings['Object Storage']?.find(
|
||
(s) => s.key === 'object_store_cache_config'
|
||
)
|
||
return [
|
||
...baseWithout,
|
||
...(jobIsolation ? [jobIsolation] : []),
|
||
...(licenseKey ? [licenseKey] : []),
|
||
...(retentionPeriod ? [retentionPeriod] : []),
|
||
...(objectStorage ? [objectStorage] : [])
|
||
]
|
||
}
|
||
return base
|
||
}
|
||
|
||
function normalizeValue(value: any, key?: string): any {
|
||
if (value == null) return undefined
|
||
if (value === false) return undefined
|
||
if (typeof value === 'string' && value.trim() === '') return undefined
|
||
if (Array.isArray(value) && value.length === 0) return undefined
|
||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||
// Recursively normalize: if all values in the object normalize to undefined,
|
||
// the object itself is effectively empty (e.g. {smtp_tls_implicit: false} ≡ {})
|
||
const hasNonEmpty = Object.values(value).some((v) => normalizeValue(v) !== undefined)
|
||
if (!hasNonEmpty) return undefined
|
||
}
|
||
|
||
// Key-specific defaults: these values are equivalent to "not set"
|
||
if (key === 'secret_backend') {
|
||
if (
|
||
typeof value === 'object' &&
|
||
value?.type === 'Database' &&
|
||
Object.keys(value).length === 1
|
||
) {
|
||
return undefined
|
||
}
|
||
}
|
||
if (key === 'automate_username_creation' && value === true) {
|
||
return undefined
|
||
}
|
||
if (key === 'critical_alerts_on_db_oversize' && typeof value === 'object') {
|
||
if (!value.enabled && (!value.value || value.value === 0)) {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
return value
|
||
}
|
||
function buildCategoryYaml(
|
||
category: string,
|
||
vals: Record<string, any>,
|
||
oauthsObj: Record<string, any>,
|
||
reqPreexisting: boolean
|
||
): string {
|
||
const categorySettings = getSettingsForCategory(category)
|
||
const obj: Record<string, any> = {}
|
||
for (const s of categorySettings) {
|
||
const normalized = normalizeValue(vals[s.key], s.key)
|
||
if (normalized !== undefined) {
|
||
obj[s.key] = vals[s.key]
|
||
}
|
||
}
|
||
if (category === 'Auth/OAuth/SAML') {
|
||
if (Object.keys(stripEmpty(oauthsObj)).length > 0) {
|
||
obj['oauths'] = oauthsObj
|
||
}
|
||
if (reqPreexisting) {
|
||
obj['require_preexisting_user_for_oauth'] = reqPreexisting
|
||
}
|
||
}
|
||
if (category === 'Registries') {
|
||
obj['workspace_registries'] = vals['workspace_registries'] ?? null
|
||
}
|
||
return YAML.stringify(obj)
|
||
}
|
||
|
||
let dirtyCategories: Record<string, boolean> = $derived.by(() => {
|
||
const result: Record<string, boolean> = {}
|
||
for (const category of settingsKeys) {
|
||
const initialYaml = buildCategoryYaml(
|
||
category,
|
||
initialValues,
|
||
initialOauths,
|
||
initialRequirePreexistingUserForOauth
|
||
)
|
||
const currentYaml = buildCategoryYaml(
|
||
category,
|
||
$values,
|
||
oauths,
|
||
requirePreexistingUserForOauth
|
||
)
|
||
result[category] = initialYaml !== currentYaml
|
||
}
|
||
return result
|
||
})
|
||
|
||
let invalidCategories: Record<string, boolean> = $derived.by(() => {
|
||
const currentValues = $values
|
||
const result: Record<string, boolean> = {}
|
||
for (const category of settingsKeys) {
|
||
const categorySettings = getSettingsForCategory(category)
|
||
result[category] = categorySettings.some((s) => {
|
||
if (s.isValid && !s.isValid(currentValues?.[s.key])) return true
|
||
if (s.validate) {
|
||
const errors = s.validate(currentValues?.[s.key])
|
||
return Object.keys(errors).length > 0
|
||
}
|
||
return false
|
||
})
|
||
}
|
||
return result
|
||
})
|
||
|
||
$effect(() => {
|
||
hasAnyInvalid = Object.values(invalidCategories).some(Boolean)
|
||
})
|
||
|
||
export function isDirty(category: string): boolean {
|
||
return dirtyCategories[category] ?? false
|
||
}
|
||
|
||
export function discardCategory(category: string) {
|
||
const categorySettings = getSettingsForCategory(category)
|
||
for (const s of categorySettings) {
|
||
const v = initialValues[s.key]
|
||
$values[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
|
||
}
|
||
if (category === 'Auth/OAuth/SAML') {
|
||
oauths = JSON.parse(JSON.stringify(initialOauths))
|
||
requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth
|
||
loadInstanceInputs(initialOauths)
|
||
} else if (category === 'Registries') {
|
||
const v = initialValues['workspace_registries']
|
||
$values['workspace_registries'] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
|
||
}
|
||
}
|
||
|
||
export function discardAll() {
|
||
// Reset all values to initial state (deep copy to avoid reference sharing)
|
||
$values = JSON.parse(JSON.stringify(initialValues))
|
||
oauths = JSON.parse(JSON.stringify(initialOauths))
|
||
requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth
|
||
loadInstanceInputs(initialOauths)
|
||
if (yamlMode) {
|
||
syncFormToYaml()
|
||
}
|
||
}
|
||
|
||
export async function saveCategorySettings(category: string) {
|
||
// Category-specific pre-processing
|
||
if (category === 'Auth/OAuth/SAML') {
|
||
setupTemplatedOauthUrls()
|
||
}
|
||
|
||
if (category === 'Alerts' && $values?.critical_error_channels) {
|
||
$values.critical_error_channels = $values.critical_error_channels.filter((entry: any) => {
|
||
if (!entry || typeof entry !== 'object') return false
|
||
if ('teams_channel' in entry) return isValidTeamsChannel(entry.teams_channel)
|
||
if ('slack_channel' in entry)
|
||
return typeof entry.slack_channel === 'string' && entry.slack_channel.trim() !== ''
|
||
if ('email' in entry) return typeof entry.email === 'string' && entry.email.trim() !== ''
|
||
return false
|
||
})
|
||
}
|
||
|
||
if (
|
||
category === 'Core' &&
|
||
$values?.['license_key'] &&
|
||
typeof $values['license_key'] === 'string'
|
||
) {
|
||
$values['license_key'] = $values['license_key'].trim()
|
||
}
|
||
|
||
let shouldReloadPage = false
|
||
let willRestart = false
|
||
const categorySettings = getSettingsForCategory(category)
|
||
|
||
let licenseKeySet = false
|
||
await Promise.all(
|
||
categorySettings
|
||
.filter((x) => {
|
||
return (
|
||
x.storage === 'setting' &&
|
||
!deepEqual(initialValues?.[x.key], $values?.[x.key]) &&
|
||
($values?.[x.key] !== '' ||
|
||
initialValues?.[x.key] !== undefined ||
|
||
initialValues?.[x.key] !== null)
|
||
)
|
||
})
|
||
.map(async (x) => {
|
||
if (x.key === 'license_key') licenseKeySet = true
|
||
if (x.requiresReloadOnChange) shouldReloadPage = true
|
||
if (x.triggersRestart) willRestart = true
|
||
let value = $values?.[x.key]
|
||
if (x.fieldType === 'codearea' && typeof value === 'string' && value.trim() === '') {
|
||
value = undefined
|
||
}
|
||
return await SettingService.setGlobal({
|
||
key: x.key,
|
||
requestBody: { value }
|
||
})
|
||
})
|
||
)
|
||
|
||
// Update only the saved category's initial values
|
||
for (const s of categorySettings) {
|
||
const v = $values[s.key]
|
||
initialValues[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
|
||
}
|
||
instanceSettingsSaved.update((n) => n + 1)
|
||
if (categorySettings.some((s) => s.key === 'base_url')) {
|
||
baseUrlIsFallback = false
|
||
}
|
||
|
||
// Handle Auth/OAuth/SAML-specific saves
|
||
if (category === 'Auth/OAuth/SAML') {
|
||
if (!deepEqual(stripEmpty(initialOauths), stripEmpty(oauths))) {
|
||
await SettingService.setGlobal({
|
||
key: 'oauths',
|
||
requestBody: { value: oauths }
|
||
})
|
||
initialOauths = JSON.parse(JSON.stringify(oauths))
|
||
}
|
||
if (initialRequirePreexistingUserForOauth !== requirePreexistingUserForOauth) {
|
||
await SettingService.setGlobal({
|
||
key: 'require_preexisting_user_for_oauth',
|
||
requestBody: { value: requirePreexistingUserForOauth }
|
||
})
|
||
initialRequirePreexistingUserForOauth = requirePreexistingUserForOauth
|
||
}
|
||
}
|
||
|
||
// Handle workspace_registries (saved separately like oauths)
|
||
if (category === 'Registries') {
|
||
if (!deepEqual(initialValues['workspace_registries'], $values['workspace_registries'])) {
|
||
await SettingService.setGlobal({
|
||
key: 'workspace_registries',
|
||
requestBody: { value: $values['workspace_registries'] ?? null }
|
||
})
|
||
initialValues['workspace_registries'] = $values['workspace_registries']
|
||
? JSON.parse(JSON.stringify($values['workspace_registries']))
|
||
: undefined
|
||
}
|
||
}
|
||
|
||
if (licenseKeySet) setLicense()
|
||
|
||
if (shouldReloadPage) {
|
||
sendUserToast('Settings updated, reloading page...')
|
||
await sleep(1000)
|
||
window.location.reload()
|
||
} else if (willRestart) {
|
||
sendUserToast(
|
||
'Settings updated. Servers are restarting and changes may take up to a minute to fully propagate.',
|
||
false,
|
||
[],
|
||
undefined,
|
||
8000
|
||
)
|
||
dispatch('saved')
|
||
} else {
|
||
sendUserToast('Settings updated')
|
||
dispatch('saved')
|
||
}
|
||
}
|
||
|
||
let yamlCode = $state('')
|
||
let yamlCodeInitial = $state('')
|
||
let yamlEditor: any | undefined = $state(undefined)
|
||
let yamlError = $state('')
|
||
let showSensitive = $state(false)
|
||
|
||
const SENSITIVE_UNCHANGED = '__SENSITIVE_AND_UNCHANGED__'
|
||
|
||
const sensitiveKeys: Set<string> = new Set([
|
||
...[...Object.values(settings), scimSamlSetting]
|
||
.flatMap((s) => Object.values(s))
|
||
.filter((s) => s.fieldType === 'password' || s.fieldType === 'license_key')
|
||
.map((s) => s.key),
|
||
'ducklake_user_pg_pwd',
|
||
'jwt_secret',
|
||
'workspace_registries'
|
||
])
|
||
|
||
// Settings that should never appear in YAML export/import.
|
||
// `worker_configs` is a legacy ghost key: worker configs live in the `config`
|
||
// table (managed from /workers), not in `global_settings`. Older DBs may
|
||
// still carry a stale `global_settings.worker_configs` row; filter it here
|
||
// so it never round-trips through this editor.
|
||
const excludedKeys: Set<string> = new Set(['worker_configs'])
|
||
|
||
// Nested fields inside object-valued settings that contain secrets.
|
||
// Each entry maps a top-level key to its sensitive sub-field names.
|
||
const nestedSensitiveFields: Record<string, string[]> = {
|
||
smtp_settings: ['smtp_password'],
|
||
secret_backend: ['token'],
|
||
object_store_cache_config: ['secret_key', 'serviceAccountKey'],
|
||
custom_instance_pg_databases: ['user_pwd'],
|
||
rsa_keys: ['private_key'],
|
||
github_enterprise_app: ['private_key']
|
||
}
|
||
|
||
/** Returns SENSITIVE_UNCHANGED if the value is non-empty and matches the initial */
|
||
function maskField(current: any, initial: any): string | undefined {
|
||
if (current != null && current !== '' && current === initial) return SENSITIVE_UNCHANGED
|
||
return undefined
|
||
}
|
||
|
||
function maskSensitive(obj: Record<string, any>): Record<string, any> {
|
||
const masked: Record<string, any> = {}
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
if (key === 'oauths' && typeof value === 'object' && value !== null) {
|
||
const maskedOauths: Record<string, any> = {}
|
||
for (const [provider, config] of Object.entries(value as Record<string, any>)) {
|
||
if (typeof config === 'object' && config !== null && 'secret' in config) {
|
||
const m = maskField(config.secret, initialOauths?.[provider]?.secret)
|
||
maskedOauths[provider] = m ? { ...config, secret: m } : config
|
||
} else {
|
||
maskedOauths[provider] = config
|
||
}
|
||
}
|
||
masked[key] = maskedOauths
|
||
} else if (key in nestedSensitiveFields && typeof value === 'object' && value !== null) {
|
||
const cp = { ...value }
|
||
const init = initialValues?.[key]
|
||
for (const field of nestedSensitiveFields[key]) {
|
||
const m = maskField(
|
||
field === 'serviceAccountKey' ? JSON.stringify(cp[field]) : cp[field],
|
||
field === 'serviceAccountKey' ? JSON.stringify(init?.[field]) : init?.[field]
|
||
)
|
||
if (m) cp[field] = m
|
||
}
|
||
masked[key] = cp
|
||
} else if (sensitiveKeys.has(key) && value != null && value !== '') {
|
||
masked[key] = value === initialValues?.[key] ? SENSITIVE_UNCHANGED : value
|
||
} else {
|
||
masked[key] = value
|
||
}
|
||
}
|
||
return masked
|
||
}
|
||
|
||
/**
|
||
* Builds a sorted YAML string of all instance settings.
|
||
* - normalize: strip keys whose values match the default (empty/falsy or key-specific defaults)
|
||
* - mask: replace sensitive values with placeholder (for display)
|
||
*/
|
||
function buildSettingsYaml(
|
||
vals: Record<string, any>,
|
||
oauthsObj: Record<string, any>,
|
||
reqPreexisting: boolean,
|
||
opts: { normalize?: boolean; mask?: boolean } = {}
|
||
): string {
|
||
// Merge all settings (including oauths) into one object so they sort together
|
||
const merged: Record<string, any> = { ...vals }
|
||
if (oauthsObj && Object.keys(stripEmpty(oauthsObj)).length > 0) {
|
||
merged['oauths'] = oauthsObj
|
||
}
|
||
if (reqPreexisting) {
|
||
merged['require_preexisting_user_for_oauth'] = reqPreexisting
|
||
}
|
||
const obj: Record<string, any> = {}
|
||
for (const key of Object.keys(merged).sort()) {
|
||
if (excludedKeys.has(key)) continue
|
||
if (opts.normalize && normalizeValue(merged[key], key) === undefined) continue
|
||
obj[key] = merged[key]
|
||
}
|
||
// Strip runtime-only `databases` sub-field from custom_instance_pg_databases
|
||
if (obj['custom_instance_pg_databases']?.databases) {
|
||
obj['custom_instance_pg_databases'] = { ...obj['custom_instance_pg_databases'] }
|
||
delete obj['custom_instance_pg_databases'].databases
|
||
}
|
||
return YAML.stringify(opts.mask ? maskSensitive(obj) : obj)
|
||
}
|
||
|
||
function syncFormToYaml() {
|
||
yamlCode = buildSettingsYaml($values, oauths, requirePreexistingUserForOauth, {
|
||
normalize: true,
|
||
mask: !showSensitive
|
||
})
|
||
yamlCodeInitial = buildSettingsYaml(
|
||
initialValues,
|
||
initialOauths,
|
||
initialRequirePreexistingUserForOauth,
|
||
{ normalize: true, mask: !showSensitive }
|
||
)
|
||
yamlEditor?.setCode(yamlCode)
|
||
yamlError = ''
|
||
}
|
||
|
||
function syncYamlToForm(): boolean {
|
||
try {
|
||
// Flush the editor's current content (bypasses the 200ms debounce in SimpleEditor)
|
||
const currentCode = yamlEditor?.getCode() ?? yamlCode
|
||
if (currentCode !== yamlCode) {
|
||
yamlCode = currentCode
|
||
}
|
||
const parsed = YAML.parse(yamlCode)
|
||
if (typeof parsed !== 'object' || parsed === null) {
|
||
sendUserToast('YAML must be a mapping (key: value)', true)
|
||
return false
|
||
}
|
||
|
||
// Restore sensitive values that were not changed (placeholder → original value)
|
||
if ('oauths' in parsed && typeof parsed['oauths'] === 'object') {
|
||
for (const [provider, config] of Object.entries(parsed['oauths'] as Record<string, any>)) {
|
||
if (
|
||
typeof config === 'object' &&
|
||
config !== null &&
|
||
config.secret === SENSITIVE_UNCHANGED
|
||
) {
|
||
config.secret = initialOauths?.[provider]?.secret
|
||
}
|
||
}
|
||
oauths = parsed['oauths'] ?? {}
|
||
delete parsed['oauths']
|
||
}
|
||
if ('require_preexisting_user_for_oauth' in parsed) {
|
||
requirePreexistingUserForOauth = parsed['require_preexisting_user_for_oauth'] ?? false
|
||
delete parsed['require_preexisting_user_for_oauth']
|
||
}
|
||
|
||
// Restore unchanged sensitive settings (placeholder → original value)
|
||
for (const key of sensitiveKeys) {
|
||
if (key in parsed && parsed[key] === SENSITIVE_UNCHANGED) {
|
||
parsed[key] = initialValues?.[key]
|
||
}
|
||
}
|
||
// Restore nested sensitive fields
|
||
for (const [parentKey, fields] of Object.entries(nestedSensitiveFields)) {
|
||
if (parsed[parentKey] && typeof parsed[parentKey] === 'object') {
|
||
const init = initialValues?.[parentKey]
|
||
for (const field of fields) {
|
||
if (parsed[parentKey][field] === SENSITIVE_UNCHANGED) {
|
||
parsed[parentKey][field] = init?.[field]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Preserve excluded keys from current form state
|
||
for (const key of excludedKeys) {
|
||
if (key in $values) {
|
||
parsed[key] = $values[key]
|
||
}
|
||
}
|
||
|
||
// Preserve runtime-only `databases` sub-field in custom_instance_pg_databases
|
||
const existingDatabases = initialValues?.['custom_instance_pg_databases']?.databases
|
||
if (existingDatabases && parsed['custom_instance_pg_databases']) {
|
||
parsed['custom_instance_pg_databases'].databases = existingDatabases
|
||
}
|
||
|
||
$values = parsed
|
||
applyFormDefaults($values)
|
||
|
||
yamlError = ''
|
||
return true
|
||
} catch (e) {
|
||
yamlError = String(e)
|
||
sendUserToast('Invalid YAML: ' + e, true)
|
||
return false
|
||
}
|
||
}
|
||
|
||
let prevYamlMode = false
|
||
let prevLoading = true
|
||
$effect(() => {
|
||
if (yamlMode && !prevYamlMode) {
|
||
syncFormToYaml()
|
||
} else if (!yamlMode && prevYamlMode) {
|
||
if (!syncYamlToForm()) {
|
||
// Reset toggle back to YAML on parse failure
|
||
yamlMode = true
|
||
}
|
||
} else if (yamlMode && prevLoading && !loading) {
|
||
// Settings just finished loading while in YAML mode
|
||
syncFormToYaml()
|
||
}
|
||
prevYamlMode = yamlMode
|
||
prevLoading = loading
|
||
})
|
||
|
||
function handleShowSensitiveToggle(checked: boolean) {
|
||
// Sync any in-progress edits back to form state before re-rendering
|
||
syncYamlToForm()
|
||
showSensitive = checked
|
||
syncFormToYaml()
|
||
}
|
||
|
||
/** Call before entering diff mode to sync YAML edits into form state */
|
||
export function syncBeforeDiff(): boolean {
|
||
if (yamlMode) {
|
||
return syncYamlToForm()
|
||
}
|
||
return true
|
||
}
|
||
|
||
export function buildFullDiff(): { original: string; modified: string } {
|
||
return {
|
||
original: buildSettingsYaml(
|
||
initialValues,
|
||
initialOauths,
|
||
initialRequirePreexistingUserForOauth,
|
||
{ normalize: true }
|
||
),
|
||
modified: buildSettingsYaml($values, oauths, requirePreexistingUserForOauth, {
|
||
normalize: true
|
||
})
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
if (yamlMode) {
|
||
// In YAML mode, compare editor content against snapshot taken on entry
|
||
hasUnsavedChanges = yamlCodeInitial !== '' && yamlCode !== yamlCodeInitial
|
||
} else {
|
||
// Reuse per-category dirty tracking instead of rebuilding full YAML
|
||
hasUnsavedChanges = Object.values(dirtyCategories).some(Boolean)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<div class="pb-12">
|
||
{#if yamlMode}
|
||
<div class="flex flex-row justify-between">
|
||
<p class="text-2xs text-tertiary">
|
||
Use this YAML to manage instance settings as code.
|
||
<a
|
||
href="https://www.windmill.dev/docs/advanced/instance_settings#kubernetes-operator"
|
||
target="_blank"
|
||
rel="noopener noreferrer">Learn more <ExternalLink size={12} class="inline-block" /></a
|
||
>
|
||
</p>
|
||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||
<div class="flex items-center justify-end gap-4 mb-2">
|
||
<Toggle
|
||
checked={showSensitive}
|
||
on:change={(e) => handleShowSensitiveToggle(e.detail)}
|
||
options={{ right: 'Show sensitive values' }}
|
||
size="xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="border rounded w-full h-[calc(100vh-12rem)]">
|
||
{#await import('$lib/components/SimpleEditor.svelte')}
|
||
<Loader2 class="animate-spin" />
|
||
{:then Module}
|
||
<Module.default
|
||
bind:this={yamlEditor}
|
||
class="h-full"
|
||
lang="yaml"
|
||
bind:code={yamlCode}
|
||
fixedOverflowWidgets={false}
|
||
/>
|
||
{/await}
|
||
</div>
|
||
{#if yamlError}
|
||
<div class="text-red-500 text-xs mt-1">{yamlError}</div>
|
||
{/if}
|
||
{:else if hideTabs}
|
||
{@render categoryContent(tab)}
|
||
{:else}
|
||
<Tabs bind:selected={tab}>
|
||
{#each settingsKeys as category}
|
||
<Tab value={category} label={category}></Tab>
|
||
{/each}
|
||
|
||
{#snippet content()}
|
||
<div class="pt-4"></div>
|
||
{#each Object.keys(settings) as category}
|
||
<TabContent value={category}>
|
||
{@render categoryContent(category)}
|
||
</TabContent>
|
||
{/each}
|
||
{/snippet}
|
||
</Tabs>
|
||
{/if}
|
||
|
||
{#snippet categoryContent(category: string)}
|
||
{#if category == 'Core'}
|
||
<SettingsPageHeader
|
||
title="Core"
|
||
description="Configure the core settings of your Windmill instance."
|
||
link="https://www.windmill.dev/docs/advanced/instance_settings"
|
||
/>
|
||
{:else if category == 'SMTP'}
|
||
<SettingsPageHeader
|
||
title="SMTP"
|
||
description="Setting SMTP unlocks sending emails upon adding new users to the workspace or the instance or sending critical alerts via email."
|
||
link="https://www.windmill.dev/docs/advanced/instance_settings#smtp"
|
||
/>
|
||
{:else if category == 'Registries'}
|
||
<SettingsPageHeader
|
||
title="Registries"
|
||
description="Add private registries for Pip, Bun and npm."
|
||
link="https://www.windmill.dev/docs/advanced/imports"
|
||
/>
|
||
{#if !$enterpriseLicense}
|
||
<Alert type="info" title="Private registries configuration is an EE feature" class="mb-2" />
|
||
{/if}
|
||
{:else if category == 'Alerts'}
|
||
<SettingsPageHeader
|
||
title="Alerts"
|
||
description="Critical alerts automatically notify administrators about system events like job crashes, license issues, worker failures, and queue delays through email, Slack, or Teams."
|
||
link="https://www.windmill.dev/docs/core_concepts/critical_alerts"
|
||
/>
|
||
{:else if category == 'OTEL/Prom'}
|
||
<SettingsPageHeader
|
||
title="OTEL/Prometheus"
|
||
description="Configure OpenTelemetry and Prometheus metrics export for monitoring your Windmill instance."
|
||
link="https://www.windmill.dev/docs/misc/guides/otel"
|
||
/>
|
||
{:else if category == 'Indexer'}
|
||
<SettingsPageHeader
|
||
title="Indexer"
|
||
description="The indexer service unlocks full text search across jobs and service logs. It requires spinning up its own separate container."
|
||
link="https://www.windmill.dev/docs/core_concepts/search_bar#setup"
|
||
/>
|
||
{#if !$enterpriseLicense}
|
||
<Alert
|
||
type="info"
|
||
title="Full text search across jobs and service logs is an EE feature"
|
||
class="mb-2"
|
||
/>
|
||
{/if}
|
||
{:else if category == 'Telemetry'}
|
||
<SettingsPageHeader title="Telemetry" />
|
||
{#if $enterpriseLicense}
|
||
<div class="text-primary pb-4 text-xs">
|
||
Telemetry is required on Enterprise Edition for license compliance. When minimal telemetry
|
||
is enabled, only the following data is sent:
|
||
<ul class="list-disc list-inside pl-2">
|
||
<li>version of your instance</li>
|
||
<li>instance base URL</li>
|
||
<li>login type usage (login type, count)</li>
|
||
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
||
<li>user usage (author count, operator count)</li>
|
||
<li>superadmin email addresses</li>
|
||
<li>development instance status</li>
|
||
</ul>
|
||
<br />When minimal telemetry is disabled, the following is also collected:
|
||
<ul class="list-disc list-inside pl-2">
|
||
<li>job usage (language, total duration, count)</li>
|
||
<li>git sync repo count (sync vs promotion mode)</li>
|
||
<li
|
||
>feature usage (counts of which product features are used, including AI provider and
|
||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||
are started for, whether AI chat skills are turned on or off and how often one is
|
||
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
|
||
membership, the plan tier and quota shown when the execution meter is opened, how often
|
||
an empty workspace home is seen, how often the home page’s create menu and hub-project
|
||
picker are opened and from which entry point, and the name of any public hub project
|
||
imported from the home page and how far that import got, last 30 days)</li
|
||
>
|
||
<li
|
||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||
deployed items use)</li
|
||
>
|
||
<li
|
||
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
|
||
apps, raw apps)</li
|
||
>
|
||
<li
|
||
>infrastructure info (container runtime, managed database provider, database version,
|
||
size and cluster size, max and active connections, object storage backend)</li
|
||
>
|
||
</ul>
|
||
<br />For air-gapped instances, you can download the telemetry data and send it manually.
|
||
</div>
|
||
<div class="flex gap-2 mb-4">
|
||
<Button
|
||
on:click={sendStats}
|
||
variant="default"
|
||
btnClasses="w-auto"
|
||
loading={sendingStats}
|
||
size="xs"
|
||
>
|
||
Send usage
|
||
</Button>
|
||
<Button
|
||
on:click={downloadStats}
|
||
variant="default"
|
||
btnClasses="w-auto"
|
||
loading={downloadingStats}
|
||
size="xs"
|
||
>
|
||
Download usage
|
||
</Button>
|
||
</div>
|
||
{:else}
|
||
<div class="text-primary pb-4 text-xs">
|
||
Anonymous usage data is collected to help improve Windmill.
|
||
<br />The following information is collected:
|
||
<ul class="list-disc list-inside pl-2">
|
||
<li>version of your instance</li>
|
||
<li>instance base URL</li>
|
||
<li>job usage (language, total duration, count)</li>
|
||
<li>login type usage (login type, count)</li>
|
||
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
||
<li>user usage (author count, operator count)</li>
|
||
<li>development instance status</li>
|
||
<li
|
||
>feature usage (counts of which product features are used, including AI provider and
|
||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||
are started for, whether AI chat skills are turned on or off and how often one is
|
||
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
|
||
membership, the plan tier and quota shown when the execution meter is opened, how often
|
||
an empty workspace home is seen, how often the home page’s create menu and hub-project
|
||
picker are opened and from which entry point, and the name of any public hub project
|
||
imported from the home page and how far that import got, last 30 days)</li
|
||
>
|
||
<li
|
||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||
deployed items use)</li
|
||
>
|
||
<li
|
||
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
|
||
apps, raw apps)</li
|
||
>
|
||
</ul>
|
||
</div>
|
||
{/if}
|
||
{:else if category == 'Jobs'}
|
||
<SettingsPageHeader
|
||
title="Jobs"
|
||
description="Configure default timeouts and retention policies for job execution."
|
||
link="https://www.windmill.dev/docs/advanced/instance_settings#jobs"
|
||
/>
|
||
{:else if category == 'Service logs'}
|
||
<SettingsPageHeader
|
||
title="Service logs"
|
||
description="The logs of the Windmill processes themselves — servers, workers and the indexer. Job logs are covered by the job retention period under Jobs."
|
||
/>
|
||
{#if !$values['object_store_cache_config']}
|
||
<div class="pb-4">
|
||
<Alert type="info" title="Log files stay on local disk" size="xs">
|
||
Instance object storage is not configured, so every server and worker keeps its log
|
||
files on its own disk. This page lists what each host wrote, but can only open the files
|
||
belonging to the replica serving the request — another host's are listed and not
|
||
readable — and a host's files go with it when it is replaced. Retention below still
|
||
governs the entries in the database and the files on disk.
|
||
</Alert>
|
||
</div>
|
||
{:else if !$enterpriseLicense}
|
||
<div class="pb-4">
|
||
<Alert type="info" title="Raw log files accumulate without the indexer" size="xs">
|
||
Log files are uploaded to instance object storage, and the indexer that would ingest
|
||
them into the columnar store and delete each one afterwards is an enterprise feature.
|
||
Retention below expires the database entries and the local files; the uploaded copies
|
||
are only removed when <b>Delete logs from s3 periodically</b> is on under Object Storage.
|
||
</Alert>
|
||
</div>
|
||
{/if}
|
||
{:else if category == 'Object Storage'}
|
||
<SettingsPageHeader
|
||
title="Object Storage"
|
||
description="Configure S3-compatible storage for large logs and distributed dependency caching."
|
||
link="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill"
|
||
/>
|
||
{:else if category == 'Private Hub'}
|
||
<SettingsPageHeader
|
||
title="Private Hub"
|
||
description="Connect to a Private Hub instance for sharing custom scripts and integrations."
|
||
link="https://www.windmill.dev/docs/core_concepts/private_hub"
|
||
/>
|
||
{:else if category == 'Secret Storage'}
|
||
<SettingsPageHeader
|
||
title="Secret Storage"
|
||
description="Configure where secrets (secret variables) are stored."
|
||
link="https://www.windmill.dev/docs/core_concepts/workspace_secret_encryption"
|
||
/>
|
||
{:else if category == 'GitHub App'}
|
||
<SettingsPageHeader
|
||
title="GitHub App"
|
||
description="Configure a self-managed GitHub App for git sync on GitHub.com, GHE Cloud or GitHub Enterprise Server."
|
||
link="https://www.windmill.dev/docs/integrations/git_repository#self-managed-github-app"
|
||
/>
|
||
{:else if category == 'DB Health'}
|
||
<SettingsPageHeader
|
||
title="DB Health"
|
||
description="On-demand database diagnostics. Analyze table sizes, job retention, connection pool health, vacuum status, and more."
|
||
/>
|
||
<DbHealth />
|
||
{:else if category == 'Auth/OAuth/SAML'}
|
||
<AuthSettings
|
||
bind:oauths
|
||
bind:instanceInputs
|
||
bind:requirePreexistingUserForOauth
|
||
baseUrl={$values?.base_url}
|
||
bind:tab={authSubTab}
|
||
{hideTabs}
|
||
>
|
||
{#snippet scim()}
|
||
<div class="flex-col flex gap-6 pb-4">
|
||
{#each scimSamlSetting as setting}
|
||
<InstanceSetting
|
||
on:closeDrawer={() => closeDrawer?.()}
|
||
{loading}
|
||
{setting}
|
||
{values}
|
||
{version}
|
||
{oauths}
|
||
/>
|
||
{/each}
|
||
</div>
|
||
{/snippet}
|
||
</AuthSettings>
|
||
{/if}
|
||
|
||
<div class="flex-col flex gap-6 pb-6">
|
||
{#each settings[category] as setting}
|
||
<!-- slack connect is handled with the alert channels settings, smtp_connect is handled in InstanceSetting -->
|
||
{#if setting.fieldType != 'slack_connect' && !(quickSetup && setting.hideInQuickSetup) && !(quickSetup && category === 'Core' && setting.key === 'license_key')}
|
||
<InstanceSetting
|
||
{openSmtpSettings}
|
||
on:closeDrawer={() => closeDrawer?.()}
|
||
{loading}
|
||
{setting}
|
||
{values}
|
||
{version}
|
||
{oauths}
|
||
warning={setting.key === 'base_url' && baseUrlIsFallback
|
||
? 'Auto-detected from browser — not yet saved'
|
||
: setting.key === 'nuget_config' &&
|
||
$values['nuget_config'] &&
|
||
!/<clear\s*\/>/.test($values['nuget_config'])
|
||
? 'Missing <clear /> in <packageSources>. Without it, default sources (like nuget.org) are merged with your custom sources, which is likely not what you want.'
|
||
: undefined}
|
||
/>
|
||
{/if}
|
||
{#if quickSetup && category === 'Core' && setting.key === 'base_url'}
|
||
{@const licenseKeySetting = settings['Core'].find((s) => s.key === 'license_key')}
|
||
{#if licenseKeySetting}
|
||
<InstanceSetting
|
||
{openSmtpSettings}
|
||
on:closeDrawer={() => closeDrawer?.()}
|
||
{loading}
|
||
setting={licenseKeySetting}
|
||
{values}
|
||
{version}
|
||
{oauths}
|
||
/>
|
||
{/if}
|
||
{/if}
|
||
{/each}
|
||
{#if quickSetup && category === 'Core'}
|
||
{@const extraSettings = [
|
||
...settings['Jobs'].filter((s) => s.key === 'job_isolation'),
|
||
...settings['Jobs'].filter((s) => s.key === 'retention_period_secs'),
|
||
...(settings['Object Storage']?.filter((s) => s.key === 'object_store_cache_config') ??
|
||
[])
|
||
]}
|
||
{#each extraSettings as setting}
|
||
<InstanceSetting
|
||
{openSmtpSettings}
|
||
on:closeDrawer={() => closeDrawer?.()}
|
||
{loading}
|
||
{setting}
|
||
{values}
|
||
{version}
|
||
{oauths}
|
||
/>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
|
||
{#if category === 'Registries'}
|
||
<WorkspaceRegistries {values} {loading} />
|
||
{/if}
|
||
|
||
{#if !loading && !quickSetup && !hideTabs}
|
||
<SettingsFooter
|
||
hasUnsavedChanges={dirtyCategories[category] ?? false}
|
||
disabled={invalidCategories[category] ?? false}
|
||
onSave={() => saveCategorySettings(category)}
|
||
onDiscard={() => discardCategory(category)}
|
||
saveLabel={`Save ${category.toLowerCase()} settings`}
|
||
class="bg-surface"
|
||
/>
|
||
{/if}
|
||
{/snippet}
|
||
</div>
|