feat: IaC hints, YAML editor for worker configs (#7956)

* feat: add CRD configuration hints to instance settings and workers pages

Add subtle text notes with doc links to help K8s users discover that
instance settings and worker group configs can be managed via the
WindmillInstance CRD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: IaC hints, YAML editor for worker configs, and EE badges

- Move IaC hint from form view to YAML mode in Instance Settings
- Add YAML editor drawer for worker group configs with diff review
- Add EE-only badges to gated fields in worker config drawer
- Make init_bash available in CE (frontend + backend gate)
- Promote Init/Periodic scripts to top-level sections
- Add "YAML editor" button to worker config drawer action bar
- Strip cache_clear and empty fields from YAML output
- Sort default/native groups first in YAML editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback on worker YAML editor

- Extract serializeWorkerGroupsAsYaml() shared helper (dedup)
- Fix name:undefined leak using proper destructuring
- Protect default/native groups from accidental deletion
- Add loading indicator during YAML save
- Add restart warning for periodic script changes
- Use consistent on:click event syntax in YAML drawer actions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: use onClick prop instead of on:click for Button components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-15 15:53:03 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 69d8d92123
commit 2f32209a50
5 changed files with 338 additions and 168 deletions
+3 -2
View File
@@ -129,10 +129,11 @@ async fn update_config(
#[cfg(not(feature = "enterprise"))]
let config = if name.starts_with("worker__") {
// In CE, only allow setting worker_tags (and cache_clear for cache management)
// In CE, only allow setting worker_tags, cache_clear, and init_bash
serde_json::json!({
"worker_tags": config.get("worker_tags"),
"cache_clear": config.get("cache_clear")
"cache_clear": config.get("cache_clear"),
"init_bash": config.get("init_bash")
})
} else {
config
@@ -20,9 +20,10 @@
config: AutoscalingConfig | undefined
worker_tags: string[] | undefined
disabled: boolean
eeOnly?: boolean
}
let { config = $bindable(), worker_tags, disabled }: Props = $props()
let { config = $bindable(), worker_tags, disabled, eeOnly = false }: Props = $props()
let test_input: number = $state(3)
let healthCheckLoading: boolean = $state(false)
let healthCheckResult: { success: boolean; error?: string } | null = $state(null)
@@ -62,6 +63,7 @@
class="flex flex-col gap-6"
bind:collapsed
description="Autoscaling automatically adjusts the number of workers based on your workload demands."
{eeOnly}
>
{#snippet labelExtra()}
<Badge color="gray">Beta</Badge>
@@ -15,7 +15,7 @@
import AuthSettings from './AuthSettings.svelte'
import InstanceSetting from './InstanceSetting.svelte'
import { writable, type Writable } from 'svelte/store'
import { Loader2 } from 'lucide-svelte'
import { ExternalLink, Loader2 } from 'lucide-svelte'
import YAML from 'yaml'
import Toggle from './Toggle.svelte'
import type SimpleEditor from './SimpleEditor.svelte'
@@ -788,6 +788,14 @@
{/await}
</div>
{:else if yamlMode}
<p class="text-2xs text-tertiary mb-2">
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
+123 -139
View File
@@ -1,14 +1,14 @@
<script lang="ts">
import {
TriangleAlert,
Copy,
Plus,
RefreshCcwIcon,
RotateCcw,
Settings,
Trash,
X,
ExternalLink
ExternalLink,
FileCode
} from 'lucide-svelte'
import { Alert, Button, Drawer } from './common'
import Badge from './common/badge/Badge.svelte'
@@ -31,7 +31,6 @@
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Section from './Section.svelte'
import Label from './Label.svelte'
import YAML from 'yaml'
import Toggle from './Toggle.svelte'
import { defaultTags, nativeTags, type AutoscalingConfig } from './worker_group'
import AutoscalingConfigEditor from './AutoscalingConfigEditor.svelte'
@@ -202,6 +201,7 @@
shouldAutoOpenDrawer?: boolean
onDrawerOpened?: () => void
onDeleted?: (deletedGroupName: string) => void
onOpenYamlEditor?: () => void
selectGroup?: import('svelte').Snippet
}
@@ -217,6 +217,7 @@
shouldAutoOpenDrawer = false,
onDrawerOpened = () => {},
onDeleted = () => {},
onOpenYamlEditor = undefined,
selectGroup = undefined
}: Props = $props()
@@ -351,7 +352,7 @@
Only superadmin or devops role can edit the worker config.
</Alert>
{/if}
<Label label="Workers assignment">
<Label label="Workers assignment" eeOnly={!hasEnterpriseFeatures}>
<ToggleButtonGroup
{selected}
disabled={!canEditEEConfig}
@@ -469,7 +470,7 @@
{#if nconfig?.worker_tags !== undefined && nconfig?.worker_tags.length > 0}
<div class="mt-8"></div>
<Label label="High-priority tags">
<Label label="High-priority tags" eeOnly={!hasEnterpriseFeatures}>
{#snippet header()}
<Tooltip>
{#snippet text()}
@@ -496,7 +497,7 @@
{#if nconfig !== undefined}
<div class="mt-8"></div>
<Label label="Alerts" tooltip="Alert is sent to the configured critical error channels">
<Label label="Alerts" tooltip="Alert is sent to the configured critical error channels" eeOnly={!hasEnterpriseFeatures}>
<Toggle
size="sm"
options={{
@@ -561,6 +562,7 @@
<Label
label="Environment variables passed to jobs"
tooltip="Add static and dynamic environment variables that will be passed to jobs handled by this worker group. Dynamic environment variable values will be loaded from the worker host environment variables while static environment variables will be set directly from their values below."
eeOnly={!hasEnterpriseFeatures}
>
<div class="flex flex-col gap-y-2 pb-2 max-w">
{#each customEnvVars as envvar, i}
@@ -698,10 +700,11 @@
worker_tags={config?.worker_tags}
bind:config={nconfig.autoscaling}
disabled={!canEditEEConfig}
eeOnly={!hasEnterpriseFeatures}
/>
<div class="mt-8"></div>
<Section label="Python dependencies overrides" collapsable={true} class="flex flex-col gap-y-6">
<Section label="Python dependencies overrides" collapsable={true} class="flex flex-col gap-y-6" eeOnly={!hasEnterpriseFeatures}>
<Label
label="Additional Python paths"
tooltip="Paths to add to the Python path for it to search dependencies, useful if you have packages pre-installed on the workers at a given path."
@@ -814,123 +817,131 @@
<div class="mt-8"></div>
<Section
label="Worker scripts"
tooltip="Bash scripts for worker initialization and maintenance. Init scripts run at worker start, periodic scripts run at configurable intervals."
label="Init script"
tooltip="Bash script run at start of the workers. More lightweight than requiring custom worker images."
collapsable
initiallyCollapsed={nconfig.init_bash === undefined}
>
{#if canEditEEConfig}
<div class="mb-4">
{#snippet header()}
<div class="ml-4 flex flex-row gap-2 items-center">
{#if nconfig.init_bash !== undefined}
<Badge color="green">Enabled</Badge>
{/if}
</div>
{/snippet}
{#if (nconfig.init_bash ?? '') !== (config?.init_bash ?? '')}
<div class="mb-2">
<Alert size="xs" type="info" title="Worker restart required">
Workers will get killed upon detecting any changes in this section (scripts or
interval). It is assumed they are in an environment where the supervisor will restart
them.
Workers will get killed upon detecting changes to the init script. It is assumed they
are in an environment where the supervisor will restart them.
</Alert>
</div>
{/if}
<p class="text-xs text-secondary mb-2">
Execution logs are visible on the runs page of the admins workspace. Use the script editor
with Bash to iterate on your script before setting it here.
</p>
<div class="border w-full h-40">
<Editor
fixedOverflowWidgets={true}
disabled={!canEditConfig}
class="flex flex-1 grow h-full w-full"
automaticLayout
scriptLang={'bash'}
useWebsockets={false}
code={config?.init_bash ?? ''}
on:change={(e) => {
if (config) {
const code = e.detail
if (code != '') {
nconfig.init_bash = code?.replace(/\r\n/g, '\n')
} else {
nconfig.init_bash = undefined
}
}
}}
/>
</div>
</Section>
<div class="mt-8"></div>
<Section
label="Periodic script"
tooltip="Bash script run periodically at configurable intervals. Useful for maintenance tasks like cleaning disk space."
collapsable
eeOnly={!hasEnterpriseFeatures}
initiallyCollapsed={nconfig.periodic_script_bash === undefined}
>
{#snippet header()}
<div class="ml-4 flex flex-row gap-2 items-center">
{#if nconfig.periodic_script_bash !== undefined}
<Badge color="green">Enabled</Badge>
{/if}
</div>
{/snippet}
{#if (nconfig.periodic_script_bash ?? '') !== (config?.periodic_script_bash ?? '') || (nconfig.periodic_script_interval_seconds ?? 0) !== (config?.periodic_script_interval_seconds ?? 0)}
<div class="mb-2">
<Alert size="xs" type="info" title="Worker restart required">
Workers will get killed upon detecting changes to the periodic script or interval.
It is assumed they are in an environment where the supervisor will restart them.
</Alert>
</div>
{/if}
<div class="space-y-6">
<div>
<div class="text-xs text-secondary mb-1">
Run at start of the workers. More lightweight than requiring custom worker images.
</div>
<Section
small
label="Init script"
collapsable
initiallyCollapsed={nconfig.init_bash === undefined}
>
{#snippet header()}
<div class="ml-4 flex flex-row gap-2 items-center">
{#if nconfig.init_bash !== undefined}
<Badge color="green">Enabled</Badge>
{/if}
</div>
{/snippet}
<div class="border w-full h-40">
<Editor
fixedOverflowWidgets={true}
disabled={!canEditEEConfig}
class="flex flex-1 grow h-full w-full"
automaticLayout
scriptLang={'bash'}
useWebsockets={false}
code={config?.init_bash ?? ''}
on:change={(e) => {
if (config) {
const code = e.detail
if (code != '') {
nconfig.init_bash = code?.replace(/\r\n/g, '\n')
} else {
nconfig.init_bash = undefined
}
}
}}
/>
</div>
</Section>
</div>
<div>
<div class="text-xs text-secondary mb-1">
Run periodically at configurable intervals. Useful for maintenance tasks like cleaning
disk space.
</div>
<Section
small
label="Periodic script"
collapsable
initiallyCollapsed={nconfig.periodic_script_bash === undefined}
>
{#snippet header()}
<div class="ml-4 flex flex-row gap-2 items-center">
{#if nconfig.periodic_script_bash !== undefined}
<Badge color="green">Enabled</Badge>
{/if}
</div>
{/snippet}
<div class="flex gap-4 items-center mb-4">
<Label label="Execution interval (seconds)" for="periodic-script-interval-seconds">
<TextInput
inputProps={{
disabled: !canEditEEConfig,
type: 'number',
min: '60',
placeholder: '3600',
class: '!w-24 '
}}
bind:value={nconfig.periodic_script_interval_seconds}
/>
<span class="text-2xs text-hint">Minimum: 60 seconds</span>
</Label>
</div>
<div class="flex gap-4 items-center mb-4">
<Label label="Execution interval (seconds)" for="periodic-script-interval-seconds">
<TextInput
inputProps={{
disabled: !canEditEEConfig,
type: 'number',
min: '60',
placeholder: '3600',
class: '!w-24 '
}}
bind:value={nconfig.periodic_script_interval_seconds}
/>
<span class="text-2xs text-hint">Minimum: 60 seconds</span>
</Label>
</div>
<div class="border w-full h-40">
<Editor
disabled={!canEditEEConfig}
class="flex flex-1 grow h-full w-full"
automaticLayout
scriptLang={'bash'}
useWebsockets={false}
fixedOverflowWidgets={false}
code={config?.periodic_script_bash ?? ''}
on:change={(e) => {
if (config) {
const code = e.detail
if (code != '') {
nconfig.periodic_script_bash = code?.replace(/\r\n/g, '\n')
} else {
nconfig.periodic_script_bash = undefined
}
}
}}
/>
</div>
</Section>
</div>
<div class="border w-full h-40">
<Editor
disabled={!canEditEEConfig}
class="flex flex-1 grow h-full w-full"
automaticLayout
scriptLang={'bash'}
useWebsockets={false}
fixedOverflowWidgets={false}
code={config?.periodic_script_bash ?? ''}
on:change={(e) => {
if (config) {
const code = e.detail
if (code != '') {
nconfig.periodic_script_bash = code?.replace(/\r\n/g, '\n')
} else {
nconfig.periodic_script_bash = undefined
}
}
}}
/>
</div>
</Section>
{#snippet actions()}
<div class="flex gap-4 items-center">
{#if onOpenYamlEditor}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: FileCode }}
onClick={() => {
drawer?.closeDrawer()
onOpenYamlEditor?.()
}}
>
YAML editor
</Button>
{/if}
<div class="flex gap-2 items-center">
{#if canEditConfig}
<Button
@@ -1092,36 +1103,9 @@
>
Clean cache
</Button>
{#if config}
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => {
navigator.clipboard.writeText(
YAML.stringify({
name,
...config
})
)
sendUserToast('Worker config copied to clipboard as YAML')
}}
startIcon={{ icon: Copy }}
>
Copy config
</Button>
{/if}
{:else}
<Dropdown
items={[
{
displayName: 'Copy config',
action: () => {
navigator.clipboard.writeText(YAML.stringify({ name, ...config }))
sendUserToast('Worker config copied to clipboard as YAML')
},
disabled: !config
},
{
displayName: 'Clean cache',
action: () => {
@@ -29,6 +29,7 @@
import {
ExternalLink,
LineChart,
Loader2,
Plus,
Search,
Tags,
@@ -379,6 +380,111 @@
await loadWorkerGroups()
}
let yamlConfigDrawer: Drawer | undefined = $state(undefined)
let yamlConfigCode = $state('')
let yamlConfigOriginal = $state('')
let yamlDiffMode = $state(false)
let yamlSaving = $state(false)
function serializeWorkerGroupsAsYaml(groups: Record<string, any>): string {
const priorityGroups = ['default', 'native']
const configs = Object.entries(groups)
.sort(([a], [b]) => {
const ai = priorityGroups.indexOf(a)
const bi = priorityGroups.indexOf(b)
if (ai !== -1 && bi !== -1) return ai - bi
if (ai !== -1) return -1
if (bi !== -1) return 1
return a.localeCompare(b)
})
.map(([name, { cache_clear, ...config }]) => {
const cleaned: Record<string, any> = { name }
for (const [k, v] of Object.entries(config)) {
if (v == null) continue
if (Array.isArray(v) && v.length === 0) continue
if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue
cleaned[k] = v
}
return cleaned
})
return YAML.stringify(configs)
}
function openYamlDrawer() {
if (!workerGroups) {
return sendUserToast('No worker groups found', true)
}
yamlConfigCode = serializeWorkerGroupsAsYaml(workerGroups)
yamlConfigOriginal = yamlConfigCode
yamlDiffMode = false
yamlConfigDrawer?.toggleDrawer?.()
}
async function saveYamlConfig() {
yamlSaving = true
try {
const parsed = YAML.parse(yamlConfigCode)
if (!Array.isArray(parsed)) {
sendUserToast('YAML must be an array of worker group configs', true)
return
}
const newGroups = new Map<string, any>()
for (const c of parsed) {
if (typeof c !== 'object' || !c?.name || typeof c.name !== 'string') {
sendUserToast('Each entry must have a "name" field', true)
return
}
const { name: _, ...rest } = c
newGroups.set(c.name, rest)
}
const oldNames = new Set(Object.keys(workerGroups ?? {}))
const newNames = new Set(newGroups.keys())
// Protect well-known groups from accidental deletion
const protectedGroups = ['default', 'native']
const deletedProtected = protectedGroups.filter(
(g) => oldNames.has(g) && !newNames.has(g)
)
if (deletedProtected.length > 0) {
sendUserToast(
`Cannot remove well-known groups: ${deletedProtected.join(', ')}. Add them back to the YAML.`,
true
)
return
}
// Delete removed groups
for (const name of oldNames) {
if (!newNames.has(name)) {
await ConfigService.deleteConfig({ name: 'worker__' + name })
}
}
// Create or update groups
for (const [name, config] of newGroups) {
await ConfigService.updateConfig({
name: 'worker__' + name,
requestBody: config
})
}
yamlDiffMode = false
yamlConfigDrawer?.toggleDrawer?.()
sendUserToast('Worker group configs saved')
await loadWorkerGroups()
} catch (err) {
if (err instanceof Error) {
sendUserToast(err.message, true)
} else {
sendUserToast('Could not save worker group configs', true)
}
} finally {
yamlSaving = false
}
}
let queueMetricsDrawer: QueueMetricsDrawer | undefined = $state(undefined)
let manageTagsDrawer: ManageTagsDrawer | undefined = $state(undefined)
let selectedTab: string = $state('default')
@@ -523,6 +629,74 @@
</DrawerContent>
</Drawer>
<Drawer bind:this={yamlConfigDrawer} size="800px">
<DrawerContent
title="Worker groups config (YAML)"
on:close={() => {
yamlDiffMode = false
yamlConfigDrawer?.toggleDrawer?.()
}}
>
<p class="text-2xs text-tertiary mb-2">
Use this YAML to manage worker group configs 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>
{#if yamlDiffMode}
<div class="w-full h-full">
{#await import('$lib/components/DiffEditor.svelte')}
<div class="flex items-center justify-center h-full">
<Loader2 class="animate-spin" />
</div>
{:then Module}
<Module.default
open={true}
className="!h-full"
defaultLang="yaml"
defaultOriginal={yamlConfigOriginal}
defaultModified={yamlConfigCode}
readOnly
inlineDiff={true}
/>
{/await}
</div>
{:else}
<SimpleEditor
bind:code={yamlConfigCode}
lang="yaml"
class="h-full"
fixedOverflowWidgets={false}
/>
{/if}
{#snippet actions()}
{#if yamlDiffMode}
<Button
unifiedSize="md"
variant="default"
disabled={yamlSaving}
onClick={() => { yamlDiffMode = false }}
>Back to editor</Button>
<Button
unifiedSize="md"
variant="accent"
loading={yamlSaving}
onClick={saveYamlConfig}
>Save</Button>
{:else}
<Button
unifiedSize="md"
variant="accent"
disabled={!yamlConfigCode || yamlConfigCode === yamlConfigOriginal}
onClick={() => { yamlDiffMode = true }}
>Review & Save</Button>
{/if}
{/snippet}
</DrawerContent>
</Drawer>
<Drawer bind:this={newHttpAgentWorkerDrawer} size="800px">
<DrawerContent
title="New HTTP agent worker"
@@ -625,33 +799,32 @@
unifiedSize="md"
startIcon={{ icon: Plus }}
nonCaptureEvent
dropdownItems={$enterpriseLicense
? [
{
label: 'Copy groups config as YAML',
onClick: () => {
if (!workerGroups) {
return sendUserToast('No worker groups found', true)
dropdownItems={[
...$enterpriseLicense
? [
{
label: 'Copy groups config as YAML',
onClick: () => {
if (!workerGroups) {
return sendUserToast('No worker groups found', true)
}
navigator.clipboard.writeText(serializeWorkerGroupsAsYaml(workerGroups))
sendUserToast('Worker groups config copied to clipboard as YAML')
}
},
{
label: 'Import groups config from YAML',
onClick: () => {
importConfigDrawer?.toggleDrawer?.()
}
const workersConfig = Object.entries(workerGroups).map(
([name, config]) => ({
name,
...config
})
)
navigator.clipboard.writeText(YAML.stringify(workersConfig))
sendUserToast('Worker groups config copied to clipboard as YAML')
}
},
{
label: 'Import groups config from YAML',
onClick: () => {
importConfigDrawer?.toggleDrawer?.()
}
}
]
: undefined}
]
: [],
{
label: 'Edit all configs as YAML',
onClick: openYamlDrawer
}
]}
>
<span class="hidden md:block"
>New group config</span
@@ -758,6 +931,7 @@
shouldAutoOpenDrawer = ''
}}
onDeleted={handleWorkerGroupDeleted}
onOpenYamlEditor={openYamlDrawer}
selectGroup={(groupedWorkers ?? []).length > 5 ? selectGroup : undefined}
/>
@@ -1080,6 +1254,7 @@
shouldAutoOpenDrawer = ''
}}
onDeleted={handleWorkerGroupDeleted}
onOpenYamlEditor={openYamlDrawer}
/>
<div class="text-xs text-primary"> No workers currently in this worker group </div>
{/if}