feat: Batch re-run (#5553)

* runs on svelte 5

* Line component from svelte-chartjs

* Replaced all svelte-chartjs occurrences with custom wrapper

* Fix props mistake

* Fix illegal table structures

* self-closing-tags fix

* aria labels

* Fixed trivial warnings and errors

* @tanstack/svelte-table fix

* upgrade to vite 6

* svelte-kit sync before running svelte-check

* Remove on:clear which is actually on:removeAll and already handled by on:change

* fix worker tags not displaying in Autoscaling

* Try to fix svelte-kit sync not working during CI

* remove warnings

* Fix add flow page crashing

* access worldStore before assignment fix

* fix infinite recursions in App Editor

* Replaced JSON.stringify with proper deepEqual

* component mount api changed (no longer classes)

* fix ci errors

* Fix infinite loops in background runnable panel

* factored effect on deep equal logic in onObjChange

* fix "Add" not working in AgGrid Table

* Replaced legacy component.$set api

* Fix multiselect infinite value reaction

* Fix flow input fields resetting when opening their edit tab

* fix date input resetting when typing year

* Remove !p-0 affecting subgrid dotted borders

* fix missing debounceTemplate causing hundreds of updates

* Fix AgGrid action refreshes and disppearing

* resolve getItems generating random ids every rerun

* fix cannot access items before init

* fix sort lambda arguments being undefined

* Revert "Remove !p-0 affecting subgrid dotted borders"

This reverts commit c62809bb45d682a48376b071680645ed4e1c601b.

* fix input not updating in decision tree editor

* Update frontend/src/lib/components/schema/EditableSchemaWrapper.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Re-added padding affecting subgrid dotted borders (#5479)

* remove !p-0 in preset components

* removed extra padding on accordion tabs subgrid

* Fix non-reactive SchemaForm

* dirty fix for the oneOf bug

* feat: add nu-lang support (#5217)

* feat: add nu (nushell) support

* add worker tests

* deactivate tables and non-any types below top-level

full support will come in V1
for V0 it's better to keep things minimal and simple

* add syntax highlighting

used python's grammar, since nushell isn't supported by monaco nor svelte-highlights

for V1 nu will get it`s own grammar

* add logo

* partially implement plugin support

* change logo + ability to deploy + nsjail draft

* static variables + get_resource + get_variable

* lsp/dev.nu + initial nu lsp (not working yet)

* make it work with nsjail

* nullguard

* Much more flexible signature parsing and better error-messages

* add init script

* rename nulsp to nu

* install nu to dockerfile

* fix merge

* implement Default for MainArgSignature

* stage NU_CACHE_DIR

* improve dockerfiles

* dev.nu for parser-wasm + flake.nix

* update code for windows

* add nushell to flake

* upload Cargo.lock

* make build.sh work on nixos

* build wasm cli parsers

* add docs to README_DEV.md

* add helper script docker/dev.nu

* improve docker/dev.nu

* fix windows

* commit frontend/package(lock).json

* update cargo.lock

* correctly update cargo.lock

* remove lsp

* update flake.nix to include svelte server and nushell

* Revert base.sql to main

* remove PLUGIN_USE_RE

* make CARGO_PATH private

* add nu to cli

* Change flags to build wasm-nu-parser

* remove flake.nix from parser-wasm

* update wasm-build target

* remove unused import

* add cli support for nu

* update github workflows

* wasm-build 0.17 -> 0.19

* update build script

* update cargo.lock

* Fix typographical error

* update Cargo.lock

* update ENV_SETTINGS

* use published nu parser

* update package.lock

* rewrite parser in tree-sitter

* implement parser from scratch

* polishing

* change init script to match new parser

* fix imports

* fix cli build

* fix cli build

* merge

* update wasm

* use MiniPulledJob

* update cli

* change cli wasm schema

* change cli

* update deno.json

* make wasm modules load lazily

* regenerate parsers

* remove leftover

* update cargo.lock

* clean up dnt.ts

* add docs to cli/test.nu

* add schema validation option

* add Nu to try_validate_schema

* reference frontend to new parser version

* feat: unsafe parameters for sql queries (table names, column names) (#5488)

* Make schema validation struct

Schema Validation rules that are constructed from the schema or from the
MainArgSig(TODO).

* Make other validator builder

* Fail dependency job like with lockfile failing for schema validator

* Add last types + tests

* Remove unused dependency

* fix typos

* Migration ID was colliding with another, changed it manually

* Add Oneof + other fixes

* fix: cache for querying scripts correclty handles ScriptMetadata

* Add cache for schema validation from main arg sig

* Prepare sqlx

* Remove default features

* Feature flags

* WIP: unsafe sql params for sql langauges

* Fix down migration table name

* cleanup: put validation logic inside a function

* Refactor to cache the should_validate boolean

Changed the schemavalidators cache to take in an
Option<SchemaValidator>, effectively storing the `should_validate_schema` information.

Also pass the schema when avaialble to construct the schema validator

* Add other job kinds to u8 cache key just in case

* Change sql languages to all get arguments as Values instead of RawValue

* Only cache if not preview

* Add last sql languages and some CI fixes

* Rename after typo on `sanitized`

* Finish rename

* Remove unused import

* Fix wrong test

* Add newly published regex parser version

* Remove default features from cargo.toml

* Change to a cleaner syntax for the interpolated args

* Update republished parser

* fix win build (#5494)

* add sysinfoapi feature flag for winapi dependency

* add ff

* add ff at the right place

* fix(frontend): use stable path for capture tables + nits (#5495)

* add missing capture move on first time deploy (#5496)

* avoid regen client as build step

* perf: cache workspace env variables to avoid one query (#5499)

* perf: optimize number of queries needed for job run (#5504)

* optPerf

* update sqlx

* update sqlx

* fix: improve cancel for flows with many substeps

* feat: list references upon renaming a script or a flow (#5487)

* Refactored flow_workspace_runnables to more generic workspace_runnable_dependencies

* list flows referencing an item upon renaming it

* Refactor with two exclusive columns to avoid breaking FK constraints

* Show apps depending on item upon renaming

* sqlx prepare

* list-disc instead of •

* on delete and on update cascade

* displayPathChangedWarning oneOf check instead noneOf

* combine migrations + add "on update cascade" to flow fk

* unique index on app dependencies to avoid duplicates

* create new workspace_runnable_dependencies instead of renaming old table

* Add "looking for references" loading msg

* Revert "create new workspace_runnable_dependencies instead of renaming old table"

This reverts commit 015c38ca8f.

* flow_workspace_runnables view for backwards compatibility

* Add warning for script imports on rename

* support import dependency tracking in deno

* number of using scripts / flows / apps tooltip

* forgot sqlx prepare

* delete app-related rows in down migration

* Made selection more generic

* RunsBatchActionsDropdown refactor

* started BatchReRunOptionsPane

* fix overflow quirk

* fetch schema

* refactor to group jobs by (kind, path)

* auto select

* computePropertyMap

* InputTransformForm works

* Pickable properties

* remove PropPickerWrapper and make it optional in InputTransformForm

* hide help btn

* available expressions info alert

* extraLib for editor linting

* fix selected group not updating

* nit

* Refactor async logic in script tag

* persist changes in state

* correct typing

* count for each (path, kind) group

* support flows

* use dot operator when possible

* count jobs and fix wrong number

* fix selectedJobs recomputing periodically

* (v0) individual api requests to re-run jobs

* move batchReRunChangedArgs state upwards

* Support static arg

* mistake

* Single confirmation modal + removed unnecessary state

* change confirmation modal color

* use runes in confirmation modal

* listSelectedJobsSchema API endpoint

* refactored batch rerun pane for listSelectedJobsSchema

* eliminated selectedJobs

* batch rerun works backend (v0 same args)

* Static input transforms

* simpler list_selected_jobs_schemas sql query with coalesce

* use latest schema UI + refactor

* run latest version in backend

* add deno_core dependency to windmill-api

* stream jobs from db

* basic js evaluation

* sqlx prepare

* add id path and hash in editor lint

* js works with job object!

* moved deno_core logic to separate function

* openapi yaml mistake

* unnecessary bind

* fix date as string

* Stream re-ran uuids

* handle SSE multiple values at once

* don't select all by default on batch action

* nit ui

* check that schema has property backend

* Better JobGroup query + cache

* handle multi type properties

* Notify user on error

* stupid mistake

* Fix warnings and update svelte-exmarkdown for svelte 5

* regen package-lock to fix crash on vite preview

* batch re-run all filtered jobs

* merge schemas to common type

* more explicit tooltips

* changed sse counter ui

* typos

* fix tutorial first part

* nit mistake

* package lock + elipsis nit

* fix: latest_schema option still checked on the job original schema

* always gotta forget sqlx prepare

* fix flashing loading screen

* fix batch re-run select all filtered

* better tooltip

* fix batch actions btn growing on wide screen

* revert disableBatchActions

* fix selectable step jobs

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: pyranota <92104930+pyranota@users.noreply.github.com>
Co-authored-by: wendrul <53628737+wendrul@users.noreply.github.com>
Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com>
Co-authored-by: HugoCasa <hugo@casademont.ch>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Diego Imbert
2025-04-14 16:25:41 +02:00
committed by GitHub
co-authored by ellipsis-dev[bot] pyranota wendrul Alexander Petric HugoCasa Ruben Fiszel
parent 12869e9170
commit a689c0b0f1
24 changed files with 1872 additions and 355 deletions
@@ -0,0 +1,79 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n j.id,\n j.kind AS \"kind: _\",\n COALESCE(s.path, f.path) AS \"script_path!\",\n COALESCE(s.hash, f.id) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n args AS input\n FROM v2_job j\n LEFT JOIN script s ON j.runnable_id = s.hash AND j.kind = 'script'\n LEFT JOIN flow_version f ON j.runnable_id = f.id AND j.runnable_path = f.path AND j.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = j.id\n LEFT JOIN v2_job_queue jq ON jq.id = j.id\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND COALESCE(s.hash, f.id) IS NOT NULL\n AND COALESCE(s.path, f.path) IS NOT NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "kind: _",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlescriptflow",
"flowscript",
"flownode",
"appscript"
]
}
}
}
},
{
"ordinal": 2,
"name": "script_path!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "script_hash!: _",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "scheduled_for!: _",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "input",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
false,
false,
null,
null,
null,
true
]
},
"hash": "3376b42d273c2499f3517c7754f4cdb6eae2e71e56bc8e5215559af967c81bd8"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT jsonb_build_object(\n 'kind', jb.kind,\n 'script_path', jb.runnable_path,\n 'latest_schema', COALESCE(\n (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = jb.runnable_path AND jb.kind = 'script' ORDER BY s.path, s.created_at DESC),\n (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = jb.runnable_path AND jb.kind = 'flow')\n ),\n 'schemas', ARRAY(\n SELECT jsonb_build_object(\n 'script_hash', LPAD(TO_HEX(COALESCE(s.hash, f.id)), 16, '0'),\n 'job_ids', ARRAY_AGG(DISTINCT j.id),\n 'schema', ANY_VALUE(COALESCE(s.schema, f.schema))\n ) FROM v2_job j\n LEFT JOIN script s ON s.hash = j.runnable_id AND j.kind = 'script'\n LEFT JOIN flow_version f ON f.id = j.runnable_id AND j.kind = 'flow'\n WHERE j.id = ANY(ARRAY_AGG(jb.id))\n GROUP BY COALESCE(s.hash, f.id)\n )\n ) FROM v2_job jb\n WHERE (jb.kind = 'flow' OR jb.kind = 'script')\n AND jb.workspace_id = $1 AND jb.id = ANY($2)\n GROUP BY jb.kind, jb.runnable_path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "jsonb_build_object",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": [
null
]
},
"hash": "5ea1d8c87a17690942ca9e70e0b953e3b32a9a7a73784219d48f8e8932cdb0c8"
}
@@ -0,0 +1,85 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n j.id,\n j.kind AS \"kind: _\",\n COALESCE(s.path, f.path) AS \"script_path!\",\n COALESCE(s.hash, f.id) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n args AS input,\n COALESCE(s.schema, f.schema) AS \"schema: _\"\n FROM v2_job j\n LEFT JOIN script s ON j.runnable_id = s.hash AND j.kind = 'script'\n LEFT JOIN flow_version f ON j.runnable_id = f.id AND j.runnable_path = f.path AND j.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = j.id\n LEFT JOIN v2_job_queue jq ON jq.id = j.id\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND COALESCE(s.hash, f.id) IS NOT NULL\n AND COALESCE(s.path, f.path) IS NOT NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "kind: _",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlescriptflow",
"flowscript",
"flownode",
"appscript"
]
}
}
}
},
{
"ordinal": 2,
"name": "script_path!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "script_hash!: _",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "scheduled_for!: _",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "input",
"type_info": "Jsonb"
},
{
"ordinal": 6,
"name": "schema: _",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
false,
false,
null,
null,
null,
true,
null
]
},
"hash": "ad88831c9021b79c9037a925c2b53be3a93dbe2a67bdc4d4342c84f439b5eaf9"
}
@@ -0,0 +1,73 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n j.kind AS \"kind!: JobKind\",\n j.runnable_id AS \"script_hash: _\",\n j.runnable_path AS script_path,\n COUNT(*) AS \"count!\",\n ANY_VALUE(COALESCE(f.schema, s.schema)) AS schema\n FROM v2_job j\n LEFT JOIN script s ON s.hash = j.runnable_id AND j.kind = 'script'\n LEFT JOIN flow_version f ON f.id = j.runnable_id AND f.path = j.runnable_path AND j.kind = 'flow'\n WHERE COALESCE(s.hash, f.id) IS NOT NULL\n AND COALESCE(s.path, f.path) IS NOT NULL\n AND j.workspace_id = $1 AND j.id = ANY($2)\n GROUP BY j.runnable_id, j.runnable_path, j.kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlescriptflow",
"flowscript",
"flownode",
"appscript"
]
}
}
}
},
{
"ordinal": 1,
"name": "script_hash: _",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "count!",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "schema",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": [
false,
true,
true,
null,
null
]
},
"hash": "b61a00e6a8ca0a1d24e64fdc9223b077c957da8455ea747b0680923d8282425b"
}
@@ -0,0 +1,73 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT ON (j.runnable_path, j.kind) \n j.kind AS \"kind!: JobKind\",\n j.runnable_path AS script_path,\n NULL as \"script_hash: _\",\n -1::bigint as \"count!: _\",\n COALESCE(f.schema, s.schema) AS schema\n FROM v2_job j\n LEFT JOIN script s ON s.path = j.runnable_path AND j.kind = 'script'\n LEFT JOIN flow_version f ON f.path = j.runnable_path AND j.kind = 'flow'\n WHERE COALESCE(s.hash, f.id) IS NOT NULL\n AND j.workspace_id = $1 AND j.id = ANY($2)\n ORDER BY j.runnable_path, j.kind, COALESCE(f.created_at, s.created_at) DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlescriptflow",
"flowscript",
"flownode",
"appscript"
]
}
}
}
},
{
"ordinal": 1,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "script_hash: _",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "count!: _",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "schema",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": [
false,
true,
null,
null,
null
]
},
"hash": "c4f382045e5c47986e02f1e57667e6b3cb1d60f15fed9a24e3f05b1bd60c0fc8"
}
@@ -0,0 +1,67 @@
{
"db_name": "PostgreSQL",
"query": "SELECT j.kind AS \"kind: _\", COALESCE(s.path, f.path) AS \"script_path!\", COALESCE(s.hash, f.id) AS \"script_hash!: _\", args\n FROM v2_job j\n LEFT JOIN script s ON j.runnable_id = s.hash AND j.kind = 'script'\n LEFT JOIN flow_version f ON j.runnable_id = f.id AND j.runnable_path = f.path AND j.kind = 'flow'\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND COALESCE(s.hash, f.id) IS NOT NULL\n AND COALESCE(s.path, f.path) IS NOT NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind: _",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlescriptflow",
"flowscript",
"flownode",
"appscript"
]
}
}
}
},
{
"ordinal": 1,
"name": "script_path!",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "script_hash!: _",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "args",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
false,
null,
null,
true
]
},
"hash": "ccd4c7fe5fbdf1ab4bbbbd7d2a9b1ec3fc844ed286ff563f0c9a5c4778cfb718"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(\n (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = jb.runnable_path AND jb.kind = 'script' ORDER BY s.path, s.created_at DESC),\n (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = jb.runnable_path AND jb.kind = 'flow')\n ) FROM v2_job jb\n WHERE jb.id = $1 AND jb.workspace_id = $2\n GROUP BY jb.kind, jb.runnable_path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "coalesce",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "d1fdfabd4f3bbe93cf9667e8e76bd8f537e68fabceb094fe535693fbd146eff2"
}
+4
View File
@@ -13941,6 +13941,7 @@ dependencies = [
"tikv-jemalloc-sys",
"tikv-jemallocator",
"tokio",
"tokio-stream",
"tracing",
"url",
"uuid",
@@ -13983,6 +13984,8 @@ dependencies = [
"cookie 0.17.0",
"cron",
"datafusion",
"deno_core",
"deno_error",
"futures",
"git-version",
"google-cloud-googleapis",
@@ -14034,6 +14037,7 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-postgres 0.7.11",
"tokio-stream",
"tokio-tar",
"tokio-tungstenite",
"tokio-util",
+3 -1
View File
@@ -64,6 +64,7 @@ cloud = ["windmill-queue/cloud", "windmill-worker/cloud", "windmill-common/cloud
jemalloc = ["windmill-common/jemalloc", "dep:tikv-jemallocator", "dep:tikv-jemalloc-sys", "dep:tikv-jemalloc-ctl"]
tantivy = ["dep:windmill-indexer", "windmill-api/tantivy", "windmill-indexer/enterprise", "windmill-indexer/parquet", "windmill-common/tantivy", "enterprise", "parquet"]
sqlx = ["windmill-worker/sqlx"]
deno_core = ["windmill-worker/deno_core", "windmill-api/deno_core", "dep:deno_core", "dep:v8"]
kafka = ["windmill-api/kafka"]
nats = ["windmill-api/nats"]
otel = ["windmill-common/otel", "windmill-worker/otel"]
@@ -82,7 +83,6 @@ static_frontend = ["windmill-api/static_frontend"]
scoped_cache = ["windmill-common/scoped_cache"]
# Languages
python = ["windmill-worker/python"]
deno_core = ["windmill-worker/deno_core", "dep:deno_core", "dep:v8"]
rust = ["windmill-worker/rust"]
mysql = ["windmill-worker/mysql"]
oracledb = ["windmill-worker/oracledb"]
@@ -98,6 +98,7 @@ all_languages = [ "python", "deno_core", "rust", "mysql", "oracledb", "mssql", "
[dependencies]
anyhow.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
dotenv.workspace = true
windmill-queue.workspace = true
windmill-common = { workspace = true, default-features = false }
@@ -181,6 +182,7 @@ axum = { version = "^0.7", features = ["multipart"] }
headers = "^0"
hyper = { version = "^1", features = ["full"] }
tokio = { version = "^1.42.0", features = ["full", "tracing", "time"] }
tokio-stream = { version = "0.1.17" }
tower = "^0"
tower-http = { version = "^0.6", features = ["trace", "cors"] }
tower-cookies = "^0.10"
+8 -1
View File
@@ -32,6 +32,7 @@ static_frontend = ["dep:rust-embed"]
postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"]
mqtt_trigger = ["dep:thiserror", "dep:rumqttc"]
sqs_trigger = ["dep:aws-sdk-sqs", "dep:thiserror", "dep:aws-config"]
deno_core = ["dep:deno_core", "dep:deno_error"]
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
cloud = ["windmill-common/cloud"]
@@ -47,6 +48,7 @@ windmill-git-sync.workspace = true
windmill-indexer = { workspace = true, optional = true }
windmill-worker.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
anyhow.workspace = true
argon2.workspace = true
axum.workspace = true
@@ -133,4 +135,9 @@ aws-sdk-sqs = { workspace = true, optional = true }
aws-config = { workspace = true, optional = true }
google-cloud-pubsub = { workspace = true, optional = true }
google-cloud-googleapis = { workspace = true , optional = true }
tonic = { workspace = true, optional = true }
tonic = { workspace = true, optional = true }
deno_error = { workspace = true, optional = true }
deno_core = { workspace = true, optional = true }
[build-dependencies]
deno_core = { workspace = true, optional = true }
+229 -81
View File
@@ -1378,7 +1378,7 @@ paths:
format: int64
description: The ID of the GitHub installation to delete
responses:
'200':
"200":
description: Installation successfully deleted
/w/{workspace}/github_app/export/{installationId}:
@@ -1400,7 +1400,7 @@ paths:
schema:
type: integer
responses:
'200':
"200":
description: Successfully exported the JWT token
content:
application/json:
@@ -1435,7 +1435,7 @@ paths:
jwt_token:
type: string
responses:
'200':
"200":
description: Successfully imported the installation
/users/accept_invite:
@@ -1791,7 +1791,7 @@ paths:
schema:
$ref: "#/components/schemas/OperatorSettings"
responses:
'200':
"200":
description: Operator settings updated successfully
content:
text/plain:
@@ -1997,7 +1997,7 @@ paths:
required:
- premium
- owner
/w/{workspace}/workspaces/threshold_alert:
get:
summary: get threshold alert info
@@ -2368,7 +2368,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AIConfig"
/w/{workspace}/workspaces/edit_error_handler:
post:
@@ -3538,14 +3537,14 @@ paths:
tags:
- teams
responses:
'200':
"200":
description: Teams information successfully synchronized
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/TeamInfo'
$ref: "#/components/schemas/TeamInfo"
/teams/activities:
post:
@@ -3579,7 +3578,7 @@ paths:
description: The card block to be sent in the Teams card
responses:
'200':
"200":
description: Activity processed successfully
/w/{workspace}/resources/create:
@@ -4946,8 +4945,7 @@ paths:
/scripts_u/tokened_raw/{workspace}/{token}/{path}:
get:
summary:
raw script by path with a token (mostly used by lsp to be used with
summary: raw script by path with a token (mostly used by lsp to be used with
import maps to resolve scripts)
operationId: rawScriptByPathTokened
tags:
@@ -5041,6 +5039,63 @@ paths:
lock_error_logs:
type: string
/w/{workspace}/jobs/list_selected_job_groups:
# We use post because sending a huge array as a query param can produce
# URLs that may be too long
post:
summary: list selected jobs script/flow schemas grouped by (kind, path)
operationId: listSelectedJobGroups
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: script args
required: true
content:
application/json:
schema:
type: array
items:
type: string
format: uuid
responses:
"200":
description: result
content:
text/plain:
schema:
type: array
items:
type: object
properties:
kind:
type: string
enum: ["script", "flow"]
script_path:
type: string
latest_schema:
type: object
schemas:
type: array
items:
type: object
properties:
schema:
type: object
script_hash:
type: string
job_ids:
type: array
items:
type: string
required: [schema, script_hash, job_ids]
required:
- kind
- script_path
- latest_schema
- schemas
/w/{workspace}/jobs/run/p/{path}:
post:
summary: run script by path
@@ -5525,7 +5580,6 @@ paths:
lock_error_logs:
type: string
/w/{workspace}/flows/get_triggers_count/{path}:
get:
summary: get triggers count of flow
@@ -6523,7 +6577,6 @@ paths:
in: query
schema:
type: boolean
requestBody:
description: flow args
required: true
@@ -6531,7 +6584,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ScriptArgs"
responses:
"201":
description: job created
@@ -6541,6 +6593,60 @@ paths:
type: string
format: uuid
/w/{workspace}/jobs/run/batch_rerun_jobs:
post:
summary: re-run multiple jobs
operationId: batchReRunJobs
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: list of job ids to re run and arg tranforms
required: true
content:
application/json:
schema:
type: object
required: [job_ids, script_options_by_path, flow_options_by_path]
properties:
job_ids:
type: array
items:
type: string
script_options_by_path:
type: object
additionalProperties:
type: object
properties:
input_transforms:
type: object
additionalProperties:
$ref: "#/components/schemas/InputTransform"
use_latest_version: boolean
flow_options_by_path:
type: object
additionalProperties:
type: object
properties:
input_transforms:
type: object
additionalProperties:
$ref: "#/components/schemas/InputTransform"
use_latest_version: boolean
responses:
"201":
description: stream of created job uuids separated by \n. Lines may start with 'Error:'
example: |
a1a74c0d-708e-4539-9768-e8b3d37996bd
f0949132-5b30-48fe-bac8-873f047df810
Error: Could not re-run 0b885808-ae89-4458-af95-c1ca3a13b0a5
52b9c01d-1125-4bbb-8bee-d41f26b70066
content:
text/event-stream:
schema:
type: string
/w/{workspace}/jobs/restart/f/{id}/from/{step_id}/{branch_or_iteration_n}:
post:
summary: restart a completed flow at a given step
@@ -6557,8 +6663,7 @@ paths:
schema:
type: string
- name: branch_or_iteration_n
description:
for branchall or loop, the iteration at which the flow should
description: for branchall or loop, the iteration at which the flow should
restart
required: true
in: path
@@ -6926,10 +7031,82 @@ paths:
schema:
type: integer
/w/{workspace}/jobs/queue/list_filtered_uuids:
/w/{workspace}/jobs/list_filtered_uuids:
get:
summary: get the ids of all jobs matching the given filters
operationId: listFilteredUuids
operationId: listFilteredJobsUuids
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/CreatedBy"
- $ref: "#/components/parameters/Label"
- $ref: "#/components/parameters/Worker"
- $ref: "#/components/parameters/ParentJob"
- $ref: "#/components/parameters/ScriptExactPath"
- $ref: "#/components/parameters/ScriptStartPath"
- $ref: "#/components/parameters/SchedulePath"
- $ref: "#/components/parameters/ScriptExactHash"
- $ref: "#/components/parameters/StartedBefore"
- $ref: "#/components/parameters/StartedAfter"
- $ref: "#/components/parameters/CreatedBefore"
- $ref: "#/components/parameters/CreatedAfter"
- $ref: "#/components/parameters/CreatedOrStartedBefore"
- $ref: "#/components/parameters/Running"
- $ref: "#/components/parameters/ScheduledForBeforeNow"
- $ref: "#/components/parameters/CreatedOrStartedAfter"
- $ref: "#/components/parameters/CreatedOrStartedAfterCompletedJob"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/Suspended"
- $ref: "#/components/parameters/ArgsFilter"
- $ref: "#/components/parameters/Tag"
- $ref: "#/components/parameters/ResultFilter"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- name: is_skipped
description: is the job skipped
in: query
schema:
type: boolean
- name: is_flow_step
description: is the job a flow step
in: query
schema:
type: boolean
- name: has_null_parent
description: has null parent
in: query
schema:
type: boolean
- name: success
description: filter on successful jobs
in: query
schema:
type: boolean
- name: all_workspaces
description: get jobs from all workspaces (only valid if request come from the `admins` workspace)
in: query
schema:
type: boolean
- name: is_not_schedule
description: is not a scheduled job
in: query
schema:
type: boolean
responses:
"200":
description: uuids of jobs
content:
application/json:
schema:
type: array
items:
type: string
/w/{workspace}/jobs/queue/list_filtered_uuids:
get:
summary: get the ids of all queued jobs matching the given filters
operationId: listFilteredQueueUuids
tags:
- job
parameters:
@@ -8739,7 +8916,7 @@ paths:
summary: delete nats trigger
operationId: deleteNatsTrigger
tags:
- nats_trigger
- nats_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
@@ -8768,7 +8945,6 @@ paths:
schema:
$ref: "#/components/schemas/NatsTrigger"
/w/{workspace}/nats_triggers/list:
get:
summary: list nats triggers
@@ -8803,7 +8979,6 @@ paths:
items:
$ref: "#/components/schemas/NatsTrigger"
/w/{workspace}/nats_triggers/exists/{path}:
get:
summary: does nats trigger exists
@@ -8850,7 +9025,6 @@ paths:
schema:
type: string
/w/{workspace}/nats_triggers/test:
post:
summary: test NATS connection
@@ -9523,8 +9697,8 @@ paths:
tags:
- postgres_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: boolean that indicates if postgres is set to logical level or not
@@ -9727,7 +9901,6 @@ paths:
schema:
type: string
/w/{workspace}/postgres_triggers/publication/delete/{publication}/{path}:
delete:
summary: delete postgres publication
@@ -10840,7 +11013,8 @@ paths:
required: true
schema:
type: string
enum: [
enum:
[
script,
group_,
resource,
@@ -11078,7 +11252,6 @@ paths:
items:
$ref: "#/components/schemas/Capture"
/w/{workspace}/capture/move/{runnable_kind}/{path}:
post:
summary: move captures and configs for a script or flow
@@ -11336,8 +11509,7 @@ paths:
/w/{workspace}/job_helpers/duckdb_connection_settings:
post:
summary:
Converts an S3 resource to the set of instructions necessary to connect
summary: Converts an S3 resource to the set of instructions necessary to connect
DuckDB to an S3 bucket
operationId: duckdbConnectionSettings
tags:
@@ -11366,8 +11538,7 @@ paths:
type: string
/w/{workspace}/job_helpers/v2/duckdb_connection_settings:
post:
summary:
Converts an S3 resource to the set of instructions necessary to connect
summary: Converts an S3 resource to the set of instructions necessary to connect
DuckDB to an S3 bucket
operationId: duckdbConnectionSettingsV2
tags:
@@ -11403,8 +11574,7 @@ paths:
/w/{workspace}/job_helpers/polars_connection_settings:
post:
summary:
Converts an S3 resource to the set of arguments necessary to connect
summary: Converts an S3 resource to the set of arguments necessary to connect
Polars to an S3 bucket
operationId: polarsConnectionSettings
tags:
@@ -11448,8 +11618,7 @@ paths:
- client_kwargs
/w/{workspace}/job_helpers/v2/polars_connection_settings:
post:
summary:
Converts an S3 resource to the set of arguments necessary to connect
summary: Converts an S3 resource to the set of arguments necessary to connect
Polars to an S3 bucket
operationId: polarsConnectionSettingsV2
tags:
@@ -11526,8 +11695,7 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description:
S3 resource path to use. If empty, the S3 resource defined in the
description: S3 resource path to use. If empty, the S3 resource defined in the
workspace settings will be used
required: true
content:
@@ -12571,8 +12739,7 @@ components:
type: string
ParentJob:
name: parent_job
description:
The parent job that is at the origin and responsible for the execution
description: The parent job that is at the origin and responsible for the execution
of this script if any
in: query
schema:
@@ -12592,8 +12759,7 @@ components:
type: string
NewJobId:
name: job_id
description:
The job id to assign to the created job. if missing, job is chosen
description: The job id to assign to the created job. if missing, job is chosen
randomly using the ULID scheme. If a job id already exists in the queue
or as a completed job, the request to create one will fail (Bad Request)
in: query
@@ -12684,8 +12850,7 @@ components:
format: date-time
CreatedOrStartedAfter:
name: created_or_started_after
description:
filter on created_at for non non started job and started_at otherwise
description: filter on created_at for non non started job and started_at otherwise
after (exclusive) timestamp
in: query
schema:
@@ -12693,8 +12858,7 @@ components:
format: date-time
CreatedOrStartedAfterCompletedJob:
name: created_or_started_after_completed_jobs
description:
filter on created_at for non non started job and started_at otherwise
description: filter on created_at for non non started job and started_at otherwise
after (exclusive) timestamp but only for the completed jobs
in: query
schema:
@@ -12702,8 +12866,7 @@ components:
format: date-time
CreatedOrStartedBefore:
name: created_or_started_before
description:
filter on created_at for non non started job and started_at otherwise
description: filter on created_at for non non started job and started_at otherwise
before (inclusive) timestamp
in: query
schema:
@@ -12791,8 +12954,7 @@ components:
enum: [Create, Update, Delete, Execute]
JobKinds:
name: job_kinds
description:
filter on job kind (values 'preview', 'script', 'dependencies', 'flow')
description: filter on job kind (values 'preview', 'script', 'dependencies', 'flow')
separated by,
in: query
schema:
@@ -13200,7 +13362,7 @@ components:
"singlescriptflow",
"flowscript",
"flownode",
"appscript",
"appscript"
]
schedule_path:
type: string
@@ -13307,7 +13469,7 @@ components:
"singlescriptflow",
"flowscript",
"flownode",
"appscript",
"appscript"
]
schedule_path:
type: string
@@ -13757,7 +13919,7 @@ components:
"bytes",
"dict",
"datetime",
"sql",
"sql"
]
- type: object
properties:
@@ -13797,7 +13959,7 @@ components:
"bytes",
"dict",
"datetime",
"sql",
"sql"
]
- type: object
properties:
@@ -13823,7 +13985,7 @@ components:
"bytes",
"dict",
"datetime",
"sql",
"sql"
]
- type: object
properties:
@@ -13855,8 +14017,7 @@ components:
ScriptLang:
type: string
enum:
[
enum: [
python3,
deno,
go,
@@ -14293,16 +14454,15 @@ components:
- edited_at
- is_flow
AuthenticationMethod:
type: string
enum:
- none
- windmill
- api_key
- basic_http
- custom_script
- signature
- none
- windmill
- api_key
- basic_http
- custom_script
- signature
HttpTrigger:
allOf:
@@ -14644,7 +14804,7 @@ components:
MqttQoS:
type: string
enum: ['qos0', 'qos1', 'qos2']
enum: ["qos0", "qos1", "qos2"]
MqttV3Config:
type: object
@@ -15024,7 +15184,6 @@ components:
- is_flow
- enabled
Slot:
type: object
properties:
@@ -15119,8 +15278,8 @@ components:
error:
type: string
last_server_ping:
type: string
format: date-time
type: string
format: date-time
required:
- enabled
- postgres_resource_path
@@ -15916,13 +16075,7 @@ components:
properties:
type:
type: string
enum:
[
"S3Storage",
"AzureBlobStorage",
"AzureWorkloadIdentity",
"S3AwsOidc",
]
enum: ["S3Storage", "AzureBlobStorage", "AzureWorkloadIdentity", "S3AwsOidc"]
s3_resource_path:
type: string
azure_blob_resource_path:
@@ -15937,12 +16090,7 @@ components:
type:
type: string
enum:
[
"S3Storage",
"AzureBlobStorage",
"AzureWorkloadIdentity",
"S3AwsOidc",
]
["S3Storage", "AzureBlobStorage", "AzureWorkloadIdentity", "S3AwsOidc"]
s3_resource_path:
type: string
azure_blob_resource_path:
@@ -16422,7 +16570,7 @@ components:
type: array
description: List of channels within the team
items:
$ref: '#/components/schemas/ChannelInfo'
$ref: "#/components/schemas/ChannelInfo"
ChannelInfo:
type: object
+361 -6
View File
@@ -8,7 +8,9 @@
use axum::body::Body;
use axum::http::HeaderValue;
use futures::TryFutureExt;
#[cfg(feature = "deno_core")]
use deno_core::{op2, serde_v8, v8, JsRuntime, OpState};
use futures::{StreamExt, TryFutureExt};
use http::{HeaderMap, HeaderName};
use itertools::Itertools;
use quick_cache::sync::Cache;
@@ -140,6 +142,13 @@ pub fn workspaced_service() -> Router {
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run/batch_rerun_jobs",
post(batch_rerun_jobs)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run/workflow_as_code/:job_id/:entrypoint",
post(run_workflow_as_code)
@@ -203,6 +212,13 @@ pub fn workspaced_service() -> Router {
"/list",
get(list_jobs).layer(Extension(api_list_jobs_query_duration)),
)
.route(
"/list_selected_job_groups",
// We use post because sending a huge array as a query param can produce
// URLs that may be too long
post(list_selected_job_groups),
)
.route("/list_filtered_uuids", get(list_filtered_job_uuids))
.route("/queue/list", get(list_queue_jobs))
.route("/queue/count", get(count_queue_jobs))
.route("/queue/list_filtered_uuids", get(list_filtered_uuids))
@@ -645,6 +661,48 @@ async fn get_flow_job_debug_info(
}
}
async fn list_selected_job_groups(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(uuids): Json<Vec<Uuid>>,
) -> error::Result<Response> {
let mut tx = user_db.begin(&authed).await?;
let results = sqlx::query_scalar!(
r#"SELECT jsonb_build_object(
'kind', jb.kind,
'script_path', jb.runnable_path,
'latest_schema', COALESCE(
(SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = jb.runnable_path AND jb.kind = 'script' ORDER BY s.path, s.created_at DESC),
(SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = jb.runnable_path AND jb.kind = 'flow')
),
'schemas', ARRAY(
SELECT jsonb_build_object(
'script_hash', LPAD(TO_HEX(COALESCE(s.hash, f.id)), 16, '0'),
'job_ids', ARRAY_AGG(DISTINCT j.id),
'schema', ANY_VALUE(COALESCE(s.schema, f.schema))
) FROM v2_job j
LEFT JOIN script s ON s.hash = j.runnable_id AND j.kind = 'script'
LEFT JOIN flow_version f ON f.id = j.runnable_id AND j.kind = 'flow'
WHERE j.id = ANY(ARRAY_AGG(jb.id))
GROUP BY COALESCE(s.hash, f.id)
)
) FROM v2_job jb
WHERE (jb.kind = 'flow' OR jb.kind = 'script')
AND jb.workspace_id = $1 AND jb.id = ANY($2)
GROUP BY jb.kind, jb.runnable_path"#,
&w_id,
&uuids
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(results).into_response())
}
#[derive(Deserialize)]
struct GetJobQuery {
pub no_logs: Option<bool>,
@@ -1745,6 +1803,37 @@ async fn cancel_selection(
cancel_jobs(jobs_to_cancel, &db, authed.username.as_str(), w_id.as_str()).await
}
async fn list_filtered_job_uuids(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(lq): Query<ListCompletedQuery>,
) -> error::JsonResult<Vec<Uuid>> {
require_admin(authed.is_admin, &authed.username)?;
check_scopes(&authed, || format!("jobs:listjobs"))?;
let mut sqlb = list_completed_jobs_query(
w_id.as_str(),
None,
0,
&lq,
&["v2_job.id"],
false,
get_scope_tags(&authed),
);
let sqlb2 = list_queue_jobs_query(
w_id.as_str(),
&lq.into(),
&["v2_job.id"],
Pagination { page: None, per_page: None },
false,
get_scope_tags(&authed),
);
let query = sqlb.union_all(sqlb2.subquery()?).subquery()?;
let ids = sqlx::query_scalar(query.as_str()).fetch_all(&db).await?;
Ok(Json(ids))
}
async fn list_filtered_uuids(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1901,7 +1990,7 @@ async fn list_jobs(
let sqlc = if lq.running.is_none() {
Some(list_completed_jobs_query(
&w_id,
per_page + offset,
Some(per_page + offset),
0,
&ListCompletedQuery { order_desc: Some(true), ..lqc },
UnifiedJob::completed_job_fields(),
@@ -1940,7 +2029,9 @@ async fn list_jobs(
} else {
if sqlc.is_none() {
return Err(error::Error::BadRequest(
"cannot specify success, label, created_or_started_before, or started_before with running".to_string(),
"cannot specify success, label, created_or_started_before, or starte
d_before with running"
.to_string(),
));
}
sqlc.unwrap().limit(per_page).offset(offset).query()?
@@ -3152,6 +3243,268 @@ pub async fn check_license_key_valid() -> error::Result<()> {
Ok(())
}
use windmill_common::flows::InputTransform;
#[derive(Deserialize)]
struct BatchReRunJobsBodyArgs {
job_ids: Vec<Uuid>,
script_options_by_path: HashMap<String, BatchReRunOptions>,
flow_options_by_path: HashMap<String, BatchReRunOptions>,
}
#[derive(Deserialize)]
struct BatchReRunOptions {
input_transforms: Option<HashMap<String, InputTransform>>,
use_latest_version: Option<bool>,
}
#[derive(sqlx::FromRow, Serialize, Clone)]
struct BatchReRunQueryReturnType {
id: Uuid,
kind: JobKind,
script_path: String,
script_hash: ScriptHash,
input: serde_json::Value,
scheduled_for: chrono::DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<serde_json::Value>,
}
#[cfg(feature = "deno_core")]
#[op2]
#[string]
fn get_deno_core_job_value(state: &mut OpState) -> Option<String> {
let obj = state.borrow::<BatchReRunQueryReturnType>();
let str = serde_json::to_string(&obj).ok()?;
Some(str)
}
#[cfg(feature = "deno_core")]
async fn batch_rerun_compute_js_expression(
expr: String,
job: BatchReRunQueryReturnType,
) -> error::Result<Box<RawValue>> {
let ext = deno_core::Extension {
name: "batch_rerun_arg_transform_ext",
ops: vec![get_deno_core_job_value()].into(),
..Default::default()
};
let mut isolate =
JsRuntime::new(deno_core::RuntimeOptions { extensions: vec![ext], ..Default::default() });
{
let op_state = isolate.op_state();
let mut op_state = op_state.borrow_mut();
op_state.put(BatchReRunQueryReturnType { schema: None, ..job });
}
isolate
.execute_script(
"<batch_rerun_arg_transform>",
"let job = JSON.parse(Deno.core.ops.get_deno_core_job_value());",
)
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
// Run user expr
let result = isolate
.execute_script("<batch_rerun_arg_transform>", expr)
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
let mut scope = isolate.handle_scope();
let result = v8::Local::new(&mut scope, result);
let result: serde_json::Value =
serde_v8::from_v8(&mut scope, result).map_err(|e| Error::ExecutionErr(e.to_string()))?;
let result = JsonRawValue::from_string(result.to_string())?;
Ok(result)
}
async fn batch_rerun_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(body): Json<BatchReRunJobsBodyArgs>,
) -> Response {
let stream = batch_rerun_jobs_inner(authed, db, user_db, w_id, body);
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
Response::builder()
.status(201)
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.body(body)
.unwrap()
}
fn batch_rerun_jobs_inner(
authed: ApiAuthed,
db: DB,
user_db: UserDB,
w_id: String,
body: BatchReRunJobsBodyArgs,
) -> impl futures::Stream<Item = String> {
let (tx, rx) = tokio::sync::mpsc::channel(10);
tokio::spawn(async move {
let mut job_stream = sqlx::query_as!(
BatchReRunQueryReturnType,
r#"SELECT
j.id,
j.kind AS "kind: _",
COALESCE(s.path, f.path) AS "script_path!",
COALESCE(s.hash, f.id) AS "script_hash!: _",
COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS "scheduled_for!: _",
args AS input,
COALESCE(s.schema, f.schema) AS "schema: _"
FROM v2_job j
LEFT JOIN script s ON j.runnable_id = s.hash AND j.kind = 'script'
LEFT JOIN flow_version f ON j.runnable_id = f.id AND j.runnable_path = f.path AND j.kind = 'flow'
LEFT JOIN v2_job_completed jc ON jc.id = j.id
LEFT JOIN v2_job_queue jq ON jq.id = j.id
WHERE j.id = ANY($1)
AND j.workspace_id = $2
AND COALESCE(s.hash, f.id) IS NOT NULL
AND COALESCE(s.path, f.path) IS NOT NULL"#,
&body.job_ids,
w_id
).fetch(&db);
while let Some(Ok(job)) = job_stream.next().await {
let job_result =
batch_rerun_handle_job(&job, &authed, &db, &user_db, &w_id, &body).await;
let send_to_stream_result = tx
.send(match job_result {
Ok(uuid) => format!("{}\n", uuid),
Err(err) => format!("Error: {}\n", err.to_string()),
})
.await;
match send_to_stream_result {
Ok(_) => {}
Err(e) => tracing::error!("Couldn't re-run job {}: {}", job.id, e.to_string()),
}
}
});
tokio_stream::wrappers::ReceiverStream::new(rx)
}
async fn batch_rerun_handle_job(
job: &BatchReRunQueryReturnType,
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &String,
body: &BatchReRunJobsBodyArgs,
) -> error::Result<String> {
let options = if matches!(job.kind, JobKind::Script) {
&body.script_options_by_path
} else {
&body.flow_options_by_path
}
.get(&job.script_path);
let mut args: HashMap<String, Box<RawValue>> = serde_json::from_value(job.input.clone())?;
let use_latest_version = options.and_then(|o| o.use_latest_version).unwrap_or(false);
let input_transforms = options
.and_then(|o| o.input_transforms.as_ref())
.map(|t| t.iter())
.into_iter()
.flatten();
let latest_schema;
let schema = if use_latest_version {
latest_schema = sqlx::query_scalar!(
r#"SELECT COALESCE(
(SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = jb.runnable_path AND jb.kind = 'script' ORDER BY s.path, s.created_at DESC),
(SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = jb.runnable_path AND jb.kind = 'flow')
) FROM v2_job jb
WHERE jb.id = $1 AND jb.workspace_id = $2
GROUP BY jb.kind, jb.runnable_path"#,
&job.id,
&w_id
).fetch_optional(db).await?.flatten();
latest_schema.as_ref()
} else {
job.schema.as_ref()
};
let schema = schema
.and_then(serde_json::Value::as_object)
.and_then(|s| s.get("properties"))
.and_then(serde_json::Value::as_object);
for (property_name, transform) in input_transforms {
let schema_has_key = schema
.map(|s| s.contains_key(property_name))
.unwrap_or(false);
if !schema_has_key {
continue;
}
match transform {
InputTransform::Static { value } => {
args.insert(property_name.clone(), value.clone());
}
InputTransform::Javascript { expr } => {
#[cfg(not(feature = "deno_core"))]
tracing::error!("deno_core feature is not activated, cannot evaluate: {expr}");
#[cfg(feature = "deno_core")]
args.insert(
property_name.clone(),
batch_rerun_compute_js_expression(expr.clone(), job.clone()).await?,
);
}
}
}
// Call appropriate function to push job to queue
match job.kind {
JobKind::Flow => {
let result = run_flow_by_path_inner(
authed.clone(),
db.clone(),
user_db.clone(),
w_id.clone(),
StripPath(job.script_path.clone()),
RunJobQuery { ..Default::default() },
PushArgsOwned { extra: None, args },
None,
)
.await;
if let Ok((_, uuid)) = result {
return Ok(uuid);
}
}
JobKind::Script => {
let result = if use_latest_version {
run_script_by_path_inner(
authed.clone(),
db.clone(),
user_db.clone(),
w_id.clone(),
StripPath(job.script_path.clone()),
RunJobQuery { ..Default::default() },
PushArgsOwned { extra: None, args },
None,
)
.await
} else {
run_job_by_hash_inner(
authed.clone(),
db.clone(),
user_db.clone(),
w_id.clone(),
job.script_hash,
RunJobQuery { ..Default::default() },
PushArgsOwned { extra: None, args },
None,
)
.await
};
if let Ok((_, uuid)) = result {
return Ok(uuid);
}
}
_ => {}
}
Err(error::Error::ExecutionErr(
format!("Couldn't re-run job {}", job.id).to_string(),
))
}
pub async fn run_flow_by_path(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -5609,7 +5962,7 @@ pub fn filter_list_completed_query(
pub fn list_completed_jobs_query(
w_id: &str,
per_page: usize,
per_page: Option<usize>,
offset: usize,
lq: &ListCompletedQuery,
fields: &[&str],
@@ -5620,8 +5973,10 @@ pub fn list_completed_jobs_query(
.fields(fields)
.order_by("v2_job.created_at", lq.order_desc.unwrap_or(true))
.offset(offset)
.limit(per_page)
.clone();
if let Some(per_page) = per_page {
sqlb.limit(per_page);
}
if let Some(tags) = tags {
sqlb.and_where_in(
@@ -5682,7 +6037,7 @@ async fn list_completed_jobs(
let sql = list_completed_jobs_query(
&w_id,
per_page,
Some(per_page),
offset,
&lq,
&[
@@ -1,8 +1,9 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import Required from './Required.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { capitalize, emptyString } from '$lib/utils'
import Tooltip from './meltComponents/Tooltip.svelte'
import { InfoIcon } from 'lucide-svelte'
export let label: string
export let format: string = ''
@@ -15,6 +16,8 @@
export let prettify = false
export let simpleTooltip: string | undefined = undefined
export let lightHeader = false
export let SimpleTooltipIcon = InfoIcon
export let simpleTooltipIconClass = ''
</script>
<div class="inline-flex flex-row items-baseline truncated">
@@ -51,8 +54,9 @@
{/if}
{#if !emptyString(simpleTooltip)}
<Tooltip class="ml-2">
<span class="text-xs">
<Tooltip class="ml-2" placement="bottom">
<SimpleTooltipIcon size="14" class={'-mb-0.5 ' + simpleTooltipIconClass} />
<span class="text-xs" slot="text">
{simpleTooltip}
</span>
</Tooltip>
@@ -29,7 +29,7 @@
import type { InputTransform } from '$lib/gen'
import TemplateEditor from './TemplateEditor.svelte'
import { setInputCat as computeInputCat, isCodeInjection } from '$lib/utils'
import { FunctionSquare } from 'lucide-svelte'
import { FunctionSquare, InfoIcon } from 'lucide-svelte'
import { getResourceTypes } from './resourceTypesStore'
import type { FlowCopilotContext } from './copilot/flow'
import StepInputGen from './copilot/StepInputGen.svelte'
@@ -39,6 +39,9 @@
export let schema: Schema | { properties?: Record<string, any>; required?: string[] }
export let arg: InputTransform | any
export let argName: string
export let headerTooltip: string | undefined = undefined
export let headerTooltipIconClass = ''
export let HeaderTooltipIcon = InfoIcon
export let extraLib: string = 'missing extraLib'
export let inputCheck: boolean = true
export let previousModuleId: string | undefined
@@ -49,6 +52,7 @@
export let argExtra: Record<string, any> = {}
export let pickableProperties: PickableProperties | undefined = undefined
export let enableAi = false
export let hideHelpButton = false
let monaco: SimpleEditor | undefined = undefined
let monacoTemplate: TemplateEditor | undefined = undefined
@@ -70,8 +74,9 @@
const { shouldUpdatePropertyType, exprsToSet } =
getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
const { inputMatches, focusProp, propPickerConfig, clearFocus } =
const propPickerWrapperContext: PropPickerWrapperContext | undefined =
getContext<PropPickerWrapperContext>('PropPickerWrapper')
const { inputMatches, focusProp, propPickerConfig, clearFocus } = propPickerWrapperContext ?? {}
function setExpr() {
const newArg = $exprsToSet?.[argName]
@@ -221,7 +226,7 @@
function onFocus() {
focused = true
if (isStaticTemplate(inputCat)) {
focusProp(argName, 'append', (path) => {
focusProp?.(argName, 'append', (path) => {
const toAppend = `\$\{${path}}`
arg.value = `${arg.value ?? ''}${toAppend}`
monacoTemplate?.setCode(arg.value)
@@ -230,7 +235,7 @@
return false
})
} else {
focusProp(argName, 'insert', (path) => {
focusProp?.(argName, 'insert', (path) => {
arg.expr = path
arg.type = 'javascript'
propertyType = 'javascript'
@@ -253,7 +258,7 @@
if (propertyType == 'static') {
setPropertyType(arg?.value)
codeInjectionDetected = checkCodeInjection(arg?.value) != undefined
} else if (propertyType == 'javascript' && focused) {
} else if (propertyType == 'javascript' && focused && inputMatches) {
// setPropertyType(arg?.expr)
$inputMatches = checkCodeInjection(arg?.expr)
}
@@ -266,7 +271,7 @@
}
function updateFocused(newFocused: boolean) {
if (focusedPrev && !newFocused) {
if (focusedPrev && !newFocused && inputMatches) {
$inputMatches = undefined
}
focusedPrev = focused
@@ -304,6 +309,9 @@
<div class="flex flex-wrap grow">
<FieldHeader
label={argName}
simpleTooltip={headerTooltip}
simpleTooltipIconClass={headerTooltipIconClass}
SimpleTooltipIcon={HeaderTooltipIcon}
format={schema?.properties?.[argName].format}
contentEncoding={schema?.properties?.[argName].contentEncoding}
required={schema.required?.includes(argName)}
@@ -449,24 +457,26 @@
</ToggleButtonGroup>
</div>
<FlowPlugConnect
id="flow-editor-plug"
{connecting}
on:click={() => {
if (
$propPickerConfig?.propName == argName &&
$propPickerConfig?.insertionMode == 'connect'
) {
clearFocus()
} else {
focusProp?.(argName, 'connect', (path) => {
connectProperty(path)
dispatch('change', { argName })
return true
})
}
}}
/>
{#if propPickerWrapperContext}
<FlowPlugConnect
id="flow-editor-plug"
{connecting}
on:click={() => {
if (
$propPickerConfig?.propName == argName &&
$propPickerConfig?.insertionMode == 'connect'
) {
clearFocus()
} else {
focusProp?.(argName, 'connect', (path) => {
connectProperty(path)
dispatch('change', { argName })
return true
})
}
}}
/>
{/if}
</div>
{/if}
</div>
@@ -503,7 +513,7 @@
bind:code={arg.value}
fontSize={14}
on:change={() => {
dispatch('change', { argName })
dispatch('change', { argName, arg })
}}
/>
{/if}
@@ -520,7 +530,7 @@
}}
shouldDispatchChanges
on:change={() => {
dispatch('change', { argName })
dispatch('change', { argName, arg })
}}
label={argName}
bind:editor={monaco}
@@ -554,20 +564,20 @@
bind:this={monaco}
bind:code={arg.expr}
on:change={() => {
dispatch('change', { argName })
dispatch('change', { argName, arg })
}}
{extraLib}
lang="javascript"
shouldBindKey={false}
on:focus={() => {
focused = true
focusProp(argName, 'insert', (path) => {
focusProp?.(argName, 'insert', (path) => {
monaco?.insertAtCursor(path)
return false
})
}}
on:change={() => {
dispatch('change', { argName })
dispatch('change', { argName, arg })
}}
on:blur={() => {
focused = false
@@ -575,7 +585,9 @@
autoHeight
/>
</div>
<DynamicInputHelpBox />
{#if !hideHelpButton}
<DynamicInputHelpBox />
{/if}
<div class="mb-2"></div>
{:else}
Not recognized input type {argName} ({arg.expr}, {propertyType})
@@ -799,7 +799,7 @@
deleteFileFromS3(fileMetadata?.fileKey)
}}
keyListen={false}
bind:loading={fileDeletionInProgress}
loading={fileDeletionInProgress}
>
<div class="flex flex-col w-full space-y-4">
<span
@@ -819,7 +819,7 @@
moveS3File(fileMetadata?.fileKey, moveDestKey)
}}
keyListen={false}
bind:loading={fileMoveInProgress}
loading={fileMoveInProgress}
>
<div class="flex flex-col space-y-4">
<div class="flex items-center justify-between">
@@ -3,14 +3,26 @@
import { createEventDispatcher } from 'svelte'
import { fade } from 'svelte/transition'
import Button from '../button/Button.svelte'
import { AlertTriangle, CornerDownLeft, Loader2 } from 'lucide-svelte'
import { AlertTriangle, CornerDownLeft, Loader2, RefreshCcw } from 'lucide-svelte'
export let title: string
export let confirmationText: string
export let keyListen: boolean = true
export let loading: boolean = false
type Props = {
title: string
confirmationText: string
keyListen?: boolean
loading?: boolean
open?: boolean
type?: 'danger' | 'reload'
}
export let open: boolean = false
const {
title,
confirmationText,
keyListen = true,
loading = false,
open = false,
type: _type
}: Props = $props()
const type = $derived(_type ?? 'danger')
const dispatch = createEventDispatcher()
@@ -31,6 +43,27 @@
function fadeFast(node: HTMLElement) {
return fade(node, { duration: 100 })
}
const theme = {
danger: {
Icon: AlertTriangle,
color: 'red',
classes: {
icon: 'text-red-500 dark:text-red-400',
iconWrapper: 'bg-red-100 dark:bg-red-800/50'
}
},
reload: {
Icon: RefreshCcw,
color: 'dark',
classes: {
icon: 'text-blue-500 dark:text-blue-400',
iconWrapper: 'bg-blue-100 dark:bg-blue-800/50'
}
}
} satisfies { [type in typeof type]: any }
const Icon = $derived(theme[type].Icon ?? AlertTriangle)
</script>
<svelte:window on:keydown|capture={onKeyDown} />
@@ -60,15 +93,16 @@
>
<div class="flex">
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50"
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
>
<AlertTriangle class="text-red-500 dark:text-red-400" />
<Icon class={theme[type].classes.icon} />
</div>
<div class="ml-4 text-left flex-1">
<h3 class="text-lg font-medium text-primary">
{title}
</h3>
<div class="mt-2 text-sm text-secondary">
<!-- svelte-ignore slot_element_deprecated -->
<slot />
</div>
</div>
@@ -77,14 +111,14 @@
<Button
disabled={loading}
on:click={() => dispatch('confirmed')}
color="red"
color={theme[type].color}
size="sm"
shortCut={{ Icon: CornerDownLeft, hide: !keyListen, withoutModifier: true }}
>
{#if loading}
<Loader2 class="animate-spin" />
{/if}
<span>{confirmationText} </span>
<span class="min-w-20">{confirmationText} </span>
</Button>
<Button
disabled={loading}
@@ -0,0 +1,110 @@
import type { Schema } from '$lib/common'
import { schemaToTsType } from '$lib/schema'
import { deepEqual } from 'fast-equals'
export function buildExtraLibForBatchReruns({
schemas,
script_path
}: {
schemas: { schema: Schema | object; script_hash: string }[]
script_path: string
}) {
return `
/**
* get variable (including secret) at path
* @param {string} path - path of the variable (e.g: f/examples/secret)
*/
declare function variable(path: string): string;
/**
* get resource at path
* @param {string} path - path of the resource (e.g: f/examples/my_resource)
*/
declare function resource(path: string): any;
declare const job: {
/**
* scheduled date of the original job in ISO 8601 string format.
* Use \`new Date(job.scheduled_for)\` to get a Date object
*/
scheduled_for: string;
/**
* id of the original job
*/
id: string;
kind: 'flow' | 'script';
script_path: ${JSON.stringify(script_path)};
} & (
${schemas
.map(
(s) => `
{
script_hash: ${JSON.stringify(s.script_hash)};
input: ${schemaToTsType(s.schema as Schema)};
}`
)
.join(' | ')}
)`
}
// Used for InputTransformForm
export function mergeSchemasForBatchReruns(schemas: Schema[]): Schema {
const merged: Schema = { $schema: '', required: [], properties: {}, type: 'object' }
for (const s of schemas) {
for (const [propertyName, property] of Object.entries(s.properties)) {
if (!(propertyName in merged.properties)) {
merged.properties[propertyName] = { ...property }
} else {
if (!deepEqual(merged.properties[propertyName], property)) {
if (merged.properties[propertyName].type === 'string' && property.type === 'string') {
merged.properties[propertyName] = { type: 'string' }
} else {
merged.properties[propertyName] = { type: 'object' }
}
}
}
}
}
for (const propertyName of Object.keys(merged.properties)) {
if (schemas.every((s) => s.required.includes(propertyName))) {
merged.required.push(propertyName)
}
}
return merged
}
export function batchReRunDefaultPropertyExpr(
propertyName: string,
schemas: { schema: Schema | object; script_hash: string }[]
): string {
const typeMap = new Map<string, string[]>()
for (const s of schemas) {
const type = (s.schema as Schema).properties[propertyName]?.type ?? 'undefined'
if (!typeMap.has(type)) typeMap.set(type, [])
const entry = typeMap.get(type)
entry?.push(s.script_hash)
}
const defaultExpr = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/.test(propertyName)
? `job.input.${propertyName}`
: `job.input[${JSON.stringify(propertyName)}]`
if (typeMap.size === 1) {
return defaultExpr
}
if (typeMap.has('undefined') && typeMap.size === 2) {
return `// ${propertyName} does not exist on all versions of the script\nif (${JSON.stringify(propertyName)} in job.input) {\n ${defaultExpr}\n} else {\n undefined\n}`
}
return (
`// '${propertyName}' has different types depending on the script version\n` +
[...typeMap.entries()]
.map(([type, hashes]) => {
const expr = type === 'undefined' ? 'undefined' : defaultExpr
const condition = hashes.map((h) => `job.script_hash === ${JSON.stringify(h)}`).join(' || ')
return `if (${condition}) {\n ${expr} // ${type}\n}`
})
.join(' else ')
)
}
@@ -0,0 +1,275 @@
<script lang="ts" module>
export type BatchReRunOptions = {
[kind in 'flow' | 'script']: {
[path: string]: {
input_transforms?: { [property: string]: InputTransform }
use_latest_version?: boolean
}
}
}
</script>
<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes'
import PanelSection from '../apps/editor/settingsPanel/common/PanelSection.svelte'
import { JobService, type InputTransform, type ListSelectedJobGroupsResponse } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import type { Schema } from '$lib/common'
import InputTransformForm from '../InputTransformForm.svelte'
import type { FlowPropPickerConfig, PropPickerContext } from '../prop_picker'
import { setContext } from 'svelte'
import { writable } from 'svelte/store'
import type { PickableProperties } from '../flows/previousResults'
import Alert from '../common/alert/Alert.svelte'
import {
batchReRunDefaultPropertyExpr,
buildExtraLibForBatchReruns,
mergeSchemasForBatchReruns
} from '$lib/components/jobs/batchReruns'
import Toggle from '../Toggle.svelte'
import { TriangleAlert } from 'lucide-svelte'
let {
selectedIds,
options = $bindable()
}: {
selectedIds: string[]
options: BatchReRunOptions
} = $props()
let selected: JobGroup | undefined = $state()
$effect(() => {
jobGroupsPromise.then((jobGroups) => {
selected = selected
? jobGroups.find((g) => g.script_path === selected?.script_path && g.kind === selected.kind)
: jobGroups[0]
})
})
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined)
})
type JobGroup = ListSelectedJobGroupsResponse[number]
const listSelectedJobsSchemaCache = new Map<string, JobGroup>()
async function fetchJobGroups(): Promise<JobGroup[]> {
if (!$workspaceStore) return []
const cachedSelectedIds = selectedIds.filter((id) => listSelectedJobsSchemaCache.has(id))
const nonCachedSelectedIds = selectedIds.filter((id) => !listSelectedJobsSchemaCache.has(id))
console.log(
`Fetching job groups for ${nonCachedSelectedIds.length} jobs, ${cachedSelectedIds.length} cached`
)
const newJobGroups = nonCachedSelectedIds.length
? await JobService.listSelectedJobGroups({
workspace: $workspaceStore,
requestBody: nonCachedSelectedIds
})
: []
// Update cache
newJobGroups.forEach((group) => {
group.schemas.forEach((s) => {
s.job_ids.forEach((job_id) => {
listSelectedJobsSchemaCache.set(job_id, group)
})
})
})
const jobGroups: JobGroup[] = newJobGroups
// Handle cached
for (const jobId of cachedSelectedIds) {
const cachedGroup = listSelectedJobsSchemaCache.get(jobId)
const jobSchema = cachedGroup?.schemas.find((s) => s.job_ids.includes(jobId))
if (!cachedGroup || !jobSchema) {
continue
}
const group =
jobGroups.find(
(j) => j.kind === cachedGroup.kind && j.script_path === cachedGroup.script_path
) ??
jobGroups[
jobGroups.push({
kind: cachedGroup.kind,
script_path: cachedGroup.script_path,
schemas: [],
latest_schema: cachedGroup.latest_schema
}) - 1
]
const schemaItem =
group.schemas.find((s) => s.script_hash === jobSchema.script_hash) ??
group.schemas[
group.schemas.push({
schema: jobSchema.schema as Schema,
job_ids: [],
script_hash: jobSchema.script_hash
}) - 1
]
schemaItem.job_ids.push(jobId)
}
jobGroups.sort((a, b) => {
if (a.script_path < b.script_path) return -1
if (a.script_path > b.script_path) return 1
return 0
})
return jobGroups
}
function jobGroupTotalCount(group: JobGroup) {
return group.schemas.reduce((p, c) => p + c.job_ids.length, 0)
}
function propertyAlwaysExists(propertyName: string, group: JobGroup): boolean {
for (const s of group.schemas) {
if (!(propertyName in (s.schema as Schema).properties)) return false
}
return true
}
function propertyAlwaysHasSameType(propertyName: string, group: JobGroup): boolean {
let prevType = 'INIT'
for (const s of group.schemas) {
const currType = (s.schema as Schema).properties[propertyName]?.type
if (currType === undefined) continue
if (prevType !== 'INIT' && currType !== prevType) return false
prevType = currType
}
return true
}
const selectedUsesLatestSchema = $derived(
!!selected &&
(selected?.kind === 'flow' ||
(options[selected.kind][selected.script_path]?.use_latest_version ?? false))
)
const jobGroupsPromise = $derived(selectedIds && fetchJobGroups())
</script>
<div class="flex-1 flex flex-col">
<p class="ml-4 mt-4 text-xs font-semibold truncate">Batch re-run options</p>
<div class="border m-4 flex-1">
<Splitpanes>
<Pane size={32} class="bg-surface-secondary relative">
<PanelSection
title="Runnables"
class="bg-surface-secondary overflow-y-scroll absolute inset-0"
id="batch-rerun-options-runnable-list"
>
<div class="w-full flex flex-col gap-1">
{#await jobGroupsPromise then jobGroup}
{#each jobGroup as group}
<button
class="border rounded-sm w-full text-left font-normal py-1.5 px-2 text-2xs flex justify-between {selected?.kind ===
group.kind && selected.script_path === group.script_path
? 'border-blue-500 bg-blue-100 dark:bg-frost-900/50'
: 'hover:bg-blue-50 dark:hover:bg-frost-900/50'}"
onclick={() => (selected = group)}
>
<span class="truncate"> {group.script_path}</span>
<span class="text-gray-400">({jobGroupTotalCount(group)})</span>
</button>
{/each}
{/await}
</div>
</PanelSection>
</Pane>
<Pane size={68} class="relative">
<PanelSection
title="Inputs"
class="overflow-y-scroll absolute inset-0"
id="batch-rerun-options-args"
>
{#if selected}
<div class="text-sm w-full pb-2">
<Alert type="info" title="Available expressions :">
Use the <code>job</code> object to access data about the original job
</Alert>
</div>
<Toggle
checked={selectedUsesLatestSchema}
disabled={selected?.kind === 'flow'}
on:change={(e) => {
if (!selected) return
;(options[selected.kind][selected.script_path] ??= {}).use_latest_version =
e.detail as boolean
}}
size="sm"
options={{
right: 'Always use latest version',
rightTooltip:
selected.kind === 'flow'
? 'Flow jobs will always run on the latest version of the flow'
: 'Run all jobs with the latest version of the script even if they originally ran an older version'
}}
/>
<!-- Even if we use the latest schema, we want the editor -->
<!-- to only lint the original jobs' values -->
{@const displayedSchema = selectedUsesLatestSchema
? (selected.latest_schema as Schema)
: mergeSchemasForBatchReruns(selected.schemas.map((s) => s.schema as Schema))}
{@const extraLib = buildExtraLibForBatchReruns({
schemas: selected.schemas,
script_path: selected.script_path
})}
<div class="w-full h-full">
{#key [selected, displayedSchema]}
{#each Object.keys(displayedSchema.properties) as propertyName}
<InputTransformForm
class="items-start mb-4"
arg={options[selected.kind][selected.script_path]?.input_transforms?.[
propertyName
] ?? {
type: 'javascript',
expr: batchReRunDefaultPropertyExpr(propertyName, selected.schemas)
}}
on:change={(e) => {
if (!selected) return
const newArg = e.detail.arg as InputTransform
;((options[selected.kind][selected.script_path] ??= {}).input_transforms ??=
{})[propertyName] = newArg
}}
argName={propertyName}
schema={displayedSchema}
{extraLib}
previousModuleId={undefined}
pickablepropertyMap={{
hasResume: false,
previousId: undefined,
priorIds: {},
flow_input: {}
}}
hideHelpButton
{...propertyAlwaysExists(propertyName, selected)
? {}
: {
headerTooltip:
'This property does not exist on all versions of the script. You can handle different cases in the code below',
HeaderTooltipIcon: TriangleAlert,
headerTooltipIconClass: 'text-orange-500'
}}
{...propertyAlwaysHasSameType(propertyName, selected)
? {}
: {
headerTooltip:
'This property does not always have the same type depending on the version of the script. You can handle different cases in the code below',
HeaderTooltipIcon: TriangleAlert,
headerTooltipIconClass: 'text-orange-500'
}}
/>
{/each}
{/key}
</div>
{/if}
</PanelSection>
</Pane>
</Splitpanes>
</div>
</div>
@@ -7,9 +7,9 @@
msToReadableTime,
truncateHash,
truncateRev,
isJobCancelable,
isFlowPreview,
isScriptPreview
isScriptPreview,
isJobSelectable
} from '$lib/utils'
import { Badge, Button } from '../common'
import ScheduleEditor from '../ScheduleEditor.svelte'
@@ -33,6 +33,7 @@
import Portal from '$lib/components/Portal.svelte'
import WaitTimeWarning from '../common/waitTimeWarning/WaitTimeWarning.svelte'
import type { RunsSelectionMode } from './RunsBatchActionsDropdown.svelte'
const dispatch = createEventDispatcher()
@@ -41,7 +42,7 @@
export let containerWidth: number = 0
export let containsLabel: boolean = false
export let activeLabel: string | null
export let isSelectingJobsToCancel: boolean = false
export let selectionMode: RunsSelectionMode | false = false
let scheduleEditor: ScheduleEditor
@@ -61,13 +62,13 @@
)}
style="width: {containerWidth}px"
on:click={() => {
if (!isSelectingJobsToCancel || isJobCancelable(job)) {
if (!selectionMode || isJobSelectable(selectionMode)(job)) {
dispatch('select')
}
}}
>
<div class="w-1/12 flex justify-center">
{#if isSelectingJobsToCancel && isJobCancelable(job)}
{#if selectionMode && isJobSelectable(selectionMode)(job)}
<div class="px-2">
<input type="checkbox" checked={selected} />
</div>
@@ -0,0 +1,92 @@
<script lang="ts" context="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 { 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
function jobCountString(count: number) {
return `${count} ${count == 1 ? 'job' : 'jobs'}`
}
</script>
{#if isLoading}
<Button size="xs" color="light" disabled>
<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">
<Button
startIcon={{ icon: X }}
size="xs"
color="gray"
variant="contained"
on:click={() => onSetSelectionMode(false)}
/>
{#if selectionMode == 'cancel'}
<Button
disabled={selectionCount == 0}
startIcon={{ icon: Check }}
size="xs"
color="red"
variant="contained"
on:click={onCancelSelectedJobs}
>
Cancel {jobCountString(selectionCount)}
</Button>
{/if}
{#if selectionMode == 're-run'}
<Button
disabled={selectionCount == 0}
startIcon={{ icon: Check }}
size="xs"
color="green"
variant="contained"
on:click={onReRunSelectedJobs}
>
Re-run {jobCountString(selectionCount)}
</Button>
{/if}
</div>
{:else}
<DropdownV2
class="w-fit mx-auto"
items={[
{
displayName: 'Select jobs to cancel',
action: () => onSetSelectionMode('cancel')
},
...($userStore?.is_admin || $superadmin
? [{ displayName: 'Cancel all jobs matching filters', action: onCancelFilteredJobs }]
: []),
{
displayName: 'Select jobs to re-run',
action: () => onSetSelectionMode('re-run')
},
...($userStore?.is_admin || $superadmin
? [{ displayName: 'Re-run all jobs matching filters', action: onReRunFilteredJobs }]
: [])
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Batch actions</span>
<ChevronDown class="w-5 h-5" />
</div>
</svelte:fragment>
</DropdownV2>
{/if}
@@ -8,14 +8,15 @@
import Popover from '../Popover.svelte'
import { workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { isJobCancelable } from '$lib/utils'
import { isJobSelectable } from '$lib/utils'
import type { RunsSelectionMode } from './RunsBatchActionsDropdown.svelte'
//import InfiniteLoading from 'svelte-infinite-loading'
export let jobs: Job[] | undefined = undefined
export let externalJobs: Job[] = []
export let omittedObscuredJobs: boolean
export let showExternalJobs: boolean = false
export let isSelectingJobsToCancel: boolean = false
export let selectionMode: RunsSelectionMode | false = false
export let selectedIds: string[] = []
export let selectedWorkspace: string | undefined = undefined
export let activeLabel: string | null = null
@@ -143,17 +144,20 @@
let allSelected: boolean = false
function selectAll() {
if (!selectionMode) return
if (allSelected) {
allSelected = false
selectedIds = []
} else {
allSelected = true
selectedIds = jobs?.filter(isJobCancelable).map((j) => j.id) ?? []
selectedIds = jobs?.filter(isJobSelectable(selectionMode)).map((j) => j.id) ?? []
}
}
let cancelableJobCount: number = 0
$: isSelectingJobsToCancel && (allSelected = selectedIds.length === cancelableJobCount)
$: isSelectingJobsToCancel && (cancelableJobCount = jobs?.filter(isJobCancelable).length ?? 0)
$: selectionMode && (allSelected = selectedIds.length === selectableJobCount)
let selectableJobCount: number = 0
$: selectionMode &&
(selectableJobCount = jobs?.filter(isJobSelectable(selectionMode)).length ?? 0)
function jobCountString(jobCount: number | undefined, lastFetchWentToEnd: boolean): string {
if (jobCount === undefined) {
@@ -195,7 +199,7 @@
bind:clientWidth={containerWidth}
>
<div bind:clientHeight={header}>
{#if isSelectingJobsToCancel && cancelableJobCount != 0}
{#if selectionMode && selectableJobCount}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
@@ -274,10 +278,10 @@
{containsLabel}
job={jobOrDate.job}
selected={jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
{isSelectingJobsToCancel}
{selectionMode}
on:select={() => {
const jobId = jobOrDate.job.id
if (isSelectingJobsToCancel) {
if (selectionMode) {
if (selectedIds.includes(jobOrDate.job.id)) {
selectedIds = selectedIds.filter((id) => id != jobId)
} else {
+11 -7
View File
@@ -1,15 +1,17 @@
import type { Schema } from './common'
import type { Schema, SchemaProperty } from './common'
export function schemaToTsType(schema: Schema): string {
if (!schema || !schema.properties) {
export function schemaToTsType(schema: Schema | SchemaProperty): string {
const schemaProperties = schema.properties
const schemaRequired = schema.required
if (!schema || !schemaProperties) {
return 'any'
}
const propKeys = Object.keys(schema.properties)
const propKeys = Object.keys(schemaProperties)
const types = propKeys
.map((key: string) => {
const prop = schema.properties[key]
const isOptional = !schema.required.includes(key)
const prop = schemaProperties[key]
const isOptional = !schemaRequired?.includes(key)
const prefix = `${key}${isOptional ? '?' : ''}`
let type: string = 'any'
if (prop.type === 'string') {
@@ -19,11 +21,13 @@ export function schemaToTsType(schema: Schema): string {
} else if (prop.type === 'boolean') {
type = 'boolean'
} else if (prop.type === 'array') {
let type = prop.items?.type ?? 'any'
type = prop.items?.type ?? 'any'
if (type === 'integer') {
type = 'number'
}
type = `${type}[]`
} else if (prop.type === 'object' && prop.properties) {
type = schemaToTsType(prop)
}
return `${prefix}: ${type}`
+18 -2
View File
@@ -16,10 +16,22 @@ import type { EnumType, SchemaProperty } from './common'
import type { Schema } from './common'
export { sendUserToast }
import type { AnyMeltElement } from '@melt-ui/svelte'
import type { RunsSelectionMode } from './components/runs/RunsBatchActionsDropdown.svelte'
export function isJobCancelable(j: Job): boolean {
return j.type === 'QueuedJob' && !j.schedule_path && !j.canceled
}
export function isJobReRunnable(j: Job): boolean {
return (j.job_kind === 'script' || j.job_kind === 'flow') && j.parent_job === undefined
}
export function isJobSelectable(selectionType: RunsSelectionMode) {
const f: (j: Job) => boolean = {
cancel: isJobCancelable,
're-run': isJobReRunnable
}[selectionType]
return f
}
export function validateUsername(username: string): string {
if (username != '' && !/^[a-zA-Z]\w+$/.test(username)) {
@@ -85,7 +97,7 @@ export function displayDate(
? {
day: 'numeric',
month: 'numeric'
}
}
: {}
return date.toLocaleString(undefined, {
...timeChoices,
@@ -229,7 +241,7 @@ export function clickOutside(
}
}
const capture = typeof options === 'boolean' ? options : options?.capture ?? true
const capture = typeof options === 'boolean' ? options : (options?.capture ?? true)
document.addEventListener('click', handleClick, capture ?? true)
return {
@@ -605,6 +617,10 @@ export function pluralize(quantity: number, word: string, customPlural?: string)
}
}
export function addDeterminant(word: string): string {
return (/^[aeiou]/i.test(word) ? 'an ' : 'a ') + word
}
export function capitalize(word: string): string {
return word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
}
@@ -7,12 +7,13 @@
FolderService,
ScriptService,
FlowService,
type ExtendedJobs
type ExtendedJobs,
OpenAPI
} from '$lib/gen'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/toast'
import { superadmin, userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common'
import RunChart from '$lib/components/RunChart.svelte'
@@ -31,19 +32,28 @@
import { twMerge } from 'tailwind-merge'
import ManuelDatePicker from '$lib/components/runs/ManuelDatePicker.svelte'
import JobLoader from '$lib/components/runs/JobLoader.svelte'
import { AlertTriangle, Calendar, Check, ChevronDown, Clock, X } from 'lucide-svelte'
import { AlertTriangle, Calendar, ChevronDown, Clock } from 'lucide-svelte'
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { goto } from '$app/navigation'
import { base } from '$app/paths'
import { isJobCancelable } from '$lib/utils'
import type { RunsSelectionMode } from '$lib/components/runs/RunsBatchActionsDropdown.svelte'
import RunsBatchActionsDropdown from '$lib/components/runs/RunsBatchActionsDropdown.svelte'
import { isJobSelectable } from '$lib/utils'
import BatchReRunOptionsPane, {
type BatchReRunOptions
} from '$lib/components/runs/BatchReRunOptionsPane.svelte'
let jobs: Job[] | undefined
let selectedIds: string[] = []
let loadingSelectedIds = false
$: loadingSelectedIds && selectedIds.length && setTimeout(() => (loadingSelectedIds = false), 250)
let selectedWorkspace: string | undefined = undefined
let batchReRunOptions: BatchReRunOptions = { flow: {}, script: {} }
// All Filters
// Filter by
let path: string | null = $page.params.path
@@ -158,9 +168,17 @@
let selectedManualDate = 0
let autoRefresh: boolean = getAutoRefresh()
let runDrawer: Drawer
let isCancelingVisibleJobs = false
let isCancelingFilteredJobs = false
let lookback: number = 1
let askingForConfirmation:
| undefined
| {
title: string
confirmBtnText: string
loading?: boolean
preContent?: string
onConfirm?: () => void
type?: ConfirmationModal['$$prop_def']['type']
} = undefined
function getAutoRefresh() {
try {
@@ -353,8 +371,8 @@
lastFetchWentToEnd = false
selectedManualDate = 0
selectedIds = []
jobIdsToCancel = []
isSelectingJobsToCancel = false
batchReRunOptions = { flow: {}, script: {} }
selectionMode = false
selectedWorkspace = undefined
jobLoader?.loadJobs(minTs, maxTs, true)
}
@@ -484,22 +502,29 @@
}
}
let jobIdsToCancel: string[] = []
let isSelectingJobsToCancel = false
let fetchingFilteredJobs = false
let selectedFiltersString: string | undefined = undefined
let selectionMode: RunsSelectionMode | false = false
async function cancelVisibleJobs() {
isSelectingJobsToCancel = true
selectedIds = jobs?.filter(isJobCancelable).map((j) => j.id) ?? []
if (selectedIds.length === 0) {
sendUserToast('There are no visible jobs that can be canceled', true)
async function onSetSelectionMode(mode: RunsSelectionMode | false) {
selectionMode = mode
if (!mode) {
selectedIds = []
batchReRunOptions = { flow: {}, script: {} }
return
}
const selectableIds = jobs?.filter(isJobSelectable(mode)).map((j) => j.id) ?? []
selectedIds = []
if (!selectableIds?.length) {
sendUserToast(
'There are no visible jobs that can be ' +
{ cancel: 'cancelled', 're-run': 're-ran' }[mode],
true
)
}
}
async function cancelFilteredJobs() {
isCancelingFilteredJobs = true
fetchingFilteredJobs = true
const selectedFilters = {
function getSelectedFilters() {
return {
workspace: $workspaceStore ?? '',
startedBefore: maxTs,
startedAfter: minTs,
@@ -536,19 +561,148 @@
allWorkspaces: allWorkspaces ? true : undefined,
allowWildcards: allowWildcards ? true : undefined
}
selectedFiltersString = JSON.stringify(selectedFilters, null, 4)
jobIdsToCancel = await JobService.listFilteredUuids(selectedFilters)
fetchingFilteredJobs = false
}
async function cancelSelectedJobs() {
jobIdsToCancel = selectedIds
isCancelingVisibleJobs = true
async function cancelJobs(uuidsToCancel: string[]) {
const uuids = await JobService.cancelSelection({
workspace: $workspaceStore ?? '',
requestBody: uuidsToCancel
})
selectedIds = []
jobLoader?.loadJobs(minTs, maxTs, true, true)
sendUserToast(`Canceled ${uuids.length} jobs`)
selectionMode = false
}
function jobCountString(count: number) {
return `${count} ${count == 1 ? 'job' : 'jobs'}`
async function onCancelFilteredJobs() {
askingForConfirmation = {
title: 'Confirm cancelling all jobs corresponding to the selected filters',
confirmBtnText: 'Loading...',
loading: true
}
const selectedFilters = getSelectedFilters()
const selectedFiltersString = JSON.stringify(selectedFilters, null, 4)
const jobIdsToCancel = await JobService.listFilteredQueueUuids(selectedFilters)
askingForConfirmation = {
title: `Confirm cancelling all jobs corresponding to the selected filters (${jobIdsToCancel.length} jobs)`,
confirmBtnText: `Cancel ${jobIdsToCancel.length} jobs that matched the filters`,
preContent: selectedFiltersString,
onConfirm: () => {
cancelJobs(jobIdsToCancel)
}
}
}
async function onCancelSelectedJobs() {
askingForConfirmation = {
confirmBtnText: `Cancel ${selectedIds.length} jobs`,
title: 'Confirm cancelling the selected jobs',
onConfirm: () => {
cancelJobs(selectedIds)
}
}
}
async function reRunJobs(jobIdsToReRun: string[]) {
if (!$workspaceStore) return
if (askingForConfirmation) {
askingForConfirmation.loading = true
}
const body: Parameters<typeof JobService.batchReRunJobs>[0]['requestBody'] = {
job_ids: jobIdsToReRun,
script_options_by_path: batchReRunOptions.script,
flow_options_by_path: batchReRunOptions.flow
}
// workaround because EventSource does not support POST requests
// https://medium.com/@david.richards.tech/sse-server-sent-events-using-a-post-request-without-eventsource-1c0bd6f14425
const response = await fetch(`${OpenAPI.BASE}/w/${$workspaceStore}/jobs/run/batch_rerun_jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
await new Promise(async (resolve) => {
const reader = response?.body?.pipeThrough(new TextDecoderStream()).getReader()
let reRanUuids: string[] = []
if (reader) {
while (true) {
const { value, done } = await reader.read()
if (value) {
// It is possible get multiple values at once in case of buffering
const receivedUuids: string[] = []
for (const line of value.split('\n')) {
if (!line) continue
else if (line.startsWith('Error:')) {
console.error(line)
} else {
receivedUuids.push(line)
}
}
if (receivedUuids.length) {
reRanUuids.push(...receivedUuids)
if (askingForConfirmation) {
askingForConfirmation.confirmBtnText = `${reRanUuids.length}/${jobIdsToReRun.length}`
}
}
}
if (done || !value) {
if (reRanUuids.length) {
sendUserToast(`Re-ran ${reRanUuids.length}/${jobIdsToReRun.length} jobs`)
}
if (reRanUuids.length !== jobIdsToReRun.length) {
sendUserToast(
`Failed to re-run ${jobIdsToReRun.length - reRanUuids.length} jobs. Check console for details`,
true
)
// We do not get explicit error from backend if the job script don't exist
for (const jobId of jobIdsToReRun) {
if (reRanUuids.includes(jobId)) continue
console.error('Could not re-run job ' + jobId)
}
}
break
}
}
}
resolve(undefined)
})
selectedIds = []
batchReRunOptions = { flow: {}, script: {} }
jobLoader?.loadJobs(minTs, maxTs, true, true)
selectionMode = false
}
async function onReRunFilteredJobs() {
const selectedFilters = getSelectedFilters()
selectedIds = []
loadingSelectedIds = true
if (jobKindsCat !== 'runs') {
sendUserToast('Batch re-run is only supported for scripts and flows', true)
}
selectedIds = await JobService.listFilteredJobsUuids({
...selectedFilters,
jobKinds: 'script,flow'
})
selectionMode = 're-run'
}
async function onReRunSelectedJobs() {
const jobIdsToReRun = selectedIds
askingForConfirmation = {
title: `Confirm re-running the selected jobs`,
confirmBtnText: `Re-run ${jobIdsToReRun.length} jobs`,
type: 'reload',
onConfirm: async () => {
await reRunJobs(jobIdsToReRun)
}
}
}
function setLookback(lookbackInDays: number) {
@@ -628,50 +782,25 @@
/>
<ConfirmationModal
title={`Confirm cancelling all jobs correspoding to the selected filters (${jobIdsToCancel.length} jobs)`}
confirmationText={`Cancel ${jobIdsToCancel.length} jobs that matched the filters`}
open={isCancelingFilteredJobs}
title={askingForConfirmation?.title ?? ''}
confirmationText={askingForConfirmation?.confirmBtnText ?? ''}
open={!!askingForConfirmation}
on:confirmed={async () => {
isCancelingFilteredJobs = false
let uuids = await JobService.cancelSelection({
workspace: $workspaceStore ?? '',
requestBody: jobIdsToCancel
})
jobIdsToCancel = []
selectedIds = []
jobLoader?.loadJobs(minTs, maxTs, true, true)
sendUserToast(`Canceled ${uuids.length} jobs`)
isSelectingJobsToCancel = false
const func = askingForConfirmation?.onConfirm
await func?.()
askingForConfirmation = undefined
}}
loading={fetchingFilteredJobs}
type={askingForConfirmation?.type}
loading={askingForConfirmation?.loading}
on:canceled={() => {
isCancelingFilteredJobs = false
askingForConfirmation = undefined
}}
>
<pre>{selectedFiltersString}</pre>
{#if askingForConfirmation?.preContent}
<pre>{askingForConfirmation.preContent}</pre>
{/if}
</ConfirmationModal>
<ConfirmationModal
title={`Confirm cancelling the selected jobs`}
confirmationText={`Cancel ${jobIdsToCancel.length} jobs`}
open={isCancelingVisibleJobs}
on:confirmed={async () => {
isCancelingVisibleJobs = false
let uuids = await JobService.cancelSelection({
workspace: $workspaceStore ?? '',
requestBody: jobIdsToCancel
})
jobIdsToCancel = []
selectedIds = []
jobLoader?.loadJobs(minTs, maxTs, true, true)
sendUserToast(`Canceled ${uuids.length} jobs`)
isSelectingJobsToCancel = false
}}
on:canceled={() => {
isCancelingVisibleJobs = false
}}
/>
<Drawer bind:this={runDrawer}>
<DrawerContent title="Run details" on:close={runDrawer.closeDrawer}>
{#if selectedIds.length === 1}
@@ -815,7 +944,7 @@
<RunChart
{lastFetchWentToEnd}
bind:selectedIds
canSelect={!isSelectingJobsToCancel}
canSelect={!selectionMode}
minTimeSet={minTs}
maxTimeSet={maxTs}
maxIsNow={maxTs == undefined}
@@ -858,69 +987,16 @@
jobsFilter('suspended')
}}
/>
<div class="flex flex-row">
{#if isSelectingJobsToCancel}
<div class="mt-1 p-2 h-8 flex flex-row items-center gap-1">
<Button
startIcon={{ icon: X }}
size="xs"
color="gray"
variant="contained"
on:click={() => {
isSelectingJobsToCancel = false
selectedIds = []
}}
/>
<Button
disabled={selectedIds.length == 0}
startIcon={{ icon: Check }}
size="xs"
color="red"
variant="contained"
on:click={cancelSelectedJobs}
>
Cancel {jobCountString(selectedIds.length)}
</Button>
</div>
{:else if !$userStore?.is_admin && !$superadmin}
<DropdownV2
items={[
{
displayName: 'Select jobs to cancel',
action: cancelVisibleJobs
}
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Cancel jobs</span>
<ChevronDown class="w-5 h-5" />
</div>
</svelte:fragment>
</DropdownV2>
{:else}
<DropdownV2
items={[
{
displayName: 'Select jobs to cancel',
action: cancelVisibleJobs
},
{ displayName: 'Cancel all jobs matching filters', action: cancelFilteredJobs }
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Cancel jobs</span>
<ChevronDown class="w-5 h-5" />
</div>
</svelte:fragment>
</DropdownV2>
{/if}
</div>
<RunsBatchActionsDropdown
isLoading={loadingSelectedIds}
{selectionMode}
selectionCount={selectedIds.length}
{onSetSelectionMode}
{onCancelFilteredJobs}
{onCancelSelectedJobs}
{onReRunFilteredJobs}
{onReRunSelectedJobs}
/>
</div>
<div class="relative flex gap-2 items-center pr-8 w-40" bind:clientWidth={schedulesWidth}>
<Toggle
@@ -1045,7 +1121,7 @@
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
showExternalJobs={!graphIsRunsChart}
activeLabel={label}
{isSelectingJobsToCancel}
{selectionMode}
bind:selectedIds
bind:selectedWorkspace
bind:lastFetchWentToEnd
@@ -1068,8 +1144,10 @@
</div>
{/if}
</Pane>
<Pane size={40} minSize={15} class="border-t">
{#if selectedIds.length === 1}
<Pane size={40} minSize={15} class="border-t flex flex-col">
{#if selectionMode === 're-run'}
<BatchReRunOptionsPane {selectedIds} bind:options={batchReRunOptions} />
{:else if selectedIds.length === 1}
{#if selectedIds[0] === '-'}
<div class="p-4">There is no information available for this job</div>
{:else}
@@ -1195,7 +1273,7 @@
<RunChart
{lastFetchWentToEnd}
bind:selectedIds
canSelect={!isSelectingJobsToCancel}
canSelect={!selectionMode}
minTimeSet={minTs}
maxTimeSet={maxTs}
maxIsNow={maxTs == undefined}
@@ -1241,67 +1319,15 @@
/>
{/if}
<div class="flex flex-row">
{#if isSelectingJobsToCancel}
<div class="mt-1 p-2 h-8 flex flex-row items-center gap-1">
<Button
startIcon={{ icon: X }}
size="xs"
color="gray"
variant="contained"
on:click={() => {
isSelectingJobsToCancel = false
selectedIds = []
}}
/>
<Button
disabled={selectedIds.length == 0}
startIcon={{ icon: Check }}
size="xs"
color="red"
variant="contained"
on:click={cancelSelectedJobs}
>
Cancel {jobCountString(selectedIds.length)}
</Button>
</div>
{:else if !$userStore?.is_admin && !$superadmin}
<DropdownV2
items={[
{
displayName: 'Select jobs to cancel',
action: cancelVisibleJobs
}
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Cancel jobs</span>
<ChevronDown class="w-5 h-5" />
</div>
</svelte:fragment>
</DropdownV2>
{:else}
<DropdownV2
items={[
{
displayName: 'Select jobs to cancel',
action: cancelVisibleJobs
},
{ displayName: 'Cancel all jobs matching filters', action: cancelFilteredJobs }
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Cancel jobs</span>
<ChevronDown class="w-5 h-5" />
</div>
</svelte:fragment>
</DropdownV2>
{/if}
<RunsBatchActionsDropdown
{selectionMode}
selectionCount={selectedIds.length}
{onSetSelectionMode}
{onCancelFilteredJobs}
{onCancelSelectedJobs}
{onReRunFilteredJobs}
{onReRunSelectedJobs}
/>
</div>
</div>
<div class="flex gap-2 py-1">
@@ -1427,13 +1453,13 @@
externalJobs={externalJobs ?? []}
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
showExternalJobs={!graphIsRunsChart}
{isSelectingJobsToCancel}
{selectionMode}
bind:selectedIds
bind:selectedWorkspace
bind:lastFetchWentToEnd
on:loadExtra={loadExtra}
on:select={() => {
if (!isSelectingJobsToCancel) runDrawer.openDrawer()
if (!selectionMode) runDrawer.openDrawer()
}}
on:filterByPath={filterByPath}
on:filterByUser={filterByUser}