Compare commits

..
Author SHA1 Message Date
Pyra f40a3b420b Merge branch 'main' into debouncing-tests 2026-03-06 14:42:25 +01:00
centdix c40ad129bc rename config file (#8230) 2026-03-06 05:03:41 +00:00
wendrul 7859bca6ae fix: cli: support deleting linked resources-variables without throwing (#8248) 2026-03-05 20:09:59 +00:00
wendrul 1ac391a795 fix: wmill workspace whoami output (#8246) 2026-03-05 18:12:21 +00:00
Diego ImbertandClaude Opus 4.6 5d79f33590 Final Svelte 5 migration (#8211)
* Remove $$props.field usage

* Rename slots to ensure no hyphen

* _props

* _trigger

* OnSelectedIteration type correct capitalization

* rename _content

* Remove afterUpdate

* Migrate everything to svelte 5

* array bind

* Fix popover

* type never

* nit fixes

* Fixed many trivial errors

* onClick

* Fix errors

* use let:

* nit typing

* fix: wrap state_referenced_locally vars with untrack()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add untrack import

* Fix all syntax errors due to untrack migration

* Fix undefined errors

* Fix more undefined errors

* untrack(() => initialOpen)

* svelte-ignore

* Fix state_descriptors_fixed error in Chart.svelte

Use $state.snapshot() to pass plain copies of data/options to Chart.js
instead of $state proxies. Chart.js's listenArrayEvents tries to define
property descriptors on data arrays, which Svelte 5 proxies reject.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* nit typing

* Merge issue

* Fix "path is not set" error in resource picker / editor

* Fix InputTransformForm error when rerunning some flows

* fix npm run check

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 18:11:40 +01:00
Pyra 993fbcde59 Merge branch 'main' into debouncing-tests 2026-03-04 17:40:57 +01:00
pyranota b1142421b8 nit: add more tests
Signed-off-by: pyranota <pyra@duck.com>
2026-03-04 17:39:54 +01:00
477 changed files with 5024 additions and 3716 deletions
View File
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, uses_batch_http_pull) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (worker)\n DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_workers = EXCLUDED.dedicated_workers, native_mode = EXCLUDED.native_mode, uses_batch_http_pull = EXCLUDED.uses_batch_http_pull",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"TextArray",
"Varchar",
"Varchar",
"TextArray",
"Varchar",
"Int8",
"Int8",
"Text",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "3e8afd021088a99a24f27fa6f0a1b7f3edba3e9b834c814b464305bc2eb6ba80"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, uses_batch_http_pull = $13 WHERE worker = $6",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"TextArray",
"Float4",
"Int8",
"Int8",
"Text",
"Int8",
"Int8",
"Float4",
"Float4",
"Float4",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "6cd099d458ac380d5da27b9e69da035755496ea50f2b78fb9b1cd3a2eb7e7625"
}
+1 -1
View File
@@ -1 +1 @@
c3c543f4c60a8c4dfe0d912c79a051376fb091a9
f9549c813b3dba5324ea9d1edacc8756a6d699bf
@@ -1 +0,0 @@
ALTER TABLE worker_ping DROP COLUMN IF EXISTS uses_batch_http_pull;
@@ -1 +0,0 @@
ALTER TABLE worker_ping ADD COLUMN IF NOT EXISTS uses_batch_http_pull BOOLEAN NOT NULL DEFAULT false;
+2 -80
View File
@@ -61,9 +61,8 @@ use windmill_common::{
MODE_AND_ADDONS,
},
worker::{
is_native_mode_from_env, reload_custom_tags_setting, Connection, HttpClient, HUB_CACHE_DIR,
HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, USES_BATCH_HTTP_PULL, WINDMILL_DIR,
WORKER_GROUP,
is_native_mode_from_env, reload_custom_tags_setting, Connection, HUB_CACHE_DIR,
HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, WINDMILL_DIR, WORKER_GROUP,
},
KillpillSender, DEFAULT_HUB_BASE_URL, METRICS_ENABLED,
};
@@ -921,20 +920,6 @@ Windmill Community Edition {GIT_VERSION}
default_base_internal_url.clone()
};
// BATCH_PULL_URL: explicit URL for native workers to pull jobs via HTTP.
// In standalone mode (server_mode=true), defaults to the local server.
let batch_pull_url: Option<String> = if is_native_mode_from_env() {
if let Ok(url) = std::env::var("BATCH_PULL_URL") {
Some(url)
} else if server_mode {
Some(default_base_internal_url.clone())
} else {
None
}
} else {
None
};
initial_load(
&conn,
killpill_tx.clone(),
@@ -1145,30 +1130,6 @@ Windmill Community Edition {GIT_VERSION}
)?;
let mut workers = vec![];
// For native workers, create a self-signed JWT for batch pulling via HTTP.
// Enabled when BATCH_PULL_URL is set (explicitly or auto-detected in standalone mode).
let batch_pull_client = if let Some(ref pull_url) = batch_pull_url {
match create_native_batch_pull_client(pull_url).await {
Ok(client) => {
tracing::info!(
"Native batch pull client created for HTTP pull at {}",
pull_url
);
USES_BATCH_HTTP_PULL
.store(true, std::sync::atomic::Ordering::Relaxed);
Some(client)
}
Err(e) => {
tracing::warn!(
"Failed to create native batch pull client, falling back to SQL pull: {e:#}"
);
None
}
}
} else {
None
};
for i in 0..num_workers {
let suffix = if i == 0 && first_suffix.is_some() {
first_suffix.as_ref().unwrap().clone()
@@ -1192,7 +1153,6 @@ Windmill Community Edition {GIT_VERSION}
WORKER_GROUP.as_str(),
&suffix,
),
batch_pull_client: batch_pull_client.clone(),
};
workers.push(worker_conn);
}
@@ -1806,7 +1766,6 @@ fn display_config(envs: &[&str]) {
pub struct WorkerConn {
conn: Connection,
worker_name: String,
batch_pull_client: Option<HttpClient>,
}
pub async fn run_workers(
@@ -1877,7 +1836,6 @@ pub async fn run_workers(
let wk_conf = &workers[i as usize - 1];
let conn1 = wk_conf.conn.clone();
let worker_name = wk_conf.worker_name.clone();
let batch_pull_client = wk_conf.batch_pull_client.clone();
WORKERS_NAMES.write().await.push(worker_name.clone());
let ip = ip.clone();
let rx = killpill_rxs.pop().unwrap();
@@ -1900,7 +1858,6 @@ pub async fn run_workers(
rx,
tx,
&base_internal_url,
batch_pull_client.as_ref(),
);
// #[cfg(tokio_unstable)]
@@ -1919,41 +1876,6 @@ pub async fn run_workers(
Ok(())
}
/// Create an HTTP client for native workers to pull jobs from the local server's batch buffer.
/// Self-signs a JWT with native_mode=true using the same JWT secret the server uses.
async fn create_native_batch_pull_client(base_internal_url: &str) -> anyhow::Result<HttpClient> {
use windmill_common::agent_workers::{build_agent_http_client, AGENT_JWT_PREFIX};
use windmill_common::jwt::encode_with_internal_secret;
#[derive(serde::Serialize)]
struct NativeAgentAuth {
worker_group: String,
tags: Vec<String>,
native_mode: Option<bool>,
exp: usize,
}
let worker_config = windmill_common::worker::WORKER_CONFIG.read().await;
let tags = worker_config.worker_tags.clone();
drop(worker_config);
// Token expires in 30 days — renewed on restart
let exp = (chrono::Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
let claims = NativeAgentAuth {
worker_group: WORKER_GROUP.to_string(),
tags,
native_mode: Some(true),
exp,
};
let jwt = encode_with_internal_secret(claims).await?;
let token = format!("{}{}", AGENT_JWT_PREFIX, jwt);
let suffix = create_default_worker_suffix(&HOSTNAME);
Ok(build_agent_http_client(&suffix, &token, base_internal_url))
}
async fn send_delayed_killpill(tx: &KillpillSender, mut max_delay_secs: u64, context: &str) {
if max_delay_secs == 0 {
max_delay_secs = 1;
+1 -1
View File
@@ -174,7 +174,7 @@ websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), work
windmill_migrations: name(text), created_at(ts)
worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint)
FK: (workspace_id) -> workspace(id)
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]), native_mode(bool), uses_batch_http_pull(bool)
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[])
workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char)
FK: (parent_workspace_id) -> workspace(id)
workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts)
+7 -17
View File
@@ -9,23 +9,7 @@ export async function main() {
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
'',
'',
'f/leafs/ts', 500001, 'nativets', '');
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
'test-workspace',
'test-user',
'
package main
import "fmt"
func main() {
fmt.Println("Go leaf")
}',
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
'',
'',
'f/leafs/go', 500002, 'go', '');
'f/leafs/ts', 500001, 'bun', '');
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
'test-workspace',
@@ -52,3 +36,9 @@ function main() {
'',
'f/leafs/php', 500004, 'php', '');
-- Link scripts to named workspace dependencies (name: "test")
INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) VALUES
('test-workspace', 'f/leafs/ts', 'script', 'dependencies/test.package.json', ''),
('test-workspace', 'f/leafs/python', 'script', 'dependencies/test.requirements.in', ''),
('test-workspace', 'f/leafs/php', 'script', 'dependencies/test.composer.json', '');
-1
View File
@@ -241,7 +241,6 @@ fn spawn_workers(
rx,
tx2,
&base_internal_url,
None,
)
.await;
};
+571 -60
View File
@@ -1,21 +1,17 @@
mod workspace_dependencies {
use windmill_test_utils::in_test_worker;
use windmill_test_utils::init_client;
use windmill_test_utils::listen_for_completed_jobs;
use sqlx::{Pool, Postgres};
use tokio_stream::StreamExt;
use windmill_common::scripts::ScriptLang;
use windmill_common::workspace_dependencies::WorkspaceDependencies;
use windmill_dep_map::workspace_dependencies::NewWorkspaceDependencies;
use windmill_test_utils::in_test_worker;
use windmill_test_utils::init_client;
use windmill_test_utils::listen_for_completed_jobs;
mod deps {
pub const REQUIREMENTS_IN: &'static str = "tiny==0.1.3";
// pub const GO_MOD: &'static str = r##"
// module example.com/project
// go 1.20
// require github.com/gin-gonic/gin v1.8.1
// "##;
pub const REQUIREMENTS_IN_V2: &'static str = "tiny==0.2.0";
pub const PACKAGE_JSON: &'static str = r##"
{
@@ -25,6 +21,18 @@ mod workspace_dependencies {
"express": "^4.17.1"
}
}
"##;
#[allow(dead_code)]
pub const PACKAGE_JSON_V2: &'static str = r##"
{
"name": "example-project",
"version": "2.0.0",
"dependencies": {
"express": "^4.18.0",
"axios": "^1.0.0"
}
}
"##;
pub const COMPOSER_JSON: &'static str = r##"
@@ -37,9 +45,510 @@ mod workspace_dependencies {
"##;
}
// =========================================================================
// CRUD Tests
// =========================================================================
/// Test: Create workspace dependencies and verify they are stored correctly.
#[sqlx::test(fixtures("base"))]
async fn test_create_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
let id = NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN.into(),
name: Some("test-deps".to_owned()),
description: Some("Test dependencies".to_owned()),
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
assert!(id > 0, "Should return a valid ID");
// Verify it was stored correctly
let stored = WorkspaceDependencies::get(id, "test-workspace".to_owned(), &db).await?;
assert_eq!(stored.name, Some("test-deps".to_owned()));
assert_eq!(stored.content, deps::REQUIREMENTS_IN);
assert_eq!(stored.language, ScriptLang::Python3);
Ok(())
}
/// Test: Create unnamed (default) workspace dependencies.
#[sqlx::test(fixtures("base"))]
async fn test_create_unnamed_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
let id = NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Bun,
content: deps::PACKAGE_JSON.into(),
name: None, // Unnamed = default
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
assert!(id > 0, "Should return a valid ID");
// Verify it was stored correctly
let stored = WorkspaceDependencies::get(id, "test-workspace".to_owned(), &db).await?;
assert_eq!(stored.name, None);
assert_eq!(stored.language, ScriptLang::Bun);
Ok(())
}
/// Test: List workspace dependencies returns all active entries.
#[sqlx::test(fixtures("base"))]
async fn test_list_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
// Create multiple workspace dependencies
for (lang, content, name) in [
(ScriptLang::Python3, deps::REQUIREMENTS_IN, Some("python-deps")),
(ScriptLang::Bun, deps::PACKAGE_JSON, Some("bun-deps")),
(ScriptLang::Bun, deps::PACKAGE_JSON, None), // Default bun deps
] {
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: lang,
content: content.into(),
name: name.map(|s| s.to_owned()),
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
}
let list = WorkspaceDependencies::list("test-workspace", &db).await?;
assert_eq!(list.len(), 3, "Should have 3 workspace dependencies");
Ok(())
}
/// Test: Archive workspace dependencies marks them as archived.
#[sqlx::test(fixtures("base"))]
async fn test_archive_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
// Create workspace dependencies
let _id = NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN.into(),
name: Some("to-archive".to_owned()),
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
// Verify it exists
let list_before = WorkspaceDependencies::list("test-workspace", &db).await?;
assert_eq!(list_before.len(), 1);
// Archive it
WorkspaceDependencies::archive(
Some("to-archive".to_owned()),
ScriptLang::Python3,
"test-workspace",
&db,
)
.await?;
// Verify it's no longer in the active list
let list_after = WorkspaceDependencies::list("test-workspace", &db).await?;
assert_eq!(list_after.len(), 0, "Archived deps should not appear in list");
Ok(())
}
/// Test: Delete workspace dependencies permanently removes them.
#[sqlx::test(fixtures("base"))]
async fn test_delete_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
// Create workspace dependencies
let id = NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN.into(),
name: Some("to-delete".to_owned()),
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
// Verify it exists
assert!(
WorkspaceDependencies::get(id, "test-workspace".to_owned(), &db)
.await
.is_ok()
);
// Delete it
WorkspaceDependencies::delete(
Some("to-delete".to_owned()),
ScriptLang::Python3,
"test-workspace",
&db,
)
.await?;
// Verify it's gone (should error)
let result = WorkspaceDependencies::get(id, "test-workspace".to_owned(), &db).await;
assert!(result.is_err(), "Deleted deps should not be retrievable");
Ok(())
}
// =========================================================================
// Version History Tests
// =========================================================================
/// Test: Creating new version archives the old one.
#[sqlx::test(fixtures("base"))]
async fn test_versioning_archives_previous(db: Pool<Postgres>) -> anyhow::Result<()> {
// Create first version
let id1 = NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN.into(),
name: Some("versioned".to_owned()),
description: Some("Version 1".to_owned()),
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
// Create second version with same name
let id2 = NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN_V2.into(),
name: Some("versioned".to_owned()),
description: Some("Version 2".to_owned()),
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
assert_ne!(id1, id2, "Should create a new entry");
// List should only show the active (latest) version
let list = WorkspaceDependencies::list("test-workspace", &db).await?;
assert_eq!(list.len(), 1, "Should only have 1 active entry");
assert_eq!(list[0].content, deps::REQUIREMENTS_IN_V2);
// History should show both versions
let history = WorkspaceDependencies::get_history(
Some("versioned".to_owned()),
ScriptLang::Python3,
"test-workspace",
&db,
)
.await?;
assert_eq!(history.len(), 2, "Should have 2 versions in history");
Ok(())
}
/// Test: Description is inherited from previous version if not provided.
#[sqlx::test(fixtures("base"))]
async fn test_description_inheritance(db: Pool<Postgres>) -> anyhow::Result<()> {
// Create first version with description
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN.into(),
name: Some("inherit-desc".to_owned()),
description: Some("Original description".to_owned()),
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
// Create second version without description
let id2 = NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN_V2.into(),
name: Some("inherit-desc".to_owned()),
description: None, // Should inherit
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
let stored = WorkspaceDependencies::get(id2, "test-workspace".to_owned(), &db).await?;
assert_eq!(
stored.description,
Some("Original description".to_owned()),
"Description should be inherited from previous version"
);
Ok(())
}
// =========================================================================
// Workspace Isolation Tests
// =========================================================================
/// Test: Workspace dependencies are isolated between workspaces.
#[sqlx::test(fixtures("base"))]
async fn test_workspace_isolation(db: Pool<Postgres>) -> anyhow::Result<()> {
// Create another workspace
sqlx::query!(
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'test-user')"
)
.execute(&db)
.await?;
sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('other-workspace')")
.execute(&db)
.await?;
// Create deps in test-workspace
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN.into(),
name: Some("shared-name".to_owned()),
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
// Create deps in other-workspace with same name
NewWorkspaceDependencies {
workspace_id: "other-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN_V2.into(),
name: Some("shared-name".to_owned()),
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
// Each workspace should have exactly 1 entry
let list1 = WorkspaceDependencies::list("test-workspace", &db).await?;
let list2 = WorkspaceDependencies::list("other-workspace", &db).await?;
assert_eq!(list1.len(), 1);
assert_eq!(list2.len(), 1);
// Content should be different
assert_eq!(list1[0].content, deps::REQUIREMENTS_IN);
assert_eq!(list2[0].content, deps::REQUIREMENTS_IN_V2);
Ok(())
}
// =========================================================================
// Language-specific Tests
// =========================================================================
/// Test: Different languages can have same-named workspace dependencies.
#[sqlx::test(fixtures("base"))]
async fn test_same_name_different_languages(db: Pool<Postgres>) -> anyhow::Result<()> {
// Create Python deps
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: deps::REQUIREMENTS_IN.into(),
name: Some("common".to_owned()),
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
// Create Bun deps with same name
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Bun,
content: deps::PACKAGE_JSON.into(),
name: Some("common".to_owned()),
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
let list = WorkspaceDependencies::list("test-workspace", &db).await?;
assert_eq!(list.len(), 2, "Should have 2 entries (different languages)");
let python_deps: Vec<_> = list
.iter()
.filter(|d| d.language == ScriptLang::Python3)
.collect();
let bun_deps: Vec<_> = list
.iter()
.filter(|d| d.language == ScriptLang::Bun)
.collect();
assert_eq!(python_deps.len(), 1);
assert_eq!(bun_deps.len(), 1);
Ok(())
}
/// Test: Nativets and Bunnative use Bun workspace dependencies.
#[sqlx::test(fixtures("base"))]
async fn test_nativets_uses_bun_deps(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::worker::Connection;
// Create Bun deps (which Nativets should use)
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Bun,
content: deps::PACKAGE_JSON.into(),
name: None,
description: None,
}
.create(
(
"test@test.com".to_owned(),
"u/test".to_owned(),
"test".to_owned(),
),
db.clone(),
)
.await?;
// Query for Nativets should return Bun deps
let result = WorkspaceDependencies::get_latest(
None,
ScriptLang::Nativets,
"test-workspace",
Connection::Sql(db.clone()),
)
.await?;
assert!(result.is_some(), "Nativets should find Bun deps");
assert_eq!(result.unwrap().language, ScriptLang::Bun);
Ok(())
}
// =========================================================================
// Path Generation Tests
// =========================================================================
/// Test: to_path generates correct paths for named and unnamed deps.
#[test]
fn test_to_path_generation() {
// Unnamed (default) deps
let path = WorkspaceDependencies::to_path(&None, ScriptLang::Python3).unwrap();
assert_eq!(path, "dependencies/requirements.in");
let path = WorkspaceDependencies::to_path(&None, ScriptLang::Bun).unwrap();
assert_eq!(path, "dependencies/package.json");
let path = WorkspaceDependencies::to_path(&None, ScriptLang::Php).unwrap();
assert_eq!(path, "dependencies/composer.json");
// Named deps
let path =
WorkspaceDependencies::to_path(&Some("custom".to_owned()), ScriptLang::Python3).unwrap();
assert_eq!(path, "dependencies/custom.requirements.in");
let path =
WorkspaceDependencies::to_path(&Some("custom".to_owned()), ScriptLang::Bun).unwrap();
assert_eq!(path, "dependencies/custom.package.json");
}
/// Test: to_path returns error for unsupported languages.
#[test]
fn test_to_path_unsupported_language() {
// Deno doesn't support workspace dependencies
let result = WorkspaceDependencies::to_path(&None, ScriptLang::Deno);
assert!(result.is_err(), "Deno should not support workspace deps");
}
/// Test E2E: Creating named workspace dependencies triggers re-lock jobs for dependent scripts.
///
/// This test:
/// 1. Uses fixture with Python, Bun, PHP scripts linked to named workspace deps via dependency_map
/// 2. Creates named workspace dependencies for each language
/// 3. Verifies dependency jobs are triggered for all linked scripts
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "workspace_dependencies_leafs"))]
#[ignore]
async fn basic_manual_named(db: Pool<Postgres>) -> anyhow::Result<()> {
let ((_client, port, _s), db, mut completed) = (
init_client(db.clone()).await,
@@ -47,67 +556,69 @@ mod workspace_dependencies {
listen_for_completed_jobs(&db).await,
);
for (idx, (l, c)) in [
// Create named workspace dependencies for Python, Bun, and PHP
// These will trigger dependency jobs for scripts linked via dependency_map
for (lang, content) in [
(ScriptLang::Python3, deps::REQUIREMENTS_IN),
(ScriptLang::Bun, deps::PACKAGE_JSON),
(ScriptLang::Php, deps::COMPOSER_JSON),
// (ScriptLang::Go, deps::GO_MOD),
]
.iter()
.enumerate()
{
let id = NewWorkspaceDependencies {
] {
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: *l,
content: (*c).into(),
language: lang,
content: content.into(),
name: Some("test".to_owned()),
description: None,
}
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();
assert_eq!(idx + 1, id as usize);
.create(
(
"test@test.com".to_owned(),
"u/test-user".to_owned(),
"test-user".to_owned(),
),
db.clone(),
)
.await?;
}
// Wait for 4 jobs.
// Creating those dependencies will trigger redeployment of all scripts in workspace_dependencies_leafs.sql
in_test_worker(
db,
async {
completed.next().await;
completed.next().await;
completed.next().await;
// completed.next().await;
},
port,
)
.await;
// Wait for 3 dependency jobs (one per script in fixture)
let mut completed_paths = vec![];
for _ in 0..3 {
let job_id = in_test_worker(db, async { completed.next().await }, port)
.await
.expect("Expected a dependency job to complete");
// Verify all scripts have correct locks
// let mut langs = vec![];
// for r in sqlx::query!(
// r#"SELECT language AS "language: ScriptLang",lock FROM script WHERE archived = false"#
// )
// .fetch_all(db)
// .await
// .unwrap()
// {
// match r.language {
// ScriptLang::Python3 => assert_eq!("", &r.lock.unwrap()),
// ScriptLang::Go => todo!(),
// ScriptLang::Bun => todo!(),
// ScriptLang::Bunnative => todo!(),
// ScriptLang::Php => todo!(),
// _ => panic!("Unsupported language"),
// }
let job_path = sqlx::query_scalar!(
"SELECT runnable_path FROM v2_job WHERE id = $1",
job_id
)
.fetch_one(db)
.await?;
// langs.push(r.language);
// }
if let Some(path) = job_path {
completed_paths.push(path);
}
}
// langs.sort();
// // Just tiny additional verification for peace of mind.
// assert_eq!(langs.as_slice(), &[]);
// Verify all 3 scripts received dependency jobs
completed_paths.sort();
let expected = vec![
"f/leafs/php".to_string(),
"f/leafs/python".to_string(),
"f/leafs/ts".to_string(),
];
assert_eq!(
completed_paths, expected,
"All scripts should have received dependency jobs"
);
// Verify no extra jobs were created
let total_jobs = sqlx::query_scalar!("SELECT COUNT(*) FROM v2_job")
.fetch_one(db)
.await?;
assert_eq!(total_jobs, Some(3), "Should have exactly 3 jobs");
Ok(())
}
@@ -19,10 +19,7 @@ use windmill_common::DB;
use axum::Router;
#[cfg(not(feature = "private"))]
pub fn global_service(
_job_completed_tx: windmill_worker::JobCompletedSender,
_batch_buffer: Option<()>,
) -> Router {
pub fn global_service(_job_completed_tx: windmill_worker::JobCompletedSender) -> Router {
Router::new()
}
@@ -34,7 +31,6 @@ pub fn workspaced_service(
Router,
Vec<tokio::task::JoinHandle<()>>,
Option<windmill_worker::JobCompletedSender>,
Option<()>,
) {
use windmill_common::worker::Connection;
use windmill_worker::JobCompletedSender;
@@ -44,7 +40,7 @@ pub fn workspaced_service(
let router = Router::new();
(router, vec![], Some(job_completed_tx), None)
(router, vec![], Some(job_completed_tx))
}
#[cfg(not(feature = "private"))]
+2 -2
View File
@@ -5416,12 +5416,12 @@ async fn add_batch_jobs(
if dedicated_worker && path.is_some() {
windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap())
} else {
language.as_worker_tag(false).to_string()
format!("{}", language.as_str())
}
} else if let Some(tag) = batch_info.tag {
tag
} else {
language.as_worker_tag(false).to_string()
format!("{}", language.as_str())
};
let mut tx = user_db.begin(&authed).await?;
+6 -11
View File
@@ -493,16 +493,12 @@ pub async fn run_server(
};
#[cfg(feature = "agent_worker_server")]
let (
agent_workers_router,
agent_workers_bg_processor,
agent_workers_job_completed_tx,
batch_buffer,
) = if server_mode {
windmill_api_agent_workers::workspaced_service(db.clone(), _base_internal_url.clone())
} else {
(Router::new(), vec![], None, None)
};
let (agent_workers_router, agent_workers_bg_processor, agent_workers_job_completed_tx) =
if server_mode {
windmill_api_agent_workers::workspaced_service(db.clone(), _base_internal_url.clone())
} else {
(Router::new(), vec![], None)
};
#[cfg(feature = "agent_worker_server")]
let agent_cache = Arc::new(AgentCache::new());
@@ -688,7 +684,6 @@ pub async fn run_server(
{
windmill_api_agent_workers::global_service(
agent_workers_job_completed_tx,
batch_buffer.clone(),
)
.layer(Extension(agent_cache.clone()))
} else {
+3 -71
View File
@@ -288,10 +288,6 @@ pub fn is_native_mode_from_env() -> bool {
/// Use this for hot-path checks (e.g. per-job dispatch) to avoid read-locking WORKER_CONFIG.
pub static NATIVE_MODE_RESOLVED: AtomicBool = AtomicBool::new(false);
/// Whether this worker uses HTTP batch pull (set at startup in main.rs).
/// Reported in worker_ping so the server knows which native workers to batch-pull for.
pub static USES_BATCH_HTTP_PULL: AtomicBool = AtomicBool::new(false);
pub static MIN_VERSION_IS_LATEST: AtomicBool = AtomicBool::new(false);
#[derive(Clone)]
pub struct HttpClient {
@@ -520,62 +516,6 @@ pub fn make_pull_query(tags: &[String]) -> String {
query
}
pub fn make_batch_pull_query(tags: &[String], limit: u32) -> String {
format_batch_pull_query(format!(
"SELECT id
FROM v2_job_queue
WHERE running = false AND tag IN ({}) AND scheduled_for <= now()
ORDER BY priority DESC NULLS LAST, scheduled_for
FOR UPDATE SKIP LOCKED
LIMIT {limit}",
tags.iter().map(|x| format!("'{x}'")).join(", ")
))
}
fn format_batch_pull_query(peek: String) -> String {
// Optimizations vs single-row format_pull_query:
// 1. ANY(ARRAY(SELECT ...)) instead of IN (SELECT ...) — forces PG to materialize IDs
// into an array, enabling Bitmap Index Scan instead of Hash Semi Join / Nested Loop
// 2. r CTE chains off q (not peek) — only updates runtime for actually-locked rows,
// avoids re-scanning peek
// 3. No separate j CTE — join v2_job directly in final SELECT off q's IDs
format!(
"WITH peek AS (
{}
), q AS NOT MATERIALIZED (
UPDATE v2_job_queue SET
running = true,
started_at = coalesce(started_at, now()),
suspend_until = null,
worker = $1
WHERE id = ANY(ARRAY(SELECT id FROM peek))
RETURNING
id, started_at, scheduled_for,
canceled_by, canceled_reason, worker, cache_ignore_s3_path, runnable_settings_handle
), r AS NOT MATERIALIZED (
UPDATE v2_job_runtime SET
ping = now()
WHERE id = ANY(ARRAY(SELECT id FROM q))
) SELECT j.id, j.workspace_id, j.parent_job, j.created_by, q.started_at, q.scheduled_for,
j.runnable_id, j.runnable_path, j.args, q.canceled_by,
q.canceled_reason, j.kind, j.trigger, j.trigger_kind, j.permissioned_as,
f.flow_status, j.script_lang,
j.same_worker, j.pre_run_error, j.visible_to_owner,
j.tag, j.concurrent_limit, j.concurrency_time_window_s, j.flow_innermost_root_job, j.root_job,
j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, j.priority, j.raw_code, j.raw_lock, j.raw_flow,
j.script_entrypoint_override, j.preprocessed, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path,
COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin,
p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email
FROM q
JOIN v2_job j ON q.id = j.id
LEFT JOIN v2_job_status f ON f.id = q.id
LEFT JOIN job_perms p ON p.job_id = q.id
LEFT JOIN v2_job pj ON j.parent_job = pj.id
",
peek
)
}
pub async fn store_pull_query(wc: &WorkerConfig) {
let mut queries = vec![];
for tags in wc.priority_tags_sorted.iter() {
@@ -1254,8 +1194,6 @@ pub struct Ping {
pub occupancy_rate_30m: Option<f32>,
pub job_isolation: Option<String>,
pub native_mode: Option<bool>,
#[serde(default)]
pub uses_batch_http_pull: Option<bool>,
pub ping_type: PingType,
}
pub async fn update_ping_http(
@@ -1280,7 +1218,6 @@ pub async fn update_ping_http(
insert_ping.occupancy_rate_5m,
insert_ping.occupancy_rate_30m,
insert_ping.native_mode.unwrap_or(false),
insert_ping.uses_batch_http_pull.unwrap_or(false),
db,
)
.await?
@@ -1308,7 +1245,6 @@ pub async fn update_ping_http(
insert_ping.memory,
insert_ping.job_isolation,
insert_ping.native_mode.unwrap_or(false),
insert_ping.uses_batch_http_pull.unwrap_or(false),
db,
)
.await?;
@@ -1441,12 +1377,11 @@ pub async fn insert_ping_query(
memory: Option<i64>,
job_isolation: Option<String>,
native_mode: bool,
uses_batch_http_pull: bool,
db: &DB,
) -> anyhow::Result<()> {
sqlx::query!(
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, uses_batch_http_pull) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (worker)
DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_workers = EXCLUDED.dedicated_workers, native_mode = EXCLUDED.native_mode, uses_batch_http_pull = EXCLUDED.uses_batch_http_pull",
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)
DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_workers = EXCLUDED.dedicated_workers, native_mode = EXCLUDED.native_mode",
worker_instance,
worker_name,
ip,
@@ -1459,7 +1394,6 @@ pub async fn insert_ping_query(
memory,
job_isolation.as_deref(),
native_mode,
uses_batch_http_pull,
)
.execute(db)
.await?;
@@ -1551,13 +1485,12 @@ pub async fn update_worker_ping_main_loop_query(
occupancy_rate_5m: Option<f32>,
occupancy_rate_30m: Option<f32>,
native_mode: bool,
uses_batch_http_pull: bool,
db: &DB,
) -> anyhow::Result<()> {
timeout(Duration::from_secs(10), sqlx::query!(
"UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,
occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),
memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, uses_batch_http_pull = $13 WHERE worker = $6",
memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12 WHERE worker = $6",
jobs_executed,
tags,
occupancy_rate,
@@ -1570,7 +1503,6 @@ pub async fn update_worker_ping_main_loop_query(
occupancy_rate_5m,
occupancy_rate_30m,
native_mode,
uses_batch_http_pull,
)
.execute(db))
.await??;
+9 -33
View File
@@ -3434,38 +3434,6 @@ async fn pull_single_job_and_mark_as_running_no_concurrency_limit<'c>(
Ok(job_and_suspended)
}
/// Batch-pull up to `limit` jobs in a single query, marking them all as running.
/// The caller controls which tags are queried, so flow/dependency jobs are never
/// pulled (they use distinct tags like "flow" / "dependency").
pub async fn batch_pull(
db: &Pool<Postgres>,
worker_name: &str,
tags: &[String],
limit: u32,
) -> windmill_common::error::Result<Vec<PulledJob>> {
use windmill_common::worker::make_batch_pull_query;
if limit == 0 || tags.is_empty() {
return Ok(vec![]);
}
let query = make_batch_pull_query(tags, limit);
let jobs: Vec<PulledJob> = timeout(
Duration::from_secs(15),
sqlx::query_as::<_, PulledJob>(&query)
.bind(worker_name)
.fetch_all(db),
)
.await
.map_err(|_| {
windmill_common::error::Error::internal_err(
"batch_pull query timed out after 15s".to_string(),
)
})??;
Ok(jobs)
}
pub async fn custom_concurrency_key(
db: &Pool<Postgres>,
job_id: &Uuid,
@@ -5405,7 +5373,15 @@ async fn push_inner<'c, 'd>(
language
.as_ref()
.map(|x| {
let tag_lang = x.as_worker_tag(job_kind == JobKind::Dependencies);
let tag_lang = if x == &ScriptLang::Bunnative {
if job_kind == JobKind::Dependencies {
ScriptLang::Bun.as_str()
} else {
ScriptLang::Nativets.as_str()
}
} else {
x.as_str()
};
if per_workspace {
format!("{}-{}", tag_lang, workspace_id)
} else {
-1
View File
@@ -421,7 +421,6 @@ pub fn spawn_test_worker(
rx,
tx2,
&base_internal_url,
None,
)
.await
};
+9 -23
View File
@@ -88,20 +88,6 @@ impl ScriptLang {
}
}
/// Returns the worker tag for this language.
/// Bunnative scripts run on nativets workers (not bun), except dependency jobs which use bun.
pub fn as_worker_tag(&self, is_dependency_job: bool) -> &'static str {
if *self == ScriptLang::Bunnative {
if is_dependency_job {
ScriptLang::Bun.as_str()
} else {
ScriptLang::Nativets.as_str()
}
} else {
self.as_str()
}
}
pub fn as_dependencies_filename(&self) -> Option<String> {
use ScriptLang::*;
Some(
@@ -119,15 +105,15 @@ impl ScriptLang {
pub fn is_native(&self) -> bool {
matches!(
self,
ScriptLang::Bunnative
| ScriptLang::Nativets
| ScriptLang::Postgresql
| ScriptLang::Mysql
| ScriptLang::Graphql
| ScriptLang::Snowflake
| ScriptLang::Mssql
| ScriptLang::Bigquery
| ScriptLang::OracleDB
ScriptLang::Bunnative |
ScriptLang::Nativets |
ScriptLang::Postgresql |
ScriptLang::Mysql |
ScriptLang::Graphql |
ScriptLang::Snowflake |
ScriptLang::Mssql |
ScriptLang::Bigquery |
ScriptLang::OracleDB
)
}
-1
View File
@@ -563,7 +563,6 @@ pub async fn update_worker_ping_for_failed_init_script(
wm_memory_usage: None,
job_isolation: None,
native_mode: None,
uses_batch_http_pull: None,
ping_type: PingType::InitScript,
},
)
+106 -121
View File
@@ -1368,7 +1368,6 @@ pub async fn run_worker(
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
killpill_tx: KillpillSender,
base_internal_url: &str,
batch_pull_client: Option<&HttpClient>,
) {
#[cfg(not(feature = "enterprise"))]
if is_sandboxing_enabled() {
@@ -2069,149 +2068,135 @@ pub async fn run_worker(
continue;
}
} else {
// If batch_pull_client is set (native worker with co-located server),
// use HTTP pull from batch buffer. Otherwise use direct SQL pull.
if let Some(bpc) = batch_pull_client {
crate::agent_workers::pull_job(bpc, None, None)
.await
.map_err(|e| error::Error::InternalErr(e.to_string()))
.map(|x| x.map(|y| NextJob::Http(y)))
} else {
match &conn {
Connection::Sql(db) => {
let pull_time = Instant::now();
let likelihood_of_suspend = last_30jobs_suspended as f64 / 30.0;
match &conn {
Connection::Sql(db) => {
let pull_time = Instant::now();
let likelihood_of_suspend = last_30jobs_suspended as f64 / 30.0;
let suspend_first = suspend_first_success
|| rand::random::<f64>() < likelihood_of_suspend
|| last_suspend_first.elapsed().as_secs_f64() > 5.0;
let suspend_first = suspend_first_success
|| rand::random::<f64>() < likelihood_of_suspend
|| last_suspend_first.elapsed().as_secs_f64() > 5.0;
if suspend_first {
last_suspend_first = Instant::now();
}
let mut job = match timeout(
Duration::from_secs(30),
pull(
&db,
suspend_first,
&worker_name,
None,
#[cfg(feature = "benchmark")]
&mut bench,
)
.warn_after_seconds(2),
if suspend_first {
last_suspend_first = Instant::now();
}
let mut job = match timeout(
Duration::from_secs(30),
pull(
&db,
suspend_first,
&worker_name,
None,
#[cfg(feature = "benchmark")]
&mut bench,
)
.warn_after_seconds(2),
)
.await
{
Ok(job) => job,
Err(e) => {
tracing::error!(worker = %worker_name, hostname = %hostname, "pull timed out after 20s, sleeping for 30s: {e:?}");
tokio::time::sleep(Duration::from_secs(30)).await;
continue;
}
};
// Preprocess pulled job result
if let Ok(ref mut pulled_job_res) = job {
if let Err(e) = timeout(
// Will fail if longer than 10 seconds
core::time::Duration::from_secs(10),
pulled_job_res.maybe_apply_debouncing(db),
)
.warn_after_seconds(2)
.await
// Flatten result
.map_err(error::Error::from)
.and_then(|r| r)
{
Ok(job) => job,
Err(e) => {
tracing::error!(worker = %worker_name, hostname = %hostname, "pull timed out after 20s, sleeping for 30s: {e:?}");
tokio::time::sleep(Duration::from_secs(30)).await;
continue;
}
};
// Preprocess pulled job result
if let Ok(ref mut pulled_job_res) = job {
if let Err(e) = timeout(
// Will fail if longer than 10 seconds
core::time::Duration::from_secs(10),
pulled_job_res.maybe_apply_debouncing(db),
)
.warn_after_seconds(2)
.await
// Flatten result
.map_err(error::Error::from)
.and_then(|r| r)
{
pulled_job_res.error_while_preprocessing = Some(e.to_string());
}
pulled_job_res.error_while_preprocessing = Some(e.to_string());
}
}
add_time!(bench, "job pulled from DB");
let duration_pull_s = pull_time.elapsed().as_secs_f64();
let err_pull = job.is_ok();
// let empty = job.as_ref().is_ok_and(|x| x.is_none());
add_time!(bench, "job pulled from DB");
let duration_pull_s = pull_time.elapsed().as_secs_f64();
let err_pull = job.is_ok();
// let empty = job.as_ref().is_ok_and(|x| x.is_none());
if duration_pull_s > 0.5 {
let empty = job.as_ref().is_ok_and(|x| x.job.is_none());
tracing::warn!(worker = %worker_name, hostname = %hostname, "pull took more than 0.5s ({duration_pull_s}), this is a sign that the database is VERY undersized for this load. empty: {empty}, err: {err_pull}");
#[cfg(feature = "prometheus")]
if empty {
if let Some(wp) = worker_pull_over_500_counter_empty.as_ref() {
wp.inc();
}
} else if let Some(wp) = worker_pull_over_500_counter.as_ref() {
if duration_pull_s > 0.5 {
let empty = job.as_ref().is_ok_and(|x| x.job.is_none());
tracing::warn!(worker = %worker_name, hostname = %hostname, "pull took more than 0.5s ({duration_pull_s}), this is a sign that the database is VERY undersized for this load. empty: {empty}, err: {err_pull}");
#[cfg(feature = "prometheus")]
if empty {
if let Some(wp) = worker_pull_over_500_counter_empty.as_ref() {
wp.inc();
}
} else if duration_pull_s > 0.1 {
let empty = job.as_ref().is_ok_and(|x| x.job.is_none());
tracing::warn!(worker = %worker_name, hostname = %hostname, "pull took more than 0.1s ({duration_pull_s}) this is a sign that the database is undersized for this load. empty: {empty}, err: {err_pull}");
#[cfg(feature = "prometheus")]
if empty {
if let Some(wp) = worker_pull_over_100_counter_empty.as_ref() {
wp.inc();
}
} else if let Some(wp) = worker_pull_over_100_counter.as_ref() {
} else if let Some(wp) = worker_pull_over_500_counter.as_ref() {
wp.inc();
}
} else if duration_pull_s > 0.1 {
let empty = job.as_ref().is_ok_and(|x| x.job.is_none());
tracing::warn!(worker = %worker_name, hostname = %hostname, "pull took more than 0.1s ({duration_pull_s}) this is a sign that the database is undersized for this load. empty: {empty}, err: {err_pull}");
#[cfg(feature = "prometheus")]
if empty {
if let Some(wp) = worker_pull_over_100_counter_empty.as_ref() {
wp.inc();
}
} else if let Some(wp) = worker_pull_over_100_counter.as_ref() {
wp.inc();
}
}
if let Ok(j) = job.as_ref() {
let suspend_success = j.suspended;
if suspend_first {
if last_30jobs_suspended < 30 {
last_30jobs_suspended += 1;
}
} else {
last_30jobs_suspended -= 1;
if let Ok(j) = job.as_ref() {
let suspend_success = j.suspended;
if suspend_first {
if last_30jobs_suspended < 30 {
last_30jobs_suspended += 1;
}
suspend_first_success = suspend_first && suspend_success;
#[cfg(feature = "prometheus")]
if j.job.is_some() {
if let Some(wp) = worker_pull_duration_counter.as_ref() {
wp.inc_by(duration_pull_s);
}
if let Some(wp) = worker_pull_duration.as_ref() {
wp.observe(duration_pull_s);
}
} else {
if let Some(wp) = worker_pull_duration_counter_empty.as_ref() {
wp.inc_by(duration_pull_s);
}
if let Some(wp) = worker_pull_duration_empty.as_ref() {
wp.observe(duration_pull_s);
}
} else {
last_30jobs_suspended -= 1;
}
suspend_first_success = suspend_first && suspend_success;
#[cfg(feature = "prometheus")]
if j.job.is_some() {
if let Some(wp) = worker_pull_duration_counter.as_ref() {
wp.inc_by(duration_pull_s);
}
if let Some(wp) = worker_pull_duration.as_ref() {
wp.observe(duration_pull_s);
}
} else {
if let Some(wp) = worker_pull_duration_counter_empty.as_ref() {
wp.inc_by(duration_pull_s);
}
if let Some(wp) = worker_pull_duration_empty.as_ref() {
wp.observe(duration_pull_s);
}
}
match job {
Ok(pulled_job_result) => match pulled_job_result.to_pulled_job() {
Ok(j) => {
Ok(j.map(|job| NextJob::Sql { flow_runners: None, job }))
}
Err(PulledJobResultToJobErr::MissingConcurrencyKey(jc))
| Err(PulledJobResultToJobErr::ErrorWhilePreprocessing(jc)) => {
if let Err(err) = job_completed_tx.send_job(jc, true).await
{
tracing::error!(
}
match job {
Ok(pulled_job_result) => match pulled_job_result.to_pulled_job() {
Ok(j) => Ok(j.map(|job| NextJob::Sql { flow_runners: None, job })),
Err(PulledJobResultToJobErr::MissingConcurrencyKey(jc))
| Err(PulledJobResultToJobErr::ErrorWhilePreprocessing(jc)) => {
if let Err(err) = job_completed_tx.send_job(jc, true).await {
tracing::error!(
"An error occurred while sending job completed: {:#?}",
err
)
}
Ok(None)
}
},
Err(err) => Err(err),
}
}
Connection::Http(client) => {
crate::agent_workers::pull_job(&client, None, None)
.await
.map_err(|e| error::Error::InternalErr(e.to_string()))
.map(|x| x.map(|y| NextJob::Http(y)))
Ok(None)
}
},
Err(err) => Err(err),
}
}
Connection::Http(client) => crate::agent_workers::pull_job(&client, None, None)
.await
.map_err(|e| error::Error::InternalErr(e.to_string()))
.map(|x| x.map(|y| NextJob::Http(y))),
}
}
};
+1 -12
View File
@@ -8,7 +8,7 @@ use windmill_common::{
get_memory, get_vcpus, get_windmill_memory_usage, get_worker_memory_usage,
insert_ping_query, update_job_ping_query, update_worker_ping_from_job_query,
update_worker_ping_main_loop_query, Connection, Ping, PingType, NATIVE_MODE_RESOLVED,
USES_BATCH_HTTP_PULL, WORKER_CONFIG, WORKER_GROUP,
WORKER_CONFIG, WORKER_GROUP,
},
KillpillSender, DB,
};
@@ -31,7 +31,6 @@ pub(crate) async fn update_worker_ping_full(
let tags = wc.worker_tags.clone();
let native_mode = wc.native_mode;
drop(wc);
let uses_batch_http_pull = USES_BATCH_HTTP_PULL.load(std::sync::atomic::Ordering::Relaxed);
let memory_usage = get_worker_memory_usage();
let wm_memory_usage = get_windmill_memory_usage();
@@ -65,7 +64,6 @@ pub(crate) async fn update_worker_ping_full(
occupancy_rate_5m,
occupancy_rate_30m,
native_mode,
uses_batch_http_pull,
)
})
.retry(
@@ -112,7 +110,6 @@ async fn update_worker_ping_full_inner(
occupancy_rate_5m: Option<f32>,
occupancy_rate_30m: Option<f32>,
native_mode: bool,
uses_batch_http_pull: bool,
) -> anyhow::Result<()> {
match conn {
Connection::Sql(db) => {
@@ -129,7 +126,6 @@ async fn update_worker_ping_full_inner(
occupancy_rate_5m,
occupancy_rate_30m,
native_mode,
uses_batch_http_pull,
db,
)
.await?;
@@ -159,7 +155,6 @@ async fn update_worker_ping_full_inner(
wm_memory_usage: get_windmill_memory_usage(),
job_isolation: None,
native_mode: Some(native_mode),
uses_batch_http_pull: Some(uses_batch_http_pull),
ping_type: PingType::MainLoop,
},
)
@@ -191,7 +186,6 @@ pub async fn insert_ping(
wc.native_mode,
)
};
let uses_batch_http_pull = USES_BATCH_HTTP_PULL.load(std::sync::atomic::Ordering::Relaxed);
let vcpus = get_vcpus();
let memory = get_memory();
@@ -219,7 +213,6 @@ pub async fn insert_ping(
memory,
job_isolation,
native_mode,
uses_batch_http_pull,
db,
)
.await?;
@@ -249,7 +242,6 @@ pub async fn insert_ping(
wm_memory_usage: get_windmill_memory_usage(),
job_isolation,
native_mode: Some(native_mode),
uses_batch_http_pull: Some(uses_batch_http_pull),
ping_type: PingType::Initial,
},
)
@@ -326,9 +318,6 @@ pub async fn update_worker_ping_from_job(
native_mode: Some(
NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed),
),
uses_batch_http_pull: Some(
USES_BATCH_HTTP_PULL.load(std::sync::atomic::Ordering::Relaxed),
),
},
)
.await?;
+2 -9
View File
@@ -47,7 +47,6 @@ export async function main({
kind,
jobs,
noVerify,
skipDeploy,
}: {
host: string;
email?: string;
@@ -57,7 +56,6 @@ export async function main({
kind: string;
jobs: number;
noVerify?: boolean;
skipDeploy?: boolean;
}) {
windmill.setClient("", host);
@@ -148,8 +146,7 @@ export async function main({
}
if (
!skipDeploy &&
["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "nativets_sleep", "dedicated_nativets"].includes(
["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets"].includes(
kind
)
) {
@@ -168,7 +165,7 @@ export async function main({
kind: "noop",
});
} else if (
["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "nativets_sleep", "dedicated_nativets"].includes(
["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets"].includes(
kind
)
) {
@@ -339,7 +336,6 @@ export async function main({
!noVerify &&
kind !== "noop" &&
kind !== "nativets" &&
kind !== "nativets_sleep" &&
kind !== "dedicated_nativets" &&
!kind.startsWith("flow:") &&
!kind.startsWith("script:")
@@ -402,9 +398,6 @@ if (import.meta.main) {
.option("--no-verify", "Do not verify the output of the jobs.", {
default: false,
})
.option("--skip-deploy", "Skip script deployment (use already deployed script).", {
default: false,
})
.action(main)
.command(
"upgrade",
-4
View File
@@ -95,10 +95,6 @@ export async function createBenchScript(
scriptContent =
'//native\nexport async function main(){ return (await fetch(BASE_URL + "/api/version")).text() }';
language = "bunnative";
} else if (scriptPattern === "nativets_sleep") {
scriptContent =
'//native\nexport async function main(){ const ms = 300 + Math.floor(Math.random() * 400); await new Promise(r => setTimeout(r, ms)); return { slept: ms }; }';
language = "bunnative";
} else if (scriptPattern === "dedicated_nativets") {
scriptContent = "//native\nexport function main(){ return 42; }";
language = "bunnative";
-165
View File
@@ -1,165 +0,0 @@
/**
* Model: Batch Pull vs Direct SQL throughput
*
* Calibrated from real benchmarks (3 native workers = 24 subworkers, local PG):
* - nativets (fast): batch 291 j/s, SQL 253 j/s at N=24; batch 108, SQL 88 at N=8
* - nativets_sleep: both ~43.8 j/s at N=24 (bottlenecked by 500ms avg exec time)
*
* Per-worker job time model:
* T_pw = T_base + T_exec + T_contention(N)
* throughput = N / T_pw
*
* Batch: T_contention grows linearly with N (HTTP server load)
* T_pw_batch(N) = BASE_BATCH + T_exec + SCALE_BATCH × N
*
* SQL: T_contention grows quadratically with N (SKIP LOCKED scanning past locked rows)
* T_pw_sql(N) = BASE_SQL + T_exec + SCALE_SQL × N²
*
* Parameters fitted from 2 data points each (N=8, N=24):
* Batch: BASE=69.9ms, SCALE=0.525ms/worker
* SQL: BASE=90.4ms, SCALE=0.0078ms/worker²
* (SQL quadratic overtakes batch linear around N~40)
*/
// --- Model parameters (fitted from benchmarks) ---
// Batch: per-worker time = BASE + SCALE_LINEAR * N + T_exec
const BASE_BATCH = 69.9; // ms — base overhead (worker loop, HTTP roundtrip, job completion writes)
const SCALE_BATCH = 0.525; // ms per subworker — linear growth from server load
// SQL: per-worker time = BASE + SCALE_QUAD * N² + T_exec
const BASE_SQL = 90.4; // ms — base overhead (worker loop, poll interval wait, job completion writes)
const SCALE_SQL = 0.0078; // ms per subworker² — quadratic growth from SKIP LOCKED contention
// --- Throughput functions ---
function throughputBatch(subworkers: number, execMs: number): number {
const tPerWorker = BASE_BATCH + execMs + SCALE_BATCH * subworkers;
return (subworkers / tPerWorker) * 1000; // jobs/s
}
function throughputSql(subworkers: number, execMs: number): number {
const tPerWorker = BASE_SQL + execMs + SCALE_SQL * subworkers * subworkers;
return (subworkers / tPerWorker) * 1000; // jobs/s
}
function pct(batch: number, sql: number): string {
const diff = ((batch - sql) / sql) * 100;
return `${diff >= 0 ? "+" : ""}${diff.toFixed(0)}%`;
}
// --- Validation against real data ---
console.log("=== Model Validation (vs real benchmarks) ===\n");
console.log(
" Setup | Model Batch | Real Batch | Model SQL | Real SQL",
);
console.log(
" ---------------------|-------------|------------|-----------|--------",
);
const cases = [
{ n: 8, exec: 0, label: "1W nativets", realBatch: 108, realSql: 88 },
{ n: 24, exec: 0, label: "3W nativets", realBatch: 291, realSql: 253 },
{
n: 24,
exec: 500,
label: "3W sleep(500ms)",
realBatch: 43.8,
realSql: 43.8,
},
];
for (const c of cases) {
const mb = throughputBatch(c.n, c.exec);
const ms = throughputSql(c.n, c.exec);
console.log(
` ${c.label.padEnd(21)}| ${mb.toFixed(0).padStart(7)} j/s | ${c.realBatch.toFixed(0).padStart(6)} j/s | ${ms.toFixed(0).padStart(5)} j/s | ${c.realSql.toFixed(0).padStart(4)} j/s`,
);
}
// --- Projections ---
const workerCounts = [1, 2, 3, 5, 8, 10, 15, 20]; // native workers (×8 subworkers each)
const execTimes = [
{ ms: 0, label: "~0ms (identity)" },
{ ms: 5, label: "5ms" },
{ ms: 20, label: "20ms" },
{ ms: 50, label: "50ms" },
{ ms: 200, label: "200ms" },
{ ms: 500, label: "500ms" },
];
console.log("\n\n=== Projected Throughput (jobs/s) ===\n");
for (const exec of execTimes) {
console.log(`--- Job duration: ${exec.label} ---\n`);
console.log(
" Native workers (subw) | Batch | SQL | Advantage | Batch wins?",
);
console.log(
" ----------------------|-----------|----------|-------------|------------",
);
for (const w of workerCounts) {
const n = w * 8;
const b = throughputBatch(n, exec.ms);
const s = throughputSql(n, exec.ms);
const advantage = pct(b, s);
const wins = b > s * 1.05 ? " YES" : b > s * 1.01 ? " marginal" : " no";
console.log(
` ${String(w).padStart(2)}W (${String(n).padStart(3)}) | ${b.toFixed(0).padStart(5)} j/s | ${s.toFixed(0).padStart(5)} j/s | ${advantage.padStart(8)} | ${wins}`,
);
}
console.log();
}
// --- Crossover analysis ---
console.log("=== Crossover: min workers where batch is >10% faster ===\n");
console.log(" Job duration | Min workers | Subworkers | Batch j/s | SQL j/s");
console.log(" -------------|-------------|------------|-----------|--------");
for (const exec of execTimes) {
let found = false;
for (let w = 1; w <= 50; w++) {
const n = w * 8;
const b = throughputBatch(n, exec.ms);
const s = throughputSql(n, exec.ms);
if (b > s * 1.1) {
console.log(
` ${exec.label.padEnd(13)}| ${String(w).padStart(5)}W | ${String(n).padStart(5)} | ${b.toFixed(0).padStart(5)} j/s | ${s.toFixed(0).padStart(5)} j/s`,
);
found = true;
break;
}
}
if (!found) {
console.log(
` ${exec.label.padEnd(13)}| >50W (never significant at this job duration)`,
);
}
}
console.log("\n\n=== Key Takeaways ===\n");
console.log(
"1. For fast jobs (~0ms): batch pull is always faster, advantage grows with scale",
);
console.log(" - 5 native workers (40 subworkers): ~13% faster");
console.log(" - 10 native workers (80 subworkers): ~25% faster");
console.log(" - 20 native workers (160 subworkers): ~88% faster");
console.log(
"2. For medium jobs (50ms): batch advantage meaningful from ~5 native workers",
);
console.log(
"3. For slow jobs (500ms+): only matters at 15+ native workers (120+ subworkers)",
);
console.log(
" (but still reduces DB load — fewer pull queries, less index scanning)",
);
console.log(
"4. The SQL quadratic contention (SKIP LOCKED scanning) is the dominant factor",
);
console.log(
" — SQL throughput plateaus around 15-20 native workers while batch keeps scaling",
);
-93
View File
@@ -1,93 +0,0 @@
# Batch Pull Benchmark Results
Date: 2026-03-06
Setup: 1 server + 1 native worker (8 subworkers), standalone mode
Hardware: fedora, 1.5TB disk, ~1GB memory usage
DB: PostgreSQL local, windmill 270 MiB
## nativets — 1000 jobs
| | Batch Pull | Direct SQL |
|--|-----------|------------|
| Duration | 9.2s | 11.4s |
| **Throughput** | **108 jobs/s** | **88 jobs/s** |
| Improvement | **+23%** | baseline |
### pg_stat_statements
| Query | Batch calls | Batch ms | SQL calls | SQL ms |
|-------|------------|----------|-----------|--------|
| Native pull (FOR UPDATE SKIP LOCKED) | 2,399 (0.02ms avg) | 47 | 2,849 (0.03ms avg) | 87 |
| Default worker pull | 520 (0.04ms avg) | 19 | 445 (0.05ms avg) | 23 |
| DELETE from queue | 1,001 (0.23ms avg) | 231 | 1,001 (0.19ms avg) | 195 |
| INSERT into completed | 1,001 (0.05ms avg) | 51 | 1,001 (0.04ms avg) | 45 |
| INSERT job_logs | 2,003 (0.02ms avg) | 44 | 2,003 (0.02ms avg) | 44 |
| Agent token blacklist | 3,920 (0.00ms avg) | 19 | — | — |
| **Total** | **9,389** | **1,332** | **8,882** | **1,212** |
### pg_stat_database
| Metric | Batch Pull | Direct SQL |
|--------|-----------|------------|
| Transactions committed | 6,484 | 5,868 |
| Blocks read (disk) | 101 | 101 |
| Blocks hit (cache) | 920,257 | 699,032 |
| Tuples returned | 7,784,044 | 8,987,097 |
| Tuples fetched | 1,028,819 | 805,100 |
| Tuples inserted | 6,822 | 6,880 |
| Tuples updated | 3,377 | 3,090 |
| Tuples deleted | 2,883 | 2,654 |
---
## nativets_sleep — 1000 jobs
Each job sleeps 300-700ms (random). Theoretical max with 8 workers: ~16 jobs/s.
| | Batch Pull | Direct SQL |
|--|-----------|------------|
| Duration | 66.7s | 68.2s |
| **Throughput** | **15.0 jobs/s** | **14.7 jobs/s** |
| Improvement | ~same | baseline |
### pg_stat_statements
| Query | Batch calls | Batch ms | SQL calls | SQL ms |
|-------|------------|----------|-----------|--------|
| Native pull (FOR UPDATE SKIP LOCKED) | 2,399 (0.02ms avg) | 43 | 1,762 (0.04ms avg) | 75 |
| Default worker pull | 1,591 (0.07ms avg) | 113 | 1,392 (0.08ms avg) | 115 |
| DELETE from queue | 1,001 (0.31ms avg) | 308 | 1,001 (0.20ms avg) | 205 |
| INSERT into completed | 1,001 (0.05ms avg) | 46 | 1,001 (0.04ms avg) | 41 |
| INSERT job_logs | 2,003 (0.02ms avg) | 45 | 2,003 (0.02ms avg) | 40 |
| Job runtime ping | 1,490 (0.02ms avg) | 33 | 1,489 (0.02ms avg) | 31 |
| Worker ping (job) | 1,001 (0.03ms avg) | 32 | 1,001 (0.03ms avg) | 30 |
| **Total** | **16,523** | **5,798** | **16,364** | **5,717** |
### pg_stat_database
| Metric | Batch Pull | Direct SQL |
|--------|-----------|------------|
| Transactions committed | 13,638 | 13,388 |
| Blocks read (disk) | 394 | 850 |
| Blocks hit (cache) | 7,033,158 | 4,932,617 |
| Tuples returned | 58,424,571 | 57,204,918 |
| Tuples fetched | 8,776,186 | 6,321,947 |
| Tuples inserted | 7,500 | 7,531 |
| Tuples updated | 6,111 | 6,193 |
| Tuples deleted | 3,006 | 3,196 |
---
## Analysis
**Throughput**: +23% for fast CPU-bound jobs. Negligible difference for I/O-bound jobs.
**Pull queries**: Batch pull does MORE pull queries for nativets_sleep (2,399 vs 1,762). The refiller polls every 50ms even when all workers are busy executing jobs. With direct SQL, workers only poll when idle. This is wasted work — the refiller queries DB and gets empty results while jobs are in-flight.
**Disk I/O**: Batch pull cuts disk reads in half for nativets_sleep (394 vs 850 blocks). Likely because the batch query locks multiple rows in one pass, reducing index traversal.
**Cache hits**: Higher with batch pull (7M vs 4.9M for sleep). More buffer hits from the refiller's repeated empty polls touching the same index pages.
**Tuples fetched**: Higher with batch pull (8.7M vs 6.3M for sleep). Same cause — the refiller's empty polls scan the index.
**At scale**: With 8 subworkers the differences are small. The real benefit is with many native workers where direct SQL SKIP LOCKED contention grows O(N²).
-123
View File
@@ -1,123 +0,0 @@
# Batch Pull Benchmark Results — 3 Workers
Date: 2026-03-06
Setup: 1 server + 3 native workers (8 subworkers each = 24 subworkers)
Hardware: fedora, 1.5TB disk, ~1GB memory usage
DB: PostgreSQL local, windmill 270 MiB
## nativets — 1000 jobs
| | 3W Batch | 3W SQL | 1W Batch | 1W SQL |
|--|---------|--------|---------|--------|
| Duration | 3.7s | 3.5s | 9.2s | 11.4s |
| **Throughput** | **272 jobs/s** | **288 jobs/s** | **108 jobs/s** | **88 jobs/s** |
| vs 1W SQL | +209% | +227% | +23% | baseline |
Note: First 3W SQL run was 33 jobs/s (outlier due to cold start or background activity). Rerun gave 288 jobs/s.
## nativets — 10,000 jobs
| | 3W Batch | 3W SQL |
|--|---------|--------|
| Duration | 34.3s | 39.5s |
| **Throughput** | **291 jobs/s** | **253 jobs/s** |
| Improvement | **+15%** | baseline |
### pg_stat_statements (1000 jobs, first run)
| Query | 3W Batch calls | 3W Batch ms | 3W SQL calls | 3W SQL ms |
|-------|---------------|-------------|-------------|----------|
| Native pull (FOR UPDATE SKIP LOCKED) | 4,801 (0.01ms) | 59 | 20,367 (0.01ms) | 231 |
| Default worker pull | 353 (0.03ms) | 10 | 880 (0.02ms) | 16 |
| DELETE from queue | 1,001 (0.44ms) | 439 | 1,001 (0.43ms) | 427 |
| INSERT into completed | 1,001 (0.06ms) | 61 | 1,001 (0.05ms) | 55 |
| INSERT job_logs | 2,003 (0.03ms) | 67 | 2,003 (0.03ms) | 64 |
| Agent token blacklist | 7,867 (0.00ms) | 33 | — | — |
| Worker ping (job) | 350 (0.04ms) | 15 | — | — |
| Outstanding wait time | 664 (0.03ms) | 21 | 742 (0.03ms) | 23 |
### pg_stat_database (1000 jobs, first run)
| Metric | 3W Batch | 3W SQL |
|--------|---------|--------|
| Transactions committed | 22,070 | 29,301 |
| Blocks read (disk) | 308 | 395 |
| Blocks hit (cache) | 280,071 | 1,276,521 |
| Tuples returned | 3,368,255 | 28,695,830 |
| Tuples fetched | 140,864 | 1,089,774 |
| Tuples inserted | 6,682 | 6,760 |
| Tuples updated | 3,521 | 3,453 |
| Tuples deleted | 3,007 | 3,007 |
---
## nativets_sleep — 1000 jobs
Each job sleeps 300-700ms (random). Theoretical max with 24 workers: ~48 jobs/s.
| | 3W Batch | 3W SQL | 1W Batch | 1W SQL |
|--|---------|--------|---------|--------|
| Duration | 22.8s | 22.8s | 66.7s | 68.2s |
| **Throughput** | **43.8 jobs/s** | **43.8 jobs/s** | **15.0 jobs/s** | **14.7 jobs/s** |
| vs 3W SQL | ~same | baseline | — | — |
### pg_stat_statements
| Query | 3W Batch calls | 3W Batch ms | 3W SQL calls | 3W SQL ms |
|-------|---------------|-------------|-------------|----------|
| Native pull (FOR UPDATE SKIP LOCKED) | 4,898 (0.01ms) | 55 | 6,440 (0.02ms) | 113 |
| Default worker pull | 696 (0.06ms) | 43 | 654 (0.06ms) | 37 |
| DELETE from queue | 1,001 (0.38ms) | 379 | 1,001 (0.29ms) | 290 |
| INSERT into completed | 1,001 (0.05ms) | 46 | 1,001 (0.04ms) | 43 |
| INSERT job_logs | 2,003 (0.02ms) | 44 | 2,003 (0.02ms) | 43 |
| Agent token blacklist | 7,295 (0.00ms) | 31 | — | — |
| Job runtime ping | 1,499 (0.02ms) | 35 | 1,444 (0.02ms) | 35 |
| Worker ping (job) | 1,001 (0.03ms) | 32 | 1,001 (0.03ms) | 31 |
| Job stats | 549 (0.05ms) | 27 | 493 (0.05ms) | 26 |
| Outstanding wait time | 928 (0.02ms) | 20 | 944 (0.02ms) | 21 |
### pg_stat_database
| Metric | 3W Batch | 3W SQL |
|--------|---------|--------|
| Transactions committed | 25,896 | 18,646 |
| Blocks read (disk) | 115 | 204 |
| Blocks hit (cache) | 1,173,696 | 1,256,397 |
| Tuples returned | 21,074,537 | 22,063,593 |
| Tuples fetched | 1,200,878 | 1,262,900 |
| Tuples inserted | 7,495 | 7,461 |
| Tuples updated | 6,204 | 6,141 |
| Tuples deleted | 3,005 | 3,011 |
---
## Analysis
### nativets (CPU-bound): +15% with 10K jobs
With 10,000 jobs, batch pull achieves **291 jobs/s vs 253 jobs/s** (+15%). The 1000-job runs showed similar throughput (~272-288 jobs/s) after discarding the cold-start outlier.
**DB load difference** (from the 1000-job first run, which captured the worst-case SQL contention):
- **20,367 pull queries** (SQL) vs 4,801 (batch) — 4x more queries
- **28.7M tuples returned** (SQL) vs 3.4M (batch) — 8.5x more index scanning
- **1.3M cache hits** (SQL) vs 280K (batch) — 4.6x more buffer activity
The batch approach consolidates all 24 subworkers into a single `LIMIT 24` query, reducing contention on the queue index.
### nativets_sleep (I/O-bound): No throughput difference
Both achieve **43.8 jobs/s** (91% of theoretical 48 jobs/s max). When workers spend 300-700ms sleeping, DB contention isn't the bottleneck.
Batch pull still shows slightly lower DB load:
- **4,898 pull queries** vs 6,440 — 24% fewer
- **115 disk reads** vs 204 — 44% fewer
### Scaling summary
| Setup | Batch jobs/s | SQL jobs/s | Batch advantage |
|-------|-------------|-----------|----------------|
| 1W × 1000 jobs | 108 | 88 | +23% |
| 3W × 1000 jobs | 272 | 288 | ~same |
| 3W × 10,000 jobs | 291 | 253 | **+15%** |
At 24 subworkers, batch pull provides a consistent ~15% throughput improvement for sustained CPU-bound workloads, with significantly lower DB load (4x fewer pull queries, 8x fewer tuples scanned). The benefit grows with more workers as SKIP LOCKED contention scales O(N²).
+33 -10
View File
@@ -2626,6 +2626,7 @@ export async function push(
let [_basePath, changes] = queue.shift()!;
const promise = (async () => {
const alreadySynced: string[] = [];
const deletedVarsResPaths: string[] = [];
const isRawApp = isRawAppFile(changes[0].path);
if (isRawApp) {
const deleteRawApp = changes.find(
@@ -2870,12 +2871,23 @@ export async function push(
name: change.path.split(SEP)[1],
});
break;
case "resource":
await wmill.deleteResource({
workspace: workspaceId,
path: removeSuffix(target, ".resource.json"),
});
case "resource": {
const resourcePath = removeSuffix(target, ".resource.json");
try {
await wmill.deleteResource({
workspace: workspaceId,
path: resourcePath,
});
} catch (e: any) {
if (e?.status === 404 && deletedVarsResPaths.includes(resourcePath)) {
log.debug(`Resource ${resourcePath} already deleted by linked variable`);
} else {
throw e;
}
}
deletedVarsResPaths.push(resourcePath);
break;
}
case "resource-type":
await wmill.deleteResourceType({
workspace: workspaceId,
@@ -3012,12 +3024,23 @@ export async function push(
});
break;
}
case "variable":
await wmill.deleteVariable({
workspace: workspaceId,
path: removeSuffix(target, ".variable.json"),
});
case "variable": {
const variablePath = removeSuffix(target, ".variable.json");
try {
await wmill.deleteVariable({
workspace: workspaceId,
path: variablePath,
});
} catch (e: any) {
if (e?.status === 404 && deletedVarsResPaths.includes(variablePath)) {
log.debug(`Variable ${variablePath} already deleted by linked resource`);
} else {
throw e;
}
}
deletedVarsResPaths.push(variablePath);
break;
}
case "user": {
const users = await wmill.listUsers({
workspace: workspaceId,
+2 -1
View File
@@ -408,7 +408,8 @@ async function remove(_opts: GlobalOptions, name: string) {
async function whoami(_opts: GlobalOptions) {
await requireLogin(_opts);
log.info(await wmill.globalWhoami());
const whoamiInfo = await wmill.globalWhoami();
log.info(JSON.stringify(whoamiInfo, null, 2));
const activeName = await getActiveWorkspaceName(_opts);
log.info("Active: " + colors.green.bold(activeName || "none"));
}
+39
View File
@@ -0,0 +1,39 @@
# Svelte 5 Migration - Bug Report
# Testing started: 2026-03-02
## Warnings (not blocking but worth fixing)
1. [WARNING] binding_property_non_reactive in Grid.svelte:372:5
- `bind:this={moveResizes[item.id]}` is binding to a non-reactive property
- File: src/lib/components/apps/svelte-grid/Grid.svelte
- Appears multiple times in App editor
- Status: NOT FIXED (non-blocking warning)
2. [WARNING] legacy_recursive_reactive_block in RecomputeAllComponents.svelte
- Migrated `$:` reactive block that both accesses and updates the same reactive value
- File: src/lib/components/apps/editor/RecomputeAllComponents.svelte
- May cause recursive updates when converted to $effect
- Status: NOT FIXED (non-blocking warning)
3. [WARNING] ownership_invalid_mutation in SchemaForm.svelte:70:16
- Mutating unbound props (`schema`) is strongly discouraged
- Parent: src/lib/components/ApiConnectForm.svelte should use `bind:schema={...}`
- Appears when opening PostgreSQL resource creation form
- Status: NOT FIXED (non-blocking warning)
4. [WARNING] ownership_invalid_binding in InputTransformSchemaForm.svelte
- Passes `schema` to InputTransformForm.svelte with `bind:`, but parent Pane.svelte didn't declare `schema` as binding
- Appears in flow editor when adding a TypeScript step
- Status: NOT FIXED (non-blocking warning)
## Bugs
1. [BUG] state_descriptors_fixed in Chart.svelte (Queue metrics drawer)
- Error: "Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`."
- Triggered by: Clicking "Queue metrics" on /workers page
- File: src/lib/components/chartjs-wrappers/Chart.svelte
- Root cause: Chart.js's `listenArrayEvents` calls Object.defineProperty on data arrays that are Svelte 5 $state proxies, which reject non-standard property descriptors
- Fix: Use $state.snapshot() to pass plain copies of data and options to Chart.js
- Status: FIXED
@@ -1,4 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { createEventDispatcher } from 'svelte'
import { Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -6,24 +8,26 @@
import AppConnectInner from './AppConnectInner.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
export let expressOAuthSetup = false
let drawer: Drawer
let resourceType = ''
let step = 1
let disabled = false
let isGoogleSignin = false
let manual = true
let appConnectInner: AppConnectInner | undefined = undefined
let rtToLoad: string | undefined = ''
export async function open(rt?: string) {
rtToLoad = rt
drawer.openDrawer?.()
interface Props {
expressOAuthSetup?: boolean
}
$: appConnectInner && onRtToLoadChange(rtToLoad)
let { expressOAuthSetup = false }: Props = $props()
let drawer: Drawer | undefined = $state()
let resourceType = $state('')
let step = $state(1)
let disabled = $state(false)
let isGoogleSignin = $state(false)
let manual = $state(true)
let appConnectInner: AppConnectInner | undefined = $state(undefined)
let rtToLoad: string | undefined = $state('')
export async function open(rt?: string) {
rtToLoad = rt
drawer?.openDrawer?.()
}
function onRtToLoadChange(rtToLoad: string | undefined) {
appConnectInner?.open(rtToLoad)
@@ -31,7 +35,10 @@
const dispatch = createEventDispatcher()
let darkMode: boolean = false
let darkMode: boolean = $state(false)
run(() => {
appConnectInner && onRtToLoadChange(rtToLoad)
})
</script>
<DarkModeObserver bind:darkMode />
@@ -47,7 +54,7 @@
<DrawerContent
title="Add a resource"
id="add-resource-drawer"
on:close={drawer.closeDrawer}
on:close={drawer?.closeDrawer}
tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs."
documentationLink="https://www.windmill.dev/docs/integrations/integrations_on_windmill"
>
@@ -68,7 +75,7 @@
<Button variant="default" on:click={appConnectInner?.back ?? (() => {})}>Back</Button>
{/if}
{#if isGoogleSignin}
<button {disabled} on:click={appConnectInner?.next}>
<button {disabled} onclick={appConnectInner?.next}>
<img
class="h-10 w-auto object-contain"
src={darkMode ? '/google_signin_dark.png' : '/google_signin_light.png'}
@@ -405,6 +405,7 @@
}
} else {
if (!path) {
if (step == 2) return
throw Error('Path is not set')
}
let exists = await VariableService.existsVariable({
@@ -3,7 +3,7 @@
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { Button } from '$lib/components/common'
import { workspaceStore } from '$lib/stores'
import { onMount } from 'svelte'
import { onMount, untrack } from 'svelte'
interface Props {
resourceType?: string | undefined
@@ -21,8 +21,8 @@
let darkMode: boolean = $state(false)
if (workspace) {
$workspaceStore = workspace
if (untrack(() => workspace)) {
$workspaceStore = untrack(() => workspace)
}
onMount(async () => {
@@ -1,10 +1,11 @@
<script lang="ts">
import { untrack } from 'svelte'
import AppEditor from './apps/editor/AppEditor.svelte'
import type { AppEditorProps } from './apps/types'
let { app: oldApp, ...props }: AppEditorProps = $props()
let app = $state(oldApp)
let app = $state(untrack(() => oldApp))
</script>
<AppEditor {app} {...props} />
@@ -1,16 +1,20 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
export let value: any
interface Props {
value: any;
}
$: enabled = value != undefined
let { value = $bindable() }: Props = $props();
let org = ''
$: changeOrg(org)
let org = $state('')
function changeOrg(org) {
if (value) {
@@ -30,10 +34,14 @@
}
}
}
let enabled = $derived(value != undefined)
run(() => {
changeOrg(org)
});
</script>
<div class="flex flex-col gap-1">
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-xs font-semibold text-emphasis flex gap-4 items-center"
><div class="w-[120px]"><IconedResourceType name={'authelia'} after={true} /></div><Toggle
checked={enabled}
@@ -1,16 +1,18 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import IconedResourceType from './IconedResourceType.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
export let value: any
interface Props {
value: any;
}
let { value = $bindable() }: Props = $props();
$: enabled = value != undefined
// Initialize org from existing auth_url
$: org = value?.connect_config?.auth_url?.replace('/application/o/authorize/', '') ?? ''
$: changeOrg(org)
function changeOrg(org) {
if (value && org) {
@@ -30,10 +32,16 @@
}
}
}
let enabled = $derived(value != undefined)
// Initialize org from existing auth_url
let org = $derived(value?.connect_config?.auth_url?.replace('/application/o/authorize/', '') ?? '')
run(() => {
changeOrg(org)
});
</script>
<div class="flex flex-col gap-1">
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-xs font-semibold text-emphasis flex gap-4 items-center"
><div class="w-[120px]"><IconedResourceType name={'authentik'} after={true} /></div><Toggle
checked={enabled}
@@ -1,38 +1,48 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { ExternalLink } from 'lucide-svelte'
import OauthScopes from './OauthScopes.svelte'
export let connect_config: {
interface Props {
connect_config?: {
scopes: string[]
auth_url: string
token_url: string
req_body_auth: boolean
extra_params: { tenant_id: string }
extra_params_callback: Record<string, any>
} = {
};
}
let { connect_config = $bindable({
scopes: ['offline_access'],
auth_url: '',
token_url: '',
req_body_auth: true,
extra_params: { tenant_id: '' },
extra_params_callback: {}
}
}) }: Props = $props();
$: if (!connect_config) {
connect_config = {
scopes: ['offline_access'],
auth_url: '',
token_url: '',
req_body_auth: true,
extra_params: { tenant_id: '' },
extra_params_callback: {}
run(() => {
if (!connect_config) {
connect_config = {
scopes: ['offline_access'],
auth_url: '',
token_url: '',
req_body_auth: true,
extra_params: { tenant_id: '' },
extra_params_callback: {}
}
}
}
});
$: if (connect_config.extra_params.tenant_id) {
connect_config.auth_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/authorize`
connect_config.token_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/token`
}
run(() => {
if (connect_config.extra_params.tenant_id) {
connect_config.auth_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/authorize`
connect_config.token_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/token`
}
});
</script>
<label class="flex flex-col gap-1" for="tenant-id">
+14 -4
View File
@@ -1,12 +1,22 @@
<script lang="ts">
import Tooltip from './Tooltip.svelte'
export let twBgColor = 'bg-blue-200'
export let twTextColor = 'text-secondary'
export let tooltip: string | undefined = undefined
interface Props {
twBgColor?: string;
twTextColor?: string;
tooltip?: string | undefined;
children?: import('svelte').Snippet;
}
let {
twBgColor = 'bg-blue-200',
twTextColor = 'text-secondary',
tooltip = undefined,
children
}: Props = $props();
</script>
<span class="{twBgColor} {twTextColor} text-2xs rounded px-1 whitespace-nowrap">
<slot />
{@render children?.()}
{#if tooltip && tooltip != ''}
<Tooltip>{tooltip}</Tooltip>
{/if}
@@ -5,12 +5,21 @@
import Alert from './common/alert/Alert.svelte'
import { createEventDispatcher } from 'svelte'
export let email: string
export let username: string
export let isConflict = false
export let noPadding = false
interface Props {
email: string;
username: string;
isConflict?: boolean;
noPadding?: boolean;
}
let loading = false
let {
email,
username = $bindable(),
isConflict = false,
noPadding = false
}: Props = $props();
let loading = $state(false)
let usernameInfo:
| {
@@ -20,7 +29,7 @@
username: string
}[]
}
| undefined = undefined
| undefined = $state(undefined)
function handleKeyUp(event: KeyboardEvent) {
const key = event.key
@@ -83,7 +92,7 @@
<input
type="text"
class="mb-4"
on:keyup={handleKeyUp}
onkeyup={handleKeyUp}
bind:value={username}
disabled={isConflict}
/>
@@ -13,25 +13,25 @@
const dispatch = createEventDispatcher()
let edit: boolean = false
let name: string = ''
let value: string = ''
let edit: boolean = $state(false)
let name: string = $state('')
let value: string = $state('')
export function initNew(): void {
edit = false
name = ''
value = ''
drawer.openDrawer()
drawer?.openDrawer()
}
export function editVariable(editName: string, editValue: string): void {
edit = true
name = editName
value = editValue
drawer.openDrawer()
drawer?.openDrawer()
}
let drawer: Drawer
let drawer: Drawer | undefined = $state()
async function updateVariable(): Promise<void> {
await WorkspaceService.setEnvironmentVariable({
@@ -48,7 +48,7 @@
)
dispatch('update')
drawer.closeDrawer()
drawer?.closeDrawer()
setTimeout(() => {
dispatch('update')
}, 5000)
@@ -58,7 +58,7 @@
<Drawer bind:this={drawer} size="900px">
<DrawerContent
title={edit ? `Update contextual variable ${name}` : 'Create a contextual variable'}
on:close={drawer.closeDrawer}
on:close={drawer?.closeDrawer}
>
<div class="flex flex-col gap-8">
{#if !edit}
+19 -15
View File
@@ -1,28 +1,32 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import OauthExtraParams from './OauthExtraParams.svelte'
import OauthScopes from './OauthScopes.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
export let connect_config = {
let { connect_config = $bindable({
scopes: [],
auth_url: '',
token_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
}
}) } = $props();
$: if (!connect_config) {
connect_config = {
scopes: [],
auth_url: '',
token_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
run(() => {
if (!connect_config) {
connect_config = {
scopes: [],
auth_url: '',
token_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
}
}
}
});
</script>
<div class="flex flex-col gap-6">
@@ -42,12 +46,12 @@
bind:value={connect_config.token_url}
/>
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Scopes</span>
<OauthScopes bind:scopes={connect_config.scopes} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"
>Extra Query Args for Authorize Request&nbsp;<Tooltip
@@ -57,14 +61,14 @@
>
<OauthExtraParams bind:extra_params={connect_config.extra_params} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"
>Extra Query Args for Token request <Tooltip>Not needed in most cases</Tooltip></span
>
<OauthExtraParams bind:extra_params={connect_config.extra_params_callback} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"
>Payload <Tooltip
@@ -5,6 +5,7 @@
import { createPopperActions, type PopperOptions } from 'svelte-popperjs'
import type { PopoverPlacement } from './Popover.model'
import Portal from '$lib/components/Portal.svelte'
import { untrack } from 'svelte'
interface Props {
placement?: PopoverPlacement
@@ -33,10 +34,10 @@
children,
overlay
}: Props = $props()
const [popperRef, popperContent] = createPopperActions({ placement })
const [popperRef, popperContent] = createPopperActions({ placement: untrack(() => placement) })
const popperOptions: PopperOptions<{}> = {
placement,
placement: untrack(() => placement),
strategy: 'fixed',
modifiers: [
{ name: 'offset', options: { offset: [8, 8] } },
+20 -16
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import OauthExtraParams from './OauthExtraParams.svelte'
import OauthScopes from './OauthScopes.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
export let login_config = {
let { login_config = $bindable({
scopes: [],
auth_url: '',
token_url: '',
@@ -12,19 +14,21 @@
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
}
}) } = $props();
$: if (!login_config) {
login_config = {
scopes: [],
auth_url: '',
token_url: '',
userinfo_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
run(() => {
if (!login_config) {
login_config = {
scopes: [],
auth_url: '',
token_url: '',
userinfo_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
}
}
}
});
</script>
<label class="block pb-6">
@@ -51,12 +55,12 @@
bind:value={login_config.userinfo_url}
/>
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Scopes</span>
<OauthScopes bind:scopes={login_config.scopes} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="block pb-6">
<span class="text-primary font-semibold text-xs"
>Extra Query Args for Authorize Request&nbsp;<Tooltip
@@ -66,14 +70,14 @@
>
<OauthExtraParams bind:extra_params={login_config.extra_params} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="block pb-6">
<span class="text-primary font-semibold text-xs"
>Extra Query Args for Token request <Tooltip>Not needed in most cases</Tooltip></span
>
<OauthExtraParams bind:extra_params={login_config.extra_params_callback} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="block pb-6">
<span class="text-primary font-semibold text-xs"
>Payload <Tooltip
@@ -1,4 +1,5 @@
<script lang="ts" module>
import { untrack } from 'svelte'
function validate(values: TableEditorValues, dbSchema?: DBSchema) {
const columnNamesErrs = values.columns.flatMap((column) => {
const isUnique = values.columns.filter((c) => c.name === column.name).length === 1
@@ -92,7 +93,7 @@
computePreview
}: Props = $props()
const columnTypes = DB_TYPES[dbType]
const columnTypes = DB_TYPES[untrack(() => dbType)]
const defaultColumnType = (
{
postgresql: 'BIGSERIAL',
@@ -102,10 +103,10 @@
mysql: 'varchar',
duckdb: 'string'
} satisfies Record<DbType, string>
)[dbType]
)[untrack(() => dbType)]
const values: TableEditorValues = $state(
$state.snapshot(initialValues) ?? {
$state.snapshot(untrack(() => initialValues)) ?? {
name: '',
columns: [],
foreignKeys: []
@@ -122,8 +123,8 @@
...(primaryKey && { primaryKey })
})
}
if (!initialValues) {
addColumn({ name: 'id', primaryKey: features?.primaryKeys })
if (!untrack(() => initialValues)) {
addColumn({ name: 'id', primaryKey: untrack(() => features)?.primaryKeys })
}
const errors: ReturnType<typeof validate> = $derived(validate(values, dbSchema))
@@ -69,6 +69,7 @@
let randomId = 'datetarget-' + Math.random().toString(36).substring(7)
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-row gap-1 items-center w-full"
id={randomId}
@@ -119,6 +119,7 @@
let randomId = 'datetarget-' + Math.random().toString(36).substring(7)
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-row gap-1 items-center w-full relative"
id={randomId}
@@ -6,16 +6,20 @@
import DrawerContent from './common/drawer/DrawerContent.svelte'
import DefaultScriptsInner from './DefaultScriptsInner.svelte'
let drawer: Drawer
export let placement: 'left' | 'right' = 'left'
interface Props {
placement?: 'left' | 'right'
size?: 'xs3' | 'xs2'
noText?: boolean
}
export let size: 'xs3' | 'xs2' = 'xs2'
export let noText = false
let { placement = 'left', size = 'xs2', noText = false }: Props = $props()
let drawer: Drawer | undefined = $state()
</script>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Drawer bind:this={drawer} {placement}>
<DrawerContent title="Edit Default Scripts" on:close={drawer.closeDrawer}>
<DrawerContent title="Edit Default Scripts" on:close={drawer?.closeDrawer}>
<DefaultScriptsInner />
</DrawerContent>
</Drawer>
@@ -6,8 +6,11 @@
import { defaultScriptLanguages } from '$lib/scripts'
import Alert from './common/alert/Alert.svelte'
export let small = false
$: langs = computeLangs($defaultScripts)
interface Props {
small?: boolean;
}
let { small = false }: Props = $props();
function computeLangs(defaultScripts: WorkspaceDefaultScripts | undefined): Script['language'][] {
const allLangs = Object.keys(defaultScriptLanguages) as Script['language'][]
@@ -30,6 +33,7 @@
requestBody: $defaultScripts
})
}
let langs = $derived(computeLangs($defaultScripts))
</script>
<Alert title="Global to workspace" type="info" class="mb-4" size={small ? 'xs' : 'sm'}>
@@ -47,7 +51,7 @@
<div>
{#if i > 0}
<button
on:click={() => changePosition(i ?? 0, true)}
onclick={() => changePosition(i ?? 0, true)}
class={small ? 'mr-2 text-secondary text-sm' : 'text-lg mr-2'}
title="Move up"
>
@@ -56,7 +60,7 @@
{/if}
{#if i < langs.length - 1}
<button
on:click={() => changePosition(i ?? 0, false)}
onclick={() => changePosition(i ?? 0, false)}
class={small ? 'mr-2 text-secondary text-sm' : 'text-lg mr-2'}
title="Move down">&downarrow;</button
>
+11 -3
View File
@@ -2,11 +2,19 @@
import { ExternalLink } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
export let link: string | undefined = undefined
interface Props {
link?: string | undefined;
class?: string;
children?: import('svelte').Snippet;
}
let { link = undefined, class: className = '', children }: Props = $props();
</script>
<div class={twMerge('text-xs text-primary font-normal', $$props.class)}>
<slot />
<div class={twMerge('text-xs text-primary font-normal', className)}>
{@render children?.()}
{#if link}
<a href={link} target="_blank" class="whitespace-nowrap"
>Learn more <ExternalLink size={12} class="inline-block" /></a
+10 -9
View File
@@ -22,7 +22,7 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { onDestroy, onMount, setContext, untrack } from 'svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import { page } from '$app/stores'
import { page } from '$app/state'
import { getUserExt } from '$lib/user'
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import FlowModuleSchemaMap from './flows/map/FlowModuleSchemaMap.svelte'
@@ -169,11 +169,12 @@
let loadingCodebaseButton = $state(false)
let lastCommandId = ''
if (initial) {
if (initial.type == 'script') {
replaceScript(initial.script)
} else if (initial.type == 'flow') {
replaceFlow(initial.flow)
const untrackedInitial = untrack(() => initial)
if (untrackedInitial) {
if (untrackedInitial.type == 'script') {
replaceScript(untrackedInitial.script)
} else if (untrackedInitial.type == 'flow') {
replaceFlow(untrackedInitial.flow)
}
modeInitialized = true
}
@@ -596,9 +597,9 @@
}
})
}
let token = $derived($page.url.searchParams.get('wm_token') ?? undefined)
let workspace = $derived($page.url.searchParams.get('workspace') ?? undefined)
let themeDarkRaw = $derived($page.url.searchParams.get('activeColorTheme'))
let token = $derived(page.url.searchParams.get('wm_token') ?? undefined)
let workspace = $derived(page.url.searchParams.get('workspace') ?? undefined)
let themeDarkRaw = $derived(page.url.searchParams.get('activeColorTheme'))
let themeDark = $derived(themeDarkRaw == '2' || themeDarkRaw == '4')
$effect.pre(() => {
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import { melt } from '@melt-ui/svelte'
import { twMerge } from 'tailwind-merge'
@@ -18,7 +19,7 @@
const {
elements: { subTrigger, subMenu },
states: { subOpen }
} = builders.createSubmenu()
} = untrack(() => builders).createSubmenu()
let subItems = $derived((item.submenuItems ?? []).filter((i) => !i.hide))
</script>
@@ -76,7 +76,7 @@
ids: { menu: dropdownId }
} = createDropdownMenu({
positioning: {
placement
placement: untrack(() => placement)
},
loop: true,
onOpenChange: ({ next }) => {
@@ -4,9 +4,13 @@
import { Hourglass } from 'lucide-svelte'
import WaitTimeWarning from './common/waitTimeWarning/WaitTimeWarning.svelte'
export let duration_ms: number
export let self_wait_time_ms: number | undefined = undefined
export let aggregate_wait_time_ms: number | undefined = undefined
interface Props {
duration_ms: number;
self_wait_time_ms?: number | undefined;
aggregate_wait_time_ms?: number | undefined;
}
let { duration_ms, self_wait_time_ms = undefined, aggregate_wait_time_ms = undefined }: Props = $props();
</script>
<div>
@@ -113,10 +113,10 @@
}
})
let lastArgs = $state.snapshot(otherArgs)
let lastArgs = $state.snapshot(untrack(() => otherArgs))
let timeout: number | undefined = $state()
let nargs = $state($state.snapshot(otherArgs))
let nargs = $state($state.snapshot(untrack(() => otherArgs)))
$effect(() => {
otherArgs
untrack(() => clearTimeout(timeout))
@@ -286,7 +286,7 @@
}
}
let jsonView: boolean = $state(customUi?.jsonOnly == true)
let jsonView: boolean = $state(untrack(() => customUi)?.jsonOnly == true)
let schemaString: string = $state(JSON.stringify(schema, null, '\t'))
let error: string | undefined = $state(undefined)
let editor: SimpleEditor | undefined = $state(undefined)
@@ -296,8 +296,8 @@
editor?.setCode(schemaString)
}
const editTabDefaultSize = noPreview ? 100 : 50
editPanelSize = editTab ? (editPanelInitialSize ?? editTabDefaultSize) : 0
const editTabDefaultSize = untrack(() => noPreview) ? 100 : 50
editPanelSize = untrack(() => editTab) ? (untrack(() => editPanelInitialSize) ?? editTabDefaultSize) : 0
let inputPanelSize = $state(100 - editPanelSize)
let editPanelSizeSmooth = tweened(editPanelSize, {
duration: 150
@@ -592,7 +592,7 @@
{argName}
{#if !uiOnly}
<div onclick={stopPropagation(preventDefault(bubble('click')))}>
<Popover placement="bottom-end" containerClasses="p-4" closeButton>
<Popover placement="bottom-end" closeButton>
{#snippet trigger()}
<Button
variant="subtle"
+4 -4
View File
@@ -189,11 +189,11 @@
}
})
let lang = $state(scriptLangToEditorLang(scriptLang))
let lang = $state(scriptLangToEditorLang(untrack(() => scriptLang)))
let filePath = $state(computePath(path))
let filePath = $state(computePath(untrack(() => path)))
let initialPath: string | undefined = $state(path)
let initialPath: string | undefined = $state(untrack(() => path))
let websockets: WebSocket[] = []
let languageClients: MonacoLanguageClient[] = []
@@ -209,7 +209,7 @@
let destroyed = false
const uri = computeUri(
untrack(() => filePath),
scriptLang
untrack(() => scriptLang)
)
console.log('uri', uri)
@@ -1,32 +1,40 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { type Job } from '$lib/gen'
import { isScriptPreview } from '$lib/utils'
import { onDestroy } from 'svelte'
export let job: Job | undefined = undefined
/** Execution duration of current active job (in ms) */
export let executionDuration: number = 0
/** Is current job running more than specified value in `longDefinition` seconds */
export let longRunning: boolean = false
/** What do we count as "long" (in ms)*/
export let longDefinition: number = 30_000
/** How often component updates execution duration (in ms)
interface Props {
job?: Job | undefined;
/** Execution duration of current active job (in ms) */
executionDuration?: number;
/** Is current job running more than specified value in `longDefinition` seconds */
longRunning?: boolean;
/** What do we count as "long" (in ms)*/
longDefinition?: number;
/** How often component updates execution duration (in ms)
* Higher value -> more efficient component is, less accuracy it has
* Lower value -> less efficient component is, more accuracy it has
*/
export let updateResolution: number = 5_000
updateResolution?: number;
}
let {
job = undefined,
executionDuration = $bindable(0),
longRunning = $bindable(false),
longDefinition = 30_000,
updateResolution = 5_000
}: Props = $props();
let startedAt: number | undefined = undefined
let busy: boolean = false
let busy: boolean = $state(false)
let interval: number | undefined
// Detect when execution of job started
$: if (
!busy &&
job &&
'running' in job &&
(job.job_kind == 'script' || isScriptPreview(job?.job_kind))
)
start(job)
function start(job: Job) {
busy = true
@@ -50,4 +58,14 @@
// Clear the interval when the component is destroyed
clearInterval(interval)
})
// Detect when execution of job started
run(() => {
if (
!busy &&
job &&
'running' in job &&
(job.job_kind == 'script' || isScriptPreview(job?.job_kind))
)
start(job)
});
</script>
@@ -1,6 +1,7 @@
<!-- Used to avoid height jitter when loading monaco asynchronously -->
<script lang="ts">
import { untrack } from 'svelte'
import { getOS } from '$lib/utils'
import { MONACO_Y_PADDING } from './vscode'
@@ -45,7 +46,7 @@
const charWidth = 9 // try to match as closely as possible to monaco editor
const lineHeight = fontSize * GOLDEN_LINE_HEIGHT_RATIO
const lineHeight = untrack(() => fontSize) * GOLDEN_LINE_HEIGHT_RATIO
let [clientWidth, clientHeight] = $state([0, 0])
let showHorizontalScrollbar = $derived(
+36 -16
View File
@@ -5,19 +5,37 @@
import Tooltip from './meltComponents/Tooltip.svelte'
import { InfoIcon } from 'lucide-svelte'
export let label: string
export let format: string = ''
export let contentEncoding = ''
export let type: string | undefined = undefined
export let disabled: boolean = false
export let required = false
export let displayType: boolean = true
export let labelClass: string = ''
export let prettify = false
export let simpleTooltip: string | undefined = undefined
export let lightHeader = false
export let SimpleTooltipIcon = InfoIcon
export let simpleTooltipIconClass = ''
interface Props {
label: string;
format?: string;
contentEncoding?: string;
type?: string | undefined;
disabled?: boolean;
required?: boolean;
displayType?: boolean;
labelClass?: string;
prettify?: boolean;
simpleTooltip?: string | undefined;
lightHeader?: boolean;
SimpleTooltipIcon?: any;
simpleTooltipIconClass?: string;
}
let {
label,
format = '',
contentEncoding = '',
type = undefined,
disabled = false,
required = false,
displayType = true,
labelClass = '',
prettify = false,
simpleTooltip = undefined,
lightHeader = false,
SimpleTooltipIcon = InfoIcon,
simpleTooltipIconClass = ''
}: Props = $props();
</script>
<div class="inline-flex flex-row items-baseline truncated">
@@ -54,9 +72,11 @@
{#if !emptyString(simpleTooltip)}
<Tooltip class="ml-2" placement="bottom">
<SimpleTooltipIcon size="14" class={'-mb-0.5 ' + simpleTooltipIconClass} />
<span class="text-xs" slot="text">
{simpleTooltip}
</span>
{#snippet text()}
<span class="text-xs" >
{simpleTooltip}
</span>
{/snippet}
</Tooltip>
{/if}
</div>
+26 -21
View File
@@ -228,7 +228,9 @@
}
}
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // kept for legacy reasons
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(
untrack(() => savedPrimarySchedule)
) // kept for legacy reasons
const triggersCount = writable<TriggersCount | undefined>(undefined)
const simplifiedPoll = writable(false)
@@ -601,8 +603,8 @@
const selectionManager = new SelectionManager()
const selectedIdStore = $derived(selectionManager.getSelectedId())
// Initialize with selected id if provided
if (selectedId) {
selectionManager.selectId(selectedId)
if (untrack(() => selectedId)) {
selectionManager.selectId(untrack(() => selectedId) ?? '')
} else {
selectionManager.selectId('settings-metadata')
}
@@ -611,11 +613,11 @@
return selectedIdStore
}
const previewArgsStore = $state({ val: initialArgs })
const previewArgsStore = $state({ val: untrack(() => initialArgs) })
const scriptEditorDrawer = writable<ScriptEditorDrawer | undefined>(undefined)
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
const history = initHistory(flowStore.val)
const pathStore = writable<string>(pathStoreInit ?? initialPath)
const history = initHistory(untrack(() => flowStore).val)
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
const captureOn = writable<boolean>(false)
const showCaptureHint = writable<boolean | undefined>(undefined)
const flowInputEditorStateStore = writable<FlowInputEditorState>({
@@ -642,15 +644,15 @@
scriptEditorDrawer,
flowEditorDrawer,
history,
flowStateStore,
flowStore,
flowStateStore: untrack(() => flowStateStore),
flowStore: untrack(() => flowStore),
pathStore,
stepsInputArgs,
saveDraft,
initialPathStore,
fakeInitialPath,
flowInputsStore: writable<FlowInput>({}),
customUi,
customUi: untrack(() => customUi),
insertButtonOpen,
executionCount: writable(0),
flowInputEditorState: flowInputEditorStateStore,
@@ -661,10 +663,13 @@
})
// Set up NoteEditor context for note editing capabilities
const noteEditor = new NoteEditor(flowStore, () => {
// Enable notes display when a note is created
flowEditor?.enableNotes?.()
})
const noteEditor = new NoteEditor(
untrack(() => flowStore),
() => {
// Enable notes display when a note is created
flowEditor?.enableNotes?.()
}
)
setNoteEditorContext(noteEditor)
setContext(
@@ -678,9 +683,9 @@
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? [])
...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? [])
],
selectedTriggerIndexFromUrl,
untrack(() => selectedTriggerIndexFromUrl),
saveSessionDraft
)
)
@@ -804,7 +809,7 @@
onClick: () => void
}> = []
if (customUi.topBar?.extraDeployOptions != false) {
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) {
dropdownItems.push({
label: 'Exit & see details',
@@ -812,14 +817,14 @@
})
}
if (!newFlow) {
if (!untrack(() => newFlow)) {
dropdownItems.push({
label: 'Fork',
onClick: () => window.open(`/flows/add?template=${initialPath}`)
})
}
if (!newFlow && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) {
if (!untrack(() => newFlow) && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) {
dropdownItems.push({
label: 'Edit in workspace fork',
onClick: () => window.open(buildForkEditUrl('flow', initialPath))
@@ -1036,10 +1041,10 @@
}
let stepHistoryLoader = new StepHistoryLoader(
loadedFromHistoryFromUrl?.stepsState ?? {},
loadedFromHistoryFromUrl?.flowJobInitial,
untrack(() => loadedFromHistoryFromUrl)?.stepsState ?? {},
untrack(() => loadedFromHistoryFromUrl)?.flowJobInitial,
saveSessionDraft,
noInitial
untrack(() => noInitial)
)
setStepHistoryLoaderContext(stepHistoryLoader)
@@ -9,23 +9,38 @@
import { dfs } from './flows/dfs'
import { workspaceStore } from '$lib/stores'
export let flow: {
interface Props {
flow: {
summary: string
description?: string
value: FlowValue
schema?: any
path?: string
};
overflowAuto?: boolean;
noSide?: boolean;
download?: boolean;
noGraph?: boolean;
triggerNode?: boolean;
stepDetail?: FlowModule | string | undefined;
workspace?: string | undefined;
minHeight?: number;
noBorder?: boolean;
}
export let overflowAuto = false
export let noSide = false
export let download = false
export let noGraph = false
export let triggerNode = false
export let stepDetail: FlowModule | string | undefined = undefined
export let workspace: string | undefined = $workspaceStore
export let minHeight = 400
export let noBorder = false
let {
flow,
overflowAuto = false,
noSide = false,
download = false,
noGraph = false,
triggerNode = false,
stepDetail = $bindable(undefined),
workspace = $workspaceStore,
minHeight = 400,
noBorder = false
}: Props = $props();
const dispatch = createEventDispatcher()
</script>
@@ -19,17 +19,20 @@
import HighlightTheme from './HighlightTheme.svelte'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
export let schema: any | undefined = undefined
interface Props {
schema?: any | undefined
stepDetail?: FlowModule | string | undefined
jobScriptHash?: string | undefined
}
export let stepDetail: FlowModule | string | undefined = undefined
export let jobScriptHash: string | undefined = undefined
let codeViewer: Drawer
let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined }: Props = $props()
let codeViewer: Drawer | undefined = $state()
</script>
<HighlightTheme />
<Drawer bind:this={codeViewer} size="900px">
<DrawerContent title={'Expanded Code'} on:close={codeViewer.closeDrawer}>
<DrawerContent title={'Expanded Code'} on:close={codeViewer?.closeDrawer}>
{#if stepDetail && typeof stepDetail != 'string'}
{#if stepDetail.value.type == 'script'}
<div class="mb-4">
@@ -183,7 +186,7 @@
<Button
unifiedSize="sm"
variant="subtle"
onClick={codeViewer.openDrawer}
onClick={codeViewer?.openDrawer}
startIcon={{ icon: Expand }}>Expand</Button
>
</div>
@@ -221,7 +224,7 @@
<Button
unifiedSize="sm"
variant="subtle"
onClick={codeViewer.openDrawer}
onClick={codeViewer?.openDrawer}
startIcon={{ icon: Expand }}>Expand</Button
>
</div>
@@ -3,7 +3,11 @@
import FieldHeader from './FieldHeader.svelte'
export let schema: Schema | { [key: string]: unknown } | undefined
interface Props {
schema: Schema | { [key: string]: unknown } | undefined;
}
let { schema }: Props = $props();
</script>
<ul class="my-2">
@@ -62,7 +62,7 @@
const timelineItems = $derived(timelineCompute?.items ?? undefined)
const timelineNow = $derived(timelineCompute?.now ?? Date.now())
let moduleTracker = new ChangeTracker($state.snapshot(job.raw_flow?.modules ?? []))
let moduleTracker = new ChangeTracker($state.snapshot(untrack(() => job).raw_flow?.modules ?? []))
$effect(() => {
readFieldsRecursively(job.raw_flow?.modules ?? [])
untrack(() => moduleTracker.track($state.snapshot(job.raw_flow?.modules ?? [])))
@@ -123,7 +123,7 @@
}
let timelineAvailableWidths = $state<Record<string, number>>({})
let lastJobId: string | undefined = $state(job.id)
let lastJobId: string | undefined = $state(untrack(() => job).id)
const timelinelWidth = $derived.by(() => {
const widths = Object.values(timelineAvailableWidths)
@@ -4,9 +4,13 @@
import AnimatedButton from './common/button/AnimatedButton.svelte'
import { twMerge } from 'tailwind-merge'
export let connecting: boolean
export let id: undefined | string = undefined
export let wrapperClasses = ''
interface Props {
connecting: boolean;
id?: undefined | string;
wrapperClasses?: string;
}
let { connecting, id = undefined, wrapperClasses = '' }: Props = $props();
</script>
<AnimatedButton animate={connecting} baseRadius="6px" animationDuration="2s" marginWidth="2px">
@@ -62,7 +62,7 @@
showLogsWithResult = false
}: Props = $props()
let lastJobId: string = jobId
let lastJobId: string = untrack(() => jobId)
let retryStatus = $state({ val: {} })
let globalRefreshes: Record<string, ((clear, root) => Promise<void>)[]> = $state({})
@@ -71,11 +71,11 @@
flowState,
suspendStatus,
retryStatus,
hideDownloadInGraph,
hideNodeDefinition,
hideTimeline,
hideJobId,
hideDownloadLogs
hideDownloadInGraph: untrack(() => hideDownloadInGraph),
hideNodeDefinition: untrack(() => hideNodeDefinition),
hideTimeline: untrack(() => hideTimeline),
hideJobId: untrack(() => hideJobId),
hideDownloadLogs: untrack(() => hideDownloadLogs)
})
function loadOwner(path: string) {
@@ -181,7 +181,7 @@
let resultStreams: Record<string, string | undefined> = $state({})
if (onResultStreamUpdate == undefined) {
if (untrack(() => onResultStreamUpdate) == undefined) {
onResultStreamUpdate = ({
jobId,
result_stream
@@ -234,7 +234,7 @@
})
let jobResults: any[] = $state(
flowJobIds?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? []
untrack(() => flowJobIds)?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? []
)
let retry_selected = $state('')
@@ -805,7 +805,7 @@
let destroyed = false
updateRecursiveRefresh(jobId)
updateRecursiveRefresh(untrack(() => jobId))
async function updateJobId() {
if (jobId !== job?.id || innerModules == undefined) {
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { type FlowValue, FlowService } from '$lib/gen'
import { Tab, Tabs, TabContent } from './common'
import SchemaViewer from './SchemaViewer.svelte'
@@ -51,13 +52,14 @@
}: Props = $props()
let open: { [id: number]: boolean } = {}
if (initialOpen) {
open[initialOpen] = true
const untrackedInitialOpen = untrack(() => initialOpen)
if (untrackedInitialOpen) {
open[untrackedInitialOpen] = true
}
let previousVersionId: number | undefined = $state(undefined)
let previousFlow: PreviousFlow | undefined = $state(undefined)
let tab: TabValue = $state(initTab ?? 'diff')
let tab: TabValue = $state(untrack(() => initTab) ?? 'diff')
let previousFlowCache: Record<number, PreviousFlow> = {}
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import AiChatLayout from './copilot/chat/AiChatLayout.svelte'
import type { FlowBuilderProps } from './flow_builder'
import FlowBuilder from './FlowBuilder.svelte'
@@ -11,12 +12,12 @@
...props
}: FlowBuilderProps & { light?: boolean } = $props()
let flowStore = $state(oldFlowStore)
let flowStateStore = $state(oldFlowStateStore)
let flowStore = $state(untrack(() => oldFlowStore))
let flowStateStore = $state(untrack(() => oldFlowStateStore))
let trialRender = $state(true)
if (light) {
if (untrack(() => light)) {
setTimeout(() => {
trialRender = false
}, 1000 * 300)
@@ -270,12 +270,14 @@
{/if}
{#if perms}
<TableCustom>
<!-- @migration-task: migrate this slot by hand, `header-row` is an invalid identifier -->
<tr slot="header-row">
<th>user/group</th>
<th></th>
<th></th>
</tr>
{#snippet headerRow()}
<tr >
<th>user/group</th>
<th></th>
<th></th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each perms ?? [] as { owner_name, role }}<tr>
@@ -407,7 +409,7 @@
<p class="text-primary text-sm">No folder is managing this folder</p>
{:else}
<TableCustom>
<tr slot="header-row">
<tr slot="headerRow">
<th>folder</th>
<th />
</tr>
@@ -1,19 +1,27 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { FolderService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import Cell from './table/Cell.svelte'
export let name: string
export let tabular = false
export let order = ['scripts', 'flows', 'apps', 'schedules', 'variables', 'resources']
interface Props {
name: string;
tabular?: boolean;
order?: any;
}
$: $workspaceStore && loadUsage()
let { name, tabular = false, order = ['scripts', 'flows', 'apps', 'schedules', 'variables', 'resources'] }: Props = $props();
let usage: Record<string, number> = {}
let usage: Record<string, number> = $state({})
async function loadUsage() {
usage = await FolderService.getFolderUsage({ workspace: $workspaceStore!, name })
}
run(() => {
$workspaceStore && loadUsage()
});
</script>
{#if tabular}
@@ -5,10 +5,17 @@
import { onDestroy, onMount } from 'svelte'
let divEl: HTMLDivElement | null = null
let divEl: HTMLDivElement | null = $state(null)
let editor: meditor.IStandaloneCodeEditor
export let code: string = ''
interface Props {
code?: string;
class?: string;
}
let { code = '', class: className = '' }: Props = $props();
async function loadMonaco() {
editor = meditor.create(divEl as HTMLDivElement, {
@@ -43,4 +50,4 @@
})
</script>
<div bind:this={divEl} class="{$$props.class ?? ''} editor"></div>
<div bind:this={divEl} class="{className} editor"></div>
+14 -10
View File
@@ -159,12 +159,14 @@
{/if}
{#if members}
<TableCustom>
<!-- @migration-task: migrate this slot by hand, `header-row` is an invalid identifier -->
<tr slot="header-row">
<th>user</th>
<th></th>
<th></th>
</tr>
{#snippet headerRow()}
<tr >
<th>user</th>
<th></th>
<th></th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each members ?? [] as { member_name, role }}<tr>
@@ -301,10 +303,12 @@
{#if instance_group?.emails}
<h2 class="mt-6 text-emphasis text-xs font-semibold">Members from the instance group</h2>
<TableCustom>
<!-- @migration-task: migrate this slot by hand, `header-row` is an invalid identifier -->
<tr slot="header-row">
<th>user</th>
</tr>
{#snippet headerRow()}
<tr >
<th>user</th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each instance_group?.emails ?? [] as email}<tr>
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { stopPropagation, createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
@@ -34,7 +35,7 @@
}: Props = $props()
let error = $state('')
const regex = acceptUnderScores ? /^[a-zA-Z][a-zA-Z0-9_]*$/ : /^[a-zA-Z][a-zA-Z0-9]*$/
const regex = untrack(() => acceptUnderScores) ? /^[a-zA-Z][a-zA-Z0-9_]*$/ : /^[a-zA-Z][a-zA-Z0-9]*$/
function validateId(id: string, reservedIds: string[], reservedPrefixes: string[]) {
if (id == initialId) {
@@ -407,6 +407,7 @@
}
function updatePropsBeingEdited(focused: boolean) {
if (!exprBeingEdited) return
let newPropsBeingEdited = [...$exprBeingEdited]
if (focused) {
newPropsBeingEdited.push(argName)
@@ -7,8 +7,12 @@
import Cell from './table/Cell.svelte'
import Row from './table/Row.svelte'
export let inputTransforms: Record<string, InputTransform>
$: entries = Object.entries(inputTransforms)
interface Props {
inputTransforms: Record<string, InputTransform>;
}
let { inputTransforms }: Props = $props();
let entries = $derived(Object.entries(inputTransforms))
</script>
{#if entries.length}
@@ -1,4 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { GroupService, type InstanceGroup } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import autosize from '$lib/autosize'
@@ -8,17 +10,18 @@
import { sendUserToast } from '$lib/toast'
import { Loader2 } from 'lucide-svelte'
export let name: string
interface Props {
name: string;
}
let email = ''
let instance_group: InstanceGroup | undefined
let members: { member_email: string }[] | undefined = undefined
let { name }: Props = $props();
let email = $state('')
let instance_group: InstanceGroup | undefined = $state()
let members: { member_email: string }[] | undefined = $state(undefined)
const dispatch = createEventDispatcher()
$: {
load()
}
async function load() {
return Promise.all([loadInstanceGroup()])
@@ -32,6 +35,9 @@
})
: []
}
run(() => {
load()
});
</script>
<div class="flex flex-col gap-6">
@@ -85,17 +91,20 @@
</div>
{#if members}
<TableCustom>
<tr slot="header-row">
<th>user</th>
<th></th>
</tr>
<tbody slot="body">
{#each members as { member_email }}<tr>
<td>{member_email}</td>
<td>
<button
class="ml-2 text-red-500"
on:click={async () => {
{#snippet headerRow()}
<tr >
<th>user</th>
<th></th>
</tr>
{/snippet}
{#snippet body()}
<tbody >
{#each members as { member_email }}<tr>
<td>{member_email}</td>
<td>
<button
class="ml-2 text-red-500"
onclick={async () => {
await GroupService.removeUserFromInstanceGroup({
name,
requestBody: { email: member_email }
@@ -104,10 +113,11 @@
sendUserToast('User removed')
loadInstanceGroup()
}}>remove</button
>
</td>
</tr>{/each}
</tbody>
>
</td>
</tr>{/each}
</tbody>
{/snippet}
</TableCustom>
{:else}
<div class="flex flex-col">
@@ -1,4 +1,7 @@
<script lang="ts">
import { stopPropagation, createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
import { Pencil } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import Button from './common/button/Button.svelte'
@@ -9,13 +12,23 @@
import { sendUserToast } from '$lib/toast'
import TextInput from './text_input/TextInput.svelte'
export let value: string | undefined
export let email: string
export let username: string | undefined = undefined
export let automateUsernameCreation: boolean = false
export let login_type: string
interface Props {
value: string | undefined
email: string
username?: string | undefined
automateUsernameCreation?: boolean
login_type: string
}
let password: string = ''
let {
value = $bindable(),
email,
username = undefined,
automateUsernameCreation = false,
login_type = $bindable()
}: Props = $props()
let password: string = $state('')
const dispatch = createEventDispatcher()
@@ -46,12 +59,12 @@
}}
closeButton
>
<svelte:fragment slot="trigger">
{#snippet trigger()}
<Button unifiedSize="sm" nonCaptureEvent={true} variant="subtle" startIcon={{ icon: Pencil }}
>Edit</Button
>
</svelte:fragment>
<svelte:fragment slot="content">
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-8 max-w-sm p-4">
{#if automateUsernameCreation && username}
<ChangeInstanceUsernameInner {email} {username} on:renamed noPadding />
@@ -96,10 +109,11 @@
type="password"
bind:value={password}
class="!w-auto grow"
on:click|stopPropagation={() => {}}
on:keydown|stopPropagation
on:keypress|stopPropagation={({ key }) => {
if (key === 'Enter') {
onclick={stopPropagation(() => {})}
onkeydown={stopPropagation(bubble('keydown'))}
onkeypress={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
savePassword()
}
}}
@@ -126,10 +140,11 @@
type="text"
bind:value={login_type}
class="!w-auto grow"
on:click|stopPropagation={() => {}}
on:keydown|stopPropagation
on:keypress|stopPropagation={({ key }) => {
if (key === 'Enter') {
onclick={stopPropagation(() => {})}
onkeydown={stopPropagation(bubble('keydown'))}
onkeypress={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
saveLoginType()
}
}}
@@ -153,5 +168,5 @@
</Button>
</label>
</div>
</svelte:fragment>
{/snippet}
</Popover>
@@ -8,13 +8,17 @@
import { generateRandomString } from '$lib/utils'
import { globalEmailInvite } from '$lib/stores'
export let close: (() => void) | undefined = undefined
interface Props {
close?: (() => void) | undefined;
}
let { close = undefined }: Props = $props();
const dispatch = createEventDispatcher()
let is_super_admin = false
let password: string = generateRandomString(10)
let name: string | undefined
let is_super_admin = $state(false)
let password: string = $state(generateRandomString(10))
let name: string | undefined = $state()
let company: string | undefined
async function addUser() {
+1 -1
View File
@@ -89,7 +89,7 @@
let lastStartedAt: number = Date.now()
let currentId: string | undefined = $state(undefined)
let noPingTimeout: number | undefined = undefined
let lastNoLogs = $state(noLogs)
let lastNoLogs = $state(untrack(() => noLogs))
let lastCompletedJobId = $state<string | undefined>(undefined)
let token = getContext<{ token?: string }>('AuthToken')
@@ -1,24 +1,22 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
export let value: any
interface Props {
value: any;
}
let { value = $bindable() }: Props = $props();
const AUTH_URL_SUFFIX = '/ui/oauth2'
$: enabled = value != undefined
// If `baseUrl` is not already set in the form, try to parse it from the `auth_url` value
//
// The binding dance here allows us to avoid rendering the string 'undefined' in the input, and
// also allow lazy/async binding of the `value` prop.
$: derivedBaseUrl = value?.connect_config?.auth_url?.replace(AUTH_URL_SUFFIX, '')
let proxyUrlValue = undefined
$: baseUrl = proxyUrlValue ?? derivedBaseUrl ?? ''
let proxyUrlValue = $state(undefined)
$: changeValues({ baseUrl, id: value?.id ?? '' })
function changeValues({ baseUrl, id }) {
if (value) {
@@ -40,10 +38,20 @@
proxyUrlValue = baseUrl
}
}
let enabled = $derived(value != undefined)
// If `baseUrl` is not already set in the form, try to parse it from the `auth_url` value
//
// The binding dance here allows us to avoid rendering the string 'undefined' in the input, and
// also allow lazy/async binding of the `value` prop.
let derivedBaseUrl = $derived(value?.connect_config?.auth_url?.replace(AUTH_URL_SUFFIX, ''))
let baseUrl = $derived(proxyUrlValue ?? derivedBaseUrl ?? '')
run(() => {
changeValues({ baseUrl, id: value?.id ?? '' })
});
</script>
<div class="flex flex-col gap-1">
<!-- svelte-ignore a11y-label-has-associated-control -->
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-xs font-semibold text-emphasis flex gap-4 items-center"
><div class="w-[120px]"><IconedResourceType name={'kanidm'} after={true} /></div><Toggle
checked={enabled}
@@ -63,7 +63,10 @@
<span class="text-secondary font-normal text-xs"
>{'REALM_URL/protocol/openid-connect/auth'}</span
>
<TextInput inputProps={{ type: 'text', placeholder: 'yourorg' }} bind:value={value['org']} />
<TextInput
inputProps={{ type: 'text', placeholder: 'yourorg' }}
bind:value={value['org']}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
+10 -2
View File
@@ -1,5 +1,13 @@
<script lang="ts">
export let id: string
import { run } from 'svelte/legacy';
$: id && console.log('updateJobId')
interface Props {
id: string;
}
let { id }: Props = $props();
run(() => {
id && console.log('updateJobId')
});
</script>
@@ -1,8 +1,14 @@
<script lang="ts">
import { untrack } from 'svelte'
import { AnsiUp } from 'ansi_up'
export let content: string
export let highlighted: any[]
interface Props {
content: string
highlighted: any[]
onClick?: () => void
}
let { content, highlighted, onClick }: Props = $props()
const ansi_up = new AnsiUp()
ansi_up.use_classes = true
@@ -27,10 +33,10 @@
return html2
}
let html = highlightSnippet(content)
let html = highlightSnippet(untrack(() => content))
</script>
<button on:click class="font-light !m-0 !p-0">
<button onclick={onClick} class="font-light !m-0 !p-0">
<pre
class="bg-surface-secondary hover:bg-surface px-2 py-1 text-secondary text-xs w-[100%] whitespace-pre border min-w-full text-start">
{@html html}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts" module>
import { untrack } from 'svelte'
const s3LogPrefixes = [
'[windmill] Previous logs have been saved to object storage at logs/',
'[windmill] Previous logs have been saved to disk at logs/',
@@ -73,7 +74,7 @@
let LOG_INC = 10000
let LOG_LIMIT = $state(LOG_INC)
let lastJobId = $state(jobId)
let lastJobId = $state(untrack(() => jobId))
let loadedFromObjectStore = $state('')
@@ -5,6 +5,7 @@
import { fade } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
import { untrack } from 'svelte'
export async function refresh() {
await getInstance()?.update()
@@ -26,7 +27,9 @@
content
}: Props = $props()
const [popperRef, popperContent, getInstance] = createPopperActions({ placement })
const [popperRef, popperContent, getInstance] = createPopperActions({
placement: untrack(() => placement)
})
export function open() {
showTooltip = true
+44 -31
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { Map, View, Feature } from 'ol'
import { Fill, Stroke, Style, Text } from 'ol/style.js'
import { useGeographic } from 'ol/proj.js'
@@ -18,17 +20,26 @@
strokeColor?: string
}
export let lon: number | undefined = undefined
export let lat: number | undefined = undefined
export let zoom: number | undefined = undefined
export let markers: Marker[] | string | undefined = undefined
interface Props {
lon?: number | undefined;
lat?: number | undefined;
zoom?: number | undefined;
markers?: Marker[] | string | undefined;
}
let {
lon = undefined,
lat = undefined,
zoom = undefined,
markers = undefined
}: Props = $props();
const LAYER_NAME = {
MARKER: 'Marker'
} as const
let map: Map | undefined = undefined
let mapElement: HTMLDivElement | undefined = undefined
let map: Map | undefined = $state(undefined)
let mapElement: HTMLDivElement | undefined = $state(undefined)
function getLayersByName(name: keyof typeof LAYER_NAME) {
return map
@@ -100,36 +111,38 @@
createMarkerLayers()?.forEach((l) => map?.addLayer(l))
}
$: if (!map && mapElement) {
useGeographic()
map = new Map({
target: mapElement,
layers: [
new TileLayer({
source: new OSM()
run(() => {
if (!map && mapElement) {
useGeographic()
map = new Map({
target: mapElement,
layers: [
new TileLayer({
source: new OSM()
}),
...(createMarkerLayers() || [])
],
view: new View({
center: [lon ?? 0, lat ?? 0],
zoom: zoom ?? 2
}),
...(createMarkerLayers() || [])
],
view: new View({
center: [lon ?? 0, lat ?? 0],
zoom: zoom ?? 2
}),
controls: defaultControls({
attribution: false
controls: defaultControls({
attribution: false
})
})
})
if (lat && lon) {
map.getView().setCenter([lon, lat])
}
if (lat && lon) {
map.getView().setCenter([lon, lat])
}
if (map && zoom) {
map.getView().setZoom(zoom)
}
if (map && zoom) {
map.getView().setZoom(zoom)
}
if (map && markers) {
updateMarkers()
if (map && markers) {
updateMarkers()
}
}
}
});
</script>
<div bind:this={mapElement} class="w-full h-[300px]"></div>
@@ -1,4 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { type MetricDataPoint, MetricsService } from '$lib/gen'
import { displayTime } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
@@ -17,16 +19,20 @@
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale)
export let jobId: string
export let jobUpdateLastFetch: Date | undefined
interface Props {
jobId: string;
jobUpdateLastFetch: Date | undefined;
}
let { jobId, jobUpdateLastFetch }: Props = $props();
let jobMetricsLastFetch: Date | undefined = undefined
let jobMemoryStats: MetricDataPoint[] | undefined = undefined
let jobMemoryStats: MetricDataPoint[] | undefined = $state(undefined)
let data: {
x: number
y: number
}[] = []
}[] = $state([])
let labels: string[] = []
async function loadMetricsData() {
@@ -66,7 +72,9 @@
data = [...data]
}
$: jobUpdateLastFetch && loadMetricsData()
run(() => {
jobUpdateLastFetch && loadMetricsData()
});
</script>
<div class="relative max-h-100">
@@ -5,9 +5,13 @@
import { displayDate } from '$lib/utils'
import { Hourglass } from 'lucide-svelte'
export let type: FlowStatusModule['type']
export let scheduled_for: Date | undefined
export let skipped: boolean = false
interface Props {
type: FlowStatusModule['type'];
scheduled_for: Date | undefined;
skipped?: boolean;
}
let { type, scheduled_for, skipped = false }: Props = $props();
</script>
{#if type == 'WaitingForEvents'}
@@ -8,7 +8,7 @@
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getScriptByPath } from '$lib/scripts'
import { getContext } from 'svelte'
import { getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import JobLoader, { type Callbacks } from './JobLoader.svelte'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
@@ -167,8 +167,8 @@
testJob = modulesTestStates.states?.[mod.id]?.testJob
})
modulesTestStates.states[mod.id] = {
...(modulesTestStates.states?.[mod.id] ?? { loading: false }),
modulesTestStates.states[untrack(() => mod).id] = {
...(modulesTestStates.states?.[untrack(() => mod).id] ?? { loading: false }),
loading: testIsLoading,
testJob: testJob
}
@@ -2,9 +2,13 @@
import { Button } from './common'
import { X, Plus } from 'lucide-svelte'
export let extra_params: Record<string, string> = {}
interface Props {
extra_params?: Record<string, string>;
}
let extra_params_vec: [string, string][] = Object.entries(extra_params)
let { extra_params = $bindable({}) }: Props = $props();
let extra_params_vec: [string, string][] = $state(Object.entries(extra_params))
function sync() {
extra_params = Object.fromEntries(extra_params_vec)
@@ -13,8 +17,8 @@
{#each extra_params_vec as o}
<div class="flex flex-row max-w-md mb-2 gap-2">
<input type="text" on:keyup={sync} bind:value={o[0]} />
<input type="text" on:keyup={sync} bind:value={o[1]} />
<input type="text" onkeyup={sync} bind:value={o[0]} />
<input type="text" onkeyup={sync} bind:value={o[1]} />
<Button
variant="subtle"
destructive
@@ -2,13 +2,17 @@
import { Button } from './common'
import { Minus, Plus } from 'lucide-svelte'
export let scopes: string[] = []
interface Props {
scopes?: string[]
}
let { scopes = $bindable([]) }: Props = $props()
</script>
{#if scopes && Array.isArray(scopes)}
{#each scopes as v}
{#each scopes as v, i}
<div class="flex flex-row max-w-md mb-2">
<input type="text" bind:value={v} />
<input type="text" bind:value={scopes[i]} />
<Button
variant="default"
size="xs"
@@ -119,61 +119,65 @@
</script>
<MeltPopover placement="bottom" on:openChange={(e) => e.detail && loadUsers()}>
<svelte:fragment slot="trigger">
<span class="inline-flex items-center gap-1">
<UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" />
{#if selectedDisplayName}
<span class="text-xs truncate max-w-24">{selectedDisplayName}</span>
{#snippet trigger()}
<span class="inline-flex items-center gap-1">
<UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" />
{#if selectedDisplayName}
<span class="text-xs truncate max-w-24">{selectedDisplayName}</span>
{/if}
</span>
{/snippet}
{#snippet content({ close: closePopover })}
<div class="p-3 flex flex-col gap-2 min-w-48">
<div class="text-xs font-medium text-secondary mb-1">{label}</div>
<!-- Target option -->
{#if targetEmail}
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => onSelect('target')}
>
<Check class="w-3 h-3 {selected === 'target' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{targetUsername}</span>
<span class="text-xs text-tertiary">{isDeployment ? '(target)' : '(current)'}</span>
</button>
{/if}
</span>
</svelte:fragment>
<div slot="content" let:close={closePopover} class="p-3 flex flex-col gap-2 min-w-48">
<div class="text-xs font-medium text-secondary mb-1">{label}</div>
<!-- Target option -->
{#if targetEmail}
<!-- Me option -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover"
onclick={() => onSelect('me')}
>
<Check class="w-3 h-3 {selected === 'me' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{$userStore?.username}</span>
<span class="text-xs text-tertiary">(me)</span>
</button>
<!-- Custom / Pick from workspace -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => onSelect('target')}
onclick={() => {
closePopover()
openModal()
}}
>
<Check class="w-3 h-3 {selected === 'target' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{targetUsername}</span>
<span class="text-xs text-tertiary">{isDeployment ? '(target)' : '(current)'}</span>
{#if selected === 'custom' && customUsername}
<Check class="w-3 h-3 opacity-100" />
<span class="truncate max-w-40">{customUsername}</span>
<span class="text-xs text-tertiary">(custom)</span>
{:else}
<Check class="w-3 h-3 opacity-0" />
<Users class="w-3 h-3 text-tertiary" />
<span>Pick from workspace&hellip;</span>
{/if}
</button>
{/if}
<!-- Me option -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover"
onclick={() => onSelect('me')}
>
<Check class="w-3 h-3 {selected === 'me' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{$userStore?.username}</span>
<span class="text-xs text-tertiary">(me)</span>
</button>
<!-- Custom / Pick from workspace -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => {
closePopover()
openModal()
}}
>
{#if selected === 'custom' && customUsername}
<Check class="w-3 h-3 opacity-100" />
<span class="truncate max-w-40">{customUsername}</span>
<span class="text-xs text-tertiary">(custom)</span>
{:else}
<Check class="w-3 h-3 opacity-0" />
<Users class="w-3 h-3 text-tertiary" />
<span>Pick from workspace&hellip;</span>
{/if}
</button>
</div>
</div>
{/snippet}
</MeltPopover>
<!-- User selection modal -->
+19 -7
View File
@@ -1,11 +1,23 @@
<script lang="ts">
import Tooltip from './Tooltip.svelte'
export let title: string
export let tooltip: string = ''
export let documentationLink: string | undefined = undefined
export let primary: boolean = true
export let childrenWrapperDivClasses: string = ''
interface Props {
title: string;
tooltip?: string;
documentationLink?: string | undefined;
primary?: boolean;
childrenWrapperDivClasses?: string;
children?: import('svelte').Snippet;
}
let {
title,
tooltip = '',
documentationLink = undefined,
primary = true,
childrenWrapperDivClasses = '',
children
}: Props = $props();
</script>
<div class="flex flex-row flex-wrap justify-between items-center pb-2 my-4 mr-2 min-h-16">
@@ -31,9 +43,9 @@
</span>
{/if}
{#if $$slots.default}
{#if children}
<div class="my-2 {childrenWrapperDivClasses}">
<slot />
{@render children?.()}
</div>
{/if}
</div>
@@ -1,4 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { type GridApi, createGrid, type IDatasource } from 'ag-grid-community'
import 'ag-grid-community/styles/ag-grid.css'
@@ -14,15 +16,19 @@
// import 'ag-grid-community/dist/styles/ag-theme-alpine-dark.css'
let selectedRowIndex = -1
export let s3resource: string
export let storage: string | undefined
export let workspaceId: string | undefined
export let disable_download: boolean = false
interface Props {
s3resource: string
storage: string | undefined
workspaceId: string | undefined
disable_download?: boolean
}
let { s3resource, storage, workspaceId, disable_download = false }: Props = $props()
let lastSearch: string | undefined = undefined
let nbRows: number | undefined = undefined
let csvSeparatorChar: string = ','
let nbRows: number | undefined = $state(undefined)
let csvSeparatorChar: string = $state(',')
let datasource: IDatasource = {
rowCount: 0,
getRows: async function (params) {
@@ -95,11 +101,9 @@
toggleRow(rows[0])
}
let eGui: HTMLDivElement
let eGui: HTMLDivElement | undefined = $state()
$: eGui && mountGrid()
let error: string | undefined = undefined
let error: string | undefined = $state(undefined)
async function mountGrid() {
if (eGui) {
try {
@@ -170,7 +174,10 @@
}
}
let darkMode: boolean = false
let darkMode: boolean = $state(false)
run(() => {
eGui && mountGrid()
})
</script>
<DarkModeObserver bind:darkMode />
@@ -182,7 +189,7 @@
<label for="csvSeparatorChar" class="text-2xs text-secondary">Separator</label>
<div class="w-12 ml-2 mr-2">
<select class="h-8" bind:value={csvSeparatorChar} on:change={(e) => mountGrid()}>
<select class="h-8" bind:value={csvSeparatorChar} onchange={(e) => mountGrid()}>
<option value=",">,</option>
<option value=";">;</option>
<option value="\t">\t</option>
@@ -79,12 +79,14 @@
<p class="text-primary text-sm">No permission changes recorded yet</p>
{:else}
<TableCustom>
<tr slot="header-row">
<th>Changed By</th>
<th>Change Type</th>
<th>Affected</th>
<th>Date</th>
</tr>
{#snippet headerRow()}
<tr >
<th>Changed By</th>
<th>Change Type</th>
<th>Affected</th>
<th>Date</th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each history as change}
@@ -10,19 +10,19 @@
import { Hourglass, Loader2, Play, RefreshCw } from 'lucide-svelte'
let dispatch = createEventDispatcher()
let drawer: Drawer
let drawer: Drawer | undefined = $state()
let script: Script
let loadQueuedJobs = true
let queuedJobsLoading = false
let script: Script | undefined = $state()
let loadQueuedJobs = $state(true)
let queuedJobsLoading = $state(false)
let queuedJobs: {
status: 'running' | 'queued'
jobId: string
scheduledFor: string
scriptHash: string
}[] = []
}[] = $state([])
let cancellingInProgress = false
let cancellingInProgress = $state(false)
async function continuouslyLoadQueuedJobs() {
while (loadQueuedJobs) {
@@ -40,7 +40,7 @@
let qjs = await JobService.listQueue({
workspace: $workspaceStore ?? '',
orderDesc: false,
scriptPathExact: script.path
scriptPathExact: script?.path
})
let loadingQueuedJobs: {
status: 'running' | 'queued'
@@ -71,12 +71,12 @@
cancellingInProgress = true
await JobService.cancelPersistentQueuedJobs({
workspace: $workspaceStore ?? '',
path: script.path,
path: script?.path ?? '',
requestBody: {
reason: undefined
}
})
sendUserToast(`All jobs cancelled for ${script.path}`)
sendUserToast(`All jobs cancelled for ${script?.path}`)
cancellingInProgress = false
}
@@ -88,12 +88,12 @@
script = persistentScript!
loadQueuedJobs = true
continuouslyLoadQueuedJobs()
drawer.openDrawer?.()
drawer?.openDrawer?.()
}
async function exit() {
loadQueuedJobs = false
drawer.closeDrawer?.()
drawer?.closeDrawer?.()
}
onDestroy(() => {
@@ -117,51 +117,57 @@
>
<div class="flex gap-2 items-center justify-between">
<h2>
Queued jobs for {script.path}
Queued jobs for {script?.path}
</h2>
<Button size="md" btnClasses="w-full h-8" variant="default" on:click={loadQueuedJobsOnce}>
<RefreshCw class={queuedJobsLoading ? 'animate-spin' : ''} size={14} />
</Button>
</div>
<TableCustom>
<tr slot="header-row">
<th class="text-xs">Script Hash</th>
<th class="text-xs">Job ID</th>
<th class="text-xs">Status</th>
<th class="text-xs">Scheduled For</th>
</tr>
<tbody slot="body">
{#each queuedJobs as { jobId, status, scriptHash, scheduledFor }}
<tr class="">
<td class="text-xs">
<a
class="pr-3"
href="{base}/scripts/get/{scriptHash}?workspace={$workspaceStore}"
target="_blank"
>
{scriptHash}
</a>
</td>
<td class="text-xs">
<a class="pr-3" href="{base}/run/{jobId}?workspace={$workspaceStore}" target="_blank"
>{jobId.substring(24)}</a
>
</td>
<td class="text-xs">
{#if status === 'running'}
<Badge color="yellow" baseClass="!px-1.5">
<Play size={14} />
</Badge>
{:else}
<Badge baseClass="!px-1.5">
<Hourglass size={14} />
</Badge>
{/if}
</td>
<td class="text-xs">{scheduledFor}</td>
</tr>
{/each}
</tbody>
{#snippet headerRow()}
<tr>
<th class="text-xs">Script Hash</th>
<th class="text-xs">Job ID</th>
<th class="text-xs">Status</th>
<th class="text-xs">Scheduled For</th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each queuedJobs as { jobId, status, scriptHash, scheduledFor }}
<tr class="">
<td class="text-xs">
<a
class="pr-3"
href="{base}/scripts/get/{scriptHash}?workspace={$workspaceStore}"
target="_blank"
>
{scriptHash}
</a>
</td>
<td class="text-xs">
<a
class="pr-3"
href="{base}/run/{jobId}?workspace={$workspaceStore}"
target="_blank">{jobId.substring(24)}</a
>
</td>
<td class="text-xs">
{#if status === 'running'}
<Badge color="yellow" baseClass="!px-1.5">
<Play size={14} />
</Badge>
{:else}
<Badge baseClass="!px-1.5">
<Hourglass size={14} />
</Badge>
{/if}
</td>
<td class="text-xs">{scheduledFor}</td>
</tr>
{/each}
</tbody>
{/snippet}
</TableCustom>
{#snippet actions()}
+2 -2
View File
@@ -43,10 +43,10 @@
onClick
}: Props = $props()
const [popperRef, popperContent] = createPopperActions({ placement })
const [popperRef, popperContent] = createPopperActions({ placement: untrack(() => placement) })
const popperOptions: PopperOptions<{}> = {
placement,
placement: untrack(() => placement),
strategy: 'fixed',
modifiers: [
{ name: 'offset', options: { offset: [8, 8] } },
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
let {
prefix = '',
value = $bindable(''),
@@ -8,7 +9,7 @@
} = $props()
let inputElement: HTMLInputElement = $state(null!)
let internalValue = $state(prefix + value)
let internalValue = $state(untrack(() => prefix) + value)
// Update internal value when prop changes
$effect(() => {
@@ -3,7 +3,7 @@
import QueueMetricsDrawerInner from './QueueMetricsDrawerInner.svelte'
import QueueAlerts from './QueueAlerts.svelte'
let drawer: Drawer
let drawer: Drawer | undefined = $state()
export function openDrawer() {
drawer?.openDrawer()
}
@@ -1,4 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import 'chartjs-adapter-date-fns'
import { Line } from '$lib/components/chartjs-wrappers/chartJs'
@@ -22,7 +24,7 @@
import Alert from './common/alert/Alert.svelte'
import { Section } from './common'
let loading: boolean = true
let loading: boolean = $state(true)
const colorTuples = [
['#7EB26D', 'rgba(126, 178, 109, 0.2)'],
@@ -64,12 +66,12 @@
LogarithmicScale
)
let countData: ChartData<'line', Point[], undefined> | undefined = undefined
let delayData: ChartData<'line', Point[], undefined> | undefined = undefined
let countData: ChartData<'line', Point[], undefined> | undefined = $state(undefined)
let delayData: ChartData<'line', Point[], undefined> | undefined = $state(undefined)
let minDate = new Date()
let minDate = $state(new Date())
let noMetrics = false
let noMetrics = $state(false)
function fillData(
data: {
@@ -179,10 +181,14 @@
loadMetrics()
let darkMode = false
let darkMode = $state(false)
$: ChartJS.defaults.color = darkMode ? '#ccc' : '#666'
$: ChartJS.defaults.borderColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
run(() => {
ChartJS.defaults.color = darkMode ? '#ccc' : '#666'
});
run(() => {
ChartJS.defaults.borderColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
});
</script>
<DarkModeObserver bind:darkMode />

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