feat: add email workspace error handler if smtp is configured (#6267)

* detecting trigger kind on job error

* v0 done

* done

* updat .sqlx and add mgrations

* fix: set back vs code settings and unused

* fix unused import

* add feature flag

* update ref

* nits

* nits

* done

* update repo ref

* remove

* add isCloudHosted

* update sqlx

* big

* fix

* use extra args instead of new col

* nits

* update .sqllx

* update ref

* update email

* fix

* fix

* fix

* nits

* update script and use env base_internal_url

* update sqlx and fix query

* remove

* fix unused import

* update ref
This commit is contained in:
dieriba
2025-08-07 16:46:53 +02:00
committed by GitHub
parent 84f76eebf7
commit 9fbb1992e5
62 changed files with 1061 additions and 366 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n workspace_settings\n SET\n error_handler = NULL,\n error_handler_extra_args = NULL,\n error_handler_muted_on_cancel = NULL\n WHERE \n workspace_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "04f8d738b1073b8c58db0965e8fdcdb872dce2c1872359c1bebb553a29ba1637"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n nats_trigger\n SET\n nats_resource_path = $1,\n subjects = $2,\n stream_name = $3,\n consumer_name = $4,\n use_jetstream = $5,\n script_path = $6,\n path = $7,\n is_flow = $8,\n edited_by = $9,\n email = $10,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $13,\n error_handler_args = $14,\n retry = $15\n WHERE\n workspace_id = $11 AND path = $12\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"VarcharArray",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "0d8415a79d8312db4d46da46fdf348ab17dee7b8358b6719fa399511962bc3e9"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n workspace_settings\n SET\n error_handler = $1,\n error_handler_extra_args = $2,\n error_handler_muted_on_cancel = $3\n WHERE \n workspace_id = $4\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Json",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "2549197c6750bb1a20041b2d7c2654a788a32a9282ed314c2b3bcafe6550efca"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n websocket_trigger\n SET\n url = $1,\n script_path = $2,\n path = $3,\n is_flow = $4,\n filters = $5,\n initial_messages = $6,\n url_runnable_args = $7,\n edited_by = $8,\n email = $9,\n can_return_message = $10,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $13,\n error_handler_args = $14,\n retry = $15\n WHERE\n workspace_id = $11 AND path = $12\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"JsonbArray",
"JsonbArray",
"Jsonb",
"Varchar",
"Varchar",
"Bool",
"Text",
"Text",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "35b211d19e53da4b64b0bd097284de3236ab939e47a1fc2b15ffc9607b552f8d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n workspace_id,\n path,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n replication_slot_name,\n publication_name,\n postgres_resource_path,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\" \n FROM postgres_trigger\n WHERE workspace_id = $1",
"query": "\n SELECT \n workspace_id,\n path,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n replication_slot_name,\n publication_name,\n postgres_resource_path,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM \n postgres_trigger\n WHERE \n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -120,5 +120,5 @@
true
]
},
"hash": "bf1fba73d3109aae48bda524cfafdf7f5b9b0e56396aed1b83a2c235222e8983"
"hash": "3ebf788b86d6aed59b8c61c08f1d9323e6c66107a1b7b37dadbbb7b91339e9d5"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE kafka_trigger SET kafka_resource_path = $1, group_id = $2, topics = $3, script_path = $4, path = $5, is_flow = $6, edited_by = $7, email = $8, edited_at = now(), server_id = NULL, error = NULL, error_handler_path = $11, error_handler_args = $12, retry = $13\n WHERE workspace_id = $9 AND path = $10",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"VarcharArray",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "3f91433fc076f47dd3516b40c9337981c329ddc31132882f037eaf2709aa8805"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE websocket_trigger SET url = $1, script_path = $2, path = $3, is_flow = $4, filters = $5, initial_messages = $6, url_runnable_args = $7, edited_by = $8, email = $9, can_return_message = $10, edited_at = now(), server_id = NULL, error = NULL, error_handler_path = $13, error_handler_args = $14, retry = $15\n WHERE workspace_id = $11 AND path = $12",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"JsonbArray",
"JsonbArray",
"Jsonb",
"Varchar",
"Varchar",
"Bool",
"Text",
"Text",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "4479beaefc36afba093e5113bb330a87979623d69b94f274863318ed351a1dac"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM kafka_trigger WHERE workspace_id = $1 AND path = $2",
"query": "SELECT \n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM \n kafka_trigger \n WHERE \n workspace_id = $1 AND \n path = $2\n ",
"describe": {
"columns": [
{
@@ -121,5 +121,5 @@
true
]
},
"hash": "6489342f52a9e1e90466bad48e0a6079fcabd696b02d57e3dbe652d28b84f008"
"hash": "494d49e92dce7a1cd7074156704ccbbff020a2042409fc318e045852323cef5e"
}
@@ -223,7 +223,8 @@
"ui",
"postgres",
"sqs",
"gcp"
"gcp",
"mqtt"
]
}
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n script_path = $4,\n path = $5,\n is_flow = $6,\n edited_by = $7,\n email = $8,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $11,\n error_handler_args = $12,\n retry = $13\n WHERE\n workspace_id = $9 AND path = $10\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"VarcharArray",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "4bfa42153df81184e02ffc7ab6c8c591605211c070e52b2919cf9d7dede0ea16"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\" \n FROM kafka_trigger\n WHERE workspace_id = $1",
"query": "SELECT \n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM kafka_trigger\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -120,5 +120,5 @@
true
]
},
"hash": "8f511fd837a99fa37a95781e50f8a83eaf93651f82c36c9795637d3ad761d003"
"hash": "4c6fbed80ba44defe3d125c258bc3449b02925c8caf85d9203529471479fdde8"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE nats_trigger SET nats_resource_path = $1, subjects = $2, stream_name = $3, consumer_name = $4, use_jetstream = $5, script_path = $6, path = $7, is_flow = $8, edited_by = $9, email = $10, edited_at = now(), server_id = NULL, error = NULL, error_handler_path = $13, error_handler_args = $14, retry = $15\n WHERE workspace_id = $11 AND path = $12",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"VarcharArray",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "570a4684bbdb390e4652301aa8d2d09b6e09b258f748d48cf2d188465621ee04"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n workspace_id,\n path,\n nats_resource_path,\n subjects,\n stream_name,\n consumer_name,\n use_jetstream,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM nats_trigger\n WHERE workspace_id = $1",
"query": "\n SELECT \n workspace_id,\n path,\n nats_resource_path,\n subjects,\n stream_name,\n consumer_name,\n use_jetstream,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM \n nats_trigger\n WHERE \n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -132,5 +132,5 @@
true
]
},
"hash": "5af0299841a0313d0cc270e98b04b5b5968184ae53dc076b1a9bc76af35923da"
"hash": "711e665344873454428d84e35f300edb7e0ebad5de40ba5645c1ce3d4a6b84be"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, slack_team_id, teams_team_id, teams_team_name, slack_name, slack_command_script, teams_command_script, slack_email, auto_invite_domain, auto_invite_operator, auto_add, customer_id, plan, webhook, deploy_to, ai_config, error_handler, error_handler_extra_args, error_handler_muted_on_cancel, large_file_storage, ducklake, git_sync, deploy_ui, default_app, default_scripts, mute_critical_alerts, color, operator_settings, git_app_installations FROM workspace_settings WHERE workspace_id = $1",
"query": "\n SELECT \n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n auto_invite_domain,\n auto_invite_operator,\n auto_add,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n error_handler,\n error_handler_extra_args,\n error_handler_muted_on_cancel,\n large_file_storage,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations\n FROM \n workspace_settings\n WHERE \n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -186,5 +186,5 @@
false
]
},
"hash": "6e60068b582af46afbb93010355cb5e7cc038042a6fa7089f3f73cd137a63956"
"hash": "8960b73f0d3cbfa0729d24ecbe3f635592feee6a5724fdff662c6ac1f3c6ddc8"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n authentication_resource_path,\n script_path, \n is_flow, \n summary,\n description,\n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n authentication_method AS \"authentication_method: _\", \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM http_trigger\n WHERE workspace_id = $1\n ",
"query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n authentication_resource_path,\n script_path, \n is_flow, \n summary,\n description,\n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n authentication_method AS \"authentication_method: _\", \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM \n http_trigger\n WHERE \n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -183,5 +183,5 @@
true
]
},
"hash": "09f43081a8b253d059d9d4302daa139cee6f055cdbafc213b2f992667f8ed578"
"hash": "89be9b595a892c82f2a9f3cfa4da213e360ebe8827d5b4680d3c227fb8ddec82"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,\n created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,\n script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,\n flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,\n cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26,\n CASE WHEN $14::VARCHAR IS NOT NULL THEN 'schedule'::job_trigger_kind END,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id) \n values ($1, $32, $33, $34, $35, $36, $37, $2) \n ON CONFLICT (job_id) DO UPDATE SET email = $32, username = $33, is_admin = $34, is_operator = $35, folders = $36, groups = $37, workspace_id = $2\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 THEN now() END, $30, $31)",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,\n created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,\n script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,\n flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,\n cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id) \n values ($1, $32, $33, $34, $35, $36, $37, $2) \n ON CONFLICT (job_id) DO UPDATE SET email = $32, username = $33, is_admin = $34, is_operator = $35, folders = $36, groups = $37, workspace_id = $2\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 THEN now() END, $30, $31)",
"describe": {
"columns": [],
"parameters": {
@@ -98,10 +98,32 @@
"Bool",
"JsonbArray",
"TextArray",
"Uuid"
"Uuid",
{
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt"
]
}
}
}
]
},
"nullable": []
},
"hash": "b7c3a66c3831eb5d145ff00807badae57bef81be051f150df754fd1444d7356d"
"hash": "acfe583fe17604ba72ba4800b62a72de0a9de0d58ef8c28dd709adf3be021597"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET error_handler = $1, error_handler_extra_args = $2, error_handler_muted_on_cancel = $3 WHERE workspace_id = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Json",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "d21e5be1ac26db926b7196316dae0c9fa82a865b95bb55de5370e4ee34889ba3"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET error_handler = NULL, error_handler_extra_args = NULL WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d9308c0154e029f7568b1d021368ca07f4867d192aa58b3888ae532d1c040828"
}
+1 -1
View File
@@ -77,7 +77,7 @@ mcp = ["windmill-api/mcp"]
mqtt_trigger = ["windmill-api/mqtt_trigger"]
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
gcp_trigger = ["windmill-api/gcp_trigger"]
smtp = ["windmill-api/smtp", "windmill-common/smtp"]
smtp = ["windmill-api/smtp", "windmill-common/smtp", "windmill-queue/smtp"]
license = ["windmill-api/license"]
oauth2 = ["windmill-api/oauth2"]
zip = ["windmill-api/zip"]
+1 -1
View File
@@ -1 +1 @@
dbc87a4966118e5486226d1a1863e4d97ba82ecc
db17bc0b22a51192aeadd8a0eab44108b0725a5b
@@ -0,0 +1 @@
-- Add down migration script here
@@ -0,0 +1,2 @@
-- Add up migration script here
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'mqtt';
+9 -1
View File
@@ -2440,7 +2440,6 @@ paths:
$ref: "#/components/schemas/ScriptArgs"
error_handler_muted_on_cancel:
type: boolean
responses:
"200":
description: status
@@ -2449,6 +2448,7 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_large_file_storage_config:
post:
summary: edit large file storage settings
@@ -15202,6 +15202,14 @@ components:
- success
- duration_ms
ErrorHandler:
type: string
enum:
- custom
- slack
- teams
- email
NewSchedule:
type: object
properties:
+32 -34
View File
@@ -12,7 +12,6 @@ use crate::{
users::fetch_api_authed,
utils::{check_scopes, non_empty_str, ExpiringCacheEntry},
};
use anyhow::anyhow;
use axum::response::Response;
use axum::{
@@ -197,38 +196,37 @@ async fn list_triggers(
) -> error::JsonResult<Vec<HttpTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(Pagination { per_page: lst.per_page, page: lst.page });
let mut sqlb = SqlBuilder::select_from("http_trigger")
.fields(&[
"workspace_id",
"path",
"route_path",
"route_path_key",
"workspaced_route",
"wrap_body",
"raw_string",
"script_path",
"summary",
"description",
"is_flow",
"http_method",
"edited_by",
"email",
"edited_at",
"extra_perms",
"is_async",
"authentication_method",
"static_asset_config",
"is_static_website",
"authentication_resource_path",
"error_handler_path",
"error_handler_args",
"retry",
])
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
let mut sqlb = SqlBuilder::select_from("http_trigger");
sqlb.fields(&[
"workspace_id",
"path",
"route_path",
"route_path_key",
"workspaced_route",
"wrap_body",
"raw_string",
"script_path",
"summary",
"description",
"is_flow",
"http_method",
"edited_by",
"email",
"edited_at",
"extra_perms",
"is_async",
"authentication_method",
"static_asset_config",
"is_static_website",
"authentication_resource_path",
"error_handler_path",
"error_handler_args",
"retry",
])
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page);
if let Some(path) = lst.path {
sqlb.and_where_eq("script_path", "?".bind(&path));
}
@@ -391,7 +389,7 @@ async fn create_trigger_inner(
new_http_trigger.is_static_website,
new_http_trigger.error_handler_path,
new_http_trigger.error_handler_args as _,
new_http_trigger.retry as _,
new_http_trigger.retry as _
)
.execute(&mut *tx)
.await?;
+246 -1
View File
@@ -16,6 +16,7 @@ use http::{HeaderMap, HeaderName};
use itertools::Itertools;
use quick_cache::sync::Cache;
use serde_json::value::RawValue;
use serde_json::Value;
use sqlx::Pool;
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
@@ -24,6 +25,8 @@ use std::str::FromStr;
use std::time::Instant;
use tokio::io::AsyncReadExt;
use tower::ServiceBuilder;
#[cfg(feature = "smtp")]
use windmill_common::auth::is_super_admin_email;
use windmill_common::auth::TOKEN_PREFIX_LEN;
use windmill_common::error::JsonResult;
use windmill_common::flow_status::{JobResult, RestartedFrom};
@@ -33,6 +36,8 @@ use windmill_common::jobs::{
};
use windmill_common::utils::WarnAfterExt;
use windmill_common::worker::{Connection, CLOUD_HOSTED, TMP_DIR};
#[cfg(feature = "smtp")]
use windmill_common::{email_oss::send_email_html, server::load_smtp_config};
use windmill_common::scripts::PREVIEW_IS_CODEBASE_HASH;
use windmill_common::variables::get_workspace_key;
@@ -66,7 +71,9 @@ use tower_http::cors::{Any, CorsLayer};
use urlencoding::encode;
use windmill_audit::audit_oss::{audit_log, AuditAuthor};
use windmill_audit::ActionKind;
use windmill_common::worker::to_raw_value;
use windmill_common::{
cache,
db::UserDB,
@@ -85,7 +92,7 @@ use windmill_common::{
use windmill_common::{
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
get_script_info_for_hash, FlowVersionInfo, ScriptHashInfo, BASE_URL,
get_script_info_for_hash, utils::empty_as_none, FlowVersionInfo, ScriptHashInfo, BASE_URL,
};
use windmill_queue::{
cancel_job, get_result_and_success_by_id_from_flow, job_is_complete, push, PushArgs,
@@ -234,6 +241,10 @@ pub fn workspaced_service() -> Router {
)
.route("/run/dependencies", post(run_dependencies_job))
.route("/run/flow_dependencies", post(run_flow_dependencies_job))
.route(
"/send_email_with_instance_smtp",
post(send_email_with_instance_smtp),
)
}
pub fn workspace_unauthed_service() -> Router {
@@ -1037,6 +1048,240 @@ impl<'a> GetQuery<'a> {
}
}
#[cfg(all(feature = "smtp", feature = "enterprise"))]
async fn send_workspace_trigger_failure_email_notification(
db: &Pool<Postgres>,
w_id: &str,
job_id: &Uuid,
trigger_path: Option<&str>,
runnable_path: Option<&str>,
email_recipients: &Vec<String>,
error: &Value,
) -> Result<String, Error> {
let smtp_config = match load_smtp_config(db).await? {
Some(config) => config,
None => {
tracing::info!(
"SMTP not configured, skipping workspace trigger failure email notification"
);
return Err(anyhow::anyhow!(
"SMTP not configured, skipping workspace trigger failure email notification"
)
.into());
}
};
let runnable_path = runnable_path.as_deref().unwrap_or("Unknown");
let (trigger_kind, trigger_path) = if let Some(trigger_path) = trigger_path.as_deref() {
match trigger_path.split_once('/') {
Some((trigger_kind, trigger_path)) => {
tracing::debug!(
"Workspace trigger job {} is a {:?} trigger",
&job_id,
trigger_kind
);
(Some(trigger_kind), Some(trigger_path))
}
_ => (None, None),
}
} else {
(None, None)
};
let base_url = BASE_URL.read().await;
let job_url = format!("{}/run/{}?workspace={}", base_url, &job_id, w_id);
let trigger_kind_str = trigger_kind.unwrap_or("Unknown").to_string().to_uppercase();
let subject = format!(
"Windmill Job Failed: {} in workspace {}",
runnable_path, w_id
);
let error_details = serde_json::to_string_pretty(&error)
.unwrap_or_else(|_| format!("Unable to serialize error: {:?}", error));
let trigger_info = if trigger_kind.is_some() && trigger_path.is_some() {
format!(
r#"
<div class="section">
<span class="label">Trigger path:</span> {}
</div>
<div class="section">
<span class="label">Trigger Type:</span> {}
</div>"#,
trigger_path.unwrap(),
trigger_kind_str
)
} else {
String::new()
};
let email_title = if trigger_kind.is_some() {
format!("Windmill Trigger Job {} Failed", &job_id)
} else {
format!("Windmill Job {} Failed", &job_id)
};
let content = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; color: #333; }}
h1 {{ color: #c0392b; }}
.section {{ margin-bottom: 20px; }}
.label {{ font-weight: bold; }}
pre {{ background-color: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; }}
a.button {{
display: inline-block;
padding: 10px 15px;
margin-top: 10px;
color: #fff;
background-color: #2980b9;
text-decoration: none;
border-radius: 5px;
}}
</style>
</head>
<body>
<h1>{}</h1>
<div class="section">
<span class="label">Workspace:</span> {}
</div>
{}
<div class="section">
<span class="label">Script/Flow Path:</span> {}
</div>
<div class="section">
<span class="label">Job ID:</span> {}
</div>
<div class="section">
<span class="label">Error Details:</span>
<pre>{}</pre>
</div>
<a href="{}" class="button">View Job Details</a>
</body>
</html>
"#,
email_title,
w_id,
trigger_info,
runnable_path,
&job_id,
error_details,
job_url
);
if let Err(e) = send_email_html(
&subject,
&content,
email_recipients.to_owned(),
smtp_config,
None,
)
.await
{
let err_msg = format!(
"Failed to send workspace email notification for trigger failure (trigger kind: {}, job ID: {}): {}",
trigger_kind_str,
&job_id,
e
);
return Err(Error::internal_err(err_msg));
}
let success_msg = format!(
"Job ID '{}' failed. An email with error details has been sent to {:?}.",
&job_id, email_recipients
);
tracing::info!("{}", &success_msg);
Ok(success_msg)
}
#[derive(Debug, Serialize, Deserialize)]
struct SendEmail {
job_id: Uuid,
#[serde(default, deserialize_with = "empty_as_none")]
trigger_path: Option<String>,
#[serde(default, deserialize_with = "empty_as_none")]
runnable_path: Option<String>,
#[serde(default, deserialize_with = "empty_as_none")]
email_recipients: Option<Vec<String>>,
error: Value,
}
#[cfg(all(feature = "enterprise", feature = "smtp"))]
async fn send_email_with_instance_smtp(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(send_email): Json<SendEmail>,
) -> error::Result<Json<String>> {
use windmill_common::jobs::EMAIL_ERROR_HANDLER_USER_EMAIL;
if *CLOUD_HOSTED {
tracing::warn!(
"Workspace trigger failure email notification is not available for cloud hosted Windmill",
);
return Err(anyhow::anyhow!("Feature not supported in cloud hosted windmill").into());
}
if send_email.email_recipients.is_none() {
use windmill_common::utils::report_critical_error;
tracing::error!("No recipient to send the error");
report_critical_error(
"No recipient to send the error".to_string(),
db.clone(),
Some(&w_id),
None,
)
.await;
return Err(anyhow::anyhow!("No recipient to send the error").into());
}
if authed.email == EMAIL_ERROR_HANDLER_USER_EMAIL
|| is_super_admin_email(&db, &authed.email).await?
{
let resp = send_workspace_trigger_failure_email_notification(
&db,
&w_id,
&send_email.job_id,
send_email.trigger_path.as_deref(),
send_email.runnable_path.as_deref(),
&send_email.email_recipients.unwrap(),
&send_email.error,
)
.await?;
return Ok(Json(resp));
}
return Err(Error::NotAuthorized(
"Only super admin or whitelisted token can access email workspace error handler feature"
.to_string(),
));
}
#[cfg(not(all(feature = "enterprise", feature = "smtp")))]
async fn send_email_with_instance_smtp(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_send_email): Json<SendEmail>,
) -> error::Result<Json<String>> {
tracing::warn!("SMTP is not enabled, skipping workspace trigger failure email notification",);
return Err(anyhow::anyhow!("SMTP is not enabled").into());
}
#[cfg(all(feature = "enterprise", feature = "parquet"))]
async fn get_logs_from_store(
log_offset: i32,
+31 -30
View File
@@ -644,35 +644,34 @@ pub async fn list_mqtt_triggers(
) -> error::JsonResult<Vec<MqttTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(Pagination { per_page: lst.per_page, page: lst.page });
let mut sqlb = SqlBuilder::select_from("mqtt_trigger")
.fields(&[
"mqtt_resource_path",
"subscribe_topics",
"v3_config",
"v5_config",
"client_version",
"client_id",
"workspace_id",
"path",
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"server_id",
"last_server_ping",
"extra_perms",
"error",
"enabled",
"error_handler_path",
"error_handler_args",
"retry",
])
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
let mut sqlb = SqlBuilder::select_from("mqtt_trigger");
sqlb.fields(&[
"mqtt_resource_path",
"subscribe_topics",
"v3_config",
"v5_config",
"client_version",
"client_id",
"workspace_id",
"path",
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"server_id",
"last_server_ping",
"extra_perms",
"error",
"enabled",
"error_handler_path",
"error_handler_args",
"retry",
])
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page);
if let Some(path) = lst.path {
sqlb.and_where_eq("script_path", "?".bind(&path));
}
@@ -760,7 +759,9 @@ pub async fn update_mqtt_trigger(
Json(mqtt_trigger): Json<EditMqttTrigger>,
) -> error::Result<String> {
let workspace_path = path.to_path();
check_scopes(&authed, || format!("mqtt_triggers:write:{}", workspace_path))?;
check_scopes(&authed, || {
format!("mqtt_triggers:write:{}", workspace_path)
})?;
let EditMqttTrigger {
mqtt_resource_path,
@@ -374,7 +374,9 @@ pub async fn create_postgres_trigger(
Path(w_id): Path<String>,
Json(new_postgres_trigger): Json<NewPostgresTrigger>,
) -> Result<(StatusCode, String)> {
check_scopes(&authed, || format!("postgres_triggers:write:{}", new_postgres_trigger.path))?;
check_scopes(&authed, || {
format!("postgres_triggers:write:{}", new_postgres_trigger.path)
})?;
if *CLOUD_HOSTED {
return Err(error::Error::BadRequest(
@@ -515,32 +517,31 @@ pub async fn list_postgres_triggers(
) -> error::JsonResult<Vec<PostgresTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(Pagination { per_page: lst.per_page, page: lst.page });
let mut sqlb = SqlBuilder::select_from("postgres_trigger")
.fields(&[
"workspace_id",
"path",
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"server_id",
"last_server_ping",
"extra_perms",
"error",
"enabled",
"postgres_resource_path",
"replication_slot_name",
"publication_name",
"error_handler_path",
"error_handler_args",
"retry",
])
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
let mut sqlb = SqlBuilder::select_from("postgres_trigger");
sqlb.fields(&[
"workspace_id",
"path",
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"server_id",
"last_server_ping",
"extra_perms",
"error",
"enabled",
"postgres_resource_path",
"replication_slot_name",
"publication_name",
"error_handler_path",
"error_handler_args",
"retry",
])
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page);
if let Some(path) = lst.path {
sqlb.and_where_eq("script_path", "?".bind(&path));
}
@@ -1204,7 +1205,9 @@ pub async fn update_postgres_trigger(
Json(postgres_trigger): Json<EditPostgresTrigger>,
) -> Result<String> {
let workspace_path = path.to_path();
check_scopes(&authed, || format!("postgres_triggers:write:{}", workspace_path))?;
check_scopes(&authed, || {
format!("postgres_triggers:write:{}", workspace_path)
})?;
let EditPostgresTrigger {
replication_slot_name,
@@ -1298,7 +1301,7 @@ pub async fn update_postgres_trigger(
workspace_path,
error_handler_path,
error_handler_args as _,
retry as _,
retry as _
)
.execute(&mut *tx)
.await?;
+2 -2
View File
@@ -32,7 +32,7 @@ use serde::Deserialize;
use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel};
use windmill_common::error::to_anyhow;
use windmill_common::{
email_oss::send_email,
email_oss::send_email_plain_text,
error::{self, JsonResult, Result},
get_database_url,
global_settings::{
@@ -105,7 +105,7 @@ pub async fn test_email(
let to = test_email.to;
let client_timeout = Duration::from_secs(3);
send_email(
send_email_plain_text(
"Test email from Windmill",
"Test email content",
vec![to],
+120 -22
View File
@@ -24,7 +24,9 @@ use windmill_common::{
db::UserDB,
error::{self, to_anyhow, JsonResult},
triggers::TriggerKind,
utils::{not_found_if_none, paginate, report_critical_error, Pagination, StripPath},
utils::{
empty_as_none, not_found_if_none, paginate, report_critical_error, Pagination, StripPath,
},
worker::{to_raw_value, CLOUD_HOSTED},
INSTANCE_NAME,
};
@@ -64,6 +66,7 @@ struct NewWebsocketTrigger {
initial_messages: Option<Vec<Box<RawValue>>>,
url_runnable_args: Option<Box<RawValue>>,
can_return_message: bool,
#[serde(default, deserialize_with = "empty_as_none")]
error_handler_path: Option<String>,
error_handler_args: Option<SqlxJson<HashMap<String, Box<RawValue>>>>,
retry: Option<SqlxJson<windmill_common::flows::Retry>>,
@@ -152,13 +155,34 @@ async fn list_websocket_triggers(
) -> error::JsonResult<Vec<WebsocketTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(Pagination { per_page: lst.per_page, page: lst.page });
let mut sqlb = SqlBuilder::select_from("websocket_trigger")
.field("*")
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
let mut sqlb = SqlBuilder::select_from("websocket_trigger");
sqlb.fields(&[
"workspace_id",
"path",
"url",
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"server_id",
"last_server_ping",
"extra_perms",
"error",
"enabled",
"filters",
"initial_messages",
"url_runnable_args",
"can_return_message",
"error_handler_path",
"error_handler_args",
"retry",
])
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page);
if let Some(path) = lst.path {
sqlb.and_where_eq("script_path", "?".bind(&path));
}
@@ -188,14 +212,39 @@ async fn get_websocket_trigger(
check_scopes(&authed, || format!("websocket_triggers:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let trigger = sqlx::query_as::<_, WebsocketTrigger>(
r#"SELECT *
FROM websocket_trigger
WHERE workspace_id = $1 AND path = $2"#,
r#"
SELECT
workspace_id,
path,
url,
script_path,
is_flow,
edited_by,
email,
edited_at,
server_id,
last_server_ping,
extra_perms,
error,
enabled,
filters,
initial_messages,
url_runnable_args,
can_return_message,
error_handler_path,
error_handler_args,
retry,
FROM
websocket_trigger
WHERE
workspace_id = $1 AND path = $2
"#,
)
.bind(w_id)
.bind(path)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let trigger = not_found_if_none(trigger, "Trigger", path)?;
@@ -317,8 +366,29 @@ async fn update_websocket_trigger(
// important to update server_id to NULL to stop current websocket listener
sqlx::query!(
"UPDATE websocket_trigger SET url = $1, script_path = $2, path = $3, is_flow = $4, filters = $5, initial_messages = $6, url_runnable_args = $7, edited_by = $8, email = $9, can_return_message = $10, edited_at = now(), server_id = NULL, error = NULL, error_handler_path = $13, error_handler_args = $14, retry = $15
WHERE workspace_id = $11 AND path = $12",
"
UPDATE
websocket_trigger
SET
url = $1,
script_path = $2,
path = $3,
is_flow = $4,
filters = $5,
initial_messages = $6,
url_runnable_args = $7,
edited_by = $8,
email = $9,
can_return_message = $10,
edited_at = now(),
server_id = NULL,
error = NULL,
error_handler_path = $13,
error_handler_args = $14,
retry = $15
WHERE
workspace_id = $11 AND path = $12
",
ct.url,
ct.script_path,
ct.path,
@@ -333,9 +403,10 @@ async fn update_websocket_trigger(
path,
ct.error_handler_path,
ct.error_handler_args as _,
ct.retry as _,
ct.retry as _
)
.execute(&mut *tx).await?;
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
@@ -546,18 +617,45 @@ async fn listen_to_unlistened_websockets(
db: &DB,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
) {
match sqlx::query_as::<_, WebsocketTrigger>(
r#"SELECT *
FROM websocket_trigger
WHERE enabled IS TRUE AND (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')"#
let websocket_triggers = sqlx::query_as::<_, WebsocketTrigger>(
r#"
SELECT
workspace_id,
path,
url,
script_path,
is_flow,
edited_by,
email,
edited_at,
server_id,
last_server_ping,
extra_perms,
error,
enabled,
filters,
initial_messages,
url_runnable_args,
can_return_message,
error_handler_path,
error_handler_args,
retry
FROM websocket_trigger
WHERE
enabled IS TRUE
AND (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')
"#,
)
.fetch_all(db)
.await
{
.await;
match websocket_triggers {
Ok(mut triggers) => {
triggers.shuffle(&mut rand::rng());
for trigger in triggers {
trigger.maybe_listen_to_websocket(db.clone(), killpill_rx.resubscribe()).await;
trigger
.maybe_listen_to_websocket(db.clone(), killpill_rx.resubscribe())
.await;
}
}
Err(err) => {
+94 -6
View File
@@ -381,6 +381,10 @@ pub struct EditErrorHandler {
pub error_handler_muted_on_cancel: Option<bool>,
}
lazy_static::lazy_static! {
pub static ref EMAIL_REGEXP: Regex = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
}
async fn list_pending_invites(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -457,15 +461,51 @@ async fn get_settings(
let mut tx = user_db.begin(&authed).await?;
let settings = sqlx::query_as!(
WorkspaceSettings,
"SELECT workspace_id, slack_team_id, teams_team_id, teams_team_name, slack_name, slack_command_script, teams_command_script, slack_email, auto_invite_domain, auto_invite_operator, auto_add, customer_id, plan, webhook, deploy_to, ai_config, error_handler, error_handler_extra_args, error_handler_muted_on_cancel, large_file_storage, ducklake, git_sync, deploy_ui, default_app, default_scripts, mute_critical_alerts, color, operator_settings, git_app_installations FROM workspace_settings WHERE workspace_id = $1",
r#"
SELECT
workspace_id,
slack_team_id,
teams_team_id,
teams_team_name,
slack_name,
slack_command_script,
teams_command_script,
slack_email,
auto_invite_domain,
auto_invite_operator,
auto_add,
customer_id,
plan,
webhook,
deploy_to,
ai_config,
error_handler,
error_handler_extra_args,
error_handler_muted_on_cancel,
large_file_storage,
ducklake,
git_sync,
deploy_ui,
default_app,
default_scripts,
mute_critical_alerts,
color,
operator_settings,
git_app_installations
FROM
workspace_settings
WHERE
workspace_id = $1
"#,
&w_id
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
tx.commit().await?;
let settings = not_found_if_none(settings, "workspace settings", &w_id)?;
tx.commit().await?;
let settings = not_found_if_none(settings, "workspace settings", &w_id)?;
Ok(Json(settings))
}
@@ -1645,8 +1685,46 @@ async fn edit_error_handler(
.await?;
if let Some(error_handler) = &ee.error_handler {
match ee.error_handler_extra_args.as_ref() {
Some(extra_args) if extra_args.is_object() => {
let Ok(email_recipients) = serde_json::from_value::<Option<Vec<String>>>(
extra_args["email_recipients"].to_owned(),
) else {
return Err(Error::BadRequest(
"Field `email_recipients` expected to be JSON array".to_string(),
));
};
if let Some(email_recipients) = email_recipients {
for email in email_recipients {
if !EMAIL_REGEXP.is_match(&email) {
return Err(Error::BadRequest(format!(
"Invalid email format: {}",
email
)));
}
}
}
}
None => {}
_ => {
return Err(Error::BadRequest(
"Field `error_handler_extra_args` expected to be JSON object".to_string(),
))
}
}
sqlx::query!(
"UPDATE workspace_settings SET error_handler = $1, error_handler_extra_args = $2, error_handler_muted_on_cancel = $3 WHERE workspace_id = $4",
r#"
UPDATE
workspace_settings
SET
error_handler = $1,
error_handler_extra_args = $2,
error_handler_muted_on_cancel = $3
WHERE
workspace_id = $4
"#,
error_handler,
ee.error_handler_extra_args,
ee.error_handler_muted_on_cancel.unwrap_or(false),
@@ -1656,12 +1734,22 @@ async fn edit_error_handler(
.await?;
} else {
sqlx::query!(
"UPDATE workspace_settings SET error_handler = NULL, error_handler_extra_args = NULL WHERE workspace_id = $1",
&w_id,
r#"
UPDATE
workspace_settings
SET
error_handler = NULL,
error_handler_extra_args = NULL,
error_handler_muted_on_cancel = NULL
WHERE
workspace_id = $1
"#,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
+20 -10
View File
@@ -564,8 +564,10 @@ pub(crate) async fn tarball_workspace(
error_handler_path,
error_handler_args as "error_handler_args: _",
retry as "retry: _"
FROM http_trigger
WHERE workspace_id = $1
FROM
http_trigger
WHERE
workspace_id = $1
"#,
&w_id
)
@@ -649,7 +651,7 @@ pub(crate) async fn tarball_workspace(
enabled,
error_handler_path,
error_handler_args as "error_handler_args: _",
retry as "retry: _"
retry as "retry: _"
FROM kafka_trigger
WHERE workspace_id = $1"#,
&w_id
@@ -760,7 +762,8 @@ pub(crate) async fn tarball_workspace(
{
let nats_triggers = sqlx::query_as!(
crate::nats_triggers_oss::NatsTrigger,
r#"SELECT
r#"
SELECT
workspace_id,
path,
nats_resource_path,
@@ -781,8 +784,11 @@ pub(crate) async fn tarball_workspace(
error_handler_path,
error_handler_args as "error_handler_args: _",
retry as "retry: _"
FROM nats_trigger
WHERE workspace_id = $1"#,
FROM
nats_trigger
WHERE
workspace_id = $1
"#,
&w_id
)
.fetch_all(&mut *tx)
@@ -801,7 +807,8 @@ pub(crate) async fn tarball_workspace(
{
let postgres_triggers = sqlx::query_as!(
crate::postgres_triggers::PostgresTrigger,
r#"SELECT
r#"
SELECT
workspace_id,
path,
script_path,
@@ -819,9 +826,12 @@ pub(crate) async fn tarball_workspace(
postgres_resource_path,
error_handler_path,
error_handler_args as "error_handler_args: _",
retry as "retry: _"
FROM postgres_trigger
WHERE workspace_id = $1"#,
retry as "retry: _"
FROM
postgres_trigger
WHERE
workspace_id = $1
"#,
&w_id
)
.fetch_all(&mut *tx)
@@ -14,8 +14,6 @@ use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use crate::{jwt::decode_without_verify, worker::HttpClient};
lazy_static! {
pub static ref BASE_INTERNAL_URL: String =
std::env::var("BASE_INTERNAL_URL").unwrap_or("http://localhost:8080".to_string());
pub static ref AGENT_TOKEN: String = std::env::var("AGENT_TOKEN").unwrap_or_default();
pub static ref DECODED_AGENT_TOKEN: Option<AgentAuth> = {
if AGENT_TOKEN.is_empty() {
+22
View File
@@ -15,3 +15,25 @@ pub async fn send_email(
) -> crate::error::Result<()> {
Ok(())
}
#[cfg(not(feature = "private"))]
pub async fn send_email_html(
_subject: &str,
_content: &str,
_to: Vec<String>,
_smtp: Smtp,
_client_timeout: Option<tokio::time::Duration>,
) -> crate::error::Result<()> {
Ok(())
}
#[cfg(not(feature = "private"))]
pub async fn send_email_plain_text(
_subject: &str,
_content: &str,
_to: Vec<String>,
_smtp: Smtp,
_client_timeout: Option<tokio::time::Duration>,
) -> crate::error::Result<()> {
Ok(())
}
+2
View File
@@ -12,6 +12,8 @@ use uuid::Uuid;
pub const ENTRYPOINT_OVERRIDE: &str = "_ENTRYPOINT_OVERRIDE";
pub const LARGE_LOG_THRESHOLD_SIZE: usize = 9000;
pub const EMAIL_ERROR_HANDLER_USER_EMAIL: &str = "email_error_handler@windmill.dev";
use crate::{
apps::AppScriptId,
auth::is_super_admin_email,
+1
View File
@@ -135,6 +135,7 @@ lazy_static::lazy_static! {
pub static ref BASE_URL: Arc<RwLock<String>> = Arc::new(RwLock::new("".to_string()));
pub static ref IS_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub static ref BASE_INTERNAL_URL: String = std::env::var("BASE_INTERNAL_URL").unwrap_or("http://localhost:8000".to_string());
pub static ref HUB_BASE_URL: Arc<RwLock<String>> = Arc::new(RwLock::new(DEFAULT_HUB_BASE_URL.to_string()));
+1
View File
@@ -49,6 +49,7 @@ use std::sync::atomic::Ordering;
use crate::worker::CLOUD_HOSTED;
lazy_static::lazy_static! {
pub static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new()
.user_agent("windmill/beta")
.timeout(std::time::Duration::from_secs(20))
+1 -7
View File
@@ -26,13 +26,7 @@ use uuid::Uuid;
use windmill_macros::annotations;
use crate::{
agent_workers::{PingJobStatusResponse, BASE_INTERNAL_URL},
cache::{unwrap_or_error, RawNode, RawScript},
error::{self, to_anyhow},
global_settings::CUSTOM_TAGS_SETTING,
indexer::TantivyIndexerSettings,
server::Smtp,
KillpillSender, DB,
agent_workers::PingJobStatusResponse, cache::{unwrap_or_error, RawNode, RawScript}, error::{self, to_anyhow}, global_settings::CUSTOM_TAGS_SETTING, indexer::TantivyIndexerSettings, server::Smtp, KillpillSender, BASE_INTERNAL_URL, DB
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
+1
View File
@@ -15,6 +15,7 @@ enterprise = ["windmill-common/enterprise"]
cloud = []
benchmark = ["windmill-common/benchmark"]
prometheus = ["dep:prometheus"]
smtp = []
[dependencies]
windmill-audit.workspace = true
+139 -27
View File
@@ -33,8 +33,11 @@ use windmill_common::add_time;
use windmill_common::auth::JobPerms;
#[cfg(feature = "benchmark")]
use windmill_common::bench::BenchmarkIter;
use windmill_common::jobs::EMAIL_ERROR_HANDLER_USER_EMAIL;
use windmill_common::utils::now_from_db;
use windmill_common::worker::{Connection, SCRIPT_TOKEN_EXPIRY};
#[cfg(feature = "enterprise")]
use windmill_common::BASE_URL;
use windmill_common::{
auth::{fetch_authed_from_permissioned_as, permissioned_as_to_username},
cache::{self, FlowData},
@@ -63,16 +66,12 @@ use windmill_common::{
use backon::ConstantBuilder;
use backon::{BackoffBuilder, Retryable};
#[cfg(feature = "enterprise")]
use windmill_common::BASE_URL;
#[cfg(feature = "cloud")]
use windmill_common::users::SUPERADMIN_SYNC_EMAIL;
use crate::flow_status::{update_flow_status_in_progress, update_workflow_as_code_status};
use crate::jobs_oss::update_concurrency_counter;
use crate::schedule::{get_schedule_opt, push_scheduled_job};
use crate::tags::per_workspace_tag;
#[cfg(feature = "cloud")]
use windmill_common::users::SUPERADMIN_SYNC_EMAIL;
#[cfg(feature = "prometheus")]
lazy_static::lazy_static! {
@@ -121,6 +120,7 @@ const MAX_FREE_CONCURRENT_RUNS: i32 = 30;
const ERROR_HANDLER_USERNAME: &str = "error_handler";
const SCHEDULE_ERROR_HANDLER_USERNAME: &str = "schedule_error_handler";
const GLOBAL_ERROR_HANDLER_USERNAME: &str = "global";
#[cfg(feature = "enterprise")]
const SCHEDULE_RECOVERY_HANDLER_USERNAME: &str = "schedule_recovery_handler";
const ERROR_HANDLER_USER_GROUP: &str = "g/error_handler";
@@ -1360,6 +1360,7 @@ async fn apply_completed_job_error_handlers<T: Serialize + Send + Sync + Validab
"Sending error of job {} to error handlers (if any)",
queued_job.id
);
if let Err(e) = send_error_to_global_handler(&queued_job, db, Json(&result)).await {
tracing::error!(
"Could not run global error handler for job {}: {}",
@@ -1378,11 +1379,16 @@ async fn apply_completed_job_error_handlers<T: Serialize + Send + Sync + Validab
{
match err {
Error::QuotaExceeded(_) => {}
_ => {
err => {
tracing::error!(
"Could not run workspace error handler for job {}: {}",
&queued_job.id,
err
);
let base_url = BASE_URL.read().await;
let w_id: &String = &queued_job.workspace_id;
report_critical_error(format!(
"Could not push workspace error handler for failed job ({base_url}/run/{}?workspace={w_id}): {}",
"Failed to push workspace error handler job to handle failed job ({base_url}/run/{}?workspace={w_id}): {}",
queued_job.id,
err
), db.clone(), Some(&w_id), None)
@@ -1485,8 +1491,16 @@ pub async fn report_error_to_workspace_handler_or_critical_side_channel(
error_message: String,
) -> () {
let w_id = &queued_job.workspace_id;
let (error_handler, error_handler_extra_args) = sqlx::query_as::<_, (Option<String>, Option<Json<Box<RawValue>>>)>(
"SELECT error_handler, error_handler_extra_args FROM workspace_settings WHERE workspace_id = $1",
let row_result = sqlx::query_as::<_, (Option<String>, Option<Json<Box<RawValue>>>)>(
r#"
SELECT
error_handler,
error_handler_extra_args
FROM
workspace_settings
WHERE
workspace_id = $1
"#,
)
.bind(&w_id)
.fetch_optional(db)
@@ -1495,6 +1509,8 @@ pub async fn report_error_to_workspace_handler_or_critical_side_channel(
.flatten()
.unwrap_or((None, None));
let (error_handler, error_handler_extra_args) = row_result;
if let Some(error_handler) = error_handler {
if let Err(err) = push_error_handler(
db,
@@ -1538,8 +1554,18 @@ pub async fn send_error_to_workspace_handler<'a, 'c, T: Serialize + Send + Sync>
result: Json<&'a T>,
) -> Result<(), Error> {
let w_id = &queued_job.workspace_id;
let (error_handler, error_handler_extra_args, error_handler_muted_on_cancel) = sqlx::query_as::<_, (Option<String>, Option<Json<Box<RawValue>>>, bool)>(
"SELECT error_handler, error_handler_extra_args, error_handler_muted_on_cancel FROM workspace_settings WHERE workspace_id = $1",
let row_result = sqlx::query_as::<_, (Option<String>, Option<Json<Box<RawValue>>>, bool)>(
r#"
SELECT
error_handler,
error_handler_extra_args,
error_handler_muted_on_cancel
FROM
workspace_settings
WHERE
workspace_id = $1
"#,
)
.bind(&w_id)
.fetch_optional(db)
@@ -1547,6 +1573,8 @@ pub async fn send_error_to_workspace_handler<'a, 'c, T: Serialize + Send + Sync>
.context("fetching error handler info from workspace_settings")?
.ok_or_else(|| Error::internal_err(format!("no workspace settings for id {w_id}")))?;
let (error_handler, error_handler_extra_args, error_handler_muted_on_cancel) = row_result;
if is_canceled && error_handler_muted_on_cancel {
return Ok(());
}
@@ -1598,7 +1626,6 @@ pub async fn send_error_to_workspace_handler<'a, 'c, T: Serialize + Send + Sync>
.await?;
}
}
Ok(())
}
@@ -1846,6 +1873,71 @@ async fn apply_schedule_handlers<'a, 'c, T: Serialize + Send + Sync>(
Ok(())
}
pub const ERROR_HANDLER_PATH_TEAMS: &str = "/workspace-or-schedule-error-handler-teams";
pub const ERROR_HANDLER_PATH_SLACK: &str = "/workspace-or-schedule-error-handler-slack";
pub const ERROR_HANDLER_PATH_EMAIL: &str = "/workspace-or-error-handler-email";
enum ErrorHandlerType {
Custom,
Teams,
Slack,
Email,
}
impl ErrorHandlerType {
fn from_error_handler_path(error_handler_path: &str) -> Option<ErrorHandlerType> {
let error_handler_path = if error_handler_path.starts_with("script/") {
error_handler_path.strip_prefix("script/").unwrap()
} else if error_handler_path.starts_with("flow/") {
error_handler_path.strip_prefix("flow/").unwrap()
} else {
error_handler_path
};
if let Some(from_hub) = error_handler_path.strip_prefix("hub/") {
let handler_type = if from_hub.ends_with(ERROR_HANDLER_PATH_TEAMS) {
ErrorHandlerType::Teams
} else if from_hub.ends_with(ERROR_HANDLER_PATH_SLACK) {
ErrorHandlerType::Slack
} else if from_hub.ends_with(ERROR_HANDLER_PATH_EMAIL) {
ErrorHandlerType::Email
} else {
return None;
};
return Some(handler_type);
}
Some(ErrorHandlerType::Custom)
}
}
fn get_email_and_permissioned_as(
error_handler_path: &str,
is_global_error_handler: bool,
is_schedule_error_handler: bool,
) -> (&'static str, String) {
let res = if is_global_error_handler {
(SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SECRET_EMAIL.to_string())
} else if is_schedule_error_handler {
(
SCHEDULE_ERROR_HANDLER_USER_EMAIL,
ERROR_HANDLER_USER_GROUP.to_string(),
)
} else {
let handler_type = ErrorHandlerType::from_error_handler_path(error_handler_path);
let email = match handler_type {
Some(ErrorHandlerType::Email) => EMAIL_ERROR_HANDLER_USER_EMAIL,
_ => ERROR_HANDLER_USER_EMAIL,
};
(email, ERROR_HANDLER_USER_GROUP.to_string())
};
res
}
pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>(
db: &Pool<Postgres>,
job_id: Uuid,
@@ -1881,6 +1973,7 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>(
extra.insert("is_flow".to_string(), to_raw_value(&is_flow));
extra.insert("started_at".to_string(), to_raw_value(&started_at));
extra.insert("email".to_string(), to_raw_value(&email));
if let Some(failed_times) = failed_times {
extra.insert("failed_times".to_string(), to_raw_value(&failed_times));
}
@@ -1902,17 +1995,11 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>(
on_behalf_of.email.as_str(),
on_behalf_of.permissioned_as.clone(),
)
} else if is_global_error_handler {
(SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SECRET_EMAIL.to_string())
} else if is_schedule_error_handler {
(
SCHEDULE_ERROR_HANDLER_USER_EMAIL,
ERROR_HANDLER_USER_GROUP.to_string(),
)
} else {
(
ERROR_HANDLER_USER_EMAIL,
ERROR_HANDLER_USER_GROUP.to_string(),
get_email_and_permissioned_as(
on_failure_path,
is_global_error_handler,
is_schedule_error_handler,
)
};
@@ -1924,7 +2011,7 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>(
payload,
PushArgs { extra: Some(extra), args: &result },
if is_global_error_handler {
"global"
GLOBAL_ERROR_HANDLER_USERNAME
} else if is_schedule_error_handler {
SCHEDULE_ERROR_HANDLER_USERNAME
} else {
@@ -2172,6 +2259,25 @@ pub enum JobTriggerKind {
Gcp,
}
impl std::fmt::Display for JobTriggerKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let kind = match self {
JobTriggerKind::Webhook => "webhook",
JobTriggerKind::Http => "http",
JobTriggerKind::Websocket => "websocket",
JobTriggerKind::Kafka => "kafka",
JobTriggerKind::Email => "email",
JobTriggerKind::Nats => "nats",
JobTriggerKind::Mqtt => "mqtt",
JobTriggerKind::Sqs => "sqs",
JobTriggerKind::Postgres => "postgres",
JobTriggerKind::Schedule => "schedule",
JobTriggerKind::Gcp => "gcp",
};
write!(f, "{}", kind)
}
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
pub struct MiniPulledJob {
pub workspace_id: String,
@@ -4532,6 +4638,12 @@ pub async fn push<'c, 'd>(
// tracing::error!("Could not insert job_perms for job {job_id}: {err:#}");
// }
let trigger_kind = if schedule_path.is_some() {
Some(JobTriggerKind::Schedule)
} else {
None
};
sqlx::query!(
"WITH inserted_job AS (
INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,
@@ -4540,8 +4652,7 @@ pub async fn push<'c, 'd>(
flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,
cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20, $38, $21, $22, $23, $24, $25, $26,
CASE WHEN $14::VARCHAR IS NOT NULL THEN 'schedule'::job_trigger_kind END,
$19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,
($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)
),
inserted_runtime AS (
@@ -4596,7 +4707,8 @@ pub async fn push<'c, 'd>(
job_authed.is_operator,
folders.as_slice(),
job_authed.groups.as_slice(),
root_job.or(parent_job)
root_job.or(parent_job),
trigger_kind as Option<JobTriggerKind>,
)
.execute(&mut *tx)
.warn_after_seconds(1)
@@ -10,6 +10,8 @@
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,
@@ -18,17 +20,17 @@
WorkspaceService,
type Flow
} from '$lib/gen'
import type { ListAvailableTeamsChannelsResponse } from '$lib/gen/types.gen'
import type { ErrorHandler, ListAvailableTeamsChannelsResponse } from '$lib/gen/types.gen'
import { inferArgs } from '$lib/infer'
import { hubBaseUrlStore } from '$lib/stores'
import { CheckCircle2, Loader2, RotateCw, XCircle, RefreshCcw } 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
@@ -38,7 +40,7 @@
isEditable: boolean
toggleText?: string
showScriptHelpText?: boolean
handlerSelected: 'custom' | 'slack' | 'teams'
handlerSelected: ErrorHandler
handlerPath: string | undefined
handlerExtraArgs: Record<string, any>
customScriptTemplate: string
@@ -51,18 +53,17 @@
isEditable,
toggleText = 'Enable',
showScriptHelpText = false,
handlerSelected = $bindable(),
handlerSelected = $bindable('custom'),
handlerPath = $bindable(),
handlerExtraArgs = $bindable(),
customScriptTemplate,
customHandlerKind = $bindable('script'),
customTabTooltip
customTabTooltip,
}: Props = $props()
let customHandlerSchema: Schema | undefined = $state()
let slackHandlerSchema: Schema | undefined = $state()
let isFetching: boolean = $state(false)
let teams_channels: ListAvailableTeamsChannelsResponse = $state([])
let teams_team_name: string | undefined = $state(undefined)
@@ -71,6 +72,8 @@
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! })
@@ -222,6 +225,14 @@
}
}
function isEmailHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/workspace-or-error-handler-email')
}
$effect(() => {
if ($workspaceStore) {
loadSlackResources()
@@ -237,22 +248,27 @@
}
})
let lastHandlerSelected: 'slack' | 'teams' | 'custom' | undefined = $state(undefined)
let channelCache = $state({
let lastHandlerSelected: ErrorHandler | undefined = $state(undefined)
let handlerCache = $state({
slack: undefined as string | undefined,
teams: undefined as string | undefined
teams: undefined as string | undefined,
email: undefined as string[] | undefined
})
$effect(() => {
if (lastHandlerSelected !== handlerSelected && lastHandlerSelected !== undefined) {
if (lastHandlerSelected === 'teams' || lastHandlerSelected === 'slack') {
channelCache[lastHandlerSelected] = handlerExtraArgs['channel']
if (lastHandlerSelected != 'custom') {
const key = lastHandlerSelected === 'email' ? EMAIL_RECIPIENTS_KEY : CHANNEL_KEY
handlerCache[lastHandlerSelected] = handlerExtraArgs[key]
}
if (handlerSelected === 'custom') {
handlerExtraArgs['channel'] = ''
handlerExtraArgs[CHANNEL_KEY] = ''
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = []
handlerPath = undefined
} else if (handlerSelected === 'email') {
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = handlerCache[handlerSelected] ?? []
} else {
handlerExtraArgs['channel'] = channelCache[handlerSelected] ?? ''
handlerExtraArgs[CHANNEL_KEY] = handlerCache[handlerSelected] ?? ''
}
}
@@ -263,6 +279,7 @@
handlerPath &&
!isSlackHandler(handlerPath) &&
!isTeamsHandler(handlerPath) &&
!isEmailHandler(handlerPath) &&
loadHandlerScriptArgs(handlerPath, [
'path',
'workspace_id',
@@ -302,12 +319,19 @@
'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}>Slack</Tab>
<Tab value="teams" disabled={!isEditable}>Teams</Tab>
<Tab value="email" disabled={!isEditable}>Email</Tab>
<Tab value="custom" disabled={!isEditable}>
Custom
{@render customTabTooltip?.()}
@@ -334,8 +358,10 @@
size="xs"
href={customScriptTemplate}
disabled={!isEditable}
target="_blank">Create from template</Button
target="_blank"
>
Create from template
</Button>
{/if}
</div>
{#if showScriptHelpText}
@@ -563,4 +589,46 @@
{/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-tertiary">
{handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[
EMAIL_RECIPIENTS_KEY
]?.length === 1
? ''
: 's'} configured
</span>
{/if}
</div>
{/if}
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import type { Retry } from '$lib/gen'
import type { ErrorHandler, Retry } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { emptyString } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
@@ -19,7 +19,7 @@
optionTabSelected: 'error_handler' | 'retries' | string
itemKind: 'script' | 'flow'
can_write: boolean
errorHandlerSelected: 'custom' | 'slack' | 'teams'
errorHandlerSelected: ErrorHandler
error_handler_path: string | undefined
error_handler_args: Record<string, any>
retry: Retry | undefined
@@ -12,7 +12,8 @@
type DeliveryType,
type PushConfig,
type SubscriptionMode,
type Retry
type Retry,
type ErrorHandler
} from '$lib/gen'
import Section from '$lib/components/Section.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
@@ -52,11 +53,10 @@
let deploymentLoading = $state(false)
let base_endpoint = $derived(`${window.location.origin}${base}`)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: 'slack' | 'teams' | 'custom' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
let {
useDrawer = true,
description = undefined,
@@ -9,6 +9,7 @@
HttpTriggerService,
VariableService,
type AuthenticationMethod,
type ErrorHandler,
type HttpTrigger,
type NewHttpTrigger,
type Retry
@@ -108,8 +109,7 @@
let initialConfig: NewHttpTrigger | undefined = undefined
let deploymentLoading = $state(false)
let optionTabSelected: 'request_options' | 'error_handler' | 'retries' = $state('request_options')
let errorHandlerSelected: 'slack' | 'teams' | 'custom' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const routeConfig = $derived.by(getRouteConfig)
const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({}))
@@ -284,7 +284,6 @@
}
extraPerms = cfg?.extra_perms ?? undefined
can_write = canWrite(path, cfg?.extra_perms ?? {}, $userStore)
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -60,7 +60,7 @@ export async function saveHttpRouteFromCfg(
summary: routeCfg.summary,
error_handler_path: routeCfg.error_handler_path,
error_handler_args: routeCfg.error_handler_path ? routeCfg.error_handler_args : undefined,
retry: routeCfg.retry
retry: routeCfg.retry,
}
try {
if (edit) {
@@ -5,7 +5,7 @@
import Path from '$lib/components/Path.svelte'
import Required from '$lib/components/Required.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import { KafkaTriggerService, type Retry } from '$lib/gen'
import { KafkaTriggerService, type ErrorHandler, type Retry } from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
import Section from '$lib/components/Section.svelte'
@@ -78,7 +78,7 @@
let kafkaCfg: Record<string, any> = $state({})
let deploymentLoading = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: 'slack' | 'teams' | 'custom' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
@@ -13,7 +13,7 @@ export async function saveKafkaTriggerFromCfg(
? {
error_handler_path: cfg.error_handler_path,
error_handler_args: cfg.error_handler_path ? cfg.error_handler_args : undefined,
retry: cfg.retry
retry: cfg.retry,
}
: {}
const requestBody: EditKafkaTrigger = {
@@ -16,7 +16,8 @@
type MqttV3Config,
type MqttV5Config,
type MqttSubscribeTopic,
type Retry
type Retry,
type ErrorHandler
} from '$lib/gen'
import MqttEditorConfigSection from './MqttEditorConfigSection.svelte'
import type { Snippet } from 'svelte'
@@ -91,7 +92,7 @@
let isValid: boolean = $state(false)
let initialConfig: Record<string, any> | undefined = {}
let deploymentLoading = $state(false)
let errorHandlerSelected: 'slack' | 'teams' | 'custom' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
@@ -13,7 +13,7 @@ export async function saveMqttTriggerFromCfg(
? {
error_handler_path: cfg.error_handler_path,
error_handler_args: cfg.error_handler_path ? cfg.error_handler_args : undefined,
retry: cfg.retry
retry: cfg.retry,
}
: {}
const requestBody: EditMqttTrigger = {
@@ -5,7 +5,7 @@
import Path from '$lib/components/Path.svelte'
import Required from '$lib/components/Required.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import { NatsTriggerService, type Retry } from '$lib/gen'
import { NatsTriggerService, type ErrorHandler, type Retry } from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
import Section from '$lib/components/Section.svelte'
@@ -82,7 +82,7 @@
let deploymentLoading = $state(false)
let isValid = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: 'slack' | 'teams' | 'custom' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
@@ -13,7 +13,7 @@ export async function saveNatsTriggerFromCfg(
? {
error_handler_path: cfg.error_handler_path,
error_handler_args: cfg.error_handler_path ? cfg.error_handler_args : undefined,
retry: cfg.retry
retry: cfg.retry,
}
: {}
const requestBody: EditNatsTrigger = {
@@ -5,7 +5,13 @@
import Path from '$lib/components/Path.svelte'
import Required from '$lib/components/Required.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import { PostgresTriggerService, type Language, type Relations, type Retry } from '$lib/gen'
import {
PostgresTriggerService,
type ErrorHandler,
type Language,
type Relations,
type Retry
} from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils'
import Section from '$lib/components/Section.svelte'
@@ -106,7 +112,7 @@
let creatingPublication: boolean = $state(false)
let pg14: boolean = $derived(postgresVersion.startsWith('14'))
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: 'slack' | 'teams' | 'custom' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
@@ -114,7 +114,7 @@ export async function savePostgresTriggerFromCfg(
? {
error_handler_path: config.error_handler_path,
error_handler_args: config.error_handler_path ? config.error_handler_args : undefined,
retry: config.retry
retry: config.retry,
}
: {}
const requestBody: EditPostgresTrigger = {
@@ -18,7 +18,8 @@
type Flow,
SettingService,
type Retry,
type Schedule
type Schedule,
type ErrorHandler
} from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
@@ -66,14 +67,14 @@
let errorHandleritemKind: 'flow' | 'script' = $state('script')
let wsErrorHandlerMuted: boolean = $state(false)
let errorHandlerPath: string | undefined = $state(undefined)
let errorHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let errorHandlerExtraArgs: Record<string, any> = $state({})
let recoveryHandlerPath: string | undefined = $state(undefined)
let recoveryHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack')
let recoveryHandlerSelected: ErrorHandler = $state('slack')
let recoveryHandlerItemKind: 'flow' | 'script' = $state('script')
let recoveryHandlerExtraArgs: Record<string, any> = $state({})
let successHandlerPath: string | undefined = $state(undefined)
let successHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack')
let successHandlerSelected: ErrorHandler = $state('slack')
let successHandlerItemKind: 'flow' | 'script' = $state('script')
let successHandlerExtraArgs: Record<string, any> = $state({})
let failedTimes = $state(1)
@@ -515,7 +516,7 @@
function getHandlerType(
isHandler: 'error' | 'recovery' | 'success',
scriptPath: string
): 'custom' | 'slack' | 'teams' {
): ErrorHandler {
const handlerMap = {
error: {
teams: '/workspace-or-schedule-error-handler-teams',
@@ -533,7 +534,7 @@
for (const [type, suffix] of Object.entries(handlerMap[isHandler])) {
if (scriptPath.startsWith('hub/') && scriptPath.endsWith(suffix)) {
return type as 'custom' | 'slack' | 'teams'
return type as ErrorHandler
}
}
return 'custom'
@@ -7,7 +7,12 @@
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
import { SqsTriggerService, type AwsAuthResourceType, type Retry } from '$lib/gen'
import {
SqsTriggerService,
type AwsAuthResourceType,
type ErrorHandler,
type Retry
} from '$lib/gen'
import SqsTriggerEditorConfigSection from './SqsTriggerEditorConfigSection.svelte'
import Section from '$lib/components/Section.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
@@ -79,7 +84,7 @@
let initialConfig: Record<string, any> | undefined = undefined
let deploymentLoading = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: 'slack' | 'teams' | 'custom' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
@@ -13,7 +13,7 @@ export async function saveSqsTriggerFromCfg(
? {
error_handler_path: cfg.error_handler_path,
error_handler_args: cfg.error_handler_path ? cfg.error_handler_args : undefined,
retry: cfg.retry
retry: cfg.retry,
}
: {}
const requestBody: EditSqsTrigger = {
+11 -4
View File
@@ -4,7 +4,13 @@ import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
import AwsIcon from '$lib/components/icons/AwsIcon.svelte'
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
import type { CaptureTriggerKind, Flow, NewScript, TriggersCount } from '$lib/gen/types.gen'
import type {
CaptureTriggerKind,
ErrorHandler,
Flow,
NewScript,
TriggersCount
} from '$lib/gen/types.gen'
import type { Writable } from 'svelte/store'
import SchedulePollIcon from '../icons/SchedulePollIcon.svelte'
import { type TriggerKind } from '$lib/components/triggers'
@@ -543,14 +549,15 @@ export function filterDraftTriggers(
return newSavedValue
}
export function getHandlerType(scriptPath: string): 'custom' | 'slack' | 'teams' {
export function getHandlerType(scriptPath: string): ErrorHandler {
const handlerMap = {
teams: '/workspace-or-schedule-error-handler-teams',
slack: '/workspace-or-schedule-error-handler-slack'
slack: '/workspace-or-schedule-error-handler-slack',
email: '/workspace-or-error-handler-email'
}
for (const [type, suffix] of Object.entries(handlerMap)) {
if (scriptPath.startsWith('hub/') && scriptPath.endsWith(suffix)) {
return type as 'slack' | 'teams'
return type as ErrorHandler
}
}
return 'custom'
@@ -13,7 +13,8 @@
type Script,
type ScriptArgs,
type WebsocketTriggerInitialMessage,
type Retry
type Retry,
type ErrorHandler
} from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils'
@@ -99,7 +100,7 @@
let deploymentLoading = $state(false)
let isValid = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: 'slack' | 'teams' | 'custom' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
@@ -16,7 +16,7 @@ export async function saveWebsocketTriggerFromCfg(
error_handler_args: triggerCfg.error_handler_path
? triggerCfg.error_handler_args
: undefined,
retry: triggerCfg.retry
retry: triggerCfg.retry,
}
: {}
const requestBody: EditWebsocketTrigger = {
+1
View File
@@ -93,6 +93,7 @@ type HubPaths = {
teamsErrorHandler: string
teamsRecoveryHandler: string
teamsSuccessHandler: string
emailErrorHandler: string
}
export const hubPaths = JSON.parse(rawHubPaths) as HubPaths
+1
View File
@@ -19,6 +19,7 @@
"slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack",
"emailErrorHandler": "hub/19795/workspace-or-error-handler-email",
"slackRecoveryHandler": "hub/9080/slack/schedule-recovery-handler-slack",
"slackSuccessHandler": "hub/9072/slack/schedule-success-handler-slack",
"teamsErrorHandler": "hub/19742/workspace-or-schedule-error-handler-teams",
@@ -18,7 +18,10 @@
WorkspaceService,
ResourceService,
SettingService,
type AIConfig
type AIConfig,
type ErrorHandler
} from '$lib/gen'
import {
enterpriseLicense,
@@ -49,6 +52,7 @@
import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte'
import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte'
import { untrack } from 'svelte'
import { getHandlerType } from '$lib/components/triggers/utils'
import DucklakeSettings, {
convertDucklakeSettingsFromBackend,
type DucklakeSettingsType
@@ -66,7 +70,7 @@
let customer_id: string | undefined = $state(undefined)
let webhook: string | undefined = $state(undefined)
let workspaceToDeployTo: string | undefined = $state(undefined)
let errorHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack')
let errorHandlerSelected: ErrorHandler = $state('slack')
let errorHandlerScriptPath: string | undefined = $state(undefined)
let errorHandlerItemKind: 'flow' | 'script' = $state('script')
let errorHandlerExtraArgs: Record<string, any> = $state({})
@@ -252,14 +256,7 @@
if (emptyString($enterpriseLicense)) {
errorHandlerSelected = 'custom'
} else {
errorHandlerSelected = emptyString(errorHandlerScriptPath)
? 'custom'
: errorHandlerScriptPath.startsWith('hub/') &&
errorHandlerScriptPath.endsWith('/workspace-or-schedule-error-handler-slack')
? 'slack'
: errorHandlerScriptPath.endsWith('/workspace-or-schedule-error-handler-teams')
? 'teams'
: 'custom'
errorHandlerSelected = getHandlerType(errorHandlerScriptPath)
}
errorHandlerExtraArgs = settings.error_handler_extra_args ?? {}
workspaceDefaultAppPath = settings.default_app
@@ -321,7 +318,7 @@
requestBody: {
error_handler: `${errorHandlerItemKind}/${errorHandlerScriptPath}`,
error_handler_extra_args: errorHandlerExtraArgs,
error_handler_muted_on_cancel: errorHandlerMutedOnCancel
error_handler_muted_on_cancel: errorHandlerMutedOnCancel,
}
})
sendUserToast(`workspace error handler set to ${errorHandlerScriptPath}`)
@@ -331,7 +328,7 @@
requestBody: {
error_handler: undefined,
error_handler_extra_args: undefined,
error_handler_muted_on_cancel: undefined
error_handler_muted_on_cancel: undefined,
}
})
sendUserToast(`workspace error handler removed`)