Files
windmill/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte
hugocasa eb284dfabd feat: triggers suspended mode (#7297)
* first commit

* base

* ok

* remove print

* rm packe json

* fix discard

* fix

* update .sqlx

* update

* update ref

* jobs update

* nits

* update ref

* big

* update sqlx

* fix

* update

* nits and fix

* nits

* ok

* sqlx + fix import

* better

* nit

* suspended mode draft

* UI and http triggers

* nits

* done for http triggers

* feat: better retry/error handler check

* generalize to all triggers

* fix merge conflicts

* fix listener

* nits

* nit

* fix merge conflict

* update ee ref

---------

Co-authored-by: dieriba <dieriba.pro@gmail.com>
2025-12-08 16:20:49 +00:00

604 lines
18 KiB
Svelte

<script lang="ts" module>
export const errorHandlerArgs = [
'path',
'workspace_id',
'job_id',
'is_flow',
'schedule_path',
'error',
'error_started_at',
'failed_times',
'started_at',
'success_times',
'success_result',
'success_started_at',
'email',
'trigger_path'
]
export const slackErrorHandlerHubPathEnding = '/workspace-or-schedule-error-handler-slack'
</script>
<script lang="ts">
import { Alert, Button, Tab, Tabs, Badge } from '$lib/components/common'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ChannelSelector from '$lib/components/ChannelSelector.svelte'
import type { Schema, SupportedLanguage } from '$lib/common'
import { base } from '$lib/base'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import MsTeamsIcon from '$lib/components/icons/MSTeamsIcon.svelte'
import { emptySchema, emptyString, sendUserToast, tryEvery } from '$lib/utils'
import Description from '$lib/components/Description.svelte'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import {
FlowService,
JobService,
type Script,
ScriptService,
WorkspaceService,
type Flow
} from '$lib/gen'
import type { ErrorHandler } from '$lib/gen/types.gen'
import { inferArgs } from '$lib/infer'
import { hubBaseUrlStore } from '$lib/stores'
import { CheckCircle2, Loader2, RotateCw, XCircle } from 'lucide-svelte'
import { hubPaths } from '$lib/hub'
import { isCloudHosted } from '$lib/cloud'
const slackRecoveryHandler = hubPaths.slackRecoveryHandler
const slackHandlerScriptPath = hubPaths.slackErrorHandler
const slackSuccessHandler = hubPaths.slackSuccessHandler
const teamsRecoveryHandler = hubPaths.teamsRecoveryHandler
const teamsHandlerScriptPath = hubPaths.teamsErrorHandler
const teamsSuccessHandler = hubPaths.teamsSuccessHandler
interface Props {
errorOrRecovery: 'error' | 'recovery' | 'success'
isEditable: boolean
toggleText?: string
showScriptHelpText?: boolean
handlerSelected: ErrorHandler
handlerPath: string | undefined
handlerExtraArgs: Record<string, any>
customScriptTemplate: string
customHandlerKind?: 'flow' | 'script'
customTabTooltip?: import('svelte').Snippet
}
let {
errorOrRecovery,
isEditable,
toggleText = 'Enable',
showScriptHelpText = false,
handlerSelected = $bindable('custom'),
handlerPath = $bindable(),
handlerExtraArgs = $bindable(),
customScriptTemplate,
customHandlerKind = $bindable('script'),
customTabTooltip
}: Props = $props()
let customHandlerSchema: Schema | undefined = $state()
let slackHandlerSchema: Schema | undefined = $state()
let teams_team_name: string | undefined = $state(undefined)
let teams_team_id: string | undefined = $state(undefined)
let workspaceConnectedToSlack: boolean | undefined = $state(undefined)
let workspaceConnectedToTeams: boolean | undefined = $state(undefined)
let connectionTestJob: { uuid: string; is_success: boolean; in_progress: boolean } | undefined =
$state()
const EMAIL_RECIPIENTS_KEY = 'email_recipients'
const CHANNEL_KEY = 'channel'
async function loadSlackResources() {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
if (!emptyString(settings.slack_name) && !emptyString(settings.slack_team_id)) {
workspaceConnectedToSlack = true
} else {
workspaceConnectedToSlack = false
}
}
async function loadTeamsResources() {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
if (!emptyString(settings.teams_team_name) && !emptyString(settings.teams_team_id)) {
workspaceConnectedToTeams = true
} else {
workspaceConnectedToTeams = false
}
if (workspaceConnectedToTeams) {
teams_team_name = settings.teams_team_name
teams_team_id = settings.teams_team_id
}
}
async function sendMessage(channel: string, platform: 'teams' | 'slack'): Promise<void> {
const testJobFunction =
platform === 'slack'
? WorkspaceService.runSlackMessageTestJob
: WorkspaceService.runTeamsMessageTestJob
let submitted_job = await testJobFunction({
workspace: $workspaceStore!,
requestBody: {
hub_script_path: handlerPath,
channel: channel,
test_msg: `This is a notification to test the connection between ${platform} and Windmill workspace '${$workspaceStore!}'`
}
})
connectionTestJob = {
uuid: submitted_job.job_uuid!,
in_progress: true,
is_success: false
}
tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace: $workspaceStore!,
id: connectionTestJob!.uuid
})
connectionTestJob!.in_progress = false
connectionTestJob!.is_success = testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace: $workspaceStore!,
id: connectionTestJob!.uuid,
requestBody: {
reason: 'Slack message not sent after 10s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 10000
})
}
async function sendSlackMessage(channel: string): Promise<void> {
await sendMessage(channel, 'slack')
}
async function sendTeamsMessage(channel: string): Promise<void> {
await sendMessage(channel, 'teams')
}
async function loadHandlerScriptArgs(p: string, defaultArgs: string[] = []) {
try {
let schema: Schema | undefined = emptySchema()
if (p.startsWith('hub/')) {
const hubScript = await ScriptService.getHubScriptByPath({
path: p
})
if ((hubScript.schema as any)?.properties) {
schema = hubScript.schema as any
} else {
await inferArgs(hubScript.language as SupportedLanguage, hubScript.content ?? '', schema)
}
} else {
let scriptOrFlow: Script | Flow =
customHandlerKind === 'script'
? await ScriptService.getScriptByPath({ workspace: $workspaceStore!, path: p })
: await FlowService.getFlowByPath({ workspace: $workspaceStore!, path: p })
schema = scriptOrFlow.schema as Schema
}
if (schema && schema.properties) {
for (let key in schema.properties) {
if (defaultArgs.includes(key)) {
delete schema.properties[key]
}
}
return schema
}
} catch (err) {
sendUserToast(`Could not query handler schema: ${err}`, true)
}
}
function isSlackHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
if (errorOrRecovery == 'error') {
return scriptPath.startsWith('hub/') && scriptPath.endsWith(slackErrorHandlerHubPathEnding)
} else if (errorOrRecovery == 'recovery') {
return (
scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-recovery-handler-slack')
)
} else {
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-success-handler-slack')
}
}
function isTeamsHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
if (errorOrRecovery == 'error') {
return (
scriptPath.startsWith('hub/') &&
scriptPath.endsWith('/workspace-or-schedule-error-handler-teams')
)
} else if (errorOrRecovery == 'recovery') {
return (
scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-recovery-handler-teams')
)
} else {
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-success-handler-teams')
}
}
function isEmailHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/workspace-or-error-handler-email')
}
$effect(() => {
if ($workspaceStore) {
loadSlackResources()
loadTeamsResources()
}
})
$effect(() => {
if (handlerSelected === 'slack' && isSlackHandler(handlerPath)) {
handlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token'
} else {
handlerExtraArgs['slack'] = undefined
}
})
let lastHandlerSelected: ErrorHandler | undefined = $state(undefined)
let handlerCache = $state({
slack: undefined as string | undefined,
teams: undefined as string | undefined,
email: undefined as string[] | undefined
})
$effect(() => {
if (lastHandlerSelected !== handlerSelected && lastHandlerSelected !== undefined) {
if (lastHandlerSelected != 'custom') {
const key = lastHandlerSelected === 'email' ? EMAIL_RECIPIENTS_KEY : CHANNEL_KEY
handlerCache[lastHandlerSelected] = handlerExtraArgs[key]
}
if (handlerSelected === 'custom') {
handlerExtraArgs[CHANNEL_KEY] = ''
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = []
handlerPath = undefined
} else if (handlerSelected === 'email') {
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = handlerCache[handlerSelected] ?? []
} else {
handlerExtraArgs[CHANNEL_KEY] = handlerCache[handlerSelected] ?? ''
}
}
lastHandlerSelected = handlerSelected
})
$effect(() => {
handlerPath &&
!isSlackHandler(handlerPath) &&
!isTeamsHandler(handlerPath) &&
!isEmailHandler(handlerPath) &&
loadHandlerScriptArgs(handlerPath, errorHandlerArgs).then(
(schema) => (customHandlerSchema = schema)
)
})
$effect(() => {
handlerPath &&
isSlackHandler(handlerPath) &&
loadHandlerScriptArgs(handlerPath, [...errorHandlerArgs, 'slack']).then(
(schema) => (slackHandlerSchema = schema)
)
})
$effect(() => {
if (handlerSelected === 'email') {
handlerPath = hubPaths.emailErrorHandler
}
})
</script>
<div>
<Tabs bind:selected={handlerSelected} class="mt-2 mb-4">
<Tab value="slack" disabled={!isEditable} label="Slack" />
<Tab value="teams" disabled={!isEditable} label="Teams" />
<Tab value="email" disabled={!isEditable} label="Email" />
<Tab value="custom" disabled={!isEditable} label="Custom" extra={customTabTooltip} />
</Tabs>
</div>
{#if handlerSelected === 'custom'}
<div class="flex flex-row mb-6">
<ScriptPicker
disabled={!isEditable || !$enterpriseLicense}
kinds={['script', 'failure']}
allowFlow={true}
bind:scriptPath={handlerPath}
bind:itemKind={customHandlerKind}
allowRefresh={isEditable}
clearable
/>
{#if !handlerPath}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
size="xs"
href={customScriptTemplate}
disabled={!isEditable}
target="_blank"
>
Create from template
</Button>
{/if}
</div>
{#if showScriptHelpText}
<div class="text-2xs text-secondary">
Example of error handler scripts can be found on <a
target="_blank"
href="{$hubBaseUrlStore}/failures"
>
Windmill Hub</a
>
</div>
{/if}
{#if handlerPath}
<p class="font-semibold text-xs mt-6 mb-1">Extra arguments</p>
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!isEditable}
schema={customHandlerSchema}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{#if customHandlerSchema && customHandlerSchema.properties && Object.keys(customHandlerSchema.properties).length === 0}
<div class="text-xs texg-gray-700">This error handler takes no extra arguments</div>
{/if}
{/if}
{:else if handlerSelected === 'slack'}
<span class="w-full flex mb-3">
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isSlackHandler(handlerPath)}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = slackHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = slackRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = slackSuccessHandler
} else {
handlerPath = undefined
}
}}
/>
</span>
{#if workspaceConnectedToSlack}
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!$enterpriseLicense || !isSlackHandler(handlerPath)}
schema={slackHandlerSchema}
hiddenArgs={['slack']}
schemaFieldTooltip={{
channel: 'Slack channel name without the "#" - example: "windmill-alerts"'
}}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{:else if workspaceConnectedToSlack == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{#if $enterpriseLicense && isSlackHandler(handlerPath)}
{#if workspaceConnectedToSlack == false}
<Alert type="error" title="Workspace not connected to Slack">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
The workspace needs to be connected to Slack to use this feature. You can <a
target="_blank"
href="{base}/workspace_settings?tab=slack">configure it here</a
>.
</p>
<Button variant="default" on:click={loadSlackResources} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{:else}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
btnClasses="w-32 text-center whitespace-nowrap"
variant="default"
on:click={() => sendSlackMessage(handlerExtraArgs['channel'])}
unifiedSize="md">Send test message</Button
>
{#if connectionTestJob !== undefined}
<p class="text-normal text-2xs mt-1 gap-2">
{#if connectionTestJob.in_progress}
<RotateCw size={14} />
{:else if connectionTestJob.is_success}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Message sent via Windmill job
<a
target="_blank"
href={`${base}/run/${connectionTestJob.uuid}?workspace=${$workspaceStore}`}
>
{connectionTestJob.uuid}
</a>
</p>
{/if}
{/if}
{/if}
{:else if handlerSelected === 'teams'}
<span class="w-full flex mb-3">
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isTeamsHandler(handlerPath)}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = teamsHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = teamsRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = teamsSuccessHandler
} else {
handlerPath = undefined
}
}}
/>
</span>
{#if workspaceConnectedToTeams}
<div class="w-2/3 flex flex-col gap-2">
<div class="flex flex-row items-center gap-2">
<div class="pt-1 flex-shrink-0">
<MsTeamsIcon size={24} />
</div>
<p class="text-sm">Teams Channel</p>
</div>
<div class="flex flex-row gap-2 items-start">
<ChannelSelector
containerClass="flex-grow"
minWidth="200px"
placeholder="Search Teams channels"
teamId={teams_team_id}
selectedChannel={handlerExtraArgs['channel']
? {
channel_id: handlerExtraArgs['channel'],
channel_name: handlerExtraArgs['channel_name']
}
: undefined}
onSelectedChannelChange={(channel) => {
handlerExtraArgs['channel'] = channel?.channel_id
handlerExtraArgs['channel_name'] = channel?.channel_name
}}
onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)}
/>
</div>
</div>
<div class="flex flex-row gap-2 pb-4">
<p class="text-sm">
This workspace is connected to Team: <Badge color="blue" size="xs" class="mt-2"
>{teams_team_name}</Badge
>
</p>
<Tooltip>
Each workspace can only be connected to one Microsoft Teams team. You can configure it under <a
target="_blank"
href="{base}/workspace_settings?tab=teams">workspace settings</a
>.
</Tooltip>
</div>
{:else if workspaceConnectedToTeams == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{#if $enterpriseLicense && isTeamsHandler(handlerPath)}
{#if workspaceConnectedToTeams == false}
<Alert type="error" title="Workspace not connected to Teams">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
The workspace needs to be connected to Teams to use this feature. You can configure it
under <a target="_blank" href="{base}/workspace_settings?tab=teams"
>workspace settings</a
>.
</p>
<Button variant="default" on:click={loadTeamsResources} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{:else}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
btnClasses="w-32 text-center mt-2 whitespace-nowrap"
variant="default"
on:click={() => sendTeamsMessage(handlerExtraArgs['channel'] ?? '')}
size="xs">Send test message</Button
>
{#if connectionTestJob !== undefined}
<p class="text-normal text-2xs mt-1 gap-2">
{#if connectionTestJob.in_progress}
<RotateCw size={14} class="animate-spin" />
{:else if connectionTestJob.is_success}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Message sent via Windmill job
<a
target="_blank"
href={`${base}/run/${connectionTestJob.uuid}?workspace=${$workspaceStore}`}
>
{connectionTestJob.uuid}
</a>
</p>
{/if}
{/if}
{/if}
{:else if handlerSelected === 'email'}
{#if isCloudHosted()}
<Alert type="info" title="Email notifications are not available in Cloud">
Email notifications for trigger failures are only available in self-hosted Windmill instances.
</Alert>
{:else}
<div class="flex flex-col gap-4 my-4">
<Description>
Configure email addresses to receive notifications when jobs fail. This feature requires
SMTP to be configured.
</Description>
</div>
<div class="flex flex-col gap-2 my-4">
<MultiSelect
items={[] as { label: string; value: string }[]}
bind:value={
() => handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [],
(recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients)
}
placeholder="Enter email addresses..."
onCreateItem={(email) => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (!emailRegex.test(email)) {
sendUserToast('Invalid email format', true)
return
}
const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? []
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email]
}}
class="w-full"
/>
{#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0}
<span class="text-sm text-primary">
{handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[
EMAIL_RECIPIENTS_KEY
]?.length === 1
? ''
: 's'} configured
</span>
{/if}
</div>
{/if}
{/if}