Merge remote-tracking branch 'upstream/main' into appeditor-multiselect-input-type

This commit is contained in:
Clement Zhang
2024-11-05 10:29:22 +01:00
91 changed files with 2314 additions and 942 deletions
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## [1.418.0](https://github.com/windmill-labs/windmill/compare/v1.417.3...v1.418.0) (2024-11-04)
### Features
* **frontend:** improve and simplify scheduled poll flows ([#4560](https://github.com/windmill-labs/windmill/issues/4560)) ([22ab51e](https://github.com/windmill-labs/windmill/commit/22ab51e9914376b47827f0be66708deef3a37767))
* **oauth:** add snowflake oauth support ([#4622](https://github.com/windmill-labs/windmill/issues/4622)) ([693b7a4](https://github.com/windmill-labs/windmill/commit/693b7a4fd4da0e2b14cb6c8ea19537aa600225be))
### Bug Fixes
* **prometheus:** improve queue_count when tags have no more jobs ([09156b6](https://github.com/windmill-labs/windmill/commit/09156b65c2a0f0ac59fce1dede2b68b790323c49))
## [1.417.3](https://github.com/windmill-labs/windmill/compare/v1.417.2...v1.417.3) (2024-11-04)
### Bug Fixes
* **cli:** improve pulling instance with folderPerInstance ([1e7909e](https://github.com/windmill-labs/windmill/commit/1e7909e95931e1e121cb3767bad093239254935d))
## [1.417.2](https://github.com/windmill-labs/windmill/compare/v1.417.1...v1.417.2) (2024-11-04)
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n hostname,\n mode::text,\n worker_group,\n log_ts,\n file_path,\n ok_lines,\n err_lines,\n json_fmt\n FROM log_file\n WHERE log_ts > $1\n ORDER BY log_ts ASC LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hostname",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "mode",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "worker_group",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "log_ts",
"type_info": "Timestamp"
},
{
"ordinal": 4,
"name": "file_path",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "ok_lines",
"type_info": "Int8"
},
{
"ordinal": 6,
"name": "err_lines",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "json_fmt",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Timestamp",
"Int8"
]
},
"nullable": [
false,
null,
true,
false,
false,
true,
true,
true
]
},
"hash": "b5c839baab25c4dcdd503d380cf7a886242277cd50555f20b2e22e13942d2a3a"
}
@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n hostname,\n mode::text,\n worker_group,\n log_ts,\n file_path,\n ok_lines,\n err_lines,\n json_fmt\n FROM log_file\n ORDER BY log_ts ASC LIMIT $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hostname",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "mode",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "worker_group",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "log_ts",
"type_info": "Timestamp"
},
{
"ordinal": 4,
"name": "file_path",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "ok_lines",
"type_info": "Int8"
},
{
"ordinal": 6,
"name": "err_lines",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "json_fmt",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
null,
true,
false,
false,
true,
true,
true
]
},
"hash": "dd967c5983fa0ff05e2b320ad0e0b5a152784826cb8fb4381c1ffe228cb7feb6"
}
+34 -34
View File
@@ -117,9 +117,9 @@ dependencies = [
[[package]]
name = "anstream"
version = "0.6.17"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338"
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -3987,9 +3987,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.15.0"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb"
checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3"
dependencies = [
"allocator-api2",
"equivalent",
@@ -4535,7 +4535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da"
dependencies = [
"equivalent",
"hashbrown 0.15.0",
"hashbrown 0.15.1",
"serde",
]
@@ -4942,7 +4942,7 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.0",
"hashbrown 0.15.1",
]
[[package]]
@@ -9124,18 +9124,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.67"
version = "1.0.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3c6efbfc763e64eb85c11c25320f0737cb7364c4b6336db90aa9ebe27a0bbd"
checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.67"
version = "1.0.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b607164372e89797d78b8e23a6d67d5d1038c1c65efd52e1389ef8b77caba2a6"
checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e"
dependencies = [
"proc-macro2",
"quote",
@@ -10563,7 +10563,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"axum",
@@ -10605,7 +10605,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"argon2",
@@ -10690,7 +10690,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -10708,7 +10708,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"chrono",
"serde",
@@ -10721,7 +10721,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"rsmq_async",
@@ -10736,7 +10736,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"async-stream",
@@ -10782,7 +10782,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"regex",
"rsmq_async",
@@ -10797,7 +10797,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"bytes",
@@ -10819,7 +10819,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"itertools 0.13.0",
"lazy_static",
@@ -10831,7 +10831,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -10840,7 +10840,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -10852,7 +10852,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"gosyn",
@@ -10864,7 +10864,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -10876,7 +10876,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"itertools 0.13.0",
@@ -10887,7 +10887,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"itertools 0.13.0",
@@ -10898,7 +10898,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -10916,7 +10916,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -10933,7 +10933,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -10945,7 +10945,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -10963,7 +10963,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"getrandom 0.2.15",
@@ -10984,7 +10984,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"serde_json",
@@ -10994,7 +10994,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -11027,7 +11027,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -11037,7 +11037,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.417.2"
version = "1.418.0"
dependencies = [
"anyhow",
"async-recursion",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.417.2"
version = "1.418.0"
authors.workspace = true
edition.workspace = true
@@ -29,7 +29,7 @@ members = [
]
[workspace.package]
version = "1.417.2"
version = "1.418.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
f136a2f499e0fe7c10c54c79488851980d796eb2
01f384348341c87c5a0b32c4be7c699975ee7d6d
+3 -2
View File
@@ -171,5 +171,6 @@
"user-library-modify",
"user-library-read"
]
}
}
},
"snowflake_oauth": {}
}
+42 -6
View File
@@ -531,7 +531,7 @@ Windmill Community Edition {GIT_VERSION}
#[cfg(feature = "tantivy")]
let (index_reader, index_writer) = if should_index_jobs {
let (r, w) = windmill_indexer::indexer_ee::init_index(&db).await?;
let (r, w) = windmill_indexer::completed_runs_ee::init_index(&db).await?;
(Some(r), Some(w))
} else {
(None, None)
@@ -543,26 +543,61 @@ Windmill Community Edition {GIT_VERSION}
let index_writer2 = index_writer.clone();
async {
if let Some(index_writer) = index_writer2 {
windmill_indexer::indexer_ee::run_indexer(db.clone(), index_writer, indexer_rx)
.await;
windmill_indexer::completed_runs_ee::run_indexer(
db.clone(),
index_writer,
indexer_rx,
)
.await;
}
Ok(())
}
};
#[cfg(all(feature = "tantivy", feature = "parquet"))]
let (log_index_reader, log_index_writer) = if should_index_jobs {
let (r, w) = windmill_indexer::service_logs_ee::init_index(&db).await?;
(Some(r), Some(w))
} else {
(None, None)
};
#[cfg(all(feature = "tantivy", feature = "parquet"))]
let log_indexer_f = {
let log_indexer_rx = killpill_rx.resubscribe();
let log_index_writer2 = log_index_writer.clone();
async {
if let Some(log_index_writer) = log_index_writer2 {
windmill_indexer::service_logs_ee::run_indexer(
db.clone(),
log_index_writer,
log_indexer_rx,
)
.await;
}
Ok(())
}
};
#[cfg(not(feature = "tantivy"))]
let (index_reader, index_writer) = (None, None);
let index_reader = None;
#[cfg(not(feature = "tantivy"))]
let indexer_f = async { Ok(()) as anyhow::Result<()> };
#[cfg(not(all(feature = "tantivy", feature = "parquet")))]
let log_index_reader = None;
#[cfg(not(all(feature = "tantivy", feature = "parquet")))]
let log_indexer_f = async { Ok(()) as anyhow::Result<()> };
let server_f = async {
if !is_agent {
windmill_api::run_server(
db.clone(),
rsmq2,
index_reader,
index_writer,
log_index_reader,
addr,
server_killpill_rx,
base_internal_tx,
@@ -842,7 +877,8 @@ Windmill Community Edition {GIT_VERSION}
monitor_f,
server_f,
metrics_f,
indexer_f
indexer_f,
log_indexer_f
)?;
} else {
tracing::info!("Nothing to do, exiting.");
+18
View File
@@ -111,6 +111,9 @@ lazy_static::lazy_static! {
"Number of jobs in the queue",
&["tag"]
).unwrap();
static ref QUEUE_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
}
pub async fn initial_load(
@@ -1089,12 +1092,23 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
if metrics_enabled || save_metrics {
let queue_counts = windmill_common::queue::get_queue_counts(db).await;
if metrics_enabled {
for q in QUEUE_COUNT_TAGS.read().await.iter() {
if queue_counts.get(q).is_none() {
(*QUEUE_COUNT).with_label_values(&[q]).set(0);
}
}
}
let mut tags_to_watch = vec![];
for q in queue_counts {
let count = q.1;
let tag = q.0;
if metrics_enabled {
let metric = (*QUEUE_COUNT).with_label_values(&[&tag]);
metric.set(count as i64);
tags_to_watch.push(tag.to_string());
}
// save queue_count and delay metrics per tag
@@ -1119,6 +1133,10 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
}
}
}
if metrics_enabled {
let mut w = QUEUE_COUNT_TAGS.write().await;
*w = tags_to_watch;
}
}
// clean queue metrics older than 14 days
+8
View File
@@ -1112,6 +1112,7 @@ async fn test_deno_flow(db: Pool<Postgres>) {
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
}
.into(),
stop_after_if: Default::default(),
@@ -1153,6 +1154,7 @@ async fn test_deno_flow(db: Pool<Postgres>) {
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
}
.into(),
stop_after_if: Default::default(),
@@ -1276,6 +1278,8 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
}.into(),
stop_after_if: Default::default(),
stop_after_all_iters_if: Default::default(),
@@ -1327,6 +1331,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
}.into(),
stop_after_if: Default::default(),
stop_after_all_iters_if: Default::default(),
@@ -1364,6 +1369,8 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
}.into(),
stop_after_if: Default::default(),
stop_after_all_iters_if: Default::default(),
@@ -1424,6 +1431,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
}.into(),
stop_after_if: Default::default(),
stop_after_all_iters_if: Default::default(),
+111 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.417.2
version: 1.418.0
title: Windmill API
contact:
@@ -9546,6 +9546,109 @@ paths:
items:
$ref: "#/components/schemas/JobSearchHit"
/srch/index/search/service_logs:
get:
summary: Search through service logs with a string query
operationId: searchLogsIndex
tags:
- indexSearch
parameters:
- name: search_query
in: query
required: true
schema:
type: string
- name: mode
in: query
required: true
schema:
type: string
- name: worker_group
in: query
required: false
schema:
type: string
- name: hostname
in: query
required: true
schema:
type: string
- name: min_ts
in: query
required: false
schema:
type: string
format: date-time
- name: max_ts
in: query
required: false
schema:
type: string
format: date-time
responses:
"200":
description: search results
content:
application/json:
schema:
type: object
properties:
query_parse_errors:
description: a list of the terms that couldn't be parsed (and thus ignored)
type: array
items:
type: string
hits:
description: log files that matched the query
type: array
items:
$ref: "#/components/schemas/LogSearchHit"
/srch/index/search/count_service_logs:
get:
summary: Search and count the log line hits on every provided host
operationId: countSearchLogsIndex
tags:
- indexSearch
parameters:
- name: search_query
in: query
required: true
schema:
type: string
- name: hosts
in: query
required: true
schema:
type: string
- name: min_ts
in: query
required: false
schema:
type: string
format: date-time
- name: max_ts
in: query
required: false
schema:
type: string
format: date-time
responses:
"200":
description: search results
content:
application/json:
schema:
type: object
properties:
query_parse_errors:
description: a list of the terms that couldn't be parsed (and thus ignored)
type: array
items:
type: string
count_per_host:
description: count of log lines that matched the query per hostname
type: object
components:
securitySchemes:
@@ -12500,6 +12603,12 @@ components:
dancer:
type: string
LogSearchHit:
type: object
properties:
dancer:
type: string
AutoscalingEvent:
type: object
properties:
@@ -12517,3 +12626,4 @@ components:
applied_at:
type: string
format: date-time
+3
View File
@@ -1209,6 +1209,7 @@ mod tests {
.into(),
hash: None,
tag_override: None,
is_trigger: None,
}),
stop_after_if: None,
stop_after_all_iters_if: None,
@@ -1236,6 +1237,7 @@ mod tests {
custom_concurrency_key: None,
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
}),
stop_after_if: Some(StopAfterIf {
expr: "foo = 'bar'".to_string(),
@@ -1290,6 +1292,7 @@ mod tests {
input_transforms: HashMap::new(),
hash: None,
tag_override: None,
is_trigger: None,
}
.into(),
stop_after_if: Some(StopAfterIf {
+4
View File
@@ -3,3 +3,7 @@ use axum::Router;
pub fn workspaced_service() -> Router {
Router::new()
}
pub fn global_service() -> Router {
Router::new()
}
+12 -7
View File
@@ -154,18 +154,18 @@ pub async fn add_webhook_allowed_origin(
type IndexReader = ();
#[cfg(not(feature = "tantivy"))]
type IndexWriter = ();
type ServiceLogIndexReader = ();
#[cfg(feature = "tantivy")]
type IndexReader = windmill_indexer::indexer_ee::IndexReader;
type IndexReader = windmill_indexer::completed_runs_ee::IndexReader;
#[cfg(feature = "tantivy")]
type IndexWriter = windmill_indexer::indexer_ee::IndexWriter;
type ServiceLogIndexReader = windmill_indexer::service_logs_ee::ServiceLogIndexReader;
pub async fn run_server(
db: DB,
rsmq: Option<rsmq_async::MultiplexedRsmq>,
index_reader: Option<IndexReader>,
index_writer: Option<IndexWriter>,
job_index_reader: Option<IndexReader>,
log_index_reader: Option<ServiceLogIndexReader>,
addr: SocketAddr,
mut rx: tokio::sync::broadcast::Receiver<()>,
port_tx: tokio::sync::oneshot::Sender<String>,
@@ -205,8 +205,9 @@ pub async fn run_server(
.layer(Extension(rsmq.clone()))
.layer(Extension(user_db.clone()))
.layer(Extension(auth_cache.clone()))
.layer(Extension(index_reader))
.layer(Extension(index_writer))
.layer(Extension(job_index_reader))
.layer(Extension(log_index_reader))
// .layer(Extension(index_writer))
.layer(CookieManagerLayer::new())
.layer(Extension(WebhookShared::new(rx.resubscribe(), db.clone())))
.layer(DefaultBodyLimit::max(
@@ -322,6 +323,10 @@ pub async fn run_server(
"/srch/w/:workspace_id/index",
indexer_ee::workspaced_service(),
)
.nest(
"/srch/index",
indexer_ee::global_service(),
)
.nest("/oidc", oidc_ee::global_service())
.nest(
"/saml",
+7
View File
@@ -426,6 +426,8 @@ pub enum FlowModuleValue {
hash: Option<ScriptHash>,
#[serde(skip_serializing_if = "Option::is_none")]
tag_override: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
is_trigger: Option<bool>,
},
Flow {
#[serde(default)]
@@ -474,6 +476,8 @@ pub enum FlowModuleValue {
concurrent_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
concurrency_time_window_s: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
is_trigger: Option<bool>,
},
Identity,
}
@@ -505,6 +509,7 @@ struct UntaggedFlowModuleValue {
custom_concurrency_key: Option<String>,
concurrent_limit: Option<i32>,
concurrency_time_window_s: Option<i32>,
is_trigger: Option<bool>,
}
impl<'de> Deserialize<'de> for FlowModuleValue {
@@ -522,6 +527,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
.ok_or_else(|| serde::de::Error::missing_field("path"))?,
hash: untagged.hash,
tag_override: untagged.tag_override,
is_trigger: untagged.is_trigger,
}),
"flow" => Ok(FlowModuleValue::Flow {
input_transforms: untagged.input_transforms.unwrap_or_default(),
@@ -574,6 +580,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
custom_concurrency_key: untagged.custom_concurrency_key,
concurrent_limit: untagged.concurrent_limit,
concurrency_time_window_s: untagged.concurrency_time_window_s,
is_trigger: untagged.is_trigger,
}),
"identity" => Ok(FlowModuleValue::Identity),
other => Err(serde::de::Error::unknown_variant(
@@ -0,0 +1,21 @@
use sqlx::{Pool, Postgres};
use windmill_common::error::Error;
use anyhow::anyhow;
#[derive(Clone)]
pub struct IndexReader;
#[derive(Clone)]
pub struct IndexWriter;
pub async fn init_index() -> Result<(IndexReader, IndexWriter), Error> {
Err(anyhow!("Cannot initialize index: not in EE").into())
}
pub async fn run_indexer(
_db: Pool<Postgres>,
mut _index_writer: IndexWriter,
mut _killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
tracing::error!("Cannot run indexer: not in EE");
}
@@ -1,21 +0,0 @@
use anyhow::anyhow;
use sqlx::{Pool, Postgres};
use windmill_common::error::Error;
#[derive(Clone)]
pub struct IndexReader;
#[derive(Clone)]
pub struct IndexWriter;
pub async fn init_index() -> Result<(IndexReader, IndexWriter), Error> {
Err(anyhow!("Cannot initialize index: not in EE").into())
}
pub async fn run_indexer(
_db: Pool<Postgres>,
mut _index_writer: IndexWriter,
mut _killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
tracing::error!("Cannot run indexer: not in EE");
}
+2
View File
@@ -1 +1,3 @@
pub mod completed_runs_ee;
pub mod service_logs_ee;
pub mod indexer_ee;
@@ -0,0 +1,21 @@
use sqlx::{Pool, Postgres};
use windmill_common::error::Error;
use anyhow::anyhow;
#[derive(Clone)]
pub struct IndexReader;
#[derive(Clone)]
pub struct IndexWriter;
pub async fn init_index() -> Result<(IndexReader, IndexWriter), Error> {
Err(anyhow!("Cannot initialize index: not in EE").into())
}
pub async fn run_indexer(
_db: Pool<Postgres>,
mut _index_writer: IndexWriter,
mut _killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
tracing::error!("Cannot run indexer: not in EE");
}
+1
View File
@@ -3522,6 +3522,7 @@ pub async fn push<'c, 'd, R: rsmq_async::RsmqConnection + Send + 'c>(
path: path.clone(),
hash: Some(hash),
tag_override: tag_override,
is_trigger: None,
},
),
stop_after_if: None,
@@ -32,9 +32,9 @@ struct Claims {
#[derive(Deserialize)]
struct SnowflakeDatabase {
account_identifier: String,
public_key: String,
private_key: String,
username: String,
public_key: Option<String>,
private_key: Option<String>,
username: Option<String>,
database: Option<String>,
schema: Option<String>,
warehouse: Option<String>,
@@ -119,6 +119,7 @@ fn do_snowflake_inner<'a>(
mut body: serde_json::Map<String, Value>,
account_identifier: &'a str,
token: &'a str,
token_is_keypair: bool,
column_order: Option<&'a mut Option<Vec<String>>>,
skip_collect: bool,
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Box<RawValue>>>> {
@@ -144,16 +145,19 @@ fn do_snowflake_inner<'a>(
}
let result_f = async move {
let result = HTTP_CLIENT
let mut request = HTTP_CLIENT
.post(format!(
"https://{}.snowflakecomputing.com/api/v2/statements/",
account_identifier.to_uppercase()
))
.bearer_auth(token)
.header("X-Snowflake-Authorization-Token-Type", "KEYPAIR_JWT")
.json(&body)
.send()
.await;
.json(&body);
if token_is_keypair {
request = request.header("X-Snowflake-Authorization-Token-Type", "KEYPAIR_JWT");
}
let result = request.send().await;
if skip_collect {
handle_snowflake_result(result).await?;
@@ -189,11 +193,17 @@ fn do_snowflake_inner<'a>(
account_identifier.to_uppercase(),
response.statementHandle
);
let response = HTTP_CLIENT
let mut request = HTTP_CLIENT
.get(url)
.bearer_auth(token)
.header("X-Snowflake-Authorization-Token-Type", "KEYPAIR_JWT")
.query(&[("partition", idx.to_string())])
.query(&[("partition", idx.to_string())]);
if token_is_keypair {
request =
request.header("X-Snowflake-Authorization-Token-Type", "KEYPAIR_JWT");
}
let response = request
.send()
.await
.parse_snowflake_response::<SnowflakeDataOnlyResponse>()
@@ -258,7 +268,7 @@ pub async fn do_snowflake(
snowflake_args.get("database").cloned()
};
let database = if let Some(db) = db_arg {
let database = if let Some(ref db) = db_arg {
serde_json::from_value::<SnowflakeDatabase>(db.clone())
.map_err(|e| Error::ExecutionErr(e.to_string()))?
} else {
@@ -267,37 +277,59 @@ pub async fn do_snowflake(
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
let qualified_username = format!(
"{}.{}",
database.account_identifier.split('.').next().unwrap_or(""), // get first part of account identifier
database.username
)
.to_uppercase();
// Check if the token is present in db_arg and use it if available
let (token, token_is_keypair) = if let Some(token) = db_arg
.as_ref()
.and_then(|db| db.get("token"))
.and_then(|t| t.as_str())
.filter(|t| !t.is_empty())
{
tracing::debug!("Using oauth token from db_arg");
(token.to_string(), false)
} else {
tracing::debug!("Generating new oauth token");
let public_key = pem::parse(database.public_key.as_bytes()).map_err(|e| {
Error::ExecutionErr(format!("Failed to parse public key: {}", e.to_string()))
})?;
let mut public_key_hash = Sha256::new();
public_key_hash.update(public_key.contents());
let qualified_username = format!(
"{}.{}",
database.account_identifier.split('.').next().unwrap_or(""),
database.username.as_deref().unwrap_or("")
)
.to_uppercase();
let public_key_fp = engine::general_purpose::STANDARD.encode(public_key_hash.finalize());
let public_key = match database.public_key.as_deref() {
Some(key) => pem::parse(key.as_bytes()).map_err(|e| {
Error::ExecutionErr(format!("Failed to parse public key: {}", e.to_string()))
})?,
None => return Err(Error::ExecutionErr("Public key is missing".to_string())),
};
let mut public_key_hash = Sha256::new();
public_key_hash.update(public_key.contents());
let iss = format!("{}.SHA256:{}", qualified_username, public_key_fp);
let public_key_fp = engine::general_purpose::STANDARD.encode(public_key_hash.finalize());
let claims = Claims {
iss: iss,
sub: qualified_username,
iat: chrono::Utc::now().timestamp(),
exp: (chrono::Utc::now() + chrono::Duration::try_hours(1).unwrap()).timestamp(),
let iss = format!("{}.SHA256:{}", qualified_username, public_key_fp);
let claims = Claims {
iss: iss,
sub: qualified_username,
iat: chrono::Utc::now().timestamp(),
exp: (chrono::Utc::now() + chrono::Duration::try_hours(1).unwrap()).timestamp(),
};
let private_key = match database.private_key.as_deref() {
Some(key) => EncodingKey::from_rsa_pem(key.as_bytes()).map_err(|e| {
Error::ExecutionErr(format!("Failed to parse private key: {}", e.to_string()))
})?,
None => return Err(Error::ExecutionErr("Private key is missing".to_string())),
};
(
encode(&Header::new(Algorithm::RS256), &claims, &private_key)
.map_err(|e| Error::ExecutionErr(e.to_string()))?,
true,
)
};
let private_key = EncodingKey::from_rsa_pem(database.private_key.as_bytes()).map_err(|e| {
Error::ExecutionErr(format!("Failed to parse private key: {}", e.to_string()))
})?;
let token = encode(&Header::new(Algorithm::RS256), &claims, &private_key)
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
tracing::debug!("Snowflake token: {}", token);
let mut body = serde_json::Map::new();
@@ -344,6 +376,7 @@ pub async fn do_snowflake(
body.clone(),
&database.account_identifier,
&token,
token_is_keypair,
None,
annotations.return_last_result && i < queries.len() - 1,
)
@@ -371,6 +404,7 @@ pub async fn do_snowflake(
body.clone(),
&database.account_identifier,
&token,
token_is_keypair,
Some(column_order),
false,
)?
@@ -735,6 +735,7 @@ async fn lock_modules<'c>(
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
} = e.get_value()?
else {
match e.get_value()? {
@@ -970,6 +971,7 @@ async fn lock_modules<'c>(
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
});
new_flow_modules.push(e);
continue;
@@ -992,6 +994,7 @@ async fn lock_modules<'c>(
custom_concurrency_key,
concurrent_limit,
concurrency_time_window_s,
is_trigger,
});
new_flow_modules.push(e);
continue;
+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.417.2";
export const VERSION = "v1.418.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+10 -6
View File
@@ -575,13 +575,14 @@ async function getLocalWorkspaces(
}
if (folderPerInstance) {
for await (const dir of Deno.readDir(rootDir + "/" + localPrefix)) {
const dirName = dir.name;
localWorkspaces.push({
dir: localPrefix + "/" + dirName,
id: dirName,
});
if (dir.isDirectory) {
const dirName = dir.name;
localWorkspaces.push({
dir: localPrefix + "/" + dirName,
id: dirName,
});
}
}
log.info(localWorkspaces);
} else {
for await (const dir of Deno.readDir(rootDir)) {
const dirName = dir.name;
@@ -593,6 +594,9 @@ async function getLocalWorkspaces(
}
}
}
log.info(
"Local workspaces found: " + localWorkspaces.map((x) => x.id).join(", ")
);
return localWorkspaces;
}
+1 -1
View File
@@ -60,7 +60,7 @@ export {
// }
// });
export const VERSION = "1.417.2";
export const VERSION = "1.418.0";
const command = new Command()
.name("wmill")
+2 -2
View File
@@ -285,8 +285,8 @@ export async function removeWorkspace(
}
return;
}
if (silent) {
log.info(colors.yellow(`Replacing existing workspace ${name}`));
if (!silent) {
log.info(colors.yellow(`Removing existing workspace ${name}`));
}
await Deno.writeTextFile(
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.417.2",
"version": "1.418.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.417.2",
"version": "1.418.0",
"license": "AGPL-3.0",
"dependencies": {
"@aws-crypto/sha256-js": "^4.0.0",
@@ -43,7 +43,7 @@
"graphql": "^16.7.1",
"hash-sum": "^2.0.0",
"highlight.js": "^11.8.0",
"lucide-svelte": "^0.293.0",
"lucide-svelte": "^0.399.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~8.0.2",
"monaco-editor-wrapper": "^5.5.2",
@@ -8607,11 +8607,11 @@
"optional": true
},
"node_modules/lucide-svelte": {
"version": "0.293.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.293.0.tgz",
"integrity": "sha512-nQ6QO6anjjiVoipw3dNptzI6NWuiArlhysVtRpoAWICSQVMR3ybwVf6piUUqbjGe+RfGufLzCl93C/yhsS+p4w==",
"version": "0.399.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.399.0.tgz",
"integrity": "sha512-NQ8AxNMKbIJsx7HV//gnAsIY1wJfb3rbXSK2S/ZDjIldvAEdzGngpUT8T8Q8zHYUuii0bavAmVARN8giR4vvpA==",
"peerDependencies": {
"svelte": ">=3 <5"
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": {
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.417.2",
"version": "1.418.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -116,7 +116,7 @@
"graphql": "^16.7.1",
"hash-sum": "^2.0.0",
"highlight.js": "^11.8.0",
"lucide-svelte": "^0.293.0",
"lucide-svelte": "^0.399.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~8.0.2",
"monaco-editor-wrapper": "^5.5.2",
@@ -182,6 +182,11 @@
"svelte": "./package/components/icons/WindmillIcon2.svelte",
"default": "./package/components/icons/WindmillIcon2.svelte"
},
"./components/icons/SchedulePollIcon.svelte": {
"types": "./package/components/icons/SchedulePollIcon.d.ts",
"svelte": "./package/components/icons/SchedulePollIcon.svelte",
"default": "./package/components/icons/SchedulePollIcon.svelte"
},
"./components/IconedResourceType.svelte": {
"types": "./package/components/IconedResourceType.svelte.d.ts",
"svelte": "./package/components/IconedResourceType.svelte",
@@ -250,6 +250,14 @@
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
if (resourceType == 'snowflake_oauth') {
const account_identifier = extra_params.find(([key, _]) => key == 'account_identifier')
if (account_identifier) {
args['account_identifier'] = account_identifier[1]
}
}
let account: number | undefined = undefined
if (valueToken?.expires_in != undefined) {
account = Number(
+3 -2
View File
@@ -476,7 +476,7 @@
const testStepStore = writable<Record<string, any>>({})
const selectedIdStore = writable('settings-metadata')
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets'
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>('webhooks')
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
@@ -484,7 +484,8 @@
setContext<TriggerContext>('TriggerContext', {
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount
triggersCount: triggersCount,
simplifiedPoll: writable(false)
})
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
+12 -5
View File
@@ -144,7 +144,7 @@
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
: undefined
)
const simplifiedPoll = writable(false)
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
loadTriggers()
@@ -468,7 +468,7 @@
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets'
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>('webhooks')
export function getSelectedId() {
@@ -490,7 +490,14 @@
}
function selectTrigger(
selectedTrigger: 'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets'
selectedTrigger:
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'scheduledPoll'
) {
selectedTriggerStore.set(selectedTrigger)
}
@@ -517,7 +524,8 @@
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount
triggersCount,
simplifiedPoll
})
async function loadTriggers() {
@@ -876,7 +884,6 @@
await tick()
select(module.id)
await tick()
await tick()
focusCopilot()
let isFirstInLoop = false
@@ -1008,6 +1008,7 @@
modules={job.raw_flow?.modules ?? []}
failureModule={job.raw_flow?.failure_module}
preprocessorModule={job.raw_flow?.preprocessor_module}
allowSimplifiedPoll={false}
/>
</div>
<div
@@ -108,9 +108,24 @@
loading = false
latestKeyRenewalAttempt = await SettingService.getLatestKeyRenewalAttempt()
// populate snowflake account identifier from db
const account_identifier =
oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier
if (account_identifier) {
snowflakeAccountIdentifier = account_identifier
}
}
export async function saveSettings() {
if (
oauths?.snowflake_oauth &&
oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !==
snowflakeAccountIdentifier
) {
setupSnowflakeUrls()
}
let shouldReloadPage = false
if (values) {
const allSettings = Object.values(settings).flatMap((x) => Object.entries(x))
@@ -217,7 +232,8 @@
'linkedin',
'quickbooks',
'visma',
'spotify'
'spotify',
'snowflake_oauth'
]
let oauth_name = undefined
@@ -269,6 +285,23 @@
}
return true
}
let snowflakeAccountIdentifier = ''
function setupSnowflakeUrls() {
// strip all whitespaces from account identifier
snowflakeAccountIdentifier = snowflakeAccountIdentifier.replace(/\s/g, '')
const connect_config = {
scopes: [],
auth_url: `https://${snowflakeAccountIdentifier}.snowflakecomputing.com/oauth/authorize`,
token_url: `https://${snowflakeAccountIdentifier}.snowflakecomputing.com/oauth/token-request`,
req_body_auth: false,
extra_params: { account_identifier: snowflakeAccountIdentifier },
extra_params_callback: {}
}
oauths['snowflake_oauth'].connect_config = connect_config
}
</script>
<div class="pb-8">
@@ -513,6 +546,22 @@
{#if !windmillBuiltins.includes(k) && k != 'slack'}
<CustomOauth bind:connect_config={oauths[k]['connect_config']} />
{/if}
{#if k == 'snowflake_oauth'}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
><a
href="https://docs.snowflake.com/en/user-guide/admin-account-identifier#using-an-account-name-as-an-identifier"
target="_blank">Snowflake Account Identifier</a
></span
>
<input
type="text"
placeholder="<orgname>-<account_name>"
required={true}
bind:value={snowflakeAccountIdentifier}
/>
</label>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,38 @@
<script lang="ts">
import AnsiUp from 'ansi_up'
export let content: string
export let highlighted: any[]
const ansi_up = new AnsiUp()
ansi_up.use_classes = true
function highlightSnippet(snippet: string) {
const opener = '!-!-!_H_START_WMILL_!-!-!'
const closer = '!-!-!_H_ENDER_WMILL_!-!-!'
let offset = 0
let ret = snippet
for (const range of highlighted) {
ret = ret.slice(0, range.start + offset) + opener + ret.slice(range.start + offset)
offset += opener.length
ret = ret.slice(0, range.end + offset) + closer + ret.slice(range.end + offset)
offset += closer.length
}
let html = ansi_up.ansi_to_html(ret)
let html2 = html
.replaceAll(opener, '<span class="bg-amber-400 text-black">')
.replaceAll(closer, '</span>')
return html2
}
let html = highlightSnippet(content)
</script>
<button on:click class="font-light !m-0 !p-0">
<pre
class="bg-surface-secondary hover:bg-surface px-2 py-1 text-secondary text-xs w-[100%] whitespace-pre border min-w-full text-start" >
{@html html}
</pre>
</button>
+1 -1
View File
@@ -245,7 +245,7 @@
: 'top-2'} left-36">mem peak: {(mem / 1024).toPrecision(4)}MB</span
>
{/if}
<pre class="whitespace-pre-wrap break-words {small ? '!text-2xs' : '!text-xs'} w-full p-2"
<pre class="whitespace-pre break-words {small ? '!text-2xs' : '!text-xs'} w-full p-2"
>{#if content}{@const len =
(content?.length ?? 0) +
(loadedFromObjectStore?.length ?? 0)}{#if downloadStartUrl}<button on:click={getStoreLogs}
+8 -1
View File
@@ -2,6 +2,7 @@
import { createPopperActions, type PopperOptions } from 'svelte-popperjs'
import type { PopoverPlacement } from './Popover.model'
import Portal from '$lib/components/Portal.svelte'
import { twMerge } from 'tailwind-merge'
import { ExternalLink } from 'lucide-svelte'
@@ -13,6 +14,7 @@
export let appearTimeout = 300
export let documentationLink: string | undefined = undefined
export let style: string | undefined = undefined
export let forceOpen = false
const [popperRef, popperContent] = createPopperActions({ placement })
@@ -47,6 +49,8 @@
inTimeout = undefined
timeout = setTimeout(() => (showTooltip = false), disappearTimeout)
}
$: forceOpen ? open() : close()
</script>
{#if notClickable}
@@ -73,7 +77,10 @@
use:popperContent={popperOptions}
on:mouseenter={open}
on:mouseleave={close}
class="z-[5001] py-2 px-3 rounded-md text-sm font-normal !text-gray-300 bg-gray-800 whitespace-normal text-left {popupClass}"
class={twMerge(
'z-[5001] py-2 px-3 rounded-md text-sm font-normal !text-gray-300 bg-gray-800 whitespace-normal text-left',
popupClass
)}
>
<div class="max-w-sm break-words">
<slot name="text" />
@@ -12,6 +12,8 @@
import SchemaForm from './SchemaForm.svelte'
import { emptyString, sendUserToast } from '$lib/utils'
import Toggle from './Toggle.svelte'
import { loadSchedules, saveSchedule } from './flows/scheduleUtils'
import { type Writable, writable } from 'svelte/store'
export let schema: any
export let isFlow: boolean
@@ -22,103 +24,33 @@
const { primarySchedule, triggersCount } = getContext<TriggerContext>('TriggerContext')
let scheduleEditor: ScheduleEditor
let schedules: Writable<Schedule[] | undefined> = writable(undefined)
let initialPrimarySchedule: Writable<ScheduleTrigger | false | undefined> = writable(undefined)
$: loadSchedules(false) || path
let schedules: Schedule[] | undefined = undefined
let initialPrimarySchedule: ScheduleTrigger | false | undefined = undefined
async function loadSchedules(forceRefresh: boolean) {
if (!path || path == '') {
schedules = []
if ($primarySchedule == undefined) {
$primarySchedule = false
}
initialPrimarySchedule = structuredClone($primarySchedule)
return
}
try {
const allSchedules = await ScheduleService.listSchedules({
workspace: $workspaceStore ?? '',
path: path,
isFlow
})
const primary = allSchedules.find((s) => s.path == path)
let remotePrimarySchedule = primary
? {
summary: primary.summary,
args: primary.args ?? {},
cron: primary.schedule,
timezone: primary.timezone,
enabled: primary.enabled
}
: false
if ($primarySchedule == undefined || forceRefresh) {
$primarySchedule = remotePrimarySchedule
}
initialPrimarySchedule = structuredClone(remotePrimarySchedule)
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: allSchedules.length,
primary_schedule: $primarySchedule ? { schedule: $primarySchedule.cron } : undefined
}
schedules = allSchedules.filter((s) => s.path != path)
} catch (e) {
console.error('impossible to load schedules', e)
}
async function updateSchedules(forceRefresh: boolean) {
loadSchedules(
forceRefresh,
path,
isFlow,
schedules,
primarySchedule,
initialPrimarySchedule,
$workspaceStore ?? '',
triggersCount
)
}
$: updateSchedules(false) || path
async function save() {
const scheduleExists =
path != '' &&
!newItem &&
(await ScheduleService.existsSchedule({
workspace: $workspaceStore!,
path
}))
if (scheduleExists) {
console.log('primary schedule exists')
if ($primarySchedule) {
await ScheduleService.updateSchedule({
workspace: $workspaceStore!,
path,
requestBody: {
summary: $primarySchedule.summary,
args: $primarySchedule.args,
schedule: $primarySchedule.cron,
timezone: $primarySchedule.timezone
}
})
sendUserToast(`Primary schedule updated`)
} else {
await ScheduleService.deleteSchedule({ workspace: $workspaceStore!, path })
sendUserToast(`Primary schedule deleted`)
}
} else {
if ($primarySchedule) {
await ScheduleService.createSchedule({
workspace: $workspaceStore!,
requestBody: {
path,
script_path: path,
is_flow: isFlow,
summary: $primarySchedule.summary,
args: $primarySchedule.args,
schedule: $primarySchedule.cron,
timezone: $primarySchedule.timezone,
enabled: $primarySchedule.enabled
}
})
sendUserToast(`Primary schedule created`)
}
}
loadSchedules(true)
await saveSchedule(path, newItem, $workspaceStore ?? '', primarySchedule, isFlow)
updateSchedules(true)
}
</script>
<ScheduleEditor
on:update={() => {
loadSchedules(true)
updateSchedules(true)
}}
bind:this={scheduleEditor}
/>
@@ -204,7 +136,7 @@
<p class="text-xs text-tertiary mt-10">Define a schedule frequency first</p>
{/if}
{#if initialPrimarySchedule != false}
{#if $initialPrimarySchedule != false}
<div class="flex">
<Button size="xs" color="light" on:click={() => scheduleEditor?.openEdit(path, isFlow)}
>Advanced</Button
@@ -213,29 +145,31 @@
{/if}
{:else}
<div class="flex flex-row gap-4 mt-2">
<Button
on:click={() => {
$primarySchedule = {
summary: '',
args: {},
cron: '0 0 */1 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
}
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount?.schedule_count ?? 0) + 1,
primary_schedule: { schedule: $primarySchedule.cron }
}
}}
variant="contained"
color="dark"
size="md"
startIcon={{ icon: Calendar }}
>
Set Primary Schedule
</Button>
{#if initialPrimarySchedule != undefined && initialPrimarySchedule != false && !newItem}
<div class="flex items-center">
<Button
on:click={() => {
$primarySchedule = {
summary: '',
args: {},
cron: '0 0 */1 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
}
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount?.schedule_count ?? 0) + 1,
primary_schedule: { schedule: $primarySchedule.cron }
}
}}
variant="contained"
color="dark"
size="md"
startIcon={{ icon: Calendar }}
>
Set Primary Schedule
</Button>
</div>
{#if $initialPrimarySchedule != undefined && $initialPrimarySchedule != false && !newItem}
<Button on:click={save} color="dark" size="md" startIcon={{ icon: Save }}>
Apply changes now
</Button>
@@ -274,12 +208,12 @@
{/if}
<Label label="Other schedules">
{#if schedules}
{#if schedules?.length == 0 || schedules == undefined}
{#if $schedules}
{#if $schedules.length == 0 || $schedules == undefined}
<div class="text-xs text-tertiary"> No other schedules </div>
{:else}
<div class="flex flex-col divide-y">
{#each schedules as schedule (schedule.path)}
{#each $schedules as schedule (schedule.path)}
<div class="grid grid-cols-6 text-xs items-center py-2">
<div class="col-span-3 truncate">{schedule.path}</div>
<div class="col-span-2 flex flex-row gap-4 flex-nowrap">
@@ -106,8 +106,9 @@
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
: undefined
)
const simplifiedPoll = writable(false)
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets'
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>('webhooks')
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
@@ -138,7 +139,8 @@
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount
triggersCount,
simplifiedPoll
})
const enterpriseLangs = ['bigquery', 'snowflake', 'mssql']
@@ -1,5 +1,5 @@
<script lang="ts">
import { ServiceLogsService } from '$lib/gen'
import { IndexSearchService, ServiceLogsService } from '$lib/gen'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import ManuelDatePicker from './runs/ManuelDatePicker.svelte'
@@ -7,9 +7,18 @@
import LogViewer from './LogViewer.svelte'
import Toggle from './Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import { onDestroy } from 'svelte'
import { onDestroy, tick } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { truncateRev } from '$lib/utils'
import { copyToClipboard, truncateRev } from '$lib/utils'
import LogSnippetViewer from './LogSnippetViewer.svelte'
import { Button, Drawer, DrawerContent } from './common'
import ClipboardCopy from 'lucide-svelte/icons/clipboard-copy'
import AnsiUp from 'ansi_up'
import { scroll_into_view_if_needed_polyfill } from './multiselect/utils'
import SplitPanesWrapper from './splitPanes/SplitPanesWrapper.svelte'
export let searchTerm: string
export let queryParseErrors: string[] = []
let minTs: undefined | string = undefined
let maxTs: undefined | string = undefined
@@ -132,8 +141,9 @@
if (upToIsLatest && selected) {
upTo = getLatestUpTo(selected)
}
if (autoRefresh && !maxTsManual) {
if (autoRefresh && searchTerm === '' && !maxTsManual) {
timeout = setTimeout(() => {
if (searchTerm !== '') return
let minMax = manualPicker?.computeMinMax()
if (minMax) {
maxTsManual = minMax?.maxTs
@@ -267,11 +277,129 @@
return log
}
}
let logs: any
let debounceTimeout: NodeJS.Timeout | undefined = undefined
const debouncePeriod: number = 400
let loadingLogs = false
let loadingLogCounts = false
let countsPerHost: any
async function searchLogs(
searchTerm: string,
selected: [string, string, string] | undefined,
minTs: string | undefined,
maxTs: string | undefined,
allLogs: ByMode | undefined
) {
if (searchTerm.trim() === '') {
debounceTimeout && clearTimeout(debounceTimeout)
logs = undefined
countsPerHost = undefined
loadingLogs = false
loadingLogCounts = false
return
}
timeout && clearTimeout(timeout)
loadingLogCounts = true
loadingLogs = true
debounceTimeout && clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(async () => {
if (allLogs) {
const hostnames: string[] = []
Object.entries(allLogs).forEach(([mode, wgs]) => {
Object.entries(wgs).forEach(([wg, keys]) => {
Object.entries(keys).forEach(([key, _log_files]) => {
hostnames.push(`${mode},${wg},${key}`)
})
})
})
const countLogsResponse = await IndexSearchService.countSearchLogsIndex({
searchQuery: searchTerm,
hosts: hostnames.join(','),
minTs,
maxTs
})
countsPerHost = countLogsResponse.count_per_host
queryParseErrors = countLogsResponse.query_parse_errors ?? []
loadingLogCounts = false
}
if (selected) {
logs = await IndexSearchService.searchLogsIndex({
searchQuery: searchTerm,
mode: selected[0],
workerGroup: selected[1] != '' ? selected[1] : undefined,
hostname: selected[2],
minTs,
maxTs
})
}
loadingLogs = false
}, debouncePeriod)
}
const ansi_up = new AnsiUp()
ansi_up.use_classes = true
let logDrawer: Drawer
let logDrawerOpen: boolean
let content: string = ''
let hitLineNumber: number | undefined = undefined
async function seeLogContext(
lineNumber: number,
path: string,
hostname: string,
jsonFmt: boolean
) {
const res = await ServiceLogsService.getLogFile({ path: `${hostname}/${path}` })
content = processLogWithJsonFmt(ansi_up.ansi_to_html(res), jsonFmt)
hitLineNumber = lineNumber
logDrawerOpen = true
await tick()
let el = document.getElementById(`log-line-${lineNumber}`)
if (el) scroll_into_view_if_needed_polyfill(el, false)
}
$: searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs)
</script>
<div class="w-full h-[70vh]" on:scroll|preventDefault>
<Drawer bind:this={logDrawer} bind:open={logDrawerOpen} size="1400px">
<DrawerContent title="See context" on:close={logDrawer.closeDrawer}>
<svelte:fragment slot="actions">
<Button
on:click={() => copyToClipboard(content)}
color="light"
size="xs"
startIcon={{
icon: ClipboardCopy
}}
>
Copy to clipboard
</Button>
</svelte:fragment>
<div class="w-fit">
<pre
class="bg-surface-secondary text-secondary text-xs w-full p-2 whitespace-pre border rounded-md"
>...<br />{#each content.split('\n') as line, index}<div
id={`log-line-${index}`}
class={index === hitLineNumber ? 'bg-yellow-200 bg-opacity-20' : ''}>{@html line}</div
>{/each}...</pre
>
</div>
</DrawerContent>
</Drawer>
<SplitPanesWrapper class="hidden md:block">
<Splitpanes>
<Pane size={40} minSize={20}>
<Pane size={30} minSize={25}>
<div class="p-1">
<div
class="flex flex-col lg:flex-row gap-y-1 justify-between w-full relative pb-4 gap-x-0.5"
@@ -290,7 +418,17 @@
: 'min datetime'}
disabled
/>
<CalendarPicker label="min datetime" date={minTs} />
<CalendarPicker
label="min datetime"
date={minTsManual}
on:change={({ detail }) => {
minTs = undefined
maxTs = undefined
allLogs = undefined
minTsManual = detail
getAllLogs(minTsManual, maxTsManual)
}}
/>
</div>
<ManuelDatePicker
bind:minTs={minTsManual}
@@ -304,7 +442,7 @@
getAllLogs(minTsManual, maxTsManual)
}}
serviceLogsChoices
loadText="Last 1000 logfiles"
loadText={searchTerm === '' ? 'Last 1000 logfiles' : 'All time'}
/>
<div class="flex relative">
<input
@@ -319,7 +457,17 @@
: 'max datetime'}
disabled
/>
<CalendarPicker label="max datetime" date={maxTs} />
<CalendarPicker
label="max datetime"
date={maxTsManual}
on:change={({ detail }) => {
minTs = undefined
maxTs = undefined
allLogs = undefined
maxTsManual = detail
getAllLogs(minTsManual, maxTsManual)
}}
/>
</div>
</div>
<div class="flex w-full flex-row-reverse pb-4 -mt-2 gap-2"
@@ -335,6 +483,7 @@
<Toggle
size="xs"
bind:checked={autoRefresh}
disabled={searchTerm != ''}
on:change={(e) => {
if (e.detail) {
getAllLogs(maxTs, undefined)
@@ -353,27 +502,29 @@
{@const minTsN = new Date(minTs).getTime()}
{@const maxTsN = new Date(maxTs).getTime()}
{@const diff = maxTsN - minTsN}
<div class="flex w-full text-2xs text-tertiary pb-6">
<div style="width: 60px;" />
{#if searchTerm === ''}
<div class="flex w-full text-2xs text-tertiary pb-6">
<div style="width: 60px;" />
<div class="flex justify-between w-full"
><div
>{new Date(minTs).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
><div
>{new Date(maxTs).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
></div
>
</div>
<div class="flex justify-between w-full"
><div
>{new Date(minTs).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
><div
>{new Date(maxTs).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
></div
>
</div>
{/if}
{#each Object.entries(allLogs) as [mode, o1]}
<div class="w-full pb-8">
<h2 class="pb-2 text-2xl">{mode}s</h2>
@@ -383,11 +534,21 @@
<h4 class="pt-4">{wg}</h4>
{/if}
<div class="divide-y flex flex-col">
{#each Object.entries(o2) as [hn, files]}
{#each Object.entries(o2).filter(([hn, files]) => {
if (selected && selected[0] === mode && selected[1] === wg && selected[2] === hn) {
return true
}
const hostKey = `${mode},${wg},${hn}`
if (countsPerHost && countsPerHost[hostKey] && countsPerHost[hostKey].count === 0) {
return false
}
return true
}) as [hn, files]}
{@const hostKey = `${mode},${wg},${hn}`}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="w-full flex items-baseline rounded px-1 hover:bg-surface-hover cursor-pointer {selected &&
class="w-full flex items-baseline justify-between rounded px-1 hover:bg-surface-hover cursor-pointer {selected &&
selected[0] == mode &&
selected[1] == wg &&
selected[2] == hn
@@ -405,22 +566,31 @@
title={hn}
style="width: 90px;">{truncateRev(hn, 8)}</div
>
<div class="relative grow h-8 mr-2">
{#each files as file}
{@const okHeight = 100.0 * ((file.ok_lines * 1.0) / (max_lines ?? 1))}
{@const errHeight = 100.0 * ((file.err_lines * 1.0) / (max_lines ?? 1))}
<div
class=" w-2 bg-red-400 absolute"
style="left: {((file.ts - minTsN) / diff) *
100}%; height: {errHeight}%; bottom: {okHeight}%;"
/>
<div
class="w-2 bg-surface-secondary-inverse absolute bottom-0"
style="left: {((file.ts - minTsN) / diff) *
100}%; height: {okHeight}%"
/>
{/each}
</div>
{#if loadingLogCounts}
<Loader2 size={15} class="animate-spin" />
{:else if countsPerHost}
<div class="text-tertiary text-xs">
{countsPerHost[hostKey]?.count} matches
</div>
{:else}
<div class="relative grow h-8 mr-2">
{#each files as file}
{@const okHeight = 100.0 * ((file.ok_lines * 1.0) / (max_lines ?? 1))}
{@const errHeight =
100.0 * ((file.err_lines * 1.0) / (max_lines ?? 1))}
<div
class=" w-2 bg-red-400 absolute"
style="left: {((file.ts - minTsN) / diff) *
100}%; height: {errHeight}%; bottom: {okHeight}%;"
/>
<div
class="w-2 bg-surface-secondary-inverse absolute bottom-0"
style="left: {((file.ts - minTsN) / diff) *
100}%; height: {okHeight}%"
/>
{/each}
</div>
{/if}
</div>
{/each}
</div>
@@ -431,126 +601,162 @@
{/if}
</div>
</Pane>
<Pane size={60} minSize={20}
<Pane size={70} minSize={25}
><div class="relative h-full flex flex-col gap-1"
><div class="w-full bg-surface-primary-inverse text-tertiary text-xs text-center"
>1 min delay: logs are compacted before being available</div
>
{#if selected}
<div class="grow overflow-auto" id="logviewer">
{#each getLogs(selected, upTo) as file}
<div
style="min-height: {logsContent[file.file_path]
? 10
: (file.ok_lines + file.err_lines) / 20}px;"
>
<div class="bg-surface-primary-inverse text-sm font-semibold px-1"
>{new Date(file.ts).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
>
{#if logsContent[file.file_path] == undefined}
<div
class="animate-skeleton dark:bg-frost-900/50 [animation-delay:1000ms] h-full w-full"
{#if loadingLogs}
<div class="flex w-full justify-center items-center h-48">
<div class="text-tertiary text-center">
<Loader2 size={34} class="animate-spin" />
</div>
</div>
{:else if logs != undefined}
<div class="flex flex-col min-w-full w-fit">
{#each logs.hits as { snippet_fragment, snippet_highlighted, document }}
<LogSnippetViewer
content={snippet_fragment || document.logs[0]}
highlighted={snippet_highlighted}
on:click={() => {
let logLineNumber = document.line_number[0]
let logFile = document.file_name[0]
let host = document.host[0]
let jsonFmt = document.json_fmt[0]
seeLogContext(logLineNumber, logFile, host, jsonFmt)
}}
/>
{:else if logsContent[file.file_path]}
{#if logsContent[file.file_path].error}
{#if logsContent[file.file_path].error?.startsWith('Not Found')}
<div class="text-xs pb-4 pt-2 text-secondary"
>Log file is missing. Log files require a shared log volume to be mounted
across servers and workers or to use the EE S3/object storage integration
for logs. To avoid mounting a shared volume, set the EE object store logs in
the instance settings</div
{/each}
{#if logs.hits.length === 0}
<div class="text-center py-20 text-bold text-xl text-tertiary"> No logs </div>
{/if}
{#if logs.hits.length === 30}
<div class="pl-6 py-6 text-sm text-secondary">
Older matches were truncated from this search, try refining your filters to get
more precise results.
</div>
{/if}
<div class="py-20" />
</div>
{:else}
{#each getLogs(selected, upTo) as file}
<div
style="min-height: {logsContent[file.file_path]
? 10
: (file.ok_lines + file.err_lines) / 20}px;"
>
<div class="bg-surface-primary-inverse text-sm font-semibold px-1"
>{new Date(file.ts).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div
>
{#if logsContent[file.file_path] == undefined}
<div
class="animate-skeleton dark:bg-frost-900/50 [animation-delay:1000ms] h-full w-full"
/>
{:else if logsContent[file.file_path]}
{#if logsContent[file.file_path].error}
{#if logsContent[file.file_path].error?.startsWith('Not Found')}
<div class="text-xs pb-4 pt-2 text-secondary"
>Log file is missing. Log files require a shared log volume to be mounted
across servers and workers or to use the EE S3/object storage integration
for logs. To avoid mounting a shared volume, set the EE object store logs
in the instance settings</div
>
{:else}
<div class="text-xs text-red-400 pb-4"
>{logsContent[file.file_path].error}</div
>
{/if}
{:else if logsContent[file.file_path].content}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click|preventDefault class="pr-2"
><LogViewer
noAutoScroll
noMaxH
isLoading={false}
tag={undefined}
content={processLogWithJsonFmt(
logsContent[file.file_path].content,
file.json_fmt
)}
/></div
>
{:else}
<div class="text-xs text-red-400 pb-4"
>{logsContent[file.file_path].error}</div
>
<div>No logs</div>
{/if}
{:else if logsContent[file.file_path].content}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click|preventDefault
><LogViewer
noAutoScroll
noMaxH
isLoading={false}
tag={undefined}
content={processLogWithJsonFmt(
logsContent[file.file_path].content,
file.json_fmt
)}
/></div
>
{:else}
<div>No logs</div>
{/if}
</div>
{/each}
{/if}
</div>
{#if searchTerm == ''}
<div class="flex w-full items-center gap-4">
<div class="text-tertiary px-1 text-2xs">Last 5 log files up to:</div>
<div class="flex grow text-xs justify-center px-2 items-center gap-2">
{#if upTo}
<button
on:click={() => {
if (upTo) {
upToIsLatest = false
upTo = new Date(new Date(upTo).getTime() - 5 * 60 * 1000).toISOString()
}
}}>{'<'} 5m</button
>
{:else}
<div />
{/if}
<div class="flex gap-1 relative items-center"
><div class="flex gap-1 relative">
<input
type="text"
value={upTo
? new Date(upTo).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: ''}
disabled
/><CalendarPicker bind:date={upTo} label="Logs up to" /></div
></div
>
{#if upTo}
<button
on:click={() => {
if (upTo) {
upToIsLatest = false
upTo = new Date(new Date(upTo).getTime() + 5 * 60 * 1000).toISOString()
}
}}>5m {'>'}</button
>
{:else}
<div />
{/if}
</div>
{/each}
</div>
<div class="flex w-full items-center gap-4">
<div class="text-tertiary px-1 text-2xs">Last 5 log files up to:</div>
<div class="flex grow text-xs justify-center px-2 items-center gap-2">
{#if upTo}
<div>
<button
class="text-xs"
on:click={() => {
if (upTo) {
upToIsLatest = false
upTo = new Date(new Date(upTo).getTime() - 5 * 60 * 1000).toISOString()
}
}}>{'<'} 5m</button
upTo = new Date().toISOString()
upToIsLatest = true
}}>now</button
>
{:else}
<div />
{/if}
<div class="flex gap-1 relative items-center"
><div class="flex gap-1 relative">
<input
type="text"
value={upTo
? new Date(upTo).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: ''}
disabled
/><CalendarPicker bind:date={upTo} label="Logs up to" /></div
></div
>
{#if upTo}
<button
on:click={() => {
if (upTo) {
upToIsLatest = false
upTo = new Date(new Date(upTo).getTime() + 5 * 60 * 1000).toISOString()
}
}}>5m {'>'}</button
>
{:else}
<div />
{/if}
</div>
</div>
<div>
<button
class="text-xs"
on:click={() => {
upTo = new Date().toISOString()
upToIsLatest = true
}}>now</button
>
</div>
</div>
{/if}
{:else}
<div class="flex justify-center items-center pt-8">Select a host to see its logs</div>
{/if}</div
></Pane
>
</Splitpanes>
</div>
</SplitPanesWrapper>
+4 -2
View File
@@ -20,7 +20,7 @@
export let lightMode: boolean = false
export let eeOnly: boolean = false
export let size: 'sm' | 'xs' = 'sm'
export let size: 'sm' | 'xs' | '2xs' = 'sm'
const dispatch = createEventDispatcher()
const bothOptions = Boolean(options.left) && Boolean(options.right)
@@ -39,7 +39,7 @@
class={twMerge(
'mr-2 font-medium duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-disabled' : 'text-primary') : 'text-primary',
size === 'xs' ? 'text-xs' : 'text-sm',
size === 'xs' ? 'text-xs' : size === '2xs' ? 'text-[0.5rem]' : 'text-sm',
textClass
)}
style={textStyle}
@@ -76,6 +76,8 @@
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-400',
size === 'sm'
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3'
)}
/>
@@ -265,7 +265,7 @@ Generate a description for the flow below:
</div>
{#if elementType === 'textarea'}
<div>
<div class="flex flex-row-reverse text-2xs text-tertiary -mt-4">GH Markdown</div>
<div class="flex flex-row-reverse !text-3xs text-tertiary -mt-4">GH Markdown</div>
<textarea
bind:this={el}
bind:value={content}
@@ -11,8 +11,10 @@
| 'schedules'
| 'cli'
| 'routes'
| 'websockets' = 'webhooks'
| 'websockets'
| 'scheduledPoll' = 'webhooks'
export let flow_json: any | undefined = undefined
export let simplfiedPoll: boolean = false
export let isOperator: boolean = false
@@ -45,7 +47,7 @@
<slot name="script" />
</TabContent>
<TabContent value="triggers" class="h-full pt-2">
<DetailPageTriggerPanel bind:triggerSelected>
<DetailPageTriggerPanel {simplfiedPoll} bind:triggerSelected>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="websockets" name="websockets" />
@@ -20,13 +20,15 @@
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets'
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>('webhooks')
const simplifiedPoll = writable(false)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount
triggersCount,
simplifiedPoll
})
</script>
@@ -41,6 +43,7 @@
</Pane>
<Pane size={35} minSize={15}>
<DetailPageDetailPanel
simplfiedPoll={$simplifiedPoll}
bind:triggerSelected={$selectedTriggerStore}
bind:selected
{isOperator}
@@ -83,7 +86,10 @@
<slot name="save_inputs" />
</TabContent>
<TabContent value="triggers" class="flex flex-col flex-1 h-full">
<DetailPageTriggerPanel bind:triggerSelected={$selectedTriggerStore}>
<DetailPageTriggerPanel
simplfiedPoll={$simplifiedPoll}
bind:triggerSelected={$selectedTriggerStore}
>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="script" name="script" />
@@ -10,64 +10,70 @@
| 'schedules'
| 'cli'
| 'routes'
| 'websockets' = 'webhooks'
| 'websockets'
| 'scheduledPoll' = 'webhooks'
export let simplfiedPoll: boolean = false
</script>
<HighlightTheme />
<Tabs bind:selected={triggerSelected}>
<Tab value="webhooks">
<span class="flex flex-row gap-2 items-center text-xs">
<Webhook size={12} />
Webhooks
</span>
</Tab>
<Tab value="schedules">
<span class="flex flex-row gap-2 items-center text-xs">
<CalendarCheck2 size={12} />
Schedules
</span>
</Tab>
<Tab value="routes">
<span class="flex flex-row gap-2 items-center text-xs">
<Route size={12} />
HTTP
</span>
</Tab>
<Tab value="websockets">
<span class="flex flex-row gap-2 items-center text-xs">
<Unplug size={12} />
Websockets
</span>
</Tab>
<Tab value="emails">
<span class="flex flex-row gap-2 items-center text-xs">
<MailIcon size={12} />
Email
</span>
</Tab>
<Tab value="cli">
<span class="flex flex-row gap-2 items-center text-xs">
<Terminal size={12} />
CLI
</span>
</Tab>
</Tabs>
{#if !simplfiedPoll}
<Tabs bind:selected={triggerSelected}>
<Tab value="webhooks">
<span class="flex flex-row gap-2 items-center text-xs">
<Webhook size={12} />
Webhooks
</span>
</Tab>
<Tab value="schedules">
<span class="flex flex-row gap-2 items-center text-xs">
<CalendarCheck2 size={12} />
Schedules
</span>
</Tab>
<Tab value="routes">
<span class="flex flex-row gap-2 items-center text-xs">
<Route size={12} />
HTTP
</span>
</Tab>
<Tab value="websockets">
<span class="flex flex-row gap-2 items-center text-xs">
<Unplug size={12} />
Websockets
</span>
</Tab>
<Tab value="emails">
<span class="flex flex-row gap-2 items-center text-xs">
<MailIcon size={12} />
Email
</span>
</Tab>
<Tab value="cli">
<span class="flex flex-row gap-2 items-center text-xs">
<Terminal size={12} />
CLI
</span>
</Tab>
</Tabs>
<div class="h-[calc(100%-32px)]">
<div class="h-full overflow-auto">
{#if triggerSelected === 'webhooks'}
<slot name="webhooks" />
{:else if triggerSelected === 'routes'}
<slot name="routes" />
{:else if triggerSelected === 'emails'}
<slot name="emails" />
{:else if triggerSelected === 'schedules'}
<slot name="schedules" />
{:else if triggerSelected === 'websockets'}
<slot name="websockets" />
{:else if triggerSelected === 'cli'}
<slot name="cli" />
{/if}
<div class="h-[calc(100%-32px)]">
<div class="h-full overflow-auto">
{#if triggerSelected === 'webhooks'}
<slot name="webhooks" />
{:else if triggerSelected === 'routes'}
<slot name="routes" />
{:else if triggerSelected === 'emails'}
<slot name="emails" />
{:else if triggerSelected === 'schedules'}
<slot name="schedules" />
{:else if triggerSelected === 'websockets'}
<slot name="websockets" />
{:else if triggerSelected === 'cli'}
<slot name="cli" />
{/if}
</div>
</div>
</div>
{:else}
<slot name="schedules" />
{/if}
@@ -41,6 +41,7 @@
export let kind: 'trigger' | 'script' | 'preprocessor' | 'failure' | 'approval'
export let selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure' =
kind
export let displayPath = false
let lang: FlowCopilotModule['lang'] = undefined
let selectedCompletion: FlowCopilotModule['selectedCompletion'] = undefined
@@ -424,6 +425,7 @@
selected={selectedByKeyboard - inlineScripts?.length - aiLength - topLevelNodes.length}
on:pickScript
on:pickFlow
{displayPath}
/>
{/if}
@@ -446,6 +448,7 @@
topLevelNodes.length}
on:pickScript
bind:loading
{displayPath}
/>
{/if}
{/if}
@@ -12,15 +12,16 @@
let flow: Flow | undefined = undefined
const selectedTriggerStore = writable<'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes'>(
'webhooks'
)
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'scheduledPoll'
>('webhooks')
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount
triggersCount: triggersCount,
simplifiedPoll: writable(false)
})
async function loadFlow(path: string) {
@@ -45,11 +45,12 @@ export async function pickScript(
path: string,
summary: string,
id: string,
hash?: string
hash?: string,
kind?: string
): Promise<[FlowModule & { value: PathScript }, FlowModuleState]> {
const flowModule: FlowModule & { value: PathScript } = {
id,
value: { type: 'script', path, hash, input_transforms: {} },
value: { type: 'script', path, hash, input_transforms: {}, is_trigger: kind === 'trigger' },
summary
}
@@ -82,7 +83,13 @@ export async function createInlineScriptModule(
const flowModule: FlowModule = {
id,
summary,
value: { type: 'rawscript', content: code, language, input_transforms: {} }
value: {
type: 'rawscript',
content: code,
language,
input_transforms: {},
is_trigger: kind === 'trigger'
}
}
return [flowModule, await loadFlowModuleState(flowModule)]
@@ -41,11 +41,13 @@
export let bold: boolean = false
export let id: string | undefined = undefined
export let label: string
export let path: string = ''
export let modType: string | undefined = undefined
export let bgColor: string = ''
export let concurrency: boolean = false
export let retries: number | undefined = undefined
export let warningMessage: string | undefined = undefined
export let isTrigger: boolean = false
const { flowInputsStore } = getContext<{ flowInputsStore: Writable<FlowInput | undefined> }>(
'FlowGraphContext'
@@ -131,7 +133,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={classNames(
'w-full module flex rounded-sm cursor-pointer',
'w-full module flex rounded-sm cursor-pointer max-w-full',
selected ? 'outline outline-offset-0 outline-2 outline-slate-500 dark:outline-gray-400' : '',
'flex relative',
$copilotCurrentStepStore === id ? 'z-[901]' : ''
@@ -139,7 +141,7 @@
style="width: 275px; height: 34px; background-color: {bgColor};"
on:mouseenter={() => (hover = true)}
on:mouseleave={() => (hover = false)}
on:click
on:click|preventDefault|stopPropagation
>
<div class="absolute text-sm right-12 -bottom-3 flex flex-row gap-1 z-10">
{#if retry}
@@ -185,7 +187,11 @@
>
<Square size={12} />
</div>
<svelte:fragment slot="text">Early stop/break</svelte:fragment>
<svelte:fragment slot="text"
>{isTrigger
? 'Stop early if there are no new events'
: 'Early stop/break'}</svelte:fragment
>
</Popover>
{/if}
{#if skip}
@@ -243,11 +249,22 @@
<slot name="icon" />
</div>
{/if}
<div
class="absolute left-1/2 transform -translate-x-1/2 text-center truncate"
class:font-bold={bold}
style="max-width: calc(100% - {marginLeft}px)">{label}</div
<Popover
class="absolute left-1/2 transform -translate-x-1/2 center-center"
style="max-width: calc(100% - {marginLeft}px)"
>
<div class="text-center truncate {bold ? '!font-bold' : 'font-normal'}">
{label}
</div>
<svelte:fragment slot="text">
<div>
<div>{label}</div>
{#if path != ''}<div>{path}</div>{/if}
</div>
</svelte:fragment>
</Popover>
<div class="flex items-center space-x-2 relative max-w-[25%]" bind:clientWidth={idBadgeWidth}>
{#if id && id !== 'preprocessor' && !id.startsWith('failure')}
<Badge color="indigo" wrapperClass="max-w-full" baseClass="max-w-full truncate" title={id}>
@@ -33,6 +33,7 @@
import { tutorialInProgress } from '$lib/tutorialUtils'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { replaceId } from '../flowStore'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
export let modules: FlowModule[] | undefined
export let sidebarSize: number | undefined = undefined
@@ -47,7 +48,7 @@
const { selectedId, moving, history, flowStateStore, flowStore, flowInputsStore, pathStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const { primarySchedule, triggersCount } = getContext<TriggerContext>('TriggerContext')
async function insertNewModuleAtIndex(
modules: FlowModule[],
index: number,
@@ -74,10 +75,17 @@
push(history, $flowStore)
var module = emptyModule($flowStateStore, $flowStore, kind == 'flow')
var state = emptyFlowModuleState()
$flowStateStore[module.id] = state
if (wsFlow) {
;[module, state] = await pickFlow(wsFlow.path, wsFlow.summary, module.id)
} else if (wsScript) {
;[module, state] = await pickScript(wsScript.path, wsScript.summary, module.id, wsScript.hash)
;[module, state] = await pickScript(
wsScript.path,
wsScript.summary,
module.id,
wsScript.hash,
kind
)
} else if (kind == 'forloop') {
;[module, state] = await createLoop(
module.id,
@@ -89,10 +97,7 @@
;[module, state] = await createBranches(module.id)
} else if (kind == 'branchall') {
;[module, state] = await createBranchAll(module.id)
}
$flowStateStore[module.id] = state
if (inlineScript) {
} else if (inlineScript) {
const { language, kind, subkind } = inlineScript
;[module, state] = await createInlineScriptModule(
language,
@@ -108,6 +113,7 @@
module.summary = 'Approval'
}
}
$flowStateStore[module.id] = state
if (kind == 'approval') {
module.suspend = { required_events: 1, timeout: 1800 }
@@ -116,6 +122,9 @@
expr: '!result || (Array.isArray(result) && result.length == 0)',
skip_if_stopped: true
}
} else if (kind == 'end') {
module.summary = 'Terminate flow'
module.stop_after_if = { skip_if_stopped: false, expr: 'true' }
}
if (!modules) return [module]
@@ -403,6 +412,7 @@
undefined
)
setExpr(detail.modules[index + 1], `results.${id}`)
setScheduledPollSchedule(primarySchedule, triggersCount)
}
}
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import { Cross, Zap } from 'lucide-svelte'
import { Cross } from 'lucide-svelte'
import StepGenQuick from '$lib/components/copilot/StepGenQuick.svelte'
import FlowInputsQuick from '../content/FlowInputsQuick.svelte'
import type { FlowModule } from '$lib/gen'
@@ -11,6 +11,7 @@
import TopLevelNode from '../pickers/TopLevelNode.svelte'
import PopupV2 from '$lib/components/common/popup/PopupV2.svelte'
import { flip, offset } from 'svelte-floating-ui/dom'
import { SchedulePollIcon } from '$lib/components/icons'
// import type { Writable } from 'svelte/store'
@@ -22,6 +23,7 @@
export let disableAi = false
export let kind: 'script' | 'trigger' | 'preprocessor' | 'failure' = 'script'
export let allowTrigger = true
export let iconSize = 12
type Alignment = 'start' | 'end' | 'center'
type Side = 'top' | 'bottom'
@@ -44,6 +46,11 @@
let small = false
let open = false
let width = 0
let height = 0
$: displayPath = width > 650 || height > 400
$: small = kind === 'preprocessor' || kind === 'failure'
</script>
@@ -70,29 +77,35 @@ shouldUsePortal={true} -->
id={`flow-editor-add-step-${index}`}
type="button"
class={twMerge(
'w-5 h-5 flex items-center justify-center',
'outline-[1px] outline dark:outline-gray-500 outline-gray-300',
'text-secondary',
'bg-surface focus:outline-none hover:bg-surface-hover rounded'
'w-[17.5px] h-[17.5px] flex items-center justify-center outline-[1px] outline dark:outline-gray-500 outline-gray-300 text-secondary bg-surface focus:outline-none hover:bg-surface-hover rounded',
$$props.class
)}
on:pointerdown|preventDefault|stopPropagation={pointerdown}
on:pointerdown|preventDefault|stopPropagation={() => {
dispatch('open')
pointerdown()
}}
on:pointerup={pointerup}
>
{#if kind === 'trigger'}
<Zap size={12} />
<SchedulePollIcon size={14} />
{:else}
<Cross size={12} />
<Cross size={iconSize} />
{/if}
</button>
</svelte:fragment>
<!-- FOO -->
<div
id="flow-editor-insert-module"
class="flex flex-col h-[400px] {small ? 'w-[450px]' : 'w-[650px]'} pt-1 pr-1 pl-1 gap-1.5"
class="flex flex-col h-[400px] {small
? 'w-[450px]'
: 'w-[650px]'} pt-1 pr-1 pl-1 gap-1.5 resize overflow-auto {small
? 'min-w-[450px]'
: 'min-w-[650px]'} min-h-[400px]"
on:wheel={(e) => {
e.stopPropagation()
}}
role="none"
bind:clientWidth={width}
bind:clientHeight={height}
>
<div class="flex flex-row items-center gap-2">
<StepGenQuick
@@ -109,7 +122,7 @@ shouldUsePortal={true} -->
</div>
<div class="flex flex-row grow min-h-0">
{#if kind === 'script' || kind == 'trigger'}
{#if kind === 'script'}
<div class="flex-none flex flex-col text-xs text-primary">
<TopLevelNode
label="Action"
@@ -201,6 +214,7 @@ shouldUsePortal={true} -->
on:pickFlow
{preFilter}
{small}
{displayPath}
/>
</div>
</div>
@@ -11,6 +11,7 @@
import { msToSec } from '$lib/utils'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import FlowJobsMenu from './FlowJobsMenu.svelte'
import { isTriggerStep } from '$lib/components/graph/graphBuilder'
export let mod: FlowModule
export let insertable: boolean
@@ -19,6 +20,7 @@
export let modules: FlowModule[]
export let moving: string | undefined = undefined
export let duration_ms: number | undefined = undefined
export let isTrigger: boolean = false
export let retries: number | undefined = undefined
export let flowJobs:
@@ -173,6 +175,8 @@
(mod.value.type === 'rawscript'
? `Inline ${prettyLanguage(mod.value.language)}`
: 'To be defined')}
path={`path` in mod.value && mod.summary ? mod.value.path : ''}
isTrigger={isTriggerStep(mod)}
>
<div slot="icon">
{#if mod.value.type === 'rawscript'}
@@ -1,9 +1,9 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import type { FlowModule } from '$lib/gen'
import { classNames } from '$lib/utils'
import { createEventDispatcher, getContext } from 'svelte'
import { getContext } from 'svelte'
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
import VirtualItemWrapper from './VirtualItemWrapper.svelte'
export let label: string | undefined = undefined
export let bgColor: string = ''
@@ -15,45 +15,22 @@
export let hideId: boolean = false
export let preLabel: string | undefined = undefined
const dispatch = createEventDispatcher<{
insert: {
script?: { path: string; summary: string; hash: string | undefined }
detail: 'script' | 'forloop' | 'branchone' | 'branchall' | 'trigger' | 'move'
modules: FlowModule[]
index: number
}
select: string
}>()
const { currentStepStore: copilotCurrentStepStore } =
getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={classNames(
'w-full flex relative overflow-hidden rounded-sm',
selectable ? 'cursor-pointer' : '',
selected ? 'outline outline-offset-1 outline-2 outline-gray-600' : '',
label === 'Input' && $copilotCurrentStepStore === 'Input' ? 'z-[901]' : ''
)}
style="width: 275px; max-height: 34px; background-color: {bgColor} !important;"
on:click={() => {
if (selectable) {
if (id) {
dispatch('select', id)
} else {
dispatch('select', label || label || '')
}
}
}}
title={(label ? label + ' ' : '') + (label ?? '')}
id={`flow-editor-virtual-${encodeURIComponent(label || label || '')}`}
<VirtualItemWrapper
{label}
{bgColor}
{selected}
{selectable}
{id}
onTop={label === 'Input' && $copilotCurrentStepStore === 'Input'}
on:select
>
<div
style={borderColor ? `border-color: ${borderColor};` : 'border: 0'}
class="flex gap-1 justify-between {center
class="flex flex-row gap-1 justify-between {center
? 'items-center'
: 'items-baseline'} w-full overflow-hidden rounded-sm border p-2 text-2xs module text-primary border-gray-400 dark:border-gray-600"
>
@@ -61,8 +38,7 @@
<slot name="icon" />
<span class="mr-2" />
{/if}
<div />
<div class="flex flex-col w-full">
<div class="flex flex-col flex-grow shrink-0 max-w-full min-w-0">
{#if label}
<div class="truncate text-center">{label}</div>
{/if}
@@ -70,10 +46,12 @@
<div class="truncate text-2xs text-center"><pre>{preLabel}</pre></div>
{/if}
</div>
<div class="flex items-center space-x-2">
{#if id && !hideId}
<Badge color="indigo">{id}</Badge>
{/if}
</div>
{#if id && !hideId}
<div class="flex items-center shrink min-w-0">
<Badge color="indigo" wrapperClass="w-full" baseClass="max-w-full" title={id}>
<span class="max-w-full text-2xs truncate">{id}</span>
</Badge>
</div>
{/if}
</div>
</div>
</VirtualItemWrapper>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { FlowModule } from '$lib/gen'
import { classNames } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
export let label: string | undefined
export let selectable: boolean
export let selected: boolean
export let id: string | undefined
export let onTop: boolean = false
export let bgColor: string
const dispatch = createEventDispatcher<{
insert: {
script?: { path: string; summary: string; hash: string | undefined }
detail: 'script' | 'forloop' | 'branchone' | 'branchall' | 'trigger' | 'move'
modules: FlowModule[]
index: number
}
select: string
}>()
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={classNames(
'w-full flex relative overflow-hidden rounded-sm',
selectable ? 'cursor-pointer' : '',
selected ? 'outline outline-offset-1 outline-2 outline-gray-600 dark:outline-gray-400' : '',
onTop ? 'z-[901]' : ''
)}
style="width: 275px; max-height: 34px; background-color: {bgColor} !important;"
on:click={() => {
if (selectable) {
if (id) {
dispatch('select', id)
} else {
dispatch('select', label || label || '')
}
}
}}
title={label ? label + ' ' : ''}
id={`flow-editor-virtual-${encodeURIComponent(label || label || '')}`}><slot /></div
>
@@ -5,6 +5,7 @@
import { APP_TO_ICON_COMPONENT } from '$lib/components/icons'
import { IntegrationService, ScriptService, type HubScriptKind } from '$lib/gen'
import { Circle } from 'lucide-svelte'
import Popover from '$lib/components/Popover.svelte'
export let kind: HubScriptKind & string = 'script'
export let filter = ''
@@ -25,6 +26,7 @@
app: string
kind: HubScriptKind
}[] = []
export let displayPath = false
export let apps: string[] = []
let allApps: string[] = []
@@ -138,32 +140,56 @@
<ul>
{#each items as item, index (item.path)}
<li class="w-full">
<button
class="px-3 py-2 gap-2 flex flex-row w-full hover:bg-surface-hover transition-all items-center rounded-md {index ===
selected
? 'bg-surface-hover'
: ''}"
on:click={() => dispatch('pickScript', item)}
>
<div class={classNames('flex justify-center items-center')}>
{#if item['app'] in APP_TO_ICON_COMPONENT}
<svelte:component this={APP_TO_ICON_COMPONENT[item['app']]} height={14} width={14} />
{:else}
<div
class="w-[14px] h-[14px] text-gray-400 flex flex-row items-center justify-center"
<Popover class="w-full" placement="right" forceOpen={index === selected}>
<svelte:fragment slot="text">
<div class="flex flex-col">
<div class="text-left text-xs font-normal leading-tight py-0"
>{item.summary ?? ''}</div
>
<Circle size="12" />
<div class="text-left text-2xs font-normal">
{item.path ?? ''}
</div>
{/if}
</div>
</div>
</svelte:fragment>
<button
class="px-3 py-2 gap-2 flex flex-row w-full hover:bg-surface-hover transition-all items-center rounded-md {index ===
selected
? 'bg-surface-hover'
: ''}"
on:click={() => dispatch('pickScript', item)}
>
<div class={classNames('flex justify-center items-center')}>
{#if item['app'] in APP_TO_ICON_COMPONENT}
<svelte:component
this={APP_TO_ICON_COMPONENT[item['app']]}
height={14}
width={14}
/>
{:else}
<div
class="w-[14px] h-[14px] text-gray-400 flex flex-row items-center justify-center"
>
<Circle size="12" />
</div>
{/if}
</div>
<span class="grow truncate text-left text-2xs text-primary font-normal">
{item.summary ?? ''}
</span>
{#if index === selected}
<kbd class="!text-xs">&crarr;</kbd>
{/if}
</button>
<div class="flex flex-col grow min-w-0">
<div
class="grow truncate text-left text-2xs text-primary font-normal leading-tight py-0.5"
>{item.summary ?? ''}</div
>
{#if displayPath && item.path}
<div class="grow truncate text-left text-2xs text-secondary font-[220]">
{item.path}
</div>
{/if}
</div>
{#if index === selected}
<kbd class="!text-xs">&crarr;</kbd>
{/if}
</button>
</Popover>
</li>
{/each}
</ul>
@@ -7,11 +7,13 @@
import { emptyString } from '$lib/utils'
import { Code2 } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import Popover from '$lib/components/Popover.svelte'
export let kind: 'script' | 'trigger' | 'approval' | 'failure' | 'flow' | 'preprocessor' =
'script'
export let isTemplate: boolean | undefined = undefined
export let selected: number | undefined = undefined
export let displayPath = false
type Item = {
path: string
@@ -105,35 +107,52 @@
<ul>
{#each filteredWithOwner ?? [] as { path, hash, summary, marked }, index}
<li class="w-full">
<button
class="px-3 py-2 gap-2 flex flex-row w-full hover:bg-surface-hover transition-all items-center rounded-md {index ===
selected
? 'bg-surface-hover'
: ''}"
on:click={() => {
if (kind == 'flow') {
dispatch('pickFlow', { path: path })
} else {
dispatch('pickScript', { path: path, hash: lockHash ? hash : undefined, kind })
}
}}
>
{#if kind == 'flow'}
<BarsStaggered size={14} class="shrink-0" />
{:else}
<Code2 size={14} />
{/if}
<span class="grow min-w-0 truncate text-left text-2xs text-primary font-normal">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? path : summary}
{/if}</span
<Popover class="w-full " placement="right" forceOpen={index === selected}>
<svelte:fragment slot="text">
<div class="flex flex-col">
<div class="text-left text-xs font-normal leading-tight py-0">{summary ?? ''}</div>
<div class="text-left text-2xs font-normal">
{path ?? ''}
</div>
</div>
</svelte:fragment>
<button
class="px-3 py-2 gap-2 flex flex-row w-full hover:bg-surface-hover transition-all items-center rounded-md {index ===
selected
? 'bg-surface-hover'
: ''}"
on:click={() => {
if (kind == 'flow') {
dispatch('pickFlow', { path: path })
} else {
dispatch('pickScript', { path: path, hash: lockHash ? hash : undefined, kind })
}
}}
>
{#if index === selected}
<kbd class="!text-xs">&crarr;</kbd>
{/if}
</button>
{#if kind == 'flow'}
<BarsStaggered size={14} class="shrink-0" />
{:else}
<Code2 size={14} />
{/if}
<div class="flex flex-col grow min-w-0">
<div class="grow min-w-0 truncate text-left text-2xs text-primary font-normal">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? path : summary}
{/if}
</div>
{#if displayPath && path}
<div class="grow min-w-0 truncate text-left text-2xs text-secondary font-[220]">
{path}
</div>
{/if}
</div>
{#if index === selected}
<kbd class="!text-xs">&crarr;</kbd>
{/if}
</button>
</Popover>
</li>
{/each}
</ul>
@@ -1,5 +1,8 @@
import { ScheduleService } from '$lib/gen'
import { ScheduleService, type Schedule, type TriggersCount } from '$lib/gen'
import type { ScheduleTrigger } from '../triggers'
import type { Writable } from 'svelte/store'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/utils'
// Load the schedule of a flow given its path and the workspace
export async function loadFlowSchedule(path: string, workspace: string): Promise<ScheduleTrigger> {
@@ -25,3 +28,108 @@ export async function loadFlowSchedule(path: string, workspace: string): Promise
args: schedule.args ?? {}
}
}
export async function loadSchedules(
forceRefresh: boolean,
path: string,
isFlow: boolean,
schedules: Writable<Schedule[] | undefined>,
primarySchedule: Writable<ScheduleTrigger | false | undefined>,
initialPrimarySchedule: Writable<ScheduleTrigger | false | undefined>,
workspace: string,
triggersCount: Writable<TriggersCount | undefined>
) {
console.log('loading schedules for path', path)
if (!path || path == '') {
schedules.set([])
primarySchedule.update((ps) => (ps === undefined ? false : ps))
initialPrimarySchedule.set(structuredClone(get(primarySchedule)))
return
}
console.log('loading schedules for path', path)
try {
const allSchedules = await ScheduleService.listSchedules({
workspace,
path: path,
isFlow
})
const primary = allSchedules.find((s) => s.path == path)
let remotePrimarySchedule: ScheduleTrigger | false | undefined = primary
? {
summary: primary.summary,
args: primary.args ?? {},
cron: primary.schedule,
timezone: primary.timezone,
enabled: primary.enabled
}
: false
primarySchedule.update((ps) => (ps === undefined || forceRefresh ? remotePrimarySchedule : ps))
initialPrimarySchedule.set(structuredClone(remotePrimarySchedule))
triggersCount.update((tc) => {
const primary = get(primarySchedule)
return {
...(tc ?? {}),
schedule_count: allSchedules.length,
primary_schedule: primary ? { schedule: primary.cron } : undefined
}
})
schedules.set(allSchedules.filter((s) => s.path != path))
} catch (e) {
console.error('impossible to load schedules', e)
}
}
export async function saveSchedule(
path: string,
newItem: boolean,
workspace: string,
primarySchedule: Writable<ScheduleTrigger | false | undefined>,
isFlow: boolean
) {
const scheduleExists =
path != '' &&
!newItem &&
(await ScheduleService.existsSchedule({
workspace,
path
}))
if (scheduleExists) {
console.log('primary schedule exists')
const primary = get(primarySchedule)
if (primary) {
await ScheduleService.updateSchedule({
workspace,
path,
requestBody: {
summary: primary.summary,
args: primary.args,
schedule: primary.cron,
timezone: primary.timezone
}
})
sendUserToast(`Primary schedule updated`)
} else {
await ScheduleService.deleteSchedule({ workspace, path })
sendUserToast(`Primary schedule deleted`)
}
} else {
const primary = get(primarySchedule)
if (primary) {
await ScheduleService.createSchedule({
workspace,
requestBody: {
path,
script_path: path,
is_flow: isFlow,
summary: primary.summary,
args: primary.args,
schedule: primary.cron,
timezone: primary.timezone,
enabled: primary.enabled
}
})
sendUserToast(`Primary schedule created`)
}
}
}
@@ -1,9 +1,9 @@
<script lang="ts">
import { type FlowModule } from '../../gen'
import { NODE, type GraphModuleState } from '.'
import { createEventDispatcher, onMount, setContext } from 'svelte'
import { createEventDispatcher, getContext, onDestroy, onMount, setContext } from 'svelte'
import { writable, type Writable } from 'svelte/store'
import { get, writable, type Writable } from 'svelte/store'
import '@xyflow/svelte/dist/style.css'
import type { FlowInput } from '../flows/types'
import {
@@ -15,7 +15,7 @@
ControlButton,
type Viewport
} from '@xyflow/svelte'
import graphBuilder from './graphBuilder'
import { graphBuilder, isTriggerStep, type SimplifiableFlow } from './graphBuilder'
import ModuleNode from './renderers/nodes/ModuleNode.svelte'
import InputNode from './renderers/nodes/InputNode.svelte'
import BranchAllStart from './renderers/nodes/BranchAllStart.svelte'
@@ -38,6 +38,8 @@
import Button from '../common/button/Button.svelte'
import FlowYamlEditor from '../flows/header/FlowYamlEditor.svelte'
import BranchOneEndNode from './renderers/nodes/branchOneEndNode.svelte'
import type { TriggerContext } from '../triggers'
export let success: boolean | undefined = undefined
export let modules: FlowModule[] | undefined = []
export let failureModule: FlowModule | undefined = undefined
@@ -72,11 +74,46 @@
useDataflow: Writable<boolean | undefined>
}>('FlowGraphContext', { selectedId, flowInputsStore, useDataflow })
const triggerContext = getContext<TriggerContext>('TriggerContext')
const dispatch = createEventDispatcher()
let fullWidth = 0
let width = 0
export let allowSimplifiedPoll: boolean = true
let simplifiableFlow: SimplifiableFlow | undefined = undefined
if (triggerContext && allowSimplifiedPoll) {
if (isSimplifiable(modules)) {
triggerContext?.simplifiedPoll?.set(true)
}
triggerContext?.simplifiedPoll.subscribe((value) => {
computeSimplifiableFlow(modules ?? [], value ?? false)
})
}
function computeSimplifiableFlow(modules: FlowModule[], simplifiedFlow: boolean) {
const isSimplif = isSimplifiable(modules)
simplifiableFlow = isSimplif ? { simplifiedFlow } : undefined
}
onDestroy(() => {
if (isSimplifiable(modules)) {
triggerContext?.simplifiedPoll?.set(undefined)
}
})
function onModulesChange(modules: FlowModule[]) {
computeSimplifiableFlow(
modules,
triggerContext?.simplifiedPoll ? get(triggerContext.simplifiedPoll) ?? false : false
)
}
$: allowSimplifiedPoll && onModulesChange(modules ?? [])
function layoutNodes(nodes: Node[]): Node[] {
let seenId: string[] = []
for (const n of nodes) {
@@ -129,6 +166,44 @@
return newNodes
}
let eventHandler = {
deleteBranch: (detail, label) => {
$selectedId = label
dispatch('deleteBranch', detail)
},
insert: (detail) => {
dispatch('insert', detail)
},
select: (modId) => {
if (!notSelectable) {
if ($selectedId != modId) {
$selectedId = modId
}
dispatch('select', modId)
}
},
changeId: (detail) => {
dispatch('changeId', detail)
},
delete: (detail, label) => {
$selectedId = label
dispatch('delete', detail)
},
newBranch: (module) => {
dispatch('newBranch', { module })
},
move: (module, modules) => {
dispatch('move', { module, modules })
},
selectedIteration: (detail, moduleId) => {
dispatch('selectedIteration', { ...detail, moduleId: moduleId })
},
simplifyFlow: (detail) => {
triggerContext?.simplifiedPoll.set(detail)
}
}
$: graph = graphBuilder(
modules,
{
@@ -141,50 +216,13 @@
},
failureModule,
preprocessorModule,
{
deleteBranch: (detail, label) => {
$selectedId = label
dispatch('deleteBranch', detail)
},
insert: (detail) => {
dispatch('insert', detail)
},
select: (modId) => {
if (!notSelectable) {
if ($selectedId != modId) {
$selectedId = modId
}
dispatch('select', modId)
}
},
changeId: (detail) => {
dispatch('changeId', detail)
},
delete: (detail, label) => {
$selectedId = label
dispatch('delete', detail)
},
newBranch: (module) => {
dispatch('newBranch', { module })
},
move: (module, modules) => {
dispatch('move', { module, modules })
},
selectedIteration: (detail, moduleId) => {
dispatch('selectedIteration', { ...detail, moduleId: moduleId })
}
},
eventHandler,
success,
$useDataflow,
$selectedId,
moving,
triggerNode
? {
path
}
: undefined
simplifiableFlow,
triggerNode ? path : undefined
)
const nodes = writable<Node[]>([])
@@ -192,18 +230,93 @@
let height = 0
// function removeInputNode(nodes, edges, id) {
// const inputNode = nodes.find((node) => node.id === id)
// if (!inputNode) return { nodes, edges }
// // Find edges connected to the input node
// const connectedEdges = edges.filter((edge) => edge.source === id || edge.target === id)
// // Remove the input node
// let updatedNodes = nodes.filter((node) => node.id !== id)
// // Remove edges connected to the input node
// let updatedEdges = edges.filter((edge) => edge.source !== id && edge.target !== id)
// // Create new edges from the input node's parent to its children
// const inputEdges = connectedEdges.filter((edge) => edge.target === id)
// const outputEdges = connectedEdges.filter((edge) => edge.source === id)
// inputEdges.forEach((inputEdge) => {
// outputEdges.forEach((outputEdge) => {
// const newEdge = {
// id: `edge:${inputEdge.source}->${outputEdge.target}`,
// source: inputEdge.source,
// target: outputEdge.target,
// type: 'empty',
// data: {
// ...outputEdge.data,
// sourceId: inputEdge.source,
// targetId: outputEdge.target
// }
// }
// updatedEdges.push(newEdge)
// })
// })
// // Update parent ids of the nodes
// updatedNodes = updatedNodes.map((node) => {
// if (node.data && node.data.parentIds && node.data.parentIds.includes(id)) {
// const updatedParentIds = node.data.parentIds.filter((parentId) => parentId !== id)
// if (inputNode.data && inputNode.data.parentIds) {
// updatedParentIds.push(...inputNode.data.parentIds)
// }
// return {
// ...node,
// data: {
// ...node.data,
// parentIds: [...new Set(updatedParentIds)] // Remove duplicates
// }
// }
// }
// return node
// })
// return { nodes: updatedNodes, edges: updatedEdges }
// }
// function processGraph(graph, simplifiable) {
// let newGraph = { nodes: graph.nodes, edges: graph.edges }
// newGraph = removeInputNode(newGraph.nodes, newGraph.edges, 'Input')
// newGraph = removeInputNode(newGraph.nodes, newGraph.edges, simplifiable.forLoopNode.id)
// newGraph = removeInputNode(newGraph.nodes, newGraph.edges, simplifiable.triggerNode.id)
// return newGraph
// }
function isSimplifiable(modules: FlowModule[] | undefined): boolean {
if (!modules || modules?.length !== 2) {
return false
}
if (isTriggerStep(modules?.[0])) {
let secondValue = modules?.[1].value
return secondValue.type == 'forloopflow'
}
return false
}
function updateStores() {
if (graph.error) {
return
}
let newGraph = graph
$nodes = layoutNodes(graph?.nodes)
$edges = graph.edges
$nodes = layoutNodes(newGraph.nodes)
$edges = newGraph.edges
height = Math.max(...$nodes.map((n) => n.position.y + NODE.height + 40), minHeight)
}
$: graph && updateStores()
$: (graph || allowSimplifiedPoll) && updateStores()
const nodeTypes = {
input2: InputNode,
@@ -14,9 +14,19 @@ export type GraphEventHandlers = {
move: (module: FlowModule, modules: FlowModule[]) => void
selectedIteration: (detail, moduleId: string) => void
changeId: (newId: string) => void
simplifyFlow: (detail: boolean) => void
}
export default function graphBuilder(
export type SimplifiableFlow = { simplifiedFlow: boolean }
export function isTriggerStep(module: FlowModule | undefined): boolean {
return (
module?.value != undefined &&
(module.value.type === 'script' || module.value.type === 'rawscript') &&
module.value.is_trigger === true
)
}
export function graphBuilder(
modules: FlowModule[] | undefined,
extra: Record<string, any>,
failureModule: FlowModule | undefined,
@@ -26,9 +36,12 @@ export default function graphBuilder(
useDataflow: boolean | undefined,
selectedId: string | undefined,
moving: string | undefined,
triggerProps?: {
path?: string
}
simplifiableFlow: SimplifiableFlow | undefined,
flowPathForTriggerNode: string | undefined
// triggerProps?: {
// path?: string
// flowIsSimplifiable?: boolean
// }
): {
nodes: Node[]
edges: Edge[]
@@ -102,6 +115,7 @@ export default function graphBuilder(
sourceId: string,
targetId: string,
options?: {
disableInsert?: boolean
customId?: string
type?: string
subModules?: FlowModule[]
@@ -129,17 +143,18 @@ export default function graphBuilder(
target: targetId,
type: options?.type ?? 'edge',
data: {
insertable: extra.insertable,
modules: options?.subModules ?? modules,
sourceId,
targetId,
moving,
eventHandlers,
simplifiedTriggerView: simplifiableFlow?.simplifiedFlow,
disableMoveIds: options?.disableMoveIds,
enableTrigger: sourceId === 'Input',
// If the index is -1, it means that the target module is not in the modules array, so we set it to the length of the array
index: index >= 0 ? index : mods?.length ?? 0,
...extra
...extra,
insertable: extra.insertable && !options?.disableInsert
}
})
}
@@ -156,15 +171,19 @@ export default function graphBuilder(
}
}
if (extra.path && triggerProps != undefined) {
const triggerNode: Node = {
let triggerNode: Node | undefined = undefined
if (flowPathForTriggerNode) {
triggerNode = {
id: 'Trigger',
position: { x: -1, y: -1 },
type: 'trigger',
data: {
path: triggerProps?.path,
simplifiableFlow: simplifiableFlow,
path: flowPathForTriggerNode,
newFlow: extra.newFlow,
eventHandlers: eventHandlers
eventHandlers: eventHandlers,
modules: modules,
isEditor: extra.insertable
}
}
nodes.push(triggerNode)
@@ -191,13 +210,16 @@ export default function graphBuilder(
type: 'result'
}
nodes.push(inputNode)
nodes.push(resultNode)
if (simplifiableFlow?.simplifiedFlow !== true) {
nodes.push(inputNode)
nodes.push(resultNode)
}
function processModules(
modules: FlowModule[],
beforeNode: Node,
nextNode: Node,
nextNode: Node | undefined,
simplifiedTriggerView: boolean,
currentOffset = 0,
disableMoveIds: string[] = [],
parentIndex?: string,
@@ -206,10 +228,12 @@ export default function graphBuilder(
let previousId: string | undefined = undefined
if (modules.length === 0) {
addEdge(beforeNode.id, nextNode.id, {
subModules: modules,
disableMoveIds
})
if (nextNode) {
addEdge(beforeNode.id, nextNode.id, {
subModules: modules,
disableMoveIds
})
}
} else {
modules.forEach((module, index) => {
const localDisableMoveIds = [...disableMoveIds, module.id]
@@ -295,6 +319,7 @@ export default function graphBuilder(
branch.modules,
startNode,
endNode,
false,
currentOffset,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}-${branchIndex}` : `${index}-${branchIndex}`
@@ -304,7 +329,9 @@ export default function graphBuilder(
previousId = endNode.id
} else if (module.value.type === 'forloopflow') {
addNode(module, currentOffset, 'module', modules)
if (!simplifiedTriggerView) {
addNode(module, currentOffset, 'module', modules)
}
const startNode = {
id: `${module.id}-start`,
@@ -313,6 +340,7 @@ export default function graphBuilder(
id: module.id,
module: module,
modules: modules,
simplifiedTriggerView,
eventHandlers: eventHandlers,
...extra
},
@@ -320,9 +348,15 @@ export default function graphBuilder(
type: 'forLoopStart'
}
addEdge(module.id, startNode.id, {
type: 'empty'
})
if (!simplifiedTriggerView) {
addEdge(module.id, startNode.id, {
type: 'empty'
})
} else if (previousId) {
addEdge(previousId, startNode.id, {
type: 'empty'
})
}
const endNode = {
id: `${module.id}-end`,
@@ -332,6 +366,7 @@ export default function graphBuilder(
module: module,
modules: modules,
eventHandlers: eventHandlers,
simplifiedTriggerView,
...extra
},
position: { x: -1, y: -1 },
@@ -347,6 +382,7 @@ export default function graphBuilder(
module.value.modules,
startNode,
endNode,
false,
currentOffset + 25,
localDisableMoveIds,
parentIndex
@@ -390,6 +426,7 @@ export default function graphBuilder(
module.value.modules,
startNode,
endNode,
false,
currentOffset + 25,
localDisableMoveIds,
parentIndex
@@ -435,6 +472,7 @@ export default function graphBuilder(
module.value.default,
defaultBranch,
endNode,
false,
currentOffset,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}` : index.toString(),
@@ -468,6 +506,7 @@ export default function graphBuilder(
branch.modules,
startNode,
endNode,
false,
currentOffset,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}` : index.toString(),
@@ -480,30 +519,31 @@ export default function graphBuilder(
addNode(module, currentOffset, 'module', modules)
previousId = module.id
}
if (index === 0) {
addEdge(beforeNode.id, module.id, {
subModules: modules,
disableMoveIds
disableMoveIds,
disableInsert: simplifiedTriggerView
})
}
if (index === modules.length - 1 && previousId) {
if (index === modules.length - 1 && previousId && nextNode) {
addEdge(previousId, nextNode.id, {
subModules: modules,
disableMoveIds
})
}
})
}
}
processModules(modules, inputNode, resultNode)
if (simplifiableFlow?.simplifiedFlow === true && triggerNode) {
processModules(modules, triggerNode, undefined, true)
} else {
processModules(modules, inputNode, resultNode, false)
}
if (failureModule) {
let toAdd: Record<string, string> = {}
@@ -86,45 +86,6 @@
}}
/>
</div>
{#if data.enableTrigger}
<div
class="edgeButtonContainer nodrag nopan"
style:transform="translate(100%, 50%) translate({sourceX}px,{sourceY + 2}px)"
>
<InsertModuleButton
disableAi={data.disableAi}
on:new={(e) => {
// console.log('new', e)
data?.eventHandlers.insert({
modules: data.modules,
index: data.index,
kind: e.detail.kind,
inlineScript: e.detail.inlineScript
})
}}
on:pickScript={(e) => {
// console.log('pickScript', e)
data?.eventHandlers.insert({
modules: data.modules,
index: data.index,
script: e.detail,
kind: e.detail.kind
})
}}
on:pickFlow={(e) => {
// console.log('pickFlow', e)
data?.eventHandlers.insert({
modules: data.modules,
index: data.index,
flow: e.detail
})
}}
kind="trigger"
index={data?.index ?? 0}
modules={data?.modules ?? []}
/>
</div>
{/if}
{/if}
{#if data?.moving}
@@ -11,19 +11,35 @@
insertable: boolean
flowModuleStates: Record<string, GraphModuleState> | undefined
eventHandlers: GraphEventHandlers
simplifiedTriggerView: boolean
}
</script>
<NodeWrapper offset={data.offset} let:darkMode>
<VirtualItem
label={'Collect result of each iteration'}
selectable={true}
selected={false}
id={data.id}
bgColor={getStateColor(undefined, darkMode)}
borderColor={getStateColor(data.flowModuleStates?.[data.id]?.type, darkMode)}
on:select={(e) => {
data?.eventHandlers?.select(e.detail)
}}
/>
{#if data.simplifiedTriggerView}
<VirtualItem
label={'Each event is processed'}
selectable={false}
selected={false}
id={data.id}
hideId
bgColor={getStateColor(undefined, darkMode)}
borderColor={getStateColor(data.flowModuleStates?.[data.id]?.type, darkMode)}
on:select={(e) => {
data?.eventHandlers?.select(e.detail)
}}
/>
{:else}
<VirtualItem
label={'Collect result of each iteration'}
selectable={true}
selected={false}
id={data.id}
bgColor={getStateColor(undefined, darkMode)}
borderColor={getStateColor(data.flowModuleStates?.[data.id]?.type, darkMode)}
on:select={(e) => {
data?.eventHandlers?.select(e.detail)
}}
/>
{/if}
</NodeWrapper>
@@ -12,12 +12,13 @@
modules: FlowModule[]
flowModuleStates: Record<string, GraphModuleState> | undefined
eventHandlers: GraphEventHandlers
simplifiedTriggerView: boolean
}
</script>
<NodeWrapper let:darkMode offset={data.offset}>
<VirtualItem
label={'Do one iteration'}
label={data.simplifiedTriggerView ? 'For each new event' : 'Do one iteration'}
selectable={false}
selected={false}
id={data.id}
@@ -15,7 +15,6 @@
moving: string | undefined
eventHandlers: GraphEventHandlers
index: number
enableTrigger: boolean
disableAi: boolean
disableMoveIds: string[]
}
@@ -50,6 +49,8 @@
detail: 'preprocessor'
})
}}
class="w-[14px] h-[14px]"
iconSize={10}
/>
</div>
{/if}
@@ -1,9 +1,14 @@
<script lang="ts">
import NodeWrapper from './NodeWrapper.svelte'
import TriggersWrapper from '../triggers/TriggersWrapper.svelte'
import { type GraphEventHandlers } from '../../graphBuilder'
import { type GraphEventHandlers, type SimplifiableFlow } from '../../graphBuilder'
import type { FlowModule } from '$lib/gen'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import { Maximize2, Minimize2, Calendar } from 'lucide-svelte'
import { getStateColor } from '../../util'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
import VirtualItemWrapper from '$lib/components/flows/map/VirtualItemWrapper.svelte'
export let data: {
path: string
@@ -11,21 +16,107 @@
newFlow: boolean
extra_perms: Record<string, any>
eventHandlers: GraphEventHandlers
modules: FlowModule[]
index: number
disableAi: boolean
simplifiableFlow: SimplifiableFlow
}
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const { primarySchedule, triggersCount, selectedTrigger } =
getContext<TriggerContext>('TriggerContext')
</script>
<NodeWrapper wrapperClass="shadow-none">
<TriggersWrapper
path={data.path}
on:select={() => {
data?.eventHandlers?.select('triggers')
}}
isFlow={true}
selected={$selectedId == 'triggers'}
newItem={data.newFlow}
/>
<NodeWrapper wrapperClass="shadow-md" let:darkMode>
{#if data.simplifiableFlow?.simplifiedFlow != true}
<TriggersWrapper
disableAi={data.disableAi}
isEditor={data.isEditor}
path={data.path}
bgColor={getStateColor(undefined, darkMode)}
on:new={(e) => {
data?.eventHandlers.insert({
modules: data.modules,
index: 0,
kind: 'trigger',
inlineScript: e.detail.inlineScript
})
data?.eventHandlers?.simplifyFlow(true)
}}
on:pickScript={(e) => {
data?.eventHandlers.insert({
modules: data.modules,
index: 0,
kind: 'trigger',
script: e.detail
})
data?.eventHandlers?.simplifyFlow(true)
}}
on:openScheduledPoll={(e) => {
$selectedTrigger = 'scheduledPoll'
}}
on:select={(e) => {
data?.eventHandlers?.select('triggers')
}}
on:delete={(e) => {
data.eventHandlers.delete(e, '')
}}
selected={$selectedId == 'triggers'}
newItem={data.newFlow}
modules={data.modules}
/>
{:else}
<VirtualItemWrapper
label="Check for new events"
selectable={true}
selected={$selectedId == 'triggers'}
id={'triggers'}
bgColor={getStateColor(undefined, darkMode)}
on:select={(e) => {
data?.eventHandlers?.select(e.detail)
}}
>
{#if $primarySchedule || ($primarySchedule == undefined && $triggersCount?.primary_schedule?.schedule)}
<div class="text-2xs text-primary p-2 flex gap-2 items-center">
<Calendar size={12} />
<div>
Schedule every {$primarySchedule?.cron ?? $triggersCount?.primary_schedule?.schedule}
{$primarySchedule?.enabled ||
($primarySchedule == undefined && $triggersCount?.primary_schedule?.schedule)
? ''
: ' (disabled)'}
</div>
</div>
{:else}
<button
class="px-2 py-1 hover:bg-surface-inverse w-full hover:text-primary-inverse"
on:click={() => {
setScheduledPollSchedule(primarySchedule, triggersCount)
}}
>
Set primary schedule
</button>
{/if}
</VirtualItemWrapper>
{/if}
{#if data.simplifiableFlow != undefined}
<button
class="absolute -top-[10px] -right-[10px] rounded-full h-[20px] w-[20px] trash center-center text-secondary
outline-[1px] outline dark:outline-gray-500 outline-gray-300 bg-surface duration-150 hover:bg-nord-950 hover:text-white"
on:click|preventDefault|stopPropagation={() =>
data?.eventHandlers?.simplifyFlow(!data.simplifiableFlow?.simplifiedFlow)}
title={data.simplifiableFlow?.simplifiedFlow
? 'Expand to full flow view'
: 'Simplify flow view for scheduled poll'}
>
{#if data.simplifiableFlow?.simplifiedFlow}
<Maximize2 size={12} strokeWidth={2} />
{:else}
<Minimize2 size={12} strokeWidth={2} />
{/if}
</button>
{/if}
</NodeWrapper>
@@ -7,7 +7,7 @@
<button
on:click
class={twMerge(
'hover:bg-surface-hover rounded-md border text-xs w-6 h-6 relative center-center cursor-pointer bg-surface',
'hover:bg-surface-hover rounded-md border text-xs w-[23px] h-[23px] relative center-center cursor-pointer bg-surface',
selected ? 'outline-1 outline-tertiary outline' : 'outline-0'
)}
>
@@ -18,6 +18,8 @@
export let isFlow: boolean
export let selected: boolean
export let showOnlyWithCount: boolean
export let triggersToDisplay: ('webhooks' | 'schedules' | 'routes' | 'websockets' | 'emails')[] =
['webhooks', 'schedules', 'routes', 'websockets', 'emails']
const dispatch = createEventDispatcher()
onMount(() => {
@@ -39,84 +41,31 @@
})
}
}
const triggerTypeConfig = {
webhooks: { icon: Webhook, countKey: 'webhook_count' },
schedules: { icon: Calendar, countKey: 'schedule_count' },
routes: { icon: Route, countKey: 'http_routes_count' },
websockets: { icon: Unplug, countKey: 'websocket_count' },
emails: { icon: Mail, countKey: 'email_count' }
}
</script>
{#if !showOnlyWithCount || ($triggersCount?.webhook_count ?? 0) > 0}
<Popover>
<svelte:fragment slot="text">Webhooks</svelte:fragment>
<TriggerButton
on:click={() => {
$selectedTrigger = 'webhooks'
dispatch('select')
}}
selected={selected && $selectedTrigger === 'webhooks'}
>
<TriggerCount count={$triggersCount?.webhook_count} />
<Webhook size={12} />
</TriggerButton>
</Popover>
{/if}
{#if !showOnlyWithCount || ($triggersCount?.schedule_count ?? 0) > 0}
<Popover>
<svelte:fragment slot="text">Schedules</svelte:fragment>
<TriggerButton
on:click={() => {
$selectedTrigger = 'schedules'
dispatch('select')
}}
selected={selected && $selectedTrigger === 'schedules'}
>
<TriggerCount count={$triggersCount?.schedule_count} />
<Calendar size={12} />
</TriggerButton>
</Popover>
{/if}
{#if !showOnlyWithCount || ($triggersCount?.http_routes_count ?? 0) > 0}
<Popover>
<svelte:fragment slot="text">HTTP Routes</svelte:fragment>
<TriggerButton
on:click={() => {
$selectedTrigger = 'routes'
dispatch('select')
}}
selected={selected && $selectedTrigger === 'routes'}
>
<TriggerCount count={$triggersCount?.http_routes_count} />
<Route size={12} />
</TriggerButton>
</Popover>
{/if}
{#if !showOnlyWithCount || ($triggersCount?.websocket_count ?? 0) > 0}
<Popover>
<svelte:fragment slot="text">Websockets</svelte:fragment>
<TriggerButton
on:click={() => {
$selectedTrigger = 'websockets'
dispatch('select')
}}
selected={selected && $selectedTrigger === 'websockets'}
>
<TriggerCount count={$triggersCount?.websocket_count} />
<Unplug size={12} />
</TriggerButton>
</Popover>
{/if}
{#if !showOnlyWithCount || ($triggersCount?.email_count ?? 0) > 0}
<Popover>
<svelte:fragment slot="text">Emails</svelte:fragment>
<TriggerButton
on:click={() => {
$selectedTrigger = 'emails'
dispatch('select')
}}
selected={selected && $selectedTrigger === 'emails'}
>
<TriggerCount count={$triggersCount?.email_count} />
<Mail size={12} />
</TriggerButton>
</Popover>
{/if}
{#each triggersToDisplay as type}
{@const { icon, countKey } = triggerTypeConfig[type]}
{#if !showOnlyWithCount || ($triggersCount?.[countKey] ?? 0) > 0}
<Popover>
<svelte:fragment slot="text">{type.charAt(0).toUpperCase() + type.slice(1)}</svelte:fragment>
<TriggerButton
on:click={() => {
$selectedTrigger = type
dispatch('select')
}}
selected={selected && $selectedTrigger === type}
>
<TriggerCount count={$triggersCount?.[countKey]} />
<svelte:component this={icon} size={12} />
</TriggerButton>
</Popover>
{/if}
{/each}
@@ -4,29 +4,51 @@
import { createEventDispatcher } from 'svelte'
import TriggersBadge from './TriggersBadge.svelte'
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import type { FlowModule } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
export let path: string
export let newItem: boolean
export let isFlow: boolean
export let selected: boolean
export let isEditor: boolean = false
export let disableAi: boolean = false
export let modules: FlowModule[] = []
export let bgColor: string
const dispatch = createEventDispatcher()
</script>
<div style={`width: ${NODE.width}px;`}>
<div class="flex flex-row mx-auto w-min">
<button
class="flex flex-row gap-2 px-2 border p-1 rounded-md bg-surface shadow-md items-center {selected
? 'outline outline-offset-1 outline-1 outline-slate-900 dark:bg-white/5 dark:outline-slate-800/60 dark:border-gray-400'
: ''}"
on:click={() => {
dispatch('select')
}}
>
<div class="flex flex-col">
<div class="flex flex-row items-center text-2xs font-normal"> Triggers </div>
</div>
<TriggersBadge showOnlyWithCount={false} {path} {newItem} {isFlow} {selected} on:select />
</button>
</div>
<button
style="background-color: {bgColor} !important;"
class="flex w-full flex-row gap-2 px-2 p-1 items-center {selected
? 'outline outline-2 outline-gray-600 rounded-sm dark:bg-white/5 dark:outline-gray-400'
: ''}"
on:click={() => {
dispatch('select')
}}
>
<div class="flex flex-col mr-1 ml-1">
<div class="flex flex-row items-center text-2xs font-normal"> Triggers </div>
</div>
<TriggersBadge showOnlyWithCount={false} {path} {newItem} isFlow {selected} on:select />
{#if isEditor}
<InsertModuleButton
{disableAi}
on:new
on:pickScript
on:select
on:open={() => {
dispatch('openScheduledPoll')
}}
kind="trigger"
index={0}
{modules}
class={twMerge(
'hover:bg-surface-hover rounded-md border text-xs w-[23px] h-[23px] relative center-center cursor-pointer bg-surface outline-0'
)}
/>
{/if}
</button>
</div>
@@ -0,0 +1,36 @@
<script>
// You can pass props for flexibility (e.g., size, color)
export let size = 24
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4.96046 2.04913L6.96046 2.04913" />
<path d="M5.96046 8.94553L4.96046 7.94553" />
<path d="M13.1611 13.8974L17.2173 13.8974" />
<path
d="M20.4622 12.2749V9.84124C20.4622 9.62608 20.3767 9.41975 20.2246 9.26761C20.0725 9.11547 19.8661 9.03001 19.651 9.03001H18.0285C17.8134 9.03001 17.607 9.11547 17.4549 9.26761C17.3028 9.41975 17.2173 9.62608 17.2173 9.84124V12.2749"
/>
<path
d="M20.8678 22.0097C21.1906 22.0097 21.5001 21.8632 21.7283 21.6024C21.9565 21.3415 22.0847 20.9878 22.0847 20.619V17.9412C22.0847 16.9747 20.8678 15.8816 20.8678 14.5834V12.9703C20.8678 12.7858 20.8037 12.609 20.6896 12.4786C20.5755 12.3482 20.4208 12.2749 20.2594 12.2749H17.8257C17.6644 12.2749 17.5096 12.3482 17.3955 12.4786C17.2814 12.609 17.2173 12.7858 17.2173 12.9703V20.619C17.2173 20.9878 17.3455 21.3415 17.5737 21.6024C17.8019 21.8632 18.1114 22.0097 18.4341 22.0097H20.8678Z"
/>
<path d="M22.0847 18.7648H8.29377" />
<path
d="M9.51061 22.0097C9.18789 22.0097 8.87838 21.8632 8.65018 21.6024C8.42197 21.3415 8.29377 20.9878 8.29377 20.619V17.9412C8.29377 16.9747 9.51061 15.8816 9.51061 14.5834V12.9703C9.51061 12.7858 9.57472 12.609 9.68882 12.4786C9.80292 12.3482 9.95767 12.2749 10.119 12.2749H12.5527C12.7141 12.2749 12.8688 12.3482 12.9829 12.4786C13.097 12.609 13.1611 12.7858 13.1611 12.9703V20.619C13.1611 20.9878 13.0329 21.3415 12.8047 21.6024C12.5765 21.8632 12.267 22.0097 11.9443 22.0097H9.51061Z"
/>
<path
d="M13.1611 12.2749V9.84124C13.1611 9.62608 13.0757 9.41975 12.9235 9.26761C12.7714 9.11547 12.5651 9.03001 12.3499 9.03001H10.7275C10.5123 9.03001 10.306 9.11547 10.1538 9.26761C10.0017 9.41975 9.91622 9.62608 9.91622 9.84124V12.2749"
/>
<path
d="M8.78889 6.11711C8.30848 5.63669 7.71362 5.28656 7.0604 5.09974C6.40719 4.91291 5.71715 4.89555 5.05536 5.04928C4.39358 5.20301 3.78186 5.52278 3.27789 5.97842C2.77392 6.43406 2.39431 7.01056 2.17486 7.65355C1.95542 8.29654 1.90337 8.98483 2.02362 9.65351C2.14387 10.3222 2.43246 10.9492 2.86217 11.4755C3.29188 12.0017 3.84856 12.4098 4.47969 12.6614C5.11083 12.9129 5.79563 12.9995 6.46951 12.913"
/>
</svg>
@@ -19,6 +19,7 @@ import Slack from './Slack.svelte'
import TogglIcon from './TogglIcon.svelte'
import WindmillIcon from './WindmillIcon.svelte'
import WindmillIcon2 from './WindmillIcon2.svelte'
import SchedulePollIcon from './SchedulePollIcon.svelte'
import MailchimpIcon from './MailchimpIcon.svelte'
import SendgridIcon from './SendgridIcon.svelte'
import SendflakeIcon from './SendflakeIcon.svelte'
@@ -215,6 +216,7 @@ export {
TogglIcon,
WindmillIcon,
WindmillIcon2,
SchedulePollIcon,
MailchimpIcon,
SendgridIcon,
LinkedinIcon,
@@ -53,7 +53,11 @@ export function scroll_into_view_if_needed_polyfill(elem: Element, centerIfNeede
})
}
observer.disconnect()
})
}, {
root: null, // or specify a scrolling parent if needed
rootMargin: '0px 1000px', // Essentially making horizontal checks irrelevant
threshold: 0.1 // Adjust threshold to control when observer should trigger
})
observer.observe(elem)
return observer // return for testing
@@ -24,16 +24,10 @@
return { minTs, maxTs }
}
const manualDates: {
const fixedManualDates: {
label: string
computeMinMax: () => { minTs: string; maxTs: string | undefined } | undefined
}[] = [
{
label: loadText ?? 'Last 1000 runs',
computeMinMax: () => {
return undefined
}
},
...(!serviceLogsChoices
? [
{
@@ -68,6 +62,17 @@
}
]
$: manualDates = [
{
label: loadText ?? 'Last 1000 runs',
computeMinMax: () => {
return undefined
}
},
...fixedManualDates
]
const dispatch = createEventDispatcher()
</script>
@@ -39,7 +39,7 @@
import { scroll_into_view_if_needed_polyfill } from '../multiselect/utils'
import { Alert } from '../common'
import Popover from '../Popover.svelte'
import ServiceLogsInner from '../ServiceLogsInner.svelte'
import Logs from 'lucide-svelte/icons/logs'
let open: boolean = false
@@ -78,13 +78,6 @@
action: () => switchMode('content'),
shortcutKey: CONTENT_SEARCH_PREFIX,
icon: SearchCode
},
{
search_id: 'switchto:log-search',
label: 'Search Windmill logs',
action: () => switchMode('logs'),
shortcutKey: LOGS_PREFIX,
icon: Search
}
]
let defaultMenuItems: quickMenuItem[] = [
@@ -108,7 +101,14 @@
action: () => gotoPage('/schedules'),
icon: CalendarIcon
},
...switchModeItems
...switchModeItems,
{
search_id: 'nav:service_logs',
label: 'Explore windmill service logs',
action: () => gotoPage('/service_logs'),
shortcutKey: LOGS_PREFIX,
icon: Logs
}
]
let itemMap = {
@@ -483,7 +483,7 @@
}
function maxModalWidth(tab: SearchMode) {
if (tab === 'runs' || tab === 'logs') {
if (tab === 'runs') {
return 'max-w-7xl'
} else {
return 'max-w-4xl'
@@ -491,7 +491,7 @@
}
function maxModalHeight(tab: SearchMode) {
if (tab === 'runs' || tab === 'logs') {
if (tab === 'runs') {
return ''
} else if (tab === 'content') {
return 'max-h-[70vh]'
@@ -614,12 +614,19 @@
<Alert title="Service logs are only available to superadmins" type="warning">
Service logs are only available to superadmins
</Alert>
{:else if searchTerm.length == 1}
<ServiceLogsInner />
{:else}
<Alert title="Not yet supported" type="info">
Full-text search across Windmill logs is not yet supported
</Alert>
<QuickMenuItem
on:select={() =>
gotoPage(
`/service_logs?query=${encodeURIComponent(removePrefix(searchTerm, '!'))}`
)}
id="goto_service_logs_search"
hovered={true}
label={searchTerm === '!'
? 'Explore Windmill service logs'
: `Search '${removePrefix(searchTerm, '!')}' in Windmill's service logs`}
icon={searchTerm === '!' ? Logs : Search}
/>
{/if}
</div>
{:else if tab === 'runs'}
@@ -680,40 +687,40 @@
</QuickMenuItem>
{/each}
</div>
{#if selectedItem === undefined}
Select a result to preview
{:else}
<div class="w-8/12 max-h-[70vh]">
<div class="w-8/12 max-h-[70vh]">
{#if selectedItem === undefined}
Select a result to preview
{:else}
<div class="h-[95%] overflow-y-scroll">
<JobPreview
id={selectedItem?.document?.id[0]}
workspace={selectedWorkspace}
/>
</div>
<div class="flex flex-row pt-3 pl-4 items-center text-xs text-secondary">
{#if indexMetadata.indexed_until}
<span class="px-2">
Most recent indexed job was created <TimeAgo
{/if}
<div class="flex flex-row pt-3 pl-4 items-center text-xs text-secondary">
{#if indexMetadata.indexed_until}
<span class="px-2">
Most recently indexed job was created <TimeAgo
agoOnlyIfRecent
date={indexMetadata.indexed_until || ''}
/>
</span>
{/if}
{#if indexMetadata.lost_lock_ownership}
<Popover notClickable placement="top">
<AlertTriangle size={16} class="text-gray-500" />
<svelte:fragment slot="text">
The current indexer is no longer indexing new jobs. This is most likely
because of an ongoing deployment and indexing will resume once it's
complete.
</svelte:fragment>
</Popover>
{/if}
</div>
</span>
{/if}
{#if indexMetadata.lost_lock_ownership}
<Popover notClickable placement="top">
<AlertTriangle size={16} class="text-gray-500" />
<svelte:fragment slot="text">
The current indexer is no longer indexing new jobs. This is most likely
because of an ongoing deployment and indexing will resume once it's
complete.
</svelte:fragment>
</Popover>
{/if}
</div>
{/if}
</div>
{:else}
<div class="flex w-full justify-center items-center h-48">
<div class="flex flex-col w-full justify-center items-center h-48">
<div class="text-tertiary text-center">
{#if searchTerm === RUNS_PREFIX}
<div class="text-2xl font-bold">Enter your search terms</div>
@@ -735,6 +742,26 @@
</Alert>
{/if}
</div>
<div class="flex flex-row pt-10 text-xs text-secondary">
{#if indexMetadata.indexed_until}
<span class="px-2">
Most recently indexed job was created <TimeAgo
agoOnlyIfRecent
date={indexMetadata.indexed_until}
/>
</span>
{/if}
{#if indexMetadata.lost_lock_ownership}
<Popover notClickable placement="top">
<AlertTriangle size={16} class="text-gray-500" />
<svelte:fragment slot="text">
The current indexer is no longer indexing new jobs. This is most likely
because of an ongoing deployment and indexing will resume once it's
complete.
</svelte:fragment>
</Popover>
{/if}
</div>
</div>
{/if}
</div>
@@ -9,6 +9,7 @@
Calendar,
DollarSign,
Eye,
Logs,
FolderCog,
FolderOpen,
Github,
@@ -167,7 +168,29 @@
],
disabled: $userStore?.operator
},
{ label: 'Audit Logs', href: `${base}/audit_logs`, icon: Eye, disabled: $userStore?.operator }
!$superadmin
? {
label: 'Audit Logs',
href: `${base}/audit_logs`,
icon: Eye,
disabled: $userStore?.operator
}
: {
label: 'Logs',
icon: Logs,
subItems: [
{
label: 'Audit Logs',
href: `${base}/audit_logs`,
icon: Eye
},
{
label: 'Service Logs',
href: `${base}/service_logs`,
icon: Logs
}
]
}
]
let hasNewChangelogs = false
+25 -1
View File
@@ -10,7 +10,31 @@ export type ScheduleTrigger = {
}
export type TriggerContext = {
selectedTrigger: Writable<'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets'>
selectedTrigger: Writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>
primarySchedule: Writable<ScheduleTrigger | undefined | false>
triggersCount: Writable<TriggersCount | undefined>
simplifiedPoll: Writable<boolean | undefined>
}
export function setScheduledPollSchedule(
primarySchedule: Writable<ScheduleTrigger | undefined | false>,
triggersCount: Writable<TriggersCount | undefined>
) {
const cron = '0 */5 * * * *'
primarySchedule.set({
enabled: true,
summary: 'Check for new events every 5 minutes',
cron: cron,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
args: {}
})
triggersCount.update((triggersCount) => {
return {
...(triggersCount ?? {}),
schedule_count: (triggersCount?.schedule_count ?? 0) + 1,
primary_schedule: { schedule: cron }
}
})
}
@@ -0,0 +1,41 @@
<script lang="ts">
import Alert from '$lib/components/common/alert/Alert.svelte'
import Label from '$lib/components/Label.svelte'
import { base } from '$lib/base'
$: console.log('dbg base', base)
</script>
<div class="flex flex-col w-full gap-4 text-sm">
<Alert
title="Scheduled Poll"
size="xs"
documentationLink="https://www.windmill.dev/docs/flows/flow_trigger"
>
Sheduled Poll is a trigger that polls an external API at a given interval. It is useful for
triggering jobs based on the change of external states. Under the hood, it is a script triggered
by a schedule, and returning a list of the changed states so we can iterate over them.
</Alert>
<Label label="Setting a scheduled poll" primary>
<ul class="pt-4 space-y-2 text-sm">
<li>
1. Chose a trigger script using the script picker that will poll for new events regularly <img
class="shadow-lg rounded"
alt="script picker"
src="{base}/script-picker.png"
width="400"
/>
</li>
<li>
2. Add logic to process each new event <img
class="shadow-lg rounded"
alt="create action"
src="{base}/create_action.png"
width="500"
/>
</li>
</ul>
</Label>
</div>
@@ -11,6 +11,7 @@
import { getContext } from 'svelte'
import type { TriggerContext } from '$lib/components/triggers'
import WebsocketTriggersPanel from './WebsocketTriggersPanel.svelte'
import ScheduledPollPanel from './ScheduledPollPanel.svelte'
export let noEditor: boolean
export let newItem = false
@@ -19,67 +20,88 @@
export let schema: any
export let isFlow: boolean
const { selectedTrigger } = getContext<TriggerContext>('TriggerContext')
const { selectedTrigger, simplifiedPoll } = getContext<TriggerContext>('TriggerContext')
</script>
<FlowCard {noEditor} title="Triggers">
<div class="pt-4">
<Tabs bind:selected={$selectedTrigger}>
<Tab value="webhooks" selectedClass="text-primary font-semibold">Webhooks</Tab>
<Tab value="schedules" selectedClass="text-primary text-sm font-semibold">Schedules</Tab>
<Tab value="routes" selectedClass="text-primary text-sm font-semibold">Routes</Tab>
<Tab value="websockets" selectedClass="text-primary text-sm font-semibold">Websockets</Tab>
<Tab value="emails" selectedClass="text-primary text-sm font-semibold">Email</Tab>
{#if !$simplifiedPoll}
<Tabs bind:selected={$selectedTrigger}>
<Tab value="webhooks" selectedClass="text-primary font-semibold">Webhooks</Tab>
<Tab value="schedules" selectedClass="text-primary text-sm font-semibold">Schedules</Tab>
<Tab value="routes" selectedClass="text-primary text-sm font-semibold">Routes</Tab>
<Tab value="websockets" selectedClass="text-primary text-sm font-semibold">Websockets</Tab>
<Tab value="emails" selectedClass="text-primary text-sm font-semibold">Email</Tab>
<Tab value="scheduledPoll" selectedClass="text-primary text-sm font-semibold"
>Scheduled Poll</Tab
>
<svelte:fragment slot="content">
{#if $selectedTrigger === 'webhooks'}
<div class="p-4">
<WebhooksPanel
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={currentPath}
{isFlow}
args={{}}
token=""
{newItem}
/>
</div>
{/if}
<svelte:fragment slot="content">
{#if $selectedTrigger === 'webhooks'}
<div class="p-4">
<WebhooksPanel
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={currentPath}
{isFlow}
args={{}}
token=""
{newItem}
/>
</div>
{/if}
{#if $selectedTrigger === 'emails'}
<div class="p-4">
<EmailTriggerPanel
token=""
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={currentPath}
{isFlow}
/>
</div>
{/if}
{#if $selectedTrigger === 'emails'}
<div class="p-4">
<EmailTriggerPanel
token=""
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={currentPath}
{isFlow}
/>
</div>
{/if}
{#if $selectedTrigger === 'routes'}
<div class="p-4">
<RoutesPanel {newItem} path={currentPath} {isFlow} />
</div>
{/if}
{#if $selectedTrigger === 'routes'}
<div class="p-4">
<RoutesPanel {newItem} path={currentPath} {isFlow} />
</div>
{/if}
{#if $selectedTrigger === 'websockets'}
<div class="p-4">
<WebsocketTriggersPanel {newItem} path={currentPath} {isFlow} />
</div>
{/if}
{#if $selectedTrigger === 'websockets'}
<div class="p-4">
<WebsocketTriggersPanel {newItem} path={currentPath} {isFlow} />
</div>
{/if}
{#if $selectedTrigger === 'schedules'}
<div class="p-2">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{/if}
</svelte:fragment>
</Tabs>
{#if $selectedTrigger === 'schedules'}
<div class="p-4">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{/if}
{#if $selectedTrigger === 'scheduledPoll'}
<div class="p-4">
<ScheduledPollPanel />
</div>
{/if}
</svelte:fragment>
</Tabs>
{:else}
<div class="px-4 pb-2">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{/if}
</div>
</FlowCard>
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Service Logs' }
}
}
@@ -0,0 +1,71 @@
<script lang="ts">
import { page } from '$app/stores'
import { Alert } from '$lib/components/common'
import Popover from '$lib/components/Popover.svelte'
import ServiceLogsInner from '$lib/components/ServiceLogsInner.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { superadmin } from '$lib/stores'
import { Search, AlertTriangle } from 'lucide-svelte'
let searchTerm = $page.url.searchParams.get('query') ?? ''
let queryParseErrors: string[] | undefined = undefined
</script>
<div class="w-full h-screen">
<div class="px-2">
<div class="flex items-center space-x-2 flex-row justify-between">
<div class="flex flex-row flex-wrap justify-between py-2 my-4 px-4 gap-1 items-center">
<h1 class="!text-2xl font-semibold leading-6 tracking-tight">Service logs</h1>
<Tooltip >
Explore and search Windmill service logs from within Windmill!
</Tooltip>
</div>
</div>
</div>
{#if !$superadmin}
<Alert title="Service logs are only available to superadmins" type="warning">
Service logs are only available to superadmins
</Alert>
{:else}
<div class="m-1 px-2 flex flex-row gap-1 items-center border-2 rounded-lg">
<Search size="16" />
<input
id="quickSearchInput"
type="text"
class="quick-search-input !bg-surface"
bind:value={searchTerm}
autocomplete="off"
autofocus
/>
{#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0}
<Popover notClickable placement="bottom-start">
<AlertTriangle size={16} class="text-yellow-500" />
<svelte:fragment slot="text">
Some of your search terms have been ignored because one or more parse errors:<br /><br
/>
<ul>
{#each queryParseErrors as msg}
<li>- {msg}</li>
{/each}
</ul>
</svelte:fragment>
</Popover>
{/if}
</div>
<ServiceLogsInner {searchTerm} bind:queryParseErrors />
{/if}
</div>
<style>
.quick-search-input {
outline: none;
border: none !important;
box-shadow: none !important;
}
.quick-search-input:focus-visible {
outline: none !important;
}
</style>
+3 -2
View File
@@ -74,12 +74,13 @@
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const triggersCount = writable<TriggersCount | undefined>(undefined)
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets'
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>('webhooks')
setContext<TriggerContext>('TriggerContext', {
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount
triggersCount: triggersCount,
simplifiedPoll: writable(false)
})
setContext<FlowEditorContext>('FlowEditorContext', {
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

+2 -1
View File
@@ -463,7 +463,8 @@ const config = {
height: 'height'
},
fontSize: {
'2xs': '0.7rem'
'2xs': '0.7rem',
'3xs': '0.65rem'
},
screens: {
fhd: '1900px',
+2 -2
View File
@@ -4,8 +4,8 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.417.2"
wmill_pg = ">=1.417.2"
wmill = ">=1.418.0"
wmill_pg = ">=1.418.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+5 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.417.2
version: 1.418.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -269,6 +269,8 @@ components:
type: number
custom_concurrency_key:
type: string
is_trigger:
type: boolean
required:
- type
- content
@@ -292,6 +294,8 @@ components:
- script
tag_override:
type: string
is_trigger:
type: boolean
required:
- type
- path
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.417.2'
ModuleVersion = '1.418.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.417.2"
version = "1.418.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill-pg"
version = "1.417.2"
version = "1.418.0"
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.417.2",
"version": "1.418.0",
"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.417.2",
"version": "1.418.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"devDependencies": {
+1 -1
View File
@@ -1 +1 @@
1.417.2
1.418.0