Merge branch 'main' into di/s3-proxy

This commit is contained in:
Diego Imbert
2025-09-01 16:18:41 +02:00
committed by GitHub
122 changed files with 4086 additions and 3014 deletions
+42
View File
@@ -1,5 +1,47 @@
# Changelog
## [1.538.0](https://github.com/windmill-labs/windmill/compare/v1.537.1...v1.538.0) (2025-09-01)
### Features
* **aichat:** cache prompts when using anthropic models ([#6489](https://github.com/windmill-labs/windmill/issues/6489)) ([8b3ae19](https://github.com/windmill-labs/windmill/commit/8b3ae1984c745164bbf3938e6f8fc3cf02a0e2c1))
* **frontend:** preprocessor snippet completion in code editor ([#6502](https://github.com/windmill-labs/windmill/issues/6502)) ([a5a9a33](https://github.com/windmill-labs/windmill/commit/a5a9a33d10fcac802834b1c046a3c116ab5b3454))
* **perf:** add 300-second local cache for variable crypt retrieval ([#6483](https://github.com/windmill-labs/windmill/issues/6483)) ([8f46066](https://github.com/windmill-labs/windmill/commit/8f46066a6478a48411e00f5c4e1f9b9fdb49c1fc))
### Bug Fixes
* **backend:** http triggers early return ([#6501](https://github.com/windmill-labs/windmill/issues/6501)) ([c01ed2d](https://github.com/windmill-labs/windmill/commit/c01ed2d62a440b0ade624ed1def6bb2a54f5aa81))
* **frontend:** capture/trigger UI nits ([#6494](https://github.com/windmill-labs/windmill/issues/6494)) ([8cd1c64](https://github.com/windmill-labs/windmill/commit/8cd1c6474b3c55d706683f83b40ef5569245b559))
* **frontend:** Improve runs page ux ([#6485](https://github.com/windmill-labs/windmill/issues/6485)) ([768c600](https://github.com/windmill-labs/windmill/commit/768c60049413277e2764ef889274d4f8691412aa))
* **frontend:** make resource a separate top-level type in schema editor for clarity ([0f6a742](https://github.com/windmill-labs/windmill/commit/0f6a74256dba0f571e9fcfa876e78971385ba541))
* schema editor reactivity improvements ([#6496](https://github.com/windmill-labs/windmill/issues/6496)) ([6e46058](https://github.com/windmill-labs/windmill/commit/6e46058c5a9af6cd9fb8c1f08bb58dc123d5323e))
## [1.537.1](https://github.com/windmill-labs/windmill/compare/v1.537.0...v1.537.1) (2025-08-29)
### Bug Fixes
* fix error handling of pre-processor steps ([641d565](https://github.com/windmill-labs/windmill/commit/641d5651c5b6fa898e4fc8ad29a8508f9400c6d8))
* fix preprocessor not displaying immediately on addition ([e28c9df](https://github.com/windmill-labs/windmill/commit/e28c9df60f20b70a4dcef802839750bb843b91a4))
* skipPreprocessor on re-running job immedaitely from UI ([e2b344e](https://github.com/windmill-labs/windmill/commit/e2b344ed02b02972366a011c3e873e0f43ab2843))
## [1.537.0](https://github.com/windmill-labs/windmill/compare/v1.536.0...v1.537.0) (2025-08-28)
### Features
* **aichat:** allow reverting specific line for inline script suggestions ([#6480](https://github.com/windmill-labs/windmill/issues/6480)) ([0cc11b3](https://github.com/windmill-labs/windmill/commit/0cc11b3f31aeee60a9d4a231cea5d4285d7ab37e))
* autovacuum or high intensity tables ([4ad0d25](https://github.com/windmill-labs/windmill/commit/4ad0d255f3eea303e97eab5325f89930b26f9e52))
### Bug Fixes
* fix okta and oauth0 sso settings ([73a3f4c](https://github.com/windmill-labs/windmill/commit/73a3f4cc73271759650e9246f4eb2e0efb7c7e37))
* fix relative imports cache invalidation ([#6468](https://github.com/windmill-labs/windmill/issues/6468)) ([006f326](https://github.com/windmill-labs/windmill/commit/006f32602c7609b282f15135989c5f164c109c1c))
* fix workflow as code behavior with multithread ([4973c86](https://github.com/windmill-labs/windmill/commit/4973c860f2c28d9bdc2af94530d90eb177234e5d))
## [1.536.0](https://github.com/windmill-labs/windmill/compare/v1.535.0...v1.536.0) (2025-08-27)
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets) \n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets \n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "0156016836adeb2714d99811e4ad2ea9245f05e772f3601c99312c13ed65ae1a"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets) \n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets \n\n FROM script WHERE hash = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "03e213d2934991c57af64b5ae94a6aa59599f852cc17b16323fc627b6ad8671e"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n w.premium,\n COALESCE(cw.is_past_due, false) as \"is_past_due!\",\n cw.max_tolerated_executions\n FROM\n workspace w\n LEFT JOIN cloud_workspace_settings cw ON cw.workspace_id = w.id\n WHERE\n w.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "premium",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "is_past_due!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "max_tolerated_executions",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null,
true
]
},
"hash": "124e67b0cee1baa6295846db4ad6242a39dd40186f1dbb48ad3018bd9f6913ec"
}
@@ -1,46 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT owner, premium, usage.usage as \"usage?\", workspace_settings.customer_id, workspace_settings.plan FROM workspace LEFT JOIN workspace_settings ON workspace_settings.workspace_id = $1 LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "premium",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "usage?",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "customer_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "plan",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "4b8132b04e454eddfe6724c6cc3a2e60c9c24decb2a6b41125247bbf741e9c25"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n owner, \n premium, \n usage.usage as \"usage?\", \n workspace_settings.customer_id, \n workspace_settings.plan, \n COALESCE(cw.is_past_due, false) as \"is_past_due!\", \n cw.max_tolerated_executions\n FROM workspace\n LEFT JOIN workspace_settings \n ON workspace_settings.workspace_id = $1\n LEFT JOIN usage \n ON usage.id = $1\n AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)\n AND usage.is_workspace IS true\n LEFT JOIN cloud_workspace_settings cw\n ON cw.workspace_id = $1\n WHERE workspace.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "premium",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "usage?",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "customer_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "plan",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "is_past_due!",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "max_tolerated_executions",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true,
null,
true
]
},
"hash": "ac4a375035049304db7ae084b2d1789afb8250adde6f38f458b115e787ed876f"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT premium FROM workspace WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "premium",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "d768bbc46f8a9c4289b918c88ca50aa180b29bbe931d948f6e61976f71b7cdb9"
}
+86 -114
View File
@@ -788,9 +788,9 @@ dependencies = [
[[package]]
name = "aws-credential-types"
version = "1.2.5"
version = "1.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1541072f81945fa1251f8795ef6c92c4282d74d59f88498ae7d4bf00f0ebdad9"
checksum = "d025db5d9f52cbc413b167136afb3d8aeea708c0d8884783cf6253be5e22f6f2"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -1040,9 +1040,9 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.61.4"
version = "0.61.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a16e040799d29c17412943bdbf488fd75db04112d0c0d4b9290bacf5ae0014b9"
checksum = "eaa31b350998e703e9826b2104dd6f63be0508666e1aba88137af060e8944047"
dependencies = [
"aws-smithy-types",
]
@@ -1917,10 +1917,11 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.34"
version = "1.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc"
checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
@@ -2127,9 +2128,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "comfy-table"
version = "7.1.4"
version = "7.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a"
checksum = "3f8e18d0dca9578507f13f9803add0df13362b02c501c1c17734f0dbb52eaf0b"
dependencies = [
"unicode-segmentation",
"unicode-width 0.2.1",
@@ -4471,9 +4472,9 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.4.0"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
dependencies = [
"powerfmt",
"serde",
@@ -5152,7 +5153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
dependencies = [
"bit-set 0.5.3",
"regex-automata 0.4.10",
"regex-automata",
"regex-syntax 0.8.6",
]
@@ -5163,7 +5164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
dependencies = [
"bit-set 0.8.0",
"regex-automata 0.4.10",
"regex-automata",
"regex-syntax 0.8.6",
]
@@ -5262,6 +5263,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650"
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -5855,9 +5862,9 @@ dependencies = [
[[package]]
name = "getopts"
version = "0.2.23"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width 0.2.1",
]
@@ -5885,7 +5892,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
"wasi 0.14.3+wasi-0.2.4",
"wasm-bindgen",
]
@@ -5951,7 +5958,7 @@ dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata 0.4.10",
"regex-automata",
"regex-syntax 0.8.6",
]
@@ -6899,9 +6906,9 @@ dependencies = [
[[package]]
name = "if_chain"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed"
checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb"
[[package]]
name = "ignore"
@@ -6913,7 +6920,7 @@ dependencies = [
"globset",
"log",
"memchr",
"regex-automata 0.4.10",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
@@ -7833,7 +7840,7 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53304fff6ab1e597661eee37e42ea8c47a146fca280af902bb76bff8a896e523"
dependencies = [
"nu-ansi-term 0.50.1",
"nu-ansi-term",
]
[[package]]
@@ -8010,11 +8017,11 @@ checksum = "204651f31b0a6a7b2128d2b92c372cd94607b210c3a6b6e542c57a8cfd4db996"
[[package]]
name = "matchers"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata 0.1.10",
"regex-automata",
]
[[package]]
@@ -8603,16 +8610,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
dependencies = [
"overload",
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.1"
@@ -9315,12 +9312,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "overload"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "ownedbytes"
version = "0.9.0"
@@ -9899,9 +9890,9 @@ dependencies = [
[[package]]
name = "potential_utf"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585"
checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a"
dependencies = [
"zerovec",
]
@@ -10275,9 +10266,9 @@ dependencies = [
[[package]]
name = "quinn"
version = "0.11.8"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases 0.2.1",
@@ -10286,7 +10277,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.29",
"socket2 0.5.10",
"socket2 0.6.0",
"thiserror 2.0.16",
"tokio",
"tracing",
@@ -10295,9 +10286,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.12"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.3",
@@ -10316,16 +10307,16 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.13"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.0",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -10635,19 +10626,10 @@ checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.4.10",
"regex-automata",
"regex-syntax 0.8.6",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
dependencies = [
"regex-syntax 0.6.29",
]
[[package]]
name = "regex-automata"
version = "0.4.10"
@@ -10665,12 +10647,6 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943f41321c63ef1c92fd763bfe054d2668f7f225a5c29f0105903dc2fc04ba30"
[[package]]
name = "regex-syntax"
version = "0.6.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.7.5"
@@ -13532,12 +13508,11 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.41"
version = "0.3.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
checksum = "8ca967379f9d8eb8058d86ed467d81d03e81acd45757e4ca341c24affbe8e8e3"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
@@ -13547,15 +13522,15 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
checksum = "a9108bb380861b07264b950ded55a44a14a4adc68b9f5efd85aafc3aa4d40a68"
[[package]]
name = "time-macros"
version = "0.2.22"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
checksum = "7182799245a7264ce590b349d90338f1c1affad93d2639aed5f8f69c090b334c"
dependencies = [
"num-conv",
"time-core",
@@ -14178,14 +14153,14 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.19"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"matchers",
"nu-ansi-term 0.46.0",
"nu-ansi-term",
"once_cell",
"regex",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
@@ -14793,11 +14768,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
version = "0.14.3+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95"
dependencies = [
"wit-bindgen-rt",
"wit-bindgen",
]
[[package]]
@@ -15152,7 +15127,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"aws-sdk-config",
@@ -15208,7 +15183,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"argon2",
@@ -15327,7 +15302,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -15342,7 +15317,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"chrono",
"serde",
@@ -15355,7 +15330,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"axum",
@@ -15374,7 +15349,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15455,7 +15430,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"regex",
"serde",
@@ -15470,7 +15445,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"bytes",
@@ -15494,7 +15469,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15506,7 +15481,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15515,7 +15490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15527,7 +15502,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15539,7 +15514,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15551,7 +15526,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15563,7 +15538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15575,7 +15550,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -15586,7 +15561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15597,7 +15572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15609,7 +15584,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15632,7 +15607,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15646,7 +15621,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15663,7 +15638,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15677,7 +15652,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15695,7 +15670,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"getrandom 0.2.16",
@@ -15720,7 +15695,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15730,7 +15705,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15763,7 +15738,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -15773,7 +15748,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.536.0"
version = "1.538.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16353,13 +16328,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
name = "wit-bindgen"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags 2.9.3",
]
checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814"
[[package]]
name = "writeable"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.536.0"
version = "1.538.0"
authors.workspace = true
edition.workspace = true
@@ -33,7 +33,7 @@ members = [
]
[workspace.package]
version = "1.536.0"
version = "1.538.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
6396854336ae27fb14ccb792d80c31ff614b2afa
d16e52d570f10dfdabb04a9061fe7ebdbf5a2aa1
@@ -0,0 +1,7 @@
-- Add down migration script here
DROP FUNCTION notify_team_plan_status_change;
DROP TRIGGER notify_team_plan_status_change ON cloud_workspace_settings;
ALTER TABLE cloud_workspace_settings
DROP COLUMN is_past_due,
DROP COLUMN max_tolerated_executions;
@@ -0,0 +1,18 @@
-- Add up migration script here
ALTER TABLE cloud_workspace_settings
ADD COLUMN is_past_due BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN max_tolerated_executions INTEGER;
CREATE OR REPLACE FUNCTION notify_team_plan_status_change()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('notify_workspace_premium_change', NEW.workspace_id); -- reuse the same channel as the one used for workspace premium change => clear cache
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER team_plan_status_change_trigger
AFTER UPDATE OF is_past_due, max_tolerated_executions ON cloud_workspace_settings
FOR EACH ROW
EXECUTE FUNCTION notify_team_plan_status_change();
@@ -0,0 +1,4 @@
-- Remove workspace key cache invalidation trigger
DROP TRIGGER workspace_key_change_trigger ON workspace_key;
DROP FUNCTION notify_workspace_key_change();
@@ -0,0 +1,19 @@
-- Add workspace key cache invalidation trigger
CREATE OR REPLACE FUNCTION notify_workspace_key_change()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM pg_notify('notify_workspace_key_change', OLD.workspace_id);
RETURN OLD;
ELSE
PERFORM pg_notify('notify_workspace_key_change', NEW.workspace_id);
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER workspace_key_change_trigger
AFTER INSERT OR UPDATE OF key OR DELETE ON workspace_key
FOR EACH ROW
EXECUTE FUNCTION notify_workspace_key_change();
+7 -1
View File
@@ -854,10 +854,15 @@ Windmill Community Edition {GIT_VERSION}
tracing::info!("Workspace envs change detected, invalidating workspace envs cache: {}", workspace_id);
windmill_common::variables::CUSTOM_ENVS_CACHE.remove(workspace_id);
},
"notify_workspace_key_change" => {
let workspace_id = n.payload();
tracing::info!("Workspace key change detected, invalidating workspace key cache: {}", workspace_id);
windmill_common::variables::WORKSPACE_CRYPT_CACHE.remove(workspace_id);
},
"notify_workspace_premium_change" => {
let workspace_id = n.payload();
tracing::info!("Workspace premium change detected, invalidating workspace premium cache: {}", workspace_id);
windmill_common::workspaces::IS_PREMIUM_CACHE.remove(workspace_id);
windmill_common::workspaces::TEAM_PLAN_CACHE.remove(workspace_id);
},
"notify_runnable_version_change" => {
let payload = n.payload();
@@ -1268,6 +1273,7 @@ async fn listen_pg(url: &str) -> Option<PgListener> {
"notify_global_setting_change",
"notify_webhook_change",
"notify_workspace_envs_change",
"notify_workspace_key_change",
"notify_runnable_version_change",
"notify_token_invalidation",
];
+4 -2
View File
@@ -149,7 +149,9 @@ lazy_static::lazy_static! {
static ref QUEUE_RUNNING_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
static ref DISABLE_CONCURRENCY_LIMIT: bool = std::env::var("DISABLE_CONCURRENCY_LIMIT").is_ok_and(|s| s == "true");
static ref STALE_JOB_TRESHOLD_MINUTES: Option<u64> = std::env::var("STALE_JOB_TRESHOLD_MINUTES")
//legacy typo
static ref STALE_JOB_THRESHOLD_MINUTES: Option<u64> = std::env::var("STALE_JOB_THRESHOLD_MINUTES").or_else(|_| std::env::var("STALE_JOB_THRESHOLD_MINUTES"))
.ok()
.and_then(|x| x.parse::<u64>().ok());
}
@@ -1896,7 +1898,7 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
}
async fn stale_job_cancellation(db: &Pool<Postgres>) {
if let Some(threshold) = *STALE_JOB_TRESHOLD_MINUTES {
if let Some(threshold) = *STALE_JOB_THRESHOLD_MINUTES {
let stale_jobs = sqlx::query!(
"SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval",
threshold.to_string()
+11 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.536.0
version: 1.538.0
title: Windmill API
contact:
@@ -2034,6 +2034,11 @@ paths:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: skip_subscription_fetch
in: query
description: skip fetching subscription status from stripe
schema:
type: boolean
responses:
"200":
@@ -2051,9 +2056,14 @@ paths:
type: string
status:
type: string
is_past_due:
type: boolean
max_tolerated_executions:
type: number
required:
- premium
- owner
- is_past_due
/w/{workspace}/workspaces/threshold_alert:
get:
+4
View File
@@ -156,6 +156,7 @@ impl AIRequestConfig {
let is_azure = matches!(provider, AIProvider::OpenAI) && base_url != OPENAI_BASE_URL
|| matches!(provider, AIProvider::AzureOpenAI);
let is_anthropic = matches!(provider, AIProvider::Anthropic);
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
let url = if is_azure && method != Method::GET {
if base_url.ends_with("/deployments") {
@@ -167,6 +168,9 @@ impl AIRequestConfig {
} else {
format!("{}/{}", base_url, path)
}
} else if is_anthropic_sdk {
let truncated_base_url = base_url.trim_end_matches("/v1");
format!("{}/{}", truncated_base_url, path)
} else {
format!("{}/{}", base_url, path)
};
+5 -4
View File
@@ -3741,7 +3741,7 @@ async fn batch_rerun_handle_job(
PushArgsOwned { extra: None, args },
)
.await;
if let Ok(uuid) = result {
if let Ok((uuid, _)) = result {
return Ok(uuid.to_string());
}
}
@@ -3798,7 +3798,7 @@ pub async fn run_flow_by_path(
)
.await?;
let uuid =
let (uuid, _) =
run_flow_by_path_inner(authed, db, user_db, w_id, flow_path, run_query, args).await?;
Ok((StatusCode::CREATED, uuid.to_string()))
@@ -3812,7 +3812,7 @@ pub async fn run_flow_by_path_inner(
flow_path: StripPath,
run_query: RunJobQuery,
args: PushArgsOwned,
) -> error::Result<Uuid> {
) -> error::Result<(Uuid, Option<String>)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
@@ -3828,6 +3828,7 @@ pub async fn run_flow_by_path_inner(
has_preprocessor,
on_behalf_of_email,
edited_by,
early_return,
..
} = get_latest_flow_version_info_for_path(&mut *tx, &w_id, &flow_path, true).await?;
@@ -3889,7 +3890,7 @@ pub async fn run_flow_by_path_inner(
)
.await?;
tx.commit().await?;
Ok(uuid)
Ok((uuid, early_return))
}
#[cfg(not(feature = "enterprise"))]
+13 -28
View File
@@ -472,12 +472,12 @@ async fn trigger_runnable_inner(
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
trigger_path: String,
) -> Result<(Uuid, Option<bool>)> {
) -> Result<(Uuid, Option<bool>, Option<String>)> {
let user_db = user_db.unwrap_or_else(|| UserDB::new(db.clone()));
let (uuid, delete_after_use) = if is_flow {
let (uuid, delete_after_use, early_return) = if is_flow {
let run_query = RunJobQuery::default();
let path = StripPath(runnable_path.to_string());
let uuid = run_flow_by_path_inner(
let (uuid, early_return) = run_flow_by_path_inner(
authed,
db.clone(),
user_db,
@@ -487,9 +487,9 @@ async fn trigger_runnable_inner(
args,
)
.await?;
(uuid, None)
(uuid, None, early_return)
} else {
trigger_script_internal(
let (uuid, delete_after_use) = trigger_script_internal(
db,
user_db,
authed,
@@ -501,10 +501,11 @@ async fn trigger_runnable_inner(
error_handler_args,
trigger_path,
)
.await?
.await?;
(uuid, delete_after_use, None)
};
Ok((uuid, delete_after_use))
Ok((uuid, delete_after_use, early_return))
}
#[allow(dead_code)]
@@ -521,7 +522,7 @@ pub async fn trigger_runnable(
error_handler_args: Option<&sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
trigger_path: String,
) -> Result<axum::response::Response> {
let (uuid, _) = trigger_runnable_inner(
let (uuid, _, _) = trigger_runnable_inner(
db,
user_db,
authed,
@@ -553,7 +554,7 @@ pub async fn trigger_runnable_and_wait_for_result(
trigger_path: String,
) -> Result<axum::response::Response> {
let username = authed.username.clone();
let (uuid, delete_after_use) = trigger_runnable_inner(
let (uuid, delete_after_use, early_return) = trigger_runnable_inner(
db,
user_db,
authed,
@@ -568,7 +569,8 @@ pub async fn trigger_runnable_and_wait_for_result(
)
.await?;
let (result, success) =
run_wait_result_internal(db, uuid, workspace_id.to_string(), None, &username).await?;
run_wait_result_internal(db, uuid, workspace_id.to_string(), early_return, &username)
.await?;
if delete_after_use.unwrap_or(false) {
delete_job_metadata_after_use(&db, uuid).await?;
@@ -592,7 +594,7 @@ pub async fn trigger_runnable_and_wait_for_raw_result(
trigger_path: String,
) -> Result<Box<RawValue>> {
let username = authed.username.clone();
let (uuid, delete_after_use) = trigger_runnable_inner(
let (uuid, delete_after_use, early_return) = trigger_runnable_inner(
db,
user_db,
authed,
@@ -607,23 +609,6 @@ pub async fn trigger_runnable_and_wait_for_raw_result(
)
.await?;
let early_return = if is_flow {
sqlx::query_scalar!(
r#"SELECT flow_version.value->>'early_return' as early_return
FROM flow
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 and flow.workspace_id = $2"#,
runnable_path,
workspace_id,
)
.fetch_optional(db)
.await?
.flatten()
} else {
None
};
let (result, success) =
run_wait_result_internal(db, uuid, workspace_id.to_string(), early_return, &username)
.await
+13 -3
View File
@@ -35,7 +35,7 @@ use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_common::s3_helpers::LargeFileStorage;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{build_crypt, decrypt, encrypt};
use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::GitRepositorySettings;
@@ -415,7 +415,9 @@ async fn is_premium(
) -> JsonResult<bool> {
require_admin(authed.is_admin, &authed.username)?;
#[cfg(feature = "cloud")]
let premium = windmill_common::workspaces::is_premium_workspace(&_db, &_w_id).await;
let premium = windmill_common::workspaces::get_team_plan_status(&_db, &_w_id)
.await
.premium;
#[cfg(not(feature = "cloud"))]
let premium = false;
Ok(Json(premium))
@@ -1926,6 +1928,8 @@ async fn set_encryption_key(
.execute(&db)
.await?;
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
if !request.skip_reencrypt.unwrap_or(false) {
let new_encryption_key = build_crypt(&db, w_id.as_str()).await?;
@@ -1948,7 +1952,13 @@ async fn set_encryption_key(
if !variable.is_secret {
continue;
}
let decrypted_value = decrypt(&previous_encryption_key, variable.value)?;
let decrypted_value =
decrypt(&previous_encryption_key, variable.value).map_err(|e| {
Error::internal_err(format!(
"Error decrypting variable {}: {}",
variable.path, e
))
})?;
let new_encrypted_value = encrypt(&new_encryption_key, decrypted_value.as_str());
sqlx::query!(
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
+11 -1
View File
@@ -654,6 +654,12 @@ pub async fn get_logs_from_store(
return None;
}
lazy_static::lazy_static! {
static ref TAGS_ARE_SENSITIVE: bool = std::env::var("TAGS_ARE_SENSITIVE").map(
|v| v.parse().unwrap()
).unwrap_or(false);
}
pub async fn check_tag_available_for_workspace_internal(
db: &DB,
w_id: &str,
@@ -691,10 +697,14 @@ pub async fn check_tag_available_for_workspace_internal(
)));
}
return Err(error::Error::BadRequest(format!(
if *TAGS_ARE_SENSITIVE {
return Err(Error::BadRequest(format!("{tag} is not available to you")));
} else {
return Err(error::Error::BadRequest(format!(
"Only super admins are allowed to use tags that are not included in the allowed CUSTOM_TAGS: {:?}",
custom_tags_per_w
)));
}
}
return Ok(());
+33 -5
View File
@@ -78,13 +78,38 @@ pub struct CreateVariable {
}
pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result<MagicCrypt256> {
let key = get_workspace_key(w_id, db).await?;
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", key, salt)
// Check cache first (300-second staleness)
let cached_key_o = WORKSPACE_CRYPT_CACHE.get(w_id).and_then(|(ts, key)| {
if ts > chrono::Utc::now().timestamp() - 300 {
Some(key)
} else {
None
}
});
let crypt = if let Some(cached_key) = cached_key_o {
cached_key
} else {
key
let key = get_workspace_key(w_id, db).await?;
tracing::info!(
"crypt for workspace {} with key {} expired, refetching",
w_id,
key
);
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", key, salt)
} else {
key
};
let ncrypt = magic_crypt::new_magic_crypt!(crypt_key, 256);
WORKSPACE_CRYPT_CACHE.insert(
w_id.to_string(),
(chrono::Utc::now().timestamp(), ncrypt.clone()),
);
ncrypt
};
Ok(magic_crypt::new_magic_crypt!(crypt_key, 256))
Ok(crypt)
}
pub async fn build_crypt_with_key_suffix(
@@ -110,6 +135,7 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result<Stri
.warn_after_seconds(5)
.await
.map_err(|e| crate::Error::internal_err(format!("fetching workspace key: {e:#}")))?;
Ok(key)
}
@@ -166,6 +192,8 @@ pub const WM_SCHEDULED_FOR: &str = "WM_SCHEDULED_FOR";
lazy_static::lazy_static! {
pub static ref CUSTOM_ENVS_CACHE: Cache<String, (i64, Vec<(String, String)>)> = Cache::new(100);
pub static ref WORKSPACE_CRYPT_CACHE: Cache<String, (i64, MagicCrypt256)> = Cache::new(1000);
}
pub async fn get_reserved_variables(
+34 -9
View File
@@ -82,22 +82,47 @@ impl Default for GitSyncSettings {
}
}
#[derive(Clone)]
pub struct TeamPlanStatus {
pub premium: bool,
pub is_past_due: bool,
pub max_tolerated_executions: Option<i32>,
}
lazy_static::lazy_static! {
pub static ref IS_PREMIUM_CACHE: Cache<String, bool> = Cache::new(5000);
pub static ref TEAM_PLAN_CACHE: Cache<String, TeamPlanStatus> = Cache::new(5000);
}
#[cfg(feature = "cloud")]
pub async fn is_premium_workspace(_db: &crate::DB, _w_id: &str) -> bool {
let cached = IS_PREMIUM_CACHE.get(_w_id);
pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> TeamPlanStatus {
let cached = TEAM_PLAN_CACHE.get(_w_id);
if let Some(cached) = cached {
return cached;
}
let premium = sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", _w_id)
.fetch_one(_db)
.await
.unwrap_or(false);
IS_PREMIUM_CACHE.insert(_w_id.to_string(), premium);
premium
let team_plan_info = sqlx::query_as!(
TeamPlanStatus,
r#"
SELECT
w.premium,
COALESCE(cw.is_past_due, false) as "is_past_due!",
cw.max_tolerated_executions
FROM
workspace w
LEFT JOIN cloud_workspace_settings cw ON cw.workspace_id = w.id
WHERE
w.id = $1
"#,
_w_id
)
.fetch_one(_db)
.await
.unwrap_or_else(|_| TeamPlanStatus {
premium: false,
is_past_due: false,
max_tolerated_executions: None,
});
TEAM_PLAN_CACHE.insert(_w_id.to_string(), team_plan_info.clone());
team_plan_info
}
#[derive(Deserialize, Serialize, Debug)]
+51 -39
View File
@@ -1419,8 +1419,9 @@ fn apply_completed_job_cloud_usage(
let email2 = email.clone();
tokio::task::spawn(async move {
let additional_usage = _duration / 1000;
let premium_workspace =
windmill_common::workspaces::is_premium_workspace(&db, &w_id).await;
let premium_workspace = windmill_common::workspaces::get_team_plan_status(&db, &w_id)
.await
.premium;
tokio::time::timeout(std::time::Duration::from_secs(10), async move {
let _ = sqlx::query!(
"INSERT INTO usage (id, is_workspace, month_, usage)
@@ -3636,8 +3637,8 @@ pub async fn push<'c, 'd>(
) -> Result<(Uuid, Transaction<'c, Postgres>), Error> {
#[cfg(feature = "cloud")]
if *CLOUD_HOSTED {
let premium_workspace =
windmill_common::workspaces::is_premium_workspace(_db, workspace_id).await;
let team_plan_status =
windmill_common::workspaces::get_team_plan_status(_db, workspace_id).await;
// we track only non flow steps
let (workspace_usage, user_usage) = if !matches!(
job_payload,
@@ -3655,7 +3656,7 @@ pub async fn push<'c, 'd>(
.await
.map_err(|e| Error::internal_err(format!("updating usage: {e:#}")))?;
let user_usage = if !premium_workspace {
let user_usage = if !team_plan_status.premium {
Some(sqlx::query_scalar!(
"INSERT INTO usage (id, is_workspace, month_, usage)
VALUES ($1, FALSE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)
@@ -3678,7 +3679,7 @@ pub async fn push<'c, 'd>(
Ok((None, None))
}?;
if !premium_workspace {
if !team_plan_status.premium || team_plan_status.is_past_due {
let is_super_admin =
sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email)
.fetch_optional(_db)
@@ -3686,7 +3687,8 @@ pub async fn push<'c, 'd>(
.unwrap_or(false);
if !is_super_admin {
if email != ERROR_HANDLER_USER_EMAIL
if !team_plan_status.premium
&& email != ERROR_HANDLER_USER_EMAIL
&& email != SCHEDULE_ERROR_HANDLER_USER_EMAIL
&& email != SCHEDULE_RECOVERY_HANDLER_USER_EMAIL
&& email != "worker@windmill.dev"
@@ -3765,43 +3767,53 @@ pub async fn push<'c, 'd>(
.flatten()
.unwrap_or(1)
};
if team_plan_status.premium {
// team plan is premium but past due, we check if the workspace has exceeded the max tolerated executions
if team_plan_status.max_tolerated_executions.is_none()
|| workspace_usage > team_plan_status.max_tolerated_executions.unwrap()
{
return Err(error::Error::QuotaExceeded(format!(
"Workspace {workspace_id} team plan is past due and isn't allowed to run any more jobs. Please fix your payment method in the workspace settings."
)));
}
} else {
if workspace_usage > MAX_FREE_EXECS
&& !matches!(job_payload, JobPayload::Dependencies { .. })
&& !matches!(job_payload, JobPayload::FlowDependencies { .. })
&& !matches!(job_payload, JobPayload::AppDependencies { .. })
{
return Err(error::Error::QuotaExceeded(format!(
"Workspace {workspace_id} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
)));
}
if workspace_usage > MAX_FREE_EXECS
&& !matches!(job_payload, JobPayload::Dependencies { .. })
&& !matches!(job_payload, JobPayload::FlowDependencies { .. })
&& !matches!(job_payload, JobPayload::AppDependencies { .. })
{
return Err(error::Error::QuotaExceeded(format!(
"Workspace {workspace_id} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
)));
}
let in_queue_workspace = sqlx::query_scalar!(
"SELECT COUNT(id) FROM v2_job_queue WHERE workspace_id = $1",
workspace_id
)
.fetch_one(_db)
.await?
.unwrap_or(0);
let in_queue_workspace = sqlx::query_scalar!(
"SELECT COUNT(id) FROM v2_job_queue WHERE workspace_id = $1",
workspace_id
)
.fetch_one(_db)
.await?
.unwrap_or(0);
if in_queue_workspace > MAX_FREE_EXECS as i64 {
return Err(error::Error::QuotaExceeded(format!(
"Workspace {workspace_id} has exceeded the jobs in queue limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
)));
}
if in_queue_workspace > MAX_FREE_EXECS as i64 {
return Err(error::Error::QuotaExceeded(format!(
"Workspace {workspace_id} has exceeded the jobs in queue limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
)));
}
let concurrent_runs_workspace = sqlx::query_scalar!(
let concurrent_runs_workspace = sqlx::query_scalar!(
"SELECT COUNT(id) FROM v2_job_queue WHERE running = true AND workspace_id = $1",
workspace_id
)
.fetch_one(_db)
.await?
.unwrap_or(0);
workspace_id
)
.fetch_one(_db)
.await?
.unwrap_or(0);
if concurrent_runs_workspace > MAX_FREE_CONCURRENT_RUNS as i64 {
return Err(error::Error::QuotaExceeded(format!(
"Workspace {workspace_id} has exceeded the concurrent runs limit of {MAX_FREE_CONCURRENT_RUNS} that applies outside of premium workspaces."
)));
if concurrent_runs_workspace > MAX_FREE_CONCURRENT_RUNS as i64 {
return Err(error::Error::QuotaExceeded(format!(
"Workspace {workspace_id} has exceeded the concurrent runs limit of {MAX_FREE_CONCURRENT_RUNS} that applies outside of premium workspaces."
)));
}
}
}
}
+3 -2
View File
@@ -680,11 +680,12 @@ pub async fn resolve_job_timeout(
let mut warn_msg: Option<String> = None;
#[cfg(feature = "cloud")]
let cloud_premium_workspace = *CLOUD_HOSTED
&& windmill_common::workspaces::is_premium_workspace(
&& windmill_common::workspaces::get_team_plan_status(
_conn.as_sql().expect("cloud cannot use http connection"),
_w_id,
)
.await;
.await
.premium;
#[cfg(not(feature = "cloud"))]
let cloud_premium_workspace = false;
+1 -1
View File
@@ -520,7 +520,7 @@ function get_from_env(name) {{
.map(|a| { format!("let {a} = get_from_env(\"{a}\");\n",) })
.join(""),
if expr.contains("error") && transform_context.contains(&"previous_result".to_string()) {
"let error = previous_result.error"
"let error = previous_result.error;"
} else {
""
},
+2 -4
View File
@@ -1027,7 +1027,7 @@ pub async fn update_flow_status_after_job_completion_internal(
.ok_or_else(|| Error::internal_err(format!("requiring flow to be in the queue")))?;
tx.commit().await?;
if matches!(module_step, Step::PreprocessorStep) {
if matches!(module_step, Step::PreprocessorStep) && success {
let tag_and_concurrency_key = get_tag_and_concurrency(&flow, db).await;
let require_args = tag_and_concurrency_key.as_ref().is_some_and(|x| {
x.tag.as_ref().is_some_and(|t| t.contains("$args"))
@@ -1144,9 +1144,7 @@ pub async fn update_flow_status_after_job_completion_internal(
"error while updating args in preprocessing step: {e:#}"
))
})?;
if success {
return Ok(UpdateFlowStatusAfterJobCompletion::PreprocessingStep);
}
return Ok(UpdateFlowStatusAfterJobCompletion::PreprocessingStep);
}
let job_root = flow_job
+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.536.0";
export const VERSION = "v1.538.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -68,7 +68,7 @@ export {
// }
// });
export const VERSION = "1.536.0";
export const VERSION = "1.538.0";
const command = new Command()
.name("wmill")
+34 -163
View File
@@ -1,15 +1,16 @@
{
"name": "windmill-components",
"version": "1.536.0",
"version": "1.538.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.536.0",
"version": "1.538.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.60.0",
"@aws-crypto/sha256-js": "^4.0.0",
"@codingame/monaco-vscode-configuration-service-override": "~20.2.1",
"@codingame/monaco-vscode-editor-api": "~20.2.1",
@@ -25,7 +26,7 @@
"@scalar/openapi-parser": "^0.15.0",
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.2",
"@windmill-labs/svelte-dnd-action": "^0.9.48",
"@windmill-labs/svelte-dnd-action": "^0.9.44",
"@xterm/addon-fit": "^0.10.0",
"@xyflow/svelte": "^1.0.0",
"ag-charts-community": "^9.0.1",
@@ -48,7 +49,7 @@
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.399.0",
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~20.2.1",
"monaco-editor-wrapper": "6.12.0",
@@ -56,7 +57,7 @@
"monaco-languageclient": "9.11.0",
"monaco-vim": "^0.4.1",
"ol": "^7.4.0",
"openai": "^4.87.1",
"openai": "^5.16.0",
"openapi-types": "^12.1.3",
"p-limit": "^6.1.0",
"panzoom": "^9.4.3",
@@ -186,6 +187,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.60.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.60.0.tgz",
"integrity": "sha512-9zu/TXaUy8BZhXedDtt1wT3H4LOlpKDO1/ftiFpeR3N1PCr3KJFKkxxlQWWt1NNp08xSwUNJ3JNY8yhl8av6eQ==",
"license": "MIT",
"bin": {
"anthropic-ai-sdk": "bin/cli"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.6.1.tgz",
@@ -3640,20 +3650,12 @@
"version": "20.19.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.10.tgz",
"integrity": "sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",
"integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/normalize-package-data": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
@@ -3898,6 +3900,7 @@
"version": "0.9.48",
"resolved": "https://registry.npmjs.org/@windmill-labs/svelte-dnd-action/-/svelte-dnd-action-0.9.48.tgz",
"integrity": "sha512-A6pWayH3nOi79DZohTscGj5t2PuNtHlKM5WIAj9WlVDx5pS1A+MsyfuNZi8oCpj2590wFmQl+58G4y4xZqDa1Q==",
"license": "MIT",
"peerDependencies": {
"svelte": ">=3.23.0 || ^5.0.0-next.0"
}
@@ -3953,17 +3956,6 @@
"svelte": "^3.57.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/abstract-leveldown": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
@@ -4028,17 +4020,6 @@
"ag-grid-community": "31.3.4"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4210,7 +4191,8 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/autoprefixer": {
"version": "10.4.21",
@@ -4802,6 +4784,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -5426,6 +5409,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -5742,6 +5726,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
@@ -6171,14 +6156,6 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
@@ -6405,6 +6382,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -6415,23 +6393,6 @@
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
"dependencies": {
"node-domexception": "1.0.0",
"web-streams-polyfill": "4.0.0-beta.3"
},
"engines": {
"node": ">= 12.20"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -7129,14 +7090,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
@@ -7895,9 +7848,10 @@
"optional": true
},
"node_modules/lucide-svelte": {
"version": "0.399.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.399.0.tgz",
"integrity": "sha512-NQ8AxNMKbIJsx7HV//gnAsIY1wJfb3rbXSK2S/ZDjIldvAEdzGngpUT8T8Q8zHYUuii0bavAmVARN8giR4vvpA==",
"version": "0.540.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.540.0.tgz",
"integrity": "sha512-jedJgKrrsT1B6mAMHGMmk6dIZKhorLq4KO7v+j7hYfeGR0LRe3W+SvwpWF1E6WT2/AY9IAm8KT/G+Nzf+0lW3g==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
@@ -8784,6 +8738,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@@ -8792,6 +8747,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -9141,44 +9097,6 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"optional": true
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch-native": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz",
@@ -9363,18 +9281,10 @@
}
},
"node_modules/openai": {
"version": "4.100.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.100.0.tgz",
"integrity": "sha512-9soq/wukv3utxcuD7TWFqKdKp0INWdeyhUCvxwrne5KwnxaCp4eHL4GdT/tMFhYolxgNhxFzg5GFwM331Z5CZg==",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"version": "5.16.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.16.0.tgz",
"integrity": "sha512-hoEH8ZNvg1HXjU9mp88L/ZH8O082Z8r6FHCXGiWAzVRrEv443aI57qhch4snu07yQydj+AUAWLenAiBXhu89Tw==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
@@ -9391,19 +9301,6 @@
}
}
},
"node_modules/openai/node_modules/@types/node": {
"version": "18.19.101",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.101.tgz",
"integrity": "sha512-Ykg7fcE3+cOQlLUv2Ds3zil6DVjriGQaSN/kEpl5HQ3DIGM6W0F2n9+GkWV4bRt7KjLymgzNdTnSKCbFUUJ7Kw==",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/openai/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
@@ -12381,11 +12278,6 @@
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -12521,6 +12413,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unified": {
@@ -12956,33 +12849,11 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
"engines": {
"node": ">= 14"
}
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wheel": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.536.0",
"version": "1.538.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -77,6 +77,7 @@
},
"type": "module",
"dependencies": {
"@anthropic-ai/sdk": "^0.60.0",
"@aws-crypto/sha256-js": "^4.0.0",
"@codingame/monaco-vscode-configuration-service-override": "~20.2.1",
"@codingame/monaco-vscode-editor-api": "~20.2.1",
@@ -92,7 +93,7 @@
"@scalar/openapi-parser": "^0.15.0",
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.2",
"@windmill-labs/svelte-dnd-action": "^0.9.48",
"@windmill-labs/svelte-dnd-action": "^0.9.44",
"@xterm/addon-fit": "^0.10.0",
"@xyflow/svelte": "^1.0.0",
"ag-charts-community": "^9.0.1",
@@ -115,7 +116,7 @@
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.399.0",
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~20.2.1",
"monaco-editor-wrapper": "6.12.0",
@@ -123,7 +124,7 @@
"monaco-languageclient": "9.11.0",
"monaco-vim": "^0.4.1",
"ol": "^7.4.0",
"openai": "^4.87.1",
"openai": "^5.16.0",
"openapi-types": "^12.1.3",
"p-limit": "^6.1.0",
"panzoom": "^9.4.3",
@@ -544,4 +545,4 @@
"@rollup/rollup-linux-x64-gnu": "^4.35.0",
"fsevents": "^2.3.3"
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="@sveltejs/kit" />
declare type Item = import('svelte-dnd-action').Item
declare type DndEvent<ItemType = Item> = import('svelte-dnd-action').DndEvent<ItemType>
declare type Item = import('@windmill-labs/svelte-dnd-action').Item
declare type DndEvent<ItemType = Item> = import('@windmill-labs/svelte-dnd-action').DndEvent<ItemType>
declare namespace svelte.JSX {
interface HTMLAttributes<T> {
onconsider?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void
+2 -2
View File
@@ -95,14 +95,14 @@
<div class="relative">
{#if JSON.stringify(value).length > 120}
<button
class="text-xs absolute top-0 right-4 text-tertiary"
class="text-xs absolute top-0 right-8 text-tertiary"
onclick={() => {
jsonViewerContent = value
jsonViewer?.toggleDrawer()
}}><Expand size={18} /></button
>
{/if}
<div class="max-h-60 overflow-auto">
<div class="max-h-60 overflow-auto" style="scrollbar-gutter: stable">
<ObjectViewer collapsed={false} topBrackets={true} pureViewer={true} json={value} />
</div>
</div>
+12 -15
View File
@@ -992,8 +992,11 @@
}
}
bind:args={value}
dndType={`nested-${title}`}
hiddenArgs={['label', 'kind']}
hiddenArgs={[
oneOf?.find((o) => Object.keys(o.properties ?? {}).includes('kind'))
? 'kind'
: 'label'
]}
on:reorder={(e) => {
if (oneOf && oneOf[objIdx]) {
const keys = e.detail
@@ -1086,20 +1089,14 @@
{disablePortal}
{disabled}
{prettifyHeader}
bind:schema={
() => ({
properties,
$schema: '',
required: nestedRequired ?? [],
type: 'object',
order
}),
(newSchema) => {
dispatch('nestedChange')
}
}
schema={{
properties,
$schema: '',
required: nestedRequired ?? [],
type: 'object',
order
}}
bind:args={value}
dndType={`nested-${title}`}
on:reorder={(e) => {
const keys = e.detail
order = keys
@@ -13,16 +13,24 @@
Tooltip
} from 'chart.js'
import type { CompletedJob, ExtendedJobs } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import { Line } from '$lib/components/chartjs-wrappers/chartJs'
export let extendedJobs: ExtendedJobs | undefined = undefined
export let maxIsNow: boolean = false
export let minTimeSet: string | undefined = undefined
export let maxTimeSet: string | undefined = undefined
interface Props {
extendedJobs?: ExtendedJobs | undefined
maxIsNow?: boolean
minTimeSet?: string | undefined
maxTimeSet?: string | undefined
onZoom: (zoom: { min: Date; max: Date }) => void
}
const dispatch = createEventDispatcher()
let {
extendedJobs = undefined,
maxIsNow = false,
minTimeSet = undefined,
maxTimeSet = undefined,
onZoom
}: Props = $props()
function calculateTimeSeries(extendedJobs: ExtendedJobs): AggregatedInterval[] {
const timeline = new Map<number, { count: number; id_started: string[]; id_ended: string[] }>()
@@ -57,7 +65,7 @@
}
const s = timeline.get(startTime)!
s.count += 1
s.id_started.push('unknoww')
s.id_started.push('unknown')
if (j.duration_ms != undefined) {
const jc = j as CompletedJob
const endTime = startTime + jc.duration_ms
@@ -103,9 +111,6 @@
}
type AggregatedInterval = { time: Date; count: number; msg?: string }
let intervals: AggregatedInterval[] | undefined = undefined
$: intervals = extendedJobs ? calculateTimeSeries(extendedJobs) : undefined
ChartJS.register(
Title,
Tooltip,
@@ -118,31 +123,12 @@
TimeScale
)
$: data = {
datasets: [
{
borderColor: '#4ade80',
backgroundColor: '#f8717100',
pointRadius: 0,
label: 'running',
showLine: true,
stepped: true,
data:
intervals?.map((job) => ({
x: job.time as any,
y: job.count,
id: job.msg
})) ?? []
}
]
}
const zoomOptions = {
pan: {
enabled: true,
modifierKey: 'ctrl' as 'ctrl',
onPanComplete: ({ chart }) => {
dispatch('zoom', {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
@@ -154,17 +140,13 @@
},
mode: 'x' as 'x',
onZoom: ({ chart }) => {
dispatch('zoom', {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
}
}
}
let minTime = addSeconds(new Date(), -300)
let maxTime = getDbClockNow()
$: computeMinMaxTime(intervals, minTimeSet, maxTimeSet)
function minJobTime(intervals: AggregatedInterval[]): Date {
return intervals[0].time
@@ -181,15 +163,13 @@
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
if (minTimeSetDate && maxTimeSetDate) {
minTime = minTimeSetDate
maxTime = maxTimeSetDate
return
return { min: minTimeSetDate, max: maxTimeSetDate }
}
if (intervals == undefined || intervals?.length == 0) {
minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
maxTime = maxTimeSetDate ?? getDbClockNow()
return
const minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
const maxTime = maxTimeSetDate ?? getDbClockNow()
return { min: minTime, max: maxTime }
}
const maxJob = maxIsNow ? getDbClockNow() : maxJobTime(intervals)
@@ -197,12 +177,11 @@
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
if (maxIsNow) {
maxTime = maxTimeSetDate ?? maxJob
} else {
maxTime = maxTimeSetDate ?? addSeconds(maxJob, diff)
}
const minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
const maxTime = maxIsNow
? (maxTimeSetDate ?? maxJob)
: (maxTimeSetDate ?? addSeconds(maxJob, diff))
return { min: minTime, max: maxTime }
}
function addSeconds(date: Date, seconds: number): Date {
@@ -210,7 +189,30 @@
return date
}
$: options = {
const intervals = $derived(extendedJobs ? calculateTimeSeries(extendedJobs) : undefined)
let data = $derived({
datasets: [
{
borderColor: '#4ade80',
backgroundColor: '#f8717100',
pointRadius: 0,
label: 'running',
showLine: true,
stepped: true,
data:
intervals?.map((job) => ({
x: job.time as any,
y: job.count,
id: job.msg
})) ?? []
}
]
})
const minMaxTimes = $derived(computeMinMaxTime(intervals, minTimeSet, maxTimeSet))
let options = $derived({
responsive: true,
maintainAspectRatio: false,
plugins: {
@@ -232,8 +234,8 @@
grid: {
display: false
},
min: minTime,
max: maxTime
min: minMaxTimes.min,
max: minMaxTimes.max
},
y: {
grid: {
@@ -254,7 +256,7 @@
intersect: false,
mode: 'index'
}
} as any
} as any)
</script>
<div class="relative max-h-40">
@@ -0,0 +1,38 @@
<script lang="ts">
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { ChevronDown } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { Item } from '$lib/utils'
interface Props {
items?: Item[]
extraLabel?: import('svelte').Snippet
selected: string
selectedDisplayName?: string
btnClasses?: string
}
let { items = [], extraLabel, selected, selectedDisplayName, btnClasses }: Props = $props()
const filteredItems = $derived(items.filter((item) => item.id !== selected))
</script>
<DropdownV2 items={filteredItems}>
{#snippet buttonReplacement()}
<div
class={twMerge(
'p-2 h-8 flex flex-row items-center gap-2 border hover:bg-surface-hover cursor-pointer rounded-md',
btnClasses
)}
>
<div class="flex flex-row items-center gap-1 pr-2 justify-between w-full">
<span class="text-xs whitespace-nowrap">
{selectedDisplayName ?? items.find((item) => item.id === selected)?.displayName ?? ''}
</span>
{@render extraLabel?.()}
</div>
<ChevronDown size={12} />
</div>
{/snippet}
</DropdownV2>
@@ -47,7 +47,7 @@
aiDescription={item.displayName}
>
{#if item.icon}
<item.icon size={14} color={item.iconColor} />
<item.icon size={14} color={item.iconColor} class="shrink-0" />
{/if}
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{item.displayName}
@@ -14,13 +14,18 @@
import FlowPropertyEditor from './schema/FlowPropertyEditor.svelte'
import PropertyEditor from './schema/PropertyEditor.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import { createEventDispatcher, tick, untrack } from 'svelte'
import { createEventDispatcher, untrack } from 'svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import Label from './Label.svelte'
import { sendUserToast } from '$lib/toast'
import Toggle from './Toggle.svelte'
import { DynamicSelect, emptyString } from '$lib/utils'
import {
DynamicSelect,
emptyString,
generateRandomString,
readFieldsRecursively
} from '$lib/utils'
import Popover from './meltComponents/Popover.svelte'
import SchemaFormDnd from './schema/SchemaFormDND.svelte'
import { deepEqual } from 'fast-equals'
@@ -48,7 +53,6 @@
isAppInput?: boolean
displayWebhookWarning?: boolean
onlyMaskPassword?: boolean
dndType?: string | undefined
editTab:
| 'inputEditor'
| 'history'
@@ -75,6 +79,8 @@
addProperty?: import('svelte').Snippet
runButton?: import('svelte').Snippet
extraTab?: import('svelte').Snippet
schemaFormClassName?: string
onChange?: (args: Record<string, any>) => void
}
let {
@@ -91,7 +97,6 @@
isAppInput = false,
displayWebhookWarning = false,
onlyMaskPassword = false,
dndType = undefined,
editTab,
previewSchema = undefined,
editPanelInitialSize = undefined,
@@ -110,7 +115,9 @@
openEditTab,
addProperty,
runButton,
extraTab
extraTab,
schemaFormClassName = undefined,
onChange = undefined
}: Props = $props()
$effect.pre(() => {
@@ -125,6 +132,13 @@
}
})
$effect(() => {
if (onChange) {
readFieldsRecursively(args)
onChange(args ?? {})
}
})
$effect(() => {
if (schema && dynSelectCode !== undefined && dynSelectLang !== undefined) {
if (dynSelectCode && dynSelectCode.trim()) {
@@ -154,9 +168,10 @@
let variableEditor: VariableEditor | undefined = $state(undefined)
let keys: string[] = $state(
Array.isArray(schema?.order)
(Array.isArray(schema?.order)
? [...schema.order]
: (Object.keys(schema?.properties ?? {}) ?? Object.keys(schema?.properties ?? {}))
).filter((x) => !hiddenArgs?.includes(x))
)
function alignOrderWithProperties(schema: {
@@ -187,37 +202,30 @@
return hasChanged
}
function onSchemaChange() {
let editSchema = false
if (alignOrderWithProperties(schema)) {
console.log('alignOrderWithProperties', JSON.stringify(schema, null, 2))
editSchema = true
// console.log('alignOrderWithProperties', JSON.stringify(schema, null, 2))
}
let lkeys = schema?.order ?? Object.keys(schema?.properties ?? {})
let lkeys = (schema?.order ?? Object.keys(schema?.properties ?? {})).filter(
(x) => !hiddenArgs?.includes(x)
)
if (schema?.properties && !deepEqual(lkeys, keys)) {
keys = [...lkeys]
editSchema = true
if (opened == undefined) {
opened = keys[0]
}
}
if (editSchema) {
schema = schema
}
}
let opened: string | undefined = $state(untrack(() => keys[0]))
function computeSelected(property: any) {
if (!opened) return ''
return property.type !== 'object'
? property.type
: property.format === 'resource-s3_object'
? 'S3'
: property.format?.startsWith('dynselect-')
? 'dynselect'
: property.oneOf && property.oneOf.length >= 2
? 'oneOf'
: 'object'
if (property.type !== 'object') return property.type
if (property.format === 'resource-s3_object') return 'S3'
if (property.format?.startsWith('dynselect-')) return 'dynselect'
if (property.oneOf && property.oneOf.length >= 2) return 'oneOf'
if (property.format?.startsWith('resource-')) return 'resource'
return 'object'
}
export function openField(key: string) {
@@ -248,30 +256,31 @@
// clear the input
el.value = oldName
} else {
let newSchema = $state.snapshot(schema)
if (args) {
args[newName] = args[oldName]
delete args[oldName]
}
schema.properties[newName] = schema.properties[oldName]
delete schema.properties[oldName]
newSchema.properties[newName] = newSchema.properties[oldName]
delete newSchema.properties[oldName]
if (schema.required?.includes(oldName)) {
schema.required = schema.required?.map((x) => (x === oldName ? newName : x))
if (newSchema.required?.includes(oldName)) {
newSchema.required = newSchema.required?.map((x) => (x === oldName ? newName : x))
}
// Replace the old name with the new name in the order array
if (schema.order) {
const index = schema.order.indexOf(oldName)
if (newSchema.order) {
const index = newSchema.order.indexOf(oldName)
if (index !== -1) {
schema.order[index] = newName
newSchema.order[index] = newName
}
}
opened = newName
schema = $state.snapshot(schema)
dispatch('change', schema)
schema = newSchema
sendUserToast('Argument renamed')
}
}
@@ -347,10 +356,11 @@
['Number', 'number'],
['Integer', 'integer'],
['Object', 'object'],
['Resource', 'resource'],
['OneOf', 'oneOf'],
['Array', 'array'],
['Boolean', 'boolean'],
['S3 Object', 'S3']
['S3', 'S3']
]
if (showDynSelectOpt) {
typeOptions.push(['DynSelect', 'dynselect'])
@@ -368,6 +378,8 @@
const code = generateFn(functionName)
dynSelectCode = dynSelectCode ? dynSelectCode.concat(code) : code
}
let dndType = $state(generateRandomString())
</script>
<div class="w-full h-full">
@@ -404,15 +416,15 @@
class="min-h-0 overflow-y-auto grow rounded-md {runButton ? 'flex flex-col gap-2' : ''}"
>
<SchemaFormDnd
nestedClasses={'flex flex-col gap-1'}
{dndType}
nestedClasses={'flex flex-col gap-1 ' + (schemaFormClassName ?? '')}
bind:schema={
() => (previewSchema ? previewSchema : schema),
(newSchema) => {
schema = newSchema
tick().then(() => dispatch('change', schema))
}
}
{dndType}
{hiddenArgs}
{disableDnd}
{onlyMaskPassword}
bind:args
@@ -420,11 +432,16 @@
opened = e.detail
}}
on:reorder={(e) => {
let order = e.detail
let newProperties = {}
for (let key of order) {
newProperties[key] = schema.properties[key]
}
schema = {
...schema,
properties: newProperties,
order: e.detail
}
tick().then(() => dispatch('change', schema))
}}
helperScript={{
type: 'inline',
@@ -436,9 +453,6 @@
{diff}
on:acceptChange
on:rejectChange
on:nestedChange={() => {
dispatch('change', schema)
}}
{shouldDispatchChanges}
bind:isValid
noVariablePicker={noVariablePicker || customUi?.disableVariablePicker === true}
@@ -446,8 +460,8 @@
{@render runButton?.()}
<div class="h-full">
{#if dynSelectFunctions.length > 0}
{#if dynSelectFunctions.length > 0}
<div class="grow">
<Section
label="Dynamic select functions"
collapsable={true}
@@ -494,8 +508,8 @@
{/key}
</div>
</Section>
{/if}
</div>
</div>
{/if}
</div>
</div>
</Pane>
@@ -514,7 +528,7 @@
{#if jsonEnabled && customUi?.jsonOnly != true}
<div class="w-full p-3 flex gap-4 justify-end items-center">
{#if addPropertyInEditorTab}
<AddPropertyV2 bind:schema on:change>
<AddPropertyV2 bind:schema>
{#snippet trigger()}
<Button color="light" size="xs" iconOnly startIcon={{ icon: Plus }} />
{/snippet}
@@ -531,9 +545,13 @@
'Arguments can be edited either using the wizard, or by editing their JSON Schema.'
}}
lightMode
on:change={() => {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
on:change={(e) => {
if (e.detail) {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
} else {
schema = JSON.parse(schemaString)
}
}}
/>
</div>
@@ -629,7 +647,7 @@
</div>
{#if opened === argName}
<div class="p-4 border-t">
{#if !hiddenArgs.includes(argName) && Object.keys(schema?.properties ?? {}).includes(argName)}
{#if Object.keys(schema?.properties ?? {}).includes(argName)}
{#if typeof args == 'object' && schema?.properties[argName]}
<PropertyEditor
bind:description={schema.properties[argName].description}
@@ -650,10 +668,6 @@
bind:order={schema.properties[argName].order}
{isFlowInput}
{isAppInput}
on:change={() => {
schema = $state.snapshot(schema)
dispatch('change', schema)
}}
>
{#snippet typeeditor()}
{#if isFlowInput || isAppInput}
@@ -664,6 +678,7 @@
bind:selected={
() => computeSelected(schema.properties[opened ?? '']),
(v) => {
const isResource = v == 'resource'
const isS3 = v == 'S3'
const isOneOf = v == 'oneOf'
const isDynSelect = v == 'dynselect'
@@ -684,13 +699,18 @@
nullable: undefined,
required: undefined
}
if (isS3) {
schema.properties[argName] = {
...emptyProperty,
type: 'object',
format: 'resource-s3_object'
}
} else if (isResource) {
schema.properties[argName] = {
...emptyProperty,
type: 'object',
format: 'resource-'
}
} else if (isDynSelect) {
const functionName = argName.replace(/\s+/g, '_')
schema.properties[argName] = {
@@ -742,13 +762,9 @@
type: v
}
}
schema.properties = schema.properties
}
}
on:selected={(e) => {
schema = schema
dispatch('change', schema)
dispatch('schemaChange')
}}
>
{#snippet children({ item })}
{#each typeOptions as x}
@@ -762,6 +778,9 @@
{#if isFlowInput || isAppInput}
<FlowPropertyEditor
onDrawerClose={() => {
dndType = generateRandomString()
}}
bind:defaultValue={schema.properties[argName].default}
{variableEditor}
{itemPicker}
@@ -792,12 +811,6 @@
(x) => x !== argName
)
}
dispatch('change', schema)
}}
on:schemaChange={(e) => {
schema = $state.snapshot(schema)
dispatch('change', schema)
dispatch('schemaChange')
}}
/>
{/if}
@@ -822,7 +835,6 @@
on:change={() => {
try {
schema = JSON.parse(schemaString)
dispatch('change', schema)
error = ''
} catch (err) {
error = err.message
+69 -8
View File
@@ -150,11 +150,12 @@
import { writable } from 'svelte/store'
import { formatResourceTypes } from './copilot/chat/script/core'
import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte'
import { editorPositionMap, readFieldsRecursively } from '$lib/utils'
import { editorPositionMap } from '$lib/utils'
import { extToLang, langToExt } from '$lib/editorLangUtils'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
import type { Selection } from 'monaco-editor'
import { getDbSchemas } from './apps/components/display/dbtable/utils'
import { PYTHON_PREPROCESSOR_MODULE_CODE, TS_PREPROCESSOR_MODULE_CODE } from '$lib/script_helpers'
// import EditorTheme from './EditorTheme.svelte'
let divEl: HTMLDivElement | null = $state(null)
@@ -185,6 +186,7 @@
key?: string | undefined
class?: string | undefined
moduleId?: string
enablePreprocessorSnippet?: boolean
}
let {
@@ -211,7 +213,8 @@
loadAsync = false,
key = undefined,
class: clazz = undefined,
moduleId = undefined
moduleId = undefined,
enablePreprocessorSnippet = false
}: Props = $props()
$effect.pre(() => {
@@ -544,9 +547,7 @@
let sqlSchemaCompletor: IDisposable | undefined = undefined
async function updateSchema() {
const newSchemaRes = lang === 'graphql' ? args?.api : args?.database
async function updateSchema(newSchemaRes: string | undefined) {
if (typeof newSchemaRes === 'string') {
const resourcePath = newSchemaRes.replace('$res:', '')
dbSchema = $dbSchemas[resourcePath]
@@ -667,6 +668,55 @@
}
}
let preprocessorCompletor: IDisposable | undefined = undefined
function addPreprocessorCompletions(lang: 'typescript' | 'python') {
if (preprocessorCompletor) {
preprocessorCompletor.dispose()
}
const preprocessorCode =
lang === 'typescript' ? TS_PREPROCESSOR_MODULE_CODE : PYTHON_PREPROCESSOR_MODULE_CODE
preprocessorCompletor = languages.registerCompletionItemProvider(lang, {
provideCompletionItems: function (model, position) {
const word = model.getWordUntilPosition(position)
if (word.word.length >= 3 && 'preprocessor'.startsWith(word.word)) {
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
}
return {
suggestions: [
{
label: 'preprocessor (windmill)',
kind: languages.CompletionItemKind.Function,
insertTextRules: languages.CompletionItemInsertTextRule.InsertAsSnippet,
insertText: preprocessorCode,
range,
additionalTextEdits: [
{
range: {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: 0,
endColumn: word.startColumn
},
text: ''
}
]
}
]
}
}
return {
suggestions: []
}
}
})
}
let reviewingChanges = $state(writable(false))
let aiChatEditorHandler: AIChatEditorHandler | undefined = $state(undefined)
@@ -1570,6 +1620,7 @@
sqlSchemaCompletor && sqlSchemaCompletor.dispose()
autocompletor && autocompletor.dispose()
sqlTypeCompletor && sqlTypeCompletor.dispose()
preprocessorCompletor && preprocessorCompletor.dispose()
timeoutModel && clearTimeout(timeoutModel)
loadTimeout && clearTimeout(loadTimeout)
aiChatEditorHandler?.clear()
@@ -1621,10 +1672,20 @@
? untrack(() => addSqlTypeCompletions())
: sqlTypeCompletor?.dispose()
})
$effect(() => {
console.log('updating schema', lang, $dbSchemas)
readFieldsRecursively(args)
lang && $dbSchemas && untrack(() => updateSchema())
initialized && (lang === 'typescript' || lang === 'python') && enablePreprocessorSnippet
? untrack(() => addPreprocessorCompletions(lang as 'typescript' | 'python'))
: preprocessorCompletor?.dispose()
})
let lastArg = undefined
$effect(() => {
let newArg = lang === 'graphql' ? args?.api : args?.database
if (newArg !== lastArg) {
lastArg = newArg
$dbSchemas && untrack(() => updateSchema(newArg))
}
})
$effect(() => {
console.log('updating db schema completions', dbSchema, lang)
@@ -50,6 +50,7 @@
AI_TOOL_MESSAGE_PREFIX,
getToolCallId
} from './graph/renderers/nodes/AIToolNode.svelte'
import JobAssetsViewer from './assets/JobAssetsViewer.svelte'
let {
flowStateStore,
@@ -1048,7 +1049,11 @@
}
}
})
let selected = $derived(isListJob ? 'sequence' : 'graph') as 'sequence' | 'graph' | 'logs'
let selected = $derived(isListJob ? 'sequence' : 'graph') as
| 'sequence'
| 'graph'
| 'logs'
| 'assets'
let animateLogsTab = $state(false)
@@ -1076,6 +1081,23 @@
return ''
}
// Set all tabs content to the same height to prevent layout jumps
let tabsHeight = $state({
sequenceHeight: 0,
logsHeight: 0,
assetsHeight: 0,
graphHeight: 0
})
let minTabHeight = $derived(
Math.max(
tabsHeight.sequenceHeight,
tabsHeight.logsHeight,
tabsHeight.assetsHeight,
tabsHeight.graphHeight
)
)
</script>
<JobLoader workspaceOverride={workspaceId} {noLogs} noCode bind:this={jobLoader} />
@@ -1154,12 +1176,17 @@
: ''}><span class="font-semibold">Logs</span></Tab
>
<Tab value="sequence"><span class="font-semibold">Details</span></Tab>
<Tab value="assets"><span class="font-semibold">Assets</span></Tab>
</Tabs>
{:else}
<div class="h-[30px]"></div>
{/if}
{/if}
<div class="{selected != 'sequence' ? 'hidden' : ''} max-w-7xl mx-auto">
<div
class="{selected != 'sequence' ? 'hidden' : ''} max-w-7xl mx-auto"
bind:clientHeight={tabsHeight.sequenceHeight}
style="min-height: {minTabHeight}px"
>
{#if isListJob}
{@const sliceFrom =
globalIterationBounds[buildSubflowKey(flowJobIds?.moduleId ?? '', prefix)]
@@ -1508,7 +1535,11 @@
<div class="p-2 text-tertiary text-sm italic">Empty flow</div>
{/if}
</div>
<div class="{selected != 'logs' ? 'hidden' : ''} mx-auto h-[800px]">
<div
class="{selected != 'logs' ? 'hidden' : ''} mx-auto"
bind:clientHeight={tabsHeight.logsHeight}
style="min-height: {minTabHeight}px"
>
<FlowLogViewerWrapper
{job}
{localModuleStates}
@@ -1517,10 +1548,23 @@
{onSelectedIteration}
/>
</div>
{#if selected == 'assets' && render}
<div
class="p-2"
bind:clientHeight={tabsHeight.assetsHeight}
style="min-height: {minTabHeight}px"
>
<JobAssetsViewer {job} />
</div>
{/if}
</div>
{#if render}
{#if job.raw_flow && !isListJob}
<div class="{selected != 'graph' ? 'hidden' : ''} grow mt-4">
<div
class="{selected != 'graph' ? 'hidden' : ''} grow mt-4"
bind:clientHeight={tabsHeight.graphHeight}
style="min-height: {minTabHeight}px"
>
<div class="grid grid-cols-3 border h-full" bind:clientHeight={wrapperHeight}>
<div class="col-span-2 bg-surface-secondary">
<div class="flex flex-col">
+1 -1
View File
@@ -85,7 +85,7 @@ ${Object.entries(args)
{#each Object.entries(args).sort((a, b) => a[0].localeCompare(b[0])) as [arg, value]}
<Row>
<Cell first>{arg}</Cell>
<Cell last><ArgInfo {value} /></Cell>
<Cell><ArgInfo {value} /></Cell>
</Row>
{/each}
{:else if args}
@@ -1,21 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import ResourceTypePicker from './ResourceTypePicker.svelte'
export let format: string | undefined
let resource: string | undefined = format?.startsWith('resource-')
? format.substring('resource-'.length)
: undefined
const dispatch = createEventDispatcher()
</script>
<ResourceTypePicker
on:click={(e) => {
resource = e.detail
format = resource != undefined ? `resource-${resource}` : undefined
dispatch('change', format)
}}
value={resource}
nonePickable={true}
/>
+11 -7
View File
@@ -1,4 +1,4 @@
<script context="module">
<script module>
import { tick } from 'svelte'
export function portal(el, options) {
@@ -50,13 +50,17 @@
<script>
/**
* DOM Element or CSS Selector
* @type { HTMLElement|string}
* @typedef {Object} Props
* @property { HTMLElement|string} [target] - DOM Element or CSS Selector
* @property {any} [name]
* @property {string|undefined} [class]
* @property {import('svelte').Snippet} [children]
*/
export let target = 'body'
export let name = undefined
/** @type {Props} */
let { target = 'body', name = undefined, class: clazz = undefined, children } = $props()
</script>
<div use:portal={{ target, name }} hidden class={$$props.class}>
<slot />
<div use:portal={{ target, name }} hidden class={clazz}>
{@render children?.()}
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import ResourceTypePicker from './ResourceTypePicker.svelte'
interface Props {
format: string | undefined
}
let { format = $bindable() }: Props = $props()
let resource: string | undefined = $state(
format?.startsWith('resource-') ? format.substring('resource-'.length) : undefined
)
</script>
<ResourceTypePicker
on:click={(e) => {
resource = e.detail
format = `resource-${resource ?? ''}`
}}
value={resource}
nonePickable={true}
/>
@@ -5,8 +5,6 @@
import { workspaceStore } from '$lib/stores'
import IconedResourceType from './IconedResourceType.svelte'
import { Button, ClearableInput } from './common'
import Popover from './meltComponents/Popover.svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import Label from './Label.svelte'
import Tooltip from './Tooltip.svelte'
import Badge from './common/badge/Badge.svelte'
@@ -46,84 +44,61 @@
)
</script>
<Label label="Resource type" class="w-full col-span-2">
<Label label="Resource type" class="w-full mb-4">
{#snippet header()}
<Tooltip light small>Select a resource type to narrow down the object type.</Tooltip>
{/snippet}
{#snippet action()}
<div class="flex flex-row gap-1">
<Button
size="xs"
color="light"
on:click={() => onClick(undefined)}
disabled={notPickable || value === undefined}
>
Clear
</Button>
<Popover
floatingConfig={{
strategy: 'fixed',
placement: 'left-end',
middleware: [offset(8), flip(), shift()]
}}
contentClasses="flex flex-col gap-2 h-full p-4 max-h-[40vh] w-[500px]"
>
{#snippet trigger()}
<Button nonCaptureEvent size="xs" color="dark">Select resource type</Button>
{/snippet}
{#snippet content({ close })}
<ClearableInput bind:value={search} placeholder="Search resource..." />
<div class="overflow-y-scroll h-full">
<div
class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center overflow-x-hidden"
>
{#if nonePickable && search === ''}
{@const isPicked = value === undefined}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'dark'}
btnClasses={isPicked ? '!border-2' : 'm-[1px]'}
disabled={notPickable}
on:click={() => {
onClick(undefined)
close()
}}
>
None
</Button>
{/if}
{#each filteredResources as r}
{@const isPicked = value === r}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'light'}
btnClasses={isPicked ? '!border-2' : 'm-[1px]'}
disabled={notPickable}
on:click={() => {
onClick(r)
close()
}}
>
<IconedResourceType name={r} after={true} width="20px" height="20px" />
</Button>
{/each}
{#if filteredResources.length === 0 && search !== ''}
<div class="text-tertiary text-sm">No resources found</div>
{/if}
</div>
</div>
{/snippet}
</Popover>
<div class="flex flex-row items-center w-full justify-between">
<Badge color={!value ? 'gray' : 'blue'}>
{value ?? 'None'}
</Badge>
</div>
{/snippet}
<div class="flex flex-row items-center w-full justify-between">
<Badge color={!value ? 'gray' : 'blue'}>
{value ?? 'None'}
</Badge>
<div class="mt-2">
<ClearableInput bind:value={search} placeholder="Search resource..." />
<div class="overflow-y-scroll max-h-[330px] h-full mt-1">
<div
class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center overflow-x-hidden"
>
{#if nonePickable && search === ''}
{@const isPicked = value === undefined}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'light'}
btnClasses={isPicked ? '!border-2' : 'm-[1px]'}
disabled={notPickable}
on:click={() => {
onClick(undefined)
close()
}}
>
None
</Button>
{/if}
{#each filteredResources as r}
{@const isPicked = value === r}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'light'}
btnClasses={isPicked ? '!border-2' : 'm-[1px]'}
disabled={notPickable}
on:click={() => {
onClick(r)
close()
}}
>
<IconedResourceType name={r} after={true} width="20px" height="20px" />
</Button>
{/each}
{#if filteredResources.length === 0 && search !== ''}
<div class="text-tertiary text-sm">No resources found</div>
{/if}
</div>
</div>
</div>
</Label>
+108 -102
View File
@@ -15,28 +15,42 @@
LogarithmicScale
} from 'chart.js'
import type { CompletedJob } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import Button from './common/button/Button.svelte'
import { Scatter } from '$lib/components/chartjs-wrappers/chartJs'
import DarkModeObserver from './DarkModeObserver.svelte'
export let jobs: CompletedJob[] | undefined = []
export let maxIsNow: boolean = false
export let minTimeSet: string | undefined = undefined
export let maxTimeSet: string | undefined = undefined
export let selectedIds: string[] = []
export let canSelect: boolean = true
export let lastFetchWentToEnd: boolean = false
interface Props {
jobs?: CompletedJob[] | undefined
maxIsNow?: boolean
minTimeSet?: string | undefined
maxTimeSet?: string | undefined
selectedIds?: string[]
canSelect?: boolean
lastFetchWentToEnd?: boolean
onPointClicked: (ids: string[]) => void
onLoadExtra: () => void
onZoom: (zoom: { min: Date; max: Date }) => void
}
let {
jobs = [],
maxIsNow = false,
minTimeSet = undefined,
maxTimeSet = undefined,
selectedIds = $bindable([]),
canSelect = true,
lastFetchWentToEnd = false,
onPointClicked,
onLoadExtra,
onZoom
}: Props = $props()
const dispatch = createEventDispatcher()
const SUCCESS_COLOR = '#4ade80'
// const SUCCESS_COLOR_TRANSPARENT = '#c9b638'
const SUCCESS_COLOR_TRANSPARENT = mergeColors(SUCCESS_COLOR, getBackgorundColor(), 0.8)
const SUCCESS_COLOR_TRANSPARENT = $derived(mergeColors(SUCCESS_COLOR, getBackgroundColor(), 0.8))
const FAIL_COLOR = '#f87171'
const FAIL_COLOR_TRANSPARENT = mergeColors(FAIL_COLOR, getBackgorundColor(), 0.8)
$: success = jobs?.filter((x) => x.success)
$: failed = jobs?.filter((x) => !x.success)
const FAIL_COLOR_TRANSPARENT = $derived(mergeColors(FAIL_COLOR, getBackgroundColor(), 0.8))
ChartJS.register(
Title,
@@ -51,43 +65,12 @@
TimeScale
)
$: data = {
datasets: [
{
borderColor: 'rgba(99,0,125, 0)',
backgroundColor: FAIL_COLOR as string | string[],
radius: 3,
label: 'Failed',
data:
failed?.map((job) => ({
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
})) ?? []
},
{
borderColor: 'rgba(99,0,125, 0)',
backgroundColor: SUCCESS_COLOR as string | string[],
radius: 3,
label: 'Successful',
data:
success?.map((job) => ({
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
})) ?? []
}
]
}
const zoomOptions = {
pan: {
enabled: true,
modifierKey: 'ctrl' as 'ctrl',
onPanComplete: ({ chart }) => {
dispatch('zoom', {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
@@ -99,7 +82,7 @@
},
mode: 'x' as 'x',
onZoom: ({ chart }) => {
dispatch('zoom', {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
@@ -107,15 +90,14 @@
}
}
function isDark(): boolean {
return document.documentElement.classList.contains('dark')
}
let darkMode = $state(false)
$effect(() => {
ChartJS.defaults.color = darkMode ? '#ccc' : '#666'
ChartJS.defaults.borderColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
})
ChartJS.defaults.color = isDark() ? '#ccc' : '#666'
ChartJS.defaults.borderColor = isDark() ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
function getBackgorundColor(): string {
return isDark() ? '#2e3440' : '#ffffff'
function getBackgroundColor(): string {
return darkMode ? '#2e3440' : '#ffffff'
}
function hexToRgb(hexColor: string): number[] {
hexColor = hexColor.replace(/^#/, '')
@@ -150,29 +132,10 @@
return rgbToHex(blendedRgb)
}
function highlightSelectedPoints(ids: string[]) {
if (!canSelect || ids.length === 0) {
data.datasets[0].backgroundColor = FAIL_COLOR
data.datasets[1].backgroundColor = SUCCESS_COLOR
} else {
data.datasets[0].backgroundColor = data.datasets[0].data.map((p) =>
ids.includes(p.id) ? FAIL_COLOR : FAIL_COLOR_TRANSPARENT
)
data.datasets[1].backgroundColor = data.datasets[1].data.map((p) =>
ids.includes(p.id) ? SUCCESS_COLOR : SUCCESS_COLOR_TRANSPARENT
)
}
}
function getPath(x: any): string {
return x.path
}
let minTime = addSeconds(new Date(), -300)
let maxTime = getDbClockNow()
$: computeMinMaxTime(jobs, minTimeSet, maxTimeSet)
function minJobTime(jobs: CompletedJob[]): Date {
let min: Date = new Date(jobs[0].started_at)
for (const job of jobs) {
@@ -195,6 +158,7 @@
}
return max
}
function computeMinMaxTime(
jobs: CompletedJob[] | undefined,
minTimeSet: string | undefined,
@@ -203,15 +167,13 @@
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
if (minTimeSetDate && maxTimeSetDate) {
minTime = minTimeSetDate
maxTime = maxTimeSetDate
return
return { minTime: minTimeSetDate, maxTime: maxTimeSetDate }
}
if (jobs == undefined || jobs?.length == 0) {
minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
maxTime = maxTimeSetDate ?? getDbClockNow()
return
const computedMinTime = minTimeSetDate ?? addSeconds(new Date(), -300)
const computedMaxTime = maxTimeSetDate ?? getDbClockNow()
return { minTime: computedMinTime, maxTime: computedMaxTime }
}
const maxJob = maxIsNow ? getDbClockNow() : maxJobTime(jobs)
@@ -219,12 +181,11 @@
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
if (maxIsNow) {
maxTime = maxTimeSetDate ?? maxJob
} else {
maxTime = maxTimeSetDate ?? addSeconds(maxJob, diff)
}
let computedMinTime = minTimeSetDate ?? addSeconds(minJob, -diff)
let computedMaxTime = maxIsNow
? (maxTimeSetDate ?? maxJob)
: (maxTimeSetDate ?? addSeconds(maxJob, diff))
return { minTime: computedMinTime, maxTime: computedMaxTime }
}
function addSeconds(date: Date, seconds: number): Date {
@@ -232,7 +193,56 @@
return date
}
$: scatterOptions = {
let success = $derived(jobs?.filter((x) => x.success))
let failed = $derived(jobs?.filter((x) => !x.success))
let data = $derived.by(() => {
const data = {
datasets: [
{
borderColor: 'rgba(99,0,125, 0)',
backgroundColor: FAIL_COLOR as string | string[],
radius: 3,
label: 'Failed',
data:
failed?.map((job) => ({
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
})) ?? []
},
{
borderColor: 'rgba(99,0,125, 0)',
backgroundColor: SUCCESS_COLOR as string | string[],
radius: 3,
label: 'Successful',
data:
success?.map((job) => ({
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
})) ?? []
}
]
}
if (!canSelect || selectedIds.length === 0) {
data.datasets[0].backgroundColor = FAIL_COLOR
data.datasets[1].backgroundColor = SUCCESS_COLOR
} else {
data.datasets[0].backgroundColor = data.datasets[0].data.map((p) =>
selectedIds.includes(p.id) ? FAIL_COLOR : FAIL_COLOR_TRANSPARENT
)
data.datasets[1].backgroundColor = data.datasets[1].data.map((p) =>
selectedIds.includes(p.id) ? SUCCESS_COLOR : SUCCESS_COLOR_TRANSPARENT
)
}
return data
})
const minMaxTime = $derived.by(() => computeMinMaxTime(jobs, minTimeSet, maxTimeSet))
let scatterOptions = $derived({
responsive: true,
maintainAspectRatio: false,
plugins: {
@@ -242,17 +252,17 @@
},
tooltip: {
callbacks: {
label: function (context) {
label: function (context: any) {
return getPath(context.raw)
}
}
}
},
onClick: (e, u) => {
onClick: (_e: any, u: any) => {
if (canSelect) {
const ids = u.map((j) => data.datasets[j.datasetIndex].data[j.index].id)
const ids = u.map((j: any) => data.datasets[j.datasetIndex].data[j.index].id)
selectedIds = ids
dispatch('pointClicked', ids)
onPointClicked(ids)
}
},
@@ -262,8 +272,8 @@
grid: {
display: false
},
min: minTime,
max: maxTime
min: minMaxTime.minTime,
max: minMaxTime.maxTime
},
y: {
grid: {
@@ -277,11 +287,11 @@
}
},
animation: false
} as any
$: data && scatterOptions && highlightSelectedPoints(selectedIds)
} as any)
</script>
<DarkModeObserver bind:darkMode />
<!-- {JSON.stringify(minTime)}
{JSON.stringify(maxTime)}
@@ -291,12 +301,8 @@
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
<div class="relative max-h-40">
{#if !lastFetchWentToEnd}
<div class="absolute top-[-10px] left-[60px]"
><Button
size="xs"
color="transparent"
variant="contained"
on:click={() => dispatch('loadExtra')}
<div class="absolute top-[-26px] left-[160px]"
><Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}
>Load more <Tooltip2
>There are more jobs to load but only the first 1000 were fetched</Tooltip2
></Button
@@ -108,7 +108,7 @@
className = '',
computeS3ForceViewerPolicies = undefined,
workspace = undefined,
actions
actions: actions_render = undefined
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -249,8 +249,6 @@
$effect.pre(() => {
isValid = allTrue(inputCheck ?? {})
})
const actions_render = $derived(actions)
</script>
{#if showReset}
@@ -1791,6 +1791,7 @@
bind:hasPreprocessor
bind:captureTable
bind:assets={script.assets}
enablePreprocessorSnippet
/>
</div>
{:else}
@@ -81,6 +81,7 @@
disableAi?: boolean
assets?: AssetWithAltAccessType[]
editor_bar_right?: import('svelte').Snippet
enablePreprocessorSnippet?: boolean
}
let {
@@ -111,7 +112,8 @@
lastDeployedCode = undefined,
disableAi = false,
assets = $bindable(),
editor_bar_right
editor_bar_right,
enablePreprocessorSnippet = false
}: Props = $props()
$effect.pre(() => {
@@ -625,6 +627,7 @@
automaticLayout={true}
{fixedOverflowWidgets}
{args}
{enablePreprocessorSnippet}
/>
<DiffEditor
className="h-full"
+1 -1
View File
@@ -6,10 +6,10 @@
export let date: string
export let agoOnlyIfRecent: boolean = false
export let noDate = false
export let isRecent: boolean = true
let computedTimeAgo: string | undefined = undefined
let isRecent = true
let interval
$: date && computeDate()
+3 -1
View File
@@ -12,6 +12,7 @@
right?: string
rightTooltip?: string
rightDocumentationLink?: string
title?: string
} = {}
export let checked: boolean = false
export let disabled = false
@@ -37,6 +38,7 @@
class="{$$props.class || ''} z-auto flex flex-row items-center duration-50 {disabled
? 'grayscale opacity-50'
: 'cursor-pointer'}"
title={options?.title}
>
{#if Boolean(options?.left)}
<span
@@ -103,7 +105,7 @@
class={twMerge(
'ml-2 font-medium duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
size === 'xs' || size === '2sm' ? 'text-xs' : 'text-sm',
size === 'xs' || size === '2sm' ? 'text-xs' : size === '2xs' ? 'text-xs' : 'text-sm',
textClass
)}
style={textStyle}
@@ -173,11 +173,11 @@
<Disposable
{id}
bind:this={disposable}
on:open={() => {
onOpen={() => {
outputs?.open.set(true)
onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
on:close={() => {
onClose={() => {
outputs?.open.set(false)
onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
@@ -10,6 +10,7 @@
import { AppService, DraftService, SettingService, type Policy } from '$lib/gen'
import { redo, undo } from '$lib/history.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import type { Item } from '$lib/utils'
import {
AlignHorizontalSpaceAround,
BellOff,
@@ -12,9 +12,8 @@
} from './componentCallbacks.svelte'
import type { AppEditorContext, AppViewerContext } from '../../types'
const { history, movingcomponents, jobsDrawerOpen } = getContext<AppEditorContext>(
'AppEditorContext'
) as AppEditorContext
const { history, movingcomponents, jobsDrawerOpen, runnableJobEditorPanel } =
getContext<AppEditorContext>('AppEditorContext') as AppEditorContext
const { app, selectedComponent, focusedGrid, componentControl } = getContext<AppViewerContext>(
'AppViewerContext'
) as AppViewerContext
@@ -33,7 +32,8 @@
let classes = event.target?.['className']
if (
(typeof classes === 'string' && classes.includes('inputarea')) ||
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!)
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!) ||
$runnableJobEditorPanel.focused
) {
return
}
@@ -308,7 +308,7 @@ export async function handlePaste(
} else {
copiedGridItems = undefined
}
} catch {}
} catch { }
}
if (tempGridItems != undefined) {
@@ -237,6 +237,8 @@
$effect(() => {
items != undefined && handleItemsChange()
})
const rnd = generateRandomString()
</script>
<div class="flex gap-2 flex-col mt-2 w-full">
@@ -286,7 +288,8 @@
use:dragHandleZone={{
items,
flipDurationMs,
dropTargetStyle: {}
dropTargetStyle: {},
type: rnd
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
@@ -126,6 +126,8 @@
})
component.numberOfSubgrids = items.length + 1
}
const rnd = generateRandomString()
</script>
<PanelSection title={'Conditions'}>
@@ -141,7 +143,8 @@
use:dragHandleZone={{
items: items,
flipDurationMs: 200,
dropTargetStyle: {}
dropTargetStyle: {},
type: rnd
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
@@ -121,6 +121,8 @@
let resolvedPaths: string[] = $state([])
let resolvedLabels: string[] = $state([])
const rnd = generateRandomString()
</script>
<PanelSection
@@ -134,7 +136,8 @@
use:dragHandleZone={{
items,
flipDurationMs: 200,
dropTargetStyle: {}
dropTargetStyle: {},
type: rnd
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
@@ -158,6 +158,8 @@
})
}
}
const rnd = generateRandomString()
</script>
<PanelSection title={`${word}s ${tabs && tabs.length > 0 ? `(${tabs.length})` : ''}`}>
@@ -169,7 +171,8 @@
use:dragHandleZone={{
items,
flipDurationMs: 200,
dropTargetStyle: {}
dropTargetStyle: {},
type: rnd
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
@@ -103,6 +103,8 @@
items = newItems
}
const rnd = generateRandomString()
</script>
{#if components}
@@ -132,7 +134,8 @@
use:dragHandleZone={{
items,
flipDurationMs: 200,
dropTargetStyle: {}
dropTargetStyle: {},
type: rnd
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
@@ -65,30 +65,34 @@
let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state()
</script>
<ul class="flex flex-col divide-y mt-1">
{#each assets.value ?? [] as asset}
<li class="flex justify-between py-3 leading-4 text-sm pl-4">
<div class="flex flex-col flex-1 truncate">
{asset.path}
<span class="text-2xs text-tertiary">
{formatAssetKind({
...asset,
...(asset.kind === 'resource'
? { metadata: { resource_type: resourceDataCache[asset.path] } }
: {})
})}
</span>
</div>
<AssetButtons
{asset}
{resourceDataCache}
{dbManagerDrawer}
{resourceEditorDrawer}
{s3FilePicker}
/>
</li>
{/each}
</ul>
{#if assets.value && assets.value.length > 0}
<ul class="flex flex-col divide-y mt-1">
{#each assets.value ?? [] as asset}
<li class="flex justify-between py-3 leading-4 text-sm pl-4">
<div class="flex flex-col flex-1 truncate">
{asset.path}
<span class="text-2xs text-tertiary">
{formatAssetKind({
...asset,
...(asset.kind === 'resource'
? { metadata: { resource_type: resourceDataCache[asset.path] } }
: {})
})}
</span>
</div>
<AssetButtons
{asset}
{resourceDataCache}
{dbManagerDrawer}
{resourceEditorDrawer}
{s3FilePicker}
/>
</li>
{/each}
</ul>
{:else}
<div class="text-sm text-tertiary">No assets found</div>
{/if}
<S3FilePicker bind:this={s3FilePicker} readOnlyMode />
<DbManagerDrawer bind:this={dbManagerDrawer} />
@@ -13,6 +13,7 @@
export let baseClass = 'text-center'
export let capitalize = false
export let icon: BadgeIconProps | undefined = undefined
export let verySmall = false
let hidden = false
const colors: Record<BadgeColor, string> = {
@@ -46,11 +47,18 @@
$: badgeClass = classNames(
baseClass,
small ? 'text-xs' : large ? 'text-sm font-medium' : 'text-xs font-semibold',
small
? 'text-xs'
: verySmall
? 'text-2xs'
: large
? 'text-sm font-medium'
: 'text-xs font-semibold',
colors[color],
href &&
(color.startsWith(ColorModifier) ? hovers[color.replace(ColorModifier, '')] : hovers[color]),
rounded ? 'rounded-full px-2 py-1' : 'rounded px-2.5 py-0.5',
verySmall ? 'px-0.5 py-0.5' : '',
'flex flex-row gap-1 items-center',
$$props.class
)
@@ -4,6 +4,7 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { Placement } from '@floating-ui/core'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import { twMerge } from 'tailwind-merge'
export let date: string | undefined
export let label: string
@@ -25,7 +26,10 @@
<svelte:fragment slot="trigger">
<button
title="Open calendar picker"
class="absolute bottom-1 right-2 top-1 py-1 min-w-min !px-2 items-center text-primary bg-surface border rounded center-center hover:bg-surface-hover transition-all cursor-pointer"
class={twMerge(
'absolute bottom-1 right-2 top-1 py-1 min-w-min !px-2 items-center text-primary bg-surface border rounded center-center hover:bg-surface-hover transition-all cursor-pointer',
$$props.class
)}
aria-label="Open calendar picker"
on:click={() => {
input?.focus()
@@ -1,25 +1,33 @@
<script lang="ts" context="module">
export let openedDrawers: string[] = []
<script lang="ts" module>
export let openedDrawers: { val: string[] } = $state({ val: [] })
</script>
<script lang="ts">
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
import { zIndexes } from '$lib/zIndexes'
import { untrack } from 'svelte'
import { createEventDispatcher } from 'svelte'
export let open = false
export let id = (Math.random() + 1).toString(36).substring(10)
export let preventEscape = false
if (open) {
openedDrawers.push(id)
interface Props {
open?: boolean
id?: any
preventEscape?: boolean
initialOffset?: number
children?: import('svelte').Snippet<[any]>
onOpen?: () => void
onClose?: () => void
}
export let initialOffset = 0
let {
open = $bindable(false),
id = (Math.random() + 1).toString(36).substring(10),
preventEscape = false,
initialOffset = 0,
children,
onOpen,
onClose
}: Props = $props()
let offset = initialOffset
let offset = $state(initialOffset)
let zIndex = $derived(zIndexes.disposables + offset)
export function toggleDrawer() {
if (!open) {
@@ -30,32 +38,31 @@
}
export function openDrawer() {
open = true
offset = openedDrawers.length - 1
if (openedDrawers.includes(id)) {
return
if (!open) {
open = true
if (openedDrawers.val.includes(id)) {
return
}
offset = openedDrawers.val.length - 1
openedDrawers.val.push(id)
}
openedDrawers.push(id)
}
export function closeDrawer() {
open = false
offset = initialOffset
// remove the last opened drawer
openedDrawers = openedDrawers.filter((drawer) => drawer !== id)
if (open) {
open = false
offset = initialOffset
// remove the last opened drawer
openedDrawers.val = openedDrawers.val.filter((drawer) => drawer !== id)
}
}
export function isOpen() {
return open
}
const dispatch = createEventDispatcher()
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
function handleClickAway(e) {
const last = openedDrawers[openedDrawers.length - 1]
const last = openedDrawers.val[openedDrawers.val.length - 1]
if (last === id) {
e.stopPropagation()
@@ -68,10 +75,11 @@
switch (event.key) {
case 'Escape':
if (
(id == openedDrawers[openedDrawers.length - 1] || openedDrawers.length == 0) &&
(id == openedDrawers.val[openedDrawers.val.length - 1] ||
openedDrawers.val.length == 0) &&
!preventEscape
) {
openedDrawers.pop()
openedDrawers.val.pop()
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
@@ -82,11 +90,34 @@
}
}
$: zIndex = zIndexes.disposables + offset
if (open) {
openedDrawers.val.push(id)
}
$: dispatchIfMounted(open ? 'open' : 'close')
let wasEverOpen = false
let lastOpen = open
$effect.pre(() => {
if (open === untrack(() => lastOpen)) {
return
}
lastOpen = open
if (open) {
// console.log('open', id, wasEverOpen)
wasEverOpen = true
onOpen?.()
} else if (untrack(() => wasEverOpen)) {
// console.log('close', id)
onClose?.()
}
})
</script>
<svelte:window on:keydown={onKeyDown} />
<svelte:window onkeydown={onKeyDown} />
<slot {handleClickAway} {zIndex} {closeDrawer} {open} />
{@render children?.({
handleClickAway,
zIndex,
closeDrawer,
open,
isTop: openedDrawers.val[openedDrawers.val.length - 1] == id
})}
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, createEventDispatcher } from 'svelte'
import { onMount, createEventDispatcher, untrack } from 'svelte'
import { BROWSER } from 'esm-env'
import Disposable from './Disposable.svelte'
import ConditionalPortal from './ConditionalPortal.svelte'
@@ -32,7 +32,7 @@
disableChatOffset = false,
class: clazz = '',
positionClass = undefined,
children
children: children_render
}: Props = $props()
if (open === undefined) {
@@ -52,11 +52,12 @@
}
export function closeDrawer() {
if (open) {
setTimeout(() => {
dispatch('afterClose')
}, durationMs)
}
disposable?.closeDrawer()
setTimeout(() => {
dispatch('afterClose')
}, durationMs)
}
export function isOpen() {
@@ -83,7 +84,10 @@
})
$effect(() => {
open ? openDrawer() : closeDrawer()
open
untrack(() => {
open ? openDrawer() : closeDrawer()
})
})
let timeout = $state(true)
@@ -94,7 +98,6 @@
mounted = true
})
const children_render = $derived(children)
const aiChatOpen = $derived(chatState.size > 0)
</script>
@@ -103,11 +106,11 @@
initialOffset={offset}
bind:open
bind:this={disposable}
on:open
on:close
onOpen={() => dispatch('open')}
onClose={() => dispatch('close')}
{preventEscape}
>
{#snippet children({ handleClickAway, zIndex })}
{#snippet children({ handleClickAway, zIndex, isTop })}
<aside
class="drawer windmill-app windmill-drawer {clazz ?? ''} {positionClass ?? ''} {aiChatOpen
? 'respect-global-chat'
@@ -122,7 +125,7 @@
<div class="overlay {positionClass ?? ''}" onclick={handleClickAway}></div>
<div class="panel {placement} {positionClass}" class:size>
{#if open || !timeout || alwaysOpen}
{@render children_render?.({ open })}
{@render children_render?.({ open, isTop })}
{/if}
</div>
</aside>
@@ -2,14 +2,24 @@
import type { TabsContext } from '$lib/components/apps/editor/settingsPanel/inputEditor/tabs.svelte'
import { getContext } from 'svelte'
export let value: string
export let alwaysMounted: boolean = false
export let style = ''
interface Props {
value: string
alwaysMounted?: boolean
style?: string
values?: string[] | undefined
class?: string
children?: import('svelte').Snippet
}
export let values: string[] | undefined = undefined
let {
value,
alwaysMounted = false,
style = '',
values = undefined,
class: clazz = '',
children
}: Props = $props()
let clazz: string = ''
export { clazz as class }
const { selected } = getContext<TabsContext>('Tabs')
</script>
@@ -18,6 +28,6 @@
class={`${clazz} ${value === $selected || values?.includes($selected) ? 'visible' : 'hidden'}`}
{style}
>
<slot />
{@render children?.()}
</div>
{/if}
@@ -7,6 +7,7 @@
type TogglableItem = {
label: string
value: string
tooltip?: string
}
interface Props {
@@ -29,11 +30,11 @@
togglableItems
}: Props = $props()
function select(v: string) {
selected = v
}
let items = togglableItems.map((i) => ({ displayName: i.label, action: () => select(i.value) }))
let items = togglableItems.map((i) => ({
displayName: i.label,
action: () => (selected = i.value),
tooltip: i.tooltip
}))
function isAnOptionSelected(selected: string | undefined) {
return togglableItems.some((i) => i.value === selected)
@@ -48,6 +49,7 @@
>
<div {id} class="flex">
{#if isAnOptionSelected(selected)}
{@const tooltip = togglableItems.find((i) => i.value === selected)?.tooltip}
<ToggleButton
{disabled}
value={selected ?? ''}
@@ -56,6 +58,8 @@
{light}
{id}
label={togglableItems.find((i) => i.value === selected)?.label}
{tooltip}
showTooltipIcon={!!tooltip}
/>
{/if}
<div class="flex items-center">
@@ -11,16 +11,13 @@ import HistoryManager from './HistoryManager.svelte'
import {
extractCodeFromMarkdown,
getLatestAssistantMessage,
processToolCall,
type DisplayMessage,
type Tool,
type ToolCallbacks,
type ToolDisplayMessage
} from './shared'
import type {
ChatCompletionChunk,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
} from 'openai/resources/chat/completions.mjs'
@@ -34,7 +31,7 @@ import { loadApiTools } from './api/apiTools'
import { prepareScriptUserMessage } from './script/core'
import { prepareNavigatorUserMessage } from './navigator/core'
import { sendUserToast } from '$lib/toast'
import { getCompletion, getModelContextWindow } from '../lib'
import { getCompletion, getModelContextWindow, parseOpenAICompletion } from '../lib'
import { dfs } from '$lib/components/flows/previousResults'
import { getStringError } from './utils'
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
@@ -47,6 +44,7 @@ import type { ContextElement } from './context'
import type { Selection } from 'monaco-editor'
import type AIChatInput from './AIChatInput.svelte'
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
@@ -380,10 +378,8 @@ class AIChatManager {
}
systemMessage?: ChatCompletionSystemMessageParam
}) => {
let addedMessages: ChatCompletionMessageParam[] = []
try {
let completion: any = null
let addedMessages: ChatCompletionMessageParam[] = []
while (true) {
const systemMessage = systemMessageOverride ?? this.systemMessage
const helpers = this.helpers
@@ -413,99 +409,28 @@ class AIChatManager {
}
this.pendingPrompt = ''
}
completion = await getCompletion(
const model = getCurrentModel()
const completionFn = model.provider === 'anthropic' ? getAnthropicCompletion : getCompletion
const parseFn =
model.provider === 'anthropic' ? parseAnthropicCompletion : parseOpenAICompletion
const completion = await completionFn(
[systemMessage, ...messages, ...(pendingUserMessage ? [pendingUserMessage] : [])],
abortController,
tools.map((t) => t.def)
)
if (completion) {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
let answer = ''
for await (const chunk of completion) {
if (!('choices' in chunk && chunk.choices.length > 0 && 'delta' in chunk.choices[0])) {
continue
}
const c = chunk as ChatCompletionChunk
const delta = c.choices[0].delta.content
if (delta) {
answer += delta
callbacks.onNewToken(delta)
}
const toolCalls = c.choices[0].delta.tool_calls || []
if (toolCalls.length > 0 && answer) {
// if tool calls are present but we have some textual content already, we need to display it to the user first
callbacks.onMessageEnd()
answer = ''
}
for (const toolCall of toolCalls) {
const { index } = toolCall
let finalToolCall = finalToolCalls[index]
if (!finalToolCall) {
finalToolCalls[index] = toolCall
} else {
if (toolCall.function?.arguments) {
if (!finalToolCall.function) {
finalToolCall.function = toolCall.function
} else {
finalToolCall.function.arguments =
(finalToolCall.function.arguments ?? '') + toolCall.function.arguments
}
}
}
finalToolCall = finalToolCalls[index]
if (finalToolCall?.function) {
const {
function: { name: funcName },
id: toolCallId
} = finalToolCall
if (funcName && toolCallId) {
const tool = tools.find((t) => t.def.function.name === funcName)
if (tool && tool.preAction) {
tool.preAction({ toolCallbacks: callbacks, toolId: toolCallId })
}
}
}
}
}
if (answer) {
const toAdd = { role: 'assistant' as const, content: answer }
addedMessages.push(toAdd)
messages.push(toAdd)
}
callbacks.onMessageEnd()
const toolCalls = Object.values(finalToolCalls).filter(
(toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined
) as ChatCompletionMessageToolCall[]
if (toolCalls.length > 0) {
const toAdd = {
role: 'assistant' as const,
tool_calls: toolCalls.map((t) => ({
...t,
function: {
...t.function,
arguments: t.function.arguments || '{}'
}
}))
}
messages.push(toAdd)
addedMessages.push(toAdd)
for (const toolCall of toolCalls) {
const messageToAdd = await processToolCall({
tools,
toolCall,
helpers,
toolCallbacks: callbacks
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
}
} else {
const continueCompletion = await parseFn(
completion as any,
callbacks,
messages,
addedMessages,
tools,
helpers
)
if (!continueCompletion) {
break
}
}
@@ -0,0 +1,264 @@
import { OpenAI } from 'openai'
import type {
ChatCompletionMessageParam,
ChatCompletionMessageFunctionToolCall
} from 'openai/resources/index.mjs'
import type {
MessageParam,
TextBlockParam,
ToolUnion,
ToolUseBlockParam,
Tool as AnthropicTool,
Message
} from '@anthropic-ai/sdk/resources'
import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream'
import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib'
import { processToolCall, type Tool, type ToolCallbacks } from './shared'
export async function getAnthropicCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[]
): Promise<MessageStream> {
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true })
const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages)
const anthropicTools = convertOpenAIToolsToAnthropic(tools)
const anthropicClient = workspaceAIClients.getAnthropicClient()
const anthropicParams = {
model: config.model,
max_tokens: config.max_tokens as number,
messages: anthropicMessages,
...(system && { system }),
...(anthropicTools && { tools: anthropicTools }),
...(typeof config.temperature === 'number' && { temperature: config.temperature })
}
const stream = anthropicClient.messages.stream(anthropicParams, {
signal: abortController.signal,
headers: {
'X-Provider': provider,
'anthropic-version': '2023-06-01',
'X-Anthropic-SDK': 'true'
}
})
return stream
}
export async function parseAnthropicCompletion(
completion: MessageStream,
callbacks: ToolCallbacks & {
onNewToken: (token: string) => void
onMessageEnd: () => void
},
messages: ChatCompletionMessageParam[],
addedMessages: ChatCompletionMessageParam[],
tools: Tool<any>[],
helpers: any
): Promise<boolean> {
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
let error = null
// Handle text streaming
completion.on('text', (textDelta: string, _textSnapshot: string) => {
callbacks.onNewToken(textDelta)
})
completion.on('message', (message: Message) => {
for (const block of message.content) {
if (block.type === 'text') {
const text = block.text
const assistantMessage = { role: 'assistant' as const, content: text }
messages.push(assistantMessage)
addedMessages.push(assistantMessage)
callbacks.onMessageEnd()
} else if (block.type === 'tool_use') {
// Convert Anthropic tool calls to OpenAI format for compatibility
toolCallsToProcess.push({
id: block.id,
type: 'function' as const,
function: {
name: block.name,
arguments: JSON.stringify(block.input)
}
})
// Preprocess tool if it has a preAction
const tool = tools.find((t) => t.def.function.name === block.name)
if (tool && tool.preAction) {
tool.preAction({ toolCallbacks: callbacks, toolId: block.id })
}
}
}
})
// Handle errors
completion.on('error', (e: any) => {
console.error('Anthropic stream error:', e)
error = e
})
// Wait for completion
await completion.done()
callbacks.onMessageEnd()
if (error) {
throw error
}
// Process tool calls if any
if (toolCallsToProcess.length > 0) {
const assistantWithTools = {
role: 'assistant' as const,
tool_calls: toolCallsToProcess
}
messages.push(assistantWithTools)
addedMessages.push(assistantWithTools)
// Process each tool call
for (const toolCall of toolCallsToProcess) {
const messageToAdd = await processToolCall({
tools,
toolCall,
helpers,
toolCallbacks: callbacks
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
}
return true // Continue the conversation loop
}
return false // End the conversation
}
export function convertOpenAIToAnthropicMessages(messages: ChatCompletionMessageParam[]): {
system: TextBlockParam[] | undefined
messages: MessageParam[]
} {
let system: TextBlockParam[] | undefined
const anthropicMessages: MessageParam[] = []
for (const message of messages) {
if (message.role === 'system') {
const systemText =
typeof message.content === 'string' ? message.content : JSON.stringify(message.content)
// Convert system to array format with cache_control for caching
system = [
{
type: 'text',
text: systemText,
cache_control: { type: 'ephemeral' }
}
]
continue
}
if (message.role === 'user') {
anthropicMessages.push({
role: 'user',
content:
typeof message.content === 'string' ? message.content : JSON.stringify(message.content)
})
} else if (message.role === 'assistant') {
const content: (TextBlockParam | ToolUseBlockParam)[] = []
if (message.content) {
content.push({
type: 'text',
text:
typeof message.content === 'string' ? message.content : JSON.stringify(message.content)
})
}
if (message.tool_calls) {
for (const toolCall of message.tool_calls) {
if (toolCall.type !== 'function') continue
let input = {}
try {
input = JSON.parse(toolCall.function.arguments || '{}')
} catch (e) {
console.error('Failed to parse tool call arguments', e)
}
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.function.name,
input
})
}
}
if (content.length > 0) {
anthropicMessages.push({
role: 'assistant',
content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
})
}
} else if (message.role === 'tool') {
// Tool results must be in user messages in Anthropic format
anthropicMessages.push({
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: message.tool_call_id,
content:
typeof message.content === 'string'
? message.content
: JSON.stringify(message.content)
}
]
})
}
}
// Add cache_control to the last message content blocks
if (anthropicMessages.length > 0) {
const lastMessage = anthropicMessages[anthropicMessages.length - 1]
if (Array.isArray(lastMessage.content)) {
// Add cache_control to the last content block
if (lastMessage.content.length > 0) {
const lastBlock = lastMessage.content[lastMessage.content.length - 1]
if (lastBlock.type === 'text') {
lastBlock.cache_control = { type: 'ephemeral' }
}
}
} else if (typeof lastMessage.content === 'string') {
// Convert string content to array format with cache_control
lastMessage.content = [
{
type: 'text',
text: lastMessage.content,
cache_control: { type: 'ephemeral' }
}
]
}
}
return { system, messages: anthropicMessages }
}
export function convertOpenAIToolsToAnthropic(
tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[]
): ToolUnion[] | undefined {
if (!tools || tools.length === 0) return undefined
const anthropicTools: ToolUnion[] = tools.map((tool) => ({
name: tool.function.name,
description: tool.function.description,
input_schema: (tool.function.parameters || {
type: 'object',
properties: {}
}) as AnthropicTool.InputSchema
}))
// Add cache_control to the last tool to cache all tool definitions
if (anthropicTools.length > 0) {
anthropicTools[anthropicTools.length - 1].cache_control = { type: 'ephemeral' }
}
return anthropicTools
}
@@ -1,11 +1,11 @@
import type { ChatCompletionTool } from 'openai/resources/index.mjs'
import type { ChatCompletionFunctionTool } from 'openai/resources/index.mjs'
import type { Tool } from '../shared'
import { get } from 'svelte/store'
import { workspaceStore } from '$lib/stores'
import type { EndpointTool } from '$lib/gen/types.gen'
import { McpService } from '$lib/gen/services.gen'
function buildApiCallTool(endpointTool: EndpointTool): ChatCompletionTool {
function buildApiCallTool(endpointTool: EndpointTool): ChatCompletionFunctionTool {
// Build the parameters schema for OpenAI function calling
const parameters: Record<string, any> = {
type: 'object',
@@ -18,10 +18,13 @@ function buildApiCallTool(endpointTool: EndpointTool): ChatCompletionTool {
for (const [key, schema] of Object.entries(endpointTool.path_params_schema.properties)) {
// Skip workspace parameter as it's auto-filled
if (key === 'workspace') continue
parameters.properties[key] = schema
if (Array.isArray(endpointTool.path_params_schema.required) && endpointTool.path_params_schema.required.includes(key)) {
if (
Array.isArray(endpointTool.path_params_schema.required) &&
endpointTool.path_params_schema.required.includes(key)
) {
parameters.required.push(key)
}
}
@@ -31,8 +34,11 @@ function buildApiCallTool(endpointTool: EndpointTool): ChatCompletionTool {
if (endpointTool.query_params_schema?.properties) {
for (const [key, schema] of Object.entries(endpointTool.query_params_schema.properties)) {
parameters.properties[key] = schema
if (Array.isArray(endpointTool.query_params_schema.required) && endpointTool.query_params_schema.required.includes(key)) {
if (
Array.isArray(endpointTool.query_params_schema.required) &&
endpointTool.query_params_schema.required.includes(key)
) {
parameters.required.push(key)
}
}
@@ -47,8 +53,11 @@ function buildApiCallTool(endpointTool: EndpointTool): ChatCompletionTool {
properties: endpointTool.body_schema.properties,
required: endpointTool.body_schema.required || []
}
if (Array.isArray(endpointTool.body_schema.required) && endpointTool.body_schema.required.length > 0) {
if (
Array.isArray(endpointTool.body_schema.required) &&
endpointTool.body_schema.required.length > 0
) {
parameters.required.push('body')
}
}
@@ -63,16 +72,17 @@ function buildApiCallTool(endpointTool: EndpointTool): ChatCompletionTool {
}
}
function buildToolsFromEndpoints(
endpointTools: EndpointTool[]
): { tools: ChatCompletionTool[]; endpointMap: Record<string, { method: string; path: string }> } {
const tools: ChatCompletionTool[] = []
function buildToolsFromEndpoints(endpointTools: EndpointTool[]): {
tools: ChatCompletionFunctionTool[]
endpointMap: Record<string, { method: string; path: string }>
} {
const tools: ChatCompletionFunctionTool[] = []
const endpointMap: Record<string, { method: string; path: string }> = {}
for (const endpointTool of endpointTools) {
const tool = buildApiCallTool(endpointTool)
tools.push(tool)
// Store the endpoint info in the map
endpointMap[endpointTool.name] = {
method: endpointTool.method,
@@ -84,17 +94,17 @@ function buildToolsFromEndpoints(
}
export function createApiTools(
chatTools: ChatCompletionTool[],
chatTools: ChatCompletionFunctionTool[],
endpointMap: Record<string, { method: string; path: string }> = {}
): Tool<{}>[] {
return chatTools.map((chatTool) => {
const toolName = chatTool.function.name
const endpoint = endpointMap[toolName]
const method = endpoint?.method?.toUpperCase() || 'GET'
// Determine if tool needs confirmation based on method
const needsConfirmation = ['DELETE', 'POST', 'PUT', 'PATCH'].includes(method)
return {
def: chatTool,
requiresConfirmation: needsConfirmation,
@@ -103,7 +113,7 @@ export function createApiTools(
fn: async ({ args, toolId, toolCallbacks }) => {
const toolName = chatTool.function.name
const endpoint = endpointMap[toolName]
if (!endpoint) {
throw new Error(`No endpoint mapping found for tool ${toolName}`)
}
@@ -111,7 +121,7 @@ export function createApiTools(
try {
const workspace = get(workspaceStore) as string
let path = endpoint.path.replace('{workspace}', workspace)
// Build URL with path parameters
let url = `/api${path}`
const queryParams: Record<string, string> = {}
@@ -143,7 +153,7 @@ export function createApiTools(
}
toolCallbacks.setToolStatus(toolId, {
content: `Calling ${toolName}...`,
content: `Calling ${toolName}...`
})
const fetchOptions: RequestInit = {
@@ -173,7 +183,7 @@ export function createApiTools(
})
toolCallbacks.setToolStatus(toolId, {
content: `Call to ${toolName} completed`,
result: jsonResult,
result: jsonResult
})
return jsonResult
} else {
@@ -186,7 +196,7 @@ export function createApiTools(
toolCallbacks.setToolStatus(toolId, {
content: `Call to ${toolName} failed`,
result: jsonResult,
error: `HTTP ${response.status}: ${text}`,
error: `HTTP ${response.status}: ${text}`
})
return jsonResult
}
@@ -194,7 +204,7 @@ export function createApiTools(
const errorMessage = `Error calling API: ${error instanceof Error ? error.message : String(error)}`
toolCallbacks.setToolStatus(toolId, {
content: `Call to ${toolName} failed`,
error: errorMessage,
error: errorMessage
})
console.error(`Error calling API:`, error)
return errorMessage
@@ -210,10 +220,10 @@ export async function loadApiTools(): Promise<Tool<{}>[]> {
const endpointTools = await McpService.listMcpTools({
workspace: get(workspaceStore) as string
})
// Build tools from the endpoint definitions
const { tools: apiTools, endpointMap } = buildToolsFromEndpoints(endpointTools)
// Create executable tools
const executableApiTools = createApiTools(apiTools, endpointMap)
return executableApiTools
@@ -221,4 +231,4 @@ export async function loadApiTools(): Promise<Tool<{}>[]> {
console.error('Failed to load API tools:', error)
return []
}
}
}
@@ -638,7 +638,10 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
}
},
{
def: setForLoopOptionsToolDef,
def: {
...setForLoopOptionsToolDef,
function: { ...setForLoopOptionsToolDef.function, strict: false }
},
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
const parsedArgs = setForLoopOptionsSchema.parse(args)
await helpers.setForLoopOptions(parsedArgs.id, {
@@ -656,7 +659,10 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
}
},
{
def: setModuleControlOptionsToolDef,
def: {
...setModuleControlOptionsToolDef,
function: { ...setModuleControlOptionsToolDef.function, strict: false }
},
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
const parsedArgs = setModuleControlOptionsSchema.parse(args)
await helpers.setModuleControlOptions(parsedArgs.id, {
@@ -5,7 +5,7 @@ import { get } from 'svelte/store'
import { compile, phpCompile, pythonCompile } from '../../utils'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionTool,
ChatCompletionFunctionTool,
ChatCompletionUserMessageParam
} from 'openai/resources/index.mjs'
import { type DBSchema, dbSchemas, getCurrentModel } from '$lib/stores'
@@ -498,7 +498,7 @@ export function prepareScriptUserMessage(
}
}
const RESOURCE_TYPE_FUNCTION_DEF: ChatCompletionTool = {
const RESOURCE_TYPE_FUNCTION_DEF: ChatCompletionFunctionTool = {
type: 'function',
function: {
name: 'search_resource_types',
@@ -519,7 +519,7 @@ const RESOURCE_TYPE_FUNCTION_DEF: ChatCompletionTool = {
}
}
const DB_SCHEMA_FUNCTION_DEF: ChatCompletionTool = {
const DB_SCHEMA_FUNCTION_DEF: ChatCompletionFunctionTool = {
type: 'function',
function: {
name: 'get_db_schema',
@@ -693,7 +693,7 @@ export async function searchExternalIntegrationResources(args: { query: string }
}
}
const SEARCH_NPM_PACKAGES_TOOL: ChatCompletionTool = {
const SEARCH_NPM_PACKAGES_TOOL: ChatCompletionFunctionTool = {
type: 'function',
function: {
name: 'search_npm_packages',
@@ -778,7 +778,7 @@ export async function fetchNpmPackageTypes(
}
}
const TEST_RUN_SCRIPT_TOOL: ChatCompletionTool = {
const TEST_RUN_SCRIPT_TOOL: ChatCompletionFunctionTool = {
type: 'function',
function: {
name: 'test_run_script',
@@ -1,7 +1,7 @@
import type {
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool
ChatCompletionFunctionTool,
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageParam
} from 'openai/resources/chat/completions.mjs'
import { get } from 'svelte/store'
import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } from './context'
@@ -257,7 +257,7 @@ export async function processToolCall<T>({
toolCallbacks
}: {
tools: Tool<T>[]
toolCall: ChatCompletionMessageToolCall
toolCall: ChatCompletionMessageFunctionToolCall
helpers: T
toolCallbacks: ToolCallbacks
}): Promise<ChatCompletionMessageParam> {
@@ -345,7 +345,7 @@ export async function processToolCall<T>({
}
export interface Tool<T> {
def: ChatCompletionTool
def: ChatCompletionFunctionTool
fn: (p: {
args: any
workspace: string
@@ -369,7 +369,7 @@ export function createToolDef(
zodSchema: z.ZodSchema,
name: string,
description: string
): ChatCompletionTool {
): ChatCompletionFunctionTool {
const schema = zodToJsonSchema(zodSchema, {
name,
target: 'openAi'
@@ -438,7 +438,7 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
})
export async function buildSchemaForTool(
toolDef: ChatCompletionTool,
toolDef: ChatCompletionFunctionTool,
schemaBuilder: () => Promise<FunctionParameters>
): Promise<boolean> {
try {
@@ -586,7 +586,10 @@ function getErrorMessage(result: unknown): string {
}
// Build test run args based on the tool definition, if it contains a fallback schema
export async function buildTestRunArgs(args: any, toolDef: ChatCompletionTool): Promise<any> {
export async function buildTestRunArgs(
args: any,
toolDef: ChatCompletionFunctionTool
): Promise<any> {
let parsedArgs = args
// if the schema is the fallback schema, parse the args as a JSON string
if (
+131 -5
View File
@@ -7,18 +7,23 @@ import {
type SQLSchema
} from '$lib/stores'
import { buildClientSchema, printSchema } from 'graphql'
import { OpenAI } from 'openai'
import OpenAI from 'openai'
import type {
ChatCompletionChunk,
ChatCompletionCreateParams,
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming,
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageParam
} from 'openai/resources/index.mjs'
import Anthropic from '@anthropic-ai/sdk'
import { get, type Writable } from 'svelte/store'
import { OpenAPI, ResourceService, type Script } from '../../gen'
import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts'
import { formatResourceTypes } from './utils'
import { z } from 'zod'
import { processToolCall, type Tool, type ToolCallbacks } from './chat/shared'
import type { Stream } from 'openai/core/streaming.mjs'
export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts))
@@ -219,9 +224,11 @@ export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCr
class WorkspacedAIClients {
private openaiClient: OpenAI | undefined
private anthropicClient: Anthropic | undefined
init(workspace: string) {
this.initOpenai(workspace)
this.initAnthropic(workspace)
}
private getBaseURL(workspace: string) {
@@ -240,12 +247,28 @@ class WorkspacedAIClients {
})
}
private initAnthropic(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.anthropicClient = new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
}
getOpenaiClient() {
if (!this.openaiClient) {
throw new Error('OpenAI not initialized')
}
return this.openaiClient
}
getAnthropicClient() {
if (!this.anthropicClient) {
throw new Error('Anthropic not initialized')
}
return this.anthropicClient
}
}
export const workspaceAIClients = new WorkspacedAIClients()
@@ -460,7 +483,7 @@ const PROMPTS_CONFIGS = {
gen: GEN_CONFIG
}
function getProviderAndCompletionConfig<K extends boolean>({
export function getProviderAndCompletionConfig<K extends boolean>({
messages,
stream,
tools,
@@ -622,10 +645,11 @@ export async function getCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
) {
): Promise<Stream<ChatCompletionChunk>> {
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools })
const openaiClient = workspaceAIClients.getOpenaiClient()
const completion = await openaiClient.chat.completions.create(config, {
const completion = openaiClient.chat.completions.create(config, {
signal: abortController.signal,
headers: {
'X-Provider': provider
@@ -634,6 +658,108 @@ export async function getCompletion(
return completion
}
export async function parseOpenAICompletion(
completion: Stream<ChatCompletionChunk>,
callbacks: ToolCallbacks & {
onNewToken: (token: string) => void
onMessageEnd: () => void
},
messages: ChatCompletionMessageParam[],
addedMessages: ChatCompletionMessageParam[],
tools: Tool<any>[],
helpers: any
): Promise<boolean> {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
let answer = ''
for await (const chunk of completion) {
if (!('choices' in chunk && chunk.choices.length > 0 && 'delta' in chunk.choices[0])) {
continue
}
const c = chunk as ChatCompletionChunk
const delta = c.choices[0].delta.content
if (delta) {
answer += delta
callbacks.onNewToken(delta)
}
const toolCalls = c.choices[0].delta.tool_calls || []
if (toolCalls.length > 0 && answer) {
// if tool calls are present but we have some textual content already, we need to display it to the user first
callbacks.onMessageEnd()
answer = ''
}
for (const toolCall of toolCalls) {
const { index } = toolCall
let finalToolCall = finalToolCalls[index]
if (!finalToolCall) {
finalToolCalls[index] = toolCall
} else {
if (toolCall.function?.arguments) {
if (!finalToolCall.function) {
finalToolCall.function = toolCall.function
} else {
finalToolCall.function.arguments =
(finalToolCall.function.arguments ?? '') + toolCall.function.arguments
}
}
}
finalToolCall = finalToolCalls[index]
if (finalToolCall?.function) {
const {
function: { name: funcName },
id: toolCallId
} = finalToolCall
if (funcName && toolCallId) {
const tool = tools.find((t) => t.def.function.name === funcName)
if (tool && tool.preAction) {
tool.preAction({ toolCallbacks: callbacks, toolId: toolCallId })
}
}
}
}
}
if (answer) {
const toAdd = { role: 'assistant' as const, content: answer }
addedMessages.push(toAdd)
messages.push(toAdd)
}
callbacks.onMessageEnd()
const toolCalls = Object.values(finalToolCalls).filter(
(toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined
) as ChatCompletionMessageFunctionToolCall[]
if (toolCalls.length > 0) {
const toAdd = {
role: 'assistant' as const,
tool_calls: toolCalls.map((t) => ({
...t,
function: {
...t.function,
arguments: t.function.arguments || '{}'
}
}))
}
messages.push(toAdd)
addedMessages.push(toAdd)
for (const toolCall of toolCalls) {
const messageToAdd = await processToolCall({
tools,
toolCall,
helpers,
toolCallbacks: callbacks
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
}
} else {
return false
}
return true
}
export function getResponseFromEvent(part: OpenAI.Chat.Completions.ChatCompletionChunk): string {
return part.choices?.[0]?.delta?.content || ''
}
@@ -757,7 +883,7 @@ export async function deltaCodeCompletion(
}
if (!match[1].endsWith('`')) {
// skip udpating if possible that part of three ticks (end of code block)s
// skip updating if possible that part of three ticks (end of code block)s
delta = getStringEndDelta(code, match[1])
generatedCodeDelta.set(delta)
code = match[1]
@@ -40,9 +40,10 @@
} from '$lib/components/schema/schemaUtils.svelte'
import SideBarTab from '$lib/components/meltComponents/SideBarTab.svelte'
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import { isObjectTooBig } from '$lib/utils'
import { isObjectTooBig, readFieldsRecursively } from '$lib/utils'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { ScriptLang } from '$lib/gen'
import { deepEqual } from 'fast-equals'
interface Props {
noEditor: boolean
@@ -333,11 +334,19 @@
function resetArgs() {
if (!previewSchema) {
// previewArguments = undefined
savedPreviewArgs = undefined
}
}
$effect(() => {
if (!previewArgs && savedPreviewArgs != undefined) {
readFieldsRecursively(flowStore.val.schema)
untrack(() => {
resetArgs()
})
}
})
let historicInputs: HistoricInputs | undefined = $state(undefined)
let captureTable: CaptureTable | undefined = $state(undefined)
let savedInputsPicker: SavedInputsPicker | undefined = $state(undefined)
@@ -363,9 +372,6 @@
bind:this={editableSchemaForm}
bind:schema={flowStore.val.schema}
isFlowInput
on:edit={(e) => {
addPropertyV2?.openDrawer(e.detail)
}}
on:delete={(e) => {
addPropertyV2?.handleDeleteArgument([e.detail])
}}
@@ -399,14 +405,13 @@
})
}}
shouldDispatchChanges={true}
on:change={() => {
onChange={() => {
if (!previewSchema) {
savedPreviewArgs = structuredClone($state.snapshot(previewArgs.val))
let args = $state.snapshot(previewArgs.val)
if (!deepEqual(args, savedPreviewArgs)) {
savedPreviewArgs = args
}
}
refreshStateStore(flowStore)
}}
on:schemaChange={() => {
resetArgs()
}}
bind:isValid
bind:dynSelectCode
@@ -475,15 +480,9 @@
<AddPropertyV2
bind:schema={flowStore.val.schema}
bind:this={addPropertyV2}
on:change={() => {
refreshStateStore(flowStore)
if (editableSchemaForm) {
editableSchemaForm.updateJson()
}
}}
on:addNew={(e) => {
onAddNew={(argName) => {
handleEditSchema('inputEditor')
editableSchemaForm?.openField(e.detail)
editableSchemaForm?.openField(argName)
refreshStateStore(flowStore)
}}
>
@@ -1,6 +1,4 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
@@ -49,7 +47,7 @@
}
}
run(() => {
$effect(() => {
if ($workspaceStore && allUserGroups.length === 0) {
untrack(() => {
loadGroups()
@@ -57,9 +55,22 @@
}
})
$effect(() => {
// If the schema is empty, remove the form
if (Object.keys(flowModule?.suspend?.resume_form?.schema?.properties ?? {}).length === 0) {
untrack(() => {
tick().then(() => {
if (!flowModule.suspend) return
flowModule.suspend.resume_form = undefined
})
})
}
})
let jsonView: boolean = $state(false)
</script>
A{JSON.stringify(flowModule.suspend?.resume_form)}B
<Section label="Suspend/Approval/Prompt" class="w-full">
{#snippet action()}
<SuspendDrawer text="Approval/Prompt helpers" />
@@ -234,21 +245,7 @@
<div class="grid grid-cols-4 mt-4 gap-8">
<div class="col-span-2">
{#if flowModule?.suspend?.resume_form}
<EditableSchemaDrawer
bind:schema={flowModule.suspend.resume_form.schema}
on:change={(e) => {
const schema = e.detail
// If the schema is empty, remove the form
if (Object.keys(schema?.properties ?? {}).length === 0) {
tick().then(() => {
if (!flowModule.suspend) return
flowModule.suspend.resume_form = undefined
})
}
}}
{jsonView}
/>
<EditableSchemaDrawer bind:schema={flowModule.suspend.resume_form.schema} {jsonView} />
{:else if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
@@ -23,6 +23,7 @@
export async function openDrawer(hash: string, cb: () => void): Promise<void> {
script = undefined
closeAnyway = false
scriptEditorDrawer?.openDrawer?.()
script = await ScriptService.getScriptByHash({
workspace: $workspaceStore!,
@@ -114,7 +115,6 @@
async function checkForUnsavedChanges() {
if (closeAnyway) {
scriptEditorDrawer?.closeDrawer()
closeAnyway = false
return
}
if (savedScript && script) {
@@ -122,12 +122,15 @@
const current = cleanValueProperties(script)
if (orderedJsonStringify(saved) !== orderedJsonStringify(current)) {
unsavedModalOpen = true
scriptEditorDrawer?.openDrawer()
} else {
scriptEditorDrawer?.closeDrawer()
}
}
}
let args = $state({})
let displayEditor = $state(true)
</script>
<ConfirmationModal
@@ -138,8 +141,9 @@
unsavedModalOpen = false
}}
on:confirmed={() => {
unsavedModalOpen = false
console.log('confirmed')
closeAnyway = true
unsavedModalOpen = false
scriptEditorDrawer?.closeDrawer()
}}
>
@@ -156,7 +160,7 @@
}
unsavedModalOpen = false
closeAnyway = true
scriptEditorDrawer?.closeDrawer()
displayEditor = false
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
title: 'Saved <> Current',
@@ -182,7 +186,6 @@
bind:this={scriptEditorDrawer}
size="1200px"
on:close={() => {
scriptEditorDrawer?.openDrawer()
checkForUnsavedChanges()
}}
>
@@ -195,7 +198,7 @@
scriptEditorDrawer?.closeDrawer()
}}
>
{#if script}
{#if script && displayEditor}
{#key script.hash}
<ScriptEditor
showCaptures={false}
@@ -236,7 +239,7 @@
return
}
closeAnyway = true
scriptEditorDrawer?.closeDrawer()
displayEditor = false
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
@@ -276,10 +279,6 @@
<DiffDrawer
bind:this={diffDrawer}
on:close={() => {
if (!closeAnyway) {
scriptEditorDrawer?.openDrawer()
} else {
closeAnyway = false
}
displayEditor = true
}}
/>
@@ -487,7 +487,7 @@
additionalAssetsMap: flowGraphAssetsCtx?.val.additionalAssetsMap
},
untrack(() => failureModule),
untrack(() => preprocessorModule),
preprocessorModule,
eventHandler,
success,
$useDataflow,
@@ -1,13 +1,23 @@
<script lang="ts">
interface Props {
height?: string;
width?: string;
height?: string
width?: string
size?: number
}
let { height = '24px', width = '24px' }: Props = $props();
let { height, width, size = 24 }: Props = $props()
let derivedWidth = $derived(width || `${size}px`)
let derivedHeight = $derived(height || `${size}px`)
</script>
<svg {width} {height} viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width={derivedWidth}
height={derivedHeight}
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="#F05133"
d="M92.71 44.408 52.591 4.291c-2.31-2.311-6.057-2.311-8.369 0l-8.33 8.332L46.459 23.19c2.456-.83 5.272-.273 7.229 1.685 1.969 1.97 2.521 4.81 1.67 7.275l10.186 10.185c2.465-.85 5.307-.3 7.275 1.671 2.75 2.75 2.75 7.206 0 9.958-2.752 2.751-7.208 2.751-9.961 0-2.068-2.07-2.58-5.11-1.531-7.658l-9.5-9.499v24.997c.67.332 1.303.774 1.861 1.332 2.75 2.75 2.75 7.206 0 9.959-2.75 2.749-7.209 2.749-9.957 0-2.75-2.754-2.75-7.21 0-9.959.68-.679 1.467-1.193 2.307-1.537v-25.23c-.84-.344-1.625-.853-2.307-1.537-2.083-2.082-2.584-5.14-1.516-7.698L31.798 16.715 4.288 44.222c-2.311 2.313-2.311 6.06 0 8.371l40.121 40.118c2.31 2.311 6.056 2.311 8.369 0L92.71 52.779c2.311-2.311 2.311-6.06 0-8.371z"
@@ -11,7 +11,7 @@
import { fade } from 'svelte/transition'
import { X, Minimize2, Maximize2 } from 'lucide-svelte'
import type { Placement } from '@floating-ui/core'
import { pointerDownOutside } from '$lib/utils'
import { debounce, pointerDownOutside } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { createEventDispatcher } from 'svelte'
import { Button } from '$lib/components/common'
@@ -24,6 +24,7 @@
export let placement: Placement = 'bottom'
export let disablePopup: boolean = false
export let openOnHover: boolean = false
export let debounceDelay: number = 0
export let floatingConfig: any | undefined = undefined
export let usePointerDownOutside: boolean = false
export let closeOnOutsideClick: boolean = true
@@ -41,6 +42,14 @@
let fullScreen = false
const dispatch = createEventDispatcher()
function clearTimers() {
clearDebounceClose()
}
// Cleanup timers on component destruction
import { onDestroy } from 'svelte'
onDestroy(clearTimers)
const {
elements: { trigger, content, arrow, close: closeElement, overlay },
states,
@@ -109,6 +118,11 @@
async function getMenuElements(): Promise<HTMLElement[]> {
return Array.from(document.querySelectorAll('[data-popover]')) as HTMLElement[]
}
let { debounced: debounceClose, clearDebounce: clearDebounceClose } = debounce(
() => openOnHover && close(),
debounceDelay
)
</script>
<button
@@ -116,8 +130,13 @@
use:melt={$trigger}
aria-label="Popup button"
disabled={disablePopup || disabled}
on:mouseenter={() => (openOnHover ? open() : null)}
on:mouseleave={() => (openOnHover ? close() : null)}
on:mouseenter={() => {
if (openOnHover) {
open()
clearDebounceClose()
}
}}
on:mouseleave={debounceClose}
use:pointerDownOutside={{
capture: true,
stopPropagation: false,
@@ -141,6 +160,13 @@
{#if isOpen && !disablePopup}
<div
on:mouseenter={() => {
if (openOnHover) {
open()
clearDebounceClose()
}
}}
on:mouseleave={debounceClose}
use:melt={$content}
transition:fade={{ duration: 0 }}
class={twMerge(
@@ -83,6 +83,13 @@
if (Object.keys(arg).length === 1 && Object.keys(arg).includes('s3')) {
return 's3object'
}
if (
Object.keys(arg).length === 2 &&
Object.keys(arg).includes('s3') &&
Object.keys(arg).includes('filename')
) {
return 's3object'
}
return typeof arg
}
@@ -172,9 +179,12 @@
</span>
{/snippet}
<Portal name="object-viewer">
<S3FilePicker bind:this={s3FileViewer} readOnlyMode={true} />
</Portal>
{#if getTypeAsString(jsonFiltered) === 's3object'}
<Portal name="object-viewer">
<S3FilePicker bind:this={s3FileViewer} readOnlyMode={true} />
</Portal>
{/if}
{#if level == 0}
<div class="float-right">
{#if searchOpen}
@@ -16,7 +16,7 @@
import Popover from '../Popover.svelte'
import { isFlowPreview, isScriptPreview, truncateRev } from '$lib/utils'
import { createEventDispatcher, setContext, untrack } from 'svelte'
import { ListFilter } from 'lucide-svelte'
import { ListFilter, LoaderCircle } from 'lucide-svelte'
import FlowAssetsHandler, { initFlowGraphAssetsCtx } from '../flows/FlowAssetsHandler.svelte'
import JobAssetsViewer from '../assets/JobAssetsViewer.svelte'
@@ -78,199 +78,244 @@
})
let jobLoader: JobLoader | undefined = $state(undefined)
// Set all tabs content to the same height to prevent layout jumps
let tabsHeight = $state({
codeHeight: 0,
logsHeight: 0,
assetsHeight: 0,
resultHeight: 0
})
let minTabHeight = $derived(
Math.max(
tabsHeight.codeHeight,
tabsHeight.logsHeight,
tabsHeight.assetsHeight,
tabsHeight.resultHeight
)
)
let jobIsLoading = $state(false)
</script>
<JobLoader workspaceOverride={workspace} bind:job={currentJob} bind:this={jobLoader} />
<JobLoader
workspaceOverride={workspace}
bind:job={currentJob}
bind:isLoading={jobIsLoading}
bind:this={jobLoader}
/>
<div class="p-4 flex flex-col gap-2 items-start h-full">
{#if job}
<div class="flex gap-2 flex-wrap">
{#if job?.['priority']}
<Badge color="red">
priority: {job?.['priority']}
</Badge>
{/if}
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
<DurationMs
duration_ms={job.duration_ms}
self_wait_time_ms={job?.self_wait_time_ms}
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
/>
{/if}
{#if job?.['mem_peak']}
<Badge large>
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{/if}
{#if workspace && $workspaceStore != workspace}
<Badge large>
Workspace: {workspace}
</Badge>
{/if}
{#if job.tag}
<Badge large>
Tag: {job.tag}
</Badge>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<Badge baseClass="text-2xs">Label: {label}</Badge>
{/each}
{/if}
{#if concurrencyKey}
<Popover notClickable>
{#snippet text()}
This job has concurrency limits enabled with the key:
<Button
class="inline-text"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByConcurrencyKey', concurrencyKey)
}}
>
{concurrencyKey}
<ListFilter class="inline-block" size={10} />
</Button>
{/snippet}
<Badge large>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge>
</Popover>
{/if}
{#if job?.worker}
<Popover notClickable>
{#snippet text()}
This job was run on worker:
<Button
class="inline-text"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByWorker', job?.worker)
}}
>
{job?.worker}
<ListFilter class="inline-block" size={10} />
</Button>
{/snippet}
<Badge large>Worker: {truncateRev(job.worker, 20)}</Badge>
</Popover>
{/if}
</div>
<a
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
class="flex flex-row gap-1 items-center"
target={blankLink ? '_blank' : undefined}
>
<span class="font-semibold text-sm leading-6">ID:</span>
<span class="text-sm">{job?.id ?? ''}</span>
</a>
<span class="font-semibold text-xs leading-6">Arguments</span>
<div class="w-full">
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
args={job?.args}
/>
</div>
{#if job?.type === 'CompletedJob'}
<span class="font-semibold text-xs leading-6">Results</span>
{/if}
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
<div class="text-sm font-semibold text-tertiary mb-1">
<div>Job is scheduled for</div>
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
</div>
{/if}
<div class=" w-full rounded-md min-h-full">
{#if job?.workflow_as_code_status}
<WorkflowTimeline
flow_status={asWorkflowStatus(job.workflow_as_code_status)}
flowDone={job.type == 'CompletedJob'}
/>
{/if}
{#if job?.type === 'CompletedJob'}
<Tabs bind:selected={viewTab}>
<Tab size="xs" value="result">Result</Tab>
<Tab size="xs" value="logs">Logs</Tab>
<Tab size="xs" value="assets">Assets</Tab>
{#if isScriptPreview(job?.job_kind)}
<Tab size="xs" value="code">Code</Tab>
{/if}
</Tabs>
<Skeleton loading={!job} layout={[[5]]} />
{#if job}
{#if viewTab == 'result' && (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind))}
<div class="flex flex-col gap-2">
<div class="w-full mt-10 mb-20">
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
</div>
</div>
{:else if viewTab == 'assets'}
<JobAssetsViewer {job} />
{:else}
<div class="flex flex-col border rounded-md p-2 mt-2 h-full overflow-auto">
{#if viewTab == 'logs'}
<div class="w-full">
<LogViewer
jobId={job.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
isLoading={job?.['running'] == false}
content={job?.logs}
tag={job?.tag}
/>
</div>
{:else if viewTab == 'code'}
{#if job && 'raw_code' in job && job.raw_code}
<div class="text-xs">
<HighlightCode lines language={job.language} code={job.raw_code} />
</div>
{:else if job}
No code is available
{:else}
<Skeleton layout={[[5]]} />
{/if}
{:else if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined))}
<DisplayResult
workspaceId={job?.workspace_id}
jobId={job?.id}
{result}
disableExpand
language={job?.language}
/>
{:else if job}
No output is available yet
{/if}
</div>
{/if}
<div class="h-full overflow-y-auto">
<div class="flex flex-col gap-2 items-start p-4 pb-8 min-h-full">
{#if job}
<div class="flex gap-2 flex-wrap">
{#if job?.['priority']}
<Badge color="red">
priority: {job?.['priority']}
</Badge>
{/if}
{:else if job && `running` in job ? job.running : false}
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
<div class="flex flex-col gap-2 w-full">
<FlowProgressBar {job} class="py-4" />
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
</div>
{:else}
<div class="text-sm font-semibold text-tertiary mb-1"> Job is still running </div>
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
content={job?.logs}
isLoading={job?.['running'] == false}
tag={job?.tag}
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
<DurationMs
duration_ms={job.duration_ms}
self_wait_time_ms={job?.self_wait_time_ms}
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
/>
{/if}
{#if job?.['mem_peak']}
<Badge large>
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{/if}
{#if workspace && $workspaceStore != workspace}
<Badge large>
Workspace: {workspace}
</Badge>
{/if}
{#if job.tag}
<Badge large>
Tag: {job.tag}
</Badge>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<Badge baseClass="text-2xs">Label: {label}</Badge>
{/each}
{/if}
{#if concurrencyKey}
<Popover notClickable>
{#snippet text()}
This job has concurrency limits enabled with the key:
<Button
class="inline-text"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByConcurrencyKey', concurrencyKey)
}}
>
{concurrencyKey}
<ListFilter class="inline-block" size={10} />
</Button>
{/snippet}
<Badge large>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge>
</Popover>
{/if}
{#if job?.worker}
<Popover notClickable>
{#snippet text()}
This job was run on worker:
<Button
class="inline-text"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByWorker', job?.worker)
}}
>
{job?.worker}
<ListFilter class="inline-block" size={10} />
</Button>
{/snippet}
<Badge large>Worker: {truncateRev(job.worker, 20)}</Badge>
</Popover>
{/if}
</div>
<a
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
class="flex flex-row gap-1 items-center"
target={blankLink ? '_blank' : undefined}
>
<span class="font-semibold text-sm leading-6">ID:</span>
<span class="text-sm">{job?.id ?? ''}</span>
</a>
<div class="w-full">
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
args={job?.args}
/>
</div>
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
<div class="text-sm font-semibold text-tertiary mb-1">
<div>Job is scheduled for</div>
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
</div>
{/if}
</div>
{/if}
<div class="w-full rounded-md min-h-full">
{#if job?.workflow_as_code_status}
<WorkflowTimeline
flow_status={asWorkflowStatus(job.workflow_as_code_status)}
flowDone={job.type == 'CompletedJob'}
/>
{/if}
{#if job?.type === 'CompletedJob'}
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
<div class="w-full mt-8 mb-20">
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} wideResults
></FlowStatusViewer>
</div>
{:else}
<Tabs bind:selected={viewTab}>
<Tab size="xs" value="result">Results</Tab>
<Tab size="xs" value="logs">Logs</Tab>
<Tab size="xs" value="assets">Assets</Tab>
{#if isScriptPreview(job?.job_kind)}
<Tab size="xs" value="code">Code</Tab>
{/if}
</Tabs>
<Skeleton loading={!job} layout={[[5]]} />
{#if job}
<div class="flex flex-col border rounded-md p-2 mt-2 overflow-auto">
{#if viewTab == 'logs'}
<div
class="w-full"
bind:clientHeight={tabsHeight.logsHeight}
style="min-height: {minTabHeight}px"
>
<LogViewer
jobId={job.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
isLoading={job?.['running'] == false}
content={job?.logs}
tag={job?.tag}
/>
</div>
{:else if viewTab == 'assets'}
<div
class="w-full h-full"
bind:clientHeight={tabsHeight.assetsHeight}
style="min-height: {minTabHeight}px"
>
<JobAssetsViewer {job} />
</div>
{:else if viewTab == 'code'}
<div
class="text-xs"
bind:clientHeight={tabsHeight.codeHeight}
style="min-height: {minTabHeight}px"
>
{#if job && 'raw_code' in job && job.raw_code}
<div class="text-xs">
<HighlightCode lines language={job.language} code={job.raw_code} />
</div>
{:else if job}
<span class="text-sm">No code available</span>
{:else}
<Skeleton layout={[[5]]} />
{/if}
</div>
{:else if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined))}
<div
class="w-full"
bind:clientHeight={tabsHeight.resultHeight}
style="min-height: {minTabHeight}px"
>
<DisplayResult
workspaceId={job?.workspace_id}
jobId={job?.id}
{result}
disableExpand
language={job?.language}
/>
</div>
{:else if job}
No output is available yet
{/if}
</div>
{/if}
{/if}
{:else if job && `running` in job ? job.running : false}
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
<div class="flex flex-col gap-2 w-full">
<FlowProgressBar {job} class="py-4" />
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
</div>
{:else}
<div class="text-sm font-semibold text-tertiary mb-1"> Job is still running </div>
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
content={job?.logs}
isLoading={job?.['running'] == false}
tag={job?.tag}
/>
{/if}
{/if}
</div>
{:else if jobIsLoading}
<div class="mx-auto my-auto">
<LoaderCircle size={20} class="animate-spin" />
</div>
{/if}
</div>
</div>
<FlowAssetsHandler
modules={job?.raw_flow?.modules ?? []}
@@ -0,0 +1,119 @@
<script lang="ts">
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
import { truncateHash } from '$lib/utils'
import { base } from '$app/paths'
import { truncateRev } from '$lib/utils'
import WorkerHostname from '$lib/components/WorkerHostname.svelte'
import { workspaceStore } from '$lib/stores'
import Badge from '$lib/components/common/badge/Badge.svelte'
import type { Job } from '$lib/gen'
interface Props {
job: Job
displayPersistentScriptDefinition?: boolean
openPersistentScriptDrawer?: () => void
concurrencyKey?: string
showScriptHash?: boolean
verySmall?: boolean
}
let {
job,
displayPersistentScriptDefinition,
openPersistentScriptDrawer,
concurrencyKey,
showScriptHash = true,
verySmall = false
}: Props = $props()
</script>
{#if job.script_hash && showScriptHash && job.job_kind !== 'aiagent'}
{#if job.job_kind == 'script'}
<a href="{base}/scripts/get/{job.script_hash}?workspace={$workspaceStore}"
><Badge color="gray" {verySmall}>{truncateHash(job.script_hash)}</Badge></a
>
{:else}
<div>
<Badge color="gray" {verySmall}>{truncateHash(job.script_hash)}</Badge>
</div>
{/if}
{/if}
{#if job && 'job_kind' in job}
<div>
<Badge color="blue" {verySmall}>{job.job_kind}</Badge>
</div>
{/if}
{#if job && job.flow_status && job.job_kind === 'script'}
<PreprocessedArgsDisplay preprocessed={job.preprocessed} />
{/if}
{#if displayPersistentScriptDefinition}
<button onclick={() => openPersistentScriptDrawer?.()}>
<Badge color="red">persistent</Badge>
</button>
{/if}
{#if job && 'priority' in job}
<div>
<Badge color="blue" {verySmall}>priority: {job.priority}</Badge>
</div>
{/if}
{#if job.tag}
<!-- for related places search: ADD_NEW_LANG -->
<div>
<Badge color="indigo" {verySmall}>Tag: {job.tag}</Badge>
</div>
{/if}
{#if !job.visible_to_owner}
<div>
<Badge color="red" {verySmall}>
only visible to you
<Tooltip>
{#snippet text()}
The option to hide this run from the owner of this script or flow was activated
{/snippet}
</Tooltip>
</Badge>
</div>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<div>
<Badge {verySmall}>Label: {label}</Badge>
</div>
{/each}
{/if}
{#if concurrencyKey}
<div>
<Tooltip notClickable>
{#snippet text()}
This job has concurrency limits enabled with the key
<a
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
>
{concurrencyKey}
</a>
{/snippet}
<a
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
>
<Badge {verySmall}>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge></a
>
</Tooltip>
</div>
{/if}
{#if job?.worker}
<div>
<Tooltip notClickable>
{#snippet text()}
worker:
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
{job?.worker}
</a><br />
<WorkerHostname worker={job?.worker!} minTs={job?.['created_at']} />
{/snippet}
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
<Badge {verySmall}>Worker: {truncateRev(job?.worker, 20)}</Badge></a
>
</Tooltip>
</div>
{/if}
@@ -0,0 +1,112 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import { ListFilterPlus } from 'lucide-svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import type { Job } from '$lib/gen'
import type { Item } from '$lib/utils'
interface Props {
job: Job
activeLabel: string | null
onFilterByLabel: (label: string) => void
labelWidth: number
}
let { job, activeLabel, onFilterByLabel, labelWidth }: Props = $props()
const GAP = 4
const LABEL_MAX_WIDTH = 84
const MORE_LABEL_WIDTH = 30
// Min width for labels columns is 120px
const labels = $derived(job && Array.isArray(job?.['labels']) ? (job['labels'] as string[]) : [])
const labelSplit = $derived.by(() => {
if (!labels || labels.length === 0 || labelWidth <= 0) {
return { visibleLabels: [], hiddenLabels: [] }
}
if (labels.length === 1) {
return { visibleLabels: labels, hiddenLabels: [] }
}
let currentWidth = 0
const visible: string[] = []
const hidden: string[] = []
const margin = 20
for (let i = 0; i < labels.length; i++) {
const label = labels[i]
// Check if we need to reserve space for overflow badge
const needsOverflowBadge = i < labels.length - 1
const remainingWidth = labelWidth - currentWidth
const requiredWidth =
LABEL_MAX_WIDTH + (needsOverflowBadge ? MORE_LABEL_WIDTH + GAP : 0) + margin
if (remainingWidth >= requiredWidth || visible.length === 0) {
visible.push(label)
currentWidth += LABEL_MAX_WIDTH + GAP
} else {
hidden.push(...labels.slice(i))
break
}
}
return { visibleLabels: visible, hiddenLabels: hidden }
})
const visibleLabels = $derived(labelSplit.visibleLabels || [])
const hiddenLabels = $derived(labelSplit.hiddenLabels || [])
const dropdownItems = $derived(
hiddenLabels.map(
(label): Item => ({
displayName: label,
action: () => onFilterByLabel(label),
icon: ListFilterPlus
})
)
)
</script>
{#if labels && labels.length > 0}
<div class="flex flex-row items-center" style="gap: {GAP}px">
{#each visibleLabels as label}
<Tooltip openDelay={500} placement="bottom">
<button
class={twMerge(
activeLabel == label ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'flex flex-row items-center px-2 group py-1 rounded-md bg-surface-secondary hover:bg-surface'
)}
style="gap: {GAP}px; width: {LABEL_MAX_WIDTH}px"
onclick={() => {
onFilterByLabel(label)
}}
>
<span class="truncate text-2xs font-normal">{label}</span>
<ListFilterPlus size={12} class="shrink-0 text-gray-300 group-hover:text-primary" />
</button>
{#snippet text()}
{`Filter by label: ${label}`}
{/snippet}
</Tooltip>
{/each}
{#if hiddenLabels.length > 0}
<DropdownV2 placement="bottom-start" items={dropdownItems} customWidth={224}>
{#snippet buttonReplacement()}
<button
class="flex flex-row items-center justify-center px-2 py-1 text-2xs font-semibold hover:bg-surface bg-surface-secondary text-secondary rounded-md"
style="gap: {GAP}px; width: {MORE_LABEL_WIDTH}px"
>
+{hiddenLabels.length}
</button>
{/snippet}
</DropdownV2>
{/if}
</div>
{:else}
<span class="text-2xs text-secondary">-</span>
{/if}
@@ -0,0 +1,30 @@
<script lang="ts">
import Tooltip from '$lib/components/Tooltip.svelte'
interface Props {
label: string
children: import('svelte').Snippet
tooltip?: import('svelte').Snippet
for?: string
noLabel?: boolean
}
let { label, children, tooltip, for: forAttr = '', noLabel = false }: Props = $props()
</script>
<div class="flex flex-col items-start w-fit">
<div class="text-xs truncate">
{#if forAttr}
<label for={forAttr} class:text-transparent={noLabel}>{label}</label>
{:else}
<span class:text-transparent={noLabel}>{label}</span>
{/if}
{#if tooltip}
<Tooltip small>{@render tooltip()}</Tooltip>
{/if}
</div>
<div class="flex flex-row gap-1 items-center justify-start w-full relative h-[34px]">
{@render children()}
</div>
</div>
+271 -158
View File
@@ -4,24 +4,28 @@
import type { Job } from '$lib/gen'
import {
displayDate,
msToReadableTime,
truncateHash,
truncateRev,
isFlowPreview,
isScriptPreview,
isJobSelectable
isJobSelectable,
msToReadableTime,
isFlowPreview
} from '$lib/utils'
import { Badge, Button } from '../common'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import {
Bot,
Calendar,
Check,
Clock,
Code,
ExternalLink,
FastForward,
Folder,
Hourglass,
ListFilter,
ListFilterPlus,
Package,
Play,
ShieldQuestion,
X
@@ -34,45 +38,104 @@
import WaitTimeWarning from '../common/waitTimeWarning/WaitTimeWarning.svelte'
import type { RunsSelectionMode } from './RunsBatchActionsDropdown.svelte'
import DropdownV2 from '../DropdownV2.svelte'
import { Tooltip } from '../meltComponents'
import { GitIcon } from '../icons'
import RunLabels from './RunLabels.svelte'
import './runs-grid.css'
const dispatch = createEventDispatcher()
export let job: Job
export let selected: boolean = false
export let containerWidth: number = 0
export let containsLabel: boolean = false
export let activeLabel: string | null
export let selectionMode: RunsSelectionMode | false = false
interface Props {
job: Job
selected?: boolean
containerWidth?: number
containsLabel?: boolean
showTag?: boolean
activeLabel: string | null
selectionMode?: RunsSelectionMode | false
}
let scheduleEditor: ScheduleEditor
let {
job,
selected = false,
containerWidth = 0,
containsLabel = false,
showTag = true,
activeLabel,
selectionMode = false
}: Props = $props()
$: isExternal = job && job.id === '-'
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
let isExternal = $derived(job && job.id === '-')
function getJobKindIcon(jobKind: Job['job_kind']) {
if (jobKind === 'flow' || isFlowPreview(jobKind)) {
return BarsStaggered
} else if (jobKind === 'deploymentcallback') {
return GitIcon
} else if (
jobKind === 'dependencies' ||
jobKind === 'appdependencies' ||
jobKind === 'flowdependencies'
) {
return Package
} else if (
jobKind === 'script' ||
isScriptPreview(jobKind) ||
jobKind === 'script_hub' ||
jobKind === 'singlescriptflow'
) {
return Code
} else if (jobKind === 'aiagent') {
return Bot
}
return Code
}
let labelWidth = $state(0)
let isJobRecent = $state(true)
</script>
<Portal name="run-row">
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
</Portal>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'hover:bg-surface-hover cursor-pointer',
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'flex flex-row items-center h-full'
'grid items-center h-full'
)}
class:grid-runs-table={!containsLabel && !selectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !selectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && selectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && selectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !selectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !selectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && selectionMode && !showTag}
class:grid-runs-table-with-labels-selection-no-tag={containsLabel && selectionMode && !showTag}
style="width: {containerWidth}px"
on:click={() => {
onclick={() => {
if (!selectionMode || isJobSelectable(selectionMode)(job)) {
dispatch('select')
}
}}
>
<div class="w-1/12 flex justify-center">
{#if selectionMode && isJobSelectable(selectionMode)(job)}
<div class="px-2">
<input type="checkbox" checked={selected} />
<!-- Selection column (only when in selection mode) -->
{#if selectionMode}
<div class="flex items-center justify-center">
<div class="w-4 h-4">
<input type="checkbox" checked={selected} disabled={!isJobSelectable(selectionMode)(job)} />
</div>
{/if}
</div>
{/if}
<!-- Status -->
<div class="flex items-center justify-start pl-2">
{#if isExternal}
<Badge color="gray" baseClass="!px-1.5">
<ShieldQuestion size={14} />
@@ -114,16 +177,13 @@
{/if}
</div>
<div class="w-4/12 flex justify-start">
<div class="flex flex-row items-center gap-1 text-gray-500 dark:text-gray-300 text-2xs">
<!-- Job time -->
<div class="overflow-hidden min-w-0">
<div class="flex flex-row items-center gap-1 text-secondary text-2xs">
{#if job}
{#if 'started_at' in job && job.started_at}
Started <TimeAgo agoOnlyIfRecent date={job.started_at ?? ''} />
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
(Ran in {msToReadableTime(
job.duration_ms
)}{#if job.job_kind == 'flow' || isFlowPreview(job.job_kind)}&nbsp;total{/if})
{/if}
{isJobRecent ? 'Started' : ''}
<TimeAgo bind:isRecent={isJobRecent} agoOnlyIfRecent date={job.started_at ?? ''} />
{#if job && (job.self_wait_time_ms || job.aggregate_wait_time_ms)}
<WaitTimeWarning
self_wait_time_ms={job.self_wait_time_ms}
@@ -132,7 +192,7 @@
/>
{/if}
{:else if `scheduled_for` in job && job.scheduled_for && forLater(job.scheduled_for)}
Scheduled for {displayDate(job.scheduled_for)}
{displayDate(job.scheduled_for)}<Clock size={12} />
{:else if job.canceled}
{#if job.type == 'CompletedJob'}
Cancelled <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />
@@ -151,154 +211,207 @@
</div>
</div>
<div class="w-4/12 flex justify-start flex-col">
<div class="flex flex-row text-sm">
{#if job === undefined}
No job found
{:else}
<div class="flex flex-row gap-1 min-w-0">
<div class="whitespace-nowrap text-xs font-semibold truncate">
{#if job.script_path}
<div class="flex flex-row gap-1 items-center">
{#if isExternal}
<span class="w-30 justify-center">-</span>
{:else}
<a
href="{base}/run/{job.id}?workspace={job.workspace_id}"
class="truncate w-30 dark:text-blue-400"
>
{job.script_path}
</a>
<Button
title="Filter by path"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByPath', job.script_path)
}}
>
<ListFilter size={10} />
</Button>
{/if}
{#if job.script_path?.startsWith('f/')}
<Button
title="Filter by folder"
size="xs2"
color="light"
on:click={() => {
// split script_path by / and get the second element
const folder = job.script_path?.split('/')[1]
dispatch('filterByFolder', folder)
}}
>
<Folder size={10} />
</Button>
{/if}
</div>
{:else if 'job_kind' in job && isScriptPreview(job.job_kind)}
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">Preview without path </a>
{:else if 'job_kind' in job && job.job_kind == 'dependencies'}
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">
lock deps of {truncateHash(job.script_hash ?? '')}
</a>
{:else if 'job_kind' in job && job.job_kind == 'identity'}
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">no op</a>
{/if}
</div>
</div>
{/if}
</div>
{#if job && job.parent_job}
{#if job.is_flow_step}
<div class="flex flex-row gap-1 items-center">
<BarsStaggered class="text-secondary" size={14} />
<span class="mx-1 text-xs">
Step of flow <a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 6)}
</a>
</span>
</div>
{:else}
<div class="flex flex-row gap-1 items-center">
<span class="text-2xs text-tertiary truncate">
parent <a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 10)}
</a>
</span>
</div>
{/if}
<!-- Job duration-->
<div class="text-2xs font-normal text-secondary pr-2">
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
{msToReadableTime(job.duration_ms, 2)}
{:else}
-
{/if}
</div>
{#if containsLabel}
<div class="w-3/12 flex justify-start px-0.5">
{#if job && job?.['labels']}
<div class="flex flex-row items-center gap-1 overflow-x-auto">
{#if Array.isArray(job?.['labels'])}
{#each job?.['labels'] as label}
<Button
variant="border"
size="xs3"
btnClasses={twMerge(
activeLabel == label ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'!text-2xs !font-normal truncate max-w-28'
)}
color="light"
on:click={() => {
dispatch('filterByLabel', label)
}}
endIcon={{ icon: ListFilter }}
>
{label}
</Button>
{/each}
<!-- Job path-->
<div class="flex justify-start flex-col pr-4">
{#if job === undefined}
No job found
{:else}
{@const JobKindIcon = getJobKindIcon(job.job_kind)}
<div class="flex flex-row gap-3 min-w-0 items-center h-full">
<Tooltip class="h-full">
<div class="relative">
{#if job && job.parent_job}
<span class="absolute -top-1 -right-1 text-xs text-blue-500">*</span>
{/if}
<JobKindIcon size={14} />
</div>
{#snippet text()}
<span>
{#if job && job.job_kind}
{job.job_kind}
{/if}
{#if job && job.is_flow_step && job.parent_job}
<br /> Step of flow
<a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 10)}
</a>
{:else if job && job.parent_job}
<br /> Parent
<a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 10)}
</a>
{/if}
</span>
{/snippet}
</Tooltip>
<div class="whitespace-nowrap text-xs text-secondary truncate">
{#if job.script_path}
<div class="flex flex-row gap-1 items-center">
{#if isExternal}
<span class="w-30 justify-center">-</span>
{:else}
<span class="truncate w-30" title={job.script_path}>
{job.script_path}
</span>
{/if}
{#if !isExternal || job.script_path?.startsWith('f/')}
{@const isFolder = job.script_path?.startsWith('f/')}
<DropdownV2
items={() => {
const items = isExternal
? []
: [
{
displayName: `Filter by path: ${job.script_path}`,
action: () => dispatch('filterByPath', job.script_path),
disabled: isExternal
}
]
if (isFolder) {
const folder = job.script_path?.split('/')[1]
return [
{
displayName: `Filter by folder: ${folder}`,
action: () => dispatch('filterByFolder', folder)
},
...items
]
}
return items
}}
class="w-fit"
>
{#snippet buttonReplacement()}
<div
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
>
<ListFilterPlus size={14} />
</div>
{/snippet}
</DropdownV2>
{/if}
</div>
{:else if 'job_kind' in job && isScriptPreview(job.job_kind)}
Preview without path
{:else if 'job_kind' in job && job.job_kind == 'dependencies'}
lock deps of {truncateHash(job.script_hash ?? '')}
{:else if 'job_kind' in job && job.job_kind == 'identity'}
no op
{:else if 'job_kind' in job && isFlowPreview(job.job_kind)}
Preview without path
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Labels-->
{#if containsLabel}
<div class="flex justify-start overflow-hidden" bind:clientWidth={labelWidth}>
<RunLabels
{job}
{activeLabel}
onFilterByLabel={(label) => dispatch('filterByLabel', label)}
{labelWidth}
/>
</div>
{/if}
<div class="w-3/12 flex justify-start">
<!-- Author and schedule-->
<div class="flex justify-start pr-4 text-secondary">
{#if job && job.schedule_path}
<div class="flex flex-row items-center gap-1">
<Calendar size={14} />
<div class="flex flex-row items-center gap-1 w-full -ml-2">
<Button
size="xs2"
color="light"
btnClasses="font-normal"
btnClasses="font-normal bg-transparent hover:bg-surface hover:text-primary"
on:click={() => scheduleEditor?.openEdit(job.schedule_path ?? '', job.job_kind == 'flow')}
>
<div class="truncate text-ellipsis text-left" title={job.schedule_path}>
{truncateRev(job.schedule_path, 20)}
</div>
<Calendar size={14} />
</Button>
<Button
size="xs2"
color="light"
on:click={() => {
dispatch('filterBySchedule', job.schedule_path)
}}
<div class="text-xs truncate text-ellipsis text-left" dir="rtl" title={job.schedule_path}>
{job.schedule_path}
</div>
<DropdownV2
items={[
{
displayName: `Filter by schedule: ${truncateRev(job.schedule_path, 20)}`,
action: () => dispatch('filterBySchedule', job.schedule_path)
}
]}
class="w-fit"
>
<ListFilter size={10} />
</Button>
{#snippet buttonReplacement()}
<div
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
>
<ListFilterPlus size={14} />
</div>
{/snippet}
</DropdownV2>
</div>
{:else}
<div class="flex flex-row gap-1 items-center">
<div class="text-xs truncate text-ellipsis text-left" title={job.created_by}>
{truncateRev(job.created_by ?? '', 20)}
<div class="flex flex-row gap-1 items-center w-full">
<div class="text-xs truncate text-ellipsis text-left" dir="rtl" title={job.created_by}>
{job.created_by ?? ''}
</div>
{#if !isExternal}
<Button
size="xs2"
color="light"
on:click={() => {
dispatch('filterByUser', job.created_by ?? '')
}}
<DropdownV2
items={[
{
displayName: `Filter by triggered by: ${job.created_by}`,
action: () => dispatch('filterByUser', job.created_by ?? '')
}
]}
customWidth={256}
class="w-fit"
>
<ListFilter size={10} />
</Button>
{#snippet buttonReplacement()}
<div
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
>
<ListFilterPlus size={14} />
</div>
{/snippet}
</DropdownV2>
{/if}
</div>
{/if}
</div>
<!-- Job tag-->
{#if showTag}
<div class="flex justify-start gap-1">
{#if job.tag}
<span class="text-xs text-secondary truncate" title={job.tag}>{job.tag}</span>
{/if}
</div>
{/if}
<!-- Job link-->
{#if !isExternal}
<div class="flex justify-end pr-2">
<a
target="_blank"
href="{base}/run/{job.id}?workspace={job.workspace_id}"
class={twMerge(
'text-right float-right px-2',
selected
? 'text-blue-500 hover:text-primary'
: 'text-gray-300 dark:text-gray-500 hover:text-primary dark:hover:text-primary'
)}
title="See run detail in a new tab"
>
<ExternalLink size={14} />
</a>
</div>
{/if}
</div>
@@ -1,21 +1,36 @@
<script lang="ts" context="module">
<script lang="ts" module>
export type RunsSelectionMode = 'cancel' | 're-run'
</script>
<script lang="ts">
import { userStore, superadmin } from '$lib/stores'
import { X, Check, ChevronDown, Loader2 } from 'lucide-svelte'
import { X, Check, ChevronDown, Loader2, SquareMousePointer } from 'lucide-svelte'
import { Button } from '../common'
import DropdownV2 from '../DropdownV2.svelte'
export let isLoading = false
export let selectionCount: number
export let selectionMode: RunsSelectionMode | false
export let onSetSelectionMode: (mode: RunsSelectionMode | false) => void
export let onCancelSelectedJobs: () => void
export let onCancelFilteredJobs: () => void
export let onReRunSelectedJobs: () => void
export let onReRunFilteredJobs: () => void
interface Props {
isLoading?: boolean
selectionCount: number
selectionMode: RunsSelectionMode | false
small?: boolean
onSetSelectionMode: (mode: RunsSelectionMode | false) => void
onCancelSelectedJobs: () => void
onCancelFilteredJobs: () => void
onReRunSelectedJobs: () => void
onReRunFilteredJobs: () => void
}
let {
isLoading = false,
selectionCount,
selectionMode,
small = false,
onSetSelectionMode,
onCancelSelectedJobs,
onCancelFilteredJobs,
onReRunSelectedJobs,
onReRunFilteredJobs
}: Props = $props()
function jobCountString(count: number) {
return `${count} ${count == 1 ? 'job' : 'jobs'}`
@@ -27,7 +42,7 @@
<Loader2 class="animate-spin" size={20} />
</Button>
{:else if selectionMode}
<div class="mt-1 p-2 h-8 flex flex-row items-center gap-1">
<div class="h-8 flex flex-row items-center gap-1">
<Button
startIcon={{ icon: X }}
size="xs"
@@ -62,7 +77,7 @@
</div>
{:else}
<DropdownV2
class="w-fit mx-auto"
class="w-fit"
items={[
{
displayName: 'Select jobs to cancel',
@@ -80,13 +95,16 @@
: [])
]}
>
<svelte:fragment slot="buttonReplacement">
{#snippet buttonReplacement()}
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
class="px-2 h-[30px] border flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md gap-2"
>
<span class="text-xs min-w-[5rem]">Batch actions</span>
<ChevronDown class="w-5 h-5" />
<SquareMousePointer size={16} />
{#if !small}
<span class="text-xs min-w-[5rem]">Batch actions</span>
{/if}
<ChevronDown size={16} />
</div>
</svelte:fragment>
{/snippet}
</DropdownV2>
{/if}
+374 -243
View File
@@ -3,7 +3,7 @@
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import Tooltip from '../Tooltip.svelte'
import { AlertCircle, CheckCircle2, Filter, Hourglass, PlayCircle, X } from 'lucide-svelte'
import { CircleAlert, CircleCheck, Hourglass, ListFilterPlus, CirclePlay, X } from 'lucide-svelte'
import JsonEditor from '../JsonEditor.svelte'
import Toggle from '../Toggle.svelte'
import Label from '../Label.svelte'
@@ -14,6 +14,9 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
import RunOption from './RunOption.svelte'
import DropdownSelect from '../DropdownSelect.svelte'
import TooltipV2 from '$lib/components/meltComponents/Tooltip.svelte'
interface Props {
// Filters
@@ -48,6 +51,8 @@
| 'worker'
| 'tag'
| 'schedulePath'
small?: boolean
calendarSmall?: boolean
}
let {
@@ -72,7 +77,9 @@
usernames = [],
folders = [],
allWorkspaces = $bindable(false),
filterBy = $bindable('path')
filterBy = $bindable('path'),
small = false,
calendarSmall = false
}: Props = $props()
let copyArgFilter = $state(argFilter)
@@ -115,106 +122,153 @@
;(path || user || folder || label || worker || concurrencyKey || tag || schedulePath) &&
untrack(() => autosetFilter())
})
function resetFilter() {
path = null
user = null
folder = null
label = null
concurrencyKey = null
tag = null
schedulePath = undefined
worker = null
}
</script>
<div class="flex gap-4">
{#if !mobile}
<div class="flex gap-2">
{#if $workspaceStore == 'admins'}
<div class="relative">
<span class="text-xs absolute -top-4">Workspaces</span>
<ToggleButtonGroup
bind:selected={allWorkspacesValue}
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
>
{#snippet children({ item })}
<ToggleButton value={'admins'} label="Admins" {item} />
<ToggleButton value={'all'} label="All" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
{#snippet runsTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they
start), they have been triggered through the UI, a schedule or webhook'
{/snippet}
</TooltipV2>
{/snippet}
{#snippet previewsTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Previews are jobs that have been started in the editor as "Tests"'
{/snippet}
</TooltipV2>
{/snippet}
{#snippet dependenciesTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Deploying a script, flow or an app launch a dependency job that creates and then attaches the
lockfile to the deployed item. This mechanism ensures that logic is always executed with the
exact same direct and indirect dependencies.'
{/snippet}
</TooltipV2>
{/snippet}
{#snippet syncTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git
repository configured in the workspace settings'
{/snippet}
</TooltipV2>
{/snippet}
<div class="relative">
<span class="text-xs absolute -top-4">Filter by</span>
<ToggleButtonGroup
bind:selected={filterBy}
on:selected={(e) => {
if (e.detail != filterBy) {
path = null
user = null
folder = null
label = null
concurrencyKey = null
tag = null
schedulePath = undefined
{#if !mobile}
{#if $workspaceStore == 'admins'}
<RunOption label="Workspaces" for="workspaces">
<ToggleButtonGroup
bind:selected={allWorkspacesValue}
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
>
{#snippet children({ item })}
<ToggleButton value={'admins'} label="Admins" {item} />
<ToggleButton value={'all'} label="All" {item} />
{/snippet}
</ToggleButtonGroup>
</RunOption>
{/if}
<!-- Filter by -->
<div class="flex flex-row gap-1">
<RunOption label="Filter by" for="filter-by">
<ToggleButtonGroup
bind:selected={filterBy}
on:selected={(e) => {
if (e.detail != filterBy) {
resetFilter()
}
}}
>
{#snippet children({ item })}
<ToggleButton value="path" label="Path" {item} />
<ToggleButton value="user" label="User" {item} />
<ToggleButton value="folder" label="Folder" {item} />
<ToggleButtonMore
togglableItems={[
{ label: 'Schedule path', value: 'schedulePath' },
{ label: 'Concurrency key', value: 'concurrencyKey' },
{ label: 'Label', value: 'label' },
{ label: 'Tag', value: 'tag' },
{ label: 'Worker', value: 'worker' }
]}
{item}
bind:selected={
() => filterBy,
(v) => {
resetFilter()
filterBy = v
}
}
}}
>
{#snippet children({ item })}
<ToggleButton value="path" label="Path" {item} />
<ToggleButton value="user" label="User" {item} />
<ToggleButton value="folder" label="Folder" {item} />
<ToggleButtonMore
togglableItems={[
{ label: 'Schedule path', value: 'schedulePath' },
{ label: 'Concurrency key', value: 'concurrencyKey' },
{ label: 'Label', value: 'label' },
{ label: 'Tag', value: 'tag' },
{ label: 'Worker', value: 'worker' }
]}
{item}
bind:selected={filterBy}
/>
{/snippet}
</ToggleButtonGroup>
</div>
/>
{/snippet}
</ToggleButtonGroup>
</RunOption>
{#if filterBy == 'user'}
{#key user}
<div class="relative">
<span class="text-xs absolute -top-4">User</span>
<Select
items={safeSelectItems(usernames)}
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
clearable
onClear={() => ((user = null), dispatch('reset'))}
inputClass="!h-[32px]"
onCreateItem={(item) => (usernames.push(item), (user = item))}
createText="Press enter to use this value"
/>
</div>
{/key}
{:else if filterBy == 'folder'}
{#if filterBy == 'user'}
{#key user}
<RunOption label="User" for="user">
<Select
items={safeSelectItems(usernames)}
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
clearable
onClear={() => ((user = null), dispatch('reset'))}
inputClass="!h-[32px] min-w-36"
onCreateItem={(item) => (usernames.push(item), (user = item))}
createText="Press enter to use this value"
id="user"
/>
</RunOption>
{/key}
{:else if filterBy == 'folder'}
<RunOption label="Folder" for="folder">
{#key folder}
<div class="relative">
<span class="text-xs absolute -top-4">Folder</span>
<Select
items={safeSelectItems(folders)}
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
clearable
onClear={() => ((folder = null), dispatch('reset'))}
inputClass="!h-[32px]"
/>
</div>
<Select
items={safeSelectItems(folders)}
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
clearable
onClear={() => ((folder = null), dispatch('reset'))}
inputClass="!h-[32px] min-w-36"
id="folder"
/>
{/key}
{:else if filterBy === 'path'}
</RunOption>
{:else if filterBy === 'path'}
<RunOption label="Path" for="path">
{#key path}
<div class="relative">
<span class="text-xs absolute -top-4">Path</span>
<Select
items={safeSelectItems(paths)}
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
clearable
onClear={() => ((path = null), dispatch('reset'))}
inputClass="!h-[32px]"
onCreateItem={(item) => (paths.push(item), (path = item))}
createText="Press enter to use this value"
/>
</div>
<Select
items={safeSelectItems(paths)}
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
clearable
onClear={() => ((path = null), dispatch('reset'))}
inputClass="!h-[32px] min-w-36"
onCreateItem={(item) => (paths.push(item), (path = item))}
createText="Press enter to use this value"
id="path"
/>
{/key}
{:else if filterBy === 'label'}
</RunOption>
{:else if filterBy === 'label'}
<RunOption label="Label" for="label">
{#snippet tooltip()}
<a
href="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#jobs-labels"
target="_blank">Job Labels</a
> are string values in the array at the result field 'wm_labels' to easily filter them.
{/snippet}
{#key label}
<div class="relative">
{#if label}
@@ -229,20 +283,11 @@
</button>
{/if}
<span class="text-xs absolute -top-4"
>Label <Tooltip
><a
href="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#jobs-labels"
target="_blank">Job Labels</a
> are string values in the array at the result field 'wm_labels' to easily filter them.</Tooltip
></span
>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedLabel}
onkeydown={(e) => {
if (labelTimeout) {
@@ -254,58 +299,59 @@
}, 1000)
}}
/>
<div class="absolute top-10">
<div class="absolute -top-4 right-0">
<Toggle
bind:checked={allowWildcards}
size="xs"
options={{ right: 'allow wildcards (*)' }}
size="2xs"
options={{ right: '(*)', title: 'allow wildcards (*)' }}
></Toggle>
</div>
</div>
{/key}
{:else if filterBy === 'concurrencyKey'}
</RunOption>
{:else if filterBy === 'concurrencyKey'}
<RunOption label="Concurrency Key" for="concurrencyKey">
{#snippet tooltip()}
For concurrency limited jobs, the concurrency key defines a group of jobs that share the
same limits.
{#if !$enterpriseLicense}
Concurrency limits are an EE feature.
{/if}
{/snippet}
{#key concurrencyKey}
<div class="relative">
{#if concurrencyKey}
<button
class="absolute top-2 right-2 z-50"
onclick={() => {
concurrencyKey = null
dispatch('reset')
}}
>
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"
>Concurrency Key <Tooltip>
For concurrency limited jobs, the concurrency key defines a group of jobs that share
the same limits.
{#if !$enterpriseLicense}
Concurrency limits are an EE feature.
{/if}
</Tooltip></span
>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
bind:value={displayedConcurrencyKey}
onkeydown={(e) => {
if (concurrencyKeyTimeout) {
clearTimeout(concurrencyKeyTimeout)
}
concurrencyKeyTimeout = setTimeout(() => {
concurrencyKey = displayedConcurrencyKey
}, 1000)
{#if concurrencyKey}
<button
class="absolute top-2 right-2 z-50"
onclick={() => {
concurrencyKey = null
dispatch('reset')
}}
/>
</div>
>
<X size={14} />
</button>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedConcurrencyKey}
onkeydown={(e) => {
if (concurrencyKeyTimeout) {
clearTimeout(concurrencyKeyTimeout)
}
concurrencyKeyTimeout = setTimeout(() => {
concurrencyKey = displayedConcurrencyKey
}, 1000)
}}
id="concurrencyKey"
/>
{/key}
{:else if filterBy === 'tag'}
</RunOption>
{:else if filterBy === 'tag'}
<RunOption label="Tag" for="tag">
{#key tag}
<div class="relative">
{#if tag}
@@ -319,13 +365,12 @@
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"> Tag </span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedTag}
onkeydown={(e) => {
if (tagTimeout) {
@@ -336,17 +381,20 @@
tag = displayedTag
}, 1000)
}}
id="tag"
/>
<div class="absolute top-10">
<div class="absolute -top-4 right-0">
<Toggle
bind:checked={allowWildcards}
size="xs"
options={{ right: 'allow wildcards (*)' }}
size="2xs"
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
></Toggle>
</div>
</div>
{/key}
{:else if filterBy === 'schedulePath'}
</RunOption>
{:else if filterBy === 'schedulePath'}
<RunOption label="Schedule Path" for="schedulePath">
{#key tag}
<div class="relative">
{#if tag}
@@ -360,13 +408,12 @@
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"> Schedule Path </span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedSchedule}
onkeydown={(e) => {
if (tagTimeout) {
@@ -377,10 +424,13 @@
schedulePath = displayedSchedule
}, 1000)
}}
id="schedulePath"
/>
</div>
{/key}
{:else if filterBy === 'worker'}
</RunOption>
{:else if filterBy === 'worker'}
<RunOption label="Worker" for="worker">
{#key worker}
<div class="relative">
{#if worker}
@@ -394,13 +444,12 @@
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"> Worker </span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedWorker}
onkeydown={(e) => {
if (workerTimeout) {
@@ -411,20 +460,70 @@
worker = displayedWorker
}, 1000)
}}
id="worker"
/>
<div class="absolute top-10">
<div class="absolute -top-4 right-0">
<Toggle
bind:checked={allowWildcards}
size="xs"
options={{ right: 'allow wildcards (*)' }}
size="2xs"
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
></Toggle>
</div>
</div>
{/key}
{/if}
</div>
<div class="relative">
<span class="text-xs absolute -top-4">Kind</span>
</RunOption>
{/if}
</div>
<!-- Kind -->
<RunOption label="Kind" for="kind">
{#if small && !calendarSmall}
<DropdownSelect
btnClasses="min-w-24"
items={[
{
displayName: 'All',
action: () => {
jobKindsCat = 'all'
},
id: 'all'
},
{
displayName: 'Runs',
action: () => {
jobKindsCat = 'runs'
},
id: 'runs',
extra: runsTooltip
},
{
displayName: 'Previews',
action: () => {
jobKindsCat = 'previews'
},
id: 'previews',
extra: previewsTooltip
},
{
displayName: 'Deps',
action: () => {
jobKindsCat = 'dependencies'
},
id: 'dependencies',
extra: dependenciesTooltip
},
{
displayName: 'Sync',
action: () => {
jobKindsCat = 'deploymentcallbacks'
},
id: 'deploymentcallbacks',
extra: syncTooltip
}
]}
selected={jobKindsCat}
/>
{:else}
<ToggleButtonGroup bind:selected={jobKindsCat}>
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
@@ -435,13 +534,6 @@
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
{item}
/>
<ToggleButton
value="previews"
label="Previews"
showTooltipIcon
tooltip="Previews are jobs that have been started in the editor as 'Tests'"
{item}
/>
<ToggleButton
value="dependencies"
label="Deps"
@@ -449,75 +541,94 @@
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
{item}
/>
<ToggleButton
value="deploymentcallbacks"
label="Sync"
showTooltipIcon
tooltip="Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings"
<ToggleButtonMore
togglableItems={[
{
label: 'Previews',
value: 'previews',
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
},
{
label: 'Sync',
value: 'deploymentcallbacks',
tooltip:
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
}
]}
{item}
bind:selected={
() => jobKindsCat,
(v) => {
resetFilter()
jobKindsCat = v
}
}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<div class="relative">
<span class="text-xs absolute -top-4">Status</span>
<ToggleButtonGroup
selected={success ?? 'all'}
on:selected={({ detail }) => {
success = detail === 'all' ? undefined : detail
dispatch('successChange', success)
}}
>
{#snippet children({ item })}
<ToggleButton value={'all'} label="All" {item} />
{/if}
</RunOption>
<!-- Status -->
<RunOption label="Status" for="status">
<ToggleButtonGroup
selected={success ?? 'all'}
on:selected={({ detail }) => {
success = detail === 'all' ? undefined : detail
dispatch('successChange', success)
}}
id="status"
>
{#snippet children({ item })}
<ToggleButton value={'all'} label="All" {item} />
<ToggleButton
value={'running'}
tooltip="Running"
class="whitespace-nowrap"
icon={CirclePlay}
iconProps={{ color: success === 'running' ? 'blue' : 'gray' }}
{item}
/>
<ToggleButton
value={'success'}
tooltip="Success"
class="whitespace-nowrap"
icon={CircleCheck}
iconProps={{ color: success === 'success' ? 'green' : 'gray' }}
{item}
/>
<ToggleButton
value={'failure'}
tooltip="Failure"
class="whitespace-nowrap"
icon={CircleAlert}
iconProps={{ color: success === 'failure' ? 'red' : 'gray' }}
{item}
/>
{#if success == 'waiting'}
<ToggleButton
value={'running'}
tooltip="Running"
value={'waiting'}
tooltip="Waiting"
class="whitespace-nowrap"
icon={PlayCircle}
iconProps={{ color: success === 'running' ? 'blue' : 'gray' }}
icon={Hourglass}
iconProps={{ color: 'blue' }}
{item}
/>
{:else if success == 'suspended'}
<ToggleButton
value={'success'}
tooltip="Success"
value={'suspended'}
tooltip="Suspended"
class="whitespace-nowrap"
icon={CheckCircle2}
iconProps={{ color: success === 'success' ? 'green' : 'gray' }}
icon={Hourglass}
iconProps={{ color: 'blue' }}
{item}
/>
<ToggleButton
value={'failure'}
tooltip="Failure"
class="whitespace-nowrap"
icon={AlertCircle}
iconProps={{ color: success === 'failure' ? 'red' : 'gray' }}
{item}
/>
{#if success == 'waiting'}
<ToggleButton
value={'waiting'}
tooltip="Waiting"
class="whitespace-nowrap"
icon={Hourglass}
iconProps={{ color: 'blue' }}
{item}
/>
{:else if success == 'suspended'}
<ToggleButton
value={'suspended'}
tooltip="Suspended"
class="whitespace-nowrap"
icon={Hourglass}
iconProps={{ color: 'blue' }}
{item}
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
{/if}
{/snippet}
</ToggleButtonGroup>
</RunOption>
{/if}
<RunOption label="_" for="more-filters" noLabel>
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
contentClasses="p-4"
@@ -525,15 +636,33 @@
usePointerDownOutside
>
{#snippet trigger()}
<Button color="dark" size="xs" nonCaptureEvent={true} startIcon={{ icon: Filter }}>
More filters
</Button>
<Button
color="light"
variant="border"
size="xs"
nonCaptureEvent={true}
startIcon={{ icon: ListFilterPlus }}
iconOnly
></Button>
{/snippet}
{#snippet content()}
<Section label="Filters">
<div class="w-102 flex flex-col gap-4">
{#if mobile || true}
{#if mobile}
{#if $workspaceStore == 'admins'}
<Label label="Workspaces">
<ToggleButtonGroup
bind:selected={allWorkspacesValue}
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
>
{#snippet children({ item })}
<ToggleButton value={'admins'} label="Admins" {item} />
<ToggleButton value={'all'} label="All" {item} />
{/snippet}
</ToggleButtonGroup>
</Label>
{/if}
<Label label="Filter by">
<ToggleButtonGroup
bind:selected={filterBy}
@@ -739,8 +868,8 @@
{#if filterBy === 'tag' || filterBy === 'label' || filterBy === 'worker'}
<Toggle
bind:checked={allowWildcards}
size="xs"
options={{ right: 'allow wildcards (*)' }}
size="2xs"
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
></Toggle>
{/if}
<Label label="Kind">
@@ -816,15 +945,17 @@
</div>
</Label>
<span class="text-xs leading-6">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<Label label="Filter by args">
<JsonEditor bind:error={argError} bind:code={copyArgFilter} />
</Label>
<Label label="Filter by result">
<JsonEditor bind:error={resultError} bind:code={copyResultFilter} />
</Label>
<div class="flex flex-col gap-1">
<span class="text-xs leading-6">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<Label label="Filter by args">
<JsonEditor bind:error={argError} bind:code={copyArgFilter} />
</Label>
<Label label="Filter by result">
<JsonEditor bind:error={resultError} bind:code={copyResultFilter} />
</Label>
</div>
<div class="flex flex-row gap-2 justify-between">
<Button
@@ -853,4 +984,4 @@
</Section>
{/snippet}
</Popover>
</div>
</RunOption>
+111 -25
View File
@@ -1,36 +1,122 @@
<script lang="ts">
import type { Tweened } from 'svelte/motion'
import { createEventDispatcher } from 'svelte'
import { Button } from '../common'
import { FilterX, ListFilter } from 'lucide-svelte'
import Tooltip from '../Tooltip.svelte'
import { Bot, Hourglass, ListFilterPlus, X } from 'lucide-svelte'
import RunOption from './RunOption.svelte'
import { Popover } from '../meltComponents'
export let queue_count: Tweened<number> | undefined = undefined
export let suspended_count: Tweened<number> | undefined = undefined
export let success: string | undefined
const dispatch = createEventDispatcher()
interface Props {
queue_count?: Tweened<number> | undefined
suspended_count?: Tweened<number> | undefined
success: string | undefined
small?: boolean
onJobsWaiting?: () => void
onJobsSuspended?: () => void
}
let {
queue_count = undefined,
suspended_count = undefined,
success,
small = false,
onJobsWaiting,
onJobsSuspended
}: Props = $props()
</script>
<div class="flex gap-1 relative max-w-36 min-w-[50px] items-baseline">
<div class="text-xs absolute -top-4 truncate flex items-baseline gap-1">Waiting for workers <Tooltip small>Jobs waiting for a worker being available to be executed</Tooltip></div>
<div class="mt-1">{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}</div>
<div class="truncate text-2xs !text-secondary mt-0.5">
{#if queue_count && ($queue_count ?? 0) > 0}
<Button size="xs2" color="light" on:click={() => dispatch('jobs_waiting')}>{#if success == 'waiting'}<FilterX size={12}></FilterX>{:else}<ListFilter size={12}></ListFilter>{/if}</Button>
{/if}
<div class="flex flex-row gap-4 items-center">
{#if small}
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
{#snippet trigger()}
<div class="relative">
<Bot size={16} />
{#if queue_count && ($queue_count ?? 0) > 0}
<div
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 bg-yellow-500 rounded-full text-white text-xs h-4 w-4"
>
{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}
</div>
{/if}
</div>
{/snippet}
</div>
{#snippet content()}
{@render queuedContent()}
{/snippet}
</Popover>
{:else}
{@render queuedContent()}
{/if}
{#if small}
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
{#snippet trigger()}
<div class="relative">
<Hourglass size={16} />
<div
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 bg-surface-secondary-inverse rounded-full text-primary-inverse text-xs h-4 w-4"
>
{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}
</div>
</div>
{/snippet}
{#snippet content()}
{@render suspendedContent()}
{/snippet}
</Popover>
{:else}
{@render suspendedContent()}
{/if}
</div>
{#if suspended_count && ($suspended_count ?? 0) > 0}
<div class="flex gap-1 relative max-w-36 min-w-[50px] items-baseline ml-20 mr-8">
<div class="text-xs absolute -top-4 truncate flex items-baseline gap-1">Suspended <Tooltip small>Jobs waiting for an event or approval before being resumed</Tooltip></div>
<div class="mt-1">{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}</div>
{#snippet queuedContent()}
<RunOption label="Waiting for workers">
{#snippet tooltip()}
Jobs waiting for a worker being available to be executed
{/snippet}
<div
class={queue_count && ($queue_count ?? 0) > 0
? 'bg-yellow-500 text-white rounded-full w-6 h-6 flex center-center'
: ''}>{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}</div
>
<div class="truncate text-2xs !text-secondary mt-0.5">
<Button size="xs2" color="light" on:click={() => dispatch('jobs_suspended')}>{#if success == 'waiting'}<FilterX size={12}></FilterX>{:else}<ListFilter size={12}></ListFilter>{/if}</Button>
<Button size="xs2" color="light" on:click={() => onJobsWaiting?.()}>
{#if success == 'waiting'}
<div class="flex flex-row items-center gap-1">
Reset filter
<X size={12} />
</div>
{:else}
<ListFilterPlus size={14} />
{/if}
</Button>
</div>
</div>
{/if}
</RunOption>
{/snippet}
{#snippet suspendedContent()}
{#if suspended_count && ($suspended_count ?? 0) > 0}
<RunOption label="Suspended">
{#snippet tooltip()}
Jobs waiting for an event or approval before being resumed
{/snippet}
<div
class={suspended_count && ($suspended_count ?? 0) > 0
? 'bg-surface-secondary-inverse text-primary-inverse rounded-full w-6 h-6 flex center-center'
: ''}>{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}</div
>
<div class="truncate text-2xs !text-secondary">
<Button size="xs2" color="light" on:click={() => onJobsSuspended?.()}>
{#if success == 'suspended'}
<div class="flex flex-row items-center gap-1">
Reset filter
<X size={12} />
</div>
{:else}
<ListFilterPlus size={14} />
{/if}
</Button>
</div>
</RunOption>
{/if}
{/snippet}
@@ -1,7 +1,4 @@
<script lang="ts">
import { createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
import type { Job } from '$lib/gen'
import RunRow from './RunRow.svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
@@ -10,9 +7,8 @@
import { AlertTriangle } from 'lucide-svelte'
import Popover from '../Popover.svelte'
import { workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { isJobSelectable } from '$lib/utils'
import type { RunsSelectionMode } from './RunsBatchActionsDropdown.svelte'
import './runs-grid.css'
interface Props {
//import InfiniteLoading from 'svelte-infinite-loading'
@@ -138,25 +134,6 @@
}
*/
let selectableJobCount = $derived.by(() => {
if (!selectionMode) return 0
return jobs?.filter(isJobSelectable(selectionMode)).length ?? 0
})
let allSelected = $derived.by(() => {
return selectionMode && selectedIds.length === selectableJobCount
})
function selectAll() {
if (!selectionMode) return
if (allSelected) {
allSelected = false
selectedIds = []
} else {
allSelected = true
selectedIds = jobs?.filter(isJobSelectable(selectionMode)).map((j) => j.id) ?? []
}
}
function jobCountString(jobCount: number | undefined, lastFetchWentToEnd: boolean): string {
if (jobCount === undefined) {
return ''
@@ -213,64 +190,67 @@
}
return nstickyIndices
})
const showTag = $derived(containerWidth > 700)
</script>
<svelte:window onresize={() => computeHeight()} />
<div
class="divide-y min-w-[640px] h-full"
class="divide-y h-full border min-w-[650px]"
id="runs-table-wrapper"
bind:clientWidth={containerWidth}
>
<div bind:clientHeight={headerHeight}>
{#if selectionMode && selectableJobCount}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'hover:bg-surface-hover bg-surface-primary cursor-pointer',
allSelected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'flex flex-row items-center sticky w-full p-2 pr-4 top-0 font-semibold border-t text-sm'
)}
onclick={selectAll}
>
<div class="px-2">
<input onfocus={bubble('focus')} type="checkbox" checked={allSelected} />
</div>
Select all
</div>
{/if}
<div class="flex flex-row bg-surface-secondary sticky top-0 w-full p-2 pr-4">
{#if showExternalJobs && externalJobs.length > 0}
<div class="w-1/12 text-2xs">
<div
class="grid bg-surface-secondary sticky top-0 w-full py-2 pr-4"
class:grid-runs-table={!containsLabel && !selectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !selectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && selectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && selectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !selectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !selectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && selectionMode && !showTag}
class:grid-runs-table-with-labels-selection-no-tag={containsLabel &&
selectionMode &&
!showTag}
>
{#if selectionMode}
<div class="text-xs font-semibold pl-4"></div>
{/if}
<div class="text-2xs px-2 flex flex-row items-center gap-2">
{#if showExternalJobs && externalJobs.length > 0}
<div class="flex flex-row">
{jobs
? jobCountString(jobs.length + externalJobs.length, lastFetchWentToEnd)
: ''}<Tooltip>{externalJobs.length} jobs obscured</Tooltip>
</div>
</div>
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
<div class="w-1/12 text-2xs flex flex-row">
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
<div class="flex flex-row">
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
<Popover>
<AlertTriangle size={16} class="ml-0.5 text-yellow-500" />
{#snippet text()}
Too specific filtering may have caused the omission of obscured jobs. This is done
for security reasons. To see obscured jobs, try removing some filters.
{/snippet}
</Popover>
</div>
{:else}
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
<Popover>
<AlertTriangle size={16} class="ml-0.5 text-yellow-500" />
{#snippet text()}
Too specific filtering may have caused the omission of obscured jobs. This is done for
security reasons. To see obscured jobs, try removing some filters.
{/snippet}
</Popover>
</div>
{:else}
<div class="w-1/12 text-2xs"
>{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}</div
>
{/if}
<div class="w-4/12 text-xs font-semibold"></div>
<div class="w-4/12 text-xs font-semibold">Path</div>
{/if}
</div>
<div class="text-xs font-semibold">Started</div>
<div class="text-xs font-semibold">Duration</div>
<div class="text-xs font-semibold">Path</div>
{#if containsLabel}
<div class="w-3/12 text-xs font-semibold">Label</div>
<div class="text-xs font-semibold">Label</div>
{/if}
<div class="w-3/12 text-xs font-semibold">Triggered by</div>
<div class="text-xs font-semibold">Triggered by</div>
{#if showTag}
<div class="text-xs font-semibold">Tag</div>
{/if}
<div class=""></div>
</div>
</div>
{#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
@@ -294,13 +274,16 @@
{#if jobOrDate}
{#if jobOrDate?.type === 'date'}
<div class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-5">
<div
class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-2 h-[42px] flex items-center"
>
{jobOrDate.date}
</div>
{:else}
<div class="flex flex-row items-center h-full w-full">
<RunRow
{containsLabel}
{showTag}
job={jobOrDate.job}
selected={jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
{selectionMode}
@@ -314,9 +297,18 @@
selectedIds = selectedIds
}
} else {
selectedWorkspace = jobOrDate.job.workspace_id
selectedIds = [jobOrDate.job.id]
dispatch('select')
if (
JSON.stringify(selectedIds) !== JSON.stringify([jobOrDate.job.id]) ||
selectedWorkspace !== jobOrDate.job.workspace_id
) {
selectedWorkspace = jobOrDate.job.workspace_id
selectedIds = [jobOrDate.job.id]
dispatch('select')
} else {
selectedIds = []
selectedWorkspace = undefined
dispatch('select')
}
}
}}
{activeLabel}
@@ -0,0 +1,95 @@
/* Shared CSS Grid Styles for RunsTable and RunRow components */
/* Grid layouts with tag column */
.grid-runs-table {
grid-template-columns:
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(200px, 2.3fr) /* Path (~33% reduced for wider tag) */
minmax(150px, 1.8fr) /* Triggered by (~28% reduced for wider tag) */
minmax(70px, 0.5fr) /* Tag (~7% wider) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-with-labels {
grid-template-columns:
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(150px, 1.6fr) /* Path (~25% reduced for labels and wider tag) */
minmax(120px, 1.3fr) /* Labels (~22% reduced for wider tag) */
minmax(110px, 1.3fr) /* Triggered by (~22% reduced for wider tag) */
minmax(70px, 0.5fr) /* Tag (~7% wider) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-selection {
grid-template-columns:
50px /* Selection checkbox (fixed width) */
80px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(200px, 2.3fr) /* Path (~33% reduced for wider tag) */
minmax(150px, 1.8fr) /* Triggered by (~28% reduced for wider tag) */
minmax(70px, 0.5fr) /* Tag (~7% wider) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-with-labels-selection {
grid-template-columns:
50px /* Selection checkbox (fixed width) */
80px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(150px, 1.6fr) /* Path (~25% reduced for labels and wider tag) */
minmax(120px, 1.3fr) /* Labels (~22% reduced for wider tag) */
minmax(110px, 1.3fr) /* Triggered by (~22% reduced for wider tag) */
minmax(70px, 0.5fr) /* Tag (~7% wider) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
/* Grid layouts without tag column */
.grid-runs-table-no-tag {
grid-template-columns:
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(200px, 2.8fr) /* Path (~38% expanded without tag) */
minmax(150px, 2.2fr) /* Triggered by (~33% expanded without tag) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-with-labels-no-tag {
grid-template-columns:
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(150px, 1.8fr) /* Path (~30% expanded without tag) */
minmax(120px, 1.8fr) /* Labels (~30% expanded without tag) */
minmax(110px, 1.8fr) /* Triggered by (~30% expanded without tag) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-selection-no-tag {
grid-template-columns:
40px /* Selection checkbox (fixed width) */
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(200px, 2.8fr) /* Path (~38% expanded without tag) */
minmax(150px, 2.2fr) /* Triggered by (~33% expanded without tag) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-with-labels-selection-no-tag {
grid-template-columns:
50px /* Selection checkbox (fixed width) */
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(150px, 1.8fr) /* Path (~30% expanded without tag) */
minmax(120px, 1.8fr) /* Labels (~30% expanded without tag) */
minmax(110px, 1.8fr) /* Triggered by (~30% expanded without tag) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
@@ -1,15 +1,15 @@
<script lang="ts">
import { type Schema, modalToSchema, type ModalSchemaProperty } from '$lib/common'
import { emptySchema, sendUserToast } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import AddPropertyFormV2 from './AddPropertyFormV2.svelte'
interface Props {
schema?: Schema | any
trigger?: import('svelte').Snippet
noPopover?: boolean
onAddNew?: (argName: string) => void
}
let { schema = $bindable(emptySchema()), trigger, noPopover }: Props = $props()
let { schema = $bindable(emptySchema()), trigger, noPopover, onAddNew }: Props = $props()
export const DEFAULT_PROPERTY: ModalSchemaProperty = {
selectedType: 'string',
@@ -18,8 +18,6 @@
required: false
}
const dispatch = createEventDispatcher()
if (!schema) {
schema = emptySchema()
}
@@ -107,7 +105,6 @@
editing = false
oldArgName = undefined
schema = $state.snapshot(newSchema)
dispatch('change', schema)
}
if (argError !== '') {
@@ -139,14 +136,11 @@
if (modifiedObject.order) {
modifiedObject.order = modifiedObject.order.filter((arg) => arg !== argName)
}
dispatch('change', schema)
} else {
throw Error('Argument not found!')
}
syncOrders(modifiedObject)
schema = $state.snapshot(modifiedObject)
dispatch('change', schema)
} catch (err) {
sendUserToast(`Could not delete argument: ${err}`, true)
}
@@ -164,7 +158,7 @@
selectedType: 'string',
name: e.detail.name
})
dispatch('addNew', e.detail.name)
onAddNew?.(e.detail.name)
} catch (err) {
sendUserToast(`Could not add argument: ${err}`, true)
}
@@ -7,7 +7,7 @@
import AddProperty from './AddProperty.svelte'
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
import { flip } from 'svelte/animate'
import { emptyString } from '$lib/utils'
import { emptyString, generateRandomString } from '$lib/utils'
import Button from '$lib/components/common/button/Button.svelte'
import { createEventDispatcher, tick } from 'svelte'
import Label from '../Label.svelte'
@@ -50,11 +50,16 @@
interface Props {
schema: Schema | undefined | any
parentId?: string | undefined
jsonView?: boolean
hiddenArgs?: string[]
onClose?: () => void
}
let { schema = $bindable(), parentId = undefined, jsonView = $bindable(false) }: Props = $props()
let {
schema = $bindable(),
jsonView = $bindable(false),
hiddenArgs = undefined
}: Props = $props()
// let schema = $state(structuredClone($state.snapshot(schema)))
@@ -63,17 +68,32 @@
let error: string | undefined = $state(undefined)
let items = $derived([
...new Set(
(schema?.order ?? Object.keys(schema?.properties ?? {}))?.map((item, index) => {
return { value: item, id: item }
}) ?? []
(schema?.order ?? Object.keys(schema?.properties ?? {}))
?.map((item, index) => {
return { value: item, id: item }
})
.filter((item) => !hiddenArgs?.includes(item.value)) ?? []
)
]) as Array<{
value: string
id: string
}>
const rnd = generateRandomString()
</script>
<div class="flex flex-col items-end mb-2 w-full">
<div class="flex flex-wrap justify-between mb-2 w-full items-center gap-y-2">
<AddProperty
on:change={() => {
if (jsonView) {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
}
}}
bind:schema
bind:this={addPropertyComponent}
/>
<Toggle
bind:checked={jsonView}
label="JSON View"
@@ -91,136 +111,116 @@
/>
</div>
<AddProperty
on:change={() => {
if (jsonView) {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
}
}}
bind:schema
bind:this={addPropertyComponent}
/>
{#if !jsonView}
<div
use:dragHandleZone={{
items,
flipDurationMs,
dropTargetStyle: {},
type: parentId ? `app-editor-fields-${parentId}` : 'app-editor-fields'
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
class="gap-1 flex flex-col mt-2"
>
{#if items?.length > 0}
{#each items as item (item.id)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
animate:flip={{ duration: 200 }}
class="w-full flex flex-col justify-between border items-center py-1 px-2 rounded-md bg-surface text-sm"
>
{#if schema.properties?.[item.value]}
<div class="flex flex-row justify-between items-center w-full">
{`${item.value}${
schema.properties?.[item.value]?.title
? ` (title: ${schema.properties?.[item.value]?.title})`
: ''
} `}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex flex-row gap-1 item-center h-full justify-center">
<Button
iconOnly
size="xs2"
color="light"
startIcon={{ icon: Trash }}
on:click={() => {
addPropertyComponent?.handleDeleteArgument([item.value])
}}
/>
<Button
iconOnly
size="xs2"
color="light"
startIcon={{ icon: Pen }}
on:click={() => {
schemaFormDrawer?.openDrawer()
{#key rnd}
<div
use:dragHandleZone={{
items,
flipDurationMs,
dropTargetStyle: {},
type: rnd
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
class="gap-1 flex flex-col mt-2"
>
{#if items?.length > 0}
{#each items as item (item.id)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
animate:flip={{ duration: 200 }}
class="w-full flex flex-col justify-between border items-center py-1 px-2 rounded-md bg-surface text-sm"
>
{#if schema.properties?.[item.value]}
<div class="flex flex-row justify-between items-center w-full">
{`${item.value}${
schema.properties?.[item.value]?.title
? ` (title: ${schema.properties?.[item.value]?.title})`
: ''
} `}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex flex-row gap-1 item-center h-full justify-center">
<Button
iconOnly
size="xs2"
color="light"
startIcon={{ icon: Trash }}
on:click={() => {
addPropertyComponent?.handleDeleteArgument([item.value])
}}
/>
<Button
iconOnly
size="xs2"
color="light"
startIcon={{ icon: Pen }}
on:click={() => {
schemaFormDrawer?.openDrawer()
tick().then(() => {
editableSchemaForm?.openField(item.value)
})
}}
/>
tick().then(() => {
editableSchemaForm?.openField(item.value)
})
}}
/>
<div class="cursor-move flex items-center handle" use:dragHandle>
<GripVertical size={16} />
<div class="flex items-center handle" use:dragHandle>
<GripVertical size={16} />
</div>
</div>
</div>
</div>
{#if schema.properties[item.value]?.type === 'object' && !(schema.properties[item.value].oneOf && schema.properties[item.value].oneOf.length >= 2)}
<div class="flex flex-col w-full mt-2">
<Label label="Nested properties">
<EditableSchemaDrawer
on:change={() => {
schema = $state.snapshot(schema)
dispatch('change', schema)
}}
bind:schema={schema.properties[item.value]}
parentId={item.value}
/>
</Label>
</div>
{#if schema.properties[item.value]?.type === 'object' && !(schema.properties[item.value].oneOf && schema.properties[item.value].oneOf.length >= 2)}
<div class="flex flex-col w-full mt-2">
<Label label="Nested properties">
<EditableSchemaDrawer bind:schema={schema.properties[item.value]} />
</Label>
</div>
{/if}
{:else}
<div class="text-tertiary"> Value is undefined </div>
{/if}
{:else}
<div class="text-tertiary"> Value is undefined </div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
{/each}
{/if}
</div>
{/key}
<Drawer bind:this={schemaFormDrawer} size="1200px">
<DrawerContent title="UI Customisation" on:close={schemaFormDrawer.closeDrawer}>
<EditableSchemaForm
on:change={(e) => {
schema = $state.snapshot(schema)
dispatch('change', schema)
}}
bind:this={editableSchemaForm}
bind:schema
isAppInput
on:edit={(e) => {
addPropertyComponent?.openDrawer(e.detail)
}}
on:delete={(e) => {
addPropertyComponent?.handleDeleteArgument([e.detail])
}}
dndType="drawer"
editTab="inputEditor"
>
{#snippet addProperty()}
<AddPropertyV2
bind:schema
on:change
on:addNew={(e) => {
schema = $state.snapshot(schema)
editableSchemaForm?.openField(e.detail)
}}
>
{#snippet trigger()}
<div
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
>
<Plus size={14} />
</div>
{/snippet}
</AddPropertyV2>
{/snippet}
</EditableSchemaForm>
</DrawerContent>
{#snippet children()}
<DrawerContent title="UI Customisation" on:close={() => schemaFormDrawer?.closeDrawer()}>
<EditableSchemaForm
schemaFormClassName="h-full"
bind:this={editableSchemaForm}
bind:schema
isAppInput
on:edit={(e) => {
addPropertyComponent?.openDrawer(e.detail)
}}
on:delete={(e) => {
addPropertyComponent?.handleDeleteArgument([e.detail])
}}
{hiddenArgs}
editTab="inputEditor"
>
{#snippet addProperty()}
<AddPropertyV2
bind:schema
onAddNew={(argName) => {
editableSchemaForm?.openField(argName)
}}
>
{#snippet trigger()}
<div
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
>
<Plus size={14} />
</div>
{/snippet}
</AddPropertyV2>
{/snippet}
</EditableSchemaForm>
</DrawerContent>
{/snippet}
</Drawer>
{:else}
<div class="mt-2"></div>
@@ -1,10 +1,28 @@
<script lang="ts">
import { readFieldsRecursively } from '$lib/utils'
import { deepEqual } from 'fast-equals'
import type { EditableSchemaWrapperProps } from './editable_schema_wrapper'
import EditableSchemaWrapper from './EditableSchemaWrapper.svelte'
let { schema: oldSchema, ...props }: EditableSchemaWrapperProps = $props()
let {
schema: oldSchema,
onSchemaChange,
...props
}: EditableSchemaWrapperProps & { onSchemaChange?: (schema: any) => void } = $props()
let schema = $state(oldSchema)
let lastSchema = $state.snapshot(schema)
$effect(() => {
if (onSchemaChange) {
readFieldsRecursively(schema)
let newSchema = $state.snapshot(schema)
if (!deepEqual(lastSchema, newSchema)) {
lastSchema = newSchema
onSchemaChange(newSchema)
}
}
})
</script>
<EditableSchemaWrapper {schema} {...props} />
<EditableSchemaWrapper bind:schema {...props} />
@@ -16,7 +16,6 @@
noPreview = false,
fullHeight = true,
formatExtension = $bindable(undefined),
onSchemaChange,
customUi
}: EditableSchemaWrapperProps = $props()
@@ -51,7 +50,6 @@
}
}
}
onSchemaChange?.({ schema: $state.snapshot(schema) })
}
let suggestedFileExtensions = $state([
@@ -81,9 +79,8 @@
noPopover={customUi?.noAddPopover}
bind:schema
bind:this={addPropertyComponent}
on:change={() => onSchemaChange?.({ schema: $state.snapshot(schema) })}
on:addNew={(e) => {
editableSchemaForm?.openField(e.detail)
onAddNew={(argName) => {
editableSchemaForm?.openField(argName)
}}
>
{#snippet trigger()}
@@ -99,11 +96,7 @@
onlyMaskPassword
bind:this={editableSchemaForm}
bind:schema
on:change={() => onSchemaChange?.({ schema: $state.snapshot(schema) })}
isFlowInput
on:edit={(e) => {
addPropertyComponent?.openDrawer(e.detail)
}}
on:delete={(e) => {
addPropertyComponent?.handleDeleteArgument([e.detail])
}}
@@ -117,7 +110,6 @@
noPopover={customUi?.noAddPopover}
bind:schema
bind:this={addPropertyComponent}
on:change={() => onSchemaChange?.({ schema })}
>
{#snippet trigger()}
<div
@@ -7,9 +7,7 @@
import type VariableEditor from '../VariableEditor.svelte'
import { createEventDispatcher } from 'svelte'
import ArgInput from '../ArgInput.svelte'
import ObjectTypeNarrowing from '../ObjectTypeNarrowing.svelte'
import Tabs from '../common/tabs/Tabs.svelte'
import { Tab, TabContent } from '../common'
import ResourceNarrowing from '../ResourceNarrowing.svelte'
import EditableSchemaDrawer from './EditableSchemaDrawer.svelte'
import type { SchemaProperty } from '$lib/common'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -18,7 +16,6 @@
import { Pen, Plus, Trash2 } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import Tooltip from '../Tooltip.svelte'
interface Props {
format?: string | undefined
@@ -49,6 +46,7 @@
order?: string[] | undefined
requiredProperty?: string[] | undefined
displayWebhookWarning?: boolean
onDrawerClose?: () => void
}
let {
@@ -72,10 +70,11 @@
properties = $bindable(undefined),
order = $bindable(undefined),
requiredProperty = $bindable(undefined),
displayWebhookWarning = true
displayWebhookWarning = true,
onDrawerClose = undefined
}: Props = $props()
let oneOfSelected: string | undefined = $state(undefined)
let oneOfSelected: string | undefined = $state(oneOf?.[0]?.title)
const dispatch = createEventDispatcher()
@@ -139,254 +138,215 @@
)
</script>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 mt-2">
{#if type === 'object' && oneOf && oneOf.length >= 2}
<div class="flex flex-row gap-1 items-center justify-start">
<ToggleButtonGroup
bind:selected={oneOfSelected}
class="h-auto w-auto"
tabListClass="flex-wrap"
>
{#snippet children({ item })}
{#each oneOf ?? [] as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
<Label label="OneOf properties">
<div class="flex flex-row gap-1 items-center justify-start pt-2">
<ToggleButtonGroup
bind:selected={oneOfSelected}
class="h-auto w-auto"
tabListClass="flex-wrap"
>
{#snippet children({ item })}
{#each oneOf ?? [] as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
<Popover placement="bottom-end" closeButton>
{#snippet trigger()}
<Button size="xs2" color="light" nonCaptureEvent startIcon={{ icon: Plus }} />
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
<Popover placement="bottom-end" closeButton>
{#snippet trigger()}
<Button size="xs2" color="light" nonCaptureEvent startIcon={{ icon: Plus }} />
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
createVariant(variantName)
close()
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
createVariant(variantName)
close()
}}
disabled={variantName.length === 0}
>
Add
</Button>
</Label>
{/snippet}
</Popover>
</div>
<div class="flex flex-row gap-2 items-center ml-1 mt-4 mb-2">
<span class="font-semibold text-sm">{oneOfSelected}</span>
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
closeButton
>
{#snippet trigger()}
<Button
size="xs2"
color="light"
startIcon={{ icon: Pen }}
propagateEvent
iconOnly={false}
on:click={() => {
if (oneOfSelected) {
variantName = oneOfSelected
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
createVariant(variantName)
close()
}}
disabled={variantName.length === 0}
>
Add
</Button>
</Label>
{/snippet}
</Popover>
</div>
<div class="flex flex-row gap-2 items-center">
<span class="font-semibold text-sm">{oneOfSelected}</span>
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
closeButton
>
{#snippet trigger()}
<Button
size="xs2"
color="light"
startIcon={{ icon: Pen }}
propagateEvent
iconOnly={false}
on:click={() => {
if (oneOfSelected) {
variantName = oneOfSelected
}
}}
/>
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close()
}
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close()
}
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close()
}
}}
disabled={variantName.length === 0}
>
Rename
</Button>
</Label>
{/snippet}
</Popover>
<Button
size="xs2"
color="red"
startIcon={{ icon: Trash2 }}
iconOnly
disabled={(oneOf?.length ?? 0) <= 2}
on:click={() => {
if (oneOf && oneOfSelected) {
const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)
oneOf = oneOf.filter((_, i) => i !== idx)
oneOfSelected = oneOf[0].title
}
}}
/>
</div>
{#if oneOfSelected && oneOf}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<EditableSchemaDrawer
bind:schema={
() => {
if (oneOf?.[idx]) {
let properties = Object.fromEntries(
Object.entries(oneOf[idx].properties ?? {}).filter(
([k]) => k !== 'label' && k !== 'kind'
)
)
return {
...oneOf[idx],
properties: properties,
order: Object.keys(properties),
required: oneOf[idx].required ?? []
}
}}
disabled={variantName.length === 0}
>
Rename
</Button>
</Label>
{/snippet}
</Popover>
<Button
size="xs2"
color="red"
startIcon={{ icon: Trash2 }}
iconOnly
disabled={(oneOf?.length ?? 0) <= 2}
on:click={() => {
if (oneOf && oneOfSelected) {
const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)
oneOf = oneOf.filter((_, i) => i !== idx)
oneOfSelected = oneOf[0].title
}
},
(v) => {
if (oneOf?.[idx]) {
const tagKey = oneOf?.find((o) => Object.keys(o.properties ?? {}).includes('kind'))
}}
/>
</div>
{#if oneOfSelected && oneOf}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<div class="ml-1">
<EditableSchemaDrawer
onClose={() => {
onDrawerClose?.()
}}
bind:schema={oneOf[idx]}
hiddenArgs={[
oneOf?.find((o) => Object.keys(o.properties ?? {}).includes('kind'))
? 'kind'
: 'label'
oneOf[idx] = {
...(v ?? {}),
type: 'object',
properties: {
...(v?.properties ?? {}),
[tagKey]: {
type: 'string',
enum: [v?.title ?? '']
}
]}
/>
</div>
{/if}
</Label>
<div class="py-2"></div>
{:else if type === 'object' && format?.startsWith('resource-') && format !== 'resource-s3_object' && !isDynSelect}
<ResourceNarrowing bind:format />
{:else if type === 'object' && !format?.startsWith('resource-') && !isDynSelect}
<div class="py-2">
<Label label="Object properties">
<ToggleButtonGroup
bind:selected={customObjectSelected}
class="my-2"
on:selected={(e) => {
if (e.detail === 'editor') {
format = undefined
} else {
properties = undefined
order = undefined
requiredProperty = undefined
}
}}
>
{#snippet children({ item })}
<ToggleButton value="editor" label="Custom" {item} />
<ToggleButton
value="json-schema-resource"
label="Template"
{item}
tooltip="Select a JSON schema resource to specify the object's properties"
showTooltipIcon
/>
{/snippet}
</ToggleButtonGroup>
{#if customObjectSelected === 'editor'}
<EditableSchemaDrawer
bind:schema={
() => {
return {
properties: properties,
order: order,
required: requiredProperty
}
},
(v) => {
properties = v.properties
order = v.order
requiredProperty = v.required
}
}
/>
{:else if customObjectSelected === 'json-schema-resource'}
{#if format == undefined}
<div class="text-xs text-tertiary my-1">
Select a <code>json_schema</code> resource as a reusable JSON schema template
</div>
{/if}
<ResourcePicker
resourceType="json_schema"
bind:value={
() => {
if (format?.startsWith('jsonschema-')) {
return format.substring('jsonschema-'.length)
}
return undefined
},
(v) => {
if (v) {
format = 'jsonschema-' + v
} else {
format = undefined
}
}
}
}
}
on:change={() => {
dispatch('schemaChange')
}}
/>
{/if}
{:else if type === 'object' && format !== 'resource-s3_object' && !isDynSelect}
<Tabs
bind:selected={initialObjectSelected}
on:selected={(e) => {
if (e.detail === 'json-schema') {
format = 'json-schema'
} else {
format = ''
}
}}
>
<Tab value="resource">Resource</Tab>
<Tab value="custom-object">Custom Object</Tab>
<Tab value="json-schema">
JSON Schema
<Tooltip>
This displays a JSON schema editor, useful when a JSON schema input is expected.
</Tooltip>
</Tab>
{#snippet content()}
<div class="pt-2">
<TabContent value="custom-object">
<ToggleButtonGroup
bind:selected={customObjectSelected}
class="mb-2"
on:selected={(e) => {
if (e.detail === 'editor') {
format = undefined
} else {
properties = undefined
order = undefined
requiredProperty = undefined
}
}}
>
{#snippet children({ item })}
<ToggleButton value="editor" label="Editor" {item} />
<ToggleButton
value="json-schema-resource"
label="JSON Schema Resource"
{item}
tooltip="Select a JSON schema resource to specify the object's properties"
showTooltipIcon
/>
{/snippet}
</ToggleButtonGroup>
{#if customObjectSelected === 'editor'}
<EditableSchemaDrawer
bind:schema={
() => {
return {
properties: properties,
order: order,
required: requiredProperty
}
},
(v) => {
properties = v.properties
order = v.order
requiredProperty = v.required
dispatch('schemaChange')
}
}
/>
{:else if customObjectSelected === 'json-schema-resource'}
<ResourcePicker
resourceType="json_schema"
bind:value={
() => {
if (format?.startsWith('jsonschema-')) {
return format.substring('jsonschema-'.length)
}
return undefined
},
(v) => {
format = 'jsonschema-' + v
}
}
/>
{/if}
</TabContent>
<TabContent value="resource">
<ObjectTypeNarrowing on:change={() => dispatch('schemaChange')} bind:format />
</TabContent>
</div>
{/snippet}
</Tabs>
/>
{/if}
</Label>
</div>
{/if}
{#if !(type === 'object' && oneOf && oneOf.length >= 2) && !(type == 'object' && initialObjectSelected == 'custom-object') && !isDynSelect}
@@ -217,7 +217,7 @@
bind:itemsType
canEditResourceType={isFlowInput || isAppInput}
/>
{:else if type == 'string' || ['number', 'integer', 'object'].includes(type ?? '')}
{:else if type == 'string' || ['number', 'integer'].includes(type ?? '')}
<div>
<Label label="Field settings">
<div>
@@ -244,44 +244,42 @@
bind:currency={extra['currency']}
bind:currencyLocale={extra['currencyLocale']}
/>
{:else if type == 'object' && oneOf && oneOf.length >= 2 && !isFlowInput && !isAppInput}
<ToggleButtonGroup bind:selected={oneOfSelected} class="mb-2">
{#snippet children({ item })}
{#each oneOf as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
{#if oneOfSelected && oneOfSchemas}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<div class="border">
<EditableSchemaForm
on:change
noPreview
bind:schema={oneOfSchemas[idx]}
uiOnly
jsonEnabled={false}
editTab="inputEditor"
/>
</div>
{/if}
{:else if type == 'object' && !format?.startsWith('resource-') && !isFlowInput && !isAppInput}
<div class="border">
<EditableSchemaForm
on:change
noPreview
bind:schema
uiOnly
jsonEnabled={false}
editTab="inputEditor"
/>
</div>
{:else}
<div class="text-tertiary text-xs">No settings available for this field type</div>
{/if}
</div>
</Label>
</div>
{:else if type == 'object' && oneOf && oneOf.length >= 2 && !isFlowInput && !isAppInput}
<ToggleButtonGroup bind:selected={oneOfSelected} class="mb-2">
{#snippet children({ item })}
{#each oneOf as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
{#if oneOfSelected && oneOfSchemas}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<div class="border">
<EditableSchemaForm
on:change
noPreview
bind:schema={oneOfSchemas[idx]}
uiOnly
jsonEnabled={false}
editTab="inputEditor"
/>
</div>
{/if}
{:else if type == 'object' && !format?.startsWith('resource-') && !isFlowInput && !isAppInput}
<div class="border">
<EditableSchemaForm
on:change
noPreview
bind:schema
uiOnly
jsonEnabled={false}
editTab="inputEditor"
/>
</div>
{/if}
{@render children?.()}
@@ -6,9 +6,8 @@
import type { Schema } from '$lib/common'
import { deepEqual } from 'fast-equals'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils.svelte'
import type { DynamicSelect } from '$lib/utils'
import { generateRandomString, type DynamicSelect } from '$lib/utils'
interface Props {
dndType?: string | undefined
schema: Schema
args?: Record<string, any>
prettifyHeader?: boolean
@@ -24,10 +23,11 @@
isValid?: boolean
noVariablePicker?: boolean
helperScript?: DynamicSelect.HelperScript
className?: string
dndType?: string
}
let {
dndType = undefined,
schema = $bindable(),
args = $bindable(undefined),
prettifyHeader = false,
@@ -42,7 +42,9 @@
diff = {},
nestedClasses = '',
isValid = $bindable(true),
noVariablePicker = false
noVariablePicker = false,
className = '',
dndType = generateRandomString()
}: Props = $props()
$effect.pre(() => {
@@ -55,14 +57,16 @@
let items = $state(computeItems())
let dragDisabled = $state(true)
let dragDisabledState = $state(true)
function computeItems() {
return (
(schema?.order ?? Object.keys(schema?.properties ?? {}) ?? []).map((key) => ({
id: key,
value: key
})) ?? []
($state.snapshot(schema?.order) ?? Object.keys(schema?.properties ?? {}) ?? []).map(
(key) => ({
id: key,
value: key
})
) ?? []
)
}
@@ -74,23 +78,20 @@
}
function handleConsider(e) {
dragDisabled = false
dragDisabledState = false
const { items: newItems } = e.detail
items = newItems
items = $state.snapshot(newItems)
}
function handleFinalize(e) {
const { items: newItems } = e.detail
dragDisabled = true
items = newItems
dragDisabledState = true
items = $state.snapshot(newItems)
const newOrder = items.map((item) => item.value)
// console.log('handleFinalize', newOrder, e.detail)
dispatch('reorder', newOrder)
}
$effect(() => {
schema && dragDisabled && untrack(() => updateItems())
schema && dragDisabledState && untrack(() => updateItems())
})
</script>
@@ -118,6 +119,7 @@
{disablePortal}
{disabled}
{helperScript}
{className}
bind:schema
dndConfig={disableDnd
? undefined
@@ -125,7 +127,7 @@
items,
flipDurationMs,
dropTargetStyle: {},
type: dndType ?? 'top-level'
type: dndType
}}
{items}
{diff}
@@ -9,5 +9,4 @@ export type EditableSchemaWrapperProps = {
customUi?: {
noAddPopover?: boolean
}
onSchemaChange?: ({ schema }: { schema: Schema }) => void
}

Some files were not shown because too many files have changed in this diff Show More