mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 00:00:33 +00:00
fa668707c0
* feat: move alert config from config table to global_settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: rename alert setting to alert_job_queue_waiting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add CLI unit tests for pullInstanceConfigs/pushInstanceConfigs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt to merged main Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
572 lines
17 KiB
Svelte
572 lines
17 KiB
Svelte
<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, SettingService, 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 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 SettingService.getGlobal({ key: 'alert_job_queue_waiting' })
|
|
queueAlertConfig = (response as any)?.alerts || []
|
|
expandedTagRows = []
|
|
} catch (error) {
|
|
console.error('Failed to fetch config:', error)
|
|
}
|
|
}
|
|
|
|
async function fetchWorkerTags(): Promise<string[]> {
|
|
try {
|
|
const response = await ConfigService.listWorkerGroups()
|
|
const workerTagsSet = new Set<string>()
|
|
|
|
response.forEach((wg) => {
|
|
const config = wg.config as any
|
|
if (Array.isArray(config?.worker_tags)) {
|
|
config.worker_tags.forEach((tag: string) => 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 SettingService.setGlobal({
|
|
key: 'alert_job_queue_waiting',
|
|
requestBody: { value: { 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>
|