Files
windmill/frontend/src/lib/components/AutoscalingConfigEditor.svelte
Guilhem 484fdfa2bc fix(frontend): update workers page ui (#7264)
* Improve workers page

* Update group config drawer

* improve dirty workergroup config

* Make layout reactive

* fix section animation

* prevent opening dropdown while clicking New group config

* migrate workers page to svelte 5

* Open drawer upon adding a worker group

* nit critical alert table

* improve queue metrics drawer

* improve agent worker drawer

* harmonize copy icon

* improve agent worker doc

* improve layout

* Improve autoscaling event list

* Improve tags managment

* Remove default tags

* fix npm check

* Add info for agent workers

* improve agent worker jwt token creation

* Improve token display

* nit

* improve tag display

* create EE component

* nit

* harmonize tag overflow

* handle permission better

* improve env var presets

* handle permission for config

* nit alerts

* nit

* Improve custom tag creation in tag select

* optimistic tag addition

* nit

* nit

* fix typo

* improve workers table

* Group config tags

* show mismatch

* fix typo

* optimistic update when adding tag

* do not allow to create tag when picking a tag to watch in alerts
2025-12-04 22:09:25 +00:00

501 lines
15 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 Badge from './common/badge/Badge.svelte'
interface Props {
config: AutoscalingConfig | undefined
worker_tags: string[] | undefined
disabled: boolean
}
let { config = $bindable(), worker_tags, disabled }: 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 collapsed: boolean = $state(true)
</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."
>
{#snippet labelExtra()}
<Badge color="gray">Beta</Badge>
{/snippet}
{#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}
<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?.['tags'] ?? undefined,
(v) => {
if (!config || !config.integration) return
if (!v || v === '') {
delete config.integration['tags']
} else {
config.integration['tags'] = v
}
}
}
items={safeSelectItems(worker_tags)}
/>
<div class="flex flex-row gap-2 justify-end mt-4">
<Button variant="default" unifiedSize="md">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>