Merge remote-tracking branch 'origin/main' into breadcrumb-leaf-navigation-popover

# Conflicts:
#	backend/ee-repo-ref.txt
#	frontend/src/lib/components/NavBreadcrumb.svelte
#	frontend/src/lib/components/PageHeaderBar.svelte
#	frontend/src/lib/components/PageHeaderContent.svelte
#	frontend/src/lib/components/details/DetailPageHeader.svelte
#	frontend/src/lib/components/pageHeaderRegistry.svelte.ts
#	frontend/src/lib/components/pageHeaderStages.test.ts
#	frontend/src/lib/components/pageHeaderStages.ts
#	frontend/src/lib/components/runs/RunDetail.svelte
This commit is contained in:
Guilhem Lemouel
2026-10-13 14:17:00 -07:00
73 changed files with 1396 additions and 993 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.831.0"
".": "1.832.0"
}
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [1.832.0](https://github.com/windmill-labs/windmill/compare/v1.831.0...v1.832.0) (2026-10-13)
### Features
* record each instance's python version and start new ones on 3.13 ([#11656](https://github.com/windmill-labs/windmill/issues/11656)) ([7558ce2](https://github.com/windmill-labs/windmill/commit/7558ce27a9736bef1a9b9db9b0799150dccb11a5))
### Bug Fixes
* badge the unavailable sign-in option in the oauth resource form ([#11728](https://github.com/windmill-labs/windmill/issues/11728)) ([9c44f5a](https://github.com/windmill-labs/windmill/commit/9c44f5a917fc628e870073c126bf9480c98c7f73))
* serve s3 proxy objects with nosniff and a sandbox csp ([#11731](https://github.com/windmill-labs/windmill/issues/11731)) ([e29a0dc](https://github.com/windmill-labs/windmill/commit/e29a0dc5dfed4bb62b894e059c9506873d32a62c))
* widen variable.value without breaking live servers' cached queries ([#11737](https://github.com/windmill-labs/windmill/issues/11737)) ([89e6bdb](https://github.com/windmill-labs/windmill/commit/89e6bdbcf50e065f6906e1ab93b3c4109aba3ab6))
## [1.831.0](https://github.com/windmill-labs/windmill/compare/v1.830.0...v1.831.0) (2026-10-13)
+79 -79
View File
@@ -15486,7 +15486,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15576,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"async-stream",
"async-trait",
@@ -15611,7 +15611,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -15624,7 +15624,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"argon2",
@@ -15767,7 +15767,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -15790,7 +15790,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -15807,7 +15807,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"axum",
@@ -15833,7 +15833,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15843,7 +15843,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -15860,7 +15860,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -15882,7 +15882,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"axum",
@@ -15905,7 +15905,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -15921,7 +15921,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -15943,7 +15943,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -15965,7 +15965,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -15979,7 +15979,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16015,7 +16015,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"axum",
@@ -16041,7 +16041,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -16069,7 +16069,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"axum",
@@ -16091,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -16111,7 +16111,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"axum",
@@ -16153,7 +16153,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"axum",
@@ -16182,7 +16182,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"lazy_static",
"serde",
@@ -16194,7 +16194,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"argon2",
"axum",
@@ -16218,7 +16218,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -16232,7 +16232,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"axum",
"chrono",
@@ -16271,7 +16271,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16286,7 +16286,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"axum",
@@ -16305,7 +16305,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -16421,7 +16421,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"chrono",
"futures",
@@ -16441,7 +16441,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"regex",
"serde",
@@ -16458,7 +16458,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16485,7 +16485,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"futures",
@@ -16502,7 +16502,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16518,7 +16518,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16540,7 +16540,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16571,7 +16571,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -16596,7 +16596,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-stream",
@@ -16632,7 +16632,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"futures",
@@ -16651,7 +16651,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16660,7 +16660,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16672,7 +16672,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16684,7 +16684,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16696,7 +16696,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16708,7 +16708,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16720,7 +16720,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16731,7 +16731,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16742,7 +16742,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16754,7 +16754,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -16765,7 +16765,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16788,7 +16788,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16800,7 +16800,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16814,7 +16814,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16831,7 +16831,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16844,7 +16844,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde",
@@ -16856,7 +16856,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16874,7 +16874,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16890,7 +16890,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -16907,7 +16907,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16921,7 +16921,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16960,7 +16960,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"const_format",
@@ -16998,7 +16998,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17009,7 +17009,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17044,7 +17044,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17068,7 +17068,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17101,7 +17101,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17128,7 +17128,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17161,7 +17161,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17181,7 +17181,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17215,7 +17215,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17252,7 +17252,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17275,7 +17275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17299,7 +17299,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17323,7 +17323,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17358,7 +17358,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17386,7 +17386,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17411,7 +17411,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -17430,7 +17430,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17549,7 +17549,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.831.0"
version = "1.832.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.831.0"
version = "1.832.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
9cd6a70486df465294f23071fbf383f21ef47ba9
581b18cac39a4bee2671fd1aa86f91b9e702e8f5
+1 -1
View File
@@ -446,7 +446,7 @@
],
"req_body_auth": true
},
"outlook": {
"ms_outlook": {
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"],
"scope_options": [
+24 -24
View File
@@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -6430,7 +6430,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6442,7 +6442,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"convert_case",
"serde",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6487,7 +6487,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6499,7 +6499,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6511,7 +6511,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6533,7 +6533,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6545,7 +6545,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6556,7 +6556,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6579,7 +6579,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6591,7 +6591,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6605,7 +6605,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6622,7 +6622,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6635,7 +6635,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde",
@@ -6647,7 +6647,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6665,7 +6665,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6681,7 +6681,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6698,7 +6698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6730,7 +6730,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6744,7 +6744,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.831.0"
version = "1.832.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.831.0"
version = "1.832.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+3
View File
@@ -153,6 +153,9 @@ pub async fn connect(
pool_options = pool_options.idle_timeout(Duration::from_secs(60));
}
pool_options
.before_acquire(|_, meta| {
Box::pin(async move { Ok(!windmill_common::db::connection_is_retired(meta.age)) })
})
.after_connect(move |conn, _| {
if worker_mode {
Box::pin(async move {
+5 -3
View File
@@ -4469,12 +4469,14 @@ pub async fn monitor_db(
// Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM.
// Every ~5 min until one pass has gone through every workspace; spawned so a large instance
// does not hold up the join below.
// does not hold up the join below. The column widening it starts with is tried every
// iteration: a v2 value too long for the narrow column fails to save until it is done.
let legacy_secret_upgrade_f = async {
if server_mode
&& iteration.is_some()
&& iteration.as_ref().unwrap().should_run(30)
&& windmill_common::variables::legacy_secret_upgrade_pending()
&& (windmill_common::variables::variable_value_widen_pending()
|| (iteration.as_ref().unwrap().should_run(30)
&& windmill_common::variables::legacy_secret_upgrade_pending()))
{
if let Some(db) = conn.as_sql() {
let db = db.clone();
+160
View File
@@ -1580,3 +1580,163 @@ async fn test_backups_fall_back_to_the_instance_storage(db: Pool<Postgres>) -> a
Ok(())
}
/// Runs its closure when dropped, so process-wide state is put back even when an assertion
/// fails.
struct OnDrop<F: FnMut()>(F);
impl<F: FnMut()> Drop for OnDrop<F> {
fn drop(&mut self) {
(self.0)()
}
}
/// Once every worker reads AES-256-GCM and this server has been up long enough for any server
/// of the previous release to be gone, objects are written in it: a session backed up as CBC
/// before the switch still restores whole with the GCM pieces added after it, a head at its
/// cap and a push token still read back under the larger ciphertext, and relocated GCM
/// ciphertext does not decrypt for another user.
#[sqlx::test(fixtures("base"))]
async fn test_backups_switch_to_gcm_once_every_worker_reads_it(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::min_version::{
MIN_VERSION, MIN_VERSION_SUPPORTS_AEAD_SECRETS, MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES,
};
use windmill_common::workspace_crypt::V2_MAGIC;
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let storage_dir = tempfile::tempdir()?;
configure_primary_lfs(&db, &storage_dir.path().to_string_lossy()).await?;
let before = MIN_VERSION.load_full();
let _restore = OnDrop(move || MIN_VERSION.store(before.clone()));
let _uptime = OnDrop(|| windmill_common::db::set_process_uptime_offset(Default::default()));
let is_gcm = |b: &[u8]| b.starts_with(&V2_MAGIC);
let object = |path: &str| -> Vec<u8> {
std::fs::read(user_root(storage_dir.path(), "test@windmill.dev").join(path)).unwrap()
};
MIN_VERSION.store(std::sync::Arc::new(
MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES.version().clone(),
));
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{
"id": "s1",
"whole": true,
"head": { "id": "s1", "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" },
"chats": [{ "id": "c1", "record": { "id": "c1", "title": "BEFORE" } }]
}]
}),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let cbc_chat = object("sessions/s1/chats/c1.json");
assert!(!cbc_chat.is_empty() && !is_gcm(&cbc_chat));
MIN_VERSION.store(std::sync::Arc::new(
MIN_VERSION_SUPPORTS_AEAD_SECRETS.version().clone(),
));
// Every worker reads it, but this server was only just started: still CBC.
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{ "id": "s1", "chats": [{ "id": "c0", "record": { "id": "c0", "title": "FRESH" } }] }]
}),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let fresh_chat = object("sessions/s1/chats/c0.json");
assert!(!fresh_chat.is_empty() && !is_gcm(&fresh_chat));
windmill_common::db::set_process_uptime_offset(
windmill_common::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME,
);
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{
"id": "s1",
"chats": [{ "id": "c2", "record": { "id": "c2", "title": "AFTER" } }],
"images": [{ "chat_id": "c2", "id": "i1", "data_url": "data:image/png;base64,AAAA" }]
}]
}),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
assert_eq!(
object("sessions/s1/chats/c1.json"),
cbc_chat,
"not rewritten"
);
assert!(is_gcm(&object("sessions/s1/chats/c2.json")));
assert!(is_gcm(&object("images/s1/c2/i1")));
let pulled = pull(&base, "SECRET_TOKEN", &["s1"]).await?;
let mut titles: Vec<String> = pulled["sessions"][0]["chats"]
.as_array()
.unwrap()
.iter()
.map(|c| c["record"]["title"].as_str().unwrap().to_string())
.collect();
titles.sort();
assert_eq!(titles, ["AFTER", "BEFORE", "FRESH"], "{pulled}");
assert_eq!(
pulled["sessions"][0]["images"][0]["data_url"],
"data:image/png;base64,AAAA"
);
// A head at its cap, pushed in two parts: the second reads back the push token.
let mut head = json!({ "id": "s2", "workspace_id": "test-workspace", "createdAt": 2, "chatId": "c", "pad": "" });
let pad = 1024 * 1024 - head.to_string().len();
head["pad"] = json!("x".repeat(pad));
assert_eq!(head.to_string().len(), 1024 * 1024);
for part in [
json!({ "id": "s2", "whole": true, "push": "p2", "opens": true, "partial": true, "head": head }),
json!({ "id": "s2", "whole": true, "push": "p2", "chats": [{ "id": "c", "record": { "id": "c" } }] }),
] {
let resp = push(
&base,
"SECRET_TOKEN",
json!({ "owner": "test@windmill.dev", "sessions": [part] }),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let pushed: Value = resp.json().await?;
assert_eq!(pushed["results"], json!([{ "id": "s2" }]), "{pushed}");
}
assert!(is_gcm(&object("sessions/s2/head.json")));
let pulled = pull(&base, "SECRET_TOKEN", &["s2"]).await?;
assert_eq!(
pulled["sessions"][0]["head"]["pad"].as_str().map(str::len),
Some(pad)
);
assert_eq!(pulled["sessions"][0]["chats"][0]["id"], "c", "{pulled}");
let first = user_root(storage_dir.path(), "test@windmill.dev");
copy_dir(
&first,
&first
.parent()
.unwrap()
.join(calculate_hash("test2@windmill.dev")),
)?;
assert_eq!(
pull(&base, "SECRET_TOKEN_2", &["s1", "s2"]).await?["sessions"],
json!([]),
"relocated ciphertext must not decrypt for another user"
);
Ok(())
}
+29
View File
@@ -0,0 +1,29 @@
//! Every s3_proxy response carries `nosniff` and the sandbox CSP, error responses included,
//! since the presigned route serves workspace objects from the app origin without a login.
#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))]
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
#[sqlx::test(fixtures("base"))]
async fn test_s3_proxy_sets_content_security_headers(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/s3_proxy/_default_/x.html?exp=9999999999&sig=00"
))
.send()
.await?;
assert!(resp.status().is_client_error(), "status: {}", resp.status());
assert_eq!(resp.headers()["x-content-type-options"], "nosniff");
assert_eq!(
resp.headers()["content-security-policy"],
"sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"
);
Ok(())
}
+10
View File
@@ -132,6 +132,15 @@ async fn get_instance_ui(
))
}
/// Whether email notifications can go out, for the workspace admins who configure email
/// handlers and cannot read `smtp_settings` itself (it holds the password). Asks the same
/// resolver the senders use, so SMTP set through `SMTP_*` env vars counts too.
async fn get_smtp_configured(Extension(db): Extension<DB>, _authed: ApiAuthed) -> JsonResult<bool> {
Ok(Json(
SMTP_ENABLED && windmill_common::server::is_smtp_configured(&db).await?,
))
}
pub fn global_service() -> Router {
#[warn(unused_mut)]
let r = Router::new()
@@ -152,6 +161,7 @@ pub fn global_service() -> Router {
)
.route("/instance_config/yaml", get(get_instance_config_yaml))
.route("/test_smtp", post(test_email))
.route("/smtp_configured", get(get_smtp_configured))
.route("/test_license_key", post(test_license_key))
.route("/send_stats", post(send_stats))
.route("/get_stats", get(get_stats))
+19 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.831.0
version: 1.832.0
title: Windmill API
contact:
@@ -1971,6 +1971,24 @@ paths:
application/json:
schema: {}
/settings/smtp_configured:
get:
summary: whether instance SMTP is configured
description: |
Readable by any authenticated user. True when this build can send email
and SMTP is set, either in the smtp_settings instance setting or through
the SMTP_* environment variables.
operationId: getSmtpConfigured
tags:
- setting
responses:
"200":
description: whether email notifications can be sent
content:
application/json:
schema:
type: boolean
/settings/test_smtp:
post:
summary: test smtp
+46 -6
View File
@@ -24,7 +24,6 @@ use axum::{
Extension, Json, Router,
};
use futures::{StreamExt, TryStreamExt};
use magic_crypt::MagicCryptTrait;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::sync::Arc;
@@ -33,9 +32,11 @@ use windmill_api_workspaces::ai_session_backups::{
fallback_store, generation_prefix, primary_store, storage_id, MAX_OBJECT_BYTES,
};
use windmill_api_workspaces::workspaces::sessions_retention_days;
use windmill_common::db::{process_uptime, PREVIOUS_RELEASE_ROLLOUT_UPTIME};
use windmill_common::error::{Error, JsonResult, Result};
use windmill_common::utils::calculate_hash;
use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key, WorkspaceCrypt};
use windmill_common::workspace_crypt::{CipherFormat, MAX_BYTES_OVERHEAD};
use windmill_object_store::object_store_reexports::{
ObjectStore, ObjectStoreError, Path as ObjectPath, PutPayload,
};
@@ -47,9 +48,9 @@ const PULL_BODY_LIMIT: usize = 64 * 1024;
/// A pull answer larger than this hands the remaining ids back as `deferred`.
const PULL_RESPONSE_BUDGET: usize = 32 * 1024 * 1024;
const MAX_HEAD_BYTES: usize = 1024 * 1024;
/// What the cipher adds to a plaintext at most (a block of padding): an object stored at a
/// cap is that much larger than the cap when read back.
const CIPHER_PADDING: usize = 16;
/// What the cipher adds to a plaintext at most: an object stored at a cap is that much larger
/// than the cap when read back.
const CIPHER_PADDING: usize = MAX_BYTES_OVERHEAD;
/// The browser bounds an image to a 1568 px edge and re-encodes past 700 KB; this is
/// well above what that produces.
const MAX_IMAGE_BYTES: usize = 4 * 1024 * 1024;
@@ -120,6 +121,15 @@ pub fn workspaced_service() -> Router {
)
}
fn backup_format(current: CipherFormat, uptime: std::time::Duration) -> CipherFormat {
// A previous-release server reads only CBC here: GCM waits for it to be rolled out.
if current == CipherFormat::V2 && uptime >= PREVIOUS_RELEASE_ROLLOUT_UPTIME {
CipherFormat::V2
} else {
CipherFormat::LegacyCbc
}
}
/// What reading an object yields. `Gone`: not there (deleted since the listing, or never
/// pushed). `Grown`: larger than expected, so replaced since the listing (or planted), and
/// left unread; a pull answers with a page ending before it rather than without it.
@@ -214,7 +224,8 @@ impl Backend {
}
fn seal(&self, plaintext: &[u8]) -> Vec<u8> {
self.mc.encrypt_bytes_to_bytes(plaintext)
let format = backup_format(CipherFormat::current(), process_uptime());
self.mc.encrypt_bytes_in(plaintext, format)
}
/// Bytes written.
@@ -255,7 +266,7 @@ impl Backend {
// apart beyond the cipher's padding check, which a wrong key passes now and then.
match self
.mc
.decrypt_bytes_to_bytes(&bytes)
.decrypt_bytes(&bytes)
.ok()
.and_then(|plaintext| String::from_utf8(plaintext).ok())
{
@@ -1632,3 +1643,32 @@ async fn push(
results,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backups_are_written_in_gcm_only_once_the_previous_release_is_rolled_out() {
let just_short = PREVIOUS_RELEASE_ROLLOUT_UPTIME - std::time::Duration::from_secs(1);
for (current, uptime, expected) in [
(
CipherFormat::V2,
PREVIOUS_RELEASE_ROLLOUT_UPTIME,
CipherFormat::V2,
),
(CipherFormat::V2, just_short, CipherFormat::LegacyCbc),
(
CipherFormat::LegacyCbc,
PREVIOUS_RELEASE_ROLLOUT_UPTIME * 4,
CipherFormat::LegacyCbc,
),
] {
assert_eq!(
backup_format(current, uptime),
expected,
"{current:?} {uptime:?}"
);
}
}
}
+6
View File
@@ -113,6 +113,12 @@ lazy_static::lazy_static! {
(20260909163047, include_str!(
"../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
// The widening fails every query the previous release's still-running
// servers prepared on the column; `variables::widen_variable_value` does it
// once they are gone.
(20261012213357, include_str!(
"../../migrations/20261012213357_widen_variable_value.up.sql"
).replace("ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR(15040);", "")),
].into_iter().collect();
}
+59
View File
@@ -290,3 +290,62 @@ impl<'b> DbExecutor<'b> for &'b mut PgConnection {
&mut **self
}
}
lazy_static::lazy_static! {
static ref PROCESS_START: std::time::Instant = std::time::Instant::now();
}
/// Milliseconds after [`PROCESS_START`] before which every pooled connection is retired;
/// 0 retires none.
static RETIRE_CONNECTIONS_OPENED_BEFORE_MS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
/// Postgres refuses a statement prepared before its result columns changed type (even a
/// varchar length) with this error, on every execution, for as long as the connection lives.
/// The routine is matched as well as the message, which `lc_messages` may translate.
pub fn is_stale_cached_plan(e: &sqlx::Error) -> bool {
let sqlx::Error::Database(d) = e else {
return false;
};
d.code().as_deref() == Some("0A000")
&& (d
.try_downcast_ref::<sqlx::postgres::PgDatabaseError>()
.is_some_and(|pg| pg.routine() == Some("RevalidateCachedQuery"))
|| d.message()
.contains("cached plan must not change result type"))
}
/// Retire every pooled connection opened up to now, so a schema change that poisoned their
/// prepared statements costs one failed query instead of failing until they expire.
pub fn retire_connections_opened_before_now() {
let now = PROCESS_START.elapsed().as_millis() as u64 + 1;
RETIRE_CONNECTIONS_OPENED_BEFORE_MS.fetch_max(now, std::sync::atomic::Ordering::Relaxed);
}
/// For the pool's `before_acquire`: whether a connection of this age must be closed.
pub fn connection_is_retired(age: std::time::Duration) -> bool {
let cutoff = RETIRE_CONNECTIONS_OPENED_BEFORE_MS.load(std::sync::atomic::Ordering::Relaxed);
cutoff != 0 && (PROCESS_START.elapsed().saturating_sub(age).as_millis() as u64) < cutoff
}
/// Servers report no version, so the worker version gate alone cannot see a server of the
/// previous release; its rollout is assumed done once this server has been up this long.
pub const PREVIOUS_RELEASE_ROLLOUT_UPTIME: std::time::Duration =
std::time::Duration::from_secs(30 * 60);
static PROCESS_UPTIME_OFFSET_SECS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
/// Time since this process first used its database pool.
pub fn process_uptime() -> std::time::Duration {
PROCESS_START.elapsed()
+ std::time::Duration::from_secs(
PROCESS_UPTIME_OFFSET_SECS.load(std::sync::atomic::Ordering::Relaxed),
)
}
/// For tests of what waits on [`process_uptime`]: adds `offset` to it.
#[doc(hidden)]
pub fn set_process_uptime_offset(offset: std::time::Duration) {
PROCESS_UPTIME_OFFSET_SECS.store(offset.as_secs(), std::sync::atomic::Ordering::Relaxed);
}
+4
View File
@@ -153,6 +153,10 @@ impl From<anyhow::Error> for Error {
impl From<sqlx::Error> for Error {
#[track_caller]
fn from(e: sqlx::Error) -> Self {
if crate::db::is_stale_cached_plan(&e) {
tracing::warn!("a schema change invalidated cached statements, reopening the database connections: {e}");
crate::db::retire_connections_opened_before_now();
}
Self::SqlErr { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
@@ -27,6 +27,8 @@ pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted
// stored secret variables. Reading it ships one release earlier (1.831), so a rollback to
// that release still reads every secret; below it, secrets written after the switch stay
// unreadable until rolled forward. Only worker versions are seen here, not servers.
// Also gates widening `variable.value`, which needs every process to retire the connections
// the widening poisons (`db::retire_connections_opened_before_now`): that also ships in 1.832.
pub const MIN_VERSION_SUPPORTS_AEAD_SECRETS: VC = vc(1, 832, 0, "AES-GCM secret encryption");
pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing");
pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage");
+17 -7
View File
@@ -31,9 +31,24 @@ pub async fn load_smtp_config(db: &DB) -> error::Result<Option<Smtp>> {
Ok(parse_smtp_config(value))
}
/// Whether SMTP is set, without the "not configured" warning: any signed-in user can ask, and
/// "no" is an ordinary answer there rather than something to log.
pub async fn is_smtp_configured(db: &DB) -> error::Result<bool> {
let value = crate::global_settings::load_value_from_global_settings(db, SMTP_SETTING).await?;
Ok(resolve_smtp_config(value).is_some())
}
/// The half of [`load_smtp_config`] after the read, so a batched settings pass can parse a
/// value it already fetched.
pub fn parse_smtp_config(value: Option<serde_json::Value>) -> Option<Smtp> {
let smtp = resolve_smtp_config(value);
if smtp.is_none() {
tracing::warn!("SMTP not configured");
}
smtp
}
fn resolve_smtp_config(value: Option<serde_json::Value>) -> Option<Smtp> {
let config: SmtpConfigOpt = value
.map(|x| serde_json::from_value(x).ok())
.flatten()
@@ -57,7 +72,7 @@ pub fn parse_smtp_config(value: Option<serde_json::Value>) -> Option<Smtp> {
} else {
None
};
let smtp = config_smtp.or(
config_smtp.or(
if let (Some(host), username, password) = (
std::env::var("SMTP_HOST").ok(),
std::env::var("SMTP_USERNAME").ok(),
@@ -86,12 +101,7 @@ pub fn parse_smtp_config(value: Option<serde_json::Value>) -> Option<Smtp> {
} else {
None
},
);
if smtp.is_none() {
tracing::warn!("SMTP not configured");
}
smtp
)
}
impl Default for SmtpConfigOpt {
+69 -3
View File
@@ -400,7 +400,61 @@ pub fn legacy_secret_upgrade_pending() -> bool {
&& !LEGACY_SECRET_UPGRADE_DONE.load(Ordering::Relaxed)
}
/// Re-encrypt the secret variables still in the legacy format in v2, workspace by workspace.
/// AES-256-GCM ciphertext is 32 bytes (magic, nonce, tag) longer than legacy CBC before base64,
/// so the longest CBC value that fits 15000 chars is exactly 15040 chars once re-encrypted.
const VARIABLE_VALUE_WIDTH: i32 = 15040;
static VARIABLE_VALUE_WIDENED: AtomicBool = AtomicBool::new(false);
/// Whether this process has seen `variable.value` wide enough for every v2 value. Only servers
/// look, so it stays false in a worker.
pub fn variable_value_widened() -> bool {
VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed)
}
/// Whether `variable.value` may still have to be widened to fit v2 values.
pub fn variable_value_widen_pending() -> bool {
CipherFormat::current() == CipherFormat::V2 && !VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed)
}
/// Widen `variable.value` to [`VARIABLE_VALUE_WIDTH`], or `false` when it is too early to.
/// Not a migration: changing a column's type, even its length, fails every statement a live
/// process prepared on it until its connection closes. It waits for v2 writes, gated on a
/// worker version that retires such connections on the first failure, so that no process too
/// old to recover is still running.
async fn widen_variable_value(db: &DB) -> error::Result<bool> {
if VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) {
return Ok(true);
}
// varchar's typmod is its length + 4, -1 when unbounded.
let typmod: i32 = sqlx::query_scalar(
"SELECT atttypmod FROM pg_attribute WHERE attrelid = 'variable'::regclass AND attname = 'value'",
)
.fetch_one(db)
.await?;
if typmod >= 0 && typmod - 4 < VARIABLE_VALUE_WIDTH {
if crate::db::process_uptime() < crate::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME {
return Ok(false);
}
tracing::info!("widening variable.value to VARCHAR({VARIABLE_VALUE_WIDTH})");
let mut tx = db.begin().await?;
// Queued behind a long transaction, the ALTER would block every variable read meanwhile.
sqlx::query("SET LOCAL lock_timeout = '3s'")
.execute(&mut *tx)
.await?;
sqlx::query(&format!(
"ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR({VARIABLE_VALUE_WIDTH})"
))
.execute(&mut *tx)
.await?;
tx.commit().await?;
crate::db::retire_connections_opened_before_now();
}
VARIABLE_VALUE_WIDENED.store(true, Ordering::Relaxed);
Ok(true)
}
/// Widen `variable.value` for v2 values, then re-encrypt the secret variables still in the
/// legacy format in v2, workspace by workspace.
/// Idempotent, and emits no git-sync event: the plaintexts do not change.
pub async fn upgrade_legacy_secret_variables(db: &DB) {
struct Running;
@@ -409,14 +463,26 @@ pub async fn upgrade_legacy_secret_variables(db: &DB) {
LEGACY_SECRET_UPGRADE_RUNNING.store(false, Ordering::Release);
}
}
if !legacy_secret_upgrade_pending()
if !(variable_value_widen_pending() || legacy_secret_upgrade_pending())
|| LEGACY_SECRET_UPGRADE_RUNNING.swap(true, Ordering::AcqRel)
{
return;
}
// Cleared on drop, so a pass that panics does not stop every later one in this process.
let _running = Running;
upgrade_legacy_secret_variables_pass(db).await;
match widen_variable_value(db).await {
Ok(true) => {}
Ok(false) => return,
Err(e) => {
tracing::warn!(
"secret encryption upgrade: widening variable.value failed, retrying later: {e:#}"
);
return;
}
}
if legacy_secret_upgrade_pending() {
upgrade_legacy_secret_variables_pass(db).await;
}
}
async fn upgrade_legacy_secret_variables_pass(db: &DB) {
+57 -4
View File
@@ -30,6 +30,10 @@ pub const V2_MAGIC: [u8; 4] = *b"wmA2";
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
const BLOCK: usize = 16;
const NARROW_VARIABLE_VALUE_WIDTH: usize = 15000;
/// The most `encrypt_bytes_in` adds to a plaintext, in either format.
pub const MAX_BYTES_OVERHEAD: usize = V2_MAGIC.len() + NONCE_LEN + BLOCK + TAG_LEN;
const AEAD_KEY_INFO: &[u8] = b"windmill workspace secret aes-256-gcm v1";
@@ -77,8 +81,8 @@ pub struct WorkspaceCrypt {
aead: Aes256Gcm,
}
/// Code that drives `magic_crypt` directly on this cipher (app link secrets, AI session
/// objects) keeps the legacy format it always had.
/// Code that drives `magic_crypt` directly on this cipher (app link secrets) keeps the legacy
/// format it always had.
impl std::ops::Deref for WorkspaceCrypt {
type Target = MagicCrypt256;
fn deref(&self) -> &MagicCrypt256 {
@@ -102,7 +106,23 @@ impl WorkspaceCrypt {
}
pub fn encrypt_str(&self, plaintext: &str) -> String {
self.encrypt_str_in(plaintext, CipherFormat::current())
self.encrypt_str_fitting(
plaintext,
CipherFormat::current(),
crate::variables::variable_value_widened(),
)
}
/// A v2 value is up to 44 chars longer than CBC: until `variable.value` is known widened
/// for it, one past the narrow width is written in CBC, which fits.
fn encrypt_str_fitting(&self, plaintext: &str, format: CipherFormat, widened: bool) -> String {
if format == CipherFormat::V2 {
let v2 = self.encrypt_str_in(plaintext, CipherFormat::V2);
if widened || v2.len() <= NARROW_VARIABLE_VALUE_WIDTH {
return v2;
}
}
self.encrypt_str_in(plaintext, CipherFormat::LegacyCbc)
}
pub fn encrypt_str_in(&self, plaintext: &str, format: CipherFormat) -> String {
@@ -136,7 +156,14 @@ impl WorkspaceCrypt {
(self.decrypt_str(&upgraded).ok()? == plaintext).then_some(upgraded)
}
fn decrypt_bytes(&self, bytes: &[u8]) -> Result<Vec<u8>, DecryptFailure> {
pub fn encrypt_bytes_in(&self, plaintext: &[u8], format: CipherFormat) -> Vec<u8> {
match format {
CipherFormat::V2 => self.seal(plaintext),
CipherFormat::LegacyCbc => self.legacy.encrypt_bytes_to_bytes(plaintext),
}
}
pub fn decrypt_bytes(&self, bytes: &[u8]) -> Result<Vec<u8>, DecryptFailure> {
match self.open(bytes) {
Some(plaintext) => Ok(plaintext),
// Also for a value with the magic: a CBC ciphertext starts with it one time in 2^32.
@@ -242,6 +269,32 @@ mod tests {
);
}
#[test]
fn bytes_round_trip_within_the_overhead_in_both_formats() {
let wc = WorkspaceCrypt::new(KEY);
for len in 0..64 {
let plain = vec![b'x'; len];
for format in [CipherFormat::V2, CipherFormat::LegacyCbc] {
let sealed = wc.encrypt_bytes_in(&plain, format);
assert_eq!(wc.open(&sealed).is_some(), format == CipherFormat::V2);
assert!(sealed.len() <= len + MAX_BYTES_OVERHEAD, "len {len}");
assert_eq!(wc.decrypt_bytes(&sealed).unwrap(), plain);
}
}
}
#[test]
fn v2_too_long_for_a_narrow_column_is_written_in_cbc() {
let wc = WorkspaceCrypt::new(KEY);
// The longest plaintext whose CBC value fits 15000 chars.
let long = "x".repeat(11231);
let narrow = wc.encrypt_str_fitting(&long, CipherFormat::V2, false);
assert!(!wc.is_v2(&narrow) && narrow.len() <= NARROW_VARIABLE_VALUE_WIDTH);
assert_eq!(wc.decrypt_str(&narrow).unwrap(), long);
assert!(wc.is_v2(&wc.encrypt_str_fitting(&long, CipherFormat::V2, true)));
assert!(wc.is_v2(&wc.encrypt_str_fitting("short", CipherFormat::V2, false)));
}
#[test]
fn v2_passes_the_block_multiple_ciphertext_shape_check() {
let wc = WorkspaceCrypt::new(KEY);
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.831.0";
export const VERSION = "v1.832.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/chat-sdk",
"version": "1.831.0",
"version": "1.832.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/chat-sdk",
"version": "1.831.0",
"version": "1.832.0",
"license": "Apache-2.0",
"devDependencies": {
"@ai-sdk/react": "^4.0.102",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@windmill-labs/chat-sdk",
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
"version": "1.831.0",
"version": "1.832.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.831.0";
export const VERSION = "1.832.0";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.831.0",
"version": "1.832.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.831.0",
"version": "1.832.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.831.0",
"version": "1.832.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -1,6 +1,4 @@
<script lang="ts" module>
import type { ErrorHandler } from '$lib/gen/types.gen'
export const errorHandlerArgs = [
'path',
'workspace_id',
@@ -19,17 +17,6 @@
]
export const slackErrorHandlerHubPathEnding = '/workspace-or-schedule-error-handler-slack'
/** The stored `<script|flow>/<path>` form of a handler. `kind` is the Custom tab's Script/Flow
* choice and survives switching to a built-in tab, whose handlers are all scripts: stored as
* `flow/...` they never run. */
export function handlerFullPath(
selected: ErrorHandler,
kind: 'flow' | 'script',
path: string
): string {
return `${selected === 'custom' ? kind : 'script'}/${path}`
}
</script>
<script lang="ts">
@@ -67,34 +54,22 @@
RotateCw,
XCircle
} from 'lucide-svelte'
import { hubPaths } from '$lib/hub'
import { isCloudHosted } from '$lib/cloud'
import { untrack } from 'svelte'
import { syncSlackBotArg } from './handlerSlackArg'
import SmtpConfigurationStatus from './common/smtp/SmtpConfigurationStatus.svelte'
import { SettingService } from '$lib/gen'
import { isSmtpSettingsValid } from './instanceSettings/SmtpSettings.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { builtinHubPath, type HandlerDraft, type HandlerMode } from './handlerDraft.svelte'
const operatingWorkspace = useOperatingWorkspace()
const slackRecoveryHandler = hubPaths.slackRecoveryHandler
const slackHandlerScriptPath = hubPaths.slackErrorHandler
const slackSuccessHandler = hubPaths.slackSuccessHandler
const teamsRecoveryHandler = hubPaths.teamsRecoveryHandler
const teamsHandlerScriptPath = hubPaths.teamsErrorHandler
const teamsSuccessHandler = hubPaths.teamsSuccessHandler
interface Props {
errorOrRecovery: 'error' | 'recovery' | 'success'
errorOrRecovery: HandlerMode
isEditable: boolean
toggleText?: string
showScriptHelpText?: boolean
handlerSelected: ErrorHandler
handlerPath: string | undefined
handlerExtraArgs: Record<string, any>
/** Owned by the caller, which restores it on Discard; every tab edits its own slot. */
draft: HandlerDraft
customScriptTemplate: string
customHandlerKind?: 'flow' | 'script'
customTabTooltip?: import('svelte').Snippet
noMargin?: boolean
/** Workspace for handler lookup / settings / test jobs. Defaults to the operating
@@ -110,11 +85,8 @@
isEditable,
toggleText = 'Enable',
showScriptHelpText = false,
handlerSelected = $bindable('custom'),
handlerPath = $bindable(),
handlerExtraArgs = $bindable(),
draft = $bindable(),
customScriptTemplate,
customHandlerKind = $bindable('script'),
customTabTooltip,
noMargin = false,
workspace = undefined,
@@ -142,8 +114,6 @@
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.getPublicSettings({ workspace: effectiveWorkspace! })
@@ -171,11 +141,7 @@
async function loadSmtpConfiguration() {
try {
const smtpSettings = (await SettingService.getGlobal({ key: 'smtp_settings' })) as Record<
string,
any
> | null
hasSmtpConfig = smtpSettings ? isSmtpSettingsValid(smtpSettings) : false
hasSmtpConfig = await SettingService.getSmtpConfigured()
} catch (error) {
hasSmtpConfig = false
}
@@ -190,7 +156,7 @@
let submitted_job = await testJobFunction({
workspace: effectiveWorkspace!,
requestBody: {
hub_script_path: handlerPath,
hub_script_path: draft[platform].path,
channel: channel,
test_msg: `This is a notification to test the connection between ${platform} and Windmill workspace '${effectiveWorkspace!}'`
}
@@ -236,7 +202,11 @@
await sendMessage(channel, 'teams')
}
async function loadHandlerScriptArgs(p: string, defaultArgs: string[] = []) {
async function loadHandlerScriptArgs(
p: string,
kind: 'script' | 'flow',
defaultArgs: string[] = []
) {
try {
let schema: Schema | undefined = emptySchema()
if (p.startsWith('hub/')) {
@@ -251,7 +221,7 @@
}
} else {
let scriptOrFlow: Script | Flow =
customHandlerKind === 'script'
kind === 'script'
? await ScriptService.getScriptByPath({ workspace: effectiveWorkspace!, path: p })
: await FlowService.getFlowByPath({ workspace: effectiveWorkspace!, path: p })
schema = scriptOrFlow.schema as Schema
@@ -269,47 +239,6 @@
}
}
function isSlackHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
if (errorOrRecovery == 'error') {
return scriptPath.startsWith('hub/') && scriptPath.endsWith(slackErrorHandlerHubPathEnding)
} else if (errorOrRecovery == 'recovery') {
return (
scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-recovery-handler-slack')
)
} else {
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-success-handler-slack')
}
}
function isTeamsHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
if (errorOrRecovery == 'error') {
return (
scriptPath.startsWith('hub/') &&
scriptPath.endsWith('/workspace-or-schedule-error-handler-teams')
)
} else if (errorOrRecovery == 'recovery') {
return (
scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-recovery-handler-teams')
)
} else {
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-success-handler-teams')
}
}
function isEmailHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/workspace-or-error-handler-email')
}
$effect(() => {
if (effectiveWorkspace) {
loadSlackResources()
@@ -318,82 +247,25 @@
loadSmtpConfiguration()
})
let builtinArgs: Record<string, any> | undefined
$effect(() => {
const isSlackBuiltin = handlerSelected === 'slack' && isSlackHandler(handlerPath)
const args = handlerExtraArgs
// A replaced args object (Discard, reload) holds the stored config, not what the built-in set.
untrack(() => syncSlackBotArg(args, isSlackBuiltin, args === builtinArgs))
builtinArgs = isSlackBuiltin ? args : undefined
})
let lastHandlerSelected: ErrorHandler | undefined = $state(undefined)
let handlerCache = $state({
slack: undefined as string | undefined,
teams: undefined as string | undefined,
email: undefined as string[] | undefined
})
let handlerPathCache: Partial<Record<ErrorHandler, string | undefined>> = $state({})
$effect(() => {
if (lastHandlerSelected !== handlerSelected && lastHandlerSelected !== undefined) {
if (lastHandlerSelected != 'custom') {
const key = lastHandlerSelected === 'email' ? EMAIL_RECIPIENTS_KEY : CHANNEL_KEY
handlerCache[lastHandlerSelected] = handlerExtraArgs[key]
}
handlerPathCache[lastHandlerSelected] = handlerPath
if (handlerSelected === 'custom') {
delete handlerExtraArgs[CHANNEL_KEY]
delete handlerExtraArgs[EMAIL_RECIPIENTS_KEY]
handlerPath = handlerPathCache['custom']
} else if (handlerSelected === 'email') {
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = handlerCache[handlerSelected] ?? []
delete handlerExtraArgs[CHANNEL_KEY]
} else {
handlerExtraArgs[CHANNEL_KEY] = handlerCache[handlerSelected] ?? ''
delete handlerExtraArgs[EMAIL_RECIPIENTS_KEY]
handlerPath = handlerPathCache[handlerSelected]
}
}
lastHandlerSelected = handlerSelected
})
$effect(() => {
handlerPath &&
!isSlackHandler(handlerPath) &&
!isTeamsHandler(handlerPath) &&
!isEmailHandler(handlerPath) &&
loadHandlerScriptArgs(handlerPath, errorHandlerArgs).then(
const { path, kind } = draft.custom
path &&
loadHandlerScriptArgs(path, kind, errorHandlerArgs).then(
(schema) => (customHandlerSchema = schema)
)
})
$effect(() => {
handlerPath &&
isSlackHandler(handlerPath) &&
loadHandlerScriptArgs(handlerPath, [...errorHandlerArgs, 'slack']).then(
const path = draft.slack.path
path &&
loadHandlerScriptArgs(path, 'script', [...errorHandlerArgs, 'slack']).then(
(schema) => (slackHandlerSchema = schema)
)
})
$effect(() => {
if (handlerSelected === 'email') {
handlerPath = hubPaths.emailErrorHandler
}
})
// The instance channels are reached by having no workspace handler at all, so the tab
// owns an empty path rather than a handler script.
$effect(() => {
if (handlerSelected === 'instance_alerts') {
handlerPath = undefined
}
})
</script>
<div class={classNames('space-y-2', noMargin ? '' : 'mt-2')}>
<ToggleButtonGroup bind:selected={handlerSelected} disabled={!isEditable}>
<ToggleButtonGroup bind:selected={draft.selected} disabled={!isEditable}>
{#snippet children({ item })}
<ToggleButton label="Slack" value="slack" {item} disabled={!isEditable} />
<ToggleButton label="Teams" value="teams" {item} disabled={!isEditable} />
@@ -418,21 +290,21 @@
</ToggleButtonGroup>
<div class="flex flex-col gap-6 p-4 rounded-md shadow-sm bg-surface-tertiary">
{#if handlerSelected === 'custom'}
{#if draft.selected === 'custom'}
<div class="flex flex-col gap-1">
<div class="flex flex-row">
<ScriptPicker
disabled={!isEditable || !$enterpriseLicense}
kinds={['script', 'failure']}
allowFlow={true}
bind:scriptPath={handlerPath}
bind:itemKind={customHandlerKind}
bind:scriptPath={draft.custom.path}
bind:itemKind={draft.custom.kind}
allowRefresh={isEditable}
workspace={effectiveWorkspace}
clearable
/>
{#if !handlerPath}
{#if !draft.custom.path}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
@@ -456,7 +328,7 @@
</div>
{/if}
</div>
{#if handlerPath}
{#if draft.custom.path}
<div>
<p class="font-semibold text-xs mb-1">Extra arguments</p>
{#await import('$lib/components/SchemaForm.svelte')}
@@ -465,7 +337,7 @@
<Module.default
disabled={!isEditable}
schema={customHandlerSchema}
bind:args={handlerExtraArgs}
bind:args={draft.custom.args}
shouldHideNoInputs
className="text-xs"
/>
@@ -475,7 +347,7 @@
{/if}
</div>
{/if}
{:else if handlerSelected === 'slack'}
{:else if draft.selected === 'slack'}
<!-- Slack Connection Status -->
<SlackConnectionStatus
isConnected={workspaceConnectedToSlack}
@@ -487,23 +359,15 @@
{#if workspaceConnectedToSlack}
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isSlackHandler(handlerPath)}
checked={!!draft.slack.path}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = slackHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = slackRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = slackSuccessHandler
} else {
handlerPath = undefined
}
on:change={(e) => {
draft.slack.path = e.detail ? builtinHubPath(errorOrRecovery, 'slack') : undefined
}}
/>
{/if}
{#if workspaceConnectedToSlack && isSlackHandler(handlerPath)}
{#if workspaceConnectedToSlack && draft.slack.path}
<div class="flex flex-col gap-2">
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
@@ -515,18 +379,18 @@
schemaFieldTooltip={{
channel: 'Slack channel name without the "#" - example: "windmill-alerts"'
}}
bind:args={handlerExtraArgs}
bind:args={draft.slack.args}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{#if $enterpriseLicense && isSlackHandler(handlerPath)}
{#if $enterpriseLicense}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
disabled={emptyString(draft.slack.args['channel'])}
wrapperClasses="w-fit"
variant="default"
on:click={() => sendSlackMessage(handlerExtraArgs['channel'])}
on:click={() => sendSlackMessage(draft.slack.args['channel'])}
unifiedSize="md">Send test message</Button
>
{#if connectionTestJob !== undefined}
@@ -559,7 +423,7 @@
{:else if workspaceConnectedToSlack == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{:else if handlerSelected === 'teams'}
{:else if draft.selected === 'teams'}
<!-- Teams Connection Status -->
<TeamsConnectionStatus
isConnected={workspaceConnectedToTeams}
@@ -571,18 +435,10 @@
{#if workspaceConnectedToTeams}
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isTeamsHandler(handlerPath)}
checked={!!draft.teams.path}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = teamsHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = teamsRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = teamsSuccessHandler
} else {
handlerPath = undefined
}
on:change={(e) => {
draft.teams.path = e.detail ? builtinHubPath(errorOrRecovery, 'teams') : undefined
}}
/>
{/if}
@@ -604,26 +460,26 @@
placeholder="Search Teams channels"
workspace={effectiveWorkspace}
teamId={teams_team_guid}
selectedChannel={handlerExtraArgs['channel']
selectedChannel={draft.teams.args['channel']
? {
channel_id: handlerExtraArgs['channel'],
channel_name: handlerExtraArgs['channel_name']
channel_id: draft.teams.args['channel'],
channel_name: draft.teams.args['channel_name']
}
: undefined}
onSelectedChannelChange={(channel) => {
handlerExtraArgs['channel'] = channel?.channel_id
handlerExtraArgs['channel_name'] = channel?.channel_name
draft.teams.args['channel'] = channel?.channel_id
draft.teams.args['channel_name'] = channel?.channel_name
}}
onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)}
/>
</div>
</div>
{#if $enterpriseLicense && isTeamsHandler(handlerPath) && workspaceConnectedToTeams}
{#if $enterpriseLicense && draft.teams.path && workspaceConnectedToTeams}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
disabled={emptyString(draft.teams.args['channel'])}
btnClasses="w-32 text-center whitespace-nowrap"
variant="default"
on:click={() => sendTeamsMessage(handlerExtraArgs['channel'] ?? '')}
on:click={() => sendTeamsMessage(draft.teams.args['channel'] ?? '')}
size="xs">Send test message</Button
>
{#if connectionTestJob !== undefined}
@@ -649,7 +505,7 @@
{:else if workspaceConnectedToTeams == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{:else if handlerSelected === 'email'}
{:else if draft.selected === '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
@@ -662,8 +518,8 @@
<MultiSelect
items={[] as { label: string; value: string }[]}
bind:value={
() => handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [],
(recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients)
() => draft.email.args.email_recipients ?? [],
(recipients) => (draft.email.args.email_recipients = recipients)
}
placeholder="Enter email addresses..."
onCreateItem={(email) => {
@@ -672,23 +528,24 @@
sendUserToast('Invalid email format', true)
return
}
const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? []
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email]
draft.email.args.email_recipients = [
...(draft.email.args.email_recipients ?? []),
email
]
}}
class="w-full"
/>
{#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0}
{#if draft.email.args.email_recipients?.length > 0}
<span class="text-xs text-secondary">
{handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[
EMAIL_RECIPIENTS_KEY
]?.length === 1
{draft.email.args.email_recipients.length} email{draft.email.args.email_recipients
.length === 1
? ''
: 's'} configured
</span>
{/if}
</div>
{/if}
{:else if handlerSelected === 'instance_alerts'}
{:else if draft.selected === 'instance_alerts'}
<div class="flex flex-col gap-2">
<span class="text-xs text-secondary">
Failed jobs are reported to the Slack, Teams and email channels configured at the instance
@@ -1583,6 +1583,7 @@
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'flow',
path: pathSnapshot ?? $pathStore,
summary: flowStore.val.summary,
summaryContent: flowSummary,
// Gated like the summary's own editor: a host that allows neither field keeps the
// band's segment, which copies the path.
@@ -160,6 +160,9 @@ not this component) — so the lead can run the whole line.
// The summary is the band's, not the breadcrumb's (see PageHeaderBar), so the path reads as the
// last segment only for an item that has no summary to follow it.
const itemIsLast = $derived(!!item && !item.summaryContent && !item.summary)
/** Only the summary's text names the item: a control offering to add one does not, and the path
* then stays at every stage. */
const summaryNamesItem = $derived(!!item?.summary?.trim())
// No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the
// fork's own name inside, so the family's name is not painted as if it were the fork.
@@ -388,7 +391,7 @@ not this component) — so the lead can run the whole line.
<!-- The summary that follows is the item's name for a person, so on a short bar it is the path
that goes (see `TrailStage.path`). What the page hangs after the name stays. -->
{@const pathShown = f.path || itemIsLast}
{@const pathShown = f.path || !summaryNamesItem}
{#if !nameOnly && (item ? pathShown : section || routePage)}
{@render slash()}
{/if}
@@ -37,6 +37,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const item = $derived(content?.item)
const section = $derived(content?.section)
const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary))
/** A summary control can be a placeholder offering to add one; the text is what names the item. */
const summaryNamesItem = $derived(!!item?.summary?.trim())
const pickerAfterSummary = $derived(hasSummary && !navHidden && !!item?.path)
/** A phone's bar: a tighter gutter around the same row. */
@@ -58,8 +60,10 @@ The row's height matches the sidebar's own header row, so the two read as one ba
let summaryContentWidth = $state(0)
let handleWidth = $state(0)
/** The actions box at each fold level it has been seen at, for this page. */
let actionWidths = $state<(number | undefined)[]>([])
let actionWidthsByVersion = $state<Record<string, (number | undefined)[]>>({})
let actionsEpoch = $state(0)
const actionsVersion = $derived(content?.actionsVersion ?? '')
const actionWidths = $derived(actionWidthsByVersion[actionsVersion] ?? [])
const handleShown = $derived((navDetached.val || panelled) && !navHidden)
// The bar's own padding, in px, as the classes on the row below set it.
@@ -81,6 +85,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
earlyFolds: content?.earlyActionFolds,
summaryNamesItem,
natural: {
family: trailMeasures.family,
fork: trailMeasures.fork,
@@ -90,13 +95,34 @@ The row's height matches the sidebar's own header row, so the two read as one ba
})
})
const stage = $derived(BAR_STAGES[fit.stage])
onDestroy(pageHeader.provideActionFold(() => fit.fold))
/** The level alone: `fit` is a new object on every recompute, and the attachment re-measuring
* on each one would feed itself. */
const fold = $derived(fit.fold)
onDestroy(pageHeader.provideActionFold(() => fold))
// Re-attached whenever the fold level changes, so the first reading at a level is taken even
// when the row comes out exactly as wide as it was at the last one.
function measureActions(fold: number, _epoch: number): Attachment<HTMLElement> {
// Re-attached whenever the fold level, the page or the actions' version changes, so the first
// reading there is taken even when the row comes out exactly as wide as it was before.
// The page's actions can change on screen — a run's Cancel goes once the job completes — and
// the other levels most likely changed with them, so a change here drops their widths: kept, a
// stale wider one would hold the row folded for good. The bar measures them again by trying.
function recordActions(fold: number, width: number) {
const version = actionsVersion
const widths = actionWidthsByVersion[version] ?? []
const seen = widths[fold]
// Unchanged, nothing is written: a write recomputes `fit`, and a reading that always wrote
// kept the bar measuring every frame.
if (seen === width) return
const next = seen !== undefined ? [] : [...widths]
next[fold] = width
actionWidthsByVersion[version] = next
}
function measureActions(
fold: number,
_epoch: number,
_version: string
): Attachment<HTMLElement> {
return (node) => {
const ro = new ResizeObserver(() => (actionWidths[fold] = GAP + node.offsetWidth))
const ro = new ResizeObserver(() => recordActions(fold, GAP + node.offsetWidth))
ro.observe(node)
return () => ro.disconnect()
}
@@ -136,7 +162,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
afterNavigate(({ from, to }) => {
scrolledUnder = false
if (from?.url.pathname !== to?.url.pathname) {
actionWidths = []
actionWidthsByVersion = {}
actionsEpoch++
}
})
@@ -250,7 +276,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
another level of it. With the path dropped it follows the workspace, and the slash is
the trail's own separator. Fixed width, so either fits the width measured below. -->
<span class="shrink-0 w-2 text-center text-hint/40 text-xs" aria-hidden="true"
>{stage.path ? '·' : '/'}</span
>{stage.path || !summaryNamesItem ? '·' : '/'}</span
>
{#if item.summaryContent}
<!-- Read only while uncapped: capped, the box says how wide the bar let it be, not how
@@ -297,7 +323,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
bar and run to its right edge, and they stretch its full height because that surface
sits on the bottom edge. -->
<div
{@attach measureActions(fit.fold, actionsEpoch)}
{@attach measureActions(fold, actionsEpoch, actionsVersion)}
class={twMerge(
'flex',
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
@@ -20,6 +20,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsFill,
actionFolds,
earlyActionFolds,
actionsVersion,
belowBar,
belowBarFold,
separator,
@@ -39,6 +40,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsFill,
actionFolds,
earlyActionFolds,
actionsVersion,
belowBar,
belowBarFold,
separator,
@@ -2311,6 +2311,7 @@
// route binds here and drops the `{#if}` around this editor in one flush, which
// invalidates the band before this component is torn down.
path: pathSnapshot ?? script?.path,
summary: script?.summary,
summaryContent: scriptSummary,
// Gated like the summary's own editor: a host that allows neither field keeps the
// band's segment, which copies the path.
@@ -1002,6 +1002,7 @@
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'app',
path: pathSnapshot ?? (newEditedPath || $appPath || newPath || undefined),
summary: $summary,
summaryContent: appSummary,
pathTrigger: appPathTrigger
}}
@@ -656,22 +656,13 @@
wsId={$workspaceStore}
itemKind="script"
canWrite
bind:errorHandlerSelected={config.advanced.errorHandlerSelected}
bind:errorHandlerPath={config.advanced.errorHandlerPath}
bind:errorHandleritemKind={config.advanced.errorHandleritemKind}
bind:errorHandlerExtraArgs={config.advanced.errorHandlerExtraArgs}
bind:errorHandler={config.advanced.errorHandler}
bind:wsErrorHandlerMuted={config.advanced.wsErrorHandlerMuted}
bind:failedTimes={config.advanced.failedTimes}
bind:failedExact={config.advanced.failedExact}
bind:recoveryHandlerSelected={config.advanced.recoveryHandlerSelected}
bind:recoveryHandlerPath={config.advanced.recoveryHandlerPath}
bind:recoveryHandlerItemKind={config.advanced.recoveryHandlerItemKind}
bind:recoveryHandlerExtraArgs={config.advanced.recoveryHandlerExtraArgs}
bind:recoveryHandler={config.advanced.recoveryHandler}
bind:recoveredTimes={config.advanced.recoveredTimes}
bind:successHandlerSelected={config.advanced.successHandlerSelected}
bind:successHandlerPath={config.advanced.successHandlerPath}
bind:successHandlerItemKind={config.advanced.successHandlerItemKind}
bind:successHandlerExtraArgs={config.advanced.successHandlerExtraArgs}
bind:successHandler={config.advanced.successHandler}
bind:retry={config.advanced.retry}
bind:dynamicSkipPath={config.advanced.dynamicSkipPath}
bind:tag={config.advanced.tag}
@@ -106,10 +106,11 @@
// The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below):
// first the secondary ones go to their `placement`, then every bar button but the last, which
// is what the page is for (Edit), then that one too. The host knows when its own box is narrow
// while the window is not — a session's preview panel — and says so through `wide`, which only
// sends the error handler's toggle to the menu.
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : 0)
// is what the page is for (Edit), then that one too. Outside the band — a session's preview
// panel — no bar measures the row, but the host knows when its own box is narrow while the
// window is not and says so through `wide`: the secondary buttons fold there, as at the first
// fold, and the error handler's toggle goes to the menu.
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : wide ? 0 : 1)
const wideRow = $derived(wide && fold <= 1)
const rowButtons = $derived(mainButtons.filter((b) => !b.placement))
@@ -303,6 +304,9 @@
item={{
kind: errorHandlerKind,
path,
// Read-only, the summary control falls back to the path when there is no summary, so the
// path is what names the item there and the trail's own copy can go.
summary: onSaved ? summary : summary || path,
summaryContent,
// Only where this page can save one: without `onSaved` the editor behind it has nothing
// to write, and the band keeps its own segment, which copies the path.
@@ -318,6 +318,7 @@
<PageHeaderContent
item={{
path: pathSnapshot ?? shownPath,
summary: draft?.state?.description,
summaryContent: agentSummary,
// Only where the rename behind it can be saved: a reader who cannot write this agent
// keeps the segment that copies the path, rather than an editor with nothing to edit.
@@ -0,0 +1,118 @@
import type { ErrorHandler } from '$lib/gen'
import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts'
import hubPaths from '$lib/hubPaths.json'
export type HandlerMode = 'error' | 'recovery' | 'success'
type BuiltinDestination = 'slack' | 'teams' | 'email'
export type HandlerSlot = { path?: string; args: Record<string, any> }
export type HandlerDraft = {
selected: ErrorHandler
slack: HandlerSlot
teams: HandlerSlot
email: HandlerSlot
custom: HandlerSlot & { kind: 'script' | 'flow' }
}
type StoredHandler = { path?: string; kind: 'script' | 'flow'; args: Record<string, any> }
const SLACK_BOT_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH
// Suffixes rather than full paths, so a handler stored under an older hub id is still recognised.
// Email uses one script for every mode.
const EMAIL_SUFFIX = '/workspace-or-error-handler-email'
const BUILTIN_SUFFIXES: Record<HandlerMode, Record<'slack' | 'teams', string>> = {
error: {
slack: '/workspace-or-schedule-error-handler-slack',
teams: '/workspace-or-schedule-error-handler-teams'
},
recovery: {
slack: '/schedule-recovery-handler-slack',
teams: '/schedule-recovery-handler-teams'
},
success: {
slack: '/schedule-success-handler-slack',
teams: '/schedule-success-handler-teams'
}
}
// The only settings each built-in hub script takes besides the ones the backend injects.
const BUILTIN_KEYS: Record<BuiltinDestination, string[]> = {
slack: ['channel'],
teams: ['channel', 'channel_name'],
email: ['email_recipients']
}
export function builtinHubPath(mode: HandlerMode, destination: 'slack' | 'teams'): string {
const key =
`${destination}${mode[0].toUpperCase()}${mode.slice(1)}Handler` as keyof typeof hubPaths
return hubPaths[key]
}
export function handlerTypeForPath(mode: HandlerMode, path: string | undefined): ErrorHandler {
if (path?.startsWith('hub/')) {
if (path.endsWith(EMAIL_SUFFIX)) return 'email'
for (const [destination, suffix] of Object.entries(BUILTIN_SUFFIXES[mode])) {
if (path.endsWith(suffix)) return destination as BuiltinDestination
}
}
return 'custom'
}
function emptyDraft(selected: ErrorHandler): HandlerDraft {
return {
selected,
slack: { args: {} },
teams: { args: {} },
email: { path: hubPaths.emailErrorHandler, args: {} },
custom: { kind: 'script', args: {} }
}
}
function pick(args: Record<string, any>, keys: string[]): Record<string, any> {
return Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
}
/** Puts a stored handler into the slot of its destination; every other slot starts empty. */
export function handlerDraftFromStored(
mode: HandlerMode,
path: string | undefined,
kind: 'script' | 'flow' = 'script',
args: Record<string, any> | null | undefined = {},
opts: { asCustom?: boolean; emptySelected?: ErrorHandler } = {}
): HandlerDraft {
if (!path) return emptyDraft(opts.emptySelected ?? 'custom')
const destination = opts.asCustom ? 'custom' : handlerTypeForPath(mode, path)
const draft = emptyDraft(destination)
if (destination === 'custom') {
draft.custom = { path, kind, args: { ...(args ?? {}) } }
} else if (destination !== 'instance_alerts') {
draft[destination] = { path, args: pick(args ?? {}, BUILTIN_KEYS[destination]) }
}
return draft
}
/** What a save stores: the selected destination only. */
export function handlerDraftToStored(draft: HandlerDraft): StoredHandler {
const none: StoredHandler = { kind: 'script', args: {} }
switch (draft.selected) {
case 'custom': {
const { path, kind, args } = draft.custom
return path ? { path, kind, args: $state.snapshot(args) } : none
}
case 'slack':
case 'teams':
case 'email': {
const { path, args } = draft[draft.selected]
if (!path) return none
const stored = { path, kind: 'script' as const, args: $state.snapshot(args) }
if (draft.selected === 'slack') stored.args.slack = SLACK_BOT_ARG
return stored
}
default:
return none
}
}
export function cloneHandlerDraft(draft: HandlerDraft): HandlerDraft {
return $state.snapshot(draft) as HandlerDraft
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
handlerDraftFromStored,
handlerDraftToStored,
handlerTypeForPath
} from './handlerDraft.svelte'
const SLACK_BOT = '$res:f/slack_bot/bot_token'
describe('handler draft', () => {
it('round-trips every destination unchanged', () => {
const stored = [
{
path: 'hub/28794/workspace-or-schedule-error-handler-slack',
kind: 'script' as const,
args: { channel: 'alerts', slack: SLACK_BOT }
},
{
path: 'hub/19742/workspace-or-schedule-error-handler-teams',
kind: 'script' as const,
args: { channel: 'c1', channel_name: 'General' }
},
{
path: 'hub/19795/workspace-or-error-handler-email',
kind: 'script' as const,
args: { email_recipients: ['ops@example.com'] }
},
{
path: 'f/handlers/on_error',
kind: 'flow' as const,
args: { slack: '$res:u/me/my_slack', channel: 'own' }
}
]
for (const s of stored) {
const draft = handlerDraftFromStored('error', s.path, s.kind, s.args)
expect(handlerDraftToStored(draft)).toEqual(s)
}
})
it("saves only the selected destination's settings", () => {
const draft = handlerDraftFromStored('error', 'u/me/handler', 'script', {
webhook_url: 'https://hooks.example/custom'
})
draft.selected = 'email'
draft.email.args.email_recipients = ['ops@example.com']
expect(handlerDraftToStored(draft)).toEqual({
path: 'hub/19795/workspace-or-error-handler-email',
kind: 'script',
args: { email_recipients: ['ops@example.com'] }
})
})
it('recognises built-in handlers per mode', () => {
const recoverySlack = 'hub/28791/slack/schedule-recovery-handler-slack'
expect(handlerTypeForPath('recovery', recoverySlack)).toBe('slack')
expect(handlerTypeForPath('error', recoverySlack)).toBe('custom')
})
it('stores no handler when the destination has none', () => {
const draft = handlerDraftFromStored('error', undefined)
expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} })
draft.selected = 'slack'
expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} })
draft.selected = 'instance_alerts'
expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} })
})
})
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest'
import { syncSlackBotArg } from './handlerSlackArg'
const AUTO = '$res:f/slack_bot/bot_token'
describe('syncSlackBotArg', () => {
it("leaves a custom handler's slack value alone", () => {
const args = { slack: '$res:u/me/my_slack' }
syncSlackBotArg(args, false, false)
syncSlackBotArg(args, false, true)
expect(args).toEqual({ slack: '$res:u/me/my_slack' })
})
it('sets the bot token for the built-in and drops it when leaving', () => {
const args: Record<string, any> = {}
syncSlackBotArg(args, true, false)
expect(args.slack).toBe(AUTO)
syncSlackBotArg(args, false, true)
expect(args).toStrictEqual({})
})
})
@@ -1,17 +0,0 @@
import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts'
const AUTO_SLACK_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH
// `slack` belongs to the Slack built-in only while it holds the value the picker set; any other
// value is a custom handler's own argument and must survive handler switches and reloads.
export function syncSlackBotArg(
args: Record<string, any>,
isSlackBuiltin: boolean,
wasSlackBuiltin: boolean
) {
if (isSlackBuiltin) {
args.slack = AUTO_SLACK_ARG
} else if (wasSlackBuiltin && args.slack === AUTO_SLACK_ARG) {
delete args.slack
}
}
+1 -1
View File
@@ -446,7 +446,7 @@ export const APP_TO_ICON_COMPONENT = {
quickbooks: QuickbooksIcon,
ms_teams_webhook: MsTeamsIcon,
teams: MsTeamsIcon,
outlook: OutlookIcon,
ms_outlook: OutlookIcon,
mailgun: MailgunIcon,
ipinfo: IpinfoIcon,
gworkspace: GoogleIcon,
@@ -60,6 +60,10 @@ export type PageHeaderContent = {
/** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons
* that are there only while the bar has room for everything. */
earlyActionFolds?: number
/** Changes whenever the actions may have changed width at a level that is not on screen — a
* run's Cancel, folded into a menu, going once the job completes. The bar keeps what it
* measured per version, so a new one is measured afresh rather than judged by stale widths. */
actionsVersion?: string
/** A second row of the band, under the breadcrumb and the actions: where a control too wide
* for one row goes at a fold that has no room for it beside them, rather than being squeezed
* until it is useless. The band is taller while it is there. */
@@ -171,6 +175,7 @@ export const pageHeader = {
if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill
if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds
if (c.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds
if (c.actionsVersion !== undefined) merged.actionsVersion = c.actionsVersion
if (c.belowBar !== undefined) merged.belowBar = c.belowBar
if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold
if (c.separator !== undefined) merged.separator = c.separator
@@ -63,5 +63,15 @@ describe('fitBar', () => {
const fit = fitBar({ ...base, barWidth: 520, natural, actions: [0], folds: 0 })
expect(fit.stage).toBe(2)
expect(fit.caps.name).toBeUndefined()
// A placeholder offering to add a summary names nothing: the path stays a part the bar sizes.
const placeholder = fitBar({
...base,
barWidth: 520,
natural,
actions: [0],
folds: 0,
summaryNamesItem: false
})
expect(placeholder.caps.name).toBeDefined()
})
})
@@ -95,7 +95,12 @@ export function shownPathLevels(levels: string[], stage: TrailStage): string[] {
}
/** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */
export function partVisible(part: Part, stage: BarStage, natural: Record<Part, number>): boolean {
export function partVisible(
part: Part,
stage: BarStage,
natural: Record<Part, number>,
summaryNamesItem = true
): boolean {
if (natural[part] <= 0) return false
switch (part) {
case 'summary':
@@ -106,7 +111,7 @@ export function partVisible(part: Part, stage: BarStage, natural: Record<Part, n
case 'fork':
return !stage.wsButton && stage.forkName
case 'name':
return stage.path || natural.summary <= 0
return stage.path || natural.summary <= 0 || !summaryNamesItem
}
}
@@ -126,6 +131,9 @@ export type BarMeasure = {
* only while there is room for everything. */
earlyFolds?: number
natural: Record<Part, number>
/** The summary is the item's own text, not a placeholder offering to add one: only then does it
* name the item well enough for the path to go. */
summaryNamesItem?: boolean
}
export type BarFit = {
@@ -185,7 +193,7 @@ export function fitBar(m: BarMeasure): BarFit {
const at = BAR_STAGES[stage]
// The summary gives last: it is what a person calls the item, so the path's name goes first.
const parts = (['family', 'fork', 'name', 'summary'] as Part[]).filter((p) =>
partVisible(p, at, m.natural)
partVisible(p, at, m.natural, m.summaryNamesItem ?? true)
)
const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p]))
// The stage was measured with its parts at their floors; this is its width with them whole.
@@ -951,6 +951,7 @@
// rename popover is open so the trail holds still as the user types.
kind: 'app',
path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined),
summary,
summaryContent: appSummary,
pathTrigger: rawAppPathTrigger
}}
@@ -765,6 +765,25 @@
!isCloudHosted() &&
editInForkAllowed(workspaceId, $userWorkspaces)
)
/** Which actions the row has: they come and go as the job runs and completes, often while the
* row is folded into the menu, where the bar cannot see them change (`actionsVersion`). */
const actionsVersion = $derived(
[
canDelete,
canCancel,
forceCancel,
canDebug,
canRunNow,
canEdit,
canEditInFork,
dbtResumable,
!!job?.schedule_path,
persistentScriptDefinition !== undefined,
restartStep !== undefined && canRestart(restart)
]
.map(Number)
.join('')
)
async function runAgain() {
// The form this lands on rebuilds the whole project. When resuming THIS run is the cheaper
@@ -1059,7 +1078,12 @@
{@render runActions()}
</div>
{:else}
<PageHeaderContent section={{ label: `run/${runId}` }} actions={runActions} actionFolds={2} />
<PageHeaderContent
section={{ label: `run/${runId}` }}
actions={runActions}
actionFolds={2}
{actionsVersion}
/>
{/if}
{#snippet runActions()}
@@ -1,9 +1,10 @@
<script lang="ts">
import type { ErrorHandler, Retry } from '$lib/gen'
import type { Retry } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { emptyString } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
import ErrorOrRecoveryHandler from '../ErrorOrRecoveryHandler.svelte'
import type { HandlerDraft } from '../handlerDraft.svelte'
import FlowRetries from '../flows/content/FlowRetries.svelte'
import Tooltip from '../Tooltip.svelte'
@@ -11,18 +12,14 @@
optionTabSelected,
itemKind,
can_write,
errorHandlerSelected = $bindable(),
error_handler_path = $bindable(),
error_handler_args = $bindable(),
errorHandler = $bindable(),
retry = $bindable(),
workspace = undefined
}: {
optionTabSelected: 'error_handler' | 'retries' | string
itemKind: 'script' | 'flow'
can_write: boolean
errorHandlerSelected: ErrorHandler
error_handler_path: string | undefined
error_handler_args: Record<string, any>
errorHandler: HandlerDraft
retry: Retry | undefined
workspace?: string
} = $props()
@@ -42,12 +39,9 @@
errorOrRecovery="error"
showScriptHelpText={true}
{workspace}
bind:handlerSelected={errorHandlerSelected}
bind:handlerPath={error_handler_path}
bind:draft={errorHandler}
toggleText="Alert channel on error"
customScriptTemplate="/scripts/add?hub=hub%2F13953%2Fwindmill%2Ftrigger_error_handler_template"
customHandlerKind="script"
bind:handlerExtraArgs={error_handler_args}
>
{#snippet customTabTooltip()}
<Tooltip>
@@ -22,7 +22,6 @@
type AmqpExchange,
type AmqpOptions,
type Retry,
type ErrorHandler,
type TriggerMode
} from '$lib/gen'
import AmqpEditorConfigSection from './AmqpEditorConfigSection.svelte'
@@ -30,7 +29,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveAmqpTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
@@ -127,9 +127,10 @@
let permissionedAs = $state<string | undefined>(undefined)
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
@@ -248,10 +249,13 @@
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
@@ -280,10 +284,13 @@
mode = cfg?.mode ?? 'enabled'
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
@@ -667,9 +674,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
{/if}
@@ -14,13 +14,7 @@
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
import {
AzureTriggerService,
type AzureMode,
type Retry,
type ErrorHandler,
type TriggerMode
} from '$lib/gen'
import { AzureTriggerService, type AzureMode, type Retry, type TriggerMode } from '$lib/gen'
import Section from '$lib/components/Section.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Required from '$lib/components/Required.svelte'
@@ -29,7 +23,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveAzureTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
@@ -87,9 +82,10 @@
let base_endpoint = $derived(apiBaseUrl())
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
@@ -211,10 +207,13 @@
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
@@ -267,10 +266,13 @@
mode = cfg?.mode ?? 'enabled'
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
@@ -570,9 +572,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
</div>
@@ -11,7 +11,6 @@
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import {
EmailTriggerService,
type ErrorHandler,
type EmailTrigger,
type NewEmailTrigger,
type Retry,
@@ -26,7 +25,8 @@
import EmailTriggerEditorConfigSection from './EmailTriggerEditorConfigSection.svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { getHandlerType, handleConfigChange } from '../utils'
import { handleConfigChange } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import { untrack } from 'svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
@@ -96,8 +96,10 @@
: editRights.canEditItem('trigger', permsPath, permsForWrite)
)
let extraPerms = $state<Record<string, boolean> | undefined>(undefined)
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let mode = $state<TriggerMode>('enabled')
// Component references
@@ -108,7 +110,6 @@
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<NewEmailTrigger | undefined>(undefined)
@@ -222,10 +223,13 @@
dirtyPath = false
workspaced_local_part = defaultValues?.workspaced_local_part ?? false
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = defaultValues?.mode ?? 'enabled'
permissionedAs = undefined
selectedPermissionedAs = undefined
@@ -251,10 +255,13 @@
extraPerms = cfg?.extra_perms ?? undefined
permsPath = path
permsForWrite = cfg?.extra_perms ?? {}
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = cfg?.mode ?? 'enabled'
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
@@ -495,9 +502,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
</div>
@@ -20,7 +20,6 @@
type PushConfig,
type SubscriptionMode,
type Retry,
type ErrorHandler,
type TriggerMode
} from '$lib/gen'
import Section from '$lib/components/Section.svelte'
@@ -30,7 +29,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveGcpTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
@@ -93,9 +93,10 @@
let auto_acknowledge_msg = $state(true)
let ack_deadline: number | undefined = $state()
let optionTabSelected: 'settings' | 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
@@ -247,12 +248,15 @@
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
auto_acknowledge_msg = defaultValues?.auto_acknowledge_msg ?? true
ack_deadline = defaultValues?.ack_deadline
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
@@ -309,12 +313,15 @@
topic_id = cfg?.topic_id ?? ''
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
auto_acknowledge_msg = cfg?.auto_acknowledge_msg ?? true
ack_deadline = cfg?.ack_deadline
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
@@ -701,9 +708,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
{/if}
@@ -15,7 +15,6 @@
SettingService,
VariableService,
type AuthenticationMethod,
type ErrorHandler,
type HttpTrigger,
type NewHttpTrigger,
type Retry,
@@ -57,7 +56,8 @@
import TestingBadge from '../testingBadge.svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { getHandlerType, handleConfigChange } from '../utils'
import { handleConfigChange } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import autosize from '$lib/autosize'
import { untrack } from 'svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
@@ -166,8 +166,10 @@
let extraPerms = $state<Record<string, boolean> | undefined>(undefined)
let summary: string | undefined = $state()
let routeDescription: string | undefined = $state()
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
// Component references
let s3FilePicker = $state<S3FilePicker | undefined>(undefined)
@@ -181,7 +183,6 @@
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
let optionTabSelected: 'request_options' | 'error_handler' | 'retries' = $state('request_options')
let errorHandlerSelected: ErrorHandler = $state('slack')
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<NewHttpTrigger | undefined>(undefined)
@@ -349,10 +350,13 @@
allowed_origins = defaultValues?.allowed_origins ?? undefined
summary = defaultValues?.summary ?? ''
routeDescription = defaultValues?.description ?? ''
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
@@ -397,10 +401,13 @@
extraPerms = cfg?.extra_perms ?? undefined
permsPath = path
permsForWrite = cfg?.extra_perms ?? {}
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
@@ -1036,9 +1043,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
{/if}
@@ -10,7 +10,7 @@
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
import { KafkaTriggerService, type Retry, type TriggerMode } from '$lib/gen'
import { usedTriggerKinds } from '$lib/stores'
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
import { useEditRights } from '$lib/operatorWriteRights'
@@ -23,7 +23,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveKafkaTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
@@ -127,9 +128,10 @@
let preservePermissionedAs = $state(false)
let resetLoading = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let filters: FilterNode[] = $state([])
let filterLogic = $state<'and' | 'or'>('and')
@@ -254,12 +256,15 @@
path = nDefaultValues?.path ?? ''
initialPath = ''
dirtyPath = false
error_handler_path = nDefaultValues?.error_handler_path ?? undefined
error_handler_args = nDefaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
nDefaultValues?.error_handler_path,
'script',
nDefaultValues?.error_handler_args
)
retry = nDefaultValues?.retry ?? undefined
filters = nDefaultValues?.filters ?? []
filterLogic = 'and'
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = nDefaultValues?.mode ?? 'enabled'
permissionedAs = undefined
selectedPermissionedAs = undefined
@@ -291,12 +296,15 @@
extra_perms = cfg?.extra_perms
permsPath = path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
filters = cfg?.filters ?? []
filterLogic = cfg?.filter_logic ?? 'and'
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
@@ -719,9 +727,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
</div>
@@ -24,7 +24,6 @@
type MqttV5Config,
type MqttSubscribeTopic,
type Retry,
type ErrorHandler,
type TriggerMode
} from '$lib/gen'
import MqttEditorConfigSection from './MqttEditorConfigSection.svelte'
@@ -32,7 +31,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveMqttTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
@@ -135,9 +135,10 @@
let permissionedAs = $state<string | undefined>(undefined)
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
@@ -247,10 +248,13 @@
client_version = defaultValues?.client_version ?? 'v5'
client_id = defaultValues?.client_id ?? ''
mode = defaultValues?.mode ?? 'enabled'
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
v3_config = defaultValues?.v3_config ?? DEFAULT_V3_CONFIG
v5_config = defaultValues?.v5_config ?? DEFAULT_V5_CONFIG
activateV5Options.topic_alias_maximum = Boolean(defaultValues?.v5_config?.topic_alias_maximum)
@@ -286,10 +290,13 @@
client_id = cfg?.client_id ?? ''
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
activateV5Options.topic_alias_maximum = Boolean(v5_config.topic_alias_maximum)
activateV5Options.session_expiry_interval = Boolean(v5_config.session_expiry_interval)
permissionedAs = cfg?.permissioned_as
@@ -750,9 +757,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
{/if}
@@ -9,7 +9,7 @@
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import { NatsTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
import { NatsTriggerService, type Retry, type TriggerMode } from '$lib/gen'
import { usedTriggerKinds } from '$lib/stores'
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
import { useEditRights } from '$lib/operatorWriteRights'
@@ -22,7 +22,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveNatsTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
@@ -128,9 +129,10 @@
let preservePermissionedAs = $state(false)
let isValid = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let mode = $state<TriggerMode>('enabled')
@@ -246,10 +248,13 @@
dirtyPath = false
defaultValues = nDefaultValues
mode = nDefaultValues?.mode ?? 'enabled'
error_handler_path = nDefaultValues?.error_handler_path ?? undefined
error_handler_args = nDefaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
nDefaultValues?.error_handler_path,
'script',
nDefaultValues?.error_handler_args
)
retry = nDefaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
@@ -280,10 +285,13 @@
mode = cfg?.mode ?? 'enabled'
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
@@ -619,9 +627,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
</div>
@@ -12,7 +12,6 @@
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import {
PostgresTriggerService,
type ErrorHandler,
type Language,
type Relations,
type Retry,
@@ -41,7 +40,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import TestingBadge from '../testingBadge.svelte'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { fade } from 'svelte/transition'
@@ -154,9 +154,10 @@
let creatingPublication: boolean = $state(false)
let pg14: boolean = $derived(postgresVersion.startsWith('14'))
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let mode = $state<TriggerMode>('enabled')
@@ -355,10 +356,13 @@
table_to_track: []
}
]
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = defaultValues?.mode ?? 'enabled'
permissionedAs = undefined
selectedPermissionedAs = undefined
@@ -411,10 +415,13 @@
permsForWrite = cfg?.extra_perms
transaction_to_track = [...cfg?.publication?.transaction_to_track]
relations = cfg?.publication?.table_to_track ?? []
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = cfg?.mode ?? 'enabled'
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
@@ -1028,9 +1035,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
</div>
@@ -1,89 +1,58 @@
<script lang="ts" module>
import { handlerFullPath } from '$lib/components/ErrorOrRecoveryHandler.svelte'
import { SettingService, type ErrorHandler, type Retry } from '$lib/gen'
import { SettingService, type Retry } from '$lib/gen'
import {
handlerDraftFromStored,
handlerDraftToStored,
type HandlerDraft,
type HandlerMode
} from '$lib/components/handlerDraft.svelte'
export type ScheduleAdvancedState = {
errorHandlerSelected: ErrorHandler
errorHandlerPath: string | undefined
errorHandleritemKind: 'flow' | 'script'
errorHandlerExtraArgs: Record<string, any>
errorHandler: HandlerDraft
wsErrorHandlerMuted: boolean
failedTimes: number
failedExact: boolean
recoveryHandlerSelected: ErrorHandler
recoveryHandlerPath: string | undefined
recoveryHandlerItemKind: 'flow' | 'script'
recoveryHandlerExtraArgs: Record<string, any>
recoveryHandler: HandlerDraft
recoveredTimes: number
successHandlerSelected: ErrorHandler
successHandlerPath: string | undefined
successHandlerItemKind: 'flow' | 'script'
successHandlerExtraArgs: Record<string, any>
successHandler: HandlerDraft
retry: Retry | undefined
dynamicSkipPath: string | undefined
tag: string | undefined
}
/** A schedule handler stored as `<script|flow>/<path>`, or none (shown on the Slack tab). */
export function scheduleHandlerDraft(
mode: HandlerMode,
fullPath?: string | null,
args?: Record<string, any> | null
): HandlerDraft {
const [kind, ...path] = (fullPath ?? '').split('/')
return handlerDraftFromStored(mode, path.join('/'), kind === 'flow' ? 'flow' : 'script', args, {
emptySelected: 'slack'
})
}
/** The `<script|flow>/<path>` a schedule stores, with its extra args. */
export function scheduleHandlerStored(draft: HandlerDraft) {
const { path, kind, args } = handlerDraftToStored(draft)
return { path: path ? `${kind}/${path}` : undefined, args }
}
export function emptyScheduleAdvanced(): ScheduleAdvancedState {
return {
errorHandlerSelected: 'slack',
errorHandlerPath: undefined,
errorHandleritemKind: 'script',
errorHandlerExtraArgs: {},
errorHandler: scheduleHandlerDraft('error'),
wsErrorHandlerMuted: false,
failedTimes: 1,
failedExact: false,
recoveryHandlerSelected: 'slack',
recoveryHandlerPath: undefined,
recoveryHandlerItemKind: 'script',
recoveryHandlerExtraArgs: {},
recoveryHandler: scheduleHandlerDraft('recovery'),
recoveredTimes: 1,
successHandlerSelected: 'slack',
successHandlerPath: undefined,
successHandlerItemKind: 'script',
successHandlerExtraArgs: {},
successHandler: scheduleHandlerDraft('success'),
retry: undefined,
dynamicSkipPath: undefined,
tag: undefined
}
}
/** Which built-in handler a hub path is, or `custom`. */
export function getHandlerType(
isHandler: 'error' | 'recovery' | 'success',
scriptPath: string
): ErrorHandler {
const handlerMap = {
error: {
teams: '/workspace-or-schedule-error-handler-teams',
slack: '/workspace-or-schedule-error-handler-slack',
email: '/workspace-or-error-handler-email'
},
recovery: {
teams: '/schedule-recovery-handler-teams',
slack: '/schedule-recovery-handler-slack',
email: '/workspace-or-error-handler-email'
},
success: {
teams: '/schedule-success-handler-teams',
slack: '/schedule-success-handler-slack',
email: '/workspace-or-error-handler-email'
}
}
for (const [type, suffix] of Object.entries(handlerMap[isHandler])) {
if (scriptPath.startsWith('hub/') && scriptPath.endsWith(suffix)) {
return type as ErrorHandler
}
}
return 'custom'
}
const splitHandler = (path: string) => {
const [kind, ...rest] = path.split('/')
return { kind: kind as 'flow' | 'script', path: rest.join('/') }
}
/** The Advanced tabs of a new schedule: the workspace's default error, recovery
* and success handlers, where it sets them. */
export async function loadDefaultScheduleAdvanced(wsId: string): Promise<ScheduleAdvancedState> {
@@ -94,55 +63,48 @@
)
)) as any[]
if (err) {
const h = splitHandler(err.errorHandlerPath)
a.wsErrorHandlerMuted = err.wsErrorHandlerMuted
a.errorHandleritemKind = h.kind
a.errorHandlerPath = h.path
a.errorHandlerExtraArgs = err.errorHandlerExtraArgs
a.errorHandlerSelected = getHandlerType('error', h.path)
a.errorHandler = scheduleHandlerDraft(
'error',
err.errorHandlerPath,
err.errorHandlerExtraArgs
)
a.failedTimes = err.failedTimes
a.failedExact = err.failedExact
}
if (rec) {
const h = splitHandler(rec.recoveryHandlerPath)
a.recoveryHandlerItemKind = h.kind
a.recoveryHandlerPath = h.path
a.recoveryHandlerExtraArgs = rec.recoveryHandlerExtraArgs
a.recoveryHandlerSelected = getHandlerType('recovery', h.path)
a.recoveryHandler = scheduleHandlerDraft(
'recovery',
rec.recoveryHandlerPath,
rec.recoveryHandlerExtraArgs
)
a.recoveredTimes = rec.recoveredTimes
}
if (suc) {
const h = splitHandler(suc.successHandlerPath)
a.successHandlerItemKind = h.kind
a.successHandlerPath = h.path
a.successHandlerExtraArgs = suc.successHandlerExtraArgs
a.successHandlerSelected = getHandlerType('success', h.path)
a.successHandler = scheduleHandlerDraft(
'success',
suc.successHandlerPath,
suc.successHandlerExtraArgs
)
}
return a
}
/** The schedule fields the Advanced tabs own, as the schedule API takes them. */
export function scheduleAdvancedCfg(a: ScheduleAdvancedState) {
const error = scheduleHandlerStored(a.errorHandler)
const recovery = scheduleHandlerStored(a.recoveryHandler)
const success = scheduleHandlerStored(a.successHandler)
return {
on_failure: a.errorHandlerPath
? handlerFullPath(a.errorHandlerSelected, a.errorHandleritemKind, a.errorHandlerPath)
: undefined,
on_failure: error.path,
on_failure_times: a.failedTimes,
on_failure_exact: a.failedExact,
on_failure_extra_args: a.errorHandlerPath ? a.errorHandlerExtraArgs : undefined,
on_recovery: a.recoveryHandlerPath
? handlerFullPath(
a.recoveryHandlerSelected,
a.recoveryHandlerItemKind,
a.recoveryHandlerPath
)
: undefined,
on_failure_extra_args: error.path ? error.args : undefined,
on_recovery: recovery.path,
on_recovery_times: a.recoveredTimes,
on_recovery_extra_args: a.recoveryHandlerPath ? a.recoveryHandlerExtraArgs : {},
on_success: a.successHandlerPath
? handlerFullPath(a.successHandlerSelected, a.successHandlerItemKind, a.successHandlerPath)
: undefined,
on_success_extra_args: a.successHandlerPath ? a.successHandlerExtraArgs : {},
on_recovery_extra_args: recovery.args,
on_success: success.path,
on_success_extra_args: success.args,
ws_error_handler_muted: a.wsErrorHandlerMuted,
retry: a.retry,
tag: a.tag,
@@ -174,22 +136,13 @@
itemKind: 'flow' | 'script'
canWrite: boolean
loading?: boolean
errorHandlerSelected: ErrorHandler
errorHandlerPath: string | undefined
errorHandleritemKind: 'flow' | 'script'
errorHandlerExtraArgs: Record<string, any>
errorHandler: HandlerDraft
wsErrorHandlerMuted: boolean
failedTimes: number
failedExact: boolean
recoveryHandlerSelected: ErrorHandler
recoveryHandlerPath: string | undefined
recoveryHandlerItemKind: 'flow' | 'script'
recoveryHandlerExtraArgs: Record<string, any>
recoveryHandler: HandlerDraft
recoveredTimes: number
successHandlerSelected: ErrorHandler
successHandlerPath: string | undefined
successHandlerItemKind: 'flow' | 'script'
successHandlerExtraArgs: Record<string, any>
successHandler: HandlerDraft
retry: Retry | undefined
dynamicSkipPath: string | undefined
tag: string | undefined
@@ -200,22 +153,13 @@
itemKind,
canWrite,
loading = false,
errorHandlerSelected = $bindable(),
errorHandlerPath = $bindable(),
errorHandleritemKind = $bindable(),
errorHandlerExtraArgs = $bindable(),
errorHandler = $bindable(),
wsErrorHandlerMuted = $bindable(),
failedTimes = $bindable(),
failedExact = $bindable(),
recoveryHandlerSelected = $bindable(),
recoveryHandlerPath = $bindable(),
recoveryHandlerItemKind = $bindable(),
recoveryHandlerExtraArgs = $bindable(),
recoveryHandler = $bindable(),
recoveredTimes = $bindable(),
successHandlerSelected = $bindable(),
successHandlerPath = $bindable(),
successHandlerItemKind = $bindable(),
successHandlerExtraArgs = $bindable(),
successHandler = $bindable(),
retry = $bindable(),
dynamicSkipPath = $bindable(),
tag = $bindable()
@@ -233,29 +177,30 @@
// in the workspace this schedule lives in.
const wsParam = $derived(wsId ? `&workspace=${encodeURIComponent(wsId)}` : '')
const errorHandlerPath = $derived(scheduleHandlerStored(errorHandler).path)
const recoveryHandlerPath = $derived(scheduleHandlerStored(recoveryHandler).path)
async function saveAsDefaultErrorHandler(overrideExisting: boolean) {
if (!$enterpriseLicense) {
sendUserToast(`Setting default error handler is an enterprise edition feature`, true)
return
}
if (wsId) {
const handler = scheduleHandlerStored(errorHandler)
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'error',
override_existing: overrideExisting,
path:
errorHandlerPath == undefined
? undefined
: handlerFullPath(errorHandlerSelected, errorHandleritemKind, errorHandlerPath),
extra_args: errorHandlerExtraArgs,
path: handler.path,
extra_args: handler.args,
number_of_occurence: failedTimes,
number_of_occurence_exact: failedExact,
workspace_handler_muted: wsErrorHandlerMuted
}
})
if (errorHandlerPath !== undefined) {
sendUserToast(`Default error handler saved to ${errorHandlerPath}`, false)
if (handler.path !== undefined) {
sendUserToast(`Default error handler saved to ${handler.path}`, false)
} else {
sendUserToast(`Default error handler reset`, false)
}
@@ -268,25 +213,19 @@
return
}
if (wsId) {
const handler = scheduleHandlerStored(recoveryHandler)
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'recovery',
override_existing: overrideExisting,
path:
recoveryHandlerPath === undefined
? undefined
: handlerFullPath(
recoveryHandlerSelected,
recoveryHandlerItemKind,
recoveryHandlerPath
),
extra_args: recoveryHandlerExtraArgs,
path: handler.path,
extra_args: handler.args,
number_of_occurence: recoveredTimes
}
})
if (recoveryHandlerPath !== undefined) {
sendUserToast(`Default recovery handler saved to ${recoveryHandlerPath}`, false)
if (handler.path !== undefined) {
sendUserToast(`Default recovery handler saved to ${handler.path}`, false)
} else {
sendUserToast(`Default recovery handler reset`, false)
}
@@ -299,21 +238,19 @@
return
}
if (wsId) {
const handler = scheduleHandlerStored(successHandler)
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'success',
override_existing: overrideExisting,
path:
successHandlerPath === undefined
? undefined
: handlerFullPath(successHandlerSelected, successHandlerItemKind, successHandlerPath),
extra_args: successHandlerExtraArgs,
path: handler.path,
extra_args: handler.args,
number_of_occurence: recoveredTimes
}
})
if (successHandlerPath !== undefined) {
sendUserToast(`Default success handler saved to ${successHandlerPath}`, false)
if (handler.path !== undefined) {
sendUserToast(`Default success handler saved to ${handler.path}`, false)
} else {
sendUserToast(`Default success handler reset`, false)
}
@@ -377,12 +314,9 @@
isEditable={canWrite}
errorOrRecovery="error"
showScriptHelpText={true}
bind:handlerSelected={errorHandlerSelected}
bind:handlerPath={errorHandlerPath}
bind:draft={errorHandler}
toggleText="Alert channel on error"
customScriptTemplate="/scripts/add?hub=hub%2F19743%2Fwindmill%2Fschedule_error_handler_template"
bind:customHandlerKind={errorHandleritemKind}
bind:handlerExtraArgs={errorHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
@@ -471,12 +405,9 @@
workspace={wsId}
isEditable={!disabled}
errorOrRecovery="recovery"
bind:handlerSelected={recoveryHandlerSelected}
bind:handlerPath={recoveryHandlerPath}
bind:draft={recoveryHandler}
toggleText="Alert channel when error recovered"
customScriptTemplate="/scripts/add?hub=hub%2F9082%2Fwindmill%2Fschedule_recovery_handler_template"
bind:customHandlerKind={recoveryHandlerItemKind}
bind:handlerExtraArgs={recoveryHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
@@ -560,12 +491,9 @@
workspace={wsId}
isEditable={!disabled}
errorOrRecovery="success"
bind:handlerSelected={successHandlerSelected}
bind:handlerPath={successHandlerPath}
bind:draft={successHandler}
toggleText="Alert channel when successful"
customScriptTemplate="/scripts/add?hub=hub%2F9071%2Fwindmill%2Fschedule_success_handler_template"
bind:customHandlerKind={successHandlerItemKind}
bind:handlerExtraArgs={successHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
@@ -8,10 +8,12 @@
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import ScheduleAdvancedOptions, {
emptyScheduleAdvanced,
getHandlerType,
loadDefaultScheduleAdvanced,
scheduleAdvancedCfg
scheduleAdvancedCfg,
scheduleHandlerDraft,
scheduleHandlerStored
} from './ScheduleAdvancedOptions.svelte'
import type { HandlerDraft } from '$lib/components/handlerDraft.svelte'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import CronInput from '$lib/components/CronInput.svelte'
@@ -30,8 +32,7 @@
ScriptService,
type Flow,
type Retry,
type Schedule,
type ErrorHandler
type Schedule
} from '$lib/gen'
import { emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
import { useEditRights } from '$lib/operatorWriteRights'
@@ -81,19 +82,11 @@
let paused_until: string | undefined = $state(undefined)
let itemKind: 'flow' | 'script' = $state('script')
let is_flow: boolean = $derived.by(() => itemKind === 'flow')
let errorHandleritemKind: 'flow' | 'script' = $state('script')
let wsErrorHandlerMuted: boolean = $state(false)
let errorHandlerPath: string | undefined = $state(undefined)
let errorHandlerSelected: ErrorHandler = $state('slack')
let errorHandlerExtraArgs: Record<string, any> = $state({})
let recoveryHandlerPath: string | undefined = $state(undefined)
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: ErrorHandler = $state('slack')
let successHandlerItemKind: 'flow' | 'script' = $state('script')
let successHandlerExtraArgs: Record<string, any> = $state({})
let errorHandler: HandlerDraft = $state(scheduleHandlerDraft('error'))
let recoveryHandler: HandlerDraft = $state(scheduleHandlerDraft('recovery'))
let successHandler: HandlerDraft = $state(scheduleHandlerDraft('success'))
const errorHandlerPath = $derived(scheduleHandlerStored(errorHandler).path)
let failedTimes = $state(1)
let failedExact = $state(false)
let recoveredTimes = $state(1)
@@ -155,9 +148,9 @@
!allowSchedule ||
pathError != '' ||
emptyString(script_path) ||
(errorHandlerSelected == 'slack' &&
!emptyString(errorHandlerPath) &&
emptyString(errorHandlerExtraArgs['channel'])) ||
(errorHandler.selected == 'slack' &&
!!errorHandler.slack.path &&
emptyString(errorHandler.slack.args['channel'])) ||
!can_write
)
const scheduleCfg = $derived.by(getScheduleCfg)
@@ -209,68 +202,27 @@
}
}
function setScheduleHandlers(s: Partial<Schedule>) {
errorHandler = scheduleHandlerDraft('error', s.on_failure, s.on_failure_extra_args)
failedTimes = s.on_failure ? (s.on_failure_times ?? 1) : 1
failedExact = s.on_failure ? (s.on_failure_exact ?? false) : false
recoveryHandler = scheduleHandlerDraft('recovery', s.on_recovery, s.on_recovery_extra_args)
recoveredTimes = s.on_recovery ? (s.on_recovery_times ?? 1) : 1
successHandler = scheduleHandlerDraft('success', s.on_success, s.on_success_extra_args)
}
async function setScheduleHandler(s?: Schedule) {
if (s) {
if (s.on_failure) {
let splitted = s.on_failure.split('/')
errorHandleritemKind = splitted[0] as 'flow' | 'script'
errorHandlerPath = splitted.slice(1)?.join('/')
failedTimes = s.on_failure_times ?? 1
failedExact = s.on_failure_exact ?? false
errorHandlerExtraArgs = s.on_failure_extra_args ?? {}
errorHandlerSelected = getHandlerType('error', errorHandlerPath)
} else {
errorHandlerPath = undefined
errorHandleritemKind = 'script'
errorHandlerExtraArgs = {}
failedExact = false
failedTimes = 1
errorHandlerSelected = 'slack'
}
if (s.on_recovery) {
let splitted = s.on_recovery.split('/')
recoveryHandlerItemKind = splitted[0] as 'flow' | 'script'
recoveryHandlerPath = splitted.slice(1)?.join('/')
recoveredTimes = s.on_recovery_times ?? 1
recoveryHandlerExtraArgs = s.on_recovery_extra_args ?? {}
recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath)
} else {
recoveryHandlerPath = undefined
recoveryHandlerItemKind = 'script'
recoveredTimes = 1
recoveryHandlerSelected = 'slack'
recoveryHandlerExtraArgs = {}
}
if (s.on_success) {
let splitted = s.on_success.split('/')
successHandlerItemKind = splitted[0] as 'flow' | 'script'
successHandlerPath = splitted.slice(1)?.join('/')
successHandlerExtraArgs = s.on_success_extra_args ?? {}
successHandlerSelected = getHandlerType('success', successHandlerPath)
} else {
successHandlerPath = undefined
successHandlerItemKind = 'script'
successHandlerSelected = 'slack'
successHandlerExtraArgs = {}
}
setScheduleHandlers(s)
} else {
const d = wsId ? await loadDefaultScheduleAdvanced(wsId) : emptyScheduleAdvanced()
wsErrorHandlerMuted = d.wsErrorHandlerMuted
errorHandlerPath = d.errorHandlerPath
errorHandleritemKind = d.errorHandleritemKind
errorHandlerExtraArgs = d.errorHandlerExtraArgs
errorHandlerSelected = d.errorHandlerSelected
errorHandler = d.errorHandler
failedTimes = d.failedTimes
failedExact = d.failedExact
recoveryHandlerPath = d.recoveryHandlerPath
recoveryHandlerItemKind = d.recoveryHandlerItemKind
recoveryHandlerExtraArgs = d.recoveryHandlerExtraArgs
recoveryHandlerSelected = d.recoveryHandlerSelected
recoveryHandler = d.recoveryHandler
recoveredTimes = d.recoveredTimes
successHandlerPath = d.successHandlerPath
successHandlerItemKind = d.successHandlerItemKind
successHandlerExtraArgs = d.successHandlerExtraArgs
successHandlerSelected = d.successHandlerSelected
successHandler = d.successHandler
}
}
@@ -442,48 +394,7 @@
no_flow_overlap = cfg.no_flow_overlap ?? false
wsErrorHandlerMuted = cfg.ws_error_handler_muted ?? false
retry = cfg.retry
if (cfg.on_failure) {
let splitted = cfg.on_failure.split('/')
errorHandleritemKind = splitted[0] as 'flow' | 'script'
errorHandlerPath = splitted.slice(1)?.join('/')
failedTimes = cfg.on_failure_times ?? 1
failedExact = cfg.on_failure_exact ?? false
errorHandlerExtraArgs = cfg.on_failure_extra_args ?? {}
errorHandlerSelected = getHandlerType('error', errorHandlerPath ?? '')
} else {
errorHandlerPath = undefined
errorHandleritemKind = 'script'
errorHandlerExtraArgs = {}
failedExact = false
failedTimes = 1
errorHandlerSelected = 'slack'
}
if (cfg.on_recovery) {
let splitted = cfg.on_recovery.split('/')
recoveryHandlerItemKind = splitted[0] as 'flow' | 'script'
recoveryHandlerPath = splitted.slice(1)?.join('/')
recoveredTimes = cfg.on_recovery_times ?? 1
recoveryHandlerExtraArgs = cfg.on_recovery_extra_args ?? {}
recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath ?? '')
} else {
recoveryHandlerPath = undefined
recoveryHandlerItemKind = 'script'
recoveredTimes = 1
recoveryHandlerSelected = 'slack'
recoveryHandlerExtraArgs = {}
}
if (cfg.on_success) {
let splitted = cfg.on_success.split('/')
successHandlerItemKind = splitted[0] as 'flow' | 'script'
successHandlerPath = splitted.slice(1)?.join('/')
successHandlerExtraArgs = cfg.on_success_extra_args ?? {}
successHandlerSelected = getHandlerType('success', successHandlerPath ?? '')
} else {
successHandlerPath = undefined
successHandlerItemKind = 'script'
successHandlerSelected = 'slack'
successHandlerExtraArgs = {}
}
setScheduleHandlers(cfg)
dynamicSkipPath = cfg.dynamic_skip
args = cfg.args ?? {}
extraPerms = cfg.extra_perms ?? {}
@@ -549,22 +460,13 @@
args: args,
enabled: enabled,
...scheduleAdvancedCfg({
errorHandlerSelected,
errorHandlerPath,
errorHandleritemKind,
errorHandlerExtraArgs,
errorHandler,
wsErrorHandlerMuted,
failedTimes,
failedExact,
recoveryHandlerSelected,
recoveryHandlerPath,
recoveryHandlerItemKind,
recoveryHandlerExtraArgs,
recoveryHandler,
recoveredTimes,
successHandlerSelected,
successHandlerPath,
successHandlerItemKind,
successHandlerExtraArgs,
successHandler,
retry,
dynamicSkipPath,
tag
@@ -934,8 +836,8 @@
error_handler_path={errorHandlerPath}
{retry}
extraBadges={[
{ name: 'Recovery Handler', active: !!recoveryHandlerPath },
{ name: 'Success Handler', active: !!successHandlerPath },
{ name: 'Recovery Handler', active: !!scheduleHandlerStored(recoveryHandler).path },
{ name: 'Success Handler', active: !!scheduleHandlerStored(successHandler).path },
{ name: 'Dynamic Skip', active: !!dynamicSkipPath },
{ name: 'Custom Tag', active: !!tag }
]}
@@ -946,22 +848,13 @@
{itemKind}
canWrite={can_write}
{loading}
bind:errorHandlerSelected
bind:errorHandlerPath
bind:errorHandleritemKind
bind:errorHandlerExtraArgs
bind:errorHandler
bind:wsErrorHandlerMuted
bind:failedTimes
bind:failedExact
bind:recoveryHandlerSelected
bind:recoveryHandlerPath
bind:recoveryHandlerItemKind
bind:recoveryHandlerExtraArgs
bind:recoveryHandler
bind:recoveredTimes
bind:successHandlerSelected
bind:successHandlerPath
bind:successHandlerItemKind
bind:successHandlerExtraArgs
bind:successHandler
bind:retry
bind:dynamicSkipPath
bind:tag
@@ -17,7 +17,6 @@
import {
SqsTriggerService,
type AwsAuthResourceType,
type ErrorHandler,
type Retry,
type TriggerMode
} from '$lib/gen'
@@ -28,7 +27,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveSqsTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
@@ -126,9 +126,10 @@
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
@@ -234,10 +235,13 @@
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
@@ -266,10 +270,13 @@
aws_auth_resource_type = cfg?.aws_auth_resource_type
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
@@ -604,9 +611,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
</div>
@@ -30,7 +30,6 @@ import GoogleMonoIcon from '$lib/components/icons/triggers/GoogleIcon.svelte'
import GithubMonoIcon from '$lib/components/icons/triggers/GithubIcon.svelte'
import type {
CaptureTriggerKind,
ErrorHandler,
Flow,
JobTriggerKind,
NewScript,
@@ -732,17 +731,3 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
export type FlowWithDraftAndDraftTriggers = Flow
export type NewScriptWithDraftAndDraftTriggers = NewScript & { hash?: string }
export function getHandlerType(scriptPath: string): ErrorHandler {
const handlerMap = {
teams: '/workspace-or-schedule-error-handler-teams',
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 ErrorHandler
}
}
return 'custom'
}
@@ -21,7 +21,6 @@
type WebsocketTriggerInitialMessage,
type WebsocketHeartbeat,
type Retry,
type ErrorHandler,
type TriggerMode
} from '$lib/gen'
import { usedTriggerKinds } from '$lib/stores'
@@ -43,7 +42,8 @@
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveWebsocketTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { handleConfigChange, type Trigger } from '../utils'
import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
@@ -150,9 +150,10 @@
let preservePermissionedAs = $state(false)
let isValid = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let errorHandler = $state(handlerDraftFromStored('error', undefined))
const storedErrorHandler = $derived(handlerDraftToStored(errorHandler))
const error_handler_path = $derived(storedErrorHandler.path)
const error_handler_args = $derived(storedErrorHandler.args)
let retry: Retry | undefined = $state()
let mode = $state<TriggerMode>('enabled')
@@ -268,10 +269,13 @@
dirtyPath = false
can_return_message = false
can_return_error_result = false
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
defaultValues?.error_handler_path,
'script',
defaultValues?.error_handler_args
)
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = defaultValues?.mode ?? 'enabled'
permissionedAs = undefined
selectedPermissionedAs = undefined
@@ -305,10 +309,13 @@
heartbeat_state_field = hb?.state_field ?? ''
permsPath = path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
errorHandler = handlerDraftFromStored(
'error',
cfg?.error_handler_path,
'script',
cfg?.error_handler_args
)
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = cfg?.mode ?? 'enabled'
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
@@ -916,9 +923,7 @@
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:errorHandler
bind:retry
/>
</div>
@@ -10,9 +10,13 @@
import DeployToSetting from '$lib/components/DeployToSetting.svelte'
import RemoteDeployTargetSetting from '$lib/components/RemoteDeployTargetSetting.svelte'
import DevWorkspaceSetting from '$lib/components/DevWorkspaceSetting.svelte'
import ErrorOrRecoveryHandler, {
handlerFullPath
} from '$lib/components/ErrorOrRecoveryHandler.svelte'
import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte'
import {
cloneHandlerDraft,
handlerDraftFromStored,
handlerDraftToStored,
type HandlerDraft
} from '$lib/components/handlerDraft.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
@@ -26,7 +30,6 @@
WorkspaceService,
SettingService,
type AIConfig,
type ErrorHandler,
type GetCopilotSettingsStateResponse,
type InstanceAISummary,
type GetSettingsResponse
@@ -67,7 +70,6 @@
import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte'
import Trashbin from '$lib/components/settings/Trashbin.svelte'
import { untrack } from 'svelte'
import { getHandlerType } from '$lib/components/triggers/utils'
import DbtSettings, {
convertDbtSettingsFromBackend,
type DbtSettingsType
@@ -180,10 +182,16 @@
let plan: string | undefined = $state(undefined)
let customer_id: string | undefined = $state(undefined)
let webhook: string | undefined = $state(undefined)
let errorHandlerSelected: ErrorHandler = $state('slack')
let errorHandlerScriptPath: string | undefined = $state(undefined)
let errorHandlerItemKind: 'flow' | 'script' = $state('script')
let errorHandlerExtraArgs: Record<string, any> = $state({})
const noErrorHandler = handlerDraftFromStored(
'error',
undefined,
'script',
{},
{
emptySelected: 'slack'
}
)
let errorHandlerDraft: HandlerDraft = $state(cloneHandlerDraft(noErrorHandler))
let errorHandlerMutedOnCancel: boolean | undefined = $state(undefined)
let errorHandlerMutedOnUserPath: boolean | undefined = $state(undefined)
let successHandlerScriptPath: string | undefined = $state(undefined)
@@ -249,10 +257,7 @@
let initialEditedWorkspaceEncryptionKey: string | undefined = $state(undefined)
// Track initial error handler settings for unsaved changes detection
let initialErrorHandlerSelected: ErrorHandler = $state('slack')
let initialErrorHandlerScriptPath: string | undefined = $state(undefined)
let initialErrorHandlerItemKind: 'flow' | 'script' = $state('script')
let initialErrorHandlerExtraArgs: Record<string, any> = $state({})
let initialErrorHandlerDraft: HandlerDraft = $state(cloneHandlerDraft(noErrorHandler))
let initialErrorHandlerMutedOnCancel: boolean | undefined = $state(undefined)
let initialErrorHandlerMutedOnUserPath: boolean | undefined = $state(undefined)
@@ -300,6 +305,13 @@
return hasUnsavedChanges(changes.savedValue, changes.modifiedValue)
})
let errorHandlerChannelMissing = $derived.by(() => {
const { selected } = errorHandlerDraft
if (selected !== 'slack' && selected !== 'teams') return false
const slot = errorHandlerDraft[selected]
return !!slot.path && emptyString(slot.args['channel'])
})
// Derived state for checking unsaved changes in success handler
let hasSuccessHandlerChanges = $derived.by(() => {
if (tab !== 'error_handler') return false
@@ -686,10 +698,6 @@
| { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean }
| undefined
const errorHandlerPath = errorHandler?.path ?? ''
errorHandlerItemKind = errorHandlerPath
? (errorHandlerPath.split('/')[0] as 'flow' | 'script')
: 'script'
errorHandlerScriptPath = errorHandlerPath.split('/').slice(1).join('/')
errorHandlerMutedOnCancel = errorHandler?.muted_on_cancel
errorHandlerMutedOnUserPath = errorHandler?.muted_on_user_path
criticalAlertUIMuted = settings.mute_critical_alerts
@@ -706,18 +714,22 @@
WorkspaceService.getGuestUsage({ workspace: $workspaceStore! })
.then((u) => (guestUsage = u))
.catch(() => (guestUsage = undefined))
if (emptyString($enterpriseLicense)) {
errorHandlerSelected = 'custom'
} else if (
const [errorHandlerKind, ...errorHandlerScriptPath] = errorHandlerPath.split('/')
errorHandlerDraft = handlerDraftFromStored(
'error',
errorHandlerScriptPath.join('/'),
errorHandlerKind === 'flow' ? 'flow' : 'script',
errorHandler?.extra_args,
{ asCustom: emptyString($enterpriseLicense) }
)
if (
!emptyString($enterpriseLicense) &&
canUseInstanceAlerts &&
!errorHandlerPath &&
settings.error_handler_fallback_to_instance_alerts
) {
errorHandlerSelected = 'instance_alerts'
} else {
errorHandlerSelected = getHandlerType(errorHandlerScriptPath)
errorHandlerDraft.selected = 'instance_alerts'
}
errorHandlerExtraArgs = errorHandler?.extra_args ?? {}
const successHandler = settings.success_handler as
| { path?: string; extra_args?: any }
| undefined
@@ -764,10 +776,7 @@
initialEditedWorkspaceEncryptionKey = editedWorkspaceEncryptionKey
// Store initial error handler state for unsaved changes detection
initialErrorHandlerSelected = errorHandlerSelected
initialErrorHandlerScriptPath = errorHandlerScriptPath
initialErrorHandlerItemKind = errorHandlerItemKind
initialErrorHandlerExtraArgs = clone(errorHandlerExtraArgs)
initialErrorHandlerDraft = cloneHandlerDraft(errorHandlerDraft)
initialErrorHandlerMutedOnCancel = errorHandlerMutedOnCancel
initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath
@@ -904,18 +913,19 @@
})
async function editErrorHandler() {
if (errorHandlerScriptPath) {
const handler = handlerDraftToStored(errorHandlerDraft)
if (handler.path) {
await WorkspaceService.editErrorHandler({
workspace: $workspaceStore!,
requestBody: {
path: handlerFullPath(errorHandlerSelected, errorHandlerItemKind, errorHandlerScriptPath),
extra_args: errorHandlerExtraArgs,
path: `${handler.kind}/${handler.path}`,
extra_args: handler.args,
muted_on_cancel: errorHandlerMutedOnCancel,
muted_on_user_path: errorHandlerMutedOnUserPath,
fallback_to_instance_alerts: false
}
})
sendUserToast(`workspace error handler set to ${errorHandlerScriptPath}`)
sendUserToast(`workspace error handler set to ${handler.path}`)
} else {
await WorkspaceService.editErrorHandler({
workspace: $workspaceStore!,
@@ -924,23 +934,28 @@
extra_args: undefined,
muted_on_cancel: undefined,
muted_on_user_path: undefined,
fallback_to_instance_alerts: errorHandlerSelected === 'instance_alerts'
fallback_to_instance_alerts: errorHandlerDraft.selected === 'instance_alerts'
}
})
sendUserToast(
errorHandlerSelected === 'instance_alerts'
errorHandlerDraft.selected === 'instance_alerts'
? `failed jobs will be reported to the instance critical alert channels`
: initialErrorHandlerScriptPath
: handlerDraftToStored(initialErrorHandlerDraft).path
? `workspace error handler removed`
: `error handler settings saved`
)
}
// Update initial values for dirty detection
initialErrorHandlerSelected = errorHandlerSelected
initialErrorHandlerScriptPath = errorHandlerScriptPath
initialErrorHandlerItemKind = errorHandlerItemKind
initialErrorHandlerExtraArgs = clone(errorHandlerExtraArgs)
// Rebuilt from what was stored, so Discard also drops the other tabs' unsaved edits.
initialErrorHandlerDraft = handlerDraftFromStored(
'error',
handler.path,
handler.kind,
handler.args,
{
emptySelected: errorHandlerDraft.selected
}
)
initialErrorHandlerMutedOnCancel = errorHandlerMutedOnCancel
initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath
}
@@ -1132,50 +1147,40 @@
guestJwtKeyType = initialGuestJwtJwksUrl ? 'jwks' : 'pem'
}
// Strip keys from extraArgs that are auto-managed by child components:
// - 'slack' while the Slack built-in is selected: set by ErrorOrRecoveryHandler's $effect
// - 'channel_name': display metadata stripped by SchemaForm's removeExtraKey()
function normalizeHandlerExtraArgs(
args: Record<string, any>,
handlerSelected: ErrorHandler
): Record<string, any> {
const { channel_name: _, ...rest } = args
if (handlerSelected === 'slack') delete rest.slack
return rest
// Compares what Save would send, so visiting another tab and coming back is not a change.
// The stored handler is nested because `hasUnsavedChanges` drops a top-level `path`.
function errorHandlerSettingsValue(
draft: HandlerDraft,
mutedOnCancel: boolean | undefined,
mutedOnUserPath: boolean | undefined
) {
return {
errorHandler: handlerDraftToStored(draft),
fallbackToInstanceAlerts: draft.selected === 'instance_alerts',
errorHandlerMutedOnCancel: mutedOnCancel,
errorHandlerMutedOnUserPath: mutedOnUserPath
}
}
// Function to check if there are unsaved changes in error handler settings
function getErrorHandlerSettingsInitialAndModifiedValues() {
const savedValue = {
errorHandlerSelected: initialErrorHandlerSelected,
errorHandlerScriptPath: initialErrorHandlerScriptPath,
errorHandlerItemKind: initialErrorHandlerItemKind,
errorHandlerExtraArgs: normalizeHandlerExtraArgs(
initialErrorHandlerExtraArgs,
initialErrorHandlerSelected
return {
savedValue: errorHandlerSettingsValue(
initialErrorHandlerDraft,
initialErrorHandlerMutedOnCancel,
initialErrorHandlerMutedOnUserPath
),
errorHandlerMutedOnCancel: initialErrorHandlerMutedOnCancel,
errorHandlerMutedOnUserPath: initialErrorHandlerMutedOnUserPath
modifiedValue: errorHandlerSettingsValue(
errorHandlerDraft,
errorHandlerMutedOnCancel,
errorHandlerMutedOnUserPath
)
}
const modifiedValue = {
errorHandlerSelected: errorHandlerSelected,
errorHandlerScriptPath: errorHandlerScriptPath,
errorHandlerItemKind: errorHandlerItemKind,
errorHandlerExtraArgs: normalizeHandlerExtraArgs(errorHandlerExtraArgs, errorHandlerSelected),
errorHandlerMutedOnCancel: errorHandlerMutedOnCancel,
errorHandlerMutedOnUserPath: errorHandlerMutedOnUserPath
}
return { savedValue, modifiedValue }
}
// Function to discard unsaved error handler settings changes
function discardErrorHandlerSettingsChanges() {
errorHandlerSelected = initialErrorHandlerSelected
errorHandlerScriptPath = initialErrorHandlerScriptPath
errorHandlerItemKind = initialErrorHandlerItemKind
errorHandlerExtraArgs = clone(initialErrorHandlerExtraArgs)
errorHandlerDraft = cloneHandlerDraft(initialErrorHandlerDraft)
errorHandlerMutedOnCancel = initialErrorHandlerMutedOnCancel
errorHandlerMutedOnUserPath = initialErrorHandlerMutedOnUserPath
}
@@ -1974,11 +1979,8 @@
isEditable={true}
errorOrRecovery="error"
showScriptHelpText={true}
bind:handlerSelected={errorHandlerSelected}
bind:handlerPath={errorHandlerScriptPath}
bind:draft={errorHandlerDraft}
customScriptTemplate="/scripts/add?hub=hub%2F9083%2Fwindmill%2Fworkspace_error_handler_template"
bind:customHandlerKind={errorHandlerItemKind}
bind:handlerExtraArgs={errorHandlerExtraArgs}
showInstanceAlerts={canUseInstanceAlerts}
>
{#snippet customTabTooltip()}
@@ -2009,21 +2011,15 @@
{/snippet}
</ErrorOrRecoveryHandler>
{#if errorHandlerSelected !== 'instance_alerts'}
{#if errorHandlerDraft.selected !== 'instance_alerts'}
<SettingCard class="gap-2">
<Toggle
disabled={!$enterpriseLicense ||
((errorHandlerSelected === 'slack' || errorHandlerSelected === 'teams') &&
!emptyString(errorHandlerScriptPath) &&
emptyString(errorHandlerExtraArgs['channel']))}
disabled={!$enterpriseLicense || errorHandlerChannelMissing}
bind:checked={errorHandlerMutedOnCancel}
options={{ right: 'Do not run error handler for canceled jobs' }}
/>
<Toggle
disabled={!$enterpriseLicense ||
((errorHandlerSelected === 'slack' || errorHandlerSelected === 'teams') &&
!emptyString(errorHandlerScriptPath) &&
emptyString(errorHandlerExtraArgs['channel']))}
disabled={!$enterpriseLicense || errorHandlerChannelMissing}
bind:checked={errorHandlerMutedOnUserPath}
options={{ right: 'Do not run error handler for u/ scripts and flows' }}
/>
@@ -2038,10 +2034,7 @@
onSave={editErrorHandler}
onDiscard={discardErrorHandlerSettingsChanges}
saveLabel="Save error handler"
disabled={!$enterpriseLicense ||
((errorHandlerSelected === 'slack' || errorHandlerSelected === 'teams') &&
!emptyString(errorHandlerScriptPath) &&
emptyString(errorHandlerExtraArgs['channel']))}
disabled={!$enterpriseLicense || errorHandlerChannelMissing}
/>
</Section>
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.831.0"
wmill = ">=1.832.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.831.0
version: 1.832.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.831.0'
ModuleVersion = '1.832.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.831.0"
version = "1.832.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.831.0",
"version": "1.832.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.831.0",
"version": "1.832.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.831.0
1.832.0
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-yaml-validator",
"version": "1.831.0",
"version": "1.832.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-yaml-validator",
"version": "1.831.0",
"version": "1.832.0",
"license": "Apache 2.0",
"dependencies": {
"@stoplight/yaml": "^4.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-yaml-validator",
"version": "1.831.0",
"version": "1.832.0",
"description": "YAML validator for Windmill flow, schedule, and trigger files",
"main": "dist/index.js",
"types": "dist/index.d.ts",