Merge branch 'main' into python-uv

This commit is contained in:
pyranota
2024-10-29 17:26:25 +00:00
committed by GitHub
98 changed files with 2089 additions and 889 deletions
+59
View File
@@ -1,5 +1,64 @@
# Changelog
## [1.414.1](https://github.com/windmill-labs/windmill/compare/v1.414.0...v1.414.1) (2024-10-29)
### Bug Fixes
* **apps:** enable text selection on aggrid tables by default ([b0b9180](https://github.com/windmill-labs/windmill/commit/b0b9180fb907c92b95a48ff286eb1dae59bb4981))
* **apps:** public apps can take full height ([703db7d](https://github.com/windmill-labs/windmill/commit/703db7d4412795b4323a2eefcc39ae3cf43bc748))
* **bun:** handle bun lockfile created with windows ([#4602](https://github.com/windmill-labs/windmill/issues/4602)) ([dcf5e2f](https://github.com/windmill-labs/windmill/commit/dcf5e2f03f977e241f6785530dad61a70c5bdd79))
* **frontend:** make script and schema scrollable on script detail page ([6e222b3](https://github.com/windmill-labs/windmill/commit/6e222b3b1a419e5543fdf35420a7413699688b41))
* **frontend:** new approval steps default to timeout 1800 ([b86de62](https://github.com/windmill-labs/windmill/commit/b86de6280e03e014e8ddf85b2b5f8fd030d0467a))
## [1.414.0](https://github.com/windmill-labs/windmill/compare/v1.413.2...v1.414.0) (2024-10-29)
* Issue with previous release, re-releasing
## [1.413.2](https://github.com/windmill-labs/windmill/compare/v1.413.1...v1.413.2) (2024-10-29)
### Bug Fixes
* **backend:** in flows, workspace scripts should use their set tags instead of the default one ([5b7c6d7](https://github.com/windmill-labs/windmill/commit/5b7c6d7d62dcfd09fec374e781bdf5c5bafe4a9d))
* **cli:** fix wmill instance pull --instance ([3c62f5e](https://github.com/windmill-labs/windmill/commit/3c62f5ea83d1da8bd3705468969d56e2fe680751))
* **frontend:** fix script and flow renaming ([d743e00](https://github.com/windmill-labs/windmill/commit/d743e0056353a4fca445a7089e3afc1fd4e8c219))
## [1.413.1](https://github.com/windmill-labs/windmill/compare/v1.413.0...v1.413.1) (2024-10-28)
### Bug Fixes
* **cli:** fix wmill instance push --base-url and --instance ([8298710](https://github.com/windmill-labs/windmill/commit/82987105a6fd6ec272c170fb094453a0267143be))
## [1.413.0](https://github.com/windmill-labs/windmill/compare/v1.412.0...v1.413.0) (2024-10-28)
### Features
* autoscaling v0 ([#4593](https://github.com/windmill-labs/windmill/issues/4593)) ([fe7d044](https://github.com/windmill-labs/windmill/commit/fe7d044a66e8ec223a337cb704d3e58942dd1502))
### Bug Fixes
* add run immediately popover to run again ([e54d253](https://github.com/windmill-labs/windmill/commit/e54d25368541dc6109a0f99022a120d28455f9bd))
* **docs:** smtp setup documentation link ([#4590](https://github.com/windmill-labs/windmill/issues/4590)) ([bac3205](https://github.com/windmill-labs/windmill/commit/bac32057259d893140d649c9dfec2ca75e395ad4))
## [1.412.0](https://github.com/windmill-labs/windmill/compare/v1.411.1...v1.412.0) (2024-10-25)
### Features
* add Spotify oauth provider ([#4581](https://github.com/windmill-labs/windmill/issues/4581)) ([a46aa64](https://github.com/windmill-labs/windmill/commit/a46aa644b096e71e75b59507224ed92f7d2f99ba))
### Bug Fixes
* **app builder:** date input default value improvements ([9f43d5d](https://github.com/windmill-labs/windmill/commit/9f43d5dcd92ddcd0c0baeaa5271779520af652f4))
* **bash:** correctly propagate sigterm for cancelled bash scripts ([134cfdb](https://github.com/windmill-labs/windmill/commit/134cfdb30eb8d29c2ecb1b78b8fabae2b6e10700))
* do not update created_at of scripts on lockfile generation ([d1a28eb](https://github.com/windmill-labs/windmill/commit/d1a28eb7cac5f465e2b07f870a507b0cc5cc722a))
* initialize empty smtp settings correctly ([84e0524](https://github.com/windmill-labs/windmill/commit/84e05249505c3d2f7eb7a4917f6bca503df81017))
## [1.411.1](https://github.com/windmill-labs/windmill/compare/v1.411.0...v1.411.1) (2024-10-22)
+1 -1
View File
@@ -163,7 +163,7 @@ ENV GO_PATH=/usr/local/go/bin/go
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.4.18/uv-installer.sh | sh && mv /root/.cargo/bin/uv /usr/local/bin/uv
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
RUN apt-get -y update && apt-get install -y curl nodejs awscli && apt-get clean \
RUN apt-get -y update && apt-get install -y curl procps nodejs awscli && apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# go build is slower the first time it is ran, so we prewarm it in the build
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT tag, count(*) as count FROM queue WHERE\n scheduled_for <= now() - ('3 seconds')::interval AND running = false\n GROUP BY tag",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
null
]
},
"hash": "02b516dac764662194db1bc33e365c01f40bae70af3683f1f09748f6020f0d49"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_at FROM metrics WHERE id = 'telemetry' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "19f0abd79372698f378cb6deea3ee6d098a2758d16ede000809bd9a09660b604"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, login_type::text, verified, super_admin, name, company, username from password ORDER BY super_admin DESC, email LIMIT $1 OFFSET $2",
"query": "SELECT email, login_type::text, verified, super_admin, name, company, username, NULL::bool as operator_only FROM password ORDER BY super_admin DESC, email LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "operator_only",
"type_info": "Bool"
}
],
"parameters": {
@@ -52,8 +57,9 @@
false,
true,
true,
true
true,
null
]
},
"hash": "c2060e8cacef6c3b5ce51ed203a2dbafc18d66f2924d1fe518c6728997647db2"
"hash": "1a4d291c2f239f7b50c116594cebb031862e1a18ad9204e02a0194817db26d6a"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO autoscaling_event (worker_group, event_type, desired_workers, reason) VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "autoscaling_event_type",
"kind": {
"Enum": [
"full_scaleout",
"scalein",
"scaleout"
]
}
}
},
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "1f5f0858909eb5bac63c4e3b1add95226bd94ca3facb92a620ffa59dacad6705"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, login_type::TEXT, super_admin, verified, name, company, username FROM password WHERE email = $1",
"query": "SELECT email, login_type::TEXT, super_admin, verified, name, company, username, NULL::bool as operator_only FROM password WHERE email = $1",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "operator_only",
"type_info": "Bool"
}
],
"parameters": {
@@ -51,8 +56,9 @@
false,
true,
true,
true
true,
null
]
},
"hash": "8813665f8adfcab0daefbac2cc6b50e427dfd1c12d12895451affd307dc59c37"
"hash": "2c14d3a88193f16ad3b8cd590749cb5537995f2499f6cb8f0f316fb62902d542"
}
@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users AS (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, name, company, username\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "verified",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
null,
null,
false,
false,
true,
true,
true
]
},
"hash": "2eec077cc9e27d7ccd160cbaac118c321c422705f79e69550bb60f377083bcef"
}
@@ -0,0 +1,39 @@
{
"db_name": "PostgreSQL",
"query": "SELECT event_type::AUTOSCALING_EVENT_TYPE AS \"event_type: _\", EXTRACT(EPOCH FROM (NOW() - applied_at))::int as seconds_ago FROM autoscaling_event WHERE worker_group = $1 ORDER BY applied_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "event_type: _",
"type_info": {
"Custom": {
"name": "autoscaling_event_type",
"kind": {
"Enum": [
"full_scaleout",
"scalein",
"scaleout"
]
}
}
}
},
{
"ordinal": 1,
"name": "seconds_ago",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null
]
},
"hash": "4eca060026a0cb19c5794cd56ace89fc04765191f251945d14bbe78718714f6e"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_try_advisory_xact_lock($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pg_try_advisory_xact_lock",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "6776dc50f184188756ad7fe263b0304333536768527525a43bdd45aedffa3c4f"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, worker_group, event_type::text, desired_workers, reason, applied_at FROM autoscaling_event WHERE worker_group = $1 ORDER BY applied_at DESC LIMIT 5",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "worker_group",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "event_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "desired_workers",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "reason",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "applied_at",
"type_info": "Timestamp"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
null,
false,
true,
false
]
},
"hash": "6d134b137ae81534e145fc5b6474cf963ee26a3ad3a0a3d8dc064cb14c8fd9a6"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_at FROM metrics WHERE id = 'license_key_renewal' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "9ed77e78e6295c62745ac3ac3b7e5f544f654d7cddc13acb5d3f4fdc12a8875f"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE script SET lock = $1, created_at = now() WHERE hash = $2 AND workspace_id = $3",
"query": "UPDATE script SET lock = $1 WHERE hash = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
@@ -12,5 +12,5 @@
},
"nullable": []
},
"hash": "ec47955683d811b12e82ff3e6aeafa0df46a320c436bad5eb1acc127df138a61"
"hash": "d697b7311430e7bd5375ec5494179f4071e3bfe123d9600249cfa80c1103edd8"
}
+185 -168
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.411.1"
version = "1.414.1"
authors.workspace = true
edition.workspace = true
@@ -13,6 +13,7 @@ members = [
"./windmill-common",
"./windmill-audit",
"./windmill-git-sync",
"./windmill-autoscaling",
"./windmill-indexer",
"./windmill-macros",
"./parsers/windmill-parser",
@@ -28,7 +29,7 @@ members = [
]
[workspace.package]
version = "1.411.1"
version = "1.414.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -45,7 +46,7 @@ lto = "thin"
[features]
default = []
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise", "windmill-indexer/enterprise"]
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise", "windmill-indexer/enterprise"]
enterprise_saml = ["windmill-api/enterprise_saml"]
stripe = ["windmill-api/stripe"]
benchmark = ["windmill-api/benchmark", "windmill-worker/benchmark", "windmill-queue/benchmark", "windmill-common/benchmark"]
@@ -72,6 +73,7 @@ windmill-git-sync.workspace = true
windmill-api = { workspace = true, default-features = false }
windmill-worker.workspace = true
windmill-indexer = { workspace = true, optional = true }
windmill-autoscaling = { workspace = true, optional = true }
futures.workspace = true
tracing.workspace = true
sqlx.workspace = true
@@ -116,6 +118,7 @@ windmill-worker = { path = "./windmill-worker" }
windmill-common = { path = "./windmill-common", default-features = false }
windmill-audit = { path = "./windmill-audit" }
windmill-git-sync = { path = "./windmill-git-sync" }
windmill-autoscaling = { path = "./windmill-autoscaling" }
windmill-indexer = {path = "./windmill-indexer"}
windmill-macros = {path = "./windmill-macros"}
windmill-parser = { path = "./parsers/windmill-parser" }
+1 -1
View File
@@ -1 +1 @@
d61c163e0a311ecd86d1398aff883eaea8d0b09a
f667fcebdb5a2d1c22c1928d6a316ba57bb43d72
@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE autoscaling_event;
DROP TYPE autoscaling_event_type;
@@ -0,0 +1,13 @@
-- Add up migration script here
CREATE TYPE AUTOSCALING_EVENT_TYPE AS ENUM ('full_scaleout', 'scalein', 'scaleout');
CREATE TABLE autoscaling_event (
id SERIAL PRIMARY KEY,
worker_group TEXT NOT NULL,
event_type AUTOSCALING_EVENT_TYPE NOT NULL,
desired_workers INTEGER NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reason TEXT
);
CREATE INDEX autoscaling_event_worker_group_idx ON autoscaling_event (worker_group, applied_at);
+19
View File
@@ -152,5 +152,24 @@
"vismanet_erp_interactive_api:read",
"vismanet_erp_interactive_api:update"
]
},
"spotify": {
"auth_url": "https://accounts.spotify.com/authorize",
"token_url": "https://accounts.spotify.com/api/token",
"scopes": [
"user-read-playback-state",
"user-modify-playback-state",
"user-read-currently-playing",
"playlist-read-private",
"playlist-read-collaborative",
"playlist-modify-private",
"playlist-modify-public",
"user-follow-read",
"user-read-playback-position",
"user-read-recently-played",
"user-top-read",
"user-library-modify",
"user-library-read"
]
}
}
@@ -188,9 +188,15 @@ pub struct AnsiblePlaybookOptions {
pub force_handlers: Option<()>,
}
#[derive(Debug, Clone)]
pub enum ResourceOrVariablePath {
Resource(String),
Variable(String),
}
#[derive(Debug, Clone)]
pub struct FileResource {
pub resource_path: String,
pub resource_path: ResourceOrVariablePath,
pub target_path: String,
}
@@ -309,7 +315,7 @@ pub fn parse_ansible_reqs(
}
}
}
Yaml::String(key) if key == "file_resources" => {
Yaml::String(key) if key == "files" || key == "file_resources" => {
if let Yaml::Array(file_resources) = value {
let resources: anyhow::Result<Vec<FileResource>> =
file_resources.iter().map(parse_file_resource).collect();
@@ -440,7 +446,24 @@ fn parse_file_resource(yaml: &Yaml) -> anyhow::Result<FileResource> {
"No `target` provided for file resource {}. Please input a target relative path for the ansible playbook to see this file.",
resource_path
))?;
return Ok(FileResource { resource_path: resource_path.clone(), target_path });
return Ok(FileResource {
resource_path: ResourceOrVariablePath::Resource(resource_path.clone()),
target_path,
});
}
if let Some(Yaml::String(resource_path)) = f.get(&Yaml::String("variable".to_string())) {
let target_path = f
.get(&Yaml::String("target".to_string()))
.and_then(|x| x.as_str())
.map(|x| x.to_string())
.ok_or(anyhow!(
"No `target` provided for file resource {}. Please input a target relative path for the ansible playbook to see this file.",
resource_path
))?;
return Ok(FileResource {
resource_path: ResourceOrVariablePath::Variable(resource_path.clone()),
target_path,
});
}
return Err(anyhow!(
"File resource should have a `resource` field, linking to a text file resource"
+2 -10
View File
@@ -1,16 +1,8 @@
use anyhow::anyhow;
#[cfg(feature = "enterprise")]
use windmill_common::error::{Error, Result};
pub async fn set_license_key(_license_key: String) -> anyhow::Result<()> {
pub async fn set_license_key(_license_key: String) -> () {
// Implementation is not open source
Err(anyhow!("License cannot be set in Windmill CE"))
}
#[cfg(feature = "enterprise")]
pub async fn verify_license_key() -> Result<()> {
pub async fn verify_license_key() -> () {
// Implementation is not open source
Err(Error::InternalErr(
"License always invalid in Windmill CE".to_string(),
))
}
+75 -13
View File
@@ -27,7 +27,7 @@ use uuid::Uuid;
use windmill_api::HTTP_CLIENT;
#[cfg(feature = "enterprise")]
use windmill_common::ee::schedule_key_renewal;
use windmill_common::ee::{maybe_renew_license_key_on_start, LICENSE_KEY_ID, LICENSE_KEY_VALID};
use windmill_common::{
global_settings::{
@@ -307,7 +307,8 @@ async fn windmill_main() -> anyhow::Result<()> {
Mode::Standalone
});
let num_workers = if mode == Mode::Server || mode == Mode::Indexer {
#[allow(unused_mut)]
let mut num_workers = if mode == Mode::Server || mode == Mode::Indexer {
0
} else {
std::env::var("NUM_WORKERS")
@@ -422,6 +423,50 @@ Windmill Community Edition {GIT_VERSION}
display_config(&ENV_SETTINGS);
if let Err(e) = reload_base_url_setting(&db).await {
tracing::error!("Error loading base url: {:?}", e)
}
if let Err(e) = reload_critical_error_channels_setting(&db).await {
tracing::error!("Could loading critical error emails setting: {:?}", e);
}
#[cfg(feature = "enterprise")]
{
// load the license key and check if it's valid
// if not valid and not server mode just quit
// if not expired and server mode then force renewal
// if key still invalid and num_workers > 0, set to 0
if let Err(err) = reload_license_key(&db).await {
tracing::error!("Failed to reload license key: {err:#}");
}
let valid_key = *LICENSE_KEY_VALID.read().await;
if !valid_key && !server_mode {
panic!("Invalid license key, workers require a valid license key");
}
if server_mode {
// only force renewal if invalid but not empty (= expired)
let renewed_now = maybe_renew_license_key_on_start(
&HTTP_CLIENT,
&db,
!valid_key && !LICENSE_KEY_ID.read().await.is_empty(),
)
.await;
if renewed_now {
if let Err(err) = reload_license_key(&db).await {
tracing::error!("Failed to reload license key: {err:#}");
}
}
if num_workers > 0 {
let valid_key = *LICENSE_KEY_VALID.read().await;
if !valid_key {
tracing::warn!("License key invalid, setting num_workers to 0");
num_workers = 0;
}
}
}
}
let worker_mode = num_workers > 0;
if server_mode || worker_mode || indexer_mode {
@@ -448,7 +493,16 @@ Windmill Community Edition {GIT_VERSION}
initial_load(&db, killpill_tx.clone(), worker_mode, server_mode, is_agent).await;
monitor_db(&db, &base_internal_url, rsmq.clone(), server_mode, true).await;
monitor_db(
&db,
&base_internal_url,
rsmq.clone(),
server_mode,
worker_mode,
true,
killpill_tx.clone(),
)
.await;
monitor_pool(&db).await;
@@ -548,8 +602,11 @@ Windmill Community Edition {GIT_VERSION}
rx.recv().await?;
}
}
tracing::info!("Starting phase 2 of shutdown");
killpill_phase2_tx.send(())?;
if killpill_phase2_tx.receiver_count() > 0 {
tracing::info!("Starting phase 2 of shutdown");
killpill_phase2_tx.send(())?;
tracing::info!("Phase 2 of shutdown completed");
}
Ok(()) as anyhow::Result<()>
};
@@ -575,7 +632,9 @@ Windmill Community Edition {GIT_VERSION}
&base_internal_url,
rsmq.clone(),
server_mode,
false
worker_mode,
false,
tx.clone(),
)
.await;
},
@@ -618,7 +677,15 @@ Windmill Community Edition {GIT_VERSION}
},
LICENSE_KEY_SETTING => {
if let Err(e) = reload_license_key(&db).await {
tracing::error!(error = %e, "Could not reload license key setting");
tracing::error!("Failed to reload license key: {e:#}");
}
#[cfg(feature = "enterprise")]
if worker_mode {
let valid_key = *LICENSE_KEY_VALID.read().await;
if !valid_key {
tracing::error!("Invalid license key, exiting...");
tx.send(()).expect("send");
}
}
},
DEFAULT_TAGS_PER_WORKSPACE_SETTING => {
@@ -764,15 +831,10 @@ Windmill Community Edition {GIT_VERSION}
Ok(()) as anyhow::Result<()>
};
if mode == Mode::Server || mode == Mode::Standalone {
if server_mode {
schedule_stats(&db, &HTTP_CLIENT).await;
}
#[cfg(feature = "enterprise")]
if mode == Mode::Server || mode == Mode::Standalone {
schedule_key_renewal(&HTTP_CLIENT, &db).await;
}
futures::try_join!(
shutdown_signal,
workers_f,
+29 -42
View File
@@ -27,7 +27,7 @@ use windmill_api::{
DEFAULT_BODY_LIMIT, IS_SECURE, OAUTH_CLIENTS, REQUEST_SIZE_LIMIT, SAML_METADATA, SCIM_TOKEN,
};
#[cfg(feature = "enterprise")]
use windmill_common::ee::{worker_groups_alerts, jobs_waiting_alerts};
use windmill_common::ee::{jobs_waiting_alerts, worker_groups_alerts, LICENSE_KEY_VALID};
use windmill_common::{
auth::JWT_SECRET,
ee::CriticalErrorChannel,
@@ -76,9 +76,6 @@ use windmill_common::global_settings::OBJECT_STORE_CACHE_CONFIG_SETTING;
#[cfg(feature = "enterprise")]
use crate::ee::verify_license_key;
#[cfg(feature = "enterprise")]
use windmill_common::ee::LICENSE_KEY_VALID;
use crate::ee::set_license_key;
lazy_static::lazy_static! {
@@ -152,18 +149,10 @@ pub async fn initial_load(
tracing::error!("Error reloading custom tags: {:?}", e)
}
if let Err(e) = reload_base_url_setting(db).await {
tracing::error!("Error reloading base url: {:?}", e)
}
if let Err(e) = reload_hub_base_url_setting(db, server_mode).await {
tracing::error!("Error reloading hub base url: {:?}", e)
}
if let Err(e) = reload_critical_error_channels_setting(&db).await {
tracing::error!("Could not reload critical error emails setting: {:?}", e);
}
if let Err(e) = reload_jwt_secret_setting(&db).await {
tracing::error!("Could not reload jwt secret setting: {:?}", e);
}
@@ -182,11 +171,6 @@ pub async fn initial_load(
reload_scim_token_setting(&db).await;
}
#[cfg(feature = "enterprise")]
if let Err(e) = reload_license_key(&db).await {
tracing::error!("Error reloading license key: {:?}", e)
}
if worker_mode {
reload_extra_pip_index_url_setting(&db).await;
reload_pip_index_url_setting(&db).await;
@@ -854,8 +838,10 @@ pub async fn reload_request_size(db: &DB) {
}
}
pub async fn reload_license_key(db: &DB) -> error::Result<()> {
let q = load_value_from_global_settings(db, LICENSE_KEY_SETTING).await?;
pub async fn reload_license_key(db: &DB) -> anyhow::Result<()> {
let q = load_value_from_global_settings(db, LICENSE_KEY_SETTING)
.await
.map_err(|err| anyhow::anyhow!("Error reloading license key: {}", err.to_string()))?;
let mut value = std::env::var("LICENSE_KEY")
.ok()
@@ -873,9 +859,7 @@ pub async fn reload_license_key(db: &DB) -> error::Result<()> {
tracing::error!("Could not parse LICENSE_KEY found: {:#?}", &q);
}
};
set_license_key(value).await?;
set_license_key(value).await;
Ok(())
}
@@ -1014,7 +998,9 @@ pub async fn monitor_db(
base_internal_url: &str,
rsmq: Option<MultiplexedRsmq>,
server_mode: bool,
_worker_mode: bool,
initial_load: bool,
_killpill_tx: tokio::sync::broadcast::Sender<()>,
) {
let zombie_jobs_f = async {
if server_mode && !initial_load {
@@ -1035,15 +1021,14 @@ pub async fn monitor_db(
let verify_license_key_f = async {
#[cfg(feature = "enterprise")]
if let Err(e) = verify_license_key().await {
tracing::error!("Error verifying license key: {:?}", e);
let mut l = LICENSE_KEY_VALID.write().await;
*l = false;
} else {
let is_valid = LICENSE_KEY_VALID.read().await.clone();
if !is_valid {
let mut l = LICENSE_KEY_VALID.write().await;
*l = true;
if !initial_load {
verify_license_key().await;
if _worker_mode {
let valid_key = *LICENSE_KEY_VALID.read().await;
if !valid_key {
tracing::error!("Invalid license key, exiting...");
_killpill_tx.send(()).expect("send");
}
}
}
};
@@ -1068,6 +1053,15 @@ pub async fn monitor_db(
}
};
let apply_autoscaling_f = async {
#[cfg(feature = "enterprise")]
if server_mode && !initial_load {
if let Err(e) = windmill_autoscaling::apply_all_autoscaling(db).await {
tracing::error!("Error applying autoscaling: {:?}", e);
}
}
};
join!(
expired_items_f,
zombie_jobs_f,
@@ -1075,6 +1069,7 @@ pub async fn monitor_db(
verify_license_key_f,
worker_groups_alerts_f,
jobs_waiting_alerts_f,
apply_autoscaling_f,
);
}
@@ -1092,19 +1087,11 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
.unwrap_or(true);
if metrics_enabled || save_metrics {
let queue_counts = sqlx::query!(
"SELECT tag, count(*) as count FROM queue WHERE
scheduled_for <= now() - ('3 seconds')::interval AND running = false
GROUP BY tag"
)
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![]);
let queue_counts = windmill_common::queue::get_queue_counts(db).await;
for q in queue_counts {
let count = q.count.unwrap_or(0);
let tag = q.tag;
let count = q.1;
let tag = q.0;
if metrics_enabled {
let metric = (*QUEUE_COUNT).with_label_values(&[&tag]);
metric.set(count as i64);
+49 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.411.1
version: 1.414.1
title: Windmill API
contact:
@@ -1467,6 +1467,11 @@ paths:
parameters:
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- name: active_only
in: query
description: filter only active users
schema:
type: boolean
responses:
"200":
description: user
@@ -8198,6 +8203,29 @@ paths:
items:
$ref: "#/components/schemas/Config"
/configs/list_autoscaling_events/{worker_group}:
get:
summary: List autoscaling events
operationId: listAutoscalingEvents
tags:
- config
parameters:
- name: worker_group
in: path
required: true
schema:
type: string
responses:
"200":
description: List of autoscaling events
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/AutoscalingEvent"
/w/{workspace}/acls/get/{kind}/{path}:
get:
summary: get granular acls
@@ -11768,6 +11796,8 @@ components:
type: string
username:
type: string
operator_only:
type: boolean
required:
- email
@@ -12469,3 +12499,21 @@ components:
properties:
dancer:
type: string
AutoscalingEvent:
type: object
properties:
id:
type: integer
format: int64
worker_group:
type: string
event_type:
type: string
desired_workers:
type: integer
reason:
type: string
applied_at:
type: string
format: date-time
+28
View File
@@ -29,6 +29,10 @@ pub fn global_service() -> Router {
.route("/update/:name", post(update_config).delete(delete_config))
.route("/get/:name", get(get_config))
.route("/list", get(list_configs))
.route(
"/list_autoscaling_events/:worker_group",
get(list_autoscaling_events),
)
}
#[derive(Serialize, Deserialize, FromRow)]
@@ -177,6 +181,30 @@ async fn delete_config(
Ok(format!("Deleted config {name}"))
}
#[derive(Serialize, Deserialize, FromRow)]
struct AutoscalingEvent {
id: i64,
worker_group: String,
event_type: Option<String>,
desired_workers: i32,
reason: Option<String>,
applied_at: chrono::NaiveDateTime,
}
async fn list_autoscaling_events(
Extension(db): Extension<DB>,
Path(worker_group): Path<String>,
) -> error::JsonResult<Vec<AutoscalingEvent>> {
let events = sqlx::query_as!(
AutoscalingEvent,
"SELECT id, worker_group, event_type::text, desired_workers, reason, applied_at FROM autoscaling_event WHERE worker_group = $1 ORDER BY applied_at DESC LIMIT 5",
worker_group
)
.fetch_all(&db)
.await?;
Ok(Json(events))
}
#[cfg(feature = "enterprise")]
async fn list_configs(
authed: ApiAuthed,
+13 -13
View File
@@ -173,17 +173,6 @@ pub async fn migrate(db: &DB) -> Result<(), Error> {
let migrator = db.acquire().await?;
let mut custom_migrator = CustomMigrator { inner: migrator };
if let Err(err) = fix_flow_versioning_migration(&mut custom_migrator, db).await {
tracing::error!("Could not apply flow versioning fix migration: {err:#}");
}
let db2 = db.clone();
let _ = tokio::task::spawn(async move {
if let Err(err) = fix_job_completed_index(&db2).await {
tracing::error!("Could not apply job completed index fix migration: {err:#}");
}
});
match sqlx::migrate!("../migrations")
.run_direct(&mut custom_migrator)
.await
@@ -199,6 +188,17 @@ pub async fn migrate(db: &DB) -> Result<(), Error> {
Err(err) => Err(err),
}?;
if let Err(err) = fix_flow_versioning_migration(&mut custom_migrator, db).await {
tracing::error!("Could not apply flow versioning fix migration: {err:#}");
}
let db2 = db.clone();
let _ = tokio::task::spawn(async move {
if let Err(err) = fix_job_completed_index(&db2).await {
tracing::error!("Could not apply job completed index fix migration: {err:#}");
}
});
Ok(())
}
@@ -302,7 +302,7 @@ macro_rules! run_windmill_migration {
.await?;
tracing::info!("Finished applying {migration_job_name} migration");
} else {
tracing::info!("migration {migration_job_name} already done");
tracing::debug!("migration {migration_job_name} already done");
}
let _ = sqlx::query("SELECT pg_advisory_unlock(4242)")
@@ -311,7 +311,7 @@ macro_rules! run_windmill_migration {
tx.commit().await?;
tracing::info!("released lock for {migration_job_name}");
} else {
tracing::info!("migration {migration_job_name} already done");
tracing::debug!("migration {migration_job_name} already done");
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
#[cfg(feature = "enterprise")]
use tokio::sync::RwLock;
pub async fn validate_license_key(_license_key: String) -> anyhow::Result<String> {
pub async fn validate_license_key(_license_key: String) -> anyhow::Result<(String, bool)> {
// Implementation is not open source
Err(anyhow!("License can't be validated in Windmill CE"))
}
-4
View File
@@ -544,7 +544,6 @@ async fn get_latest_version(
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Option<FlowVersion>> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
@@ -1333,7 +1332,6 @@ mod tests {
},
"type": "script",
"path": "test",
"tag_override": Option::<String>::None,
},
},
{
@@ -1377,14 +1375,12 @@ mod tests {
"input_transforms": {},
"type": "script",
"path": "test",
"tag_override": Option::<String>::None,
},
"stop_after_if": {
"expr": "previous.isEmpty()",
"skip_if_stopped": false
}
},
"preprocessor_module": Option::<String>::None
});
assert_eq!(dbg!(serde_json::json!(fv)), dbg!(expect));
}
+2 -29
View File
@@ -2815,7 +2815,6 @@ pub async fn run_flow_by_path_inner(
let flow_path = flow_path.to_path();
check_scopes(&authed, || format!("run:flow/{flow_path}"))?;
let (tag, dedicated_worker, has_preprocessor) = sqlx::query!(
"SELECT tag, dedicated_worker, flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor
FROM flow
@@ -2910,7 +2909,6 @@ pub async fn restart_flow(
) -> error::Result<(StatusCode, String)> {
check_license_key_valid().await?;
let completed_job = sqlx::query_as::<_, CompletedJob>(
"SELECT *, result->'wm_labels' as labels from completed_job WHERE id = $1 and workspace_id = $2",
)
@@ -3010,7 +3008,6 @@ pub async fn run_script_by_path_inner(
check_scopes(&authed, || format!("run:script/{script_path}"))?;
let (job_payload, tag, _delete_after_use, timeout) =
script_path_to_payload(script_path, &db, &w_id, run_query.skip_preprocessor).await?;
let scheduled_for = run_query.get_scheduled_for(&db).await?;
@@ -3018,7 +3015,6 @@ pub async fn run_script_by_path_inner(
let tag = run_query.tag.clone().or(tag);
check_tag_available_for_workspace(&w_id, &tag).await?;
let tx = PushIsolationLevel::Isolated(user_db, authed.clone().into(), rsmq);
let (uuid, tx) = push(
@@ -3067,7 +3063,6 @@ pub async fn run_workflow_as_code(
Query(wkflow_query): Query<WorkflowAsCodeQuery>,
Json(task): Json<WorkflowTask>,
) -> error::Result<(StatusCode, String)> {
let mut i = 1;
if *CLOUD_HOSTED {
@@ -3079,23 +3074,18 @@ pub async fn run_workflow_as_code(
check_license_key_valid().await?;
check_tag_available_for_workspace(&w_id, &run_query.tag).await?;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
let job = get_queued_job(&job_id, &w_id, &db).await?;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
let job = not_found_if_none(job, "Queued Job", &job_id.to_string())?;
let (job_payload, tag, _delete_after_use, timeout) = match job.job_kind {
JobKind::Preview => (
@@ -3118,18 +3108,12 @@ pub async fn run_workflow_as_code(
run_query.timeout,
),
JobKind::Script => {
script_path_to_payload(
job.script_path(),
&db,
&w_id,
run_query.skip_preprocessor,
)
.await?
script_path_to_payload(job.script_path(), &db, &w_id, run_query.skip_preprocessor)
.await?
}
_ => return Err(anyhow::anyhow!("Not supported").into()),
};
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
@@ -3143,22 +3127,18 @@ pub async fn run_workflow_as_code(
let tag = run_query.tag.clone().or(tag).or(Some(job.tag));
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
let tx = PushIsolationLevel::Isolated(user_db, authed.clone().into(), rsmq);
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
let (uuid, mut tx) = push(
&db,
tx,
@@ -3185,7 +3165,6 @@ pub async fn run_workflow_as_code(
)
.await?;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
@@ -3203,16 +3182,13 @@ pub async fn run_workflow_as_code(
tracing::info!("Skipping update of flow status for job {job_id} in workspace {w_id}");
}
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
i += 1;
}
tx.commit().await?;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
}
@@ -3866,7 +3842,6 @@ pub async fn run_wait_result_flow_by_path_internal(
let flow_path = flow_path.to_path();
check_scopes(&authed, || format!("run:flow/{flow_path}"))?;
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let (tag, dedicated_worker, early_return, has_preprocessor) = sqlx::query!(
@@ -4362,7 +4337,6 @@ async fn add_batch_jobs(
}
}
"flow" => {
let mut uuids: Vec<Uuid> = Vec::new();
let payload = if let Some(ref fv) = batch_info.flow_value {
JobPayload::RawFlow { value: fv.clone(), path: None, restarted_from: None }
@@ -4580,7 +4554,6 @@ pub async fn run_job_by_hash_inner(
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
let hash = script_hash.0;
let (
path,
+6 -2
View File
@@ -456,9 +456,13 @@ async fn ee_license() -> &'static str {
#[cfg(feature = "enterprise")]
async fn ee_license() -> String {
use windmill_common::ee::LICENSE_KEY_ID;
use windmill_common::ee::{LICENSE_KEY_ID, LICENSE_KEY_VALID};
LICENSE_KEY_ID.read().await.clone()
if *LICENSE_KEY_VALID.read().await {
LICENSE_KEY_ID.read().await.clone()
} else {
"".to_string()
}
}
async fn openapi() -> &'static str {
+7 -3
View File
@@ -163,8 +163,13 @@ pub async fn test_license_key(
Json(TestKey { license_key }): Json<TestKey>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
validate_license_key(license_key).await?;
Ok("Sent test email".to_string())
let (_, expired) = validate_license_key(license_key).await?;
if expired {
Err(error::Error::BadRequest("Expired license key".to_string()))
} else {
Ok("Valid license key".to_string())
}
}
pub async fn get_local_settings(
@@ -301,7 +306,6 @@ pub async fn send_stats(Extension(db): Extension<DB>, authed: ApiAuthed) -> Resu
windmill_common::stats_ee::send_stats(
&HTTP_CLIENT,
&db,
true,
windmill_common::stats_ee::SendStatsReason::Manual,
)
.await?;
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "windmill-autoscaling"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_autoscaling"
path = "./src/lib.rs"
[features]
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"]
default = []
[dependencies]
uuid.workspace = true
serde.workspace = true
sqlx.workspace = true
serde_json.workspace = true
tracing.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-queue.workspace = true
rsmq_async.workspace = true
anyhow.workspace = true
@@ -0,0 +1,6 @@
use windmill_common::DB;
pub async fn apply_all_autoscaling(_db: &DB) -> anyhow::Result<()> {
// Autoscaling is an ee feature
Ok(())
}
+2
View File
@@ -0,0 +1,2 @@
mod autoscaling_ee;
pub use autoscaling_ee::*;
+6 -1
View File
@@ -48,8 +48,13 @@ pub async fn send_critical_alert(
}
#[cfg(feature = "enterprise")]
pub async fn schedule_key_renewal(_http_client: &reqwest::Client, _db: &crate::db::DB) -> () {
pub async fn maybe_renew_license_key_on_start(
_http_client: &reqwest::Client,
_db: &crate::db::DB,
force_renew_now: bool,
) -> bool {
// Implementation is not open source
force_renew_now
}
#[cfg(feature = "enterprise")]
+2
View File
@@ -101,6 +101,7 @@ pub struct FlowValue {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub failure_module: Option<Box<FlowModule>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub preprocessor_module: Option<Box<FlowModule>>,
#[serde(default)]
@@ -423,6 +424,7 @@ pub enum FlowModuleValue {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
hash: Option<ScriptHash>,
#[serde(skip_serializing_if = "Option::is_none")]
tag_override: Option<String>,
},
Flow {
+2 -2
View File
@@ -17,6 +17,7 @@ use scripts::ScriptLang;
use sqlx::{Pool, Postgres};
pub mod apps;
pub mod auth;
#[cfg(feature = "benchmark")]
pub mod bench;
pub mod db;
@@ -32,9 +33,8 @@ pub mod job_s3_helpers_ee;
pub mod jobs;
pub mod more_serde;
pub mod oauth2;
pub mod queue;
pub mod s3_helpers;
pub mod auth;
pub mod schedule;
pub mod scripts;
pub mod server;
+16
View File
@@ -0,0 +1,16 @@
use std::collections::HashMap;
use sqlx::{Pool, Postgres};
pub async fn get_queue_counts(db: &Pool<Postgres>) -> HashMap<String, u32> {
sqlx::query_as::<_, (String, i64)>(
"SELECT tag, count(*) as count FROM queue WHERE
scheduled_for <= now() - ('3 seconds')::interval AND running = false
GROUP BY tag",
)
.fetch_all(db)
.await
.ok()
.map(|v| v.into_iter().map(|(k, v)| (k, v as u32)).collect())
.unwrap_or_else(|| HashMap::new())
}
-1
View File
@@ -28,7 +28,6 @@ pub enum SendStatsReason {
pub async fn send_stats(
_http_client: &reqwest::Client,
_db: &DB,
_skip_job_usage: bool,
_reason: SendStatsReason,
) -> Result<()> {
// stats details are closed source
+8
View File
@@ -206,6 +206,14 @@ pub async fn get_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(db: E) -> R
Ok(uid)
}
pub fn map_string_to_number(s: &str, max_number: u64) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish() % (max_number + 1)
}
#[derive(Serialize, Deserialize, PartialEq, Clone)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
+33 -14
View File
@@ -23,7 +23,7 @@ use windmill_common::{
jobs::QueuedJob,
worker::{to_raw_value, write_file, write_file_at_user_defined_location, WORKER_CONFIG},
};
use windmill_parser_yaml::AnsibleRequirements;
use windmill_parser_yaml::{AnsibleRequirements, ResourceOrVariablePath};
use windmill_queue::{append_logs, CanceledBy};
use crate::{
@@ -186,7 +186,7 @@ async fn install_galaxy_collections(
#[cfg(not(feature = "enterprise"))]
fn check_ansible_exists() -> Result<(), error::Error> {
if !Path::new(ANSIBLE_PLAYBOOK_PATH.as_str()).exists() {
let msg = format!("Couldn't find ansible-playbook at {}. This probably means that you are not using the windmill-full image. Please use the image `windmill-full` for your instance in order to run rust jobs.", ANSIBLE_PLAYBOOK_PATH.as_str());
let msg = format!("Couldn't find ansible-playbook at {}. This probably means that you are not using the windmill-full image. Please use the image `windmill-full` for your instance in order to run Ansible jobs.", ANSIBLE_PLAYBOOK_PATH.as_str());
return Err(error::Error::NotFound(msg));
}
Ok(())
@@ -195,7 +195,7 @@ fn check_ansible_exists() -> Result<(), error::Error> {
#[cfg(feature = "enterprise")]
fn check_ansible_exists() -> Result<(), error::Error> {
if !Path::new(ANSIBLE_PLAYBOOK_PATH.as_str()).exists() {
let msg = format!("Couldn't find ansible-playbook at {}. This probably means that you are not using the windmill-full image. Please use the image `windmill-full-ee` for your instance in order to run rust jobs.", ANSIBLE_PLAYBOOK_PATH.as_str());
let msg = format!("Couldn't find ansible-playbook at {}. This probably means that you are not using the windmill-full image. Please use the image `windmill-ee-full` for your instance in order to run Ansible jobs.", ANSIBLE_PLAYBOOK_PATH.as_str());
return Err(error::Error::NotFound(msg));
}
Ok(())
@@ -557,20 +557,17 @@ async fn create_file_resources(
}
for file_res in &r.file_resources {
let r = client
.get_resource_value_interpolated::<serde_json::Value>(
&file_res.resource_path,
Some(job_id.to_string()),
)
.await?;
let r = get_resource_or_variable_content(
client,
&file_res.resource_path,
job_id.to_string(),
)
.await?;
let path = file_res.target_path.clone();
let validated_path = write_file_at_user_defined_location(
job_dir,
path.as_str(),
r.get("content").and_then(|v| v.as_str()).ok_or(anyhow!(
"Invalid text file resource {}, `content` field absent or invalid",
&file_res.resource_path
))?,
&r,
)
.map_err(|e| anyhow!("Couldn't write text file at {}: {}", path, e))?;
@@ -580,7 +577,7 @@ async fn create_file_resources(
);
logs.push_str(&format!(
"\nCreated {} from {}",
"\nCreated {} from {:?}",
file_res.target_path, file_res.resource_path
));
}
@@ -588,3 +585,25 @@ async fn create_file_resources(
Ok(nsjail_mounts)
}
async fn get_resource_or_variable_content(
client: &crate::AuthedClient,
path: &ResourceOrVariablePath,
job_id: String,
) -> anyhow::Result<String> {
Ok(match path {
ResourceOrVariablePath::Resource(p) => {
let r = client
.get_resource_value_interpolated::<serde_json::Value>(&p, Some(job_id))
.await?;
r.get("content").and_then(|v| v.as_str()).ok_or(anyhow!(
"Invalid text file resource {}, `content` field absent or invalid",
p
))?.to_string()
}
ResourceOrVariablePath::Variable(p) => {
client.get_variable_value(&p).await?
}
})
}
+36 -5
View File
@@ -59,11 +59,42 @@ pub async fn handle_bash_job(
append_logs(&job.id, &job.workspace_id, logs1, db).await;
write_file(job_dir, "main.sh", &format!("set -e\n{content}"))?;
write_file(
job_dir,
"wrapper.sh",
&format!("set -o pipefail\nset -e\nmkfifo bp\ncat bp | tail -1 > ./result2.out &\n {bash} ./main.sh \"$@\" 2>&1 | tee bp\nwait $!", bash = BIN_BASH.as_str()),
)?;
let script = format!(
r#"
set -o pipefail
set -e
# Function to kill child processes
cleanup() {{
echo "Terminating child processes..."
# Ignore SIGTERM and SIGINT
trap '' SIGTERM SIGINT
# Kill the process group of the script (negative PID value)
pkill -P $$
exit
}}
# Trap SIGTERM (or other signals) and call cleanup function
trap cleanup SIGTERM SIGINT
# Create a named pipe
mkfifo bp
# Start background processes
cat bp | tail -1 >> ./result2.out &
# Run main.sh in the same process group
{bash} ./main.sh "$@" 2>&1 | tee bp &
# Wait for all background processes to finish
wait
"#,
bash = BIN_BASH.as_str(),
);
write_file(job_dir, "wrapper.sh", &script)?;
let token = client.get_token().await;
let mut reserved_variables = get_reserved_variables(job, &token, db).await?;
+43 -16
View File
@@ -63,8 +63,26 @@ const RELATIVE_BUN_BUILDER: &str = include_str!("../loader_builder.bun.js");
const NSJAIL_CONFIG_RUN_BUN_CONTENT: &str = include_str!("../nsjail/run.bun.config.proto");
pub const BUN_LOCKB_SPLIT: &str = "\n//bun.lockb\n";
pub const BUN_LOCKB_SPLIT_WINDOWS: &str = "\r\n//bun.lockb\r\n";
pub const EMPTY_FILE: &str = "<empty>";
fn split_lockfile(lockfile: &str) -> (&str, Option<&str>, bool) {
if let Some(index) = lockfile.find(BUN_LOCKB_SPLIT) {
// Split using "\n//bun.lockb\n"
let (before, after_with_sep) = lockfile.split_at(index);
let after = &after_with_sep[BUN_LOCKB_SPLIT.len()..];
(before, Some(after), after == EMPTY_FILE)
} else if let Some(index) = lockfile.find(BUN_LOCKB_SPLIT_WINDOWS) {
// Split using "\r\n//bun.lockb\r\n"
let (before, after_with_sep) = lockfile.split_at(index);
let after = &after_with_sep[BUN_LOCKB_SPLIT_WINDOWS.len()..];
(before, Some(after), after == EMPTY_FILE)
} else {
(lockfile, None, false)
}
}
pub async fn gen_bun_lockfile(
mem_peak: &mut i32,
canceled_by: &mut Option<CanceledBy>,
@@ -180,7 +198,12 @@ pub async fn gen_bun_lockfile(
file.read_to_string(&mut content).await?;
}
if !npm_mode {
#[cfg(any(target_os = "linux", target_os = "macos"))]
content.push_str(BUN_LOCKB_SPLIT);
#[cfg(target_os = "windows")]
content.push_str(BUN_LOCKB_SPLIT_WINDOWS);
{
let file = format!("{job_dir}/bun.lockb");
if !empty_deps && tokio::fs::metadata(&file).await.is_ok() {
@@ -762,12 +785,15 @@ async fn compute_bundle_local_and_remote_path(
}
pub async fn prepare_job_dir(reqs: &str, job_dir: &str) -> Result<()> {
let splitted = reqs.split(BUN_LOCKB_SPLIT).collect::<Vec<&str>>();
let _ = write_file(job_dir, "package.json", &splitted[0])?;
let (pkg, lock, empty) = split_lockfile(reqs);
let _ = write_file(job_dir, "package.json", pkg)?;
if splitted[1] != EMPTY_FILE {
let _ = write_lockb(splitted[1], job_dir).await?;
if !empty {
if let Some(lock) = lock {
let _ = write_lockb(lock, job_dir).await?;
}
}
Ok(())
}
async fn write_lockb(splitted_lockb_2: &str, job_dir: &str) -> Result<()> {
@@ -874,22 +900,23 @@ pub async fn handle_bun_job(
} else if let Some(codebase) = codebase.as_ref() {
pull_codebase(&job.workspace_id, codebase, job_dir).await?;
} else if let Some(reqs) = requirements_o.as_ref() {
let splitted = reqs.split(BUN_LOCKB_SPLIT).collect::<Vec<&str>>();
if splitted.len() != 2 && !annotation.npm {
let (pkg, lock, empty) = split_lockfile(reqs);
if lock.is_none() && !annotation.npm {
return Err(error::Error::ExecutionErr(
format!("Invalid requirements, expected to find //bun.lockb split pattern in reqs. Found: |{reqs}|")
));
}
let _ = write_file(job_dir, "package.json", &splitted[0])?;
let lockb = if annotation.npm { "" } else { splitted[1] };
if lockb != EMPTY_FILE {
let _ = write_file(job_dir, "package.json", pkg)?;
let lockb = if annotation.npm { "" } else { lock.unwrap() };
if !empty {
let mut skip_install = false;
let mut create_buntar = false;
let mut buntar_path = "".to_string();
if !annotation.npm {
let _ = write_lockb(&splitted[1], job_dir).await?;
let _ = write_lockb(lockb, job_dir).await?;
let mut sha_path = sha2::Sha256::new();
sha_path.update(lockb.as_bytes());
@@ -1561,20 +1588,20 @@ pub async fn start_worker(
if let Some(codebase) = codebase.as_ref() {
pull_codebase(w_id, codebase, job_dir).await?;
} else if let Some(reqs) = requirements_o {
let splitted = reqs.split(BUN_LOCKB_SPLIT).collect::<Vec<&str>>();
if splitted.len() != 2 {
let (pkg, lock, empty) = split_lockfile(&reqs);
if lock.is_none() {
return Err(error::Error::ExecutionErr(
format!("Invalid requirements, expected to find //bun.lockb split pattern in reqs. Found: |{reqs}|")
));
}
let _ = write_file(job_dir, "package.json", &splitted[0])?;
let lockb = splitted[1];
if lockb != EMPTY_FILE {
let _ = write_file(job_dir, "package.json", pkg)?;
let lockb = lock.unwrap();
if !empty {
let _ = write_file_binary(
job_dir,
"bun.lockb",
&base64::engine::general_purpose::STANDARD
.decode(&splitted[1])
.decode(lockb)
.map_err(|_| {
error::Error::InternalErr("Could not decode bun.lockb".to_string())
})?,
+1 -1
View File
@@ -85,7 +85,7 @@ fn do_postgresql_inner<'a>(
let arg_t = arg
.otyp
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing otyp for pg arg"))?;
.ok_or_else(|| anyhow::anyhow!("Missing otzyp for pg arg"))?;
let typ = &arg.typ;
let param = convert_val(value, arg_t, typ)?;
query_params.push(param);
+1 -1
View File
@@ -143,7 +143,7 @@ fn check_php_exists() -> error::Result<()> {
#[cfg(feature = "enterprise")]
fn check_php_exists() -> error::Result<()> {
if !Path::new(PHP_PATH.as_str()).exists() {
let msg = format!("Couldn't find php at {}. This probably means that you are not using the windmill-full image. Please use the image `windmill-full-ee` for your instance in order to run php jobs.", PHP_PATH.as_str());
let msg = format!("Couldn't find php at {}. This probably means that you are not using the windmill-full image. Please use the image `windmill-ee-full` for your instance in order to run php jobs.", PHP_PATH.as_str());
return Err(error::Error::NotFound(msg));
}
Ok(())
+1 -1
View File
@@ -281,7 +281,7 @@ fn check_cargo_exists() -> Result<(), Error> {
#[cfg(feature = "enterprise")]
fn check_cargo_exists() -> Result<(), Error> {
if !Path::new(CARGO_PATH.as_str()).exists() {
let msg = format!("Couldn't find cargo at {}. This probably means that you are not using the windmill-full image. Please use the image `windmill-full-ee` for your instance in order to run rust jobs.", CARGO_PATH.as_str());
let msg = format!("Couldn't find cargo at {}. This probably means that you are not using the windmill-full image. Please use the image `windmill-ee-full` for your instance in order to run rust jobs.", CARGO_PATH.as_str());
return Err(Error::NotFound(msg));
}
Ok(())
+15 -10
View File
@@ -2403,15 +2403,15 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
}
tracing::debug!(id = %flow_job.id, root_id = %job_root, "pushing job {i} of {len}");
let payload_tag = match &job_payloads {
ContinuePayload::SingleJob(payload) => payload.clone(),
ContinuePayload::BranchAllJobs(payloads) => payloads[i].clone(),
ContinuePayload::SingleJob(payload) => payload,
ContinuePayload::BranchAllJobs(payloads) => &payloads[i],
ContinuePayload::ForloopJobs { flow_value, delete_after_use, .. } => {
let mut fv = flow_value.clone();
if let Some(failure_module) = fv.failure_module.as_mut() {
failure_module.id_append(&format!("{}-{i}", &status.step.to_string()));
}
JobPayloadWithTag {
&JobPayloadWithTag {
payload: JobPayload::RawFlow {
value: fv,
path: Some(format!("{}/forloop-{i}", flow_job.script_path())),
@@ -2590,13 +2590,17 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
};
tracing::debug!(id = %flow_job.id, root_id = %job_root, "computed perms for job {i} of {len}");
let tag = if flow_job.tag == "flow" || flow_job.tag == format!("flow-{}", flow_job.workspace_id) {
payload_tag.tag.clone()
} else {
Some(flow_job.tag.clone())
};
let tx2 = PushIsolationLevel::Transaction(tx);
let (uuid, mut inner_tx) = push(
&db,
tx2,
&flow_job.workspace_id,
payload_tag.payload,
payload_tag.payload.clone(),
push_args,
&flow_job.created_by,
&flow_job.email,
@@ -2610,11 +2614,7 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
continue_on_same_worker,
err,
flow_job.visible_to_owner,
if flow_job.tag == "flow" || flow_job.tag == format!("flow-{}", flow_job.workspace_id) {
payload_tag.tag
} else {
Some(flow_job.tag.clone())
},
tag,
payload_tag.timeout,
Some(module.id.clone()),
new_job_priority_override,
@@ -3761,6 +3761,11 @@ async fn script_to_payload(
module: &FlowModule,
tag_override: &Option<String>,
) -> Result<JobPayloadWithTag, Error> {
let tag_override = if tag_override.as_ref().is_some_and(|x| x.trim().is_empty()) {
None
} else {
tag_override.clone()
};
let (payload, tag, delete_after_use, script_timeout) = if script_hash.is_none() {
let (jp, tag, delete_after_use, script_timeout) =
script_path_to_payload(script_path, db, &flow_job.workspace_id, Some(true)).await?;
@@ -291,7 +291,7 @@ pub async fn handle_dependency_job<R: rsmq_async::RsmqConnection + Send + Sync +
let hash = job.script_hash.unwrap_or(ScriptHash(0));
let w_id = &job.workspace_id;
sqlx::query!(
"UPDATE script SET lock = $1, created_at = now() WHERE hash = $2 AND workspace_id = $3",
"UPDATE script SET lock = $1 WHERE hash = $2 AND workspace_id = $3",
&content,
&hash.0,
w_id
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.411.1";
export const VERSION = "v1.414.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+5 -6
View File
@@ -650,7 +650,10 @@ const command = new Command()
.option("--skip-configs", "Skip pulling configs (worker groups and SMTP)")
.option("--skip-groups", "Skip pulling instance groups")
.option("--include-workspaces", "Also pull workspaces")
.option(
"--instance <instance:string>",
"Name of the instance to push to, override the active instance"
)
.action(instancePull as any)
.command("push")
.description(
@@ -663,13 +666,9 @@ const command = new Command()
.option("--skip-groups", "Skip pushing instance groups")
.option("--include-workspaces", "Also push workspaces")
.option(
"--instance",
"--instance <instance:string>",
"Name of the instance to push to, override the active instance"
)
.option(
"--base-url",
"If used with --token, will be used as the base url for the instance"
)
.action(instancePush as any)
.command("whoami")
.description("Display information about the currently logged-in user")
+85 -87
View File
@@ -3,102 +3,100 @@ import crypto from "node:crypto";
// Helper function to convert strings to Uint8Array (binary)
function encode(input: string): Uint8Array {
return new TextEncoder().encode(input);
}
// Helper function to convert Uint8Array (binary) to base64
function toBase64(arr: Uint8Array): string {
return btoa(String.fromCharCode(...arr));
}
// Helper function to convert base64 to Uint8Array (binary)
function fromBase64(base64: string): Uint8Array {
return new Uint8Array(
atob(base64).split("").map((char) => char.charCodeAt(0))
);
}
// Function to derive a 256-bit key from any input string using SHA-256
async function deriveKey(keyString: string): Promise<CryptoKey> {
const keyMaterial = encode(keyString);
const keyHash = await crypto.subtle.digest("SHA-256", keyMaterial); // Generate SHA-256 hash
// Import the hash as a CryptoKey for AES-GCM
return crypto.subtle.importKey(
"raw",
keyHash,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"]
);
}
return new TextEncoder().encode(input);
}
// Encrypt function
export async function encrypt(
plaintext: string,
keyString: string
): Promise<string> {
const key = await deriveKey(keyString); // Derive a 256-bit AES key from any input string
const iv = crypto.getRandomValues(new Uint8Array(12)); // AES-GCM needs a 12-byte IV
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
tagLength: 128
},
key,
encode(plaintext) // convert plaintext to binary
);
// Concatenate IV and encrypted data
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv, 0); // first part is the IV
combined.set(new Uint8Array(encrypted), iv.length); // second part is the ciphertext
// Convert to base64 for storage/transmission
return toBase64(combined);
}
// Decrypt function
export async function decrypt(
combinedCiphertext: string,
keyString: string
): Promise<string> {
const key = await deriveKey(keyString); // Derive the same 256-bit AES key from the input string
const combined = fromBase64(combinedCiphertext); // decode base64 to binary
// Split the IV and the ciphertext
const iv = combined.slice(0, 12); // First 12 bytes are the IV
const ciphertext = combined.slice(12); // The rest is the encrypted data
console.log()
// Helper function to convert Uint8Array (binary) to base64
function toBase64(arr: Uint8Array): string {
return btoa(String.fromCharCode(...arr));
}
// Helper function to convert base64 to Uint8Array (binary)
function fromBase64(base64: string): Uint8Array {
return new Uint8Array(
atob(base64)
.split("")
.map((char) => char.charCodeAt(0))
);
}
// Function to derive a 256-bit key from any input string using SHA-256
async function deriveKey(
keyString: string
): Promise<crypto.webcrypto.CryptoKey> {
const keyMaterial = encode(keyString);
const keyHash = await crypto.subtle.digest("SHA-256", keyMaterial); // Generate SHA-256 hash
// Import the hash as a CryptoKey for AES-GCM
return crypto.subtle.importKey("raw", keyHash, { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
}
// Encrypt function
export async function encrypt(
plaintext: string,
keyString: string
): Promise<string> {
const key = await deriveKey(keyString); // Derive a 256-bit AES key from any input string
const iv = crypto.getRandomValues(new Uint8Array(12)); // AES-GCM needs a 12-byte IV
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
tagLength: 128,
},
key,
encode(plaintext) // convert plaintext to binary
);
// Concatenate IV and encrypted data
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv, 0); // first part is the IV
combined.set(new Uint8Array(encrypted), iv.length); // second part is the ciphertext
// Convert to base64 for storage/transmission
return toBase64(combined);
}
// Decrypt function
export async function decrypt(
combinedCiphertext: string,
keyString: string
): Promise<string> {
const key = await deriveKey(keyString); // Derive the same 256-bit AES key from the input string
const combined = fromBase64(combinedCiphertext); // decode base64 to binary
// Split the IV and the ciphertext
const iv = combined.slice(0, 12); // First 12 bytes are the IV
const ciphertext = combined.slice(12); // The rest is the encrypted data
console.log();
// log.info({keyString, key, ciphertext})
// Perform decryption
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
tagLength: 128,
},
key,
ciphertext
);
// Convert decrypted data from binary to string
return new TextDecoder().decode(decrypted);
}
// log.info({keyString, key, ciphertext})
// Perform decryption
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
tagLength: 128
},
key,
ciphertext
);
// Convert decrypted data from binary to string
return new TextDecoder().decode(decrypted);
}
// // Example usage:
// const key = "any-length-key-you-want"; // Now can be any length
// const message = "This is a secret message.";
// encrypt(message, key).then((combinedCiphertext) => {
// console.log("Encrypted message:", combinedCiphertext);
// // Now decrypt it
// decrypt(combinedCiphertext, key).then((decryptedMessage) => {
// console.log("Decrypted message:", decryptedMessage);
// });
// });
+1 -1
View File
@@ -60,7 +60,7 @@ export {
// }
// });
export const VERSION = "1.411.1";
export const VERSION = "1.414.1";
const command = new Command()
.name("wmill")
+1 -1
View File
@@ -11,7 +11,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
RUN apt-get -y update && apt-get install -y curl nodejs awscli
RUN apt-get -y update && apt-get install -y curl procps nodejs awscli
ENV TZ=Etc/UTC
+1 -1
View File
@@ -11,7 +11,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
RUN apt-get -y update && apt-get install -y curl nodejs awscli
RUN apt-get -y update && apt-get install -y curl procps nodejs awscli
ENV TZ=Etc/UTC
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.411.1",
"version": "1.414.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.411.1",
"version": "1.414.1",
"license": "AGPL-3.0",
"dependencies": {
"@aws-crypto/sha256-js": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.411.1",
"version": "1.414.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
+12 -10
View File
@@ -75,16 +75,18 @@
}}>{value}</button
>
{:else if typeof value !== 'object'}
{truncate(JSON.stringify(value), 100)}
{#if JSON.stringify(value).length > 100}
<button
class="text-xs text-blue-500"
on:click={() => {
jsonViewerContent = value
jsonViewer.toggleDrawer()
}}>See expanded</button
>
{/if}
<span>
{truncate(JSON.stringify(value), 80)}
{#if JSON.stringify(value).length > 80}
<button
class="text-xs text-blue-500"
on:click={() => {
jsonViewerContent = value
jsonViewer.toggleDrawer()
}}>See expanded</button
>
{/if}
</span>
{:else}
<div class="relative">
{#if JSON.stringify(value).length > 120}
@@ -0,0 +1,364 @@
<script lang="ts">
import type { AutoscalingConfig } from './worker_group'
import Toggle from './Toggle.svelte'
import Section from './Section.svelte'
import Tooltip from './Tooltip.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Button } from './common'
import { ExternalLink } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import Label from './Label.svelte'
import MultiSelect from 'svelte-multiselect'
export let config: AutoscalingConfig | undefined
export let worker_tags: string[] | undefined
const dispatch = createEventDispatcher()
let test_input: number = 3
</script>
<div class="flex flex-row gap-16 pt-2">
<div class="space-y-4 flex flex-col gap-1 max-w-xs text-sm">
<h5>Rules</h5>
<Toggle
checked={config?.enabled ?? false}
options={{ right: 'Enabled' }}
on:change={(e) => {
dispatch('dirty')
if (e.detail) {
if (!config) {
config = {
enabled: true,
min_workers: 3,
max_workers: 10,
integration: { type: 'dryrun' }
}
} else {
config.enabled = true
}
} else {
config = {
...(config ?? {
min_workers: 3,
max_workers: 10,
integration: { type: 'dryrun' }
}),
enabled: false
}
}
}}
/>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Min # of Workers
{#if config !== undefined}
<input on:input={() => dispatch('dirty')} type="number" bind:value={config.min_workers} />
{#if config.min_workers !== undefined && config.min_workers != undefined && config.min_workers > config.max_workers}
<div class="text-red-600 text-xs whitespace-nowrap"
>Minimum cannot be {'>'} to Maximum</div
>
{/if}
{:else}
<input type="number" disabled />
{/if}
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Max # of Workers
{#if config !== undefined}
<input on:input={() => dispatch('dirty')} type="number" bind:value={config.max_workers} />
{:else}
<input type="number" disabled />
{/if}
</label>
<div class="p-2">
<Section label="Advanced" small collapsable={true}>
<div class="flex flex-col gap-2 text-2xs">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Cooldown seconds after an incremental scale-in/out
{#if config !== undefined}
<input
on:input={() => dispatch('dirty')}
type="number"
step="1"
min="30"
placeholder="300"
bind:value={config.cooldown_seconds}
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Cooldown seconds after a full scale out
{#if config !== undefined}
<input
on:input={() => dispatch('dirty')}
type="number"
step="1"
min="30"
placeholder="1500"
bind:value={config.full_scale_cooldown_seconds}
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Num jobs waiting to trigger an incremental scale-out
{#if config !== undefined}
<input
on:input={() => dispatch('dirty')}
type="number"
bind:value={config.inc_scale_num_jobs_waiting}
placeholder="1"
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Num jobs waiting to trigger a full scale out <Tooltip
>Default: max_workers, full scale out = scale out to max workers</Tooltip
>
{#if config !== undefined}
<input
on:input={() => dispatch('dirty')}
type="number"
placeholder="max workers"
bind:value={config.full_scale_jobs_waiting}
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Occupancy rate % threhsold to go below to trigger a scale-in (decrease) <Tooltip
>Default: 25%, need to go below average of all of 15s, 5m and 30m occupancy rates</Tooltip
>
{#if config !== undefined}
<input
on:input={() => dispatch('dirty')}
type="number"
step="1"
min="0"
max="100"
placeholder="25"
bind:value={config.dec_scale_occupancy_rate}
/>
{:else}
<input type="number" step="0.01" disabled />
{/if}
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Occupancy rate threshold to exceed to trigger an incremental scale-out (increase) <Tooltip
>Default: 75%, need to exceed average of all of 15s, 5m and 30m occupancy rates</Tooltip
>
{#if config !== undefined}
<input
on:input={() => dispatch('dirty')}
type="number"
step="1"
min="0"
max="100"
placeholder="75"
bind:value={config.inc_scale_occupancy_rate}
/>
{:else}
<input type="number" step="0.01" disabled />
{/if}
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
Num workers to scale-in/out by when incremental <Tooltip
>Default: (max_workers - min_workers) / 5</Tooltip
>
{#if config !== undefined}
<input
on:input={() => dispatch('dirty')}
type="number"
step="1"
min="1"
placeholder="(max_workers - min_workers) / 5"
bind:value={config.inc_num_workers}
/>
{:else}
<input type="number" disabled />
{/if}
</label>
<Label label="Custom tags to autoscale on">
<svelte:fragment slot="header">
<Tooltip>
By default, autoscaling will apply to the tags the worker group is assigned to but
you can override this here.
</Tooltip>
</svelte:fragment>
{#if config}
{#if config.custom_tags}
<MultiSelect
outerDivClass="text-secondary !bg-surface-disabled !border-0"
selected={config.custom_tags}
on:change={(e) => {
console.log(e.detail.type, config?.custom_tags)
if (e.detail && config?.custom_tags) {
if (e.detail.type === 'add') {
config.custom_tags = [...config.custom_tags, e.detail.option]
} else if (e.detail.type === 'remove') {
config.custom_tags = config.custom_tags.filter((t) => t !== e.detail.option)
if (config?.custom_tags && config.custom_tags.length == 0) {
config.custom_tags = undefined
}
} else if (e.detail.type === 'removeAll') {
config.custom_tags = undefined
} else {
console.error(
`Priority tags multiselect - unknown event type: '${e.detail.type}'`
)
}
dispatch('dirty')
}
}}
on:clear={() => {
if (config) {
config.custom_tags = undefined
}
dispatch('dirty')
}}
options={worker_tags ?? []}
selectedOptionsDraggable={false}
ulOptionsClass={'!bg-surface-secondary'}
placeholder="Tags"
/>
{:else}
<Button
color="light"
size="xs"
variant="contained"
on:click={() => {
if (config) {
config.custom_tags = []
dispatch('dirty')
}
}}>Add custom tags</Button
>
{/if}
{/if}
</Label>
</div>
</Section>
</div>
</div>
<div class="flex flex-col gap-1 max-w-xs text-sm">
<h5>Integration</h5>
{#if config?.integration}
<ToggleButtonGroup
on:selected={(e) => dispatch('dirty')}
bind:selected={config.integration.type}
class="mb-4 mt-2"
>
<ToggleButton
value="dryrun"
size="sm"
label="Dry run"
tooltip="See autoscaling events but not actual scaling actions will be performed"
/>
<ToggleButton
value="script"
size="sm"
label="Custom Script"
tooltip="Run a custom script to scale your worker group"
/>
<ToggleButton position="center" disabled value="ecs" size="sm" label="ECS (soon)" />
<ToggleButton position="right" disabled value="nomad" size="sm" label="Nomad (soon)" />
<ToggleButton
position="right"
disabled
value="kubernetes"
size="sm"
label="Kubernetes (soon)"
/>
</ToggleButtonGroup>
{#if config.integration.type === 'script'}
<label>
Script path on the 'admins' workspace
<input
on:input={() => dispatch('dirty')}
type="text"
bind:value={config.integration.path}
/>
</label>
<label>
Custom tag for executing script (optional)
{#if config.integration.tag}
<input
on:input={() => dispatch('dirty')}
type="text"
bind:value={config.integration.tag}
/>
{:else}
<Button
color="light"
size="xs"
variant="contained"
on:click={() => {
if (config?.integration?.type === 'script') {
config.integration.tag = 'bash'
dispatch('dirty')
}
}}>Set tag</Button
>
{/if}
</label>
<div class="flex mt-6 gap-2">
<Button
color="dark"
target="_blank"
endIcon={{ icon: ExternalLink }}
href="/scripts/add?hub=hub%2F9204%2Fhelper%2FScale%20a%20worker%20group%20deployed%20as%20a%20kubernetes%20service&workspace=admins"
>Create from template</Button
>
<Button
color="dark"
target="_blank"
href={`/runs/${config.integration.path}?workspace=admins`}
endIcon={{ icon: ExternalLink }}
>
See jobs
</Button>
</div>
<div class="flex flex-row gap-2 mt-4">
<Button color="light" size="xs" variant="contained">Test scaling</Button>
<div class="flex text-xs flex-row gap-2 items-center">
<input class="!w-16" type="number" bind:value={test_input} />
workers
</div>
</div>
{/if}
{:else}
<ToggleButtonGroup selected={'script'} disabled class="mb-4 mt-2">
<ToggleButton value="dryrun" size="sm" label="Dry run" />
<ToggleButton value="script" size="sm" label="Custom Script" />
<ToggleButton position="center" value="ecs" size="sm" label="ECS (soon)" />
<ToggleButton position="right" value="nomad" size="sm" label="Nomad (soon)" />
<ToggleButton position="right" value="kubernetes" size="sm" label="Kubernetes (soon)" />
</ToggleButtonGroup>
<label>
Script path on the 'admins' workspace
<input type="text" disabled />
</label>
{/if}
</div>
</div>
@@ -0,0 +1,73 @@
<script lang="ts">
import { ConfigService, type AutoscalingEvent } from '$lib/gen'
import { LoaderIcon, RefreshCw } from 'lucide-svelte'
import { Button, Skeleton } from './common'
import { twMerge } from 'tailwind-merge'
import TimeAgo from './TimeAgo.svelte'
import { enterpriseLicense } from '$lib/stores'
export let worker_group: string
let loading = true
let events: AutoscalingEvent[] | undefined = undefined
$: worker_group && loadEvents()
async function loadEvents() {
loading = true
try {
events = await ConfigService.listAutoscalingEvents({ workerGroup: worker_group })
} catch (e) {
events = []
console.error(e)
} finally {
loading = false
}
}
</script>
<div>
<h6
class={!$enterpriseLicense || (events != undefined && events.length == 0)
? 'text-tertiary'
: ''}
>Autoscaling events {#if $enterpriseLicense}<span class="text-xs text-tertiary">(5 last)</span>
<span class="inline-flex ml-6">
<Button
startIcon={{
icon: loading ? LoaderIcon : RefreshCw,
classes: twMerge(
loading ? 'animate-spin text-blue-800' : '',
'transition-all text-gray-500 dark:text-white'
)
}}
color="light"
size="xs2"
btnClasses={twMerge(loading ? ' bg-blue-100 dark:bg-blue-400' : '', 'transition-all')}
on:click={() => loadEvents()}
iconOnly
/>
</span>{/if}
</h6>
{#if !$enterpriseLicense}
<div class="text-xs pt-2 text-tertiary">Autoscaling is an EE feature</div>
{:else if loading}
<Skeleton layout={[[12], 1]} />
{:else if events}
{#if events.length == 0}
<div class="text-xs pt-2 text-tertiary"
>No events, is autoscaling set in the worker group config?</div
>
{:else}
<div class="flex flex-col gap-2 text-xs text-tertiary pt-4">
{#each events as event}
<div class="flex flex-row gap-4">
<div class="text-primary">{event.event_type} to {event.desired_workers}</div>
<div class="text-secondary">{event.reason}</div>
<div class="text-tertiary"><TimeAgo date={event.applied_at ?? ''} /></div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
@@ -9,6 +9,7 @@
export let disappearTimeout = 100
export let appearTimeout = 300
export let style: string | undefined = undefined
export let noPadding = false
export let focusEl: HTMLElement | undefined = undefined
@@ -73,7 +74,7 @@
use:popperContent={popperOptions}
on:mouseenter={open}
on:mouseleave={close}
class="z-[5001] border rounded-lg shadow-lg p-4 bg-surface"
class="z-[5001] border rounded-lg shadow-lg {noPadding ? '' : 'p-4'} bg-surface"
>
<slot name="overlay" />
</div>
+35 -29
View File
@@ -119,13 +119,21 @@
if (version === undefined) {
return
}
const flowVersion = await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: $pathStore
})
onLatest = version === flowVersion?.id
try {
if (initialPath && initialPath != '') {
const flowVersion = await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: initialPath
})
onLatest = version === flowVersion?.id
} else {
onLatest = true
}
} catch (err) {
console.error('Error comparing versions', err)
onLatest = true
}
}
const dispatch = createEventDispatcher()
@@ -277,9 +285,8 @@
}
async function handleSaveFlow(deploymentMsg?: string) {
await compareVersions();
if (onLatest) {
await compareVersions()
if (onLatest || initialPath == '' || savedFlow?.draft_only) {
// Handle directly
await saveFlow(deploymentMsg)
} else {
@@ -294,28 +301,27 @@
open = true
}
}
async function syncWithDeployed(){
const flow = await FlowService.getFlowByPath({
workspace: $workspaceStore!,
path: $pathStore,
withStarredInfo: true
})
deployedValue = {
...flow,
starred: undefined,
id: undefined,
edited_at: undefined,
edited_by: undefined,
workspace_id: undefined,
archived: undefined,
same_worker: undefined,
visible_to_runner_only: undefined,
ws_error_handler_muted: undefined,
}
deployedBy = flow.edited_by
async function syncWithDeployed() {
const flow = await FlowService.getFlowByPath({
workspace: $workspaceStore!,
path: initialPath,
withStarredInfo: true
})
deployedValue = {
...flow,
starred: undefined,
id: undefined,
edited_at: undefined,
edited_by: undefined,
workspace_id: undefined,
archived: undefined,
same_worker: undefined,
visible_to_runner_only: undefined,
ws_error_handler_muted: undefined
}
deployedBy = flow.edited_by
}
async function saveFlow(deploymentMsg?: string): Promise<void> {
loadingSave = true
try {
@@ -133,8 +133,10 @@
Error handler
{:else if stepDetail.id === 'preprocessor'}
Preprocessor
{:else}
Anonymous step
{:else if stepDetail.value.type == 'rawscript'}
Inline {stepDetail.value.language} script
{:else if stepDetail.value.type == 'script'}
Workspace script
{/if}
</span>
</div>
@@ -102,8 +102,8 @@
if (values['base_url'] == undefined) {
values['base_url'] = 'http://localhost'
}
if (values['smtp_connect'] == undefined) {
values['smtp_connect'] = {}
if (values['smtp_settings'] == undefined) {
values['smtp_settings'] = {}
}
loading = false
@@ -206,7 +206,8 @@
'basecamp',
'linkedin',
'quickbooks',
'visma'
'visma',
'spotify'
]
let oauth_name = undefined
@@ -274,22 +275,34 @@
<TabContent value={category}>
{#if category == 'SMTP'}
<div class="text-secondary pb-4 text-xs"
>Setting SMTP unlocks sending emails upon adding new users to the workspace or the instance or sending critical alerts.
<a target="_blank" href="https://www.windmill.dev/docs/misc/setup_smtp">Learn more</a></div
>Setting SMTP unlocks sending emails upon adding new users to the workspace or the
instance or sending critical alerts.
<a target="_blank" href="https://www.windmill.dev/docs/misc/setup_smtp">Learn more</a
></div
>
{:else if category == 'Registries'}
<div class="text-secondary pb-4 text-xs">
Add private registries for Pip, Bun and npm. <a target="_blank" href="https://www.windmill.dev/docs/advanced/imports">Learn more</a>
Add private registries for Pip, Bun and npm. <a
target="_blank"
href="https://www.windmill.dev/docs/advanced/imports">Learn more</a
>
</div>
{:else if category == 'Slack'}
<div class="text-secondary pb-4 text-xs">
Connecting your instance to a Slack workspace enables critical alerts to be sent to a Slack channel.
<a target="_blank" href="https://www.windmill.dev/docs/misc/saml_and_scim">Learn more</a>
Connecting your instance to a Slack workspace enables critical alerts to be sent to a
Slack channel.
<a target="_blank" href="https://www.windmill.dev/docs/misc/saml_and_scim"
>Learn more</a
>
</div>
{:else if category == 'SCIM/SAML'}
<div class="text-secondary pb-4 text-xs">
Setting up SAML and SCIM allows you to authenticate users using your identity provider.
<a target="_blank" href="https://www.windmill.dev/docs/advanced/instance_settings#slack">Learn more</a>
Setting up SAML and SCIM allows you to authenticate users using your identity
provider.
<a
target="_blank"
href="https://www.windmill.dev/docs/advanced/instance_settings#slack">Learn more</a
>
</div>
{:else if category == 'Debug'}
<div class="text-secondary pb-4 text-xs">
@@ -310,9 +323,11 @@
</ul>
</div>
{#if $enterpriseLicense}
<div class="text-secondary pb-4 text-xs">
On Enterprise Edition, you must send data to check that usage is in line with the terms of the subscription. You can either enable telemetry or regularly send usage data by clicking the button below.
</div>
<div class="text-secondary pb-4 text-xs">
On Enterprise Edition, you must send data to check that usage is in line with the
terms of the subscription. You can either enable telemetry or regularly send usage
data by clicking the button below.
</div>
<Button
on:click={sendStats}
variant="border"
@@ -340,12 +355,17 @@
{/if}
<div class="py-1" />
<div class="mb-2">
<span class="text-primary text-sm">When at least one of the below options is set, users will be able to login to
Windmill via their third-party account.
<br /> To test SSO, the recommended workflow is to to save the settings and try to
login in an incognito window. <a target="_blank" href="https://www.windmill.dev/docs/misc/setup_oauth#sso">Learn more</a></span>
</div>
<div class="mb-2">
<span class="text-primary text-sm"
>When at least one of the below options is set, users will be able to login to
Windmill via their third-party account.
<br /> To test SSO, the recommended workflow is to to save the settings and try
to login in an incognito window.
<a target="_blank" href="https://www.windmill.dev/docs/misc/setup_oauth#sso"
>Learn more</a
></span
>
</div>
<div class="flex flex-col gap-2 py-4">
<OAuthSetting name="google" bind:value={oauths['google']} />
<OAuthSetting name="microsoft" bind:value={oauths['microsoft']} />
@@ -374,7 +394,11 @@
<div class="p-2 border rounded">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={oauths[k]['display_name']} />
<input
type="text"
placeholder="Custom Name"
bind:value={oauths[k]['display_name']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Id</span>
@@ -429,12 +453,18 @@
</div>
{:else if ssoOrOauth === 'oauth'}
<div class="mb-2">
<span class="text-primary text-sm">When one of the below options is set, you will be able to create a specific
<span class="text-primary text-sm"
>When one of the below options is set, you will be able to create a specific
resource containing a token automatically generated by the third-party provider.
<br />
To test it after setting an oauth client, go to the Resources menu and create a new
one of the type of your oauth client (i.e. a 'github' resource if you set Github OAuth).
<br /><a target="_blank" href="https://www.windmill.dev/docs/misc/setup_oauth#oauth">Learn more</a></span>
one of the type of your oauth client (i.e. a 'github' resource if you set Github
OAuth).
<br /><a
target="_blank"
href="https://www.windmill.dev/docs/misc/setup_oauth#oauth">Learn more</a
></span
>
</div>
<div class="py-1" />
<OAuthSetting login={false} name="slack" bind:value={oauths['slack']} />
@@ -621,24 +651,33 @@
{@const attemptedAt = new Date(
latestKeyRenewalAttempt.attempted_at
).toLocaleString()}
{@const isTrial =
latestKeyRenewalAttempt.result.startsWith('error: trial:')}
<div class="relative">
<Popover notClickable>
<div class="flex flex-row items-center gap-1">
{#if latestKeyRenewalAttempt.result === 'success'}
<BadgeCheck class="text-green-600" size={12} />
{:else}
<BadgeX class="text-red-600" size={12} />
<BadgeX
class={isTrial ? 'text-yellow-600' : 'text-red-600'}
size={12}
/>
{/if}
<span
class={classNames(
'text-xs',
latestKeyRenewalAttempt.result === 'success'
? 'text-green-600'
: isTrial
? 'text-yellow-600'
: 'text-red-600'
)}
>
{latestKeyRenewalAttempt.result === 'success'
? 'Latest key renewal succeeded'
: isTrial
? 'Latest key renewal ignored because in trial'
: 'Latest key renewal failed'}
on {attemptedAt}
</span>
@@ -648,6 +687,10 @@
<span class="text-green-300">
Latest key renewal succeeded on {attemptedAt}
</span>
{:else if isTrial}
<span class="text-yellow-300">
License key cannot be renewed during trial ({attemptedAt})
</span>
{:else}
<span class="text-red-300">
Latest key renewal failed on {attemptedAt}: {latestKeyRenewalAttempt.result.replace(
+4 -3
View File
@@ -12,6 +12,7 @@
import Row from './table/Row.svelte'
import HighlightTheme from './HighlightTheme.svelte'
import { deepEqual } from 'fast-equals'
import { isWindmillTooBigObject } from './job_args'
export let id: string | undefined = undefined
export let args: any
@@ -63,7 +64,7 @@ ${Object.entries(args)
<div class="relative">
<DataTable size="sm">
<Head>
<tr>
<tr class="w-full">
<Cell head first>{argLabel ?? 'Arg'}</Cell>
<Cell head last>Value</Cell>
</tr>
@@ -79,7 +80,7 @@ ${Object.entries(args)
</svelte:fragment>
</Head>
<tbody class="divide-y">
<tbody class="divide-y w-full">
{#if args && Object.keys(args).length > 0}
{#each Object.entries(args).sort((a, b) => a[0].localeCompare(b[0])) as [arg, value]}
<Row>
@@ -137,7 +138,7 @@ ${Object.entries(args)
Copy to clipboard
</Button>
</svelte:fragment>
{#if jsonStr.length > 100000 || (id && workspace && args && typeof args === 'object' && deepEqual( Object.keys(args), ['reason'] ) && args['reason'] == 'WINDMILL_TOO_BIG')}
{#if jsonStr.length > 100000 || (id && workspace && args && isWindmillTooBigObject(args))}
<div class="text-sm mb-2 text-tertiary">
<a
download="windmill-args.json"
@@ -32,7 +32,7 @@
getContext<FlowEditorContext>('FlowEditorContext')
// Test
let scriptProgress = undefined;
let scriptProgress = undefined
let testJobLoader: TestJobLoader
let testIsLoading = false
let testJob: Job | undefined = undefined
@@ -54,7 +54,7 @@
export async function runTest(args: any) {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset();
if (jobProgressReset) jobProgressReset()
const val = mod.value
// let jobId: string | undefined = undefined
@@ -75,7 +75,7 @@
script.content,
script.language,
args,
$flowStore?.tag ?? script.tag
$flowStore?.tag ?? (val.tag_override ? val.tag_override : script.tag)
)
} else if (val.type == 'flow') {
await testJobLoader?.abstractRun(() =>
@@ -103,8 +103,6 @@
let forceJson = false
</script>
<TestJobLoader
toastError={noEditor}
on:done={() => jobDone()}
@@ -158,9 +156,14 @@
/>
</Pane>
<Pane size={50} minSize={10} class="text-sm text-tertiary">
{#if scriptProgress}
<JobProgressBar job={testJob} bind:scriptProgress bind:reset={jobProgressReset} compact={true} />
{/if}
{#if scriptProgress}
<JobProgressBar
job={testJob}
bind:scriptProgress
bind:reset={jobProgressReset}
compact={true}
/>
{/if}
{#if testJob != undefined && 'result' in testJob && testJob.result != undefined}
<div class="break-words relative h-full p-2">
<DisplayResult
@@ -186,7 +189,7 @@
<div class="p-2">
{#if testIsLoading}
{#if !scriptProgress}
<Loader2 class="animate-spin" />
<Loader2 class="animate-spin" />
{/if}
{:else}
Test to see the result here
@@ -66,10 +66,12 @@
>
{#if enabled}
<div class="p-2 rounded border">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
</label>
{#if name != 'slack'}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
</label>
{/if}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
@@ -9,6 +9,18 @@
import Tooltip from './Tooltip.svelte'
import { enterpriseLicense } from '$lib/stores'
function updateChangesMade() {
changesMade = JSON.stringify(alerts) !== JSON.stringify(originalAlerts)
}
function handleInput(event) {
const target = event.target
console.log(target)
if (target.tagName.toLowerCase() === 'input') {
updateChangesMade()
}
}
type Alert = {
name: string
tags_to_monitor: string[]
@@ -70,16 +82,20 @@
}
function startEditing(index) {
if (editingIndex !== -1) {
const success = saveAlert(editingIndex)
if (!success) return
}
editingIndex = index
updateWorkerTags()
}
function saveAlert(index) {
function saveAlert(index): boolean {
const newAlert = alerts[index]
if (newAlert.tags_to_monitor.length === 0) {
sendUserToast('Please add at least one tag before saving.', true)
return
return false
}
if (
@@ -88,7 +104,7 @@
newAlert.alert_time_threshold_seconds <= 0
) {
sendUserToast('All numeric values must be strictly positive.', true)
return
return false
}
const alertExists = originalAlerts.some(
@@ -99,13 +115,15 @@
)
if (alertExists) {
sendUserToast('An identical alert already exists.', true)
return
sendUserToast('You can only define one alert per identical set of tags', true)
return false
}
editingIndex = -1
changesMade = JSON.stringify(alerts) !== JSON.stringify(originalAlerts)
updateChangesMade()
stagedNewAlert = false
return true
}
function stageDeleteAlert(index) {
@@ -126,16 +144,18 @@
}
newTag = ''
filteredTags = []
updateChangesMade()
}
function removeTag(alertIndex, tag) {
alerts[alertIndex].tags_to_monitor = alerts[alertIndex].tags_to_monitor.filter((t) => t !== tag)
updateChangesMade()
}
async function applyConfig() {
if (stagedNewAlert) {
sendUserToast('New alert staged. Please save or cancel the changes before applying.', true)
return
if (editingIndex !== -1) {
const success = saveAlert(editingIndex)
if (!success) return
}
try {
@@ -146,6 +166,7 @@
removedAlerts = []
changesMade = false
stagedNewAlert = false
editingIndex = -1
} catch (error) {
console.error('Failed to update config:', error)
}
@@ -167,7 +188,7 @@
}
const newAlert = {
name: 'New Alert',
name: 'Job Queue Alert',
tags_to_monitor: [],
jobs_num_threshold: 3,
alert_cooldown_seconds: 600,
@@ -177,6 +198,7 @@
alerts = [...alerts, newAlert]
editingIndex = alerts.length - 1
stagedNewAlert = true
updateChangesMade()
updateWorkerTags()
}
@@ -188,28 +210,35 @@
alerts[alertIndex].tags_to_monitor = [
...new Set([...alerts[alertIndex].tags_to_monitor, ...workerTags])
]
alerts = alerts // Trigger reactivity
alerts = [...alerts]
updateChangesMade()
}
</script>
<Drawer bind:this={drawer} size="800px">
<DrawerContent title="Queue Metrics" on:close={drawer.closeDrawer}>
<DrawerContent
title="Queues"
on:close={drawer.closeDrawer}
documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups#queue-metrics"
>
<Section
label="Queue Alert Settings"
collapsable={true}
tooltip="Manage queue alerts for monitoring queue metrics. These alerts will trigger a critical alert notification if the queue metrics are met."
tooltip="A critical alert is triggered when the number of jobs in the queue exceeds the set threshold and they have been waiting for at least the specified time. After an alert, no new alerts will be triggered during the cooldown period."
eeOnly={true}
>
{#if $enterpriseLicense}
{#if changesMade}
<div class="text-red-600 text-xs whitespace-nowrap pb-2">Non applied changes</div>
<div class="flex gap-2 pb-2">
<Button color="blue" size="xs" on:click={applyConfig}>
<SaveIcon size={16} /> Apply Config
</Button>
<Button color="light" size="xs" on:click={cancelChanges}>Cancel</Button>
</div>
{/if}
<div class="flex gap-2 pb-2">
<Button color="blue" size="xs" on:click={applyConfig} disabled={!changesMade}>
<SaveIcon size={16} /> Apply Config
</Button>
<Button color="light" size="xs" on:click={cancelChanges} disabled={!changesMade}>
Cancel
</Button>
</div>
{#if alerts.length > 0}
<div>
@@ -217,12 +246,6 @@
<table class="w-full border-collapse mb-2 text-xs table-auto">
<thead class="bg-gray-200 dark:bg-slate-600 text-left text-xs">
<tr>
<th class="p-2 min-w-[120px]">
Name
<Tooltip
markdownTooltip="The name of the alert. This will be used in the crticial alert notification and help identify the alert."
/>
</th>
<th class="p-2 w-full">
Queue Tags to Monitor
<Tooltip markdownTooltip="Queue tags to monitor for this alert." />
@@ -230,38 +253,31 @@
<th class="p-2 min-w-[65px]">
Jobs
<Tooltip
markdownTooltip="Number of jobs threshold. An alert will be triggerd if more than this number of jobs are in the queue for at least the time threshold."
markdownTooltip="Number of jobs threshold: An alert will be triggered if the number of jobs in the queue exceeds this threshold and they have been waiting for at least the specified time threshold."
/>
</th>
<th class="p-2 min-w-[115px]">
Cooldown (s)
<Tooltip
markdownTooltip="Cooldown period in seconds. The cooldown period is the time after an alert is triggered that no new alerts will be triggered."
markdownTooltip="Cooldown period in seconds: This defines the time interval after an alert is triggered during which no additional alerts will be sent."
/>
</th>
<th class="p-2 min-w-[105px]">
Time (s)
<Tooltip
markdownTooltip="Time threshold in seconds. An alert will be triggered if more than 'n jobs threshold' have been in the queue for at least this amount of time."
markdownTooltip="Time threshold in seconds: An alert will be triggered if the number of jobs in the queue exceeds the job threshold and they have remained in the queue for at least this duration."
/>
</th>
<th class="p-2 min-w-[100px]"> Actions </th>
</tr>
</thead>
<tbody>
<tbody on:input={handleInput}>
{#each alerts as alert, index}
<tr
class={removedAlerts.includes(alert)
? 'bg-red-100 dark:bg-red-900 pointer-events-none opacity-50'
: ''}
>
<td class="border p-2">
{#if editingIndex === index}
<input type="text" bind:value={alert.name} class="w-full p-1" />
{:else}
{alert.name}
{/if}
</td>
<td class="border p-2">
{#if editingIndex === index}
<div class="flex flex-wrap gap-1 mb-2">
@@ -282,8 +298,9 @@
<input
type="text"
bind:value={newTag}
placeholder="Add tag"
placeholder="{workerTags.length === alert.tags_to_monitor.length ? 'All tags already added' : 'Add tag from dropdown' }"
on:input={(e) => filterTags(e)}
disabled={workerTags.length === alert.tags_to_monitor.length}
class="p-1 flex-grow mr-1"
/>
<button on:click={() => addTag(index, newTag)} aria-label="Add Tag">
@@ -294,6 +311,7 @@
<button
on:click={() => addAllTags(index)}
class="text-xs hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-1 mt-1"
disabled={workerTags.length === alert.tags_to_monitor.length}
>
Add All Tags
</button>
@@ -302,6 +320,7 @@
class="autocomplete-list border max-h-36 overflow-y-auto absolute z-50"
>
{#each filteredTags as tag}
{#if !alert.tags_to_monitor.includes(tag)}
<li>
<button
type="button"
@@ -311,6 +330,7 @@
{tag}
</button>
</li>
{/if}
{/each}
</ul>
{/if}
@@ -401,6 +421,7 @@
</p>
{/if}
</Section>
<h1 class="pt-4">Queue Metrics</h1>
<div class="p-8">
<QueueMetricsDrawerInner />
</div>
@@ -18,9 +18,7 @@
encodeState,
formatCron,
orderedJsonStringify,
type Value
} from '$lib/utils'
import Path from './Path.svelte'
import ScriptEditor from './ScriptEditor.svelte'
@@ -92,7 +90,6 @@
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
let open: boolean = false // Is confirmation modal open
let metadataOpen =
!neverShowMeta &&
(showMeta ||
@@ -251,62 +248,74 @@
}
}
async function handleEditScript(stay: boolean, deployMsg?: string): Promise<void>{
// Fetch latest version and fetch entire script after if needed
let actual_parent_hash = (await ScriptService.getScriptLatestVersion({
workspace: $workspaceStore!,
path: script.path,
}))?.script_hash;
async function handleEditScript(stay: boolean, deployMsg?: string): Promise<void> {
// Fetch latest version and fetch entire script after if needed
let actual_parent_hash: string | undefined = undefined
// Usually when we create new script, we put current hash as a parent_hash
// But if we specify parent_hash that is already used, than we get error
// In order to fix it we make sure that client's understanding of parent_hash
// is aligns with understanding of backend.
if (script.parent_hash == actual_parent_hash) {
// Handle directly
await editScript(stay, actual_parent_hash, deployMsg);
} else {
// Fetch entire script, since we need it to show Diff
await syncWithDeployed()
// Handle through confirmation modal
confirmCallback = async () => {
open = false
await editScript(stay, actual_parent_hash, deployMsg);
}
// Open confirmation modal
open = true
try {
if (initialPath && initialPath != '') {
actual_parent_hash = (
await ScriptService.getScriptLatestVersion({
workspace: $workspaceStore!,
path: initialPath
})
)?.script_hash
}
} catch (error) {
//
}
// Usually when we create new script, we put current hash as a parent_hash
// But if we specify parent_hash that is already used, than we get error
// In order to fix it we make sure that client's understanding of parent_hash
// is aligns with understanding of backend.
if (actual_parent_hash == undefined || script.parent_hash == actual_parent_hash) {
// Handle directly
await editScript(stay, script.parent_hash!, deployMsg)
} else {
// Fetch entire script, since we need it to show Diff
await syncWithDeployed()
// Handle through confirmation modal
confirmCallback = async () => {
open = false
await editScript(stay, actual_parent_hash, deployMsg)
}
// Open confirmation modal
open = true
}
}
async function syncWithDeployed(){
const latestScript = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path: script.path,
withStarredInfo: true
});
async function syncWithDeployed() {
const latestScript = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path: initialPath,
withStarredInfo: true
})
deployedValue = {
...latestScript,
starred: undefined,
workspace_id: undefined,
archived: undefined,
created_at: undefined,
created_by: undefined,
deleted: undefined,
extra_perms: undefined,
is_template: undefined,
lock: undefined,
lock_error_logs: undefined,
parent_hashes: undefined,
};
deployedValue = {
...latestScript,
starred: undefined,
workspace_id: undefined,
archived: undefined,
created_at: undefined,
created_by: undefined,
deleted: undefined,
extra_perms: undefined,
is_template: undefined,
lock: undefined,
lock_error_logs: undefined,
parent_hashes: undefined
}
deployedBy = latestScript.created_by;
deployedBy = latestScript.created_by
}
async function editScript(stay: boolean, parentHash: string, deploymentMsg?: string, ): Promise<void> {
async function editScript(
stay: boolean,
parentHash: string,
deploymentMsg?: string
): Promise<void> {
loadingSave = true
try {
try {
@@ -354,7 +363,6 @@
}
})
console.log('initialPath', initialPath)
const scheduleExists =
initialPath != '' &&
(await ScheduleService.existsSchedule({
@@ -22,11 +22,12 @@
import ChangeInstanceUsername from './ChangeInstanceUsername.svelte'
import { isCloudHosted } from '$lib/cloud'
import InstanceNameEditor from './InstanceNameEditor.svelte'
import Toggle from './Toggle.svelte'
let drawer: Drawer
let filter = ''
export function openDrawer() {
listUsers()
listUsers(activeOnly)
drawer?.openDrawer?.()
}
@@ -45,11 +46,14 @@
let users: GlobalUserInfo[] = []
let filteredUsers: GlobalUserInfo[] = []
let deleteConfirmedCallback: (() => void) | undefined = undefined
let activeOnly = false
async function listUsers(): Promise<void> {
users = await UserService.listUsersAsSuperAdmin({ perPage: 100000 })
async function listUsers(activeOnly: boolean): Promise<void> {
users = await UserService.listUsersAsSuperAdmin({ perPage: 100000, activeOnly: activeOnly })
}
$: listUsers(activeOnly)
let tab: 'users' | string = 'users'
let nbDisplayed = 50
@@ -70,7 +74,7 @@
})
getAutomateUsernameCreationSetting()
sendUserToast('Automatic username creation enabled')
listUsers()
listUsers(activeOnly)
}
async function updateName(name: string | undefined, email: string) {
@@ -82,7 +86,7 @@
}
})
sendUserToast('User updated')
listUsers()
listUsers(activeOnly)
} catch (e) {
sendUserToast('Error updating user', true)
}
@@ -96,7 +100,12 @@
f={(x) => x.email + ' ' + x.name + ' ' + x.company}
/>
<Drawer bind:this={drawer} on:open={listUsers} size="1200px" on:close={removeHash}>
<Drawer
bind:this={drawer}
on:open={() => listUsers(activeOnly)}
size="1200px"
on:close={removeHash}
>
<DrawerContent overflow_y={true} title="Instance settings" on:close={closeDrawer}>
<div class="flex flex-col h-full w-full">
<div>
@@ -130,11 +139,16 @@
<div class="h-full">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
<h3 class="mb-2">
Automatic username creation
</h3>
<h3 class="mb-2"> Automatic username creation </h3>
<div class="mb-2">
<span class="text-primary text-sm">Automatically create a username for new users based on their email, shared across workspaces. <a target="_blank" href="https://www.windmill.dev/docs/advanced/instance_settings#automatic-username-creation">Learn more</a></span>
<span class="text-primary text-sm"
>Automatically create a username for new users based on their email, shared
across workspaces. <a
target="_blank"
href="https://www.windmill.dev/docs/advanced/instance_settings#automatic-username-creation"
>Learn more</a
></span
>
</div>
<Button
btnClasses="w-auto"
@@ -164,10 +178,20 @@
{/if}
<div class="py-2 mb-4">
<InviteGlobalUser on:new={listUsers} />
<InviteGlobalUser on:new={() => listUsers(activeOnly)} />
</div>
<h3>All instance users</h3>
<div class="flex flex-row justify-between">
<h3>All instance users</h3>
<Toggle
bind:checked={activeOnly}
options={{
left: 'Show active users only',
leftTooltip:
'An active user is a user who has performed at least one action in the last 30 days'
}}
/>
</div>
<div class="pb-1" />
<div>
<input placeholder="Search users" bind:value={filter} class="input mt-1" />
@@ -181,12 +205,15 @@
{#if automateUsernameCreation}
<th>username</th>
{/if}
{#if activeOnly}
<th>kind</th>
{/if}
<th />
<th />
</tr>
<tbody slot="body" class="overflow-y-auto w-full h-full max-h-full">
{#if filteredUsers && users}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, login_type, name, username } (email)}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, login_type, name, username, operator_only } (email)}
<tr class="border">
<td>{email}</td>
<td>{login_type}</td>
@@ -203,13 +230,22 @@
{email}
isConflict
on:renamed={() => {
listUsers()
listUsers(activeOnly)
}}
/>
{/key}
{/if}
</td>
{/if}
{#if activeOnly}
<td>
{#if operator_only}
Operator only
{:else}
Developer
{/if}
</td>
{/if}
<td>
<ToggleButtonGroup
selected={super_admin}
@@ -217,7 +253,7 @@
console.log('BAR')
if (email == $userStore?.email) {
sendUserToast('You cannot demote yourself', true)
listUsers()
listUsers(activeOnly)
return
}
await UserService.globalUserUpdate({
@@ -227,7 +263,7 @@
}
})
sendUserToast('User updated')
listUsers()
listUsers(activeOnly)
}}
>
<ToggleButton value={false} size="xs" label="User" />
@@ -244,7 +280,7 @@
updateName(e.detail, email)
}}
on:renamed={() => {
listUsers()
listUsers(activeOnly)
}}
{automateUsernameCreation}
/>
@@ -258,7 +294,7 @@
deleteConfirmedCallback = async () => {
await UserService.globalUserDelete({ email })
sendUserToast(`User ${email} removed`)
listUsers()
listUsers(activeOnly)
}
}}
>
@@ -1,6 +1,6 @@
<script lang="ts">
import { Copy, Plus, RefreshCcwIcon, Settings, Trash, X } from 'lucide-svelte'
import { Alert, Button, Drawer } from './common'
import { Alert, Badge, Button, Drawer } from './common'
import Multiselect from 'svelte-multiselect'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -18,6 +18,8 @@
import AutoComplete from 'simple-svelte-autocomplete'
import YAML from 'yaml'
import Toggle from './Toggle.svelte'
import type { AutoscalingConfig } from './worker_group'
import AutoscalingConfigEditor from './AutoscalingConfigEditor.svelte'
export let name: string
export let config:
@@ -31,6 +33,7 @@
additional_python_paths?: string[]
pip_local_dependencies?: string[]
min_alive_workers_alert_threshold?: number
autoscaling?: AutoscalingConfig
}
export let activeWorkers: number
export let customTags: string[] | undefined
@@ -65,6 +68,7 @@
additional_python_paths?: string[]
pip_local_dependencies?: string[]
min_alive_workers_alert_threshold?: number
autoscaling?: AutoscalingConfig
} = {}
function loadNConfig() {
@@ -438,7 +442,7 @@
</Tooltip>
</svelte:fragment>
<Multiselect
outerDivClass="text-secondary !bg-surface-disabled"
outerDivClass="text-secondary !bg-surface-disabled !border-0"
disabled={!$enterpriseLicense}
bind:selected={selectedPriorityTags}
on:change={(e) => {
@@ -452,6 +456,9 @@
delete nconfig.priority_tags[e.detail.option]
}
dirty = true
} else if (e.detail.type === 'removeAll') {
nconfig.priority_tags = undefined
dirty = true
} else {
console.error(
`Priority tags multiselect - unknown event type: '${e.detail.type}'`
@@ -782,6 +789,21 @@
</Section>
<div class="mt-8" />
<Section label="Autoscaling" collapsable>
<div slot="header" class="ml-4 flex flex-row gap-2 items-center">
<Badge>Beta</Badge>
{#if nconfig.autoscaling?.enabled}
<Badge color="green">Enabled</Badge>
{/if}
</div>
<AutoscalingConfigEditor
on:dirty={() => (dirty = true)}
worker_tags={nconfig.worker_tags}
bind:config={nconfig.autoscaling}
/>
</Section>
<div class="mt-8" />
<Section
label="Init Script"
tooltip="Bash scripts run at start of the workers. More lightweight than having to require the worker images at the cost of being run on every start."
@@ -27,28 +27,29 @@ export type ColumnMetadata = {
export type TableMetadata = ColumnMetadata[]
export type ColumnDef = {
minWidth: number
hide: boolean
flex: number
sort: 'asc' | 'desc'
sortIndex: number
aggFunc: string
pivot: boolean
pivotIndex: number
pinned: 'left' | 'right' | boolean
rowGroup: boolean
rowGroupIndex: number
valueFormatter: string
valueParser: string
field: string
headerName: string
minWidth?: number
hide?: boolean
flex?: number
sort?: 'asc' | 'desc'
sortIndex?: number
aggFunc?: string
pivot?: boolean
pivotIndex?: number
pinned?: 'left' | 'right' | boolean
rowGroup?: boolean
rowGroupIndex?: number
valueFormatter?: string
valueParser?: string
field?: string
headerName?: string
children?: ColumnDef[]
// DBExplorer
ignored: boolean
hideInsert: boolean
editable: boolean
overrideDefaultValue: boolean
defaultUserValue: any
defaultValueNull: boolean
ignored?: boolean
hideInsert?: boolean
editable?: boolean
overrideDefaultValue?: boolean
defaultUserValue?: any
defaultValueNull?: boolean
} & ColumnMetadata
export async function loadTableMetaData(
@@ -150,7 +150,7 @@
outputs?.inputs.set(inputs, true)
},
onRemove: (id, rowIndex) => {
if (inputs?.[id] == undefined) {
if (inputs?.[id] == undefined) {
return
}
delete inputs[id][rowIndex]
@@ -267,10 +267,24 @@
// Validate each column definition
columnDefs.forEach((colDef, index) => {
let noField = !colDef.field || typeof colDef.field !== 'string' || colDef.field.trim() === ''
// Check if 'field' property exists and is a non-empty string
if (!colDef.field || typeof colDef.field !== 'string' || colDef.field.trim() === '') {
if (noField && !(colDef.children && Array.isArray(colDef.children))) {
isValid = false
errors.push(`Column at index ${index} is missing a valid 'field' property.`)
errors.push(
`Column at index ${index} is missing a valid 'field' property nor having any children.`
)
}
if (colDef.children && Array.isArray(colDef.children)) {
const { isValid: isChildrenValid, errors: childrenErrors } = validateColumnDefs(
colDef.children
)
if (!isChildrenValid) {
isValid = false
errors.push(...childrenErrors.map((err) => `Error in children at index ${index}: ${err}`))
}
}
})
@@ -310,6 +324,7 @@
: false,
initialState: state,
suppressRowDeselection: true,
enableCellTextSelection: true,
...(resolvedConfig?.extraConfig ?? {}),
onViewportChanged: (e) => {
firstRow = e.firstRow
@@ -334,6 +334,7 @@
initialState: state,
suppressRowDeselection: true,
suppressDragLeaveHidesColumns: true,
enableCellTextSelection: true,
...(resolvedConfig?.extraConfig ?? {}),
onStateUpdated: (e) => {
state = e?.api?.getState()
@@ -368,18 +368,17 @@
}
async function handleUpdateApp(npath: string) {
// We have to make sure there is no updates when we clicked the button
await compareVersions();
await compareVersions()
if (onLatest) {
// Handle directly
await updateApp(npath)
} else {
// There is onLatest, but we need more information while deploying
// We need it to show diff
// There is onLatest, but we need more information while deploying
// We need it to show diff
// Handle through confirmation modal
await syncWithDeployed();
await syncWithDeployed()
confirmCallback = async () => {
open = false
@@ -628,13 +627,18 @@
if (version === undefined) {
return
}
const appVersion = await AppService.getAppLatestVersion({
workspace: $workspaceStore!,
path: appPath
})
onLatest = version === appVersion?.version
try {
const appVersion = await AppService.getAppLatestVersion({
workspace: $workspaceStore!,
path: appPath
})
onLatest = version === appVersion?.version
} catch (e) {
console.error('Error comparing versions', e)
onLatest = true
}
}
$: saveDrawerOpen && compareVersions()
let selectedJobId: string | undefined = undefined
@@ -764,7 +768,7 @@
}
// deployedValue should be syncronized when we open Diff
await syncWithDeployed();
await syncWithDeployed()
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
@@ -955,7 +959,7 @@
return
}
// deployedValue should be syncronized when we open Diff
await syncWithDeployed();
await syncWithDeployed()
saveDrawerOpen = false
diffDrawer?.openDrawer()
@@ -218,7 +218,7 @@
<svelte:window on:hashchange={hashchange} on:resize={resizeWindow} />
<div class="relative h-full grow" bind:clientHeight={appHeight}>
<div class="relative min-h-full grow" bind:clientHeight={appHeight}>
<div id="app-editor-top-level-drawer" />
<div id="app-editor-select" />
@@ -1,7 +1,6 @@
<script lang="ts">
import { Tabs, Tab, TabContent } from '$lib/components/common'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import HighlightTheme from '../HighlightTheme.svelte'
import FlowViewerInner from '../FlowViewerInner.svelte'
import DetailPageTriggerPanel from './DetailPageTriggerPanel.svelte'
@@ -22,51 +21,48 @@
<HighlightTheme />
<Splitpanes horizontal class="h-full">
<Pane size={100}>
<Tabs bind:selected>
<Tab value="saved_inputs">Saved Inputs</Tab>
{#if !isOperator}
<Tab value="triggers">Triggers</Tab>
{/if}
{#if flow_json}
<Tab value="raw">Export</Tab>
{:else}
<Tab value="script">Script</Tab>
{/if}
{#if selected == 'flow_step'}
<Tab value="flow_step">Step</Tab>
{/if}
<Tabs bind:selected>
<Tab value="saved_inputs">Saved Inputs</Tab>
{#if !isOperator}
<Tab value="triggers">Triggers</Tab>
{/if}
{#if flow_json}
<Tab value="raw">Export</Tab>
{:else}
<Tab value="script">Script</Tab>
{/if}
{#if selected == 'flow_step'}
<Tab value="flow_step">Step</Tab>
{/if}
<svelte:fragment slot="content">
<div class="overflow-hidden" style="height:calc(100% - 32px);">
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="schema" class="flex flex-col flex-1 h-full">
<slot name="schema" />
</TabContent>
<TabContent value="script" class="flex flex-col flex-1 h-full">
<slot name="script" />
</TabContent>
<TabContent value="triggers" class="flex flex-col flex-1 h-full pt-2">
<DetailPageTriggerPanel bind:triggerSelected>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="websockets" name="websockets" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
</TabContent>
<svelte:fragment slot="content">
<div class="h-[calc(100%-32px)]">
<div class="h-full overflow-auto">
<TabContent value="saved_inputs" class="h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="script" class="h-full">
<slot name="script" />
</TabContent>
<TabContent value="triggers" class="h-full pt-2">
<DetailPageTriggerPanel bind:triggerSelected>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="websockets" name="websockets" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
</TabContent>
{#if flow_json}
<TabContent value="raw" class="flex flex-col flex-1 h-full overflow-auto p-2">
<FlowViewerInner flow={flow_json} />
</TabContent>
<TabContent value="flow_step" class="flex flex-col flex-1 h-full">
<slot name="flow_step" />
</TabContent>
</div>
</svelte:fragment>
</Tabs>
</Pane>
</Splitpanes>
{/if}
</div>
</div>
</svelte:fragment>
</Tabs>
@@ -61,7 +61,7 @@
</SplitPanesWrapper>
</div>
{:else}
<div class="h-full">
<div class="h-full w-full">
<slot name="header" />
<Tabs bind:selected={mobileTab}>
<Tab value="form">Run form</Tab>
@@ -74,27 +74,29 @@
{/if}
<svelte:fragment slot="content">
<TabContent value="form" class="flex flex-col flex-1 h-full">
<slot name="form" />
</TabContent>
<div class="h-full">
<TabContent value="form" class="flex flex-col flex-1 h-full">
<slot name="form" />
</TabContent>
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="triggers" class="flex flex-col flex-1 h-full">
<DetailPageTriggerPanel bind:triggerSelected={$selectedTriggerStore}>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="script" name="script" />
<slot slot="websockets" name="websockets" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
</TabContent>
<TabContent value="script" class="flex flex-col flex-1 h-full">
<slot name="script" />
</TabContent>
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="triggers" class="flex flex-col flex-1 h-full">
<DetailPageTriggerPanel bind:triggerSelected={$selectedTriggerStore}>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="script" name="script" />
<slot slot="websockets" name="websockets" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
</TabContent>
<TabContent value="script" class="flex flex-col flex-1 h-full">
<slot name="script" />
</TabContent>
</div>
</svelte:fragment>
</Tabs>
</div>
@@ -33,7 +33,7 @@
{#if tag}
<option value="">reset to default</option>
{:else}
<option value="" disabled selected>Tag</option>
<option value={undefined} disabled selected>Tag</option>
{/if}
{#each $workerTags ?? [] as tag (tag)}
<option value={tag}>{tag}</option>
@@ -441,7 +441,7 @@
}}
options={{
right: `Label as high priority`,
rightTooltip: `All jobs scheduled by flows labeled as high priority take precedence over the other jobs in the jobs queue. ${
rightTooltip: `All jobs scheduled by flows labeled as high priority take precedence over the other jobs in the jobs queue. Higher priority numbers are executed first. ${
!$enterpriseLicense ? 'This is a feature only available on enterprise edition.' : ''
}`
}}
@@ -110,7 +110,7 @@
}
if (kind == 'approval') {
module.suspend = { required_events: 1 }
module.suspend = { required_events: 1, timeout: 1800 }
} else if (kind == 'trigger') {
module.stop_after_if = {
expr: '!result || (Array.isArray(result) && result.length == 0)',
@@ -0,0 +1,9 @@
<script lang="ts">
export let height = '24px'
export let width = '24px'
</script>
<svg {width} {height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
<path fill="#1ed760" d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8Z"/>
<path d="M406.6 231.1c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3zm-31 76.2c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm-26.9 65.6c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4z"/>
</svg>
+3 -1
View File
@@ -91,6 +91,7 @@ import VismaIcon from './VismaIcon.svelte'
import JumpCloudIcon from './JumpCloudIcon.svelte'
import KeycloakIcon from './KeycloakIcon.svelte'
import ZitadelIcon from './ZitadelIcon.svelte'
import SpotifyIcon from './SpotifyIcon.svelte'
export const APP_TO_ICON_COMPONENT = {
postgresql: PostgresIcon,
@@ -188,7 +189,8 @@ export const APP_TO_ICON_COMPONENT = {
visma: VismaIcon,
jumpcloud: JumpCloudIcon,
keycloak: KeycloakIcon,
zitadel: ZitadelIcon
zitadel: ZitadelIcon,
spotify: SpotifyIcon
} as const
export {
+9
View File
@@ -0,0 +1,9 @@
import { deepEqual } from 'fast-equals'
export function isWindmillTooBigObject(obj: any): boolean {
return (
typeof obj === 'object' &&
deepEqual(Object.keys(obj), ['reason']) &&
obj['reason'] == 'WINDMILL_TOO_BIG'
)
}
@@ -0,0 +1,39 @@
// enabled: bool,
// min_workers: usize,
// max_workers: usize,
// cooldown_seconds: usize,
// inc_scale_jobs_waiting: usize,
// full_scale_cooldown_seconds: usize,
// full_scale_jobs_waiting: usize,
// dec_scale_occupancy_rate: u8, // occupancy rate of 30s, 5m, 30m to scale down
// inc_scale_occupancy_rate: u8, // occupancy rate of 30s, 5m to scale up
// inc_percent: usize,
// integration: Option<AutoscalingIntegration>,
export type AutoscalingConfig = {
enabled: boolean
min_workers: number
max_workers: number
cooldown_seconds?: number
inc_scale_num_jobs_waiting?: number
full_scale_cooldown_seconds?: number
full_scale_jobs_waiting?: number
dec_scale_occupancy_rate?: number
inc_scale_occupancy_rate?: number
inc_num_workers?: number
integration?: AutoscalingIntegration
custom_tags?: string[]
}
export type AutoscalingIntegration = AutoscaleScript | AutoscaleDryRun
export type AutoscaleScript = {
type: 'script'
tag?: string
path: string
}
export type AutoscaleDryRun = {
type: 'dryrun'
}
+3 -1
View File
@@ -635,9 +635,11 @@ inventory:
# File resources will be written in the relative \`target\` location before
# running the playbook
# file_resources:
# files:
# - resource: u/user/fabulous_jinja_template
# target: ./config_template.j2
# - variable: u/user/ssh_key
# target: ./ssh_key
# Define the arguments of the windmill script
extra_vars:
+3 -3
View File
@@ -472,7 +472,7 @@ export async function copyToClipboard(value?: string, sendToast = true): Promise
}
export function pluralize(quantity: number, word: string, customPlural?: string) {
if (quantity <= 1) {
if (quantity == 1) {
return `${quantity} ${word}`
} else if (customPlural) {
return `${quantity} ${customPlural}}`
@@ -498,7 +498,7 @@ export function isObject(obj: any) {
export function debounce(func: (...args: any[]) => any, wait: number) {
let timeout: any
return function(...args: any[]) {
return function (...args: any[]) {
// @ts-ignore
const context = this
clearTimeout(timeout)
@@ -508,7 +508,7 @@ export function debounce(func: (...args: any[]) => any, wait: number) {
export function throttle<T>(func: (...args: any[]) => T, wait: number) {
let timeout: any
return function(...args: any[]) {
return function (...args: any[]) {
if (!timeout) {
timeout = setTimeout(() => {
timeout = null
@@ -9,7 +9,8 @@
type WorkflowStatus,
type NewScript,
ConcurrencyGroupsService,
MetricsService
MetricsService,
type ScriptArgs
} from '$lib/gen'
import {
canWrite,
@@ -91,6 +92,8 @@
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
import ExecutionDuration from '$lib/components/ExecutionDuration.svelte'
import CustomPopover from '$lib/components/CustomPopover.svelte'
import { isWindmillTooBigObject } from '$lib/components/job_args'
let job: Job | undefined
let jobUpdateLastFetch: Date | undefined
@@ -306,6 +309,46 @@
}
let scheduleEditor: ScheduleEditor
let runImmediatelyLoading = false
async function runImmediately() {
runImmediatelyLoading = true
try {
let args = job?.args as ScriptArgs
if (isWindmillTooBigObject(args)) {
args = (await JobService.getJobArgs({
workspace: $workspaceStore!,
id: job?.id!
})) as ScriptArgs
}
const commonArgs = {
workspace: $workspaceStore!,
requestBody: args
}
if (job?.job_kind == 'script' || job?.job_kind == 'flow') {
let id
if (job?.job_kind == 'script') {
id = await JobService.runScriptByHash({
...commonArgs,
hash: job.script_hash!
})
} else {
id = await JobService.runFlowByPath({
...commonArgs,
path: job.script_path!
})
}
await goto('/run/' + id + '?workspace=' + $workspaceStore)
} else {
sendUserToast('Cannot run this job immediately', true)
}
} finally {
runImmediatelyLoading = false
}
}
</script>
<HighlightTheme />
@@ -595,14 +638,23 @@
{/if}
{/if}
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
<Button
on:click|once={() => {
goto(viewHref + `#${computeSharableHash(job?.args)}`)
}}
color="blue"
size="sm"
startIcon={{ icon: RefreshCw }}>Run again</Button
>
<CustomPopover noPadding appearTimeout={0}>
<Button
on:click|once={() => {
goto(viewHref + `#${computeSharableHash(job?.args)}`)
}}
color="blue"
size="sm"
startIcon={{ icon: RefreshCw }}>Run again</Button
>
<svelte:fragment slot="overlay">
<div class="flex flex-row gap-2">
<Button size="xs" loading={runImmediatelyLoading} on:click={() => runImmediately()}>
Run immediately with same args
</Button>
</div>
</svelte:fragment>
</CustomPopover>
{/if}
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
{#if !$userStore?.operator}
@@ -742,7 +742,7 @@
</div>
</svelte:fragment>
<svelte:fragment slot="script">
<div>
<div class="h-full">
<Skeleton {loading} layout={[[20]]} />
<Tabs selected="code">
@@ -751,7 +751,7 @@
<Tab value="schema" size="xs">Schema</Tab>
<svelte:fragment slot="content">
<TabContent value="code">
<div class="p-2 w-full overflow-auto">
<div class="p-2 w-full">
<HighlightCode
language={script.language}
code={script.content}
@@ -785,7 +785,7 @@
</div>
</TabContent>
<TabContent value="schema">
<div class="p-1 relative">
<div class="p-1 relative h-full">
<button
on:click={() => copyToClipboard(JSON.stringify(script?.schema, null, 4))}
class="absolute top-2 right-2"
@@ -13,17 +13,18 @@
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import WorkspaceGroup from '$lib/components/WorkspaceGroup.svelte'
import WorkspaceGroup from '$lib/components/WorkerGroup.svelte'
import { WorkerService, type WorkerPing, ConfigService, SettingService } from '$lib/gen'
import { enterpriseLicense, superadmin } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { displayDate, groupBy, pluralize, truncate } from '$lib/utils'
import { AlertTriangle, CopyIcon, LineChart, List, Plus, Search } from 'lucide-svelte'
import { AlertTriangle, LineChart, List, Plus, Search } from 'lucide-svelte'
import { getContext, onDestroy, onMount } from 'svelte'
import AutoComplete from 'simple-svelte-autocomplete'
import YAML from 'yaml'
import { DEFAULT_TAGS_WORKSPACES_SETTING } from '$lib/consts'
import AutoscalingEvents from '$lib/components/AutoscalingEvents.svelte'
let workers: WorkerPing[] | undefined = undefined
let workerGroups: Record<string, any> | undefined = undefined
@@ -326,7 +327,7 @@
<p>No workers seem to be available</p>
{/if}
<div class="py-4 w-full flex justify-between"
<div class="pt-4 pb-8 w-full flex justify-between items-center"
><h4
>{groupWorkers?.length} Worker Groups <Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups"
@@ -336,6 +337,7 @@
</Tooltip></h4
>
<div />
{#if $superadmin}
<div class="flex flex-row items-center">
<Popup
@@ -344,31 +346,30 @@
>
<svelte:fragment slot="button">
<div class="flex items-center gap-2">
<Button
size="sm"
color="light"
startIcon={{ icon: CopyIcon }}
on:click={() => {
if (!workerGroups) {
return sendUserToast('No worker groups found', true)
}
const workersConfig = Object.entries(workerGroups).map(([name, config]) => ({
name,
...config
}))
navigator.clipboard.writeText(YAML.stringify(workersConfig))
sendUserToast('Worker groups config copied to clipboard as YAML')
}}
>
<span class="hidden md:block">Copy groups config</span>
</Button>
<Button
size="sm"
startIcon={{ icon: Plus }}
nonCaptureEvent
disabled={!$enterpriseLicense}
dropdownItems={$enterpriseLicense
? [
{
label: 'Copy groups config as YAML',
onClick: () => {
if (!workerGroups) {
return sendUserToast('No worker groups found', true)
}
const workersConfig = Object.entries(workerGroups).map(
([name, config]) => ({
name,
...config
})
)
navigator.clipboard.writeText(YAML.stringify(workersConfig))
sendUserToast('Worker groups config copied to clipboard as YAML')
}
},
{
label: 'Import groups config from YAML',
onClick: () => {
@@ -378,7 +379,9 @@
]
: undefined}
>
<span class="hidden md:block">New group config</span>
<span class="hidden md:block"
>New group config {!$enterpriseLicense ? '(EE)' : ''}</span
>
<Tooltip light>
Worker Group configs are propagated to every workers in the worker group
@@ -644,7 +647,8 @@
{/if}
{/if}
</div>
<div class="pb-4" />
<div class="pb-20" />
<AutoscalingEvents worker_group={selectedTab} />
{:else}
<div class="flex flex-col">
{#each new Array(4) as _}
@@ -137,7 +137,7 @@
{#key app}
<div
class={twMerge(
'min-h-screen h-full w-full',
'min-h-screen h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
+2 -2
View File
@@ -4,8 +4,8 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.411.1"
wmill_pg = ">=1.411.1"
wmill = ">=1.414.1"
wmill_pg = ">=1.414.1"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.411.1
version: 1.414.1
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.411.1'
ModuleVersion = '1.414.1'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.411.1"
version = "1.414.1"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill-pg"
version = "1.411.1"
version = "1.414.1"
description = "An extension client for the wmill client library focused on pg"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.411.1",
"version": "1.414.1",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.411.1",
"version": "1.414.1",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"devDependencies": {
+1 -1
View File
@@ -1 +1 @@
1.411.1
1.414.1