mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 16:02:28 +00:00
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
This commit is contained in:
@@ -8,8 +8,16 @@
|
||||
import { superadmin, devopsRole } from '$lib/stores'
|
||||
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
|
||||
import { CUSTOM_TAGS_SETTING } from '$lib/consts'
|
||||
import { base } from '$lib/base'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
|
||||
interface Props {
|
||||
variant?: 'popover' | 'drawer'
|
||||
}
|
||||
|
||||
let { variant = 'popover' }: Props = $props()
|
||||
|
||||
let newTag: string = $state('')
|
||||
let customTags: string[] | undefined = $state(undefined)
|
||||
@@ -58,16 +66,48 @@
|
||||
})
|
||||
|
||||
loadCustomTags()
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && newTag.trim() !== '' && tagEditor) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
saveCustomTag(newTag)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomTag(tag: string, restoreCustomTags: boolean = false) {
|
||||
try {
|
||||
await SettingService.setGlobal({
|
||||
key: CUSTOM_TAGS_SETTING,
|
||||
requestBody: { value: [...(customTags ?? []), tag.trim().replaceAll(' ', '_')] }
|
||||
})
|
||||
dispatch('refresh')
|
||||
loadCustomTags()
|
||||
sendUserToast(restoreCustomTags ? 'Tag restored' : 'Tag added')
|
||||
if (!restoreCustomTags) {
|
||||
newTag = ''
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not ${restoreCustomTags ? 'restore' : 'save'} custom tag: ${err}`, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-72 p-4 gap-2">
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
class:w-72={variant === 'popover'}
|
||||
class:p-4={variant === 'popover'}
|
||||
>
|
||||
{#if customTags == undefined}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<div class="flex flex-row flex-wrap gap-y-1 gap-x-2">
|
||||
{#each customTags as customTag}
|
||||
<div class="flex gap-0.5 items-center"
|
||||
><div class="text-2xs p-1 rounded border text-primary">{customTag}</div>
|
||||
<Badge color="blue">
|
||||
{customTag}
|
||||
|
||||
{#if tagEditor}
|
||||
<button
|
||||
class="z-10 rounded-full p-1 duration-200 hover:bg-gray-200"
|
||||
@@ -80,7 +120,14 @@
|
||||
})
|
||||
dispatch('refresh')
|
||||
loadCustomTags()
|
||||
sendUserToast('Tag removed')
|
||||
sendUserToast('Tag removed', false, [
|
||||
{
|
||||
label: 'Undo',
|
||||
callback: () => {
|
||||
saveCustomTag(customTag, true)
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
)}
|
||||
>
|
||||
@@ -88,12 +135,26 @@
|
||||
</button>
|
||||
{/if}
|
||||
<NoWorkerWithTagWarning tag={customTag} />
|
||||
</div>
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
<input type="text" bind:value={newTag} />
|
||||
|
||||
<div class={twMerge('w-full flex gap-2', variant === 'popover' ? 'flex-col ' : 'flex-row ')}>
|
||||
<TextInput bind:value={newTag} />
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
onClick={() => saveCustomTag(newTag)}
|
||||
disabled={newTag.trim() == '' || !tagEditor}
|
||||
wrapperClasses="min-w-24"
|
||||
>
|
||||
Add custom tag {#if !tagEditor}
|
||||
<span class="text-2xs text-primary">superadmin or devops only</span>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{#if extractedCustomTag}
|
||||
<div class="text-2xs text-primary p-2 bg-surface-secondary rounded border">
|
||||
<div class="text-2xs text-primary p-2 bg-surface-secondary rounded">
|
||||
<div class="font-medium mb-1">Workspace specific tag</div>
|
||||
<div>
|
||||
<b>Tag:</b>
|
||||
@@ -140,55 +201,40 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
on:click={async () => {
|
||||
await SettingService.setGlobal({
|
||||
key: CUSTOM_TAGS_SETTING,
|
||||
requestBody: {
|
||||
value: [...(customTags ?? []), newTag.trim().replaceAll(' ', '_')]
|
||||
}
|
||||
})
|
||||
dispatch('refresh')
|
||||
loadCustomTags()
|
||||
sendUserToast('Tag added')
|
||||
}}
|
||||
disabled={newTag.trim() == '' || !tagEditor}
|
||||
>
|
||||
Add {#if !tagEditor}
|
||||
<span class="text-2xs text-primary">superadmin or devops only</span>
|
||||
<span class="text-2xs text-secondary leading-relaxed">
|
||||
{#if variant !== 'drawer'}
|
||||
Configure <a
|
||||
href="https://www.windmill.dev/docs/core_concepts/worker_groups"
|
||||
target="_blank"
|
||||
class="inline-flex gap-1 items-baseline"
|
||||
>worker groups <ExternalLink size={12} />
|
||||
</a>
|
||||
to listen to tags.
|
||||
<br />
|
||||
{/if}
|
||||
</Button>
|
||||
<span class="text-sm text-primary"
|
||||
>Configure <a href="{base}/workers" target="_blank" class="inline-flex gap-1 items-baseline"
|
||||
>worker groups <ExternalLink size={12} /></a
|
||||
> to listen to tags</span
|
||||
>
|
||||
<span class="text-2xs text-primary"
|
||||
>For tags specific to some workspaces, use <pre class="inline">tag(workspace1+workspace2)</pre
|
||||
></span
|
||||
>
|
||||
<span class="text-2xs text-primary"
|
||||
>To exclude 'workspace1' and 'workspace2' from a tag, use <pre class="inline"
|
||||
>tag(^workspace1^workspace2)</pre
|
||||
></span
|
||||
>
|
||||
<span class="text-2xs text-primary"
|
||||
>For <a
|
||||
|
||||
For tags specific to some workspaces, use
|
||||
<pre class="inline text-emphasis">tag(workspace1+workspace2)</pre>
|
||||
<br />{#if variant !== 'drawer'}<br />{/if}
|
||||
To exclude 'workspace1' and 'workspace2' from a tag, use
|
||||
<pre class="inline text-emphasis">tag(^workspace1^workspace2)</pre>
|
||||
<br />{#if variant !== 'drawer'}<br />{/if}
|
||||
For
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/worker_groups#dynamic-tag"
|
||||
target="_blank">dynamic tags</a
|
||||
target="_blank">dynamic tags <ExternalLink size={12} class="inline-block" /></a
|
||||
>
|
||||
based on the workspace, use <pre class="inline">$workspace</pre>, e.g:
|
||||
<pre class="inline">tag-$workspace</pre></span
|
||||
>
|
||||
<span class="text-2xs text-primary"
|
||||
>For <a
|
||||
based on the workspace, use <pre class="inline text-emphasis">$workspace</pre>, e.g:
|
||||
<pre class="inline text-emphasis">tag-$workspace</pre><br />
|
||||
{#if variant !== 'drawer'}<br />{/if}
|
||||
|
||||
For
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/worker_groups#dynamic-tag"
|
||||
target="_blank">dynamic tags</a
|
||||
target="_blank">dynamic tags <ExternalLink size={12} class="inline-block" /></a
|
||||
>
|
||||
based on args input, use <pre class="inline">$args[a.b.c]</pre> where
|
||||
<pre class="inline">a.b.c</pre> is the path to the value in the args object</span
|
||||
>
|
||||
based on args input, use <pre class="inline text-emphasis">$args[a.b.c]</pre> where
|
||||
<pre class="inline">a.b.c</pre> is the path to the value in the args object.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,26 +5,35 @@
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { Button } from './common'
|
||||
import { Alert, Button } from './common'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
import { createEventDispatcher } from '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 }: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
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
|
||||
|
||||
@@ -43,366 +52,258 @@
|
||||
healthCheckLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
let collapsed: boolean = $state(true)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row gap-16 pt-2">
|
||||
<div class="space-y-4 flex flex-col gap-1 max-w-xs text-sm">
|
||||
<h5>Rules</h5>
|
||||
<Toggle
|
||||
checked={config?.enabled ?? false}
|
||||
options={{ right: 'Enabled' }}
|
||||
on:change={(e) => {
|
||||
dispatch('dirty')
|
||||
if (e.detail) {
|
||||
if (!config) {
|
||||
config = {
|
||||
enabled: true,
|
||||
min_workers: 3,
|
||||
max_workers: 10,
|
||||
integration: { type: 'dryrun' }
|
||||
<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.enabled = true
|
||||
config = {
|
||||
...(config ?? {
|
||||
min_workers: 3,
|
||||
max_workers: 10,
|
||||
integration: { type: 'dryrun' }
|
||||
}),
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
config = {
|
||||
...(config ?? {
|
||||
min_workers: 3,
|
||||
max_workers: 10,
|
||||
integration: { type: 'dryrun' }
|
||||
}),
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Min # of Workers
|
||||
}}
|
||||
/>
|
||||
</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 oninput={() => dispatch('dirty')} type="number" bind:value={config.min_workers} />
|
||||
{#if config.min_workers !== undefined && config.min_workers != undefined && config.min_workers > config.max_workers}
|
||||
<div class="text-red-600 text-xs whitespace-nowrap"
|
||||
>Minimum cannot be {'>'} to Maximum</div
|
||||
>
|
||||
<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 />
|
||||
<input type="number" {disabled} placeholder="3" />
|
||||
{/if}
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Max # of Workers
|
||||
</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 oninput={() => dispatch('dirty')} type="number" bind:value={config.max_workers} />
|
||||
<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 />
|
||||
<input type="number" disabled placeholder="10" />
|
||||
{/if}
|
||||
</label>
|
||||
<div class="p-2">
|
||||
<Section label="Advanced" small collapsable={true}>
|
||||
<div class="flex flex-col gap-2 text-2xs">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Cooldown seconds after an incremental scale-in/out
|
||||
{#if config !== undefined}
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="number"
|
||||
step="1"
|
||||
min="30"
|
||||
placeholder="300"
|
||||
bind:value={config.cooldown_seconds}
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Cooldown seconds after a full scale out
|
||||
{#if config !== undefined}
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="number"
|
||||
step="1"
|
||||
min="30"
|
||||
placeholder="1500"
|
||||
bind:value={config.full_scale_cooldown_seconds}
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Num jobs waiting to trigger an incremental scale-out
|
||||
{#if config !== undefined}
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="number"
|
||||
bind:value={config.inc_scale_num_jobs_waiting}
|
||||
placeholder="1"
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Num jobs waiting to trigger a full scale out <Tooltip
|
||||
>Default: max_workers, full scale out = scale out to max workers</Tooltip
|
||||
>
|
||||
{#if config !== undefined}
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="number"
|
||||
placeholder="max workers"
|
||||
bind:value={config.full_scale_jobs_waiting}
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Occupancy rate % threshold to go below to trigger a scale-in (decrease) <Tooltip
|
||||
>Default: 25%, need to go below average of all of 15s, 5m and 30m occupancy rates</Tooltip
|
||||
>
|
||||
{#if config !== undefined}
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="25"
|
||||
bind:value={config.dec_scale_occupancy_rate}
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" step="0.01" disabled />
|
||||
{/if}
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Occupancy rate threshold to exceed to trigger an incremental scale-out (increase) <Tooltip
|
||||
>Default: 75%, need to exceed average of all of 15s, 5m and 30m occupancy rates</Tooltip
|
||||
>
|
||||
{#if config !== undefined}
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="75"
|
||||
bind:value={config.inc_scale_occupancy_rate}
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" step="0.01" disabled />
|
||||
{/if}
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
Num workers to scale-in/out by when incremental <Tooltip
|
||||
>Default: (max_workers - min_workers) / 5</Tooltip
|
||||
>
|
||||
{#if config !== undefined}
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
placeholder="(max_workers - min_workers) / 5"
|
||||
bind:value={config.inc_num_workers}
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<Label label="Custom tags to autoscale on">
|
||||
{#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}
|
||||
{#if config.custom_tags}
|
||||
<MultiSelect
|
||||
bind:value={
|
||||
() => config?.custom_tags ?? [],
|
||||
{#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) => {
|
||||
config && (config.custom_tags = v.length ? v : undefined)
|
||||
dispatch('dirty')
|
||||
if (!config || !config.integration) return
|
||||
|
||||
if (!v || v === '') {
|
||||
delete config.integration['path']
|
||||
} else {
|
||||
config.integration['path'] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
items={safeSelectItems(worker_tags)}
|
||||
placeholder="Tags"
|
||||
clearable
|
||||
{disabled}
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="contained"
|
||||
on:click={() => {
|
||||
if (config) {
|
||||
config.custom_tags = []
|
||||
dispatch('dirty')
|
||||
}
|
||||
}}>Add custom tags</Button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</Label>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 max-w-xs text-sm">
|
||||
<h5>Integration</h5>
|
||||
{#if config?.integration}
|
||||
<ToggleButtonGroup
|
||||
on:selected={(e) => dispatch('dirty')}
|
||||
bind:selected={config.integration.type}
|
||||
class="mb-4 mt-2"
|
||||
>
|
||||
{#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'}
|
||||
<label>
|
||||
Script path on the 'admins' workspace
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="text"
|
||||
bind:value={config.integration.path}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Custom tag for executing script (optional)
|
||||
{#if config.integration.tag}
|
||||
<input
|
||||
oninput={() => dispatch('dirty')}
|
||||
type="text"
|
||||
bind:value={config.integration.tag}
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="contained"
|
||||
on:click={() => {
|
||||
if (config?.integration?.type === 'script') {
|
||||
config.integration.tag = 'bash'
|
||||
dispatch('dirty')
|
||||
}
|
||||
}}>Set tag</Button
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<div class="flex mt-6 gap-2">
|
||||
<Button
|
||||
variant="accent"
|
||||
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</Button
|
||||
>
|
||||
<Button
|
||||
variant="accent"
|
||||
target="_blank"
|
||||
href={`/runs/${config.integration.path}?workspace=admins`}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
>
|
||||
See jobs
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 mt-4">
|
||||
<Button color="light" size="xs" variant="contained">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>
|
||||
{/if}
|
||||
|
||||
{#if config.integration.type === 'kubernetes'}
|
||||
<div class="text-sm text-secondary mb-3">
|
||||
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-3 mt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="accent"
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
href="https://windmill.dev/docs/core_concepts/autoscaling#kubernetes"
|
||||
target="_blank"
|
||||
>
|
||||
Setup Guide (Roles & Bindings)
|
||||
</Button>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="contained"
|
||||
onclick={checkKubernetesHealth}
|
||||
disabled={healthCheckLoading}
|
||||
>
|
||||
{healthCheckLoading ? 'Checking...' : 'Check Health'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if healthCheckResult !== null}
|
||||
<div
|
||||
class="p-2 rounded-md text-sm {healthCheckResult.success
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400'}"
|
||||
>
|
||||
{#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 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>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button color="light" size="xs" variant="contained">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}
|
||||
{: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 })}
|
||||
@@ -414,10 +315,186 @@
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<label>
|
||||
Script path on the 'admins' workspace
|
||||
<input type="text" disabled />
|
||||
</label>
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { ConfigService, type AutoscalingEvent } from '$lib/gen'
|
||||
import { LoaderIcon, RefreshCw } from 'lucide-svelte'
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
import { Button, Skeleton } from './common'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import TimeAgo from './TimeAgo.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { untrack } from 'svelte'
|
||||
import DataTable from './table/DataTable.svelte'
|
||||
import Head from './table/Head.svelte'
|
||||
import Cell from './table/Cell.svelte'
|
||||
|
||||
interface Props {
|
||||
worker_group: string
|
||||
@@ -37,55 +40,74 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h6
|
||||
class={!$enterpriseLicense || (events != undefined && events.length == 0)
|
||||
? 'text-xs text-emphasis font-semibold'
|
||||
: ''}
|
||||
>Autoscaling events {#if $enterpriseLicense}<span class="text-xs text-primary">(5 last)</span>
|
||||
<span class="inline-flex ml-6">
|
||||
<Button
|
||||
startIcon={{
|
||||
icon: loading ? LoaderIcon : RefreshCw,
|
||||
classes: twMerge(
|
||||
loading ? 'animate-spin text-blue-800' : '',
|
||||
'transition-all text-gray-500 dark:text-white'
|
||||
)
|
||||
}}
|
||||
color="light"
|
||||
size="xs2"
|
||||
btnClasses={twMerge(loading ? ' bg-blue-100 dark:bg-blue-400' : '', 'transition-all')}
|
||||
on:click={() => loadEvents()}
|
||||
iconOnly
|
||||
/>
|
||||
</span>{/if}
|
||||
</h6>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div class="flex flex-row items-baseline gap-2">
|
||||
<h3 class="text-xs font-semibold text-emphasis">Autoscaling events</h3>
|
||||
{#if $enterpriseLicense && events && events.length > 0}
|
||||
<span class="text-2xs text-secondary">Showing last {Math.min(limit, events.length)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $enterpriseLicense}
|
||||
<Button
|
||||
startIcon={{
|
||||
icon: RefreshCw,
|
||||
classes: twMerge(loading ? 'animate-spin' : '')
|
||||
}}
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
on:click={() => loadEvents()}
|
||||
iconOnly
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="text-xs pt-1 text-secondary">Autoscaling is an EE feature</div>
|
||||
<div class="text-xs font-normal text-secondary">Autoscaling is an EE feature</div>
|
||||
{:else if loading}
|
||||
<Skeleton layout={[[12], 1]} />
|
||||
{:else if events}
|
||||
{#if events.length == 0}
|
||||
<div class="text-xs pt-2 text-primary"
|
||||
>No events, is autoscaling set in the worker group config?</div
|
||||
>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2 text-xs text-primary pt-4">
|
||||
{#each events as event}
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="text-primary">{event.event_type} to {event.desired_workers}</div>
|
||||
<div class="text-secondary">{event.reason}</div>
|
||||
<div class="text-primary"><TimeAgo date={event.applied_at ?? ''} /></div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="text-xs font-normal text-secondary">
|
||||
No events. Is autoscaling configured in the worker group config?
|
||||
</div>
|
||||
{:else}
|
||||
<DataTable size="sm" noBorder={false} rounded={true}>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Event type</Cell>
|
||||
<Cell head>Desired workers</Cell>
|
||||
<Cell head>Reason</Cell>
|
||||
<Cell head last>Time</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody>
|
||||
{#each events as event}
|
||||
<tr class="border-b last:border-b-0">
|
||||
<Cell first class="text-xs font-normal text-primary">{event.event_type ?? 'N/A'}</Cell
|
||||
>
|
||||
<Cell class="text-xs font-normal text-primary">{event.desired_workers}</Cell>
|
||||
<Cell class="text-xs font-normal text-secondary">{event.reason ?? 'N/A'}</Cell>
|
||||
<Cell last class="text-xs font-normal text-secondary">
|
||||
<TimeAgo date={event.applied_at ?? ''} />
|
||||
</Cell>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
|
||||
{#if events.length >= limit && limit < 100}
|
||||
<div class="flex">
|
||||
<Button variant="subtle" unifiedSize="sm" on:click={() => (limit = limit + 25)}>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="mt-4 flex">
|
||||
<Button color="light" size="xs2" on:click={() => (limit = limit + 25)}>Show more</Button>
|
||||
</div>
|
||||
|
||||
{#if limit > 50}
|
||||
<div class="mt-4 flex text-xs text-primary">
|
||||
Note that autoscaling events are only stored for the last 30 days.
|
||||
<div class="text-2xs font-normal text-hint">
|
||||
Note: Autoscaling events are only stored for the last 30 days.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
class?: string
|
||||
children?: import('svelte').Snippet<[{ width: number }]>
|
||||
}
|
||||
|
||||
let { class: clazz = '', children }: Props = $props()
|
||||
|
||||
let width = $state(0)
|
||||
</script>
|
||||
|
||||
<div class="pb-8">
|
||||
<div class={twMerge('max-w-7xl mx-auto px-4 sm:px-6 md:px-8', $$restProps.class)}>
|
||||
<slot />
|
||||
</div>
|
||||
<div class={twMerge('max-w-7xl mx-auto px-4 sm:px-6 md:px-8', clazz)} bind:clientWidth={width}
|
||||
>{@render children?.({ width })}</div
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class={twMerge('flex', $$props.class)}>
|
||||
<Button
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
btnClasses="text-primary {small ? 'text-xs' : ''} "
|
||||
on:click={() => (open = !open)}
|
||||
endIcon={{ icon: ChevronDown, classes: open ? 'transform rotate-180' : '' }}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from './common'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
|
||||
import DefaultTagsInner from './DefaultTagsInner.svelte'
|
||||
|
||||
interface Props {
|
||||
defaultTagPerWorkspace?: boolean | undefined
|
||||
defaultTagWorkspaces?: string[]
|
||||
}
|
||||
|
||||
let {
|
||||
defaultTagPerWorkspace = $bindable(undefined),
|
||||
defaultTagWorkspaces = $bindable([])
|
||||
}: Props = $props()
|
||||
|
||||
let placement: 'bottom-end' | 'top-end' = 'bottom-end'
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
floatingConfig={{ strategy: 'absolute', placement: placement }}
|
||||
contentClasses="p-4 max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="default" unifiedSize="md" nonCaptureEvent={true}>
|
||||
<div class="flex flex-row gap-1 items-center"
|
||||
><Pen size={14} /> Default tags <Tooltip light
|
||||
>Scripts and steps that have not been specifically assigned tags will use a default tag
|
||||
that can be customized here</Tooltip
|
||||
></div
|
||||
>
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<DefaultTagsInner bind:defaultTagPerWorkspace bind:defaultTagWorkspaces />
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -1,21 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Button } from './common'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-svelte'
|
||||
import { ExternalLink, Loader2, Save } from 'lucide-svelte'
|
||||
import { SettingService, WorkerService, WorkspaceService } from '$lib/gen'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { enterpriseLicense, superadmin } from '$lib/stores'
|
||||
import { DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING } from '$lib/consts'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import MultiSelect from './select/MultiSelect.svelte'
|
||||
import { safeSelectItems } from './select/utils.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import Section from './Section.svelte'
|
||||
interface Props {
|
||||
defaultTagPerWorkspace?: boolean | undefined
|
||||
defaultTagWorkspaces?: string[]
|
||||
}
|
||||
|
||||
let defaultTags: string[] | undefined = undefined
|
||||
export let defaultTagPerWorkspace: boolean | undefined = undefined
|
||||
export let defaultTagWorkspaces: string[] = []
|
||||
let limitToWorkspaces = false
|
||||
let {
|
||||
defaultTagPerWorkspace = $bindable(undefined),
|
||||
defaultTagWorkspaces = $bindable([])
|
||||
}: Props = $props()
|
||||
|
||||
let workspaces: string[] = []
|
||||
let defaultTags = $state<string[] | undefined>(undefined)
|
||||
let limitToWorkspaces = $state(false)
|
||||
|
||||
// Change detection
|
||||
let originalDefaultTagPerWorkspace = $state<boolean | undefined>(defaultTagPerWorkspace)
|
||||
let originalDefaultTagWorkspaces = $state<string[]>(defaultTagWorkspaces)
|
||||
|
||||
// Detect changes
|
||||
let hasChanges = $derived(
|
||||
originalDefaultTagPerWorkspace !== defaultTagPerWorkspace ||
|
||||
JSON.stringify($state.snapshot(originalDefaultTagWorkspaces)?.sort() || []) !==
|
||||
JSON.stringify($state.snapshot(defaultTagWorkspaces)?.sort() || [])
|
||||
)
|
||||
|
||||
let workspaces: string[] = $state([])
|
||||
async function loadWorkspaces() {
|
||||
workspaces = (await WorkspaceService.listWorkspacesAsSuperAdmin()).map((m) => m.id)
|
||||
}
|
||||
@@ -33,85 +52,122 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await SettingService.setGlobal({
|
||||
key: DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
requestBody: {
|
||||
value: defaultTagPerWorkspace
|
||||
}
|
||||
})
|
||||
await SettingService.setGlobal({
|
||||
key: DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
requestBody: {
|
||||
value:
|
||||
limitToWorkspaces && defaultTagWorkspaces && defaultTagWorkspaces.length > 0
|
||||
? defaultTagWorkspaces
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Update original state after save
|
||||
originalDefaultTagPerWorkspace = defaultTagPerWorkspace
|
||||
originalDefaultTagWorkspaces = [...(defaultTagWorkspaces || [])]
|
||||
|
||||
loadDefaultTags()
|
||||
sendUserToast('Saved')
|
||||
}
|
||||
|
||||
loadDefaultTags()
|
||||
loadWorkspaces()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-80 p-2 gap-2">
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap justify-end">
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
<Section label="Default tags">
|
||||
<div class="text-2xs text-secondary mb-2">
|
||||
Jobs that have not been specifically assigned custom tags will use a <a
|
||||
href="https://www.windmill.dev/docs/core_concepts/worker_groups#default-worker-group"
|
||||
target="_blank"
|
||||
class="gap-1 items-baseline">default tags <ExternalLink size={12} class="inline-block" /></a
|
||||
> based on the language they are in or their kind.
|
||||
</div>
|
||||
|
||||
{#snippet action()}
|
||||
{#if !$enterpriseLicense}
|
||||
<span class="text-secondary text-xs">Read only</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
on:click={handleSave}
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={!hasChanges || !$enterpriseLicense || !$superadmin}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if defaultTagPerWorkspace == undefined || defaultTags == undefined}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-y-1">
|
||||
{#each defaultTags.sort() as tag (tag)}
|
||||
<div class="flex gap-2 items-center"
|
||||
><div class="p-1 text-xs px-2 rounded border text-primary w-32">{tag} </div><div
|
||||
class="flex gap-2 items-center w-92"
|
||||
>→
|
||||
<input
|
||||
class="text-xs w-full"
|
||||
disabled
|
||||
type="text"
|
||||
value={defaultTagPerWorkspace ? `${tag}-$workspace` : tag}
|
||||
/></div
|
||||
>
|
||||
</div>
|
||||
{:else if !$enterpriseLicense}
|
||||
<!-- Tag List -->
|
||||
<div class="flex gap-y-1 gap-x-2 flex-wrap">
|
||||
{#each $state.snapshot(defaultTags).sort() as tag (tag)}
|
||||
<Badge color="blue">{defaultTagPerWorkspace ? `${tag}-$workspace` : tag}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Settings -->
|
||||
<div class="py-4 flex flex-col gap-2">
|
||||
<Toggle
|
||||
bind:checked={defaultTagPerWorkspace}
|
||||
options={{ right: 'workspace specific default tags' }}
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Toggle
|
||||
bind:checked={defaultTagPerWorkspace}
|
||||
options={{
|
||||
right: 'make default tags workspace specific',
|
||||
rightTooltip:
|
||||
'When tags use $workspace, the final tag has $workspace replaced with the workspace id, allowing multi-vpc setup with more ease, without having to assign a specific tag each time.'
|
||||
}}
|
||||
class="w-fit"
|
||||
disabled={!$enterpriseLicense}
|
||||
/>
|
||||
</div>
|
||||
{#if defaultTagPerWorkspace}
|
||||
<Toggle bind:checked={limitToWorkspaces} options={{ right: 'only for some workspaces' }} />
|
||||
<Toggle
|
||||
bind:checked={limitToWorkspaces}
|
||||
options={{ right: 'only for some workspaces' }}
|
||||
class="w-fit"
|
||||
disabled={!$enterpriseLicense}
|
||||
/>
|
||||
{#if limitToWorkspaces}
|
||||
<MultiSelect
|
||||
disablePortal
|
||||
disabled={!$enterpriseLicense}
|
||||
items={safeSelectItems(workspaces)}
|
||||
bind:value={defaultTagWorkspaces}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
on:click={async () => {
|
||||
await SettingService.setGlobal({
|
||||
key: DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
requestBody: {
|
||||
value: defaultTagPerWorkspace
|
||||
}
|
||||
})
|
||||
await SettingService.setGlobal({
|
||||
key: DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
requestBody: {
|
||||
value:
|
||||
limitToWorkspaces && defaultTagWorkspaces && defaultTagWorkspaces.length > 0
|
||||
? defaultTagWorkspaces
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
loadDefaultTags()
|
||||
sendUserToast('Saved')
|
||||
}}
|
||||
disabled={!$enterpriseLicense || !$superadmin}
|
||||
>
|
||||
Save {#if !$superadmin}
|
||||
<span class="text-2xs text-primary">superadmin only</span>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<span class="text-2xs text-primary"
|
||||
>When tags use <pre class="inline">$workspace</pre>, the final tag has
|
||||
<pre class="inline">$workspace</pre> replaced with the workspace id, allowing multi-vpc setup with
|
||||
more ease, without having to assign a specific tag each time.</span
|
||||
>
|
||||
<div class="flex gap-2 items-center mb-1">
|
||||
<div class="w-36 text-2xs font-semibold text-secondary">Job language or kind</div>
|
||||
<div class="w-6 text-2xs font-semibold text-secondary"></div>
|
||||
<div class="flex-1 text-2xs font-semibold text-secondary">Default tag</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag List -->
|
||||
<div class="flex gap-y-1 flex-col">
|
||||
{#each $state.snapshot(defaultTags).sort() as tag (tag)}
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="w-36">
|
||||
<Badge color="transparent">{tag}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="w-6 flex justify-center text-secondary">→</div>
|
||||
<div class="flex-1">
|
||||
<Badge color="blue">{defaultTagPerWorkspace ? `${tag}-$workspace` : tag}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Tooltip } from './meltComponents'
|
||||
|
||||
interface Props {
|
||||
aiId?: string
|
||||
@@ -53,6 +54,13 @@
|
||||
{item.displayName}
|
||||
</p>
|
||||
{@render item.extra?.()}
|
||||
{#if item.tooltip}
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
{item.tooltip}
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Building } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Tooltip } from './meltComponents'
|
||||
|
||||
interface Props {
|
||||
class?: string
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { class: className = '', children = undefined }: Props = $props()
|
||||
</script>
|
||||
|
||||
<Tooltip>
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap px-1',
|
||||
className
|
||||
)}
|
||||
title="Enterprise Edition only feature"
|
||||
aria-label="Enterprise Edition only feature"
|
||||
role="tooltip"
|
||||
>
|
||||
EE only <Building size={16} />
|
||||
</div>
|
||||
{#snippet text()}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
Enterprise Edition only feature
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Tab, Tabs, Button } from './common'
|
||||
import { copyToClipboard } from '../utils'
|
||||
|
||||
import { ArrowDown, Clipboard } from 'lucide-svelte'
|
||||
import { ArrowDown, Copy } from 'lucide-svelte'
|
||||
import YAML from 'yaml'
|
||||
import { yaml } from 'svelte-highlight/languages'
|
||||
import HighlightTheme from './HighlightTheme.svelte'
|
||||
@@ -80,7 +80,7 @@
|
||||
color="light"
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: Clipboard }}
|
||||
startIcon={{ icon: Copy }}
|
||||
btnClasses="absolute top-2 right-2 w-min z-20"
|
||||
iconOnly
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { AgentWorkersService, type ListBlacklistedAgentTokensResponse } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Copy, Trash2, RefreshCw } from 'lucide-svelte'
|
||||
import { ExternalLink, RefreshCw, Trash } from 'lucide-svelte'
|
||||
import { Alert, Button, Tab, Tabs } from './common'
|
||||
import Section from './Section.svelte'
|
||||
import TagsToListenTo from './TagsToListenTo.svelte'
|
||||
import { enterpriseLicense, superadmin } from '$lib/stores'
|
||||
import CollapseLink from './CollapseLink.svelte'
|
||||
import Label from './Label.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import CopyableCodeBlock from './details/CopyableCodeBlock.svelte'
|
||||
import { shell, json } from 'svelte-highlight/languages'
|
||||
import TokenDisplay from './settings/TokenDisplay.svelte'
|
||||
import Description from './Description.svelte'
|
||||
import { defaultTags, nativeTags } from './worker_group'
|
||||
|
||||
type Props = {
|
||||
customTags: string[] | undefined
|
||||
@@ -16,11 +22,14 @@
|
||||
let workerGroup: string = $state('agent')
|
||||
let token: string = $state('')
|
||||
let blacklistToken: string = $state('')
|
||||
let blacklistTokenError: string = $state('')
|
||||
let selectedTab: 'create' | 'blacklist' = $state('create')
|
||||
let blacklistedTokens: ListBlacklistedAgentTokensResponse | undefined = $state(undefined)
|
||||
let isLoadingBlacklist: boolean = $state(false)
|
||||
let isGeneratingToken: boolean = $state(false)
|
||||
|
||||
async function refreshToken(workerGroup: string, selectedTags: string[]) {
|
||||
async function generateToken() {
|
||||
isGeneratingToken = true
|
||||
try {
|
||||
const newToken = await AgentWorkersService.createAgentToken({
|
||||
requestBody: {
|
||||
@@ -31,8 +40,11 @@
|
||||
})
|
||||
|
||||
token = newToken
|
||||
sendUserToast('JWT token generated successfully')
|
||||
} catch (error) {
|
||||
sendUserToast('Error creating agent token: ' + error.toString(), true)
|
||||
} finally {
|
||||
isGeneratingToken = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +62,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function validateBlacklistToken(token: string) {
|
||||
if (!blacklistToken.trim()) {
|
||||
blacklistTokenError = 'Token cannot be empty'
|
||||
} else if (token && !token.startsWith('jwt_agent_')) {
|
||||
blacklistTokenError = 'Token must start with jwt_agent_'
|
||||
} else {
|
||||
blacklistTokenError = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function addToBlacklist() {
|
||||
if (!blacklistToken.trim()) {
|
||||
sendUserToast('Please enter a token to blacklist', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (blacklistTokenError) {
|
||||
sendUserToast('Invalid token format', true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await AgentWorkersService.blacklistAgentToken({
|
||||
requestBody: {
|
||||
@@ -65,6 +92,7 @@
|
||||
|
||||
sendUserToast('Token successfully added to blacklist')
|
||||
blacklistToken = ''
|
||||
blacklistTokenError = ''
|
||||
// Refresh the blacklist after adding a new token
|
||||
await loadBlacklistedTokens()
|
||||
} catch (error) {
|
||||
@@ -95,12 +123,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (selectedTags.length > 0 && $superadmin) {
|
||||
refreshToken(workerGroup, selectedTags)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (selectedTab === 'blacklist' && $enterpriseLicense && $superadmin) {
|
||||
loadBlacklistedTokens()
|
||||
@@ -112,197 +134,242 @@
|
||||
<Tab value="create" label="Create" />
|
||||
<Tab value="blacklist" label="Blacklist" />
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-y-4 pt-2">
|
||||
<div class="flex flex-col gap-y-6 pt-2">
|
||||
{#if selectedTab === 'create'}
|
||||
<Alert type="info" title="HTTP agent workers "
|
||||
>Use HTTP agent workers only when the workers need to be deployed remotely OR with only
|
||||
HTTP connectivity OR in untrusted environments. HTTP agent workers have more latency and
|
||||
less capabilities than normal workers.</Alert
|
||||
<Description
|
||||
><a href="https://www.windmill.dev/docs/core_concepts/agent_workers" target="_blank"
|
||||
>Agent workers <ExternalLink size={12} class="inline-block" /></a
|
||||
> can be used to run jobs with remote workers with unreliable connectivity, workers behind
|
||||
firewalls (HTTP-only), untrusted environments (no database access), or large deployments (thousands
|
||||
of workers). They have more latency than normal workers. Follow the steps below to create an
|
||||
agent worker.</Description
|
||||
>
|
||||
<div class="flex flex-col gap-y-4 mt-4">
|
||||
<Section
|
||||
|
||||
<Section
|
||||
label="1. Generate an agent worker token"
|
||||
class="flex flex-col gap-y-6"
|
||||
description="Generate a JWT token to authenticate the agent worker."
|
||||
>
|
||||
<Label
|
||||
label="Worker group"
|
||||
tooltip="This is only used to give a name prefix to the agent worker and to group workers in the workers page, no worker group config is passed to an agent worker."
|
||||
>
|
||||
<input class="max-w-md" type="text" bind:value={workerGroup} />
|
||||
</Section>
|
||||
<Section label="Tags to listen to" eeOnly>
|
||||
</Label>
|
||||
<Label
|
||||
label="Tags to listen to"
|
||||
eeOnly
|
||||
tooltip="Tags determine which jobs this worker can execute. They are encoded in the JWT token and cannot be changed by the worker. You can use dynamic tags like 'tag-$args[argName]' or 'tag-$workspace' to target different workers based on job arguments or workspace."
|
||||
>
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="text-sm text-secondary mb-2 max-w-md">
|
||||
<div class="text-xs text-secondary mb-2 max-w-md">
|
||||
Agent workers are only available in the enterprise edition. For evaluation purposes,
|
||||
you can only use the tag `agent_test` tag and it is limited to 100 jobs.
|
||||
you can only use the `agent_test` tag and it is limited to 100 jobs.
|
||||
</div>
|
||||
{/if}
|
||||
<TagsToListenTo
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:worker_tags={selectedTags}
|
||||
{customTags}
|
||||
/>
|
||||
</Section>
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<TagsToListenTo
|
||||
class="grow min-w-0"
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:worker_tags={selectedTags}
|
||||
{customTags}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
onclick={() => {
|
||||
selectedTags = [...defaultTags, ...nativeTags, ...(customTags ?? [])]
|
||||
}}>Add all tags</Button
|
||||
>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Section label="Generated JWT token">
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="text-sm text-secondary mb-2 max-w-md">
|
||||
Agent workers are only available in the enterprise edition. For evaluation purposes,
|
||||
you can only use the tag `agent_test` tag and it is limited to 100 jobs.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="relative max-w-md group">
|
||||
<input
|
||||
onclick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (token) {
|
||||
navigator.clipboard.writeText(token)
|
||||
sendUserToast('Copied to clipboard')
|
||||
}
|
||||
}}
|
||||
placeholder="Select tags to generate a JWT token"
|
||||
type="text"
|
||||
disabled
|
||||
value={token}
|
||||
class="w-full pr-10 pl-3 py-2 text-sm text-gray-600 bg-gray-50 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-100 transition truncatere"
|
||||
{#if !token}
|
||||
<div class="mb-4">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
disabled={selectedTags.length === 0 || !$superadmin || isGeneratingToken}
|
||||
onclick={generateToken}
|
||||
loading={isGeneratingToken}
|
||||
>
|
||||
{isGeneratingToken ? 'Generating...' : 'Generate token'}
|
||||
</Button>
|
||||
{#if selectedTags.length === 0}
|
||||
<div class="text-xs text-secondary mt-2">
|
||||
Please select at least one tag to generate a token.
|
||||
</div>
|
||||
{:else if !$superadmin}
|
||||
<div class="text-xs text-secondary mt-2">
|
||||
Only superadmins can generate JWT tokens.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<TokenDisplay
|
||||
{token}
|
||||
title="JWT Token Generated Successfully"
|
||||
onClose={() => {
|
||||
token = ''
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
<Section label="2. Create an agent worker" class="flex flex-col gap-y-2">
|
||||
<p class="text-xs text-primary">
|
||||
Set these environment variables for your agent worker.
|
||||
</p>
|
||||
<CopyableCodeBlock
|
||||
code={`MODE=agent
|
||||
AGENT_TOKEN=<token>
|
||||
BASE_INTERNAL_URL=<base url>
|
||||
`}
|
||||
language={shell}
|
||||
/>
|
||||
<p class="text-2xs text-secondary">
|
||||
BASE_INTERNAL_URL: Base URL without trailing slash (e.g.,
|
||||
<code>http://windmill.example.com</code>). Can be same as BASE_URL or private network
|
||||
URL. <code>INIT_SCRIPT</code> can be passed as env variable if needed.
|
||||
</p>
|
||||
<Alert type="warning" size="sm" title="Agent Worker Limitations">
|
||||
Ensure at least one normal worker is running and listening to the tags
|
||||
<code>flow</code> and <code>dependency</code>
|
||||
(or <code>flow-<workspace></code> and
|
||||
<code>dependency-<workspace></code>
|
||||
if using workspace-specific default tags), because agent workers
|
||||
<strong>cannot run dependency jobs</strong>
|
||||
nor execute the
|
||||
<strong>flow state machine</strong>. They can, however, run subjobs within flows.
|
||||
</Alert>
|
||||
|
||||
<div class="mt-2"></div>
|
||||
<Section small collapsable label="Automate JWT token generation">
|
||||
<div class="text-xs text-primary">
|
||||
<p class="mb-2">
|
||||
Generate tokens programmatically using this endpoint with superadmin bearer token:
|
||||
</p>
|
||||
<code class="block mt-1 mb-2">POST /api/agent_workers/create_agent_token</code>
|
||||
<p class="mb-2">Request body:</p>
|
||||
<CopyableCodeBlock
|
||||
code={`{
|
||||
"worker_group": "agent",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"exp": 1717334400
|
||||
}`}
|
||||
language={json}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 group-hover:text-blue-600 hover:scale-105 transition"
|
||||
aria-label="Copy token to clipboard"
|
||||
onclick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (token) {
|
||||
navigator.clipboard.writeText(token)
|
||||
sendUserToast('Copied to clipboard')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 text-sm mt-3 leading-relaxed">
|
||||
Set the following environment variables:
|
||||
<ul class="list-disc list-inside mt-1">
|
||||
<li><code>MODE=agent</code></li>
|
||||
<li><code>AGENT_TOKEN=<token></code></li>
|
||||
<li><code>BASE_INTERNAL_URL=<base url></code></li>
|
||||
</ul>
|
||||
<p class="text-sm leading-relaxed">
|
||||
to a worker to have it act as an HTTP agent worker.
|
||||
<code>INIT_SCRIPT</code>, if needed, must be passed as an env variable.
|
||||
<p class="mt-2">
|
||||
<code>exp</code> is Unix timestamp. Response contains the JWT token.
|
||||
</p>
|
||||
<Alert type="warning" size="sm" title="Agent Worker Limitations">
|
||||
Ensure at least one normal worker is running and listening to the tags
|
||||
<code>flow</code> and <code>dependency</code>
|
||||
(or <code>flow-<workspace></code> and
|
||||
<code>dependency-<workspace></code>
|
||||
if using workspace-specific default tags), because agent workers
|
||||
<strong>cannot run dependency jobs</strong>
|
||||
nor execute the
|
||||
<strong>flow state machine</strong>. They can, however, run subjobs within flows.
|
||||
</Alert>
|
||||
<CollapseLink text="Automate JWT token generation" small>
|
||||
<div class="text-xs mt-2">
|
||||
Use the following API endpoint with a superadmin bearer token:
|
||||
<code class="block mt-1 mb-2">POST /api/agent_workers/create_agent_token</code>
|
||||
<pre class=" p-2 rounded-lg text-xs overflow-auto">
|
||||
<code
|
||||
>{`
|
||||
"worker_group": "agent",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"exp": 1717334400
|
||||
`}</code
|
||||
>
|
||||
</pre>
|
||||
The JSON response will contain the generated JWT token.
|
||||
</div>
|
||||
</CollapseLink>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</Section>
|
||||
{:else if selectedTab === 'blacklist'}
|
||||
<div class="flex flex-col gap-y-4 mt-4">
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<Section label="Agent Token Blacklist" eeOnly>
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="text-sm text-secondary mb-2 max-w-md">
|
||||
<div class="text-xs text-secondary mb-2 max-w-md">
|
||||
Token blacklist management is only available in the enterprise edition.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-secondary mb-4 max-w-md">
|
||||
Add tokens to the blacklist to prevent them from being used by agent workers.
|
||||
Blacklisted tokens may take up to 5 minutes to be effective because of caching.
|
||||
<div class="text-xs text-secondary mb-4 max-w-md">
|
||||
Revoke tokens to prevent agent workers from authenticating. Blacklisted tokens may
|
||||
take up to 5 minutes to be effective because of caching.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" for="blacklistTokenInput"
|
||||
>Token</label
|
||||
>
|
||||
<input
|
||||
id="blacklistTokenInput"
|
||||
class="w-full"
|
||||
type="text"
|
||||
bind:value={blacklistToken}
|
||||
placeholder="jwt_agent_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ3b3JrZXJfZ3JvdXAiOiJhZ2VudCIsInN1ZmZpeCI6bnVsbCwidGFncyI6WyJiYXNoIl0sImV4cCI6MTg0NDk1NDYxMX0.JQWb-_ERGaomukbl_cEPPmmCAEepTR79d9oIrKREscE"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<Button color="red" on:click={addToBlacklist} disabled={!$superadmin}
|
||||
>Blacklist</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if !$superadmin}
|
||||
<div class="text-xs text-amber-600">
|
||||
Only superadmins can manage the token blacklist.
|
||||
<Label
|
||||
label="Token"
|
||||
for="blacklistTokenInput"
|
||||
tooltip="Blacklisted tokens cannot be used by agent workers to authenticate. Useful for revoking compromised tokens or decommissioning workers."
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<TextInput
|
||||
size="md"
|
||||
inputProps={{
|
||||
id: 'blacklistTokenInput',
|
||||
placeholder:
|
||||
'jwt_agent_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ3b3JrZXJfZ3JvdXAiOiJhZ2VudCIsInN1ZmZpeCI6bnVsbCwidGFncyI6WyJiYXNoIl0sImV4cCI6MTg0NDk1NDYxMX0.JQWb-_ERGaomukbl_cEPPmmCAEepTR79d9oIrKREscE',
|
||||
type: 'text',
|
||||
disabled: !$superadmin,
|
||||
oninput: (e) =>
|
||||
validateBlacklistToken((e.target as HTMLInputElement)?.value ?? '')
|
||||
}}
|
||||
bind:value={blacklistToken}
|
||||
error={blacklistTokenError}
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
on:click={addToBlacklist}
|
||||
disabled={!$superadmin || blacklistTokenError !== ''}>Blacklist</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if blacklistTokenError !== ''}
|
||||
<div class="text-xs text-red-600">
|
||||
{blacklistTokenError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$superadmin}
|
||||
<div class="text-xs text-amber-600">
|
||||
Only superadmins can manage the token blacklist.
|
||||
</div>
|
||||
{/if}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- Blacklisted Tokens List -->
|
||||
<div class="border-t pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium">Blacklisted Tokens</h3>
|
||||
<button
|
||||
class="p-2 text-gray-500 hover:text-blue-600 hover:bg-gray-100 rounded-lg transition"
|
||||
onclick={loadBlacklistedTokens}
|
||||
<div class="pt-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs text-primary">Blacklisted tokens</h3>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
on:click={loadBlacklistedTokens}
|
||||
disabled={isLoadingBlacklist}
|
||||
title="Refresh blacklist"
|
||||
>
|
||||
<RefreshCw size={16} class={isLoadingBlacklist ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
iconProps={{ class: isLoadingBlacklist ? 'animate-spin' : '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isLoadingBlacklist}
|
||||
<div class="text-center py-4 text-gray-500"> Loading blacklisted tokens... </div>
|
||||
<div class="text-center py-4 text-xs text-secondary">
|
||||
Loading blacklisted tokens...
|
||||
</div>
|
||||
{:else if blacklistedTokens?.length === 0}
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<div class="text-center py-4 text-xs text-secondary">
|
||||
No tokens are currently blacklisted.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each blacklistedTokens ?? [] as blacklistedToken}
|
||||
{#each blacklistedTokens ?? [] as blacklistedToken (blacklistedToken.token)}
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border"
|
||||
class="flex items-center justify-between p-3 surface-tertiary rounded-lg border border-light"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-mono text-xs text-gray-700 pr-4 break-all">
|
||||
<div class="font-mono text-2xs text-emphasis pr-4 break-all">
|
||||
{blacklistedToken.token}
|
||||
</div>
|
||||
{#if blacklistedToken.expires_at}
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<div class="text-2xs text-secondary mt-1">
|
||||
Expires: {new Date(blacklistedToken.expires_at).toLocaleString()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $superadmin}
|
||||
<button
|
||||
class="ml-3 p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition"
|
||||
onclick={() => removeFromBlacklist(blacklistedToken.token)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
destructive
|
||||
unifiedSize="sm"
|
||||
on:click={() => removeFromBlacklist(blacklistedToken.token)}
|
||||
title="Remove from blacklist"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
startIcon={{ icon: Trash }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { enterpriseLicense, isCriticalAlertsUIOpen } from '$lib/stores'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
BadgeCheck,
|
||||
BadgeX,
|
||||
Info,
|
||||
Plus,
|
||||
Slack,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import { AlertCircle, BadgeCheck, BadgeX, Info, Plus, Slack, X } from 'lucide-svelte'
|
||||
import type { Setting } from './instanceSettings'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import ObjectStoreConfigSettings from './ObjectStoreConfigSettings.svelte'
|
||||
@@ -38,6 +29,7 @@
|
||||
import LoadingIcon from './apps/svelte-select/lib/LoadingIcon.svelte'
|
||||
import TeamSelector from './TeamSelector.svelte'
|
||||
import ChannelSelector from './ChannelSelector.svelte'
|
||||
import EEOnly from './EEOnly.svelte'
|
||||
|
||||
interface Props {
|
||||
setting: Setting
|
||||
@@ -195,16 +187,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- {JSON.stringify($values, null, 2)} -->
|
||||
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key])}
|
||||
{#if setting.ee_only != undefined && !$enterpriseLicense}
|
||||
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap">
|
||||
<AlertTriangle size={16} />
|
||||
EE only {#if setting.ee_only != ''}<Tooltip>{setting.ee_only}</Tooltip>{/if}
|
||||
</div>
|
||||
<EEOnly>
|
||||
{#if setting.ee_only != ''}{setting.ee_only}{/if}
|
||||
</EEOnly>
|
||||
{/if}
|
||||
{#if setting.fieldType == 'select'}
|
||||
<div>
|
||||
@@ -571,13 +561,17 @@
|
||||
{@const currentTeam = $values['critical_error_channels'][i]?.teams_channel
|
||||
? {
|
||||
team_id: $values['critical_error_channels'][i]?.teams_channel?.team_id,
|
||||
team_name: $values['critical_error_channels'][i]?.teams_channel?.team_name
|
||||
team_name:
|
||||
$values['critical_error_channels'][i]?.teams_channel?.team_name
|
||||
}
|
||||
: undefined}
|
||||
{@const currentChannel = $values['critical_error_channels'][i]?.teams_channel?.channel_id
|
||||
{@const currentChannel = $values['critical_error_channels'][i]?.teams_channel
|
||||
?.channel_id
|
||||
? {
|
||||
channel_id: $values['critical_error_channels'][i]?.teams_channel?.channel_id,
|
||||
channel_name: $values['critical_error_channels'][i]?.teams_channel?.channel_name
|
||||
channel_id:
|
||||
$values['critical_error_channels'][i]?.teams_channel?.channel_id,
|
||||
channel_name:
|
||||
$values['critical_error_channels'][i]?.teams_channel?.channel_name
|
||||
}
|
||||
: undefined}
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Required from './Required.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import EEOnly from './EEOnly.svelte'
|
||||
|
||||
interface Props {
|
||||
label?: string | undefined
|
||||
@@ -12,6 +14,7 @@
|
||||
class?: string | undefined
|
||||
for?: string | undefined
|
||||
tooltip?: string | undefined
|
||||
eeOnly?: boolean
|
||||
header?: import('svelte').Snippet
|
||||
error?: import('svelte').Snippet
|
||||
action?: import('svelte').Snippet
|
||||
@@ -27,6 +30,7 @@
|
||||
class: clazz = undefined,
|
||||
for: forAttr = undefined,
|
||||
tooltip = undefined,
|
||||
eeOnly = false,
|
||||
header,
|
||||
error,
|
||||
action,
|
||||
@@ -49,6 +53,11 @@
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
{/if}
|
||||
</span>
|
||||
{#if eeOnly}
|
||||
{#if !$enterpriseLicense}
|
||||
<EEOnly />
|
||||
{/if}
|
||||
{/if}
|
||||
{@render header?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import Drawer from './common/drawer/Drawer.svelte'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import AssignableTagsInner from './AssignableTagsInner.svelte'
|
||||
import DefaultTagsInner from './DefaultTagsInner.svelte'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
import { Section } from './common'
|
||||
|
||||
interface Props {
|
||||
defaultTagPerWorkspace?: boolean | undefined
|
||||
defaultTagWorkspaces?: string[]
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
defaultTagPerWorkspace = $bindable(undefined),
|
||||
defaultTagWorkspaces = $bindable([]),
|
||||
onRefresh
|
||||
}: Props = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
|
||||
export function openDrawer() {
|
||||
drawer?.openDrawer?.()
|
||||
}
|
||||
|
||||
export function closeDrawer() {
|
||||
drawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
export function toggleDrawer() {
|
||||
drawer?.toggleDrawer?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="800px">
|
||||
<DrawerContent title="Manage tags" on:close={() => drawer?.closeDrawer?.()}>
|
||||
<div class="flex flex-col h-full gap-6">
|
||||
<!-- Overall Description -->
|
||||
<div class="text-xs font-normal text-secondary">
|
||||
Tags determine which worker group will execute a given job. Workers process only those jobs
|
||||
whose tags match those defined in their <a
|
||||
href="https://www.windmill.dev/docs/core_concepts/worker_groups"
|
||||
target="_blank">worker group <ExternalLink size={12} class="inline-block" /></a
|
||||
>
|
||||
configuration.
|
||||
</div>
|
||||
|
||||
<!-- Content Sections -->
|
||||
<div class="flex flex-col gap-8 flex-1">
|
||||
<!-- Custom Tags Section -->
|
||||
<Section label="Custom tags">
|
||||
<AssignableTagsInner
|
||||
variant="drawer"
|
||||
on:refresh={() => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<!-- Default Tags Section -->
|
||||
<DefaultTagsInner bind:defaultTagPerWorkspace bind:defaultTagWorkspaces />
|
||||
|
||||
<!-- Extra padding -->
|
||||
<div class="pb-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import MeltTooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
|
||||
interface Props {
|
||||
rate_15s?: number
|
||||
rate_5m?: number
|
||||
rate_30m?: number
|
||||
rate_ever?: number
|
||||
}
|
||||
|
||||
let { rate_15s, rate_5m, rate_30m, rate_ever }: Props = $props()
|
||||
|
||||
function displayOccupancyRate(occupancy_rate: number | undefined) {
|
||||
if (occupancy_rate == undefined) {
|
||||
return '--'
|
||||
}
|
||||
return Math.ceil(occupancy_rate * 100) + '%'
|
||||
}
|
||||
|
||||
const rates = $derived([
|
||||
{ value: rate_15s, label: '15s' },
|
||||
{ value: rate_5m, label: '5m' },
|
||||
{ value: rate_30m, label: '30m' },
|
||||
{ value: rate_ever, label: 'ever' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<div class="flex gap-1 items-end py-1">
|
||||
{#each rates as rate}
|
||||
<MeltTooltip>
|
||||
<div class="relative w-4 h-8 bg-surface-secondary rounded-sm border shadow-sm">
|
||||
{#if rate.value !== undefined && rate.value > 0}
|
||||
{@const heightPercent = Math.min(rate.value * 100, 100)}
|
||||
{@const minHeight = heightPercent > 0 && heightPercent < 3 ? 1 : heightPercent}
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 bg-surface-accent-primary rounded-sm transition-all duration-200"
|
||||
style="height: {minHeight < 3 ? `${minHeight}px` : `${heightPercent}%`}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet text()}
|
||||
{rate.label}: {rate.value ? displayOccupancyRate(rate.value) : '--'}
|
||||
{/snippet}
|
||||
</MeltTooltip>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,572 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { Popover } from '$lib/components/meltComponents'
|
||||
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
|
||||
import { Plus, Edit3, Save, X, Trash, ExternalLink } from 'lucide-svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { ConfigService, type Alert } from '$lib/gen'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
|
||||
let queueAlertConfig = $state<Alert[]>([])
|
||||
let availableTags = $state<string[]>([])
|
||||
let configName = 'alert__job_queue_waiting'
|
||||
|
||||
let editingRowIndex = $state<number>(-1)
|
||||
let editForm = $state<{
|
||||
tags_to_monitor: string[]
|
||||
jobs_num_threshold: string
|
||||
alert_cooldown_seconds: string
|
||||
alert_time_threshold_seconds: string
|
||||
}>({
|
||||
tags_to_monitor: [],
|
||||
jobs_num_threshold: '',
|
||||
alert_cooldown_seconds: '',
|
||||
alert_time_threshold_seconds: ''
|
||||
})
|
||||
|
||||
let newAlertForm = $state({
|
||||
tags_to_monitor: [] as string[],
|
||||
jobs_num_threshold: '3',
|
||||
alert_cooldown_seconds: '600',
|
||||
alert_time_threshold_seconds: '30'
|
||||
})
|
||||
|
||||
let addAlertOpen = $state(false)
|
||||
let formErrors = $state<Record<string, string>>({})
|
||||
let expandedTagRows = $state<number[]>([])
|
||||
|
||||
const MAX_NUMBER_OF_TAGS_DISPLAYED = 10
|
||||
|
||||
onMount(async () => {
|
||||
await fetchConfig()
|
||||
availableTags = await fetchWorkerTags()
|
||||
})
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
const response = await ConfigService.getConfig({ name: configName })
|
||||
queueAlertConfig = response?.alerts || []
|
||||
expandedTagRows = []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkerTags(): Promise<string[]> {
|
||||
try {
|
||||
const response = await ConfigService.listConfigs()
|
||||
const workerTagsSet = new Set<string>()
|
||||
|
||||
response.forEach((config) => {
|
||||
if (config.name.startsWith('worker__') && Array.isArray(config.config?.worker_tags)) {
|
||||
config?.config?.worker_tags.forEach((tag) => workerTagsSet.add(tag))
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(workerTagsSet)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch worker tags:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(index: number) {
|
||||
editingRowIndex = index
|
||||
const config = queueAlertConfig[index]
|
||||
editForm = {
|
||||
tags_to_monitor: [...config.tags_to_monitor],
|
||||
jobs_num_threshold: config.jobs_num_threshold.toString(),
|
||||
alert_cooldown_seconds: config.alert_cooldown_seconds.toString(),
|
||||
alert_time_threshold_seconds: config.alert_time_threshold_seconds.toString()
|
||||
}
|
||||
// Reset expanded state when entering edit mode
|
||||
expandedTagRows = expandedTagRows.filter((i) => i !== index)
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingRowIndex = -1
|
||||
formErrors = {}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!validateForm(editForm)) return
|
||||
|
||||
try {
|
||||
queueAlertConfig[editingRowIndex] = {
|
||||
name: 'Job Queue Alert',
|
||||
tags_to_monitor: editForm.tags_to_monitor,
|
||||
jobs_num_threshold: parseInt(editForm.jobs_num_threshold),
|
||||
alert_cooldown_seconds: parseInt(editForm.alert_cooldown_seconds),
|
||||
alert_time_threshold_seconds: parseInt(editForm.alert_time_threshold_seconds)
|
||||
}
|
||||
|
||||
await saveQueueAlertConfig()
|
||||
editingRowIndex = -1
|
||||
formErrors = {}
|
||||
sendUserToast('Alert configuration updated successfully')
|
||||
} catch (error) {
|
||||
sendUserToast('Failed to update alert configuration', true)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAlert(index: number) {
|
||||
try {
|
||||
queueAlertConfig.splice(index, 1)
|
||||
await saveQueueAlertConfig()
|
||||
// Clean up expanded state for deleted row and shift indices down
|
||||
expandedTagRows = expandedTagRows
|
||||
.filter((i) => i !== index)
|
||||
.map((i) => (i > index ? i - 1 : i))
|
||||
sendUserToast('Alert deleted successfully')
|
||||
} catch (error) {
|
||||
sendUserToast('Failed to delete alert', true)
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(form: typeof editForm | typeof newAlertForm): boolean {
|
||||
formErrors = {}
|
||||
let isValid = true
|
||||
|
||||
if (form.tags_to_monitor.length === 0) {
|
||||
formErrors.tags_to_monitor = 'At least one tag is required'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
const jobsThreshold = parseInt(form.jobs_num_threshold)
|
||||
if (isNaN(jobsThreshold) || jobsThreshold < 1) {
|
||||
formErrors.jobs_num_threshold = 'Must be a positive number'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
const cooldown = parseInt(form.alert_cooldown_seconds)
|
||||
if (isNaN(cooldown) || cooldown < 1) {
|
||||
formErrors.alert_cooldown_seconds = 'Must be a positive number'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
const timeThreshold = parseInt(form.alert_time_threshold_seconds)
|
||||
if (isNaN(timeThreshold) || timeThreshold < 1) {
|
||||
formErrors.alert_time_threshold_seconds = 'Must be a positive number'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
async function addNewAlert() {
|
||||
if (!validateForm(newAlertForm)) return
|
||||
|
||||
try {
|
||||
queueAlertConfig.push({
|
||||
name: 'Job Queue Alert',
|
||||
tags_to_monitor: newAlertForm.tags_to_monitor,
|
||||
jobs_num_threshold: parseInt(newAlertForm.jobs_num_threshold),
|
||||
alert_cooldown_seconds: parseInt(newAlertForm.alert_cooldown_seconds),
|
||||
alert_time_threshold_seconds: parseInt(newAlertForm.alert_time_threshold_seconds)
|
||||
})
|
||||
|
||||
await saveQueueAlertConfig()
|
||||
|
||||
// Reset form
|
||||
newAlertForm = {
|
||||
tags_to_monitor: [],
|
||||
jobs_num_threshold: '3',
|
||||
alert_cooldown_seconds: '600',
|
||||
alert_time_threshold_seconds: '30'
|
||||
}
|
||||
|
||||
addAlertOpen = false
|
||||
formErrors = {}
|
||||
sendUserToast('Alert added successfully')
|
||||
} catch (error) {
|
||||
sendUserToast('Failed to add alert', true)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQueueAlertConfig() {
|
||||
await ConfigService.updateConfig({
|
||||
name: configName,
|
||||
requestBody: { alerts: queueAlertConfig }
|
||||
})
|
||||
}
|
||||
|
||||
function safeSelectItems(items: string[]) {
|
||||
return items.map((item) => ({ label: item, value: item }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<Section
|
||||
label="Queue alerts"
|
||||
description={$enterpriseLicense
|
||||
? 'Configure alerts for queue monitoring based on worker tags and thresholds'
|
||||
: ''}
|
||||
eeOnly
|
||||
>
|
||||
{#snippet action()}
|
||||
{#if $enterpriseLicense}
|
||||
<Popover
|
||||
bind:isOpen={addAlertOpen}
|
||||
closeButton
|
||||
placement="bottom-end"
|
||||
contentClasses="p-4 w-96 max-w-96"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="default" unifiedSize="md" startIcon={{ icon: Plus }}
|
||||
>Add new alert</Button
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<form class="flex flex-col gap-y-6">
|
||||
<h3 class="text-sm font-semibold text-emphasis">Add queue alert</h3>
|
||||
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<label for="new-tags" class="text-xs font-semibold text-emphasis">
|
||||
Worker tags to monitor
|
||||
</label>
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
Tags that identify which workers to monitor for this alert
|
||||
</span>
|
||||
<div class="flex gap-2 items-start">
|
||||
<MultiSelect
|
||||
items={safeSelectItems(availableTags)}
|
||||
bind:value={newAlertForm.tags_to_monitor}
|
||||
createText="Press Enter to add custom tag"
|
||||
placeholder="Select or create tags..."
|
||||
error={!!formErrors.tags_to_monitor}
|
||||
class="flex-1"
|
||||
disablePortal
|
||||
/>
|
||||
{#if newAlertForm.tags_to_monitor.length === 0}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
onclick={() => {
|
||||
newAlertForm.tags_to_monitor = [...availableTags]
|
||||
}}
|
||||
>
|
||||
Add all tags
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if formErrors.tags_to_monitor}
|
||||
<span class="text-2xs font-normal text-red-500">{formErrors.tags_to_monitor}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<label for="new-jobs-threshold" class="text-xs font-semibold text-emphasis">
|
||||
Jobs count threshold
|
||||
</label>
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
Trigger alert when queue exceeds this many jobs
|
||||
</span>
|
||||
<TextInput
|
||||
inputProps={{
|
||||
id: 'new-jobs-threshold',
|
||||
type: 'number',
|
||||
min: '1',
|
||||
placeholder: '3'
|
||||
}}
|
||||
bind:value={newAlertForm.jobs_num_threshold}
|
||||
size="sm"
|
||||
class="w-full"
|
||||
error={!!formErrors.jobs_num_threshold}
|
||||
/>
|
||||
{#if formErrors.jobs_num_threshold}
|
||||
<span class="text-2xs font-normal text-red-500"
|
||||
>{formErrors.jobs_num_threshold}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<label for="new-cooldown" class="text-xs font-semibold text-emphasis">
|
||||
Alert cooldown (seconds)
|
||||
</label>
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
Wait time between alerts for the same condition
|
||||
</span>
|
||||
<TextInput
|
||||
inputProps={{
|
||||
id: 'new-cooldown',
|
||||
type: 'number',
|
||||
min: '1',
|
||||
placeholder: '600'
|
||||
}}
|
||||
bind:value={newAlertForm.alert_cooldown_seconds}
|
||||
size="sm"
|
||||
class="w-full"
|
||||
error={!!formErrors.alert_cooldown_seconds}
|
||||
/>
|
||||
{#if formErrors.alert_cooldown_seconds}
|
||||
<span class="text-2xs font-normal text-red-500"
|
||||
>{formErrors.alert_cooldown_seconds}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<label for="new-time-threshold" class="text-xs font-semibold text-emphasis">
|
||||
Time threshold (seconds)
|
||||
</label>
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
How long the condition must persist before alerting
|
||||
</span>
|
||||
<TextInput
|
||||
inputProps={{
|
||||
id: 'new-time-threshold',
|
||||
type: 'number',
|
||||
min: '1',
|
||||
placeholder: '30'
|
||||
}}
|
||||
bind:value={newAlertForm.alert_time_threshold_seconds}
|
||||
size="sm"
|
||||
class="w-full"
|
||||
error={!!formErrors.alert_time_threshold_seconds}
|
||||
/>
|
||||
{#if formErrors.alert_time_threshold_seconds}
|
||||
<span class="text-2xs font-normal text-red-500"
|
||||
>{formErrors.alert_time_threshold_seconds}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-x-2 pt-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
onclick={() => {
|
||||
addAlertOpen = false
|
||||
formErrors = {}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={addNewAlert}
|
||||
type="submit"
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Plus }}>Add alert</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="text-xs text-primary">
|
||||
Queue Metric Alerts is an enterprise feature allowing you to monitor queues for waiting jobs.
|
||||
Please upgrade to access this functionality. <a
|
||||
href="https://www.windmill.dev/pricing"
|
||||
target="_blank"
|
||||
>Learn more about our plans <ExternalLink size={12} class="inline-block" /></a
|
||||
>
|
||||
</div>
|
||||
{:else if queueAlertConfig.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-sm text-secondary">No queue alerts configured</p>
|
||||
<p class="text-xs text-hint mt-1">Add your first alert to monitor queue conditions</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto border rounded-md">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b bg-surface-secondary">
|
||||
<th class="text-left py-3 px-4 text-xs font-normal text-normal min-w-48">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Worker Tags
|
||||
<Tooltip>Tags that identify which workers to monitor for this alert</Tooltip>
|
||||
</span>
|
||||
</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-normal text-normal">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Jobs Threshold
|
||||
<Tooltip>Trigger alert when queue exceeds this many jobs</Tooltip>
|
||||
</span>
|
||||
</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-normal text-normal">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Cooldown (s)
|
||||
<Tooltip>Wait time between alerts for the same condition</Tooltip>
|
||||
</span>
|
||||
</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-normal text-normal">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Time Threshold (s)
|
||||
<Tooltip>How long the condition must persist before alerting</Tooltip>
|
||||
</span>
|
||||
</th>
|
||||
<th class="text-right py-3 px-4 text-xs font-normal text-normal">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each queueAlertConfig as config, index}
|
||||
<tr
|
||||
class={twMerge(
|
||||
'text-xs text-primary',
|
||||
index !== queueAlertConfig.length - 1 ? 'border-b' : '',
|
||||
editingRowIndex === index ? 'bg-surface-selected' : ''
|
||||
)}
|
||||
>
|
||||
<td class="p-2">
|
||||
{#if editingRowIndex === index}
|
||||
<div class="flex gap-2 items-start">
|
||||
<MultiSelect
|
||||
items={safeSelectItems(availableTags)}
|
||||
bind:value={editForm.tags_to_monitor}
|
||||
onCreateItem={(tag) => {
|
||||
if (!editForm.tags_to_monitor.includes(tag)) {
|
||||
editForm.tags_to_monitor = [...editForm.tags_to_monitor, tag]
|
||||
}
|
||||
}}
|
||||
createText="Press Enter to add custom tag"
|
||||
placeholder="Select or create tags..."
|
||||
class="flex-1"
|
||||
/>
|
||||
{#if editForm.tags_to_monitor.length === 0}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
onclick={() => {
|
||||
editForm.tags_to_monitor = [...availableTags]
|
||||
}}
|
||||
>
|
||||
Add all tags
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{@const isExpanded = expandedTagRows.includes(index)}
|
||||
{@const tagsToShow =
|
||||
isExpanded || config.tags_to_monitor.length <= MAX_NUMBER_OF_TAGS_DISPLAYED
|
||||
? config.tags_to_monitor
|
||||
: config.tags_to_monitor.slice(0, MAX_NUMBER_OF_TAGS_DISPLAYED)}
|
||||
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each tagsToShow as tag}
|
||||
<Badge color="blue" small>
|
||||
{tag}
|
||||
</Badge>
|
||||
{/each}
|
||||
|
||||
{#if config.tags_to_monitor.length > MAX_NUMBER_OF_TAGS_DISPLAYED && !isExpanded}
|
||||
<Badge
|
||||
clickable
|
||||
color="blue"
|
||||
small
|
||||
onclick={() => {
|
||||
expandedTagRows = [...expandedTagRows, index]
|
||||
}}
|
||||
>
|
||||
+ {config.tags_to_monitor.length}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-2">
|
||||
{#if editingRowIndex === index}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
min: '1'
|
||||
}}
|
||||
bind:value={editForm.jobs_num_threshold}
|
||||
size="sm"
|
||||
class="w-20"
|
||||
/>
|
||||
{:else}
|
||||
<span>{config.jobs_num_threshold}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-2">
|
||||
{#if editingRowIndex === index}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
min: '1'
|
||||
}}
|
||||
bind:value={editForm.alert_cooldown_seconds}
|
||||
size="sm"
|
||||
class="w-24"
|
||||
/>
|
||||
{:else}
|
||||
<span>{config.alert_cooldown_seconds}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-2">
|
||||
{#if editingRowIndex === index}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
min: '1'
|
||||
}}
|
||||
bind:value={editForm.alert_time_threshold_seconds}
|
||||
size="sm"
|
||||
class="w-24"
|
||||
/>
|
||||
{:else}
|
||||
<span>{config.alert_time_threshold_seconds}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-2">
|
||||
{#if editingRowIndex === index}
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Save }}
|
||||
onclick={saveEdit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: X }}
|
||||
onclick={cancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Edit3 }}
|
||||
onclick={() => startEditing(index)}
|
||||
disabled={editingRowIndex !== -1}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
destructive
|
||||
unifiedSize="sm"
|
||||
onclick={() => deleteAlert(index)}
|
||||
disabled={editingRowIndex !== -1}
|
||||
startIcon={{ icon: Trash }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
@@ -1,423 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Drawer, DrawerContent, Button } from './common'
|
||||
import { Drawer, DrawerContent } from './common'
|
||||
import QueueMetricsDrawerInner from './QueueMetricsDrawerInner.svelte'
|
||||
import { ConfigService, type Alert } from '$lib/gen'
|
||||
import Section from './Section.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Pencil, Trash, Check, PlusCircle, SaveIcon } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
|
||||
function updateChangesMade() {
|
||||
changesMade = JSON.stringify(alerts) !== JSON.stringify(originalAlerts)
|
||||
}
|
||||
|
||||
function handleInput(event) {
|
||||
const target = event.target
|
||||
console.log(target)
|
||||
if (target.tagName.toLowerCase() === 'input') {
|
||||
updateChangesMade()
|
||||
}
|
||||
}
|
||||
import QueueAlerts from './QueueAlerts.svelte'
|
||||
|
||||
let drawer: Drawer
|
||||
export function openDrawer() {
|
||||
drawer?.openDrawer()
|
||||
}
|
||||
|
||||
let alerts: Alert[] = []
|
||||
|
||||
let configName = 'alert__job_queue_waiting'
|
||||
let originalAlerts: Alert[] = []
|
||||
let newTag = ''
|
||||
let editingIndex = -1
|
||||
let changesMade = false
|
||||
let removedAlerts: Alert[] = []
|
||||
let stagedNewAlert = false
|
||||
let workerTags: string[] = []
|
||||
let filteredTags: string[] = []
|
||||
|
||||
$: removedAlerts
|
||||
|
||||
onMount(async () => {
|
||||
await fetchConfig()
|
||||
workerTags = await fetchWorkerTags()
|
||||
})
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
const response = await ConfigService.getConfig({ name: configName })
|
||||
alerts = response?.alerts || []
|
||||
originalAlerts = JSON.parse(JSON.stringify(alerts))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkerTags(): Promise<string[]> {
|
||||
try {
|
||||
const response = await ConfigService.listConfigs()
|
||||
const workerTagsSet = new Set<string>()
|
||||
|
||||
response.forEach((config) => {
|
||||
if (config.name.startsWith('worker__') && Array.isArray(config.config?.worker_tags)) {
|
||||
config?.config?.worker_tags.forEach((tag) => workerTagsSet.add(tag))
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(workerTagsSet)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch worker tags:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(index) {
|
||||
if (editingIndex !== -1) {
|
||||
const success = saveAlert(editingIndex)
|
||||
if (!success) return
|
||||
}
|
||||
editingIndex = index
|
||||
updateWorkerTags()
|
||||
}
|
||||
|
||||
function saveAlert(index): boolean {
|
||||
const newAlert = alerts[index]
|
||||
|
||||
if (newAlert.tags_to_monitor.length === 0) {
|
||||
sendUserToast('Please add at least one tag before saving.', true)
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
newAlert.jobs_num_threshold <= 0 ||
|
||||
newAlert.alert_cooldown_seconds <= 0 ||
|
||||
newAlert.alert_time_threshold_seconds <= 0
|
||||
) {
|
||||
sendUserToast('All numeric values must be strictly positive.', true)
|
||||
return false
|
||||
}
|
||||
|
||||
const alertExists = originalAlerts.some(
|
||||
(alert) =>
|
||||
originalAlerts.indexOf(alert) !== index &&
|
||||
JSON.stringify(alert.tags_to_monitor.sort()) ===
|
||||
JSON.stringify(newAlert.tags_to_monitor.sort())
|
||||
)
|
||||
|
||||
if (alertExists) {
|
||||
sendUserToast('You can only define one alert per identical set of tags', true)
|
||||
return false
|
||||
}
|
||||
|
||||
editingIndex = -1
|
||||
updateChangesMade()
|
||||
|
||||
stagedNewAlert = false
|
||||
return true
|
||||
}
|
||||
|
||||
function stageDeleteAlert(index) {
|
||||
const alert = alerts[index]
|
||||
removedAlerts = [...removedAlerts, alert]
|
||||
changesMade =
|
||||
removedAlerts.length > 0 || JSON.stringify(alerts) !== JSON.stringify(originalAlerts)
|
||||
}
|
||||
|
||||
function filterTags(event: Event) {
|
||||
const input = (event.target as HTMLInputElement).value
|
||||
filteredTags = workerTags.filter((tag) => tag.toLowerCase().includes(input.toLowerCase()))
|
||||
}
|
||||
|
||||
function addTag(alertIndex, tag) {
|
||||
if (workerTags.includes(tag) && !alerts[alertIndex].tags_to_monitor.includes(tag)) {
|
||||
alerts[alertIndex].tags_to_monitor = [...alerts[alertIndex].tags_to_monitor, tag]
|
||||
}
|
||||
newTag = ''
|
||||
filteredTags = []
|
||||
updateChangesMade()
|
||||
}
|
||||
|
||||
function removeTag(alertIndex, tag) {
|
||||
alerts[alertIndex].tags_to_monitor = alerts[alertIndex].tags_to_monitor.filter((t) => t !== tag)
|
||||
updateChangesMade()
|
||||
}
|
||||
|
||||
async function applyConfig() {
|
||||
if (editingIndex !== -1) {
|
||||
const success = saveAlert(editingIndex)
|
||||
if (!success) return
|
||||
}
|
||||
|
||||
try {
|
||||
await ConfigService.updateConfig({ name: configName, requestBody: { alerts } })
|
||||
sendUserToast('Configuration updated successfully')
|
||||
alerts = alerts.filter((alert) => !removedAlerts.includes(alert))
|
||||
originalAlerts = JSON.parse(JSON.stringify(alerts))
|
||||
removedAlerts = []
|
||||
changesMade = false
|
||||
stagedNewAlert = false
|
||||
editingIndex = -1
|
||||
} catch (error) {
|
||||
console.error('Failed to update config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelChanges() {
|
||||
alerts = [...alerts, ...removedAlerts]
|
||||
alerts = JSON.parse(JSON.stringify(originalAlerts))
|
||||
removedAlerts = []
|
||||
editingIndex = -1
|
||||
changesMade = false
|
||||
stagedNewAlert = false
|
||||
}
|
||||
|
||||
function addNewAlert() {
|
||||
// alert already being added
|
||||
if (stagedNewAlert) {
|
||||
return
|
||||
}
|
||||
|
||||
const newAlert = {
|
||||
name: 'Job Queue Alert',
|
||||
tags_to_monitor: [],
|
||||
jobs_num_threshold: 3,
|
||||
alert_cooldown_seconds: 600,
|
||||
alert_time_threshold_seconds: 30
|
||||
}
|
||||
|
||||
alerts = [...alerts, newAlert]
|
||||
editingIndex = alerts.length - 1
|
||||
stagedNewAlert = true
|
||||
updateChangesMade()
|
||||
updateWorkerTags()
|
||||
}
|
||||
|
||||
async function updateWorkerTags() {
|
||||
workerTags = await fetchWorkerTags()
|
||||
}
|
||||
|
||||
function addAllTags(alertIndex) {
|
||||
alerts[alertIndex].tags_to_monitor = [
|
||||
...new Set([...alerts[alertIndex].tags_to_monitor, ...workerTags])
|
||||
]
|
||||
alerts = [...alerts]
|
||||
updateChangesMade()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="800px">
|
||||
<Drawer bind:this={drawer} size="1000px">
|
||||
<DrawerContent
|
||||
title="Queues"
|
||||
on:close={drawer.closeDrawer}
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups#queue-metrics"
|
||||
>
|
||||
<Section
|
||||
label="Queue alert settings"
|
||||
collapsable={true}
|
||||
tooltip="A critical alert is triggered when the number of jobs in the queue exceeds the set threshold and they have been waiting for at least the specified time. After an alert, no new alerts will be triggered during the cooldown period."
|
||||
eeOnly={true}
|
||||
>
|
||||
{#if $enterpriseLicense}
|
||||
{#if changesMade}
|
||||
<div class="text-red-600 text-xs whitespace-nowrap pb-2">Non applied changes</div>
|
||||
{/if}
|
||||
<div class="flex gap-2 pb-2">
|
||||
<Button color="blue" size="xs" on:click={applyConfig} disabled={!changesMade}>
|
||||
<SaveIcon size={16} /> Apply config
|
||||
</Button>
|
||||
<Button color="light" size="xs" on:click={cancelChanges} disabled={!changesMade}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<QueueAlerts />
|
||||
|
||||
{#if alerts.length > 0}
|
||||
<div>
|
||||
<form on:submit|preventDefault>
|
||||
<table class="w-full border-collapse mb-2 text-xs table-auto">
|
||||
<thead class="bg-gray-200 dark:bg-slate-600 text-left text-xs">
|
||||
<tr>
|
||||
<th class="p-2 w-full">
|
||||
Queue Tags to Monitor
|
||||
<Tooltip markdownTooltip="Queue tags to monitor for this alert." />
|
||||
</th>
|
||||
<th class="p-2 min-w-[65px]">
|
||||
Jobs
|
||||
<Tooltip
|
||||
markdownTooltip="Number of jobs threshold: An alert will be triggered if the number of jobs in the queue exceeds this threshold and they have been waiting for at least the specified time threshold."
|
||||
/>
|
||||
</th>
|
||||
<th class="p-2 min-w-[115px]">
|
||||
Cooldown (s)
|
||||
<Tooltip
|
||||
markdownTooltip="Cooldown period in seconds: This defines the time interval after an alert is triggered during which no additional alerts will be sent."
|
||||
/>
|
||||
</th>
|
||||
<th class="p-2 min-w-[105px]">
|
||||
Time (s)
|
||||
<Tooltip
|
||||
markdownTooltip="Time threshold in seconds: An alert will be triggered if the number of jobs in the queue exceeds the job threshold and they have remained in the queue for at least this duration."
|
||||
/>
|
||||
</th>
|
||||
<th class="p-2 min-w-[100px]"> Actions </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody on:input={handleInput}>
|
||||
{#each alerts as alert, index}
|
||||
<tr
|
||||
class={removedAlerts.includes(alert)
|
||||
? 'bg-red-100 dark:bg-red-900 pointer-events-none opacity-50'
|
||||
: ''}
|
||||
>
|
||||
<td class="border p-2">
|
||||
{#if editingIndex === index}
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
{#each alert.tags_to_monitor as tag}
|
||||
<span
|
||||
class="inline-block bg-blue-100 dark:bg-blue-700 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
on:click={() => removeTag(index, tag)}
|
||||
aria-label="Remove tag"
|
||||
class="ml-1 text-xs">x</button
|
||||
>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTag}
|
||||
placeholder={workerTags.length === alert.tags_to_monitor.length
|
||||
? 'All tags already added'
|
||||
: 'Add tag from dropdown'}
|
||||
on:input={(e) => filterTags(e)}
|
||||
disabled={workerTags.length === alert.tags_to_monitor.length}
|
||||
class="p-1 flex-grow mr-1"
|
||||
/>
|
||||
<button on:click={() => addTag(index, newTag)} aria-label="Add tag">
|
||||
<PlusCircle size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add the new "Add All Tags" button here -->
|
||||
<button
|
||||
on:click={() => addAllTags(index)}
|
||||
class="text-xs hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-1 mt-1"
|
||||
disabled={workerTags.length === alert.tags_to_monitor.length}
|
||||
>
|
||||
Add All Tags
|
||||
</button>
|
||||
{#if filteredTags.length > 0}
|
||||
<ul
|
||||
class="autocomplete-list border max-h-36 overflow-y-auto absolute z-50"
|
||||
>
|
||||
{#each filteredTags as tag}
|
||||
{#if !alert.tags_to_monitor.includes(tag)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left p-2 cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-700"
|
||||
on:click={() => addTag(index, tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each alert.tags_to_monitor as tag}
|
||||
<span
|
||||
class="inline-block bg-blue-100 dark:bg-blue-700 rounded px-2 py-1 text-xs"
|
||||
>{tag}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="border p-2">
|
||||
{#if editingIndex === index}
|
||||
<input
|
||||
type="number"
|
||||
bind:value={alert.jobs_num_threshold}
|
||||
class="w-full p-1"
|
||||
/>
|
||||
{:else}
|
||||
{alert.jobs_num_threshold}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="border p-2">
|
||||
{#if editingIndex === index}
|
||||
<input
|
||||
type="number"
|
||||
bind:value={alert.alert_cooldown_seconds}
|
||||
class="w-full p-1"
|
||||
/>
|
||||
{:else}
|
||||
{alert.alert_cooldown_seconds}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="border p-2">
|
||||
{#if editingIndex === index}
|
||||
<input
|
||||
type="number"
|
||||
bind:value={alert.alert_time_threshold_seconds}
|
||||
class="w-full p-1"
|
||||
/>
|
||||
{:else}
|
||||
{alert.alert_time_threshold_seconds}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="border p-2">
|
||||
<div class="flex gap-3 justify-center items-center">
|
||||
{#if editingIndex === index}
|
||||
<button on:click={() => saveAlert(index)} aria-label="Save">
|
||||
<Check size={16} />
|
||||
</button>
|
||||
{:else}
|
||||
<button on:click={() => startEditing(index)} aria-label="Edit">
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button on:click={() => stageDeleteAlert(index)} aria-label="Delete">
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="py-8"></div>
|
||||
|
||||
<!-- Button to Add New Alert at the Bottom of the Table -->
|
||||
<div class="flex">
|
||||
<Button color="blue" size="xs" on:click={addNewAlert}>
|
||||
<PlusCircle size={16} />
|
||||
Add new alert
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm">
|
||||
Queue Metric Alerts are an enterprise feature allowing you to monitor queues for waiting
|
||||
jobs. Please upgrade to access this functionality.
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/misc/plans_details"
|
||||
target="_blank"
|
||||
class="text-blue-500 underline">Learn more about our plans.</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</Section>
|
||||
<h1 class="pt-4">Queue Metrics</h1>
|
||||
<div class="p-8">
|
||||
<QueueMetricsDrawerInner />
|
||||
</div>
|
||||
<QueueMetricsDrawerInner />
|
||||
|
||||
<div class="py-8"></div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import Skeleton from './common/skeleton/Skeleton.svelte'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { Section } from './common'
|
||||
|
||||
let loading: boolean = true
|
||||
|
||||
@@ -186,87 +187,89 @@
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
{#if loading}
|
||||
<Skeleton layout={[[20]]} />
|
||||
{:else if noMetrics}
|
||||
<p class="text-secondary">No jobs delayed by more than 3 seconds in the last 14 days</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if countData}
|
||||
<Line
|
||||
data={countData}
|
||||
options={{
|
||||
animation: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of delayed jobs per tag (> 3s)'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
min: minDate.toISOString(),
|
||||
max: new Date().toISOString()
|
||||
},
|
||||
y: {
|
||||
<Section label="Queue metrics">
|
||||
{#if loading}
|
||||
<Skeleton layout={[[20]]} />
|
||||
{:else if noMetrics}
|
||||
<p class="text-secondary">No jobs delayed by more than 3 seconds in the last 14 days</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if countData}
|
||||
<Line
|
||||
data={countData}
|
||||
options={{
|
||||
animation: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'count'
|
||||
text: 'Number of delayed jobs per tag (> 3s)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if delayData}
|
||||
<Line
|
||||
data={delayData}
|
||||
options={{
|
||||
animation: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Queue delay per tag (> 3s)'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
// @ts-ignore
|
||||
if (context.raw.y === 1) {
|
||||
return context.dataset.label + ': 0'
|
||||
} else {
|
||||
// @ts-ignore
|
||||
return context.dataset.label + ': ' + context.raw.y
|
||||
}
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
min: minDate.toISOString(),
|
||||
max: new Date().toISOString()
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'count'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
min: minDate.toISOString(),
|
||||
max: new Date().toISOString()
|
||||
},
|
||||
|
||||
y: {
|
||||
type: 'logarithmic',
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if delayData}
|
||||
<Line
|
||||
data={delayData}
|
||||
options={{
|
||||
animation: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'delay (s)'
|
||||
text: 'Queue delay per tag (> 3s)'
|
||||
},
|
||||
ticks: {
|
||||
callback: (value, _) => (value === 1 ? '0' : value)
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
// @ts-ignore
|
||||
if (context.raw.y === 1) {
|
||||
return context.dataset.label + ': 0'
|
||||
} else {
|
||||
// @ts-ignore
|
||||
return context.dataset.label + ': ' + context.raw.y
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
min: minDate.toISOString(),
|
||||
max: new Date().toISOString()
|
||||
},
|
||||
|
||||
y: {
|
||||
type: 'logarithmic',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'delay (s)'
|
||||
},
|
||||
ticks: {
|
||||
callback: (value, _) => (value === 1 ? '0' : value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Alert title="Info">
|
||||
Only tags for jobs that have been delayed by more than 3 seconds in the last 14 days are
|
||||
included in the graph.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Alert title="Info">
|
||||
Only tags for jobs that have been delayed by more than 3 seconds in the last 14 days are
|
||||
included in the graph.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
PostgresTriggerService,
|
||||
CaptureService,
|
||||
type ScriptLang,
|
||||
WorkerService,
|
||||
WorkerService
|
||||
} from '$lib/gen'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import {
|
||||
@@ -189,7 +189,7 @@
|
||||
: undefined
|
||||
)
|
||||
const simplifiedPoll = writable(false)
|
||||
|
||||
|
||||
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
|
||||
primaryScheduleStore.set(schedule)
|
||||
loadTriggers()
|
||||
@@ -1046,7 +1046,7 @@
|
||||
{#snippet content()}
|
||||
<div class="min-h-0 grow overflow-y-auto">
|
||||
<TabContent value="metadata">
|
||||
<div class="flex flex-col gap-8 px-4 py-2">
|
||||
<div class="flex flex-col gap-8 px-4 py-2 pb-12">
|
||||
<Section label="Metadata">
|
||||
{#snippet action()}
|
||||
{#if customUi?.settingsPanel?.metadata?.disableMute !== true}
|
||||
@@ -1214,7 +1214,7 @@
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="runtime">
|
||||
<div class="flex flex-col gap-8 px-4 py-2">
|
||||
<div class="flex flex-col gap-8 px-4 py-2 pb-12">
|
||||
<Section label="Worker group tag (queue)">
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
@@ -1759,7 +1759,6 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
{#if $enterpriseLicense && initialPath != ''}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import Modal from './common/modal/Modal.svelte'
|
||||
import DiffEditor from './DiffEditor.svelte'
|
||||
import {
|
||||
Clipboard,
|
||||
Copy,
|
||||
CornerDownLeft,
|
||||
ExternalLink,
|
||||
Github,
|
||||
@@ -556,7 +556,7 @@
|
||||
|
||||
<Button
|
||||
color="light"
|
||||
startIcon={{ icon: Clipboard }}
|
||||
startIcon={{ icon: Copy }}
|
||||
iconOnly
|
||||
on:click={() => copyToClipboard(collabUrl())}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { AlertTriangle, ChevronRight } from 'lucide-svelte'
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { slide } from 'svelte/transition'
|
||||
import EEOnly from './EEOnly.svelte'
|
||||
|
||||
interface Props {
|
||||
label?: string | undefined
|
||||
@@ -19,10 +20,13 @@
|
||||
animate?: boolean
|
||||
breakAll?: boolean
|
||||
class?: string | undefined
|
||||
description?: string | undefined
|
||||
initiallyCollapsed?: boolean
|
||||
header?: import('svelte').Snippet
|
||||
action?: import('svelte').Snippet
|
||||
badge?: import('svelte').Snippet
|
||||
children?: import('svelte').Snippet
|
||||
labelExtra?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -34,21 +38,24 @@
|
||||
wrapperClass = '',
|
||||
headerClass = '',
|
||||
collapsable = false,
|
||||
collapsed = $bindable(true),
|
||||
initiallyCollapsed = true,
|
||||
collapsed = $bindable(initiallyCollapsed),
|
||||
headless = false,
|
||||
animate = false,
|
||||
breakAll = false,
|
||||
class: clazz = undefined,
|
||||
description = undefined,
|
||||
header,
|
||||
action,
|
||||
badge,
|
||||
children
|
||||
children,
|
||||
labelExtra
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class={twMerge('w-full flex flex-col', wrapperClass)}>
|
||||
{#if !headless}
|
||||
<div class="flex flex-row justify-between items-center mb-2">
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
<h2
|
||||
class={twMerge(
|
||||
'text-emphasis flex flex-row items-center gap-1',
|
||||
@@ -59,18 +66,16 @@
|
||||
>
|
||||
{#if collapsable}
|
||||
<button class="flex items-center gap-1" onclick={() => (collapsed = !collapsed)}>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
class={twMerge(
|
||||
'transition',
|
||||
collapsed ? '' : 'rotate-90',
|
||||
animate ? 'duration-200' : 'duration-0'
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
{@render labelExtra?.()}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
class={twMerge('transition duration-200', collapsed ? '' : 'rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
{label}
|
||||
{@render labelExtra?.()}
|
||||
{/if}
|
||||
|
||||
{@render header?.()}
|
||||
@@ -79,10 +84,7 @@
|
||||
{/if}
|
||||
{#if eeOnly}
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap ml-8">
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</div>
|
||||
<EEOnly />
|
||||
{/if}
|
||||
{/if}
|
||||
</h2>
|
||||
@@ -94,10 +96,17 @@
|
||||
{/if}
|
||||
{#if !collapsable || !collapsed}
|
||||
<div
|
||||
class={twMerge('grow min-h-0', clazz)}
|
||||
transition:slide={animate ? { duration: 200 } : { duration: 0 }}
|
||||
class={'grow min-h-0 '}
|
||||
transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if description}
|
||||
<div class="text-xs text-primary mt-1">{description}</div>
|
||||
{/if}
|
||||
<div class={twMerge('flex flex-col gap-6 h-full', description ? 'mt-4' : 'mt-2')}>
|
||||
<div class={twMerge('grow min-h-0', clazz)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-svelte'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import EEOnly from './EEOnly.svelte'
|
||||
|
||||
interface Props {
|
||||
label?: string | undefined
|
||||
@@ -62,10 +63,7 @@
|
||||
{/if}
|
||||
{#if eeOnly}
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap ml-8">
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</div>
|
||||
<EEOnly />
|
||||
{/if}
|
||||
{/if}
|
||||
</h3>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
|
||||
interface Props {
|
||||
tags: string[]
|
||||
maxVisible?: number
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
let { tags, maxVisible = undefined, class: clazz = undefined }: Props = $props()
|
||||
|
||||
const visibleTags = $derived(maxVisible ? tags.slice(0, maxVisible) : tags)
|
||||
const extraTags = $derived(maxVisible ? tags.slice(maxVisible) : [])
|
||||
const hasExtraTags = $derived(extraTags.length > 0)
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="flex items-center gap-1 min-w-0 {clazz} w-full">
|
||||
<!-- Display visible tags -->
|
||||
{#each visibleTags as tag (tag)}
|
||||
<Badge color="blue" small wrapperClass="shrink min-w-0" class="truncate" title={tag}>
|
||||
<span class="min-w-0 truncate">{tag}</span>
|
||||
</Badge>
|
||||
{/each}
|
||||
|
||||
<!-- Display +n badge with popover for extra tags -->
|
||||
{#if hasExtraTags}
|
||||
<Popover
|
||||
floatingConfig={{
|
||||
strategy: 'absolute',
|
||||
placement: 'bottom-start'
|
||||
}}
|
||||
contentClasses="border border-light rounded-lg shadow-lg p-4 surface-tertiary max-w-xs"
|
||||
openOnHover
|
||||
debounceDelay={150}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Badge color="blue" small clickable>
|
||||
+{extraTags.length}
|
||||
</Badge>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each extraTags as tag (tag)}
|
||||
<Badge color="blue" verySmall class="max-w-20 truncate" title={tag}>{tag}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-secondary text-xs">No tags</span>
|
||||
{/if}
|
||||
@@ -1,37 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { defaultTags, nativeTags } from './worker_group'
|
||||
import { safeSelectItems } from './select/utils.svelte'
|
||||
import MultiSelect from './select/MultiSelect.svelte'
|
||||
import { superadmin } from '$lib/stores'
|
||||
import { superadmin, devopsRole } from '$lib/stores'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { SettingService, WorkerService } from '$lib/gen'
|
||||
import { CUSTOM_TAGS_SETTING } from '$lib/consts'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
type Props = {
|
||||
worker_tags: string[]
|
||||
customTags: string[] | undefined
|
||||
disabled?: boolean
|
||||
class?: string
|
||||
}
|
||||
let {
|
||||
worker_tags = $bindable([]),
|
||||
customTags = $bindable([]),
|
||||
disabled: _disabled = $bindable(false)
|
||||
disabled: _disabled = $bindable(false),
|
||||
class: clazz = ''
|
||||
}: Props = $props()
|
||||
|
||||
let disabled = $derived(_disabled || !$superadmin)
|
||||
let disabled = $derived(_disabled || !($superadmin || $devopsRole))
|
||||
|
||||
let multiSelect = $state<MultiSelect<{ label?: string; value: any }> | undefined>(undefined)
|
||||
|
||||
const searchText = $derived(multiSelect?.getFilteredInputText())
|
||||
|
||||
async function createCustomTag(tag: string) {
|
||||
const tagName = tag.trim().replaceAll(' ', '_')
|
||||
// optimistic update
|
||||
worker_tags = [...worker_tags, tagName]
|
||||
try {
|
||||
// Get current custom tags
|
||||
const currentCustomTags = await WorkerService.getCustomTags({
|
||||
showWorkspaceRestriction: Boolean($superadmin || $devopsRole)
|
||||
})
|
||||
|
||||
// Check if tag already exists
|
||||
if (currentCustomTags?.includes(tagName)) {
|
||||
sendUserToast('Tag already exists', false)
|
||||
return
|
||||
}
|
||||
|
||||
// Add new tag to the list
|
||||
await SettingService.setGlobal({
|
||||
key: CUSTOM_TAGS_SETTING,
|
||||
requestBody: { value: [...(currentCustomTags ?? []), tagName] }
|
||||
})
|
||||
|
||||
// Update local state if customTags is bound
|
||||
if (customTags) {
|
||||
customTags = [...customTags, tagName]
|
||||
}
|
||||
|
||||
sendUserToast('Custom tag created and added successfully')
|
||||
} catch (err) {
|
||||
// rollback optimistic update
|
||||
worker_tags = worker_tags.filter((t) => t !== tagName)
|
||||
sendUserToast(`Could not create custom tag: ${err}`, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MultiSelect
|
||||
items={safeSelectItems([...(customTags ?? []), ...worker_tags, ...defaultTags, ...nativeTags])}
|
||||
bind:value={
|
||||
() => worker_tags,
|
||||
(w) => ((worker_tags = w.map((s) => s.replaceAll(' ', '_'))), dispatch('dirty'))
|
||||
}
|
||||
bind:this={multiSelect}
|
||||
bind:value={() => worker_tags, (w) => (worker_tags = w.map((s) => s.replaceAll(' ', '_')))}
|
||||
{disabled}
|
||||
class={disabled ? 'border-0' : ''}
|
||||
class={twMerge(disabled ? 'border-0' : '', clazz)}
|
||||
allowClear={!disabled}
|
||||
onCreateItem={(c) => {
|
||||
worker_tags.push(c)
|
||||
dispatch('dirty')
|
||||
}}
|
||||
createText="Press Enter to use this tag"
|
||||
onCreateItem={createCustomTag}
|
||||
createText={searchText ? `Create custom tag: ${searchText}` : 'Create custom tag'}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="shadow-lg max-w-sm break-words py-2 px-3 rounded-md text-xs font-normal text-primary bg-surface-secondary whitespace-normal text-left dark:border"
|
||||
class="shadow-lg max-w-sm break-words py-2 px-3 rounded-md text-xs font-normal text-primary bg-surface-secondary whitespace-normal text-left dark:border max-h-64 overflow-y-auto"
|
||||
>
|
||||
{#if markdownTooltip}
|
||||
<div class="prose-sm">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
|
||||
const bubble = createBubbler()
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { Clipboard } from 'lucide-svelte'
|
||||
import { Copy } from 'lucide-svelte'
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { copyToClipboard, isCodeInjection } from '../../../../utils'
|
||||
@@ -273,7 +273,7 @@
|
||||
btnClasses="!p-1"
|
||||
on:click={() => copyToClipboard(result)}
|
||||
>
|
||||
<Clipboard size={14} strokeWidth={2} />
|
||||
<Copy size={14} strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Alert } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Loader2, AlertTriangle } from 'lucide-svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { computeSecretUrl } from './appDeploy.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import EEOnly from '$lib/components/EEOnly.svelte'
|
||||
|
||||
let {
|
||||
policy,
|
||||
@@ -216,11 +217,9 @@
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore block_empty -->
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap mb-2">
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</div>
|
||||
<EEOnly />
|
||||
{/if}
|
||||
<Toggle
|
||||
on:change={({ detail }) => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Highlight } from 'svelte-highlight'
|
||||
import json from 'svelte-highlight/languages/json'
|
||||
import { Button } from '../../common'
|
||||
import { Clipboard } from 'lucide-svelte'
|
||||
import { Copy } from 'lucide-svelte'
|
||||
import { yaml } from 'svelte-highlight/languages'
|
||||
import YAML from 'yaml'
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
|
||||
@@ -45,7 +45,7 @@
|
||||
)}
|
||||
variant="accent"
|
||||
size="sm"
|
||||
startIcon={{ icon: Clipboard }}
|
||||
startIcon={{ icon: Copy }}
|
||||
btnClasses="absolute top-2 right-2 w-min z-20"
|
||||
iconOnly
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { AlertTriangle, GitBranch } from 'lucide-svelte'
|
||||
import { GitBranch } from 'lucide-svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import type { AppViewerContext } from '../../types'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
@@ -13,6 +13,7 @@
|
||||
import { resolveTheme } from './themeUtils'
|
||||
import ThemeCodePreview from './ThemeCodePreview.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import EEOnly from '$lib/components/EEOnly.svelte'
|
||||
const { app, appPath } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
let cssEditor: SimpleEditor | undefined = $state(undefined)
|
||||
@@ -50,10 +51,7 @@
|
||||
{#if $enterpriseLicense === undefined}
|
||||
<div bind:clientHeight={alertHeight} class="p-2 flex flex-row gap-2">
|
||||
<div class="flex flex-row items-center text-yellow-500 text-xs">
|
||||
<div class="flex items-center whitespace-nowrap">
|
||||
<AlertTriangle size={16} />
|
||||
EE only
|
||||
</div>
|
||||
<EEOnly />
|
||||
<Tooltip light>
|
||||
App CSS editor is an exclusive feature of the Enterprise Edition. You can
|
||||
experiment with this feature in the editor, but please note that the changes
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
(color.startsWith(ColorModifier)
|
||||
? hovers[color.replace(ColorModifier, '')]
|
||||
: hovers[color]),
|
||||
|
||||
rounded ? 'rounded-full px-2 py-1' : 'rounded-md px-2 py-0.5',
|
||||
verySmall ? 'px-0.5 py-0.5' : '',
|
||||
'flex flex-row gap-1 items-center',
|
||||
@@ -132,7 +133,7 @@
|
||||
class="inline-flex justify-center items-center whitespace-nowrap {wrapperClass}"
|
||||
>
|
||||
<svelte:element
|
||||
this={href ? 'a' : 'span'}
|
||||
this={href ? 'a' : clickable ? 'button' : 'span'}
|
||||
{href}
|
||||
{...rest}
|
||||
class={badgeClass}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
href?: string
|
||||
icon?: any
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
interface Props {
|
||||
id?: string
|
||||
@@ -95,6 +96,7 @@
|
||||
tooltip?: import('svelte').Snippet
|
||||
[key: string]: any
|
||||
dropdownOpen?: boolean
|
||||
dropdownWidth?: number | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -136,6 +138,7 @@
|
||||
tooltip,
|
||||
onClick,
|
||||
dropdownOpen = $bindable(false),
|
||||
dropdownWidth = undefined,
|
||||
...rest
|
||||
}: Props = $props()
|
||||
|
||||
@@ -146,7 +149,8 @@
|
||||
action: item.onClick ? (e) => item.onClick?.(e) : undefined,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled ?? false,
|
||||
href: item.href
|
||||
href: item.href,
|
||||
tooltip: item.tooltip
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -466,6 +470,7 @@
|
||||
on:close={() => dispatch('dropdownOpen', false)}
|
||||
bind:open={dropdownOpen}
|
||||
enableFlyTransition
|
||||
customWidth={dropdownWidth}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import CloseButton from '../CloseButton.svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import EEOnly from '$lib/components/EEOnly.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
|
||||
interface Props {
|
||||
aiId?: string | undefined
|
||||
@@ -16,6 +18,7 @@
|
||||
documentationLink?: string | undefined
|
||||
CloseIcon?: any | undefined
|
||||
fullScreen?: boolean
|
||||
eeOnly?: boolean
|
||||
actions?: import('svelte').Snippet
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
@@ -31,6 +34,7 @@
|
||||
documentationLink = undefined,
|
||||
CloseIcon = undefined,
|
||||
fullScreen = true,
|
||||
eeOnly = false,
|
||||
actions,
|
||||
children
|
||||
}: Props = $props()
|
||||
@@ -39,7 +43,7 @@
|
||||
</script>
|
||||
|
||||
<div class={classNames('flex flex-col divide-y', fullScreen ? 'h-screen max-h-screen' : 'h-full')}>
|
||||
<div class="flex justify-between w-full items-center px-4 py-2 gap-2">
|
||||
<div class="flex justify-between w-full items-center pl-2 pr-4 py-2 gap-2">
|
||||
<div class="flex items-center gap-2 w-full truncate">
|
||||
<div
|
||||
use:triggerableByAI={{
|
||||
@@ -58,6 +62,9 @@
|
||||
<Tooltip {documentationLink}>{tooltip}</Tooltip>
|
||||
{/if}</span
|
||||
>
|
||||
{#if eeOnly && !$enterpriseLicense}
|
||||
<EEOnly />
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions}
|
||||
<div class="flex gap-2 items-center justify-end shrink-0">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
Pen,
|
||||
Share,
|
||||
Trash,
|
||||
Clipboard
|
||||
Copy
|
||||
} from 'lucide-svelte'
|
||||
import { goto as gotoUrl } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
{
|
||||
displayName: 'Copy path',
|
||||
icon: Clipboard,
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
copyToClipboard(path)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
Calendar,
|
||||
Share,
|
||||
Archive,
|
||||
Clipboard,
|
||||
Copy,
|
||||
Eye,
|
||||
HistoryIcon
|
||||
} from 'lucide-svelte'
|
||||
@@ -211,7 +211,7 @@
|
||||
},
|
||||
{
|
||||
displayName: 'Copy path',
|
||||
icon: Clipboard,
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
copyToClipboard(path)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import { Clipboard } from 'lucide-svelte'
|
||||
import { Copy } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { inputSizeClasses } from '../text_input/TextInput.svelte'
|
||||
|
||||
@@ -33,5 +33,5 @@
|
||||
}}
|
||||
>
|
||||
<div class={twMerge('truncate whitespace-no-wrap grow text-xs')}>{content}</div>
|
||||
<Clipboard size={12} class="flex-shrink-0" />
|
||||
<Copy size={12} class="flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import { Clipboard } from 'lucide-svelte'
|
||||
import { Copy } from 'lucide-svelte'
|
||||
import Highlight from 'svelte-highlight'
|
||||
import type { LanguageType } from 'svelte-highlight/languages'
|
||||
|
||||
export let code: string
|
||||
export let language: LanguageType<string>
|
||||
export let disabled = false
|
||||
interface Props {
|
||||
code: string
|
||||
language: LanguageType<string>
|
||||
disabled?: boolean
|
||||
wrap?: boolean
|
||||
}
|
||||
|
||||
let { code, language, disabled = false, wrap = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex flex-col flex-1 border rounded-md relative"
|
||||
class="flex flex-col flex-1 border rounded-md relative bg-surface-input"
|
||||
class:cursor-not-allowed={disabled}
|
||||
on:click={(e) => {
|
||||
onclick={(e) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
@@ -23,9 +28,13 @@
|
||||
}}
|
||||
>
|
||||
<div class="absolute top-2 right-1 z-10 pointer-events-none">
|
||||
<Clipboard size={14} class="w-8 cursor-pointer pointer-events-auto" />
|
||||
<Copy size={14} class="w-8 cursor-pointer pointer-events-auto" />
|
||||
</div>
|
||||
<div class="p-2 overflow-auto w-full">
|
||||
<Highlight {language} {code} class="pointer-events-none" />
|
||||
<div class="p-2 w-full overflow-auto">
|
||||
<Highlight
|
||||
{language}
|
||||
{code}
|
||||
class="pointer-events-none {wrap ? 'whitespace-pre-wrap break-all pr-8' : ''}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="overflow-x-auto scrollbar-hidden flex items-center justify-between px-4 pt-1 pb-1 flex-nowrap"
|
||||
class="overflow-x-auto scrollbar-hidden flex items-center justify-between px-4 py-2 flex-nowrap"
|
||||
>
|
||||
{#if flowModuleValue}
|
||||
<span class="text-sm w-full mr-4">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
title="Worker Group is defined at the flow level"
|
||||
title="Worker group is defined at the flow level"
|
||||
class="w-full text-left items-center font-normal p-1 py-2 border text-xs rounded"
|
||||
onclick={() => selectionManager.selectId('settings-worker-group')}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
import { untrack, getContext } from 'svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
@@ -15,6 +14,7 @@
|
||||
import { getStepPropPicker } from '../previousResults'
|
||||
import { NEVER_TESTED_THIS_FAR } from '../models'
|
||||
import { validateRetryConfig } from '$lib/utils'
|
||||
import EEOnly from '$lib/components/EEOnly.svelte'
|
||||
|
||||
interface Props {
|
||||
flowModuleRetry: Retry | undefined
|
||||
@@ -270,10 +270,8 @@
|
||||
<div class="text-xs font-bold !mt-2">Randomization factor (percentage)</div>
|
||||
<div class="flex w-full gap-4">
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap">
|
||||
<AlertTriangle size={16} />
|
||||
EE only
|
||||
</div>{/if}
|
||||
<EEOnly />
|
||||
{/if}
|
||||
<input
|
||||
disabled={!$enterpriseLicense}
|
||||
type="range"
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
|
||||
import MetadataGen from '$lib/components/copilot/MetadataGen.svelte'
|
||||
import Badge from '$lib/components/Badge.svelte'
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
import AIFormSettings from '$lib/components/copilot/AIFormSettings.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { inputBaseClass, inputBorderClass } from '$lib/components/text_input/TextInput.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import DebounceLimit from '../DebounceLimit.svelte'
|
||||
import EEOnly from '$lib/components/EEOnly.svelte'
|
||||
|
||||
interface Props {
|
||||
noEditor: boolean
|
||||
@@ -386,12 +386,7 @@
|
||||
bind:errorHandlerMuted={flowStore.val.ws_error_handler_muted}
|
||||
/>
|
||||
{#if !$enterpriseLicense}
|
||||
<span
|
||||
class="inline-flex text-xs items-center gap-1 !text-yellow-500 whitespace-nowrap ml-8"
|
||||
>
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</span>
|
||||
<EEOnly />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -520,12 +515,7 @@
|
||||
}}
|
||||
/>
|
||||
{#if !$enterpriseLicense || isCloudHosted()}
|
||||
<span
|
||||
class="inline-flex absolute top-0 left-72 text-xs items-center gap-1 !text-yellow-500 whitespace-nowrap ml-8"
|
||||
>
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</span>
|
||||
<EEOnly />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Toggle>
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
export let disableFocusTrap: boolean = false
|
||||
export let escapeBehavior: EscapeBehaviorType = 'close'
|
||||
export let enableFlyTransition: boolean = false
|
||||
export let onKeyDown: (e: KeyboardEvent) => void = () => {}
|
||||
export let onClose: () => void = () => {}
|
||||
/**
|
||||
* If provided, the popover will only open if the click is on the element with the given id.
|
||||
*/
|
||||
export let targetId: string | undefined = undefined
|
||||
|
||||
let fullScreen = false
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -52,6 +58,7 @@
|
||||
|
||||
// Cleanup timers on component destruction
|
||||
import { onDestroy } from 'svelte'
|
||||
import type { MeltEventHandler } from '@melt-ui/svelte/internal/types'
|
||||
onDestroy(clearTimers)
|
||||
|
||||
const {
|
||||
@@ -67,6 +74,9 @@
|
||||
onOpenChange: ({ curr, next }) => {
|
||||
if (curr != next) {
|
||||
dispatch('openChange', next)
|
||||
if (!next) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
if (closeOnOtherPopoverOpen) {
|
||||
if (next) {
|
||||
@@ -135,8 +145,22 @@
|
||||
() => openOnHover && close(),
|
||||
debounceDelay
|
||||
)
|
||||
|
||||
const handleClick: MeltEventHandler<PointerEvent> = (event) => {
|
||||
if (targetId) {
|
||||
const target = event.detail.originalEvent.target as Element
|
||||
const targetElement = target.closest(`#${targetId}`)
|
||||
if (!targetElement) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => isOpen && onKeyDown(e)} />
|
||||
|
||||
<button
|
||||
class={$$props.class}
|
||||
use:melt={$trigger}
|
||||
@@ -161,6 +185,7 @@
|
||||
}
|
||||
}}
|
||||
data-popover
|
||||
on:m-click={handleClick}
|
||||
on:click
|
||||
>
|
||||
<slot name="trigger" {isOpen} />
|
||||
|
||||
@@ -79,11 +79,14 @@
|
||||
{#snippet tooltip()}
|
||||
Jobs waiting for a worker being available to be executed
|
||||
{/snippet}
|
||||
<div
|
||||
<button
|
||||
class={queue_count && ($queue_count ?? 0) > 0
|
||||
? 'bg-yellow-500 text-white rounded-full min-w-6 h-6 flex center-center'
|
||||
: ''}>{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}</div
|
||||
: ''}
|
||||
onclick={() => onJobsWaiting?.()}
|
||||
>
|
||||
{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}
|
||||
</button>
|
||||
<div class="truncate text-2xs !text-secondary mt-0.5">
|
||||
<Button variant="subtle" unifiedSize="md" on:click={() => onJobsWaiting?.()}>
|
||||
{#if success == 'waiting'}
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
allowClear = true,
|
||||
hideMainClearBtn = false,
|
||||
size = 'md',
|
||||
id,
|
||||
error = false,
|
||||
onOpen,
|
||||
groupBy,
|
||||
sortBy,
|
||||
@@ -56,6 +58,8 @@
|
||||
allowClear?: boolean
|
||||
hideMainClearBtn?: boolean
|
||||
size?: keyof typeof inputSizeClasses
|
||||
id?: string
|
||||
error?: boolean
|
||||
groupBy?: (item: Item) => string
|
||||
sortBy?: (a: Item, b: Item) => number
|
||||
onOpen?: () => void
|
||||
@@ -101,6 +105,10 @@
|
||||
filterText = ''
|
||||
value = []
|
||||
}
|
||||
|
||||
export function getFilteredInputText() {
|
||||
return filterText
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -109,7 +117,7 @@
|
||||
'flex items-center flex-wrap relative',
|
||||
inputBaseClass,
|
||||
inputSizeClasses[size],
|
||||
inputBorderClass({ forceFocus: open && !disabled }),
|
||||
inputBorderClass({ forceFocus: open && !disabled, error }),
|
||||
disabled ? 'pointer-events-none' : '',
|
||||
open && !disabled ? 'open' : '',
|
||||
disabled ? 'disabled' : '',
|
||||
@@ -118,6 +126,7 @@
|
||||
{style}
|
||||
onpointerup={() => (open = true)}
|
||||
use:clickOutside={{ onClickOutside: () => (open = false) }}
|
||||
{id}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
|
||||
@@ -1,105 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import { Clipboard } from 'lucide-svelte'
|
||||
import { Check, Link, X } from 'lucide-svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import { classes } from '../common/alert/model'
|
||||
import { shell } from 'svelte-highlight/languages'
|
||||
import CopyableCodeBlock from '../details/CopyableCodeBlock.svelte'
|
||||
|
||||
interface Props {
|
||||
token: string
|
||||
mcpUrl?: string
|
||||
title?: string
|
||||
onCopy?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
token,
|
||||
mcpUrl,
|
||||
title,
|
||||
onCopy
|
||||
}: Props = $props()
|
||||
|
||||
function handleCopyClick() {
|
||||
copyToClipboard(mcpUrl || token)
|
||||
onCopy?.()
|
||||
}
|
||||
let { token, mcpUrl, title, onClose }: Props = $props()
|
||||
|
||||
const displayTitle = $derived(
|
||||
title || (mcpUrl ? 'MCP URL Generated Successfully' : 'Token Created Successfully')
|
||||
title || (mcpUrl ? 'MCP URL generated successfully' : 'Token created successfully')
|
||||
)
|
||||
|
||||
const label = $derived(
|
||||
mcpUrl ? 'Your MCP Server URL' : 'Your Token'
|
||||
const info = $derived(
|
||||
`Make sure to copy your ${mcpUrl ? 'MCP Server URL' : 'personal access token'} now. You won\'t be able to see it again!`
|
||||
)
|
||||
|
||||
const info = $derived(`Make sure to copy your ${mcpUrl ? 'MCP Server URL' : 'personal access token'} now. You won\'t be able to see it again!`)
|
||||
const tokenOrUrl = $derived(mcpUrl ? mcpUrl : token)
|
||||
|
||||
const tokenOrUrl = $derived(
|
||||
mcpUrl ? mcpUrl : token
|
||||
)
|
||||
|
||||
const colorScheme = {
|
||||
gradient: 'from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20',
|
||||
border: 'border-blue-200 dark:border-blue-700',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-800',
|
||||
iconColor: 'text-blue-600 dark:text-blue-300',
|
||||
titleColor: 'text-blue-800 dark:text-blue-200',
|
||||
labelColor: 'text-blue-700 dark:text-blue-300',
|
||||
infoBg: 'bg-blue-50 dark:bg-blue-900/30',
|
||||
infoBorder: 'border-blue-200 dark:border-blue-600',
|
||||
infoText: 'text-blue-700 dark:text-blue-300'
|
||||
}
|
||||
// Use alert model for consistent styling with design system
|
||||
const alertStyles = classes.info
|
||||
</script>
|
||||
|
||||
<div class="border rounded-lg mb-6 p-4 bg-gradient-to-r {colorScheme.gradient} {colorScheme.border} shadow-sm">
|
||||
<!-- Use surface-tertiary for elevated content according to brand guidelines -->
|
||||
<div class="border bg-surface-tertiary rounded-lg mb-6 p-4 shadow-md relative">
|
||||
<!-- Close button in top-right corner -->
|
||||
{#if onClose}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="absolute top-2 right-2 p-1 text-secondary hover:text-primary surface-hover hover:surface-secondary rounded transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-8 h-8 {colorScheme.iconBg} rounded-full flex items-center justify-center mt-0.5">
|
||||
<!-- Icon with info alert styling -->
|
||||
<div
|
||||
class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-0.5 {alertStyles.iconClass}"
|
||||
>
|
||||
{#if mcpUrl}
|
||||
<svg class="w-4 h-4 {colorScheme.iconColor}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
<Link size={16} />
|
||||
{:else}
|
||||
<svg class="w-4 h-4 {colorScheme.iconColor}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<Check size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-semibold {colorScheme.titleColor} mb-2">
|
||||
|
||||
<div class="flex-1 min-w-0 pr-6">
|
||||
<!-- Page title typography according to brand guidelines -->
|
||||
<h4 class="text-sm font-semibold text-emphasis mb-2">
|
||||
{displayTitle}
|
||||
</h4>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label class="block text-xs font-medium {colorScheme.labelColor} mb-1 mt-4">
|
||||
{label}
|
||||
</label>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-md p-3 border {colorScheme.border}">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<code class="text-sm font-mono text-gray-800 dark:text-gray-200 break-all flex-1">
|
||||
{tokenOrUrl}
|
||||
</code>
|
||||
<button
|
||||
onclick={handleCopyClick}
|
||||
class="flex-shrink-0 p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Copy token"
|
||||
>
|
||||
<Clipboard size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<!-- Token display with proper surface and border styling -->
|
||||
<CopyableCodeBlock code={tokenOrUrl} language={shell} wrap />
|
||||
|
||||
<!-- Warning alert using existing Alert component -->
|
||||
<div class="mt-1">
|
||||
<Alert type="warning" title="Important" size="xs">
|
||||
{info}
|
||||
</Alert>
|
||||
{#if mcpUrl}
|
||||
<div class="{colorScheme.infoBg} rounded-md p-2 border {colorScheme.infoBorder}">
|
||||
<p class="text-xs {colorScheme.infoText}">
|
||||
<strong>Next steps:</strong> Use this URL in your MCP-compatible client (like Claude Desktop) to access your Windmill scripts and flows as tools.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
{#if mcpUrl}
|
||||
<!-- Additional info using alert info styling -->
|
||||
<div class="mt-1 {alertStyles.bgClass} rounded-md p-2">
|
||||
<p class="text-xs {alertStyles.descriptionClass}">
|
||||
<strong>Next steps:</strong> Use this URL in your MCP-compatible client (like Claude Desktop)
|
||||
to access your Windmill scripts and flows as tools.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
<span>Acked</span>
|
||||
|
||||
<Button
|
||||
color="green"
|
||||
variant="accent"
|
||||
startIcon={{ icon: CheckCircle2 }}
|
||||
size="xs2"
|
||||
unifiedSize="sm"
|
||||
disabled={numUnacknowledgedCriticalAlerts === 0}
|
||||
on:click={acknowledgeAll}
|
||||
title="Acknowledge all"
|
||||
@@ -123,9 +123,9 @@
|
||||
<div class="w-full flex justify-center items-center">
|
||||
{#if !acknowledged}
|
||||
<Button
|
||||
color="green"
|
||||
variant="accent"
|
||||
startIcon={{ icon: CheckCircle2 }}
|
||||
size="xs2"
|
||||
unifiedSize="sm"
|
||||
on:click={() => {
|
||||
if (id) acknowledgeAlert(id)
|
||||
}}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
HardHat,
|
||||
Boxes,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
@@ -382,7 +382,7 @@
|
||||
{
|
||||
label: 'Workers',
|
||||
href: `${base}/workers`,
|
||||
icon: Bot,
|
||||
icon: HardHat,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-workers',
|
||||
aiDescription: 'Button to navigate to workers'
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
type Props<UnderlyingInputElT extends 'input' | 'textarea'> = {
|
||||
inputProps?: UnderlyingInputElT extends 'input' ? HTMLInputAttributes : HTMLTextareaAttributes
|
||||
value?: string
|
||||
value?: string | number
|
||||
class?: string
|
||||
error?: string | boolean
|
||||
size?: ButtonType.UnifiedSize
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
} from '$lib/consts'
|
||||
import bash from 'svelte-highlight/languages/bash'
|
||||
import { Tabs, Tab, TabContent, Button } from '$lib/components/common'
|
||||
import { ArrowDownRight, ArrowUpRight, Clipboard } from 'lucide-svelte'
|
||||
import { ArrowDownRight, ArrowUpRight, Copy } from 'lucide-svelte'
|
||||
import { Highlight } from 'svelte-highlight'
|
||||
import { typescript } from 'svelte-highlight/languages'
|
||||
import ClipboardPanel from '../../details/ClipboardPanel.svelte'
|
||||
@@ -430,7 +430,7 @@ done`
|
||||
}}
|
||||
>
|
||||
<Highlight language={bash} code={curlCode()} />
|
||||
<Clipboard size={14} class="w-8 top-2 right-2 absolute cursor-pointer" />
|
||||
<Copy size={14} class="w-8 top-2 right-2 absolute cursor-pointer" />
|
||||
</div>
|
||||
{/key}
|
||||
{/key}
|
||||
@@ -452,7 +452,7 @@ done`
|
||||
}}
|
||||
>
|
||||
<Highlight language={typescript} code={fetchCode()} />
|
||||
<Clipboard size={14} class="w-8 top-2 right-2 absolute cursor-pointer" />
|
||||
<Copy size={14} class="w-8 top-2 right-2 absolute cursor-pointer" />
|
||||
</div>
|
||||
{/key}{/key}{/key}{/key}
|
||||
{/key}
|
||||
|
||||
@@ -1343,6 +1343,7 @@ export type Item = {
|
||||
hide?: boolean | undefined
|
||||
extra?: Snippet
|
||||
id?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export function isObjectTooBig(obj: any): boolean {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user