feat(frontend): offer Slack and Teams as variable expiration handlers

Gives the variable expiration tab the same no-code destinations the workspace
error handler has, so "tell #ops before this credential expires" no longer
requires writing a script.

A sibling component rather than a fourth mode of ErrorOrRecoveryHandler: that
one is shared with the workspace error handler and the three schedule handlers,
and its stripped-args list, hub path suffixes and Email tab are all
error-shaped. This reuses the pieces that are already generic
(SlackConnectionStatus, TeamsConnectionStatus, ChannelSelector, ScriptPicker,
SchemaForm) and leaves those five call sites untouched.

extra_args stops being an API/CLI-only escape hatch now that the built-ins write
their channel into it, so it joins the unsaved-changes comparison and the
discard path, normalised through the existing helper that drops the two
auto-managed keys.

The two hub ids are placeholders until the scripts are pushed to the hub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Do7wJTnL6MG6ngFpCGW4DG
This commit is contained in:
AlexRV12
2026-09-03 18:48:14 +02:00
co-authored by Claude Opus 5
parent 7cc348874a
commit a6a4b18240
4 changed files with 485 additions and 39 deletions
@@ -0,0 +1,429 @@
<script lang="ts" module>
import { hubPaths } from '$lib/hub'
/** Arguments the sweep always sends. Stripped from the extra-args form so an admin is only
* asked for what the dispatch cannot supply. */
export const variableExpirationHandlerArgs = [
'workspace_id',
'variable_path',
'description',
'value_expires_at',
'is_secret'
]
/** Built-ins are recognised by path suffix, not by hub id: the id is assigned when the
* script is pushed to the hub and differs per environment, while the slug is ours. */
export const slackVariableExpirationPathEnding = '/variable-expiration-handler-slack'
export const teamsVariableExpirationPathEnding = '/variable-expiration-handler-teams'
export type VariableExpirationHandlerType = 'slack' | 'teams' | 'custom'
export function getVariableExpirationHandlerType(
path: string | undefined
): VariableExpirationHandlerType {
if (!path?.startsWith('hub/')) {
return 'custom'
}
if (path.endsWith(slackVariableExpirationPathEnding)) {
return 'slack'
}
if (path.endsWith(teamsVariableExpirationPathEnding)) {
return 'teams'
}
return 'custom'
}
</script>
<script lang="ts">
import { Alert, Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import SlackConnectionStatus from '$lib/components/common/slack/SlackConnectionStatus.svelte'
import TeamsConnectionStatus from '$lib/components/common/teams/TeamsConnectionStatus.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import ChannelSelector from '$lib/components/ChannelSelector.svelte'
import MsTeamsIcon from '$lib/components/icons/MSTeamsIcon.svelte'
import type { Schema, SupportedLanguage } from '$lib/common'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { emptySchema, emptyString, sendUserToast, tryEvery } from '$lib/utils'
import {
FlowService,
JobService,
type Script,
ScriptService,
WorkspaceService,
type Flow
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { CircleCheck, CircleX, ExternalLink, Loader2, RotateCw } from 'lucide-svelte'
interface Props {
isEditable: boolean
handlerSelected: VariableExpirationHandlerType
handlerPath: string | undefined
handlerExtraArgs: Record<string, any>
handlerKind: 'flow' | 'script'
/** Href of the "create a handler" script template, already carrying its query string. */
customScriptTemplate: string
}
let {
isEditable,
handlerSelected = $bindable('custom'),
handlerPath = $bindable(),
handlerExtraArgs = $bindable(),
handlerKind = $bindable('script'),
customScriptTemplate
}: Props = $props()
const CHANNEL_KEY = 'channel'
const CHANNEL_NAME_KEY = 'channel_name'
let customHandlerSchema: Schema | undefined = $state()
let slackHandlerSchema: Schema | undefined = $state()
let workspaceConnectedToSlack: boolean | undefined = $state(undefined)
let workspaceConnectedToTeams: boolean | undefined = $state(undefined)
let slackTeamName: string | undefined = $state(undefined)
let teamsTeamName: string | undefined = $state(undefined)
let teamsTeamGuid: string | undefined = $state(undefined)
let testJob: { uuid: string; is_success: boolean; in_progress: boolean } | undefined = $state()
let isSlackHandler = $derived(getVariableExpirationHandlerType(handlerPath) === 'slack')
let isTeamsHandler = $derived(getVariableExpirationHandlerType(handlerPath) === 'teams')
async function loadSlackResources() {
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
workspaceConnectedToSlack =
!emptyString(settings.slack_name) && !emptyString(settings.slack_team_id)
slackTeamName = workspaceConnectedToSlack ? settings.slack_name : undefined
}
async function loadTeamsResources() {
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
workspaceConnectedToTeams =
!emptyString(settings.teams_team_name) && !emptyString(settings.teams_team_id)
teamsTeamName = workspaceConnectedToTeams ? settings.teams_team_name : undefined
teamsTeamGuid = workspaceConnectedToTeams ? settings.teams_team_guid : undefined
}
async function loadHandlerSchema(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 =
handlerKind === 'script'
? await ScriptService.getScriptByPath({ workspace: $workspaceStore!, path: p })
: await FlowService.getFlowByPath({ workspace: $workspaceStore!, path: p })
schema = scriptOrFlow.schema as Schema
}
if (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)
}
}
async function sendTestMessage() {
const submitted = await WorkspaceService.runVariableExpirationTestJob({
workspace: $workspaceStore!,
requestBody: {
handler_path: `${isSlackHandler || isTeamsHandler ? 'script' : handlerKind}/${handlerPath}`,
extra_args: handlerExtraArgs
}
})
testJob = { uuid: submitted.job_uuid!, in_progress: true, is_success: false }
tryEvery({
tryCode: async () => {
const result = await JobService.getCompletedJob({
workspace: $workspaceStore!,
id: testJob!.uuid
})
testJob!.in_progress = false
testJob!.is_success = result.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace: $workspaceStore!,
id: testJob!.uuid,
requestBody: { reason: 'Test message not sent after 10s' }
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 10000
})
}
$effect(() => {
if ($workspaceStore) {
loadSlackResources()
loadTeamsResources()
}
})
// The Slack built-in reads the workspace bot token as a resource. Derived from the selected
// handler rather than typed by the admin, so it is stripped before the unsaved-changes check.
$effect(() => {
handlerExtraArgs['slack'] = isSlackHandler ? '$res:f/slack_bot/bot_token' : undefined
})
// Remembered per destination so flipping between tabs to compare them does not discard a
// channel that was already picked.
let lastHandlerSelected: VariableExpirationHandlerType | undefined = $state(undefined)
let channelCache = $state({
slack: undefined as string | undefined,
teams: undefined as string | undefined
})
let pathCache: Partial<Record<VariableExpirationHandlerType, string | undefined>> = $state({})
$effect(() => {
if (lastHandlerSelected !== handlerSelected && lastHandlerSelected !== undefined) {
if (lastHandlerSelected !== 'custom') {
channelCache[lastHandlerSelected] = handlerExtraArgs[CHANNEL_KEY]
}
pathCache[lastHandlerSelected] = handlerPath
if (handlerSelected === 'custom') {
delete handlerExtraArgs[CHANNEL_KEY]
delete handlerExtraArgs[CHANNEL_NAME_KEY]
} else {
handlerExtraArgs[CHANNEL_KEY] = channelCache[handlerSelected] ?? ''
}
handlerPath = pathCache[handlerSelected]
testJob = undefined
}
lastHandlerSelected = handlerSelected
})
$effect(() => {
handlerPath &&
!isSlackHandler &&
!isTeamsHandler &&
loadHandlerSchema(handlerPath, variableExpirationHandlerArgs).then(
(schema) => (customHandlerSchema = schema)
)
})
$effect(() => {
handlerPath &&
isSlackHandler &&
loadHandlerSchema(handlerPath, [...variableExpirationHandlerArgs, 'slack']).then(
(schema) => (slackHandlerSchema = schema)
)
})
</script>
<div class="space-y-2">
<ToggleButtonGroup bind:selected={handlerSelected} disabled={!isEditable}>
{#snippet children({ item })}
<ToggleButton label="Slack" value="slack" {item} disabled={!isEditable} />
<ToggleButton label="Teams" value="teams" {item} disabled={!isEditable} />
<ToggleButton
label="Custom"
value="custom"
{item}
disabled={!isEditable}
tooltip="Run your own script or flow"
/>
{/snippet}
</ToggleButtonGroup>
<div class="flex flex-col gap-6 p-4 rounded-md shadow-sm bg-surface-tertiary">
{#if handlerSelected === 'custom'}
<div class="flex flex-row gap-2 items-center">
<ScriptPicker
disabled={!isEditable}
bind:scriptPath={handlerPath}
bind:itemKind={handlerKind}
allowRefresh={isEditable}
allowFlow
clearable
/>
{#if !handlerPath}
<Button
variant="default"
unifiedSize="sm"
btnClasses="whitespace-nowrap"
href={customScriptTemplate}
disabled={!isEditable}
target="_blank"
>
Create a handler
</Button>
{/if}
</div>
{#if handlerPath}
<div>
<p class="font-semibold text-xs 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?.properties && Object.keys(customHandlerSchema.properties).length === 0}
<div class="text-xs text-secondary">This handler takes no extra arguments</div>
{/if}
</div>
{/if}
{:else if handlerSelected === 'slack'}
<SlackConnectionStatus
isConnected={workspaceConnectedToSlack}
{slackTeamName}
mode="workspace"
onRefresh={loadSlackResources}
/>
{#if workspaceConnectedToSlack}
<Toggle
disabled={!isEditable}
checked={isSlackHandler}
options={{ right: 'Notify a Slack channel when a variable is about to expire' }}
on:change={async (e) => {
handlerPath = e.detail ? hubPaths.slackVariableExpirationHandler : undefined
}}
/>
{/if}
{#if workspaceConnectedToSlack && isSlackHandler}
<div class="flex flex-col gap-2">
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!isEditable}
schema={slackHandlerSchema}
hiddenArgs={['slack']}
schemaFieldTooltip={{
channel: 'Slack channel name without the "#" - example: "windmill-alerts"'
}}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
</div>
{:else if workspaceConnectedToSlack === undefined}
<Loader2 class="animate-spin" size={14} />
{/if}
{:else if handlerSelected === 'teams'}
<TeamsConnectionStatus
isConnected={workspaceConnectedToTeams}
{teamsTeamName}
mode="workspace"
onRefresh={loadTeamsResources}
/>
{#if workspaceConnectedToTeams}
<Toggle
disabled={!isEditable}
checked={isTeamsHandler}
options={{ right: 'Notify a Teams channel when a variable is about to expire' }}
on:change={async (e) => {
handlerPath = e.detail ? hubPaths.teamsVariableExpirationHandler : undefined
}}
/>
<div class="w-2/3 flex flex-col gap-2">
<div class="flex flex-row items-center gap-2">
<p class="text-xs text-emphasis font-semibold">Teams channel</p>
<MsTeamsIcon size={14} />
</div>
<ChannelSelector
containerClass="flex-grow"
minWidth="200px"
placeholder="Search Teams channels"
workspace={$workspaceStore}
teamId={teamsTeamGuid}
selectedChannel={handlerExtraArgs[CHANNEL_KEY]
? {
channel_id: handlerExtraArgs[CHANNEL_KEY],
channel_name: handlerExtraArgs[CHANNEL_NAME_KEY]
}
: undefined}
onSelectedChannelChange={(channel) => {
handlerExtraArgs[CHANNEL_KEY] = channel?.channel_id
handlerExtraArgs[CHANNEL_NAME_KEY] = channel?.channel_name
}}
onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)}
/>
</div>
{:else if workspaceConnectedToTeams === undefined}
<Loader2 class="animate-spin" size={14} />
{/if}
{/if}
{#if (isSlackHandler || isTeamsHandler) && isEditable}
<div class="flex flex-col gap-2">
<Button
disabled={emptyString(handlerExtraArgs[CHANNEL_KEY])}
wrapperClasses="w-fit"
variant="default"
unifiedSize="md"
on:click={sendTestMessage}
>
Send test message
</Button>
<p class="text-2xs text-secondary">
Runs the handler now against a placeholder variable, as g/variable_expiration_handler.
Save the handler first: the test runs what is stored.
</p>
{#if testJob !== undefined}
<div class="flex items-center gap-2 p-4 rounded-md bg-surface-tertiary">
<p class="text-normal text-2xs flex items-center gap-4">
{#if testJob.in_progress}
<RotateCw size={14} class="animate-spin" />
Sending message...
{:else if testJob.is_success}
<CircleCheck size={14} class="text-green-600" />
Message sent via Windmill job
{:else}
<CircleX size={14} class="text-red-700" />
Message not sent
{/if}
<a
target="_blank"
href={`${base}/run/${testJob.uuid}?workspace=${$workspaceStore}`}
class="inline-flex items-center gap-1"
>
{testJob.uuid}
<ExternalLink size={12} class="inline-block" />
</a>
</p>
</div>
{/if}
</div>
{/if}
{#if handlerSelected !== 'custom' && workspaceConnectedToSlack === false && handlerSelected === 'slack'}
<Alert type="info" title="Connect Slack first">
Connect this workspace to Slack to notify a channel without writing a script.
</Alert>
{/if}
</div>
</div>
+7
View File
@@ -59,6 +59,13 @@ type HubPaths = {
teamsErrorHandler: string
teamsRecoveryHandler: string
teamsSuccessHandler: string
// TODO(WIN-2332): the ids of these two are placeholders until the scripts are pushed to
// the hub, which assigns them. Nothing resolves them meanwhile, and the image build runs
// `windmill cache hubPaths.json` over every entry (Dockerfile), so it fails on an id the
// hub does not have. Replace both before merging; only the number matters, the slug is
// ours and is what the settings tab matches on.
slackVariableExpirationHandler: string
teamsVariableExpirationHandler: string
emailErrorHandler: string
cloneRepoToS3forGitRepoViewer: string
appReport: string
+2
View File
@@ -8,6 +8,8 @@
"teamsErrorHandler": "hub/19742/workspace-or-schedule-error-handler-teams",
"teamsRecoveryHandler": "hub/11593/schedule-recovery-handler-teams",
"teamsSuccessHandler": "hub/11596/schedule-success-handler-teams",
"slackVariableExpirationHandler": "hub/99999901/variable-expiration-handler-slack",
"teamsVariableExpirationHandler": "hub/99999902/variable-expiration-handler-teams",
"slackReport": "hub/28792/slack",
"discordReport": "hub/9085/discord",
"smtpReport": "hub/28242/smtp",
@@ -10,6 +10,10 @@
import DeployToSetting from '$lib/components/DeployToSetting.svelte'
import DevWorkspaceSetting from '$lib/components/DevWorkspaceSetting.svelte'
import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte'
import VariableExpirationHandler, {
getVariableExpirationHandlerType,
type VariableExpirationHandlerType
} from '$lib/components/VariableExpirationHandler.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
@@ -188,10 +192,12 @@
let variableExpirationPath: string | undefined = $state(undefined)
let variableExpirationItemKind: 'flow' | 'script' = $state('script')
let variableExpirationMutedOnUserPath: boolean = $state(false)
// Carried through saves rather than edited: `extra_args` is an API/CLI-only escape hatch
// for passing fixed arguments to the handler, and omitting it on a save that keeps the
// handler would erase it. Only a save that clears the handler drops it.
let variableExpirationExtraArgs: Record<string, any> | undefined = $state(undefined)
let variableExpirationSelected: VariableExpirationHandlerType = $state('custom')
// The built-in destinations write `channel` here, and the API and CLI can set arbitrary
// fixed arguments the tab never renders. Both have to survive a save that keeps the
// handler, since the endpoint rebuilds the setting from the request and an omitted
// `extra_args` erases it. Only a save that clears the handler drops it.
let variableExpirationExtraArgs: Record<string, any> = $state({})
let criticalAlertUIMuted: boolean | undefined = $state(undefined)
let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined)
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
@@ -249,6 +255,7 @@
let initialVariableExpirationPath: string | undefined = $state(undefined)
let initialVariableExpirationItemKind: 'flow' | 'script' = $state('script')
let initialVariableExpirationMutedOnUserPath: boolean = $state(false)
let initialVariableExpirationExtraArgs: Record<string, any> = $state({})
let s3ResourceSettings: S3ResourceSettings = $state({
resourceType: 's3',
@@ -324,11 +331,13 @@
initialVariableExpirationItemKind,
initialVariableExpirationPath
),
mutedOnUserPath: initialVariableExpirationMutedOnUserPath
mutedOnUserPath: initialVariableExpirationMutedOnUserPath,
extraArgs: normalizeHandlerExtraArgs(initialVariableExpirationExtraArgs)
},
{
handlerPath: variableExpirationFullPath(variableExpirationItemKind, variableExpirationPath),
mutedOnUserPath: variableExpirationMutedOnUserPath
mutedOnUserPath: variableExpirationMutedOnUserPath,
extraArgs: normalizeHandlerExtraArgs(variableExpirationExtraArgs)
}
)
})
@@ -693,7 +702,8 @@
: 'script'
variableExpirationPath = variableExpirationHandlerPath.split('/').slice(1).join('/')
variableExpirationMutedOnUserPath = variableExpirationHandler?.muted_on_user_path ?? false
variableExpirationExtraArgs = variableExpirationHandler?.extra_args
variableExpirationExtraArgs = variableExpirationHandler?.extra_args ?? {}
variableExpirationSelected = getVariableExpirationHandlerType(variableExpirationPath)
workspaceDefaultAppPath = settings.default_app
initialWorkspaceDefaultAppPath = settings.default_app
@@ -750,6 +760,7 @@
initialVariableExpirationPath = variableExpirationPath
initialVariableExpirationItemKind = variableExpirationItemKind
initialVariableExpirationMutedOnUserPath = variableExpirationMutedOnUserPath
initialVariableExpirationExtraArgs = clone(variableExpirationExtraArgs)
loadedSettings = true
}
@@ -925,11 +936,11 @@
async function editVariableExpirationHandler() {
const path = variableExpirationFullPath(variableExpirationItemKind, variableExpirationPath)
// Saving with no path clears the whole setting server-side, `extra_args` included, so
// the loaded copy has to go too: a handler picked afterwards without reloading the page
// would otherwise be persisted with the cleared one's arguments, which this tab does
// not display.
// the in-memory copy has to go too: a handler picked afterwards without reloading the
// page would otherwise be persisted with the cleared one's arguments, including any the
// API or CLI set that this tab never renders.
if (!path) {
variableExpirationExtraArgs = undefined
variableExpirationExtraArgs = {}
}
await WorkspaceService.editVariableExpirationHandler({
workspace: $workspaceStore!,
@@ -950,12 +961,15 @@
initialVariableExpirationPath = variableExpirationPath
initialVariableExpirationItemKind = variableExpirationItemKind
initialVariableExpirationMutedOnUserPath = variableExpirationMutedOnUserPath
initialVariableExpirationExtraArgs = clone(variableExpirationExtraArgs)
}
function discardVariableExpirationChanges() {
variableExpirationPath = initialVariableExpirationPath
variableExpirationItemKind = initialVariableExpirationItemKind
variableExpirationMutedOnUserPath = initialVariableExpirationMutedOnUserPath
variableExpirationExtraArgs = clone(initialVariableExpirationExtraArgs)
variableExpirationSelected = getVariableExpirationHandlerType(variableExpirationPath)
}
async function editSuccessHandler() {
@@ -1219,14 +1233,16 @@
initialVariableExpirationItemKind,
initialVariableExpirationPath
),
mutedOnUserPath: initialVariableExpirationMutedOnUserPath
mutedOnUserPath: initialVariableExpirationMutedOnUserPath,
extraArgs: normalizeHandlerExtraArgs(initialVariableExpirationExtraArgs)
},
modifiedValue: {
handlerPath: variableExpirationFullPath(
variableExpirationItemKind,
variableExpirationPath
),
mutedOnUserPath: variableExpirationMutedOnUserPath
mutedOnUserPath: variableExpirationMutedOnUserPath,
extraArgs: normalizeHandlerExtraArgs(variableExpirationExtraArgs)
}
}
case 'critical_alerts':
@@ -2120,26 +2136,22 @@ export async function main(
<Section label="Handler">
<div class="flex flex-col gap-6">
<div class="flex flex-row gap-2 items-center">
<!-- Bound rather than seeded through `initialPath`: the picker keeps its own
copy of the selection, so a Discard that only reset the page state would leave
the reverted path invisible. -->
<ScriptPicker
bind:scriptPath={variableExpirationPath}
bind:itemKind={variableExpirationItemKind}
allowRefresh
allowFlow
clearable
/>
<Button
variant="default"
href={`${base}/scripts/add?lang=bun#` +
encodeState({
path: 'f/variable_expiration_handler',
summary: 'Workspace Variable Expiration Handler',
description:
'Called an hour before the value of a variable in this workspace expires',
content: `//native
<!-- Bound rather than seeded through `initialPath`: the picker keeps its own
copy of the selection, so a Discard that only reset the page state would leave
the reverted path invisible. -->
<VariableExpirationHandler
isEditable={true}
bind:handlerSelected={variableExpirationSelected}
bind:handlerPath={variableExpirationPath}
bind:handlerKind={variableExpirationItemKind}
bind:handlerExtraArgs={variableExpirationExtraArgs}
customScriptTemplate={`${base}/scripts/add?lang=bun#` +
encodeState({
path: 'f/variable_expiration_handler',
summary: 'Workspace Variable Expiration Handler',
description:
'Called an hour before the value of a variable in this workspace expires',
content: `//native
// Workspace Variable Expiration Handler
// Called an hour before the value of a variable in this workspace expires, and
@@ -2161,12 +2173,8 @@ export async function main(
// variable — that is what re-arms this handler for the following rotation.
}
`
})}
target="_blank"
>
Create a handler
</Button>
</div>
})}
/>
<SettingCard class="gap-2">
<!-- `w-fit`: the trigger is a flex item of the card, so it would otherwise