Files
windmill/frontend/src/lib/components/AutoscalingConfigEditor.svelte
T
2026-10-18 11:12:17 +02:00

550 lines
17 KiB
Svelte

<script lang="ts">
import type { AutoscalingConfig } from './worker_group'
import Toggle from './Toggle.svelte'
import Section from './Section.svelte'
import Tooltip from './Tooltip.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Alert, Button } from './common'
import { ExternalLink } from 'lucide-svelte'
import TextInput from './text_input/TextInput.svelte'
import Label from './Label.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
import { ConfigService } from '$lib/gen'
import Select from './select/Select.svelte'
import ScriptPicker from './ScriptPicker.svelte'
import { sendUserToast } from '$lib/toast'
import { untrack } from 'svelte'
interface Props {
config: AutoscalingConfig | undefined
worker_tags: string[] | undefined
/** Whether the draft config gives the group any tags (worker_tags or dedicated workers). */
group_has_tags: boolean
/** Tags reported by the group's live workers, which for agent workers come from their token. */
reported_tags: string[]
disabled: boolean
eeOnly?: boolean
}
let {
config = $bindable(),
worker_tags,
group_has_tags,
reported_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)
function validateMinMax(): string | undefined {
if (config?.min_workers && config?.max_workers && config.min_workers > config.max_workers) {
return 'Minimum cannot be greater than maximum'
}
return undefined
}
async function checkKubernetesHealth() {
if (!config?.integration || config.integration.type !== 'kubernetes') return
healthCheckLoading = true
healthCheckResult = null
try {
await ConfigService.nativeKubernetesAutoscalingHealthcheck()
healthCheckResult = { success: true }
} catch (error: any) {
healthCheckResult = {
success: false,
error: error.body || error.message || 'Unknown error'
}
} finally {
healthCheckLoading = false
}
}
let noTagsToCount = $derived(!!config?.enabled && !group_has_tags && !config.custom_tags?.length)
let collapsed: boolean = $state(untrack(() => !noTagsToCount))
</script>
<Section
label="Autoscaling"
collapsable
class="flex flex-col gap-6"
bind:collapsed
description="Autoscaling automatically adjusts the number of workers based on your workload demands."
{eeOnly}
>
{#snippet header()}
<div class="ml-2">
<Toggle
checked={config?.enabled ?? false}
options={{ right: 'Enabled' }}
{disabled}
on:change={(e) => {
if (e.detail) {
collapsed = false
if (!config) {
config = {
enabled: true,
min_workers: 3,
max_workers: 10,
integration: { type: 'dryrun' }
}
} else {
config.enabled = true
}
} else {
config = {
...(config ?? {
min_workers: 3,
max_workers: 10,
integration: { type: 'dryrun' }
}),
enabled: false
}
}
}}
/>
</div>
{/snippet}
{#if noTagsToCount}
<Alert
type="warning"
size="xs"
title="Queued jobs won't trigger autoscaling"
actions={reported_tags.length && !disabled
? [
{
label: `Use the tags these workers report: ${reported_tags.join(', ')}`,
onClick: () => {
if (config) config.custom_tags = [...reported_tags]
}
}
]
: []}
>
Autoscaling counts waiting jobs on this group's worker tags, or on the custom tags under
Advanced, and this group has neither. Agent workers don't need either to run, since their tags
come from their token, but to scale on their queue set worker tags or custom tags to the tags
their token was issued with.
</Alert>
{/if}
<div class="flex flex-row gap-4">
<Label label="Min # of workers" disabled={config === undefined} class="grow min-w-0">
<span class="text-xs text-secondary">The minimum number of workers to scale down to</span>
{#if config !== undefined}
<input
type="number"
min="1"
class="rounded-md border border-border-light text-xs text-primary font-normal bg-surface-input px-2 py-1 focus:border-border-selected hover:border-border-selected/50 disabled:bg-surface-disabled disabled:border-transparent disabled:text-disabled"
bind:value={config.min_workers}
{disabled}
/>
{#if validateMinMax()}
<div class="text-2xs text-red-500 font-normal mt-1">
{validateMinMax()}
</div>
{/if}
{:else}
<input type="number" {disabled} placeholder="3" />
{/if}
</Label>
<Label label="Max # of workers" disabled={config === undefined} class="grow min-w-0">
<span class="text-xs text-secondary">The maximum number of workers to scale up to</span>
{#if config !== undefined}
<input
type="number"
min="1"
class="rounded-md border border-border-light text-xs text-primary font-normal bg-surface-input px-2 py-1 focus:border-border-selected hover:border-border-selected/50 disabled:bg-surface-disabled disabled:border-transparent disabled:text-disabled"
bind:value={config.max_workers}
{disabled}
/>
{:else}
<input type="number" disabled placeholder="10" />
{/if}
</Label>
</div>
<Label label="Integration">
<span class="text-xs text-secondary">Choose how to autoscale your worker group</span>
{#if config?.integration}
<div class="flex flex-col gap-2">
<ToggleButtonGroup bind:selected={config.integration.type} {disabled}>
{#snippet children({ item })}
<ToggleButton
value="dryrun"
label="Dry run"
tooltip="See autoscaling events but not actual scaling actions will be performed"
{item}
/>
<ToggleButton
value="script"
label="Custom script"
tooltip="Run a custom script to scale your worker group"
{item}
/>
<ToggleButton disabled value="ecs" label="ECS (soon)" {item} />
<ToggleButton disabled value="nomad" label="Nomad (soon)" {item} />
<ToggleButton value="kubernetes" label="Kubernetes" {item} />
{/snippet}
</ToggleButtonGroup>
{#if config.integration.type === 'script'}
<div class="flex flex-col gap-6 p-4 rounded-md border border-border-light">
<Label label="Script path" required>
<div class="flex flex-row gap-2">
<ScriptPicker
itemKind="script"
bind:scriptPath={
() => config?.integration?.['path'] ?? undefined,
(v) => {
if (!config || !config.integration) return
if (!v || v === '') {
delete config.integration['path']
} else {
config.integration['path'] = v
}
}
}
clearable
{disabled}
/>
{#if config?.integration?.['path'] === undefined || config?.integration?.['path'] === ''}
<Button
variant="default"
target="_blank"
endIcon={{ icon: ExternalLink }}
href="/scripts/add?hub=hub%2F9204%2Fhelper%2FScale%20a%20worker%20group%20deployed%20as%20a%20kubernetes%20service&workspace=admins"
>Create from template
{disabled}
</Button>
{:else}
<Button
variant="default"
target="_blank"
href={`/runs/${config.integration.path}?workspace=admins`}
endIcon={{ icon: ExternalLink }}
{disabled}
>
See jobs
</Button>
{/if}
</div>
<span class="text-2xs text-hint">Script must be in the 'admins' workspace</span>
</Label>
<Label
label="Custom tag for executing script"
tooltip="Optional tag to specify worker capabilities required for this script"
for="custom_tag_select"
>
<Select
clearable
id="custom_tag_select"
disabled={!config || !config.integration || disabled}
bind:value={
() => config?.integration?.['tag'] ?? undefined,
(v) => {
if (!config || !config.integration) return
if (!v || v === '') {
delete config.integration['tag']
} else {
config.integration['tag'] = v
}
}
}
items={safeSelectItems(worker_tags)}
/>
<div class="flex flex-row gap-2 justify-end mt-4">
<Button
variant="default"
unifiedSize="md"
onclick={() => {
if (!config?.integration?.['path']) {
sendUserToast('Please select a script before testing scaling', true)
return
}
if (!config?.integration?.['tag']) {
sendUserToast('Please select a custom tag before testing scaling', true)
return
}
}}>Test scaling</Button
>
<div class="flex text-xs flex-row gap-2 items-center">
<input class="!w-16" type="number" bind:value={test_input} />
workers
</div>
</div>
</Label>
</div>
{:else if config.integration.type === 'kubernetes'}
<div class="flex flex-col gap-3 p-4 border border-border-light rounded-md">
<div class="text-xs text-secondary mb-2">
Kubernetes configuration is automatically inferred from the cluster environment. The
worker group name and namespace will be detected automatically.
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 justify-between">
<div class="flex flex-row gap-2">
<Button
unifiedSize="md"
variant="default"
endIcon={{ icon: ExternalLink }}
href="https://windmill.dev/docs/core_concepts/autoscaling#kubernetes"
target="_blank"
>
Setup Guide (Roles & Bindings)
</Button>
<Button
unifiedSize="md"
variant="default"
onclick={checkKubernetesHealth}
disabled={healthCheckLoading}
>
{healthCheckLoading ? 'Checking...' : 'Check Health'}
</Button>
</div>
<div class="flex flex-row gap-2 justify-end">
<Button unifiedSize="md" variant="default">Test scaling</Button>
<div class="flex text-xs flex-row gap-2 items-center">
<input class="!w-16" type="number" bind:value={test_input} />
workers
</div>
</div>
</div>
{#if healthCheckResult !== null}
<Alert
type={healthCheckResult.success ? 'success' : 'error'}
title={healthCheckResult.success ? 'Health check passed' : 'Health check failed'}
>
{#if healthCheckResult.success}
Kubernetes autoscaling is healthy
{:else}
{healthCheckResult.error}
{#if healthCheckResult.error?.includes('permissions') || healthCheckResult.error?.includes('role')}
<br /><small
>Please follow the setup guide above to configure proper RBAC permissions.</small
>
{/if}
{/if}
</Alert>
{/if}
</div>
</div>
{:else if config.integration.type === 'dryrun'}
<div class="p-4 border border-border-light rounded-md">
<span class="text-xs text-secondary">
In dry run mode, autoscaling will be simulated and events will be logged but no actual
scaling will be performed.
</span>
</div>
{/if}
</div>
{:else}
<ToggleButtonGroup selected={'script'} disabled class="mb-4 mt-2">
{#snippet children({ item })}
<ToggleButton value="dryrun" label="Dry run" {item} />
<ToggleButton value="script" label="Custom script" {item} />
<ToggleButton value="ecs" label="ECS (soon)" {item} />
<ToggleButton value="nomad" label="Nomad (soon)" {item} />
<ToggleButton value="kubernetes" label="Kubernetes" {item} />
{/snippet}
</ToggleButtonGroup>
<Label label="Script path on the 'admins' workspace" for="script_path">
<TextInput
inputProps={{
disabled: true,
id: 'script_path',
placeholder: 'e.g. f/scaling/scale_worker_group'
}}
/>
</Label>
{/if}
</Label>
<Section label="Advanced" small collapsable={true} class="flex flex-col gap-6">
<Label
label="Cooldown seconds after incremental scale-in/out"
disabled={config === undefined || disabled}
tooltip="Time to wait between incremental scaling operations"
>
{#if config !== undefined}
<input
type="number"
step="1"
min="30"
placeholder="300"
class="rounded-md border border-border-light text-xs text-primary font-normal bg-surface-input px-2 py-1 focus:border-border-selected hover:border-border-selected/50"
bind:value={config.cooldown_seconds}
{disabled}
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<Label
label="Cooldown seconds after full scale out"
disabled={config === undefined || disabled}
tooltip="Time to wait after scaling to maximum capacity"
>
{#if config !== undefined}
<input
type="number"
step="1"
min="30"
placeholder="1500"
class="rounded-md border border-border-light text-xs text-primary font-normal bg-surface-input px-2 py-1 focus:border-border-selected hover:border-border-selected/50"
bind:value={config.full_scale_cooldown_seconds}
{disabled}
/>
{:else}
<input
type="number"
disabled
class="rounded-md border border-border-light text-xs font-normal bg-surface-disabled border-transparent text-disabled px-2 py-1"
/>
{/if}
</Label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<Label label="Num jobs waiting to trigger an incremental scale-out">
{#if config !== undefined}
<input
type="number"
bind:value={config.inc_scale_num_jobs_waiting}
placeholder="1"
{disabled}
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<Label
label="Num jobs waiting to trigger a full scale out"
tooltip="Default: max_workers, full scale out = scale out to max workers"
for="full_scale_jobs_waiting"
>
{#if config !== undefined}
<input
type="number"
placeholder="max workers"
bind:value={config.full_scale_jobs_waiting}
id="full_scale_jobs_waiting"
{disabled}
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<div class="flex flex-row gap-4">
<Label
label="Min occupancy rate"
tooltip="Default: 25%, need to go below average of all of 15s, 5m and 30m occupancy rates"
for="occupancy_rate_min"
class="grow min-w-0"
>
<span class="text-xs text-secondary"
>{`Threshold (%) to go below to trigger a scale-in (decrease)`}</span
>
{#if config !== undefined}
<input
type="number"
step="1"
min="0"
max="100"
placeholder="25"
id="occupancy_rate_min"
bind:value={config.dec_scale_occupancy_rate}
{disabled}
/>
{:else}
<input type="number" step="0.01" disabled />
{/if}
</Label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<Label
label="Max occupancy rate"
tooltip="Default: 75%, need to exceed average of all of 15s, 5m and 30m occupancy rates"
for="occupancy_rate_max"
class="grow min-w-0"
>
<span class="text-xs text-secondary"
>{`Threshold (%) to exceed to trigger a scale-out (increase)`}</span
>
{#if config !== undefined}
<input
type="number"
step="1"
min="0"
max="100"
placeholder="75"
id="occupancy_rate_max"
bind:value={config.inc_scale_occupancy_rate}
{disabled}
/>
{:else}
<input type="number" step="0.01" disabled />
{/if}
</Label>
</div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<Label
label="Num workers to scale-in/out by when incremental"
tooltip="Default: (max_workers - min_workers) / 5"
>
{#if config !== undefined}
<input
type="number"
step="1"
min="1"
placeholder="(max_workers - min_workers) / 5"
bind:value={config.inc_num_workers}
{disabled}
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<Label label="Custom tags to autoscale on" for="multi_select_custom_tags">
{#snippet header()}
<Tooltip>
By default, autoscaling will apply to the tags the worker group is assigned to but you can
override this here.
</Tooltip>
{/snippet}
{#if config}
<MultiSelect
id="multi_select_custom_tags"
bind:value={
() => config?.custom_tags ?? [],
(v) => {
config && (config.custom_tags = v.length ? v : undefined)
}
}
items={safeSelectItems(worker_tags)}
placeholder="Tags"
{disabled}
/>
{/if}
</Label>
</Section>
</Section>