feat(frontend): unify all triggers UX and simplify flow settings (#4259)

* feat(frontend): added list of triggers in the flow graph

* feat(frontend): added list of triggers in the flow graph

* feat(frontend): clean up

* feat(frontend): improve UX

* feat(frontend): triggers

* feat(frontend): triggers

* feat(frontend): done

* feat(frontend): fix trigger when position when a preprocessor is presetn

* Glm/rework flow settings v2 (#4497)

* fat(frontend): simplify flow settings menu

* improve scroll

* changing mute toggle

* Add advanced settings badge

* Add nord theme colors

* Add bage for advanced options

* fix minor issue

* fix minor issue

* Add triggers menu to flow settings

* Add quick trigger access

* remove triggers in flow settings

* fix minor issue

* Move triggers settings to flow right panel

* polishing

* fix unset store

* remove save up to for triggers

* fix padding

* reset default tag color

* remove custom select component

* revert path change

* revert section modif

* Revert unused feature

---------

Co-authored-by: Guilhem <guilhem@mbp-de-windmill.home>

* Connect top bar cron to schedules settings

* Turn copilot into node

* fix copilot placement

* remove useless import

* fix center copilot

* fix binding

* remove copilot on top of preprocessor

* render copilot node on condition

* quickfix

* remove copilot node

* fix minor issues

* fix route count update

* fix schedule sync

* harmonize colors

* fix alignment and add edges

* recenter node summary

* fix schedules sync

* Add id title

* all

* all

* all

* iteration

* all

* all

* done

* fix

* more fixes

---------

Co-authored-by: Guilhem <guilhemlemouel@gmail.com>
Co-authored-by: Guilhem <guilhem@mbp-de-windmill.home>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
This commit is contained in:
Faton Ramadani
2024-10-16 17:58:15 +02:00
committed by Ruben Fiszel
co-authored by Guilhem Guilhem Ruben Fiszel Ruben Fiszel
parent f38b3d14e8
commit 91a3d06529
86 changed files with 2737 additions and 1416 deletions
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM schedule WHERE script_path = $1 AND is_flow = $2 AND workspace_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Bool",
"Text"
]
},
"nullable": [
null
]
},
"hash": "1ca5bc2d35c0498b587fd0618434def64233dc4f8fc3344d8d74be8e96ded659"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM http_trigger WHERE script_path = $1 AND is_flow = $2 AND workspace_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Bool",
"Text"
]
},
"nullable": [
null
]
},
"hash": "31bc3dcea29be9cc0242771d25a232f173446d29c08fc29ddb8d55294f2c070e"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM token WHERE label LIKE 'webhook-%' AND workspace_id = $1 AND scopes @> ARRAY['run:' || $2]::text[]",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "5fc6b4a4dbb7875bdec76f876c18543435a95b019b20081f52f6ed6f4457e3c7"
}
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "bool",
"name": "?column?",
"type_info": "Bool"
}
],
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM token WHERE label LIKE 'email-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "a7f5431e3b8960e9dc46fae69dd4391516d8b169186548ec44528c84078b80d8"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT schedule FROM schedule WHERE path = $1 AND script_path = $1 AND is_flow = $2 AND workspace_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "schedule",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Bool",
"Text"
]
},
"nullable": [
false
]
},
"hash": "c060b8bbc5af7d2e7d0aaff64f0f62ec9db58611a99b0ba7f0375638b128ab89"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes)\n VALUES ($1, $2, $3, $4, $5, $6)",
"query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes, workspace_id)\n VALUES ($1, $2, $3, $4, $5, $6, $7)",
"describe": {
"columns": [],
"parameters": {
@@ -10,10 +10,11 @@
"Varchar",
"Timestamptz",
"Bool",
"TextArray"
"TextArray",
"Varchar"
]
},
"nullable": []
},
"hash": "34ad8a2a5bd89b9b8e25847a7e5e94ef99e35a178ad6328c1bcde2a6d6f88cb5"
"hash": "c624f15f3e321b1eecf123da9bf0b18e8c1d16ef25ffb9d04e5447d0d583d55c"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes, email FROM token WHERE workspace_id = $1 AND scopes @> ARRAY['run:script/' || $2]::text[]",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "token_prefix",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "expiration",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 6,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
null,
true,
false,
false,
true,
true
]
},
"hash": "c7ee7ce64686cef41cebd99ad7ef31572fc1bf12e6ae473fd58fafb025989965"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM token WHERE label LIKE 'webhook-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "d7a0f19f9e18d2ea49316012375ad78b69292ba091d69880945e42bebe890d66"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes, email FROM token WHERE workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "token_prefix",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "expiration",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 6,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
null,
true,
false,
false,
true,
true
]
},
"hash": "eff32aeac25a75d06f73e08c26dd3fd25f6b85cbea870505751c6a82457ae1da"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM token WHERE label LIKE 'email-%' AND workspace_id = $1 AND scopes @> ARRAY['run:script/' || $2]::text[]",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "f06e0e4fa358b26792df22fff48b71a6fcfa1e5603ea472892917c1accd1aafb"
}
+96
View File
@@ -4020,6 +4020,42 @@ paths:
schema:
$ref: "#/components/schemas/Script"
/w/{workspace}/scripts/get_triggers_count/{path}:
get:
summary: get triggers count of script
operationId: getTriggersCountOfScript
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: triggers count
content:
application/json:
schema:
$ref: "#/components/schemas/TriggersCount"
/w/{workspace}/scripts/list_tokens/{path}:
get:
summary: get tokens with script scope
operationId: listTokensOfScript
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: tokens list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TruncatedToken"
/w/{workspace}/scripts/get/draft/{path}:
get:
summary: get script by path with draft
@@ -4624,6 +4660,43 @@ paths:
schema:
$ref: "#/components/schemas/Flow"
/w/{workspace}/flows/get_triggers_count/{path}:
get:
summary: get triggers count of flow
operationId: getTriggersCountOfFlow
tags:
- flow
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: triggers count
content:
application/json:
schema:
$ref: "#/components/schemas/TriggersCount"
/w/{workspace}/flows/list_tokens/{path}:
get:
summary: get tokens with flow scope
operationId: listTokensOfFlow
tags:
- flow
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: tokens list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TruncatedToken"
/w/{workspace}/flows/toggle_workspace_error_handler/{path}:
post:
summary: Toggle ON and OFF the workspace error handler for a given flow
@@ -10254,6 +10327,8 @@ components:
type: array
items:
type: string
email:
type: string
required:
- token_prefix
- created_at
@@ -10271,6 +10346,8 @@ components:
type: array
items:
type: string
workspace_id:
type: string
NewTokenImpersonate:
type: object
@@ -10282,6 +10359,8 @@ components:
format: date-time
impersonate_email:
type: string
workspace_id:
type: string
required:
- impersonate_email
@@ -11118,6 +11197,23 @@ components:
- requires_auth
- http_method
TriggersCount:
type: object
properties:
primary_schedule:
type: object
properties:
schedule:
type: string
schedule_count:
type: number
http_routes_count:
type: number
webhook_count:
type: number
email_count:
type: number
Group:
type: object
properties:
+21
View File
@@ -9,6 +9,9 @@
use std::collections::HashMap;
use crate::db::ApiAuthed;
use crate::triggers::{
get_triggers_count_internal, list_tokens_internal, TriggersCount, TruncatedTokenWithEmail,
};
use crate::utils::WithStarredInfoQuery;
use crate::{
db::DB,
@@ -53,6 +56,8 @@ pub fn workspaced_service() -> Router {
.route("/update/*path", post(update_flow))
.route("/archive/*path", post(archive_flow_by_path))
.route("/delete/*path", delete(delete_flow_by_path))
.route("/get_triggers_count/*path", get(get_triggers_count))
.route("/list_tokens/*path", get(list_tokens))
.route("/get/*path", get(get_flow_by_path))
.route("/get/draft/*path", get(get_flow_by_path_w_draft))
.route("/exists/*path", get(exists_flow_by_path))
@@ -874,6 +879,22 @@ async fn update_flow(
Ok(nf.path.to_string())
}
async fn get_triggers_count(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<TriggersCount> {
let path = path.to_path();
get_triggers_count_internal(&db, &w_id, &path, true).await
}
async fn list_tokens(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Vec<TruncatedTokenWithEmail>> {
let path = path.to_path();
list_tokens_internal(&db, &w_id, &path, true).await
}
async fn get_flow_by_path(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
+1
View File
@@ -82,6 +82,7 @@ pub mod smtp_server_ee;
mod static_assets;
mod stripe_ee;
mod tracing_init;
mod triggers;
mod users;
mod utils;
mod variables;
+21
View File
@@ -9,6 +9,9 @@
use crate::{
db::{ApiAuthed, DB},
schedule::clear_schedule,
triggers::{
get_triggers_count_internal, list_tokens_internal, TriggersCount, TruncatedTokenWithEmail,
},
users::{maybe_refresh_folders, require_owner_of_path, AuthCache},
utils::WithStarredInfoQuery,
webhook_util::{WebhookMessage, WebhookShared},
@@ -132,6 +135,8 @@ pub fn workspaced_service() -> Router {
.route("/archive/p/*path", post(archive_script_by_path))
.route("/get/draft/*path", get(get_script_by_path_w_draft))
.route("/get/p/*path", get(get_script_by_path))
.route("/get_triggers_count/*path", get(get_triggers_count))
.route("/list_tokens/*path", get(list_tokens))
.route("/raw/p/*path", get(raw_script_by_path))
.route("/raw_unpinned/p/*path", get(raw_script_by_path_unpinned))
.route("/exists/p/*path", get(exists_script_by_path))
@@ -874,6 +879,22 @@ async fn get_script_by_path(
Ok(Json(script))
}
async fn list_tokens(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Vec<TruncatedTokenWithEmail>> {
let path = path.to_path();
list_tokens_internal(&db, &w_id, &path, false).await
}
async fn get_triggers_count(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<TriggersCount> {
let path = path.to_path();
get_triggers_count_internal(&db, &w_id, &path, false).await
}
async fn get_script_by_path_w_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
+131
View File
@@ -0,0 +1,131 @@
use axum::Json;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use windmill_common::error::JsonResult;
use crate::db::DB;
#[derive(Serialize, Deserialize, Debug)]
pub struct TriggerPrimarySchedule {
schedule: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TriggersCount {
primary_schedule: Option<TriggerPrimarySchedule>,
schedule_count: i64,
http_routes_count: i64,
webhook_count: i64,
email_count: i64,
}
pub(crate) async fn get_triggers_count_internal(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
) -> JsonResult<TriggersCount> {
let primary_schedule = sqlx::query_scalar!(
"SELECT schedule FROM schedule WHERE path = $1 AND script_path = $1 AND is_flow = $2 AND workspace_id = $3",
path,
is_flow,
w_id
)
.fetch_optional(db)
.await?;
let schedule_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM schedule WHERE script_path = $1 AND is_flow = $2 AND workspace_id = $3",
path,
is_flow,
w_id
)
.fetch_one(db)
.await?
.unwrap_or(0);
let http_routes_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM http_trigger WHERE script_path = $1 AND is_flow = $2 AND workspace_id = $3",
path,
is_flow,
w_id
)
.fetch_one(db)
.await?
.unwrap_or(0);
let webhook_count = (if is_flow {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'webhook-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
w_id,
path,
)
} else {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'webhook-%' AND workspace_id = $1 AND scopes @> ARRAY['run:' || $2]::text[]",
w_id,
path,
)
}).fetch_one(db)
.await?
.unwrap_or(0);
let email_count = (if is_flow {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'email-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
w_id,
path,
)
} else {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'email-%' AND workspace_id = $1 AND scopes @> ARRAY['run:script/' || $2]::text[]",
w_id,
path,
)
}).fetch_one(db)
.await?
.unwrap_or(0);
Ok(Json(TriggersCount {
primary_schedule: primary_schedule.map(|s| TriggerPrimarySchedule { schedule: s }),
schedule_count,
http_routes_count,
webhook_count,
email_count,
}))
}
#[derive(FromRow, Serialize)]
pub struct TruncatedTokenWithEmail {
pub label: Option<String>,
pub token_prefix: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub scopes: Option<Vec<String>>,
pub email: Option<String>,
}
pub async fn list_tokens_internal(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
) -> JsonResult<Vec<TruncatedTokenWithEmail>> {
let tokens = if is_flow {
sqlx::query_as!(
TruncatedTokenWithEmail,
"SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes, email FROM token WHERE workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
w_id, path).fetch_all(db)
.await?
} else {
sqlx::query_as!(
TruncatedTokenWithEmail,
"SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes, email FROM token WHERE workspace_id = $1 AND scopes @> ARRAY['run:script/' || $2]::text[]",
w_id, path)
.fetch_all(db)
.await?
};
Ok(Json(tokens))
}
+14 -5
View File
@@ -128,7 +128,13 @@ pub fn make_unauthed_service() -> Router {
fn username_override_from_label(label: Option<String>) -> Option<String> {
match label {
Some(label) if label.starts_with("webhook-") => Some(label),
Some(label)
if label.starts_with("webhook-")
|| label.starts_with("http-")
|| label.starts_with("email-") =>
{
Some(label)
}
Some(label) if label.starts_with("ephemeral-script-end-user-") => Some(
label
.trim_start_matches("ephemeral-script-end-user-")
@@ -270,9 +276,10 @@ impl AuthCache {
_ => {
let user_o = sqlx::query_as::<_, (Option<String>, Option<String>, bool, Option<Vec<String>>, Option<String>)>(
"UPDATE token SET last_used_at = now() WHERE token = $1 AND (expiration > NOW() \
OR expiration IS NULL) RETURNING owner, email, super_admin, scopes, label",
OR expiration IS NULL) AND (workspace_id IS NULL OR workspace_id = $2) RETURNING owner, email, super_admin, scopes, label",
)
.bind(token)
.bind(w_id.as_ref())
.fetch_optional(&self.db)
.await
.ok()
@@ -837,6 +844,7 @@ pub struct NewToken {
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub impersonate_email: Option<String>,
pub scopes: Option<Vec<String>>,
pub workspace_id: Option<String>,
}
#[derive(Deserialize)]
@@ -2389,14 +2397,15 @@ async fn create_token(
.unwrap_or(false);
sqlx::query!(
"INSERT INTO token
(token, email, label, expiration, super_admin, scopes)
VALUES ($1, $2, $3, $4, $5, $6)",
(token, email, label, expiration, super_admin, scopes, workspace_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
token,
authed.email,
new_token.label,
new_token.expiration,
is_super_admin,
new_token.scopes.as_ref().map(|x| x.as_slice())
new_token.scopes.as_ref().map(|x| x.as_slice()),
new_token.workspace_id,
)
.execute(&mut *tx)
.await?;
@@ -1,28 +0,0 @@
Error parsing /git/windmill/backend/windmill-api/openapi.yaml: duplicated mapping key (8350:3)
8347 | application/json:
8348 | schema: {}
8349 |
8350 | /w/{workspace}/job_helpers/loa ...
----------^
8351 | get:
8352 | summary: Load a preview of ...
ParserError: Error parsing /git/windmill/backend/windmill-api/openapi.yaml: duplicated mapping key (8350:3)
8347 | application/json:
8348 | schema: {}
8349 |
8350 | /w/{workspace}/job_helpers/loa ...
----------^
8351 | get:
8352 | summary: Load a preview of ...
at Object.parse (/git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/parsers/yaml.js:44:23)
at getResult (/git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/util/plugins.js:116:22)
at runNextPlugin (/git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/util/plugins.js:64:32)
at /git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/util/plugins.js:55:9
at new Promise (<anonymous>)
at Object.run (/git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/util/plugins.js:54:12)
at parseFile (/git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/parse.js:130:38)
at parse (/git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/parse.js:56:30)
at async $RefParser.parse (/git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/index.js:115:28)
at async $RefParser.resolve (/git/windmill/frontend/node_modules/@apidevtools/json-schema-ref-parser/dist/lib/index.js:145:13)
@@ -6,6 +6,7 @@
import AssignableTagsInner from './AssignableTagsInner.svelte'
export let placement: 'bottom-end' | 'top-end' = 'bottom-end'
export let color: 'nord' | 'dark' = 'dark'
</script>
<Popup
@@ -13,7 +14,7 @@
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
>
<svelte:fragment slot="button">
<Button color="dark" size="xs" nonCaptureEvent={true}>
<Button {color} size="xs" nonCaptureEvent={true}>
<div class="flex flex-row gap-1 items-center"
><Pen size={14} /> Custom Tags&nbsp;<Tooltip light
>Tags are assigned to scripts and flows. Workers only accept jobs that correspond to their
+4 -5
View File
@@ -203,7 +203,7 @@
<div class="w-full flex space-x-8">
<div class="w-full flex flex-col gap-4">
<Label label="Cron">
<Label label="Cron" class="font-semibold" primary={true}>
<svelte:fragment slot="error">
{#if !validCRON}
<div class="text-red-600 text-xs"> Invalid cron syntax </div>
@@ -219,10 +219,10 @@
{disabled}
/>
</Label>
<Label label="Timezone">
<Label label="Timezone" class="font-semibold" primary>
{#if disabled}
<div>
<Badge>{timezone}</Badge>
<Badge><span class="text-primary-inverse">{timezone}</span></Badge>
</div>
{:else}
<Select
@@ -390,9 +390,8 @@
</div>
<div class="w-full flex flex-col space-y-2">
<div class="text-sm font-semibold leading-none">Execution summary</div>
<div class="text-sm font-semibold leading-none">Estimated upcoming events ({timezone})</div>
<div class="flex flex-col space-y-2">
<div class="text-sm">Estimated upcoming events ({timezone})</div>
<div class="flex flex-col rounded-md p-4 border text-tertiary bg-surface-secondary gap-0.5">
{#each preview as date}
<span class="text-sm">{dateFormatter(new Date(date))}</span>
+14 -11
View File
@@ -15,7 +15,8 @@
WorkspaceService,
type InputTransform,
type RawScript,
type PathScript
type PathScript,
type TriggersCount
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { copilotInfo, userStore, workspaceStore } from '$lib/stores'
@@ -41,14 +42,13 @@
import { workspacedOpenai } from './copilot/lib'
import type { FlowCopilotContext, FlowCopilotModule } from './copilot/flow'
import { pickScript } from './flows/flowStateUtils'
import type { Schedule } from './flows/scheduleUtils'
import {
approximateFindPythonRelativePath,
isTypescriptRelativePath,
parseTypescriptDeps
} from '$lib/relative_imports'
import Tooltip from './Tooltip.svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
$: token = $page.url.searchParams.get('wm_token') ?? undefined
$: workspace = $page.url.searchParams.get('workspace') ?? undefined
$: themeDarkRaw = $page.url.searchParams.get('activeColorTheme')
@@ -467,13 +467,7 @@
}
const flowStateStore = writable({} as FlowState)
const scheduleStore = writable<Schedule>({
args: {},
cron: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: false,
summary: undefined
})
const previewArgsStore = writable<Record<string, any>>({})
const scriptEditorDrawer = writable(undefined)
const moving = writable<{ module: FlowModule; modules: FlowModule[] } | undefined>(undefined)
@@ -481,10 +475,19 @@
const testStepStore = writable<Record<string, any>>({})
const selectedIdStore = writable('settings-metadata')
const selectedTriggerStore = writable<'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes'>(
'webhooks'
)
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount
})
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
schedule: scheduleStore,
previewArgs: previewArgsStore,
scriptEditorDrawer,
moving,
+119 -75
View File
@@ -9,7 +9,8 @@
ScriptService,
type OpenFlow,
type RawScript,
type InputTransform
type InputTransform,
type TriggersCount
} from '$lib/gen'
import { initHistory, push, redo, undo } from '$lib/history'
import {
@@ -40,7 +41,6 @@
import { dfs, getPreviousIds } from './flows/previousResults'
import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte'
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import { loadFlowSchedule, type Schedule } from './flows/scheduleUtils'
import type { FlowEditorContext, FlowInput } from './flows/types'
import { cleanInputs, emptyFlowModuleState } from './flows/utils'
import {
@@ -82,6 +82,7 @@
import Summary from './Summary.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
import { type TriggerContext, type ScheduleTrigger } from './triggers'
export let initialPath: string = ''
export let pathStoreInit: string | undefined = undefined
@@ -100,30 +101,47 @@
export let customUi: FlowBuilderWhitelabelCustomUi = {}
export let disableAi: boolean = false
export let disabledFlowInputs = false
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined
$: setContext('customUi', customUi)
const dispatch = createEventDispatcher()
async function createSchedule(path: string) {
const { cron, timezone, args, enabled, summary } = $scheduleStore
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
const triggersCount = writable<TriggersCount | undefined>(
savedPrimarySchedule
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
: undefined
)
try {
await ScheduleService.createSchedule({
workspace: $workspaceStore!,
requestBody: {
path: path,
schedule: formatCron(cron),
timezone,
script_path: path,
is_flow: true,
args,
enabled,
summary
}
})
} catch (err) {
sendUserToast(`The primary schedule could not be created: ${err}`, true)
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
loadTriggers()
}
async function createSchedule(path: string) {
if ($primaryScheduleStore) {
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
try {
await ScheduleService.createSchedule({
workspace: $workspaceStore!,
requestBody: {
path: path,
schedule: formatCron(cron),
timezone,
script_path: path,
is_flow: true,
args,
enabled,
summary
}
})
} catch (err) {
sendUserToast(`The primary schedule could not be created: ${err}`, true)
}
} else {
sendUserToast('The primary schedule could not be created: no schedule data', true)
}
}
@@ -187,7 +205,8 @@
typ: 'flow',
value: {
...flow,
path: $pathStore
path: $pathStore,
primary_schedule: $primaryScheduleStore
}
}
})
@@ -241,7 +260,7 @@
// console.log('flow', computeUnlockedSteps(flow)) // del
// loadingSave = false // del
// return
const { cron, timezone, args, enabled, summary } = $scheduleStore
if (newFlow) {
try {
localStorage.removeItem('flow')
@@ -264,7 +283,7 @@
deployment_message: deploymentMsg || undefined
}
})
if (enabled) {
if ($primaryScheduleStore && $primaryScheduleStore.enabled) {
await createSchedule($pathStore)
}
} else {
@@ -284,31 +303,40 @@
workspace: $workspaceStore ?? '',
path: initialPath
})
if (
JSON.stringify(schedule.args) != JSON.stringify(args) ||
schedule.schedule != cron ||
schedule.timezone != timezone ||
schedule.summary != summary
) {
await ScheduleService.updateSchedule({
if ($primaryScheduleStore) {
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
if (
JSON.stringify(schedule.args) != JSON.stringify(args) ||
schedule.schedule != cron ||
schedule.timezone != timezone ||
schedule.summary != summary
) {
await ScheduleService.updateSchedule({
workspace: $workspaceStore ?? '',
path: initialPath,
requestBody: {
schedule: formatCron(cron),
timezone,
args,
summary
}
})
}
if (enabled != schedule.enabled) {
await ScheduleService.setScheduleEnabled({
workspace: $workspaceStore ?? '',
path: initialPath,
requestBody: { enabled }
})
}
} else if (scheduleExists) {
await ScheduleService.deleteSchedule({
workspace: $workspaceStore ?? '',
path: initialPath,
requestBody: {
schedule: formatCron(cron),
timezone,
args,
summary
}
path: $pathStore
})
}
if (enabled != schedule.enabled) {
await ScheduleService.setScheduleEnabled({
workspace: $workspaceStore ?? '',
path: initialPath,
requestBody: { enabled }
})
}
} else if (enabled) {
} else if ($primaryScheduleStore && $primaryScheduleStore.enabled) {
await createSchedule(initialPath)
}
@@ -359,7 +387,8 @@
encodeState({
flow: $flowStore,
path: $pathStore,
selectedId: $selectedIdStore
selectedId: $selectedIdStore,
primarySchedule: $primaryScheduleStore
})
)
} catch (err) {
@@ -369,18 +398,14 @@
}
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
const selectedTriggerStore = writable<'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes'>(
'webhooks'
)
export function getSelectedId() {
return $selectedIdStore
}
const scheduleStore = writable<Schedule>({
summary: undefined,
args: {},
cron: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: false
})
const previewArgsStore = writable<Record<string, any>>(initialArgs)
const scriptEditorDrawer = writable<ScriptEditorDrawer | undefined>(undefined)
const moving = writable<{ module: FlowModule; modules: FlowModule[] } | undefined>(undefined)
@@ -395,10 +420,14 @@
selectedIdStore.set(selectedId)
}
function selectTrigger(selectedTrigger: 'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes') {
selectedTriggerStore.set(selectedTrigger)
}
let insertButtonOpen = writable<boolean>(false)
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
schedule: scheduleStore,
previewArgs: previewArgsStore,
scriptEditorDrawer,
moving,
@@ -414,25 +443,31 @@
insertButtonOpen
})
async function loadSchedule() {
loadFlowSchedule(initialPath, $workspaceStore!)
.then((schedule: Schedule) => {
scheduleStore.set(schedule)
})
.catch(() => {
scheduleStore.set({
summary: undefined,
cron: '0 */5 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
args: {},
enabled: false
})
})
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount
})
async function loadTriggers() {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: $workspaceStore!,
path: initialPath
})
if ($primaryScheduleStore && $triggersCount.primary_schedule == undefined) {
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount.schedule_count ?? 0) + 1,
primary_schedule: {
schedule: $primaryScheduleStore.cron
}
}
}
}
$: selectedId && select(selectedId)
$: initialPath && initialPath != '' && $workspaceStore && loadSchedule()
$: initialPath && initialPath != '' && $workspaceStore && loadTriggers()
function onKeyDown(event: KeyboardEvent) {
let classes = event.target?.['className']
@@ -699,10 +734,13 @@
}
if (module.type === 'trigger') {
if (!$scheduleStore.cron) {
$scheduleStore.cron = '0 */15 * * *'
$primaryScheduleStore = {
summary: 'Scheduled poll of flow',
args: {},
cron: '0 */15 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
}
$scheduleStore.enabled = true
}
const flowModule: FlowModule & {
@@ -1152,7 +1190,7 @@
</div>
<div class="gap-4 flex-row hidden md:flex w-full max-w-md">
{#if $scheduleStore.enabled}
{#if $primaryScheduleStore != undefined ? $primaryScheduleStore && $primaryScheduleStore?.enabled : $triggersCount?.primary_schedule}
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: Calendar }}
@@ -1160,10 +1198,15 @@
color="light"
size="xs"
on:click={async () => {
select('settings-schedule')
select('triggers')
selectTrigger('schedules')
}}
>
{$scheduleStore.cron ?? ''}
{$primaryScheduleStore != undefined
? $primaryScheduleStore
? $primaryScheduleStore?.cron
: ''
: $triggersCount?.primary_schedule?.schedule}
</Button>
{/if}
@@ -1334,6 +1377,7 @@
on:reload={() => {
renderCount += 1
}}
{newFlow}
/>
{:else}
<CenteredPage>Loading...</CenteredPage>
@@ -13,6 +13,7 @@
description?: string
value: FlowValue
schema?: any
path?: string
}
export let overflowAuto = false
@@ -32,20 +33,25 @@
class:overflow-auto={overflowAuto}
>
<FlowGraphV2
path={flow?.path}
{download}
minHeight={400}
modules={flow?.value?.modules}
failureModule={flow?.value?.failure_module}
preprocessorModule={flow?.value?.preprocessor_module}
on:select={(e) => {
if (e?.detail === 'failure') {
let nodeId = e?.detail
if (nodeId === 'triggers') {
dispatch('triggerDetail')
return
} else if (nodeId === 'failure') {
stepDetail = flow?.value?.failure_module
} else if (e?.detail === 'preprocessor') {
} else if (nodeId === 'preprocessor') {
stepDetail = flow?.value?.preprocessor_module
} else {
stepDetail = dfs(flow?.value?.modules ?? [], (m) => m).find((m) => m?.id === e?.detail)
}
stepDetail = stepDetail ?? e?.detail
stepDetail = stepDetail ?? nodeId
dispatch('select', stepDetail)
}}
/>
@@ -76,9 +76,8 @@
size="xs"
startIcon={{ icon: Clipboard }}
btnClasses="absolute top-2 right-2 w-min z-20"
>
Copy content
</Button>
iconOnly
/>
<div class={shouldDisplayLoadMore ? 'code-container' : ''}>
<Highlight
+2 -1
View File
@@ -1,11 +1,12 @@
<script lang="ts">
export let label: string | undefined = undefined
export let primary = false
</script>
<div class={$$props.class}>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex flex-row items-center gap-1">
<span class="text-secondary text-sm leading-6">{label}</span>
<span class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6">{label}</span>
<slot name="header" />
</div>
<slot name="error" />
+47 -65
View File
@@ -24,8 +24,6 @@
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import FolderEditor from './FolderEditor.svelte'
import { random_adj } from './random_positive_adjetive'
import Required from './Required.svelte'
import Tooltip from './Tooltip.svelte'
import { Eye, Folder, Plus, SearchCode, User } from 'lucide-svelte'
type PathKind =
@@ -162,7 +160,7 @@
.map((x) => ({
name: x,
write:
($userStore?.folders?.includes(x) == true ?? false) ||
$userStore?.folders?.includes(x) == true ||
($userStore?.is_admin ?? false) ||
($userStore?.is_super_admin ?? false)
}))
@@ -322,54 +320,54 @@
<div>
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 pb-0 mb-1">
{#if meta != undefined}
<div class="flex gap-x-4 shrink">
<!-- svelte-ignore a11y-label-has-associated-control -->
{#if !hideUser}
<div class="block">
<span class="text-secondary text-sm whitespace-nowrap">&nbsp;</span>
<ToggleButtonGroup
class="mt-0.5"
bind:selected={meta.ownerKind}
on:selected={(e) => {
setDirty()
const kind = e.detail
if (meta) {
if (kind === 'folder') {
meta.owner = folders?.[0]?.name ?? ''
} else if (kind === 'group') {
meta.owner = 'all'
} else {
meta.owner = $userStore?.username?.split('@')[0] ?? ''
}
<!-- svelte-ignore a11y-label-has-associated-control -->
{#if !hideUser}
<div class="block">
<ToggleButtonGroup
class="mt-0.5"
bind:selected={meta.ownerKind}
on:selected={(e) => {
setDirty()
const kind = e.detail
if (meta) {
if (kind === 'folder') {
meta.owner = folders?.[0]?.name ?? ''
} else if (kind === 'group') {
meta.owner = 'all'
} else {
meta.owner = $userStore?.username?.split('@')[0] ?? ''
}
}}
>
<ToggleButton
icon={User}
{disabled}
light
size="xs"
value="user"
position="left"
label="User"
/>
<!-- <ToggleButton light size="xs" value="group" position="center">Group</ToggleButton> -->
<ToggleButton
icon={Folder}
{disabled}
light
size="xs"
value="folder"
position="right"
label="Folder"
/>
</ToggleButtonGroup>
</div>
{/if}
}
}}
>
<ToggleButton
icon={User}
{disabled}
light
size="xs"
value="user"
position="left"
label="User"
/>
<!-- <ToggleButton light size="xs" value="group" position="center">Group</ToggleButton> -->
<ToggleButton
icon={Folder}
{disabled}
light
size="xs"
value="folder"
position="right"
label="Folder"
/>
</ToggleButtonGroup>
</div>
{/if}
{#if !hideUser}
<div class="text-xl">/</div>
{/if}
<div>
{#if meta.ownerKind === 'user'}
<label class="block shrink min-w-0">
<span class="text-secondary text-sm">User</span>
<input
class="!w-36"
type="text"
@@ -381,16 +379,6 @@
</label>
{:else if meta.ownerKind === 'folder'}
<label class="block grow w-48">
<span class="text-secondary text-sm">
Folder
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/groups_and_folders"
>
Read and write permissions are given to groups and users at the folder level and
shared by all items inside the folder.
</Tooltip>
</span>
<div class="flex flex-row items-center gap-1 w-full">
<select class="grow w-full" {disabled} bind:value={meta.owner}>
{#if folders?.length == 0}
@@ -426,14 +414,8 @@
</label>
{/if}
</div>
<span class="text-xl">/</span>
<label class="block grow w-full max-w-md">
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>
Name
<Required required={true} />
</div>
<div class="text-2xs text-tertiary"> '/' for subfolders </div>
</div>
<!-- svelte-ignore a11y-autofocus -->
<input
{disabled}
@@ -0,0 +1,86 @@
<script lang="ts">
import { Badge, Button } from './common'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import Toggle from './Toggle.svelte'
import { ListOrdered, PenBox } from 'lucide-svelte'
import JobArgs from './JobArgs.svelte'
import Tooltip from './Tooltip.svelte'
import ScheduleEditor from './ScheduleEditor.svelte'
import { type Schedule } from '$lib/gen'
export let schedule: any
export let can_write: boolean
export let path: string
export let isFlow: boolean
export let scheduleEditor: ScheduleEditor
export let setScheduleEnabled: (path: string, enabled: boolean) => void
$: schedule = typeof schedule === 'boolean' ? undefined : (schedule as Schedule | undefined)
</script>
<div class="flex flex-col gap-2 grow w-full">
<div class="grid grid-cols-3 w-full">
<div class="flex justify-start">
<Badge color="indigo" small>
Primary&nbsp;
<Tooltip light>
Share the same path as the script or flow it is attached to and its path get renamed
whenever the source path is renamed
</Tooltip>
</Badge>
</div>
<div class="flex justify-center">
<div class="flex flex-row gap-2 items-center">
<input
size="9"
class="!text-xs !h-6 !text-primary"
type="text"
id="cron-schedule"
name="cron-schedule"
placeholder="*/30 * * * *"
value={schedule?.schedule ?? ''}
disabled={true}
/>
<Toggle
checked={schedule?.enabled ?? false}
on:change={(e) => {
if (can_write) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
}
}}
options={{
right: 'On'
}}
size="xs"
textClass="text-primary font-normal text-xs"
/>
</div>
</div>
<div class="flex justify-end">
<div class="flex flex-row gap-2 items-center">
<Button size={'xs'} variant="border" color="light" href={`${base}/runs/${path}`}>
<span>Runs</span>
<ListOrdered size={14} />
</Button>
<Button
size="xs"
color="dark"
on:click={() => scheduleEditor?.openEdit(path ?? '', isFlow)}
>
<PenBox size={14} />
</Button>
</div>
</div>
</div>
{#if Object.keys(schedule?.args ?? {}).length > 0}
<div class="">
<JobArgs args={schedule?.args ?? {}} />
</div>
{:else}
<div class="text-xs text-tertiary"> No arguments </div>
{/if}
</div>
@@ -1,178 +1,294 @@
<script lang="ts">
import ScheduleEditor from './ScheduleEditor.svelte'
import { Badge, Button } from './common'
import { Button } from './common'
import { workspaceStore } from '$lib/stores'
import { ScheduleService, type Schedule } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import Toggle from './Toggle.svelte'
import { ListOrdered, Calendar, PenBox } from 'lucide-svelte'
import JobArgs from './JobArgs.svelte'
import { Calendar, Trash, Save } from 'lucide-svelte'
import Skeleton from './common/skeleton/Skeleton.svelte'
import Tooltip from './Tooltip.svelte'
import Label from '$lib/components/Label.svelte'
import { getContext } from 'svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import CronInput from './CronInput.svelte'
import SchemaForm from './SchemaForm.svelte'
import { emptyString, sendUserToast } from '$lib/utils'
import Toggle from './Toggle.svelte'
export let schema: any
export let isFlow: boolean
export let path: string
export let can_write: boolean
export let newItem: boolean = false
const { primarySchedule, triggersCount } = getContext<TriggerContext>('TriggerContext')
let scheduleEditor: ScheduleEditor
let schedule: Schedule | false | undefined = undefined
$: loadSchedules(false) || path
let schedules: Schedule[] | undefined = undefined
$: path && loadSchedule()
$: path && loadSchedules()
async function loadSchedule() {
try {
let exists = await ScheduleService.existsSchedule({
workspace: $workspaceStore ?? '',
path
})
if (exists) {
schedule = await ScheduleService.getSchedule({
workspace: $workspaceStore ?? '',
path
})
} else {
schedule = false
let initialPrimarySchedule: ScheduleTrigger | false | undefined = undefined
async function loadSchedules(forceRefresh: boolean) {
if (!path || path == '') {
schedules = []
if ($primarySchedule == undefined) {
$primarySchedule = false
}
} catch (e) {
console.log('no primary schedule')
initialPrimarySchedule = structuredClone($primarySchedule)
return
}
}
async function loadSchedules() {
try {
schedules = (
await ScheduleService.listSchedules({
workspace: $workspaceStore ?? '',
path,
isFlow
})
).filter((s) => s.path != path)
} catch (e) {
console.error('impossible to load schedules')
}
}
async function setScheduleEnabled(path: string, enabled: boolean): Promise<void> {
try {
await ScheduleService.setScheduleEnabled({
path,
workspace: $workspaceStore!,
requestBody: { enabled }
const allSchedules = await ScheduleService.listSchedules({
workspace: $workspaceStore ?? '',
path: path,
isFlow
})
loadSchedule()
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)
sendUserToast(`Schedule ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
sendUserToast(`Cannot ` + (enabled ? 'disable' : 'enable') + ` schedule: ${err}`, true)
loadSchedule()
$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 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)
}
</script>
<ScheduleEditor
on:update={() => {
loadSchedule()
loadSchedules()
loadSchedules(true)
}}
bind:this={scheduleEditor}
/>
<div class="p-2 flex flex-col">
<Button
on:click={() => scheduleEditor?.openNew(isFlow, path)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Calendar }}
>
New Schedule
</Button>
</div>
<div class="flex flex-col gap-4 w-full">
{#if $primarySchedule == undefined}
<Skeleton layout={[[12]]} />
{:else if $primarySchedule}
<div class="w-full flex flex-col mb-4">
{#if can_write}
<div class="w-full flex-row-reverse flex mb-2">
<div class="flex flex-row gap-4">
<Button
on:click={() => {
$primarySchedule = false
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount?.schedule_count ?? 1) - 1,
primary_schedule: undefined
}
}}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Trash }}
/>
{#if initialPrimarySchedule && !newItem}
<Toggle
disabled={emptyString($primarySchedule.cron)}
bind:checked={$primarySchedule.enabled}
options={{
right: 'Enabled'
}}
on:change={async (e) => {
if (!newItem) {
await ScheduleService.setScheduleEnabled({
path: path,
workspace: $workspaceStore ?? '',
requestBody: { enabled: e.detail }
})
{#if schedule}
<div class="p-2 flex flex-col gap-2">
<div class="flex flex-row justify-between h-8">
<div class="flex flex-row gap-2 items-center">
<input
class="inline-block !w-32"
type="text"
id="cron-schedule"
name="cron-schedule"
placeholder="*/30 * * * *"
value={schedule.schedule}
disabled={true}
/>
<div>
<Badge color="indigo" small
>Primary&nbsp;<Tooltip light
>Share the same path as the script or flow it is attached to and its path get renamed
whenever the source path is renamed</Tooltip
></Badge
>
</div>
</div>
<div class="flex flex-row gap-2 items-center">
<Toggle
checked={schedule.enabled}
on:change={(e) => {
if (can_write) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
}
}}
options={{
right: 'On'
}}
size="xs"
/>
<Button size="xs" variant="border" color="light" href={`${base}/runs/${path}`}>
<div class="flex flex-row gap-2">
<ListOrdered size={14} />
Runs
sendUserToast(`${e.detail ? 'enabled' : 'disabled'} schedule ${path}`)
}
}}
/>
{/if}
{#if !newItem}
<Button
on:click={save}
color="dark"
size="sm"
startIcon={{ icon: Save }}
disabled={JSON.stringify({ ...$primarySchedule, enabled: true }) ==
JSON.stringify({ ...initialPrimarySchedule, enabled: true })}
>Apply changes now</Button
>
{:else}
<div class="text-sm text-secondary mt-1 text-center"
>Deployed automatically with {isFlow ? 'flow' : 'script'}</div
>
{/if}
</div>
</Button>
<Button
size="xs"
color="dark"
on:click={() => scheduleEditor?.openEdit(path ?? '', isFlow)}
>
<PenBox size={14} />
</Button>
</div>
</div>
{#if Object.keys(schedule?.args ?? {}).length > 0}
<div class="">
<JobArgs args={schedule.args ?? {}} />
</div>
{:else}
<div class="text-xs texg-gray-700"> No arguments </div>
{/if}
</div>
{:else if schedule == undefined}
<Skeleton layout={[[6]]} />
{/if}
<h3 class="px-2 pt-4">Other schedules</h3>
{#if schedules}
{#if schedules.length == 0}
<div class="text-xs text-secondary px-2"> No other schedules </div>
{:else}
<div class="flex flex-col divide-y px-2 pt-2">
{#each schedules as schedule (schedule.path)}
<div class="grid grid-cols-6 text-2xs items-center py-2"
><div class="col-span-3 truncate">{schedule.path}</div><div>{schedule.schedule}</div>
<div>{schedule.enabled ? 'on' : 'off'}</div>
<button on:click={() => scheduleEditor?.openEdit(schedule.path, isFlow)}>Edit</button>
</div>
{/each}
{/if}
<!-- svelte-ignore a11y-autofocus -->
<Label label="Summary" class="font-semibold" primary>
<input
autofocus
type="text"
placeholder="Short summary to be displayed when listed"
class="text-sm w-full"
bind:value={$primarySchedule.summary}
/>
</Label>
</div>
<CronInput bind:schedule={$primarySchedule.cron} bind:timezone={$primarySchedule.timezone} />
<SchemaForm {schema} bind:args={$primarySchedule.args} />
{#if emptyString($primarySchedule.cron)}
<p class="text-xs text-tertiary mt-10">Define a schedule frequency first</p>
{/if}
{#if initialPrimarySchedule != false}
<div class="flex">
<Button size="xs" color="light" on:click={() => scheduleEditor?.openEdit(path, isFlow)}
>Advanced</Button
>
</div>
{/if}
{:else}
<div class="flex flex-row gap-4 mt-2">
<Button
on:click={() => {
$primarySchedule = {
summary: '',
args: {},
cron: '0 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 != false && !newItem}
<Button on:click={save} color="dark" size="md" startIcon={{ icon: Save }}>
Apply changes now
</Button>
{:else}
<div class="text-sm text-center text-secondary mt-2"
>Deployed automatically with {isFlow ? 'flow' : 'script'}</div
>
{/if}
</div>
<CronInput schedule={''} disabled timezone={Intl.DateTimeFormat().resolvedOptions().timeZone} />
<SchemaForm disabled {schema} />
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
{#if !newItem}
<div class="mt-10" />
{#if $primarySchedule}
<Button
on:click={() => scheduleEditor?.openNew(isFlow, path)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Calendar }}
>
New Schedule
</Button>
{/if}
<Label label="Other schedules">
{#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)}
<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">
<div>{schedule.schedule}</div>
<div>{schedule.enabled ? 'on' : 'off'}</div>
</div>
<div class="flex justify-end">
<button
on:click={() => scheduleEditor?.openEdit(schedule.path, isFlow)}
class="px-2">Edit</button
>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</Label>
{/if}
</div>
@@ -520,13 +520,14 @@
<Toggle
disabled={!can_write}
checked={enabled}
options={{ right: 'enable', left: 'disable' }}
options={{ right: 'Enabled' }}
on:change={async (e) => {
await ScheduleService.setScheduleEnabled({
path: initialPath,
workspace: $workspaceStore ?? '',
requestBody: { enabled: e.detail }
})
dispatch('update')
sendUserToast(`${e.detail ? 'enabled' : 'disabled'} schedule ${initialPath}`)
}}
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { emptySchema } from '$lib/utils'
import { copyToClipboard, emptySchema } from '$lib/utils'
import Highlight from 'svelte-highlight'
import json from 'svelte-highlight/languages/json'
@@ -10,6 +10,8 @@
import Tabs from './common/tabs/Tabs.svelte'
import { Badge } from './common'
import HighlightTheme from './HighlightTheme.svelte'
import Button from './common/button/Button.svelte'
import { ClipboardCopy } from 'lucide-svelte'
export let schema: Schema | undefined | any = emptySchema()
@@ -79,7 +81,19 @@
{/if}
</TabContent>
<TabContent value="advanced">
<Highlight language={json} code={JSON.stringify(schema, null, 4)} />
<div class="h-full relative">
<Button
wrapperClasses="absolute top-2 right-2 z-20"
on:click={() => copyToClipboard(JSON.stringify(schema, null, 4))}
color="light"
size="xs2"
startIcon={{
icon: ClipboardCopy
}}
iconOnly
/>
<Highlight language={json} code={JSON.stringify(schema, null, 4)} />
</div>
</TabContent>
</div>
</svelte:fragment>
+106 -67
View File
@@ -5,7 +5,8 @@
ScriptService,
type NewScriptWithDraft,
ScheduleService,
type Script
type Script,
type TriggersCount
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { initialCode } from '$lib/script_helpers'
@@ -55,19 +56,15 @@
import type Editor from './Editor.svelte'
import WorkerTagPicker from './WorkerTagPicker.svelte'
import MetadataGen from './copilot/MetadataGen.svelte'
import ScriptSchedules from './ScriptSchedules.svelte'
import { writable } from 'svelte/store'
import {
type ScriptSchedule,
loadScriptSchedule,
defaultScriptLanguages,
processLangs
} from '$lib/scripts'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, setContext } from 'svelte'
import CustomPopover from './CustomPopover.svelte'
import Summary from './Summary.svelte'
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import TriggersEditor from './triggers/TriggersEditor.svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
export let script: NewScript
export let fullyLoaded: boolean = true
@@ -84,6 +81,7 @@
export let replaceStateFn: (url: string) => void = (url) =>
window.history.replaceState(null, '', url)
export let customUi: ScriptBuilderWhitelabelCustomUi = {}
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined
let metadataOpen =
!neverShowMeta &&
@@ -95,29 +93,47 @@
let editor: Editor | undefined = undefined
let scriptEditor: ScriptEditor | undefined = undefined
let scheduleStore = writable<ScriptSchedule>({
summary: '',
cron: '0 */5 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
args: {},
enabled: false
})
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
const triggersCount = writable<TriggersCount | undefined>(
savedPrimarySchedule
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
: undefined
)
const selectedTriggerStore = writable<'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes'>(
'webhooks'
)
async function loadSchedule() {
const scheduleRes = await loadScriptSchedule(initialPath, $workspaceStore!)
if (scheduleRes) {
scheduleStore.set(scheduleRes)
}
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
loadTriggers()
}
const dispatch = createEventDispatcher()
$: {
if (initialPath != '') {
loadSchedule()
$: initialPath != '' && loadTriggers()
async function loadTriggers() {
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: $workspaceStore!,
path: initialPath
})
if ($primaryScheduleStore && $triggersCount.primary_schedule == undefined) {
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount.schedule_count ?? 0) + 1,
primary_schedule: {
schedule: $primaryScheduleStore.cron
}
}
}
}
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount
})
const enterpriseLangs = ['bigquery', 'snowflake', 'mssql']
export function setCode(code: string): void {
@@ -180,7 +196,8 @@
})
}
$: !disableHistoryChange && replaceStateFn('#' + encodeState(script))
$: !disableHistoryChange &&
replaceStateFn('#' + encodeState({ script, primarySchedule: $primaryScheduleStore }))
if (script.content == '') {
initContent(script.language, script.kind, template)
@@ -200,7 +217,10 @@
}
async function createSchedule(path: string) {
const { cron, timezone, args, enabled, summary } = $scheduleStore
if (!$primaryScheduleStore) {
return
}
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
try {
await ScheduleService.createSchedule({
@@ -269,43 +289,53 @@
}
})
const { enabled, timezone, args, cron, summary } = $scheduleStore
const scheduleExists = await ScheduleService.existsSchedule({
workspace: $workspaceStore ?? '',
path: script.path
})
console.log('initialPath', initialPath)
const scheduleExists =
initialPath != '' &&
(await ScheduleService.existsSchedule({
workspace: $workspaceStore ?? '',
path: script.path
}))
if ($primaryScheduleStore) {
const { enabled, timezone, args, cron, summary } = $primaryScheduleStore
if (scheduleExists) {
const schedule = await ScheduleService.getSchedule({
if (scheduleExists) {
const schedule = await ScheduleService.getSchedule({
workspace: $workspaceStore ?? '',
path: script.path
})
if (
JSON.stringify(schedule.args) != JSON.stringify(args) ||
schedule.schedule != cron ||
schedule.timezone != timezone ||
schedule.summary != summary
) {
await ScheduleService.updateSchedule({
workspace: $workspaceStore ?? '',
path: script.path,
requestBody: {
schedule: formatCron(cron),
timezone,
args,
summary
}
})
}
if (enabled != schedule.enabled) {
await ScheduleService.setScheduleEnabled({
workspace: $workspaceStore ?? '',
path: script.path,
requestBody: { enabled }
})
}
} else if (enabled) {
await createSchedule(script.path)
}
} else if (scheduleExists) {
await ScheduleService.deleteSchedule({
workspace: $workspaceStore ?? '',
path: script.path
})
if (
JSON.stringify(schedule.args) != JSON.stringify(args) ||
schedule.schedule != cron ||
schedule.timezone != timezone ||
schedule.summary != summary
) {
await ScheduleService.updateSchedule({
workspace: $workspaceStore ?? '',
path: script.path,
requestBody: {
schedule: formatCron(cron),
timezone,
args,
summary
}
})
}
if (enabled != schedule.enabled) {
await ScheduleService.setScheduleEnabled({
workspace: $workspaceStore ?? '',
path: script.path,
requestBody: { enabled }
})
}
} else if (enabled) {
await createSchedule(script.path)
}
savedScript = structuredClone(script) as NewScriptWithDraft
@@ -405,7 +435,7 @@
requestBody: {
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
typ: 'script',
value: script
value: { ...script, primary_schedule: $primaryScheduleStore }
}
})
@@ -481,7 +511,7 @@
let path: Path | undefined = undefined
let dirtyPath = false
let selectedTab: 'metadata' | 'runtime' | 'ui' | 'schedule' = 'metadata'
let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = 'metadata'
let deploymentMsg = ''
let msgInput: HTMLInputElement | undefined = undefined
@@ -520,7 +550,7 @@
cannot be inferred from the type directly.
</Tooltip>
</Tab>
<Tab value="schedule" active={$scheduleStore.enabled}>Schedule</Tab>
<Tab value="triggers">Triggers</Tab>
<svelte:fragment slot="content">
<div
class={selectedTab === 'ui' ? 'p-0' : 'p-4'}
@@ -1026,8 +1056,16 @@
<TabContent value="ui" class="h-full">
<ScriptSchema bind:schema={script.schema} />
</TabContent>
<TabContent value="schedule">
<ScriptSchedules {initialPath} schema={script.schema} schedule={scheduleStore} />
<TabContent value="triggers">
<TriggersEditor
{initialPath}
schema={script.schema}
noEditor={true}
isFlow={false}
currentPath={script.path}
newItem={initialPath == ''}
/>
<!-- <ScriptSchedules {initialPath} schema={script.schema} schedule={scheduleStore} /> -->
</TabContent>
</div>
</svelte:fragment>
@@ -1056,7 +1094,7 @@
</div>
<div class="gap-4 flex">
{#if $scheduleStore.enabled}
{#if $primaryScheduleStore && $primaryScheduleStore?.enabled}
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: Calendar }}
@@ -1065,10 +1103,11 @@
size="xs"
on:click={async () => {
metadataOpen = true
selectedTab = 'schedule'
selectedTab = 'triggers'
$selectedTriggerStore = 'schedules'
}}
>
{$scheduleStore.cron ?? ''}
{$primaryScheduleStore?.cron ?? ''}
</Button>
{/if}
{#if customUi?.topBar?.path != false}
@@ -74,7 +74,7 @@
let args: Record<string, any> = initialArgs
let isValid: boolean = true
let scriptProgress = undefined;
let scriptProgress = undefined
// Test
let testIsLoading = false
@@ -106,7 +106,7 @@
function runTest() {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset();
if (jobProgressReset) jobProgressReset()
//@ts-ignore
testJobLoader.runPreview(
path,
@@ -155,6 +155,7 @@
setLicense()
export async function setCollaborationMode() {
await setLicense()
if (!$enterpriseLicense) {
sendUserToast(`Multiplayer is an enterprise feature`, true, [
{
@@ -177,7 +178,7 @@
wsProvider = new WebsocketProvider(
`${wsProtocol}://${window.location.host}/ws_mp/`,
$workspaceStore + '/' + path ?? 'no-room-name',
$workspaceStore + '/' + (path ?? 'no-room-name'),
ydoc,
{ connect: false }
)
@@ -432,10 +433,15 @@
{diffEditor}
{args}
>
{#if scriptProgress}
<!-- Put to the slot in logpanel -->
<JobProgressBar job={testJob} bind:scriptProgress bind:reset={jobProgressReset} compact={true} />
{/if}
{#if scriptProgress}
<!-- Put to the slot in logpanel -->
<JobProgressBar
job={testJob}
bind:scriptProgress
bind:reset={jobProgressReset}
compact={true}
/>
{/if}
</LogPanel>
</Pane>
</Splitpanes>
@@ -1,110 +0,0 @@
<script lang="ts">
import CronInput from '$lib/components/CronInput.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { emptyString } from '$lib/utils'
import { Alert, Button, Skeleton } from '$lib/components/common'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import { ScheduleService, type Schedule } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Calendar } from 'lucide-svelte'
import type { Writable } from 'svelte/store'
import type { ScriptSchedule } from '$lib/scripts'
import Label from './Label.svelte'
export let initialPath: string
export let schema: Record<string, any> | undefined
export let schedule: Writable<ScriptSchedule>
// const { schedule, flowStore, pathStore, initialPath } =
// getContext<FlowEditorContext>('FlowEditorContext')
let schedules: Schedule[] | undefined = undefined
async function loadSchedules() {
try {
schedules = (
await ScheduleService.listSchedules({
workspace: $workspaceStore ?? '',
path: initialPath,
isFlow: false
})
).filter((s) => s.path != initialPath)
} catch (e) {
console.error('impossible to load schedules')
}
}
let scheduleEditor: ScheduleEditor
$: initialPath && loadSchedules()
</script>
<div class="w-full flex flex-col gap-4 mb-4">
<!-- svelte-ignore a11y-autofocus -->
<Label label="Summary">
<input
autofocus
type="text"
placeholder="Short summary to be displayed when listed"
class="text-sm w-full"
bind:value={$schedule.summary}
/>
</Label>
</div>
<CronInput bind:schedule={$schedule.cron} bind:timezone={$schedule.timezone} />
<div class="mt-10" />
<SchemaForm {schema} bind:args={$schedule.args} />
{#if emptyString($schedule.cron)}
<p class="text-xs text-tertiary mt-10">Define a schedule frequency first</p>
{/if}
<div class="mt-10" />
<Toggle
disabled={emptyString($schedule.cron)}
bind:checked={$schedule.enabled}
options={{
right: 'Schedule enabled'
}}
/>
<Alert bgClass="my-4" type="warning" title="Changes only applied upon deploy">
Changes to the primary schedule are only applied upon deploy. Other schedules' changes are applied
immediately.
</Alert>
{#if initialPath != ''}
<ScheduleEditor
on:update={() => {
loadSchedules()
}}
bind:this={scheduleEditor}
/>
<h2 class="pt-7">Other schedules</h2>
<div class="py-4 flex">
<Button
on:click={() => scheduleEditor?.openNew(false, initialPath)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Calendar }}
>
New Schedule
</Button>
</div>
{#if schedules}
{#if schedules.length == 0}
<div class="text-xs text-secondary px-2"> No other schedules </div>
{:else}
<div class="flex flex-col divide-y px-2 pt-2 max-w-lg">
{#each schedules as schedule (schedule.path)}
<div class="grid grid-cols-6 text-2xs items-center py-2"
><div class="col-span-3 truncate">{schedule.path}</div><div>{schedule.schedule}</div>
<div>{schedule.enabled ? 'on' : 'off'}</div>
<button on:click={() => scheduleEditor?.openEdit(schedule.path, false)}>Edit</button>
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
{/if}
@@ -48,6 +48,9 @@
{/if}
</h2>
<slot name="action" />
{#if collapsable && collapsed}
<slot name="badge" />
{/if}
</div>
<div class={collapsable && collapsed ? `hidden ${$$props.class}` : `${$$props.class}`}>
<slot />
+2 -1
View File
@@ -4,6 +4,7 @@
import { onMount } from 'svelte'
import Button from './common/button/Button.svelte'
import type { ToastAction } from '$lib/toast'
import { processMessage } from './toast'
export let message: string
export let toastId: string
@@ -37,7 +38,7 @@
{/if}
</div>
<div class="ml-3 flex-1 w-0">
<p class="text-sm text-secondary break-words">{message}</p>
<p class="text-sm text-secondary break-words">{@html processMessage(message)}</p>
{#if errorMessage}
<p
class="text-sm text-secondary border bg-surface-secondary p-2 w-full overflow-auto mt-2"
+19 -5
View File
@@ -3,6 +3,7 @@
import { createEventDispatcher } from 'svelte'
import { twMerge } from 'tailwind-merge'
import Tooltip from './Tooltip.svelte'
import { AlertTriangle } from 'lucide-svelte'
export let options: {
left?: string
@@ -14,14 +15,17 @@
export let disabled = false
export let textClass = ''
export let textStyle = ''
export let color: 'blue' | 'red' = 'blue'
export let color: 'blue' | 'red' | 'nord' = 'blue'
export let id = (Math.random() + 1).toString(36).substring(10)
export let lightMode: boolean = false
export let eeOnly: boolean = false
export let size: 'sm' | 'xs' = 'sm'
const dispatch = createEventDispatcher()
const bothOptions = Boolean(options.left) && Boolean(options.right)
export let textDisabled = false
</script>
<label
@@ -34,7 +38,7 @@
<span
class={twMerge(
'mr-2 font-medium duration-50 select-none',
bothOptions ? (checked ? 'text-disabled' : 'text-primary') : 'text-primary',
bothOptions || textDisabled ? (checked ? 'text-disabled' : 'text-primary') : 'text-primary',
size === 'xs' ? 'text-xs' : 'text-sm',
textClass
)}
@@ -64,10 +68,12 @@
/>
<div
class={classNames(
"transition-all bg-surface-selected rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all ",
"transition-all bg-surface-selected rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
color == 'red'
? 'peer-checked:bg-red-600'
: 'peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500',
: color == 'blue'
? 'peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500'
: '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'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3'
@@ -78,7 +84,7 @@
<span
class={twMerge(
'ml-2 font-medium duration-50 select-none',
bothOptions ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
size === 'xs' ? 'text-xs' : 'text-sm',
textClass
)}
@@ -92,3 +98,11 @@
{/if}
<slot name="right" />
</label>
{#if eeOnly && disabled}
<span
class="inline-flex text-xs text-primary items-center gap-1 !text-yellow-500 whitespace-nowrap ml-8"
>
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</span>
{/if}
@@ -25,6 +25,7 @@
export let scopes: string[] | undefined = undefined
export let newTokenLabel: string | undefined = undefined
export let newTokenWorkspace: string | undefined = undefined
export let newToken: string | undefined = undefined
let newPassword: string | undefined
@@ -79,7 +80,12 @@
date.setDate(date.getDate() + newTokenExpiration)
}
newToken = await UserService.createToken({
requestBody: { label: newTokenLabel, expiration: date?.toISOString(), scopes } as NewToken
requestBody: {
label: newTokenLabel,
expiration: date?.toISOString(),
scopes,
workspace_id: newTokenWorkspace
} as NewToken
})
dispatch('tokenCreated', newToken)
listTokens()
@@ -71,5 +71,5 @@
}}
startIcon={{ icon: RotateCw }}
/>
<AssignableTags placement={popupPlacement} />
<AssignableTags placement={popupPlacement} color="nord" />
</div>
@@ -49,9 +49,8 @@
size="sm"
startIcon={{ icon: Clipboard }}
btnClasses="absolute top-2 right-2 w-min z-20"
>
Copy content
</Button>
iconOnly
/>
{#key rawType}
<Highlight
class="overflow-auto px-1 flex-1"
@@ -114,6 +114,13 @@
contained:
'bg-surface border-transparent hover:bg-surface-hover focus-visible:bg-surface-hover text-primary focus-visible:ring-surface-selected',
divider: 'divide-x divide-gray-200 dark:divide-gray-700'
},
nord: {
border:
'border-nord-200 bg-surface hover:bg-surface-hover focus-visible:bg-surface-hover text-primary hover:text-secondary focus-visible:text-secondary focus-visible:ring-surface-selected-inverse dark:border-nord-200',
contained:
'bg-nord-300 hover:bg-nord-0 focus-visible:bg-surface-hover-inverse text-primary-inverse focus-visible:ring-surface-selected-inverse dark:bg-nord-400 dark:hover:bg-nord-600 dark:text-primary-inverse',
divider: 'divide-x divide-gray-800 dark:divide-gray-200'
}
}
@@ -30,7 +30,7 @@
<button
class={twMerge(
'border-b-2 py-1 px-2 cursor-pointer transition-all z-10 ease-linear font-normal text-primary',
'border-b-2 py-1 px-2 cursor-pointer transition-all z-10 ease-linear font-normal text-tertiary',
isSelected
? 'wm-tab-active font-main'
: 'border-gray-300 dark:border-gray-600 border-opacity-0 hover:border-opacity-100 ',
@@ -13,12 +13,12 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="px-2 py-1 border flex items-center cursor-pointer bg-surface-secondary text-primary justify-between rounded-md"
class="grow min-w-0 w-full px-2 py-1 border flex items-center cursor-pointer bg-surface-secondary text-primary justify-between rounded-md"
on:click={(e) => {
e.preventDefault()
copyToClipboard(content)
}}
>
<div class="text-xs truncate whitespace-pre-wrap w-11/12">{content}</div>
<Clipboard size={14} class="w-1/12" />
<div class="text-xs truncate whitespace-no-wrap grow">{content}</div>
<Clipboard size={12} class="flex-shrink-0" />
</div>
@@ -1,25 +1,17 @@
<script lang="ts">
import { Tabs, Tab, TabContent } from '$lib/components/common'
import { CalendarCheck2, MailIcon, Route, Terminal, Webhook } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import HighlightTheme from '../HighlightTheme.svelte'
import FlowViewerInner from '../FlowViewerInner.svelte'
import DetailPageTriggerPanel from './DetailPageTriggerPanel.svelte'
export let triggerSelected: 'webhooks' | 'email' | 'schedule' | 'cli' | 'routes' = 'webhooks'
export let triggerSelected: 'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' = 'webhooks'
export let flow_json: any | undefined = undefined
export let hasStepDetails: boolean = false
export let isOperator: boolean = false
export let selected: string
$: if (hasStepDetails) {
selected = 'flow_step'
}
// When we no longer have a selected flow step, switch to saved inputs
$: !hasStepDetails && selected === 'flow_step' && (selected = 'saved_inputs')
</script>
<HighlightTheme />
@@ -27,79 +19,38 @@
<Splitpanes horizontal class="h-full">
<Pane size={100}>
<Tabs bind:selected>
<Tab value="saved_inputs">Saved inputs</Tab>
<Tab value="saved_inputs">Saved Inputs</Tab>
{#if !isOperator}
<Tab value="details">Details & Triggers</Tab>
<Tab value="triggers">Triggers</Tab>
{/if}
{#if flow_json}
<Tab value="raw">Raw</Tab>
<Tab value="raw">Export</Tab>
{/if}
{#if hasStepDetails}
{#if selected == 'flow_step'}
<Tab value="flow_step">Step</Tab>
{/if}
{#if !flow_json && !isOperator}
<Tab value="schema">Schema</Tab>
{/if}
<svelte:fragment slot="content">
<div class="overflow-hidden" style="height:calc(100% - 32px);">
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="details" class="flex flex-col flex-1 h-full">
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={20}>
<slot name="details" />
</Pane>
<Pane size={50} minSize={20}>
<Tabs bind:selected={triggerSelected}>
<Tab value="webhooks">
<span class="flex flex-row gap-2 items-center">
<Webhook size={14} />
Webhooks
</span>
</Tab>
<Tab value="schedule">
<span class="flex flex-row gap-2 items-center">
<CalendarCheck2 size={14} />
Schedules
</span>
</Tab>
<Tab value="routes">
<span class="flex flex-row gap-2 items-center">
<Route size={14} />
HTTP
</span>
</Tab>
<Tab value="email">
<span class="flex flex-row gap-2 items-center">
<MailIcon size={14} />
Email
</span>
</Tab>
<Tab value="cli">
<span class="flex flex-row gap-2 items-center">
<Terminal size={14} />
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 === 'email'}
<slot name="email" />
{:else if triggerSelected === 'schedule'}
<slot name="schedule" />
{:else if triggerSelected === 'cli'}
<slot name="cli" />
{/if}
</div>
</div>
</Pane>
</Splitpanes>
<TabContent value="schema" class="flex flex-col flex-1 h-full">
<slot name="schema" />
</TabContent>
<TabContent value="raw" class="flex flex-col flex-1 h-full overflow-auto">
<TabContent value="triggers" class="flex flex-col flex-1 h-full pt-2">
<DetailPageTriggerPanel bind:triggerSelected>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
</TabContent>
<TabContent value="raw" class="flex flex-col flex-1 h-full overflow-auto p-2">
<FlowViewerInner flow={flow_json} />
</TabContent>
<TabContent value="flow_step" class="flex flex-col flex-1 h-full">
@@ -7,6 +7,9 @@
import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte'
import { twMerge } from 'tailwind-merge'
import { userStore } from '$lib/stores'
import { createEventDispatcher, getContext } from 'svelte'
import type { TriggerContext } from '../triggers'
import { Calendar } from 'lucide-svelte'
type MainButton = {
label: string
@@ -22,6 +25,8 @@
color?: 'red'
}
const { triggersCount, selectedTrigger } = getContext<TriggerContext>('TriggerContext')
export let mainButtons: MainButton[] = []
export let menuItems: MenuItemButton[] = []
export let title: string
@@ -30,6 +35,8 @@
export let errorHandlerKind: 'flow' | 'script'
export let scriptOrFlowPath: string
export let errorHandlerMuted: boolean | undefined
const dispatch = createEventDispatcher()
</script>
<div class="border-b p-2 shadow-md">
@@ -47,6 +54,22 @@
<Badge>tag: {tag}</Badge>
{/if}
<slot />
{#if $triggersCount?.primary_schedule}
<Button
btnClasses="inline-flex"
startIcon={{ icon: Calendar }}
variant="contained"
color="light"
size="xs"
on:click={() => {
$selectedTrigger = 'schedules'
dispatch('triggerDetail')
}}
>
{$triggersCount?.primary_schedule?.schedule ?? ''}
</Button>
{/if}
<slot name="trigger-badges" />
</div>
<div class="flex gap-1 md:gap-2 items-center">
{#if menuItems.length > 0}
@@ -3,16 +3,31 @@
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import DetailPageDetailPanel from './DetailPageDetailPanel.svelte'
import type { ScheduleTrigger, TriggerContext } from '../triggers'
import { setContext } from 'svelte'
import { writable, type Writable } from 'svelte/store'
import type { TriggersCount } from '$lib/gen'
import DetailPageTriggerPanel from './DetailPageTriggerPanel.svelte'
export let isOperator: boolean = false
export let flow_json: any | undefined = undefined
export let hasStepDetails: boolean = false
export let selected: string
export let triggerSelected: 'webhooks' | 'schedule' | 'cli' = 'webhooks'
export let triggersCount: Writable<TriggersCount | undefined>
let mobileTab: 'form' | 'detail' = 'form'
let clientWidth = window.innerWidth
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const selectedTriggerStore = writable<'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes'>(
'webhooks'
)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount
})
</script>
<main class="h-screen w-full" bind:clientWidth>
@@ -26,20 +41,20 @@
</Pane>
<Pane size={35} minSize={15}>
<DetailPageDetailPanel
bind:triggerSelected
bind:triggerSelected={$selectedTriggerStore}
bind:selected
{isOperator}
{flow_json}
{hasStepDetails}
>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="email" name="email" />
<slot slot="schedule" name="schedule" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
<slot slot="details" name="details" />
<slot slot="save_inputs" name="save_inputs" />
<slot slot="flow_step" name="flow_step" />
<slot slot="schema" name="schema" />
</DetailPageDetailPanel>
</Pane>
</Splitpanes>
@@ -50,23 +65,26 @@
<slot name="header" />
<Tabs bind:selected={mobileTab}>
<Tab value="form">Run form</Tab>
<Tab value="detail">Details</Tab>
<Tab value="saved_inputs">Saved Inputs</Tab>
{#if !isOperator}
<Tab value="triggers">Triggers</Tab>
{/if}
<svelte:fragment slot="content">
<TabContent value="form" class="flex flex-col flex-1 h-full">
<slot name="form" />
</TabContent>
<TabContent value="detail" class="flex flex-col flex-1 h-full">
<DetailPageDetailPanel bind:triggerSelected bind:selected {isOperator} {hasStepDetails}>
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="triggers" class="flex flex-col flex-1 h-full">
<DetailPageTriggerPanel bind:triggerSelected={$selectedTriggerStore}>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="email" name="email" />
<slot slot="schedule" name="schedule" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
<slot slot="details" name="details" />
<slot slot="save_inputs" name="save_inputs" />
<slot slot="flow_step" name="flow_step" />
</DetailPageDetailPanel>
</DetailPageTriggerPanel>
</TabContent>
</svelte:fragment>
</Tabs>
@@ -0,0 +1,59 @@
<script lang="ts">
import { Tabs, Tab } from '$lib/components/common'
import { CalendarCheck2, MailIcon, Route, Terminal, Webhook } from 'lucide-svelte'
import HighlightTheme from '../HighlightTheme.svelte'
export let triggerSelected: 'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' = 'webhooks'
</script>
<HighlightTheme />
<Tabs bind:selected={triggerSelected}>
<Tab value="webhooks">
<span class="flex flex-row gap-2 items-center">
<Webhook size={14} />
Webhooks
</span>
</Tab>
<Tab value="schedules">
<span class="flex flex-row gap-2 items-center">
<CalendarCheck2 size={14} />
Schedules
</span>
</Tab>
<Tab value="routes">
<span class="flex flex-row gap-2 items-center">
<Route size={14} />
HTTP
</span>
</Tab>
<Tab value="emails">
<span class="flex flex-row gap-2 items-center">
<MailIcon size={14} />
Email
</span>
</Tab>
<Tab value="cli">
<span class="flex flex-row gap-2 items-center">
<Terminal size={14} />
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 === 'cli'}
<slot name="cli" />
{/if}
</div>
</div>
@@ -14,7 +14,8 @@
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import { AlertTriangle } from 'lucide-svelte'
import Skeleton from '../common/skeleton/Skeleton.svelte'
import Label from '$lib/components/Label.svelte'
import TriggerTokens from '../triggers/TriggerTokens.svelte'
let userSettings: UserSettings
export let token: string
@@ -33,6 +34,7 @@
})) as any) ?? null
loading = false
}
getEmailDomain()
let requestType: 'hash' | 'path' = 'path'
@@ -49,6 +51,12 @@
.toLowerCase()
return `${pathOrHash}+${encodedPrefix}@${emailDomain}`
}
export let email: string = ''
$: email = emailAddress()
let triggerTokens: TriggerTokens | undefined = undefined
</script>
<HighlightTheme />
@@ -57,18 +65,20 @@
bind:this={userSettings}
on:tokenCreated={(e) => {
token = e.detail
triggerTokens?.listTokens()
}}
newTokenWorkspace={$workspaceStore}
newTokenLabel={`${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
{scopes}
/>
<div class="p-2 flex flex-col w-full gap-4">
<div class="flex flex-col w-full gap-4">
{#if loading}
<Skeleton layout={[[18]]} />
{:else}
{#if emailDomain}
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<div class="my-2">
<Label label="Token">
<div class="flex flex-row justify-between gap-2">
<input
bind:value={token}
@@ -89,7 +99,7 @@
Create/input a valid token before copying the email address below
</div>
{/if}
</div>
</Label>
{/if}
{#if !isFlow}
@@ -103,20 +113,19 @@
</div>
</div>
{/if}
<div class="flex flex-col gap-4">
{#key requestType}
{#key token}
<div class="flex flex-col gap-2">
<ClipboardPanel title="Email address" content={emailAddress()} />
</div>
{/key}
{#key requestType}
{#key token}
<Label label="Email address">
<ClipboardPanel content={email} />
</Label>
{/key}
<Alert title="Email trigger" size="xs">
To trigger the job by email, send an email to the address above. The job will receive two
arguments: `raw_email` containing the raw email as string, and `parsed_email` containing
the parsed email as an object.
</Alert>
</div>
{/key}
<Alert title="Email trigger" size="xs">
To trigger the job by email, send an email to the address above. The job will receive two
arguments: `raw_email` containing the raw email as string, and `parsed_email` containing the
parsed email as an object.
</Alert>
{:else}
<div>
<Alert title="Email triggers are disabled" size="xs" type="warning">
@@ -133,5 +142,7 @@
Email triggers on Windmill Community Edition are limited to 100 emails per day.
</Alert>
{/if}
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="email" />
{/if}
</div>
@@ -0,0 +1,67 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import { FlowService, ScriptService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
export let kind: 'script' | 'flow'
export let scriptOrFlowPath: string
export let errorHandlerMuted: boolean | undefined
export let textDisabled = false
let toggleState = errorHandlerMuted
export let color: 'nord' | 'red' | 'blue' = 'nord'
async function toggleErrorHandler(): Promise<void> {
toggleState = !toggleState
if ($workspaceStore !== undefined) {
try {
if (kind === 'flow') {
await FlowService.toggleWorkspaceErrorHandlerForFlow({
workspace: $workspaceStore,
path: scriptOrFlowPath,
requestBody: {
muted: !errorHandlerMuted
}
})
} else {
await ScriptService.toggleWorkspaceErrorHandlerForScript({
workspace: $workspaceStore,
path: scriptOrFlowPath,
requestBody: {
muted: !errorHandlerMuted
}
})
}
} catch (error) {
sendUserToast(
`Error while toggling Workspace Error Handler: ${error.body || error.message}`,
true
)
toggleState = false
return
}
errorHandlerMuted = !errorHandlerMuted
toggleState = errorHandlerMuted
sendUserToast(
errorHandlerMuted ? 'Workspace error handler muted' : 'Workspace error handler active',
false
)
}
toggleState = false
}
</script>
<Toggle
{color}
textClass="font-normal text-sm"
size="xs"
checked={toggleState}
on:change={toggleErrorHandler}
options={{
right: 'Mute',
rightTooltip: 'Disable workspace error handler, EE only'
}}
{textDisabled}
/>
@@ -18,6 +18,7 @@
export let disableSettings = false
export let disabledFlowInputs = false
export let smallErrorHandler = false
export let newFlow: boolean = false
let size = 50
@@ -48,6 +49,7 @@
{disableAi}
{disableSettings}
{smallErrorHandler}
{newFlow}
bind:modules={$flowStore.value.modules}
on:reload
/>
@@ -62,7 +64,7 @@
</div>
</div>
{:else}
<FlowEditorPanel {disabledFlowInputs} enableAi={!disableAi} />
<FlowEditorPanel {disabledFlowInputs} {newFlow} enableAi={!disableAi} />
{/if}
</Pane>
</Splitpanes>
@@ -7,6 +7,7 @@
import FlowInput from './FlowInput.svelte'
import FlowFailureModule from './FlowFailureModule.svelte'
import FlowConstants from './FlowConstants.svelte'
import TriggersEditor from '../../triggers/TriggersEditor.svelte'
import type { FlowModule } from '$lib/gen'
import { initFlowStepWarnings } from '../utils'
import { dfs } from '../dfs'
@@ -14,9 +15,10 @@
export let noEditor = false
export let enableAi = false
export let newFlow = false
export let disabledFlowInputs = false
const { selectedId, flowStore, flowStateStore, flowInputsStore } =
const { selectedId, flowStore, flowStateStore, flowInputsStore, pathStore, initialPath } =
getContext<FlowEditorContext>('FlowEditorContext')
function checkDup(modules: FlowModule[]): string | undefined {
@@ -67,6 +69,15 @@
<FlowFailureModule {noEditor} />
{:else if $selectedId === 'preprocessor'}
<FlowPreprocessorModule {noEditor} />
{:else if $selectedId === 'triggers'}
<TriggersEditor
currentPath={$pathStore}
{initialPath}
schema={$flowStore.schema}
{noEditor}
newItem={newFlow}
isFlow={true}
/>
{:else}
{@const dup = checkDup($flowStore.value.modules)}
{#if dup}
@@ -20,14 +20,17 @@
import FlowWhileLoop from './FlowWhileLoop.svelte'
import { initFlowStepWarnings } from '../utils'
import { dfs } from '../dfs'
import type { TriggerContext } from '$lib/components/triggers'
export let flowModule: FlowModule
export let noEditor: boolean = false
export let enableAi = false
const { selectedId, schedule, flowStateStore, flowInputsStore, flowStore } =
const { selectedId, flowStateStore, flowInputsStore, flowStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const { primarySchedule } = getContext<TriggerContext>('TriggerContext')
let scriptKind: 'script' | 'trigger' | 'approval' = 'script'
let scriptTemplate: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' = 'script'
@@ -37,6 +40,26 @@
// Pointer to previous module, for easy access to testing results
export let previousModule: FlowModule | undefined = undefined
function initializePrimaryScheduleForTriggerScript(module: FlowModule) {
if (!$primarySchedule) {
$primarySchedule = {
summary: 'Scheduled poll of flow',
args: {},
cron: '0 */15 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
}
}
if (!$primarySchedule.cron) {
$primarySchedule.cron = '0 */15 * * *'
}
$primarySchedule.enabled = true
module.stop_after_if = {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
skip_if_stopped: true
}
}
async function createModuleFromScript(
path: string,
summary: string,
@@ -50,15 +73,7 @@
}
if (kind == 'trigger') {
if (!$schedule.cron) {
$schedule.cron = '0 */15 * * *'
}
$schedule.enabled = true
module.stop_after_if = {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
skip_if_stopped: true
}
initializePrimaryScheduleForTriggerScript(module)
}
flowModule = module
@@ -140,15 +155,7 @@
scriptTemplate = subkind
if (kind == 'trigger') {
if (!$schedule.cron) {
$schedule.cron = '0 */15 * * *'
}
$schedule.enabled = true
module.stop_after_if = {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
skip_if_stopped: true
}
initializePrimaryScheduleForTriggerScript(module)
}
if (kind == 'approval') {
@@ -1,106 +0,0 @@
<script lang="ts">
import CronInput from '$lib/components/CronInput.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { emptyString } from '$lib/utils'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import { Alert, Button, Skeleton } from '$lib/components/common'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import { ScheduleService, type Schedule } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Calendar } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
const { schedule, flowStore, initialPath } = getContext<FlowEditorContext>('FlowEditorContext')
let schedules: Schedule[] | undefined = undefined
async function loadSchedules() {
try {
schedules = (
await ScheduleService.listSchedules({
workspace: $workspaceStore ?? '',
path: initialPath,
isFlow: true
})
).filter((s) => s.path != initialPath)
} catch (e) {
console.error('impossible to load schedules')
}
}
let scheduleEditor: ScheduleEditor
$: initialPath && loadSchedules()
</script>
<div class="w-full flex flex-col gap-4 mb-4">
<!-- svelte-ignore a11y-autofocus -->
<Label label="Summary">
<input
autofocus
type="text"
placeholder="Short summary to be displayed when listed"
class="text-sm w-full"
bind:value={$schedule.summary}
/>
</Label>
</div>
<CronInput bind:schedule={$schedule.cron} bind:timezone={$schedule.timezone} />
<div class="mt-10" />
<SchemaForm schema={$flowStore.schema} bind:args={$schedule.args} />
{#if emptyString($schedule.cron)}
<p class="text-xs text-tertiary mt-10">Define a schedule frequency first</p>
{/if}
<div class="mt-10" />
<Toggle
disabled={emptyString($schedule.cron)}
bind:checked={$schedule.enabled}
options={{
right: 'Schedule enabled'
}}
/>
<Alert bgClass="my-4" type="warning" title="Changes only applied upon deploy">
Changes to the primary schedule are only applied upon deploy. Other schedules' changes are applied
immediately.
</Alert>
{#if initialPath != ''}
<ScheduleEditor
on:update={() => {
loadSchedules()
}}
bind:this={scheduleEditor}
/>
<h2 class="pt-7">Other schedules</h2>
<div class="py-4 flex">
<Button
on:click={() => scheduleEditor?.openNew(true, initialPath)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Calendar }}
>
New Schedule
</Button>
</div>
{#if schedules}
{#if schedules.length == 0}
<div class="text-xs text-secondary px-2"> No other schedules </div>
{:else}
<div class="flex flex-col divide-y px-2 pt-2 max-w-lg">
{#each schedules as schedule (schedule.path)}
<div class="grid grid-cols-6 text-2xs items-center py-2"
><div class="col-span-3 truncate">{schedule.path}</div><div>{schedule.schedule}</div>
<div>{schedule.enabled ? 'on' : 'off'}</div>
<button on:click={() => scheduleEditor?.openEdit(schedule.path, true)}>Edit</button>
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
{/if}
@@ -1,348 +1,195 @@
<script lang="ts">
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
import { BROWSER } from 'esm-env'
import { base } from '$lib/base'
import Path from '$lib/components/Path.svelte'
import FlowCard from '../common/FlowCard.svelte'
import FlowSchedules from './FlowSchedules.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { Alert, Button, SecondsInput } from '$lib/components/common'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import Slider from '$lib/components/Slider.svelte'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import { copyToClipboard } from '$lib/utils'
import Tooltip from '$lib/components/Tooltip.svelte'
import { AlertTriangle, Clipboard } from 'lucide-svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { schemaToObject } from '$lib/schema'
import type { Schema } from '$lib/common'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import ErrorHandlerToggleButton from '$lib/components/details/ErrorHandlerToggleButton.svelte'
import ErrorHandlerToggleButtonV2 from '$lib/components/details/ErrorHandlerToggleButtonV2.svelte'
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
import MetadataGen from '$lib/components/copilot/MetadataGen.svelte'
import Badge from '$lib/components/Badge.svelte'
import { AlertTriangle } from 'lucide-svelte'
export let noEditor: boolean
const { selectedId, flowStore, initialPath, previewArgs, pathStore, schedule, customUi } =
const { flowStore, initialPath, previewArgs, pathStore, customUi } =
getContext<FlowEditorContext>('FlowEditorContext')
let hostname = BROWSER ? window.location.protocol + '//' + window.location.host : 'SSR'
$: url = `${hostname}/api/w/${$workspaceStore}/jobs/run/f/${$pathStore}`
$: syncedUrl = `${hostname}/api/w/${$workspaceStore}/jobs/run_wait_result/f/${$pathStore}`
function asSchema(x: any) {
return x as Schema
}
let path: Path | undefined = undefined
let dirtyPath = false
let displayWorkerTagPicker = false
$: $flowStore.tag ? (displayWorkerTagPicker = true) : null
$: activeAdvancedOptions = [
{
name: 'High Priority',
active: $flowStore.value.priority !== undefined && $flowStore.value.priority > 0
},
{ name: 'Error Handler Muted', active: Boolean($flowStore.ws_error_handler_muted) },
{ name: 'Invisible to Others', active: Boolean($flowStore.visible_to_runner_only) },
{ name: 'Shared Directory', active: Boolean($flowStore.value.same_worker) },
{ name: 'Cache Results', active: Boolean($flowStore.value.cache_ttl) },
{ name: 'Early Stop', active: Boolean($flowStore.value.skip_expr) },
{ name: 'Early Return', active: Boolean($flowStore.value.early_return) },
{ name: 'Dedicated Worker', active: Boolean($flowStore.dedicated_worker) },
{ name: 'Concurrent Limit', active: Boolean($flowStore.value.concurrent_limit) },
{ name: 'Worker Tag', active: displayWorkerTagPicker }
]
$: numberOfAdvancedOptionsOn = activeAdvancedOptions.filter((option) => option.active).length
$: activeAdvancedOptionNames = activeAdvancedOptions
.filter((option) => option.active)
.map((option) => option.name)
</script>
<div class="h-full overflow-hidden">
<div class="h-full overflow-y-auto flex flex-col">
<FlowCard {noEditor} title="Settings">
<div class="h-full flex-1">
<!-- svelte-ignore a11y-autofocus -->
<Tabs bind:selected={$selectedId}>
<Tab value="settings-metadata">Metadata</Tab>
{#if !noEditor && customUi?.settingsTabs?.schedule != false}
<Tab value="settings-schedule" active={$schedule.enabled}>Schedule</Tab>
{/if}
{#if customUi?.settingsTabs?.sharedDiretory != false}
<Tab value="settings-same-worker" active={$flowStore.value.same_worker}>
Shared Directory
</Tab>
{/if}
{#if customUi?.settingsTabs?.earlyStop != false}
<Tab value="settings-early-stop" active={Boolean($flowStore.value.skip_expr)}>
Early Stop
</Tab>
{/if}
{#if customUi?.settingsTabs?.earlyReturn != false}
<Tab value="settings-early-return" active={Boolean($flowStore.value.early_return)}>
Early Return
</Tab>
<div class="grow min-h-0 p-4 h-full flex flex-col gap-8">
<!-- Metadata Section -->
<div class="gap-8 flex flex-col">
<Label label="Summary">
<MetadataGen
bind:content={$flowStore.summary}
promptConfigName="flowSummary"
flow={$flowStore.value}
on:change={() => {
if (initialPath == '' && $flowStore.summary?.length > 0 && !dirtyPath) {
path?.setName(
$flowStore.summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
elementProps={{
type: 'text',
id: 'flow-summary',
placeholder: 'Short summary to be displayed when listed'
}}
/>
</Label>
{#if !noEditor}
<Label label="Path">
<Path
autofocus={false}
bind:this={path}
bind:dirty={dirtyPath}
bind:path={$pathStore}
{initialPath}
namePlaceholder="flow"
kind="flow"
/>
</Label>
{/if}
<Label label="Description">
<MetadataGen
bind:content={$flowStore.description}
promptConfigName="flowDescription"
flow={$flowStore.value}
class="w-full"
elementType="textarea"
elementProps={{
id: 'inp',
placeholder: 'What this flow does and how to use it.'
}}
/>
</Label>
</div>
<!-- Deployable Section -->
<Section
label="Advanced"
collapsable={true}
small={true}
class="h-full grow min-h-0 flex flex-col gap-4"
>
<!-- Worker Group Section -->
{#if customUi?.settingsTabs?.workerGroup != false}
<Tab value="settings-worker-group">Worker Group</Tab>
{/if}
{#if customUi?.settingsTabs?.concurrency != false}
<Tab value="settings-concurrency">Concurrency</Tab>
{/if}
{#if customUi?.settingsTabs?.cache != false}
<Tab value="settings-cache" active={Boolean($flowStore.value.cache_ttl)}>Cache</Tab>
{/if}
<svelte:fragment slot="content">
<TabContent value="settings-metadata" class="p-4 h-full overflow-auto">
<Section label="Metadata">
<div class="h-full gap-8 flex flex-col">
<Label label="Summary">
<MetadataGen
bind:content={$flowStore.summary}
promptConfigName="flowSummary"
flow={$flowStore.value}
on:change={() => {
if (initialPath == '' && $flowStore.summary?.length > 0 && !dirtyPath) {
path?.setName(
$flowStore.summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
elementProps={{
type: 'text',
id: 'flow-summary',
placeholder: 'Short summary to be displayed when listed'
}}
/>
</Label>
<div>
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
checked={displayWorkerTagPicker}
on:change={() => {
displayWorkerTagPicker = !displayWorkerTagPicker
if (!displayWorkerTagPicker) {
$flowStore.tag = undefined
}
}}
options={{
right: 'Worker Group Tag (Queue)',
rightTooltip:
"When a worker group tag is defined at the flow level, any steps inside the flow will run on any worker group that listen to that tag, regardless of the steps tag. If no worker group tags is defined, the flow controls will be executed with the default tag 'flow' and the steps will be executed with their respective tag"
}}
class="py-1"
/>
{#if !noEditor}
<Label label="Path">
<Path
autofocus={false}
bind:this={path}
bind:dirty={dirtyPath}
bind:path={$pathStore}
{initialPath}
namePlaceholder="flow"
kind="flow"
/>
</Label>
{/if}
{#if displayWorkerTagPicker}
<WorkerTagPicker bind:tag={$flowStore.tag} popupPlacement="top-end" />
{/if}
</div>
<Label label="Description">
<MetadataGen
bind:content={$flowStore.description}
promptConfigName="flowDescription"
flow={$flowStore.value}
class="w-full"
elementType="textarea"
elementProps={{
id: 'inp',
placeholder: 'What this flow does and how to use it.'
}}
/>
</Label>
<!-- <Tooltip
>In this mode, every scripts of this flow is run on the workers dedicated to this flow
that keep the scripts "hot" so that there is not cold start cost incurred. Steps can run
at >1500 rps in this mode.</Tooltip
> -->
{/if}
<!-- TODO: Add EE-only badge when we have it -->
<Toggle
disabled={!$enterpriseLicense || isCloudHosted()}
checked={$flowStore.value.priority !== undefined && $flowStore.value.priority > 0}
on:change={() => {
if ($flowStore.value.priority) {
$flowStore.value.priority = undefined
} else {
$flowStore.value.priority = 100
}
}}
options={{
right: `Label as high priority`,
rightTooltip: `All jobs scheduled by flows labeled as high priority take precedence over the other jobs in the jobs queue. ${
!$enterpriseLicense
? 'This is a feature only available on enterprise edition.'
: ''
}`
}}
<!-- Metadata Advanced Section -->
<svelte:fragment slot="badge">
{#if numberOfAdvancedOptionsOn > 0}
<div class="flex grow min-w-0 w-full flex-wrap gap-1 ps-2">
{#each activeAdvancedOptionNames as optionName}
<Badge twBgColor="bg-nord-950 dark:bg-nord-400" twTextColor="text-primary-inverse"
>{optionName}</Badge
>
<svelte:fragment slot="right">
<input
type="number"
class="!w-16 ml-4"
disabled={$flowStore.value.priority === undefined}
bind:value={$flowStore.value.priority}
on:focus
on:change={() => {
if ($flowStore.value.priority && $flowStore.value.priority > 100) {
$flowStore.value.priority = 100
} else if ($flowStore.value.priority && $flowStore.value.priority < 0) {
$flowStore.value.priority = 0
}
}}
/>
</svelte:fragment>
</Toggle>
{/each}
</div>
{/if}
</svelte:fragment>
<Toggle
size="sm"
checked={Boolean($flowStore.visible_to_runner_only)}
on:change={() => {
if ($flowStore.visible_to_runner_only) {
$flowStore.visible_to_runner_only = undefined
} else {
$flowStore.visible_to_runner_only = true
}
}}
options={{
right: 'Make runs invisible to others',
rightTooltip:
'When this option is enabled, manual executions of this script are invisible to users other than the user running it, including the owner(s). This setting can be overridden when this script is run manually from the advanced menu.'
}}
/>
<div class="flex flex-row items-center gap-1">
<ErrorHandlerToggleButton
kind="flow"
scriptOrFlowPath={$pathStore}
bind:errorHandlerMuted={$flowStore.ws_error_handler_muted}
iconOnly={false}
/>
</div>
<Slider text="How to trigger flows?">
<div class="text-sm text-tertiary border p-4 mb-20">
On-demand:
<ul class="pt-4">
<li>
1. <a
href="https://www.windmill.dev/docs/core_concepts/auto_generated_uis"
target="_blank">Auto-generated UIs</a
>
</li>
<li>
2. <a href="{base}/apps/add?nodraft=true" target="_blank"> App Editor</a> for
customized-UIs
</li>
<li>
3. <a href="{base}/schedules" target="_blank">Scheduling</a>
</li>
<li>
4. <a href="https://www.windmill.dev/docs/advanced/cli" target="_blank"
>Windmill CLI</a
>
</li>
<br />
<li class="mt-2">
<div class="flex flex-col gap-2">
<p> From external events: </p>
</div>
</li>
<li class="mt-2">
5. Send a <a
href="https://www.windmill.dev/docs/core_concepts/webhooks"
target="_blank">webhook</a
>
after each event:
<ul class="list-disc pl-4"
><li
>Async <Tooltip
>Return an uuid instantly that you can use to fetch status and result</Tooltip
>:
<a
on:click={(e) => {
e.preventDefault()
copyToClipboard(url)
}}
href={url}
class="whitespace-nowrap text-ellipsis overflow-hidden mr-1"
>
{url}
<span class="text-secondary ml-2">
<Clipboard />
</span>
</a>
</li>
<li
>Sync <Tooltip>Wait for result within a timeout of 20s</Tooltip>:
<a
on:click={(e) => {
e.preventDefault()
copyToClipboard(syncedUrl)
}}
href={syncedUrl}
class="whitespace-nowrap text-ellipsis overflow-hidden mr-1"
>
{syncedUrl}
<span class="text-secondary ml-2">
<Clipboard />
</span>
</a>
</li>
</ul></li
>
<br />
<li>
6. Use a <a
href="https://www.windmill.dev/docs/flows/flow_trigger"
target="_blank">trigger script</a
>
and schedule this flow to run as frequently as needed and compare a state persisted
in Windmill to the state of the external system. If a difference is detected,
then the rest of the flow is triggered. Oftentimes, the second step of a flow
is a for-loop that will iterate over every elements. When using a trigger, a
default schedule will be created.
<img
class="shadow-lg border rounded"
alt="static button"
src="{base}/trigger_button.png"
/>
</li></ul
>
</div>
</Slider>
</div>
</Section>
</TabContent>
<TabContent value="settings-schedule" class="p-4 h-full overflow-scroll">
<Alert
type="info"
title="Primary Schedule"
documentationLink="https://www.windmill.dev/docs/core_concepts/scheduling"
>
Flows can be triggered by any schedules, their webhooks or their UI but they have only
one primary schedule with which they share the same path. The primary schedule can be
set here.
</Alert>
<div class="mt-4" />
<FlowSchedules />
</TabContent>
<TabContent value="settings-same-worker" class="p-4 flex flex-col">
<Section label="Shared Directory" class="flex flex-col">
<Alert type="info" title="Shared Directory">
Steps will share a folder at `./shared` in which they can store heavier data and
pass them to the next step. <br /><br />Beware that the `./shared` folder is not
preserved across suspends and sleeps. <br /><br />
Furthermore, steps' worker groups is not respected and only the flow's worker group will
be respected.
</Alert>
<Toggle
bind:checked={$flowStore.value.same_worker}
options={{
right: 'Shared Directory on `./shared`'
}}
class="mt-2"
/>
</Section>
</TabContent>
<TabContent value="settings-cache" class="p-4 flex flex-col">
<Section label="Cache">
<svelte:fragment slot="action">
<Toggle
size="xs"
checked={Boolean($flowStore.value.cache_ttl)}
on:change={() => {
if ($flowStore.value.cache_ttl && $flowStore.value.cache_ttl != undefined) {
$flowStore.value.cache_ttl = undefined
} else {
$flowStore.value.cache_ttl = 300
}
}}
options={{
right: 'Cache the results for each possible inputs'
}}
/>
</svelte:fragment>
<div class="flex gap-x-4 flex-col gap-2">
<div class="text-xs">How long to keep the cache valid</div>
<!-- Cache Section -->
{#if customUi?.settingsTabs?.cache != false}
<div>
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
checked={Boolean($flowStore.value.cache_ttl)}
on:change={() => {
if ($flowStore.value.cache_ttl && $flowStore.value.cache_ttl != undefined) {
$flowStore.value.cache_ttl = undefined
} else {
$flowStore.value.cache_ttl = 300
}
}}
options={{
right: 'Cache the results for each possible inputs'
}}
class="py-1"
/>
{#if $flowStore.value.cache_ttl}
<div class="flex gap-x-4 flex-col gap-1">
<div class="text-sm text-secondary">How long to keep the cache valid</div>
<div>
{#if $flowStore.value.cache_ttl}
<SecondsInput bind:seconds={$flowStore.value.cache_ttl} />
@@ -351,136 +198,87 @@
{/if}
</div>
</div>
</Section>
</TabContent>
<TabContent value="settings-worker-group" class="p-4 flex flex-col">
<Alert type="info" title="Worker Group Tag (Queue)">
When a worker group tag is defined at the flow level, any steps inside the flow will
run on any worker group that listen to that tag, regardless of the steps' tag. If no
worker group tags is defined, the flow controls will be executed with the default tag
'flow' and the steps will be executed with their respective tag
</Alert>
<span class="my-4 text-lg font-bold">Worker Group Tag (Queue)</span>
<WorkerTagPicker bind:tag={$flowStore.tag} />
<div class="py-6" />
<span class="my-4 text-lg font-bold flex items-baseline gap-8"
>Dedicated Worker {#if !$enterpriseLicense}<div
class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap"
>
<AlertTriangle size={16} />
EE only
</div>{/if}</span
>
{/if}
</div>
{/if}
<!-- Early Stop Section -->
{#if customUi?.settingsTabs?.earlyStop != false}
<div>
<!-- documentationLink="https://www.windmill.dev/docs/flows/early_stop -->
<Toggle
disabled={!$enterpriseLicense || isCloudHosted()}
size="sm"
checked={Boolean($flowStore.dedicated_worker)}
textClass="font-normal text-sm"
color="nord"
size="xs"
checked={Boolean($flowStore.value.skip_expr)}
on:change={() => {
if ($flowStore.dedicated_worker) {
$flowStore.dedicated_worker = undefined
if (Boolean($flowStore.value.skip_expr) && $flowStore.value.skip_expr) {
$flowStore.value.skip_expr = undefined
} else {
$flowStore.dedicated_worker = true
$flowStore.value.skip_expr = 'flow_input.foo == undefined'
}
}}
options={{
right: 'Flow is run on dedicated workers'
right: 'Early stop if condition met',
rightTooltip:
'If defined, at the beginning of the step the predicate expression will be evaluated' +
'to decide if the flow should stop early.'
}}
class="py-1"
/>
{#if $flowStore.dedicated_worker}
<div class="py-2">
<Alert type="info" title="Require dedicated workers">
One worker in a worker group needs to be configured with dedicated worker set to: <pre
>{$workspaceStore}:flow/{$pathStore}</pre
>
</Alert>
</div>
{/if}
<svelte:fragment slot="header">
<Tooltip
>In this mode, every scripts of this flow is run on the workers dedicated to this
flow that keep the scripts "hot" so that there is not cold start cost incurred.
Steps can run at >1500 rps in this mode.</Tooltip
>
</svelte:fragment>
</TabContent>
<TabContent value="settings-early-stop" class="p-4">
<Section label="Early stop">
<svelte:fragment slot="header">
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/early_stop">
If defined, at the beginning of the step the predicate expression will be
evaluated to decide if the flow should stop early.
</Tooltip>
</svelte:fragment>
<Toggle
checked={Boolean($flowStore.value.skip_expr)}
on:change={() => {
if (Boolean($flowStore.value.skip_expr) && $flowStore.value.skip_expr) {
$flowStore.value.skip_expr = undefined
} else {
$flowStore.value.skip_expr = 'flow_input.foo == undefined'
}
}}
options={{
right: 'Early stop if condition met'
}}
/>
{#if $flowStore.value.skip_expr}
<div
class="w-full border mt-2 p-2 flex flex-col {$flowStore.value.skip_expr
class="w-full border flex flex-col {$flowStore.value.skip_expr
? ''
: 'bg-surface-secondary'}"
>
{#if $flowStore.value.skip_expr}
<div class="border w-full">
<SimpleEditor
lang="javascript"
bind:code={$flowStore.value.skip_expr}
class="small-editor"
extraLib={`declare const flow_input = ${JSON.stringify(
schemaToObject(asSchema($flowStore.schema), $previewArgs)
)};
declare const WM_SCHEDULED_FOR: string;`}
/>
<div class="text-xs text-tertiary mt-2">
You can use the variable `flow_input` to access the inputs of the flow. <br
/>The variable `WM_SCHEDULED_FOR` contains the time the flow was scheduled for
which you can use to stop early non fresh jobs:
<pre>new Date().getTime() - new Date(WM_SCHEDULED_FOR).getTime() {'>'} X</pre>
</div>
<div class="border w-full">
<SimpleEditor
lang="javascript"
bind:code={$flowStore.value.skip_expr}
class="small-editor"
extraLib={`declare const flow_input = ${JSON.stringify(
schemaToObject(asSchema($flowStore.schema), $previewArgs)
)};
declare const WM_SCHEDULED_FOR: string;`}
/>
<div class="text-xs text-tertiary mt-2">
You can use the variable `flow_input` to access the inputs of the flow. <br
/>The variable `WM_SCHEDULED_FOR` contains the time the flow was scheduled for
which you can use to stop early non fresh jobs:
<pre>new Date().getTime() - new Date(WM_SCHEDULED_FOR).getTime() {'>'} X</pre>
</div>
{:else}
<textarea disabled rows="3" class="min-h-[80px]" />
{/if}
</div>
</div>
</Section>
</TabContent>
<TabContent value="settings-early-return" class="p-4">
<Section label="Early Return">
<div class="py-2">
<Alert type="info" title="Return sync endpoints early">
If defined, sync endpoints will return early at the node defined here while the
rest of the flow continue asynchronously.
</Alert>
</div>
<Toggle
checked={Boolean($flowStore.value.early_return)}
on:change={() => {
if (Boolean($flowStore.value.early_return) && $flowStore.value.early_return) {
$flowStore.value.early_return = undefined
} else {
$flowStore.value.early_return = $flowStore.value.modules?.[0]?.id ?? 'a'
}
}}
options={{
right: 'Early return sync endpoint at a top-level step'
}}
/>
{/if}
</div>
{/if}
<!-- Early Return Section -->
{#if customUi?.settingsTabs?.earlyReturn != false}
<div>
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
checked={Boolean($flowStore.value.early_return)}
on:change={() => {
if (Boolean($flowStore.value.early_return) && $flowStore.value.early_return) {
$flowStore.value.early_return = undefined
} else {
$flowStore.value.early_return = $flowStore.value.modules?.[0]?.id ?? 'a'
}
}}
options={{
right: 'Early return for sync webhooks',
rightTooltip:
'If defined, sync endpoints will return early at the node defined here while the rest of the flow continue asynchronously.'
}}
class="py-1"
/>
{#if $flowStore.value.early_return}
<div
class="border max-w-[120px] mt-2 p-2 flex flex-col {$flowStore.value.early_return
class="max-w-[120px] flex flex-col {$flowStore.value.early_return
? ''
: 'bg-surface-secondary'}"
>
@@ -488,6 +286,7 @@
name="oauth_name"
id="oauth_name"
bind:value={$flowStore.value.early_return}
class="text-xs"
>
<option value={undefined}>Node's id</option>
{#each $flowStore.value?.modules?.map((x) => x.id) as name}
@@ -495,14 +294,92 @@
{/each}
</select>
</div>
</Section>
</TabContent>
{/if}
</div>
{/if}
<TabContent value="settings-concurrency" class="p-4 flex flex-col">
<Section label="Concurrency Limits" eeOnly>
<svelte:fragment slot="header">
<Tooltip>Allowed concurrency within a given timeframe</Tooltip>
</svelte:fragment>
<!-- Shared Directory Section -->
{#if customUi?.settingsTabs?.sharedDiretory != false}
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
bind:checked={$flowStore.value.same_worker}
options={{
right: 'Same Worker + Shared Directory on `./shared`',
rightTooltip:
'Steps will share a folder at `./shared` in which they can store heavier data and ' +
'pass them to the next step. Beware that the `./shared` folder is not ' +
'preserved across suspends and sleeps.'
}}
class="py-1"
/>
{/if}
<!-- Visibility Section -->
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
checked={Boolean($flowStore.visible_to_runner_only)}
on:change={() => {
if ($flowStore.visible_to_runner_only) {
$flowStore.visible_to_runner_only = undefined
} else {
$flowStore.visible_to_runner_only = true
}
}}
options={{
right: 'Make runs invisible to others',
rightTooltip:
'When this option is enabled, manual executions of this script are invisible to users other than the user running it, including the owner(s). This setting can be overridden when this script is run manually from the advanced menu.'
}}
class="py-1"
/>
<!-- Error Handler Section -->
<div class="flex flex-row items-center py-1">
<ErrorHandlerToggleButtonV2
color="nord"
kind="flow"
scriptOrFlowPath={$pathStore}
bind:errorHandlerMuted={$flowStore.ws_error_handler_muted}
/>
{#if !$enterpriseLicense}
<span
class="inline-flex text-xs text-primary items-center gap-1 !text-yellow-500 whitespace-nowrap ml-8"
>
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</span>
{/if}
</div>
<!-- Concurrency Section -->
{#if customUi?.settingsTabs?.concurrency != false}
<div>
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
disabled={!$enterpriseLicense}
checked={Boolean($flowStore.value.concurrent_limit)}
on:change={() => {
if ($flowStore.value.concurrent_limit) {
$flowStore.value.concurrent_limit = undefined
} else {
$flowStore.value.concurrent_limit = 1
}
}}
options={{
right: 'Concurrency Limits',
rightTooltip: 'Allowed concurrency within a given timeframe'
}}
class="py-1"
eeOnly={true}
/>
{#if $flowStore.value.concurrent_limit}
<div class="flex flex-col gap-4">
<Label label="Max number of executions within the time window">
<div class="flex flex-row gap-2 max-w-sm">
@@ -544,10 +421,90 @@
/>
</Label>
</div>
</Section>
</TabContent>
</svelte:fragment>
</Tabs>
{/if}
</div>
{/if}
<!-- Priority Section -->
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
disabled={!$enterpriseLicense || isCloudHosted()}
checked={$flowStore.value.priority !== undefined && $flowStore.value.priority > 0}
on:change={() => {
if ($flowStore.value.priority) {
$flowStore.value.priority = undefined
} else {
$flowStore.value.priority = 100
}
}}
options={{
right: `Label as high priority`,
rightTooltip: `All jobs scheduled by flows labeled as high priority take precedence over the other jobs in the jobs queue. ${
!$enterpriseLicense ? 'This is a feature only available on enterprise edition.' : ''
}`
}}
class="py-1 relative"
>
<svelte:fragment slot="right">
<input
type="number"
class="!w-16 text-xs ml-4 absolute left-52"
disabled={$flowStore.value.priority === undefined}
bind:value={$flowStore.value.priority}
on:focus
on:change={() => {
if ($flowStore.value.priority && $flowStore.value.priority > 100) {
$flowStore.value.priority = 100
} else if ($flowStore.value.priority && $flowStore.value.priority < 0) {
$flowStore.value.priority = 0
}
}}
/>
{#if !$enterpriseLicense || isCloudHosted()}
<span
class="inline-flex absolute top-0 left-72 text-xs text-primary items-center gap-1 !text-yellow-500 whitespace-nowrap ml-8"
>
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</span>
{/if}
</svelte:fragment>
</Toggle>
<div>
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
disabled={!$enterpriseLicense || isCloudHosted()}
checked={Boolean($flowStore.dedicated_worker)}
on:change={() => {
if ($flowStore.dedicated_worker) {
$flowStore.dedicated_worker = undefined
} else {
$flowStore.dedicated_worker = true
}
}}
options={{
right: 'Flow is run on dedicated workers'
}}
class="py-1"
eeOnly={true}
/>
{#if $flowStore.dedicated_worker}
<div>
<Alert type="info" title="Require dedicated workers">
One worker in a worker group needs to be configured with dedicated worker set to: <pre
>{$workspaceStore}:flow/{$pathStore}</pre
>
</Alert>
</div>
{/if}
</div>
</Section>
</div>
</FlowCard>
</div>
@@ -45,7 +45,8 @@
'preprocessor',
'constants',
'Result',
'Input'
'Input',
'triggers'
].includes($selectedId) ||
$selectedId?.includes('branch')
</script>
@@ -6,7 +6,6 @@
import { copilotInfo } from '$lib/stores'
import { ExternalLink, Wand2 } from 'lucide-svelte'
import { base } from '$lib/base'
import { twMerge } from 'tailwind-merge'
let openNoCopilot = false
export let className = ''
@@ -15,7 +14,7 @@
getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
</script>
<div class={twMerge('absolute left-1/2 transform -translate-x-1/2 z-10', className)}>
<div class={className}>
<Popover>
<div class={openNoCopilot ? 'z-10' : ''}>
<Menu pointerDown noMinW placement="bottom-center" let:close bind:show={openNoCopilot}>
@@ -62,6 +62,11 @@
let newId: string = id ?? ''
let hover = false
let idBadgeWidth: number | undefined = undefined
let iconWidth: number | undefined = undefined
$: marginLeft = Math.max(iconWidth ?? 0, idBadgeWidth ?? 0) * 2 + 32
</script>
{#if deletable && id && editId}
@@ -230,16 +235,24 @@
</div>
<div
class="flex gap-1 justify-between items-center w-full overflow-hidden rounded-sm
class="relative flex gap-1 justify-between items-center w-full overflow-hidden rounded-sm
p-2 text-2xs module text-primary"
>
{#if $$slots.icon}
<slot name="icon" />
<div class="flex-none" bind:clientWidth={iconWidth}>
<slot name="icon" />
</div>
{/if}
<div class="truncate" class:font-bold={bold}>{label}</div>
<div class="flex items-center space-x-2 relative">
<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
>
<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">{id}</Badge>
<Badge color="indigo" wrapperClass="max-w-full" baseClass="max-w-full truncate" title={id}>
<span class="max-w-full text-2xs truncate">{id}</span></Badge
>
{#if deletable}
<button
class="absolute -left-[28px] z-10 h-[20px] rounded-l rounded-t rounded-s w-[20px] trash center-center text-secondary bg-surface duration-150 hover:bg-blue-400 {editId
@@ -40,12 +40,12 @@
export let disableTutorials = false
export let disableAi = false
export let disableSettings = false
export let newFlow: boolean = false
export let smallErrorHandler = false
let flowTutorials: FlowTutorials | undefined = undefined
const { selectedId, moving, history, flowStateStore, flowStore, flowInputsStore } =
const { selectedId, moving, history, flowStateStore, flowStore, flowInputsStore, pathStore } =
getContext<FlowEditorContext>('FlowEditorContext')
async function insertNewModuleAtIndex(
@@ -320,6 +320,8 @@
<div class="z-10 flex-auto grow bg-surface-secondary" bind:clientHeight={minHeight}>
<FlowGraphV2
path={$pathStore}
{newFlow}
{disableAi}
insertable
scroll
@@ -11,11 +11,9 @@
import { msToSec } from '$lib/utils'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import FlowJobsMenu from './FlowJobsMenu.svelte'
import FlowCopilotButton from './FlowCopilotButton.svelte'
export let mod: FlowModule
export let insertable: boolean
export let disableAi: boolean
export let annotation: string | undefined = undefined
export let bgColor: string = ''
export let modules: FlowModule[]
@@ -63,9 +61,6 @@
{#if mod}
<div class="relative">
{#if insertable && !disableAi && mod.id === 'preprocessor'}
<FlowCopilotButton className="-top-10" />
{/if}
{#if moving == mod.id}
<div class="absolute z-10 right-20 top-0.5 center-center">
<Button color="dark" on:click={() => dispatch('move')} size="xs" variant="border">
@@ -1,15 +1,8 @@
import { ScheduleService } from '$lib/gen'
export type Schedule = {
summary: string | undefined
args: Record<string, any>
cron: string
timezone: string
enabled: boolean
}
import type { ScheduleTrigger } from '../triggers'
// Load the schedule of a flow given its path and the workspace
export async function loadFlowSchedule(path: string, workspace: string): Promise<Schedule> {
export async function loadFlowSchedule(path: string, workspace: string): Promise<ScheduleTrigger> {
const existsSchedule = await ScheduleService.existsSchedule({
workspace,
path
@@ -3,7 +3,6 @@ import type { History } from '$lib/history'
import type { Writable } from 'svelte/store'
import type ScriptEditorDrawer from './content/ScriptEditorDrawer.svelte'
import type { FlowState } from './flowState'
import type { Schedule } from './scheduleUtils'
import type { FlowBuilderWhitelabelCustomUi } from '../custom_ui'
export type FlowInput = Record<
@@ -29,7 +28,6 @@ export type ExtendedOpenFlow = OpenFlow & {
export type FlowEditorContext = {
selectedId: Writable<string>
moving: Writable<{ module: FlowModule; modules: FlowModule[] } | undefined>
schedule: Writable<Schedule>
previewArgs: Writable<Record<string, any>>
scriptEditorDrawer: Writable<ScriptEditorDrawer | undefined>
history: History<OpenFlow>
@@ -32,10 +32,11 @@
import { encodeState } from '$lib/utils'
import BranchOneStart from './renderers/nodes/BranchOneStart.svelte'
import NoBranchNode from './renderers/nodes/NoBranchNode.svelte'
import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte'
import TriggersNode from './renderers/nodes/TriggersNode.svelte'
import { Alert, Drawer } from '../common'
import Button from '../common/button/Button.svelte'
import FlowYamlEditor from '../flows/header/FlowYamlEditor.svelte'
export let success: boolean | undefined = undefined
export let modules: FlowModule[] | undefined = []
export let failureModule: FlowModule | undefined = undefined
@@ -46,6 +47,8 @@
export let flowModuleStates: Record<string, GraphModuleState> | undefined = undefined
export let selectedId: Writable<string | undefined> = writable<string | undefined>(undefined)
export let path: string | undefined = undefined
export let newFlow: boolean = false
export let insertable = false
export let scroll = false
@@ -130,7 +133,9 @@
disableAi,
insertable,
flowModuleStates,
selectedId: $selectedId
selectedId: $selectedId,
path,
newFlow
},
failureModule,
preprocessorModule,
@@ -172,7 +177,10 @@
success,
$useDataflow,
$selectedId,
moving
moving,
{
path
}
)
const nodes = writable<Node[]>([])
@@ -205,13 +213,15 @@
whileLoopEnd: ForLoopEndNode,
branchOneStart: BranchOneStart,
branchOneEnd: BranchAllEndNode,
noBranch: NoBranchNode
noBranch: NoBranchNode,
trigger: TriggersNode
} as any
const edgeTypes = {
edge: BaseEdge,
empty: EmptyEdge,
dataflowedge: DataflowEdge
dataflowedge: DataflowEdge,
hiddenedge: HiddenBaseEdge
} as any
const proOptions = { hideAttribution: true }
@@ -222,7 +232,8 @@
!$selectedId.startsWith('settings') &&
$selectedId !== 'failure' &&
$selectedId !== 'preprocessor' &&
$selectedId !== 'Result'
$selectedId !== 'Result' &&
$selectedId !== 'triggers'
const viewport = writable<Viewport>({
x: 0,
@@ -25,7 +25,10 @@ export default function graphBuilder(
success: boolean | undefined,
useDataflow: boolean | undefined,
selectedId: string | undefined,
moving: string | undefined
moving: string | undefined,
triggerProps?: {
path?: string
}
): {
nodes: Node[]
edges: Edge[]
@@ -152,6 +155,29 @@ export default function graphBuilder(
}
}
if (extra.path) {
const triggerNode: Node = {
id: 'Trigger',
position: { x: -1, y: -1 },
type: 'trigger',
data: {
path: triggerProps?.path,
newFlow: extra.newFlow,
eventHandlers: eventHandlers
}
}
nodes.push(triggerNode)
if (preprocessorModule != null && preprocessorModule != undefined) {
addEdge('Trigger', preprocessorModule.id, {
type: 'empty'
})
} else {
addEdge('Trigger', 'Input', {
type: 'empty'
})
}
}
const resultNode: Node = {
id: 'result',
data: {
@@ -549,5 +575,4 @@ export default function graphBuilder(
error: e
}
}
}
@@ -0,0 +1,6 @@
<script lang="ts">
import { BaseEdge } from '@xyflow/svelte'
export let markerEnd: string | undefined = undefined
</script>
<BaseEdge path={''} {markerEnd} class="hidden" />
@@ -6,7 +6,6 @@
import { getStateColor } from '../../util'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import FlowCopilotButton from '$lib/components/flows/map/FlowCopilotButton.svelte'
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
export let data: {
@@ -27,11 +26,8 @@
</script>
<NodeWrapper let:darkMode>
{#if data.insertable && !data.disableAi && !data.hasPreprocessor}
<FlowCopilotButton className="-top-[4.25rem]" />
{/if}
{#if data.insertable && !data.hasPreprocessor}
<div class="absolute -top-8 left-1/2 transform -translate-x-1/2 z-10">
<div class="absolute bottom-full left-0 right-0 flex center-center mb-3.5">
<InsertModuleButton
disableAi={data.disableAi}
index={data.index ?? 0}
@@ -61,7 +61,6 @@
moving={data.moving}
duration_ms={state?.duration_ms}
retries={data.retries}
disableAi={data.disableAi}
{flowJobs}
on:delete={(e) => {
data.eventHandlers.delete(e.detail, '')
@@ -1,17 +1,19 @@
<script lang="ts">
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { Handle, Position } from '@xyflow/svelte'
import { twMerge } from 'tailwind-merge'
export let enableSourceHandle: boolean = true
export let enableTargetHandle: boolean = true
export let offset: number = 0
export let wrapperClass: string = ''
let darkMode: boolean = false
</script>
<DarkModeObserver bind:darkMode />
<div class="relative shadow-md" style={`margin-left: ${offset}px;`}>
<div class={twMerge('relative shadow-md', wrapperClass)} style={`margin-left: ${offset}px;`}>
<slot {darkMode} />
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import NodeWrapper from './NodeWrapper.svelte'
import TriggersWrapper from '../triggers/TriggersWrapper.svelte'
import { type GraphEventHandlers } from '../../graphBuilder'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
export let data: {
path: string
isEditor: boolean
newFlow: boolean
extra_perms: Record<string, any>
eventHandlers: GraphEventHandlers
}
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
</script>
<NodeWrapper wrapperClass="shadow-none">
<TriggersWrapper
path={data.path}
on:select={() => {
data?.eventHandlers?.select('triggers')
}}
isFlow={true}
selected={$selectedId == 'triggers'}
newItem={data.newFlow}
/>
</NodeWrapper>
@@ -0,0 +1,15 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
export let selected: boolean
</script>
<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',
selected ? 'outline-1 outline-tertiary outline' : 'outline-0'
)}
>
<slot />
</button>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Loader2 } from 'lucide-svelte'
export let count: number | undefined = undefined
</script>
{#if count && count > 0}
<div class="absolute -right-2 -top-2">
<div
class="bg-surface-secondary font-bold border border-surface-border rounded-md h-4 w-4 p-1 flex center-center text-primary text-xs"
>
{#if count === undefined}
<Loader2 class="animate-spin text-2xs" />
{:else}
{count}
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,106 @@
<script lang="ts">
import { Calendar, Mail, Webhook } from 'lucide-svelte'
import TriggerButton from './TriggerButton.svelte'
import Popover from '$lib/components/Popover.svelte'
import TriggerCount from './TriggerCount.svelte'
import { createEventDispatcher, onMount } from 'svelte'
import { Route } from 'lucide-svelte'
import { getContext } from 'svelte'
import { type TriggerContext } from '$lib/components/triggers'
import { FlowService, ScriptService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
const { selectedTrigger, triggersCount } = getContext<TriggerContext>('TriggerContext')
export let path: string
export let newItem: boolean
export let isFlow: boolean
export let selected: boolean
export let showOnlyWithCount: boolean
const dispatch = createEventDispatcher()
onMount(() => {
if (!newItem) {
loadCount()
}
})
async function loadCount() {
if (isFlow) {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: $workspaceStore!,
path
})
} else {
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: $workspaceStore!,
path
})
}
}
</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?.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}
@@ -0,0 +1,32 @@
<script lang="ts">
import { NODE } from '../../util'
import { createEventDispatcher } from 'svelte'
import TriggersBadge from './TriggersBadge.svelte'
export let path: string
export let newItem: boolean
export let isFlow: boolean
export let selected: boolean
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-2 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>
</div>
+8
View File
@@ -0,0 +1,8 @@
//regex that match path starting with u/ or f/ and with at least 2 /
const pathRegex = /\b(u|f)\/[^\/]+\/[^\/]+\b/g
export function processMessage(message: string): string {
return message.replaceAll(pathRegex, (path) => {
return `<span class="bg-surface-secondary p-1 text-xs font-mono whitespace-nowrap rounded-md">${path}</span>`
})
}
+16
View File
@@ -0,0 +1,16 @@
import type { TriggersCount } from '$lib/gen'
import type { Writable } from 'svelte/store'
export type ScheduleTrigger = {
summary: string | undefined
args: Record<string, any>
cron: string
timezone: string
enabled: boolean
}
export type TriggerContext = {
selectedTrigger: Writable<'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes'>
primarySchedule: Writable<ScheduleTrigger | undefined | false>
triggersCount: Writable<TriggersCount | undefined>
}
@@ -15,6 +15,10 @@
drawer?.openNew(is_flow, initial_script_path)
}
export async function getTriggers() {
return drawer?.getTriggers()
}
let drawer: RouteEditorInner
</script>
@@ -7,18 +7,24 @@
import Skeleton from '../common/skeleton/Skeleton.svelte'
import RouteEditor from './RouteEditor.svelte'
import { canWrite } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
import type { TriggerContext } from '../triggers'
import { getContext } from 'svelte'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
let routeEditor: RouteEditor
let triggers: (HttpTrigger & { canWrite: boolean })[] | undefined = undefined
$: path && loadTriggers()
async function loadTriggers() {
const { triggersCount } = getContext<TriggerContext>('TriggerContext')
let httpTriggers: (HttpTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
try {
triggers = (
httpTriggers = (
await HttpTriggerService.listHttpTriggers({
workspace: $workspaceStore ?? '',
path,
@@ -27,8 +33,9 @@
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), http_routes_count: httpTriggers?.length }
} catch (e) {
console.error('impossible to load http routes')
console.error('impossible to load http routes', e)
}
}
</script>
@@ -40,8 +47,8 @@
bind:this={routeEditor}
/>
<div class="p-2 flex flex-col">
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<div class="flex flex-col gap-4">
{#if !newItem && ($userStore?.is_admin || $userStore?.is_super_admin)}
<Button
on:click={() => routeEditor?.openNew(isFlow, path)}
variant="border"
@@ -52,30 +59,41 @@
New Route
</Button>
{/if}
</div>
{#if triggers}
{#if triggers.length == 0}
<div class="text-xs text-secondary px-2"> No http routes </div>
{:else}
<div class="flex flex-col divide-y px-2 pt-2">
{#each triggers as trigger (trigger.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{trigger.path}</div>
<div class="col-span-2 truncate">
{trigger.http_method.toUpperCase()} /{trigger.route_path}
{#if httpTriggers}
{#if httpTriggers.length == 0}
<div class="text-xs text-secondary"> No http routes </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each httpTriggers as httpTriggers (httpTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{httpTriggers.path}</div>
<div class="col-span-2 truncate">
{httpTriggers.http_method.toUpperCase()} /{httpTriggers.route_path}
</div>
<div class="flex justify-end">
<button
on:click={() => routeEditor?.openEdit(httpTriggers.path, isFlow)}
class="px-2"
>
{#if httpTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
<button on:click={() => routeEditor?.openEdit(trigger.path, isFlow)}>
{#if trigger.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
{/each}
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
{#if newItem}
<Alert title="Triggers disabled" type="warning" size="xs">
Deploy the {isFlow ? 'flow' : 'script'} to enable http routes triggers.
</Alert>
{/if}
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import { FlowService, ScriptService, UserService, type TruncatedToken } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { getContext } from 'svelte'
import { Skeleton } from '../common'
import Label from '../Label.svelte'
import type { TriggerContext } from '../triggers'
import { capitalize } from '$lib/utils'
export let isFlow: boolean
export let path: string
export let labelPrefix: 'email' | 'webhook'
const { triggersCount } = getContext<TriggerContext>('TriggerContext')
let tokens: TruncatedToken[] | undefined = undefined
export async function listTokens() {
tokens = (
isFlow
? await FlowService.listTokensOfFlow({ workspace: $workspaceStore!, path })
: await ScriptService.listTokensOfScript({ workspace: $workspaceStore!, path })
).filter((x) => x.label && x.label.startsWith(labelPrefix + '-'))
if (labelPrefix == 'email') {
$triggersCount = { ...($triggersCount ?? {}), email_count: tokens?.length }
} else {
$triggersCount = { ...($triggersCount ?? {}), webhook_count: tokens?.length }
}
}
async function deleteToken(tokenPrefix: string) {
await UserService.deleteToken({
tokenPrefix: tokenPrefix
})
await listTokens()
}
$: $workspaceStore && listTokens()
</script>
<div class="flex flex-col gap-2">
<Label label="Existing {capitalize(labelPrefix)} Tokens">
{#if tokens}
{#if tokens.length == 0}
<div class="text-xs text-secondary">No {labelPrefix} specific tokens found</div>
{:else}
<div class="flex flex-col divide-y pt-2">
<div class="grid grid-cols-6 text-2xs items-center py-2">
<div class="col-span-2 truncate font-semibold">Prefix</div>
<div class="col-span-2 truncate font-semibold">Label</div>
<div class="col-span-1 truncate font-semibold">Owner</div>
<div class="col-span-1" />
</div>
{#each tokens as token (token.token_prefix)}
<div class="grid grid-cols-6 text-2xs items-center py-2">
<div class="col-span-2 truncate">{token.token_prefix}***</div>
<div class="col-span-2 truncate">
{token.label}
</div>
<div class="col-span-1 truncate">
{token.email}
</div>
{#if token.email == $userStore?.email}
<button
on:click={() => deleteToken(token.token_prefix)}
class="text-xs text-secondary">delete</button
>
{:else}
<div class="col-span-1" />
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</Label>
</div>
@@ -0,0 +1,77 @@
<script lang="ts">
import Tab from '$lib/components/common/tabs/Tab.svelte'
import { Tabs } from '$lib/components/common'
import WebhooksPanel from '$lib/components/triggers/WebhooksPanel.svelte'
import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte'
import RoutesPanel from '$lib/components/triggers/RoutesPanel.svelte'
import RunPageSchedules from '$lib/components/RunPageSchedules.svelte'
import { canWrite } from '$lib/utils'
import { userStore } from '$lib/stores'
import FlowCard from '../flows/common/FlowCard.svelte'
import { getContext } from 'svelte'
import type { TriggerContext } from '$lib/components/triggers'
export let noEditor: boolean
export let newItem = false
export let currentPath: string
export let initialPath: string
export let schema: any
export let isFlow: boolean
const { selectedTrigger } = 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="emails" selectedClass="text-primary text-sm font-semibold">Email</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}
{#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 === 'schedules'}
<div class="p-2">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{/if}
</svelte:fragment>
</Tabs>
</div>
</FlowCard>
@@ -3,7 +3,7 @@
import Tooltip from '$lib/components/Tooltip.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import bash from 'svelte-highlight/languages/bash'
import { Tabs, Tab, TabContent, Button } from '$lib/components/common'
import { Tabs, Tab, TabContent, Button, Alert } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import {
@@ -15,10 +15,12 @@
import UserSettings from '../UserSettings.svelte'
import { Highlight } from 'svelte-highlight'
import { typescript } from 'svelte-highlight/languages'
import ClipboardPanel from './ClipboardPanel.svelte'
import ClipboardPanel from '../details/ClipboardPanel.svelte'
import { copyToClipboard, generateRandomString } from '$lib/utils'
import HighlightTheme from '../HighlightTheme.svelte'
import { base } from '$lib/base'
import Label from '$lib/components/Label.svelte'
import TriggerTokens from './TriggerTokens.svelte'
let userSettings: UserSettings
export let token: string
@@ -27,7 +29,8 @@
export let isFlow: boolean = false
export let hash: string | undefined = undefined
export let path: string
export let url: string = ''
export let newItem: boolean = false
let selectedTab: string = 'rest'
let webhooks: {
@@ -219,6 +222,8 @@ while true; do
done`
}`
}
let triggerTokens: TriggerTokens | undefined = undefined
</script>
<HighlightTheme />
@@ -227,32 +232,36 @@ done`
bind:this={userSettings}
on:tokenCreated={(e) => {
token = e.detail
triggerTokens?.listTokens()
}}
newTokenWorkspace={$workspaceStore}
newTokenLabel={`webhook-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
{scopes}
/>
<div class="p-2 flex flex-col w-full gap-4">
<div class="flex flex-col w-full gap-4">
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<div class="flex flex-row justify-between my-2 gap-2">
<input
bind:value={token}
placeholder="paste your token here once created to alter examples below"
class="!text-xs"
/>
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
Create a Webhook-specific Token
<Tooltip light>
The token will have a scope such that it can only be used to trigger this script. It is
safe to share as it cannot be used to impersonate you.
</Tooltip>
</Button>
</div>
<Label label="Token">
<div class="flex flex-row justify-between gap-2">
<input
bind:value={token}
placeholder="paste your token here once created to alter examples below"
class="!text-xs !font-normal"
/>
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
Create a Webhook-specific Token
<Tooltip light>
The token will have a scope such that it can only be used to trigger this script. It is
safe to share as it cannot be used to impersonate you.
</Tooltip>
</Button>
</div>
</Label>
{/if}
<div class="flex flex-col gap-2">
<div class="flex flex-row justify-between">
<div class="text-xs font-semibold flex flex-row items-center">Request type</div>
<div class="text-sm font-normal text-secondary flex flex-row items-center">Request type</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={webhookType}>
<ToggleButton
label="Async"
@@ -267,7 +276,7 @@ done`
</ToggleButtonGroup>
</div>
<div class="flex flex-row justify-between">
<div class="text-xs font-semibold flex flex-row items-center">Call method</div>
<div class="text-sm font-normal text-secondary flex flex-row items-center">Call method</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={requestType}>
<ToggleButton
label="POST by path"
@@ -294,7 +303,9 @@ done`
</ToggleButtonGroup>
</div>
<div class="flex flex-row justify-between">
<div class="text-xs font-semibold flex flex-row items-center">Token configuration</div>
<div class="text-sm font-normal text-secondary flex flex-row items-center"
>Token configuration</div
>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={tokenType}>
<ToggleButton label="Token in Headers" value="headers" />
<ToggleButton label="Token in Query" value="query" />
@@ -314,14 +325,20 @@ done`
{#key token}
<TabContent value="rest" class="flex flex-col flex-1 h-full ">
<div class="flex flex-col gap-2">
<ClipboardPanel title="Url" content={url} />
<Label label="Url">
<ClipboardPanel content={url} />
</Label>
{#if requestType !== 'get_path'}
<ClipboardPanel title="Body" content={JSON.stringify(args, null, 2)} />
<Label label="Body">
<ClipboardPanel content={JSON.stringify(args, null, 2)} />
</Label>
{/if}
{#key requestType}
{#key tokenType}
<ClipboardPanel title="Headers" content={JSON.stringify(headers(), null, 2)} />
<Label label="Headers">
<ClipboardPanel content={JSON.stringify(headers(), null, 2)} />
</Label>
{/key}
{/key}
</div>
@@ -370,4 +387,15 @@ done`
{/key}
</svelte:fragment>
</Tabs>
<div class="mt-10" />
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="webhook" />
{#if newItem}
<div class="mt-10" />
<Alert type="warning" title="Attached to a deployed path">
The webhooks are only valid for a given path and will only trigger the deployed version of the
{isFlow ? 'flow' : 'script'}.
</Alert>
{/if}
</div>
@@ -13,6 +13,7 @@
import { decodeState, emptySchema } from '$lib/utils'
import { tick } from 'svelte'
import { writable } from 'svelte/store'
import type { ScheduleTrigger } from '$lib/components/triggers'
let nodraft = $page.url.searchParams.get('nodraft')
@@ -53,6 +54,7 @@
})
const flowStateStore = writable<FlowState>({})
let savedPrimarySchedule: ScheduleTrigger | undefined = undefined
async function loadFlow() {
loading = true
let flow: Flow = {
@@ -97,6 +99,7 @@
flow = state.flow
pathStoreInit = state.path
savedPrimarySchedule = state.primarySchedule
state?.selectedId && (selectedId = state?.selectedId)
} else {
if (templatePath) {
@@ -172,6 +175,7 @@
{flowStateStore}
{selectedId}
{loading}
{savedPrimarySchedule}
>
<UnsavedConfirmationModal savedValue={savedFlow} modifiedValue={$flowStore} /></FlowBuilder
>
@@ -13,6 +13,7 @@
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
let nodraft = $page.url.searchParams.get('nodraft')
const initialState = nodraft ? undefined : localStorage.getItem(`flow-${$page.params.path}`)
@@ -54,6 +55,11 @@
let selectedId: string = 'settings-metadata'
let nobackenddraft = false
let savedPrimarySchedule: ScheduleTrigger | undefined = stateLoadedFromUrl?.primarySchedule
let flowBuilder: FlowBuilder | undefined = undefined
async function loadFlow(): Promise<void> {
loading = true
let flow: Flow
@@ -67,6 +73,7 @@
const draftOrDeployed = cleanValueProperties(savedFlow?.draft || savedFlow)
const urlScript = cleanValueProperties(stateLoadedFromUrl.flow)
flow = stateLoadedFromUrl.flow
savedPrimarySchedule = stateLoadedFromUrl.primarySchedule
const reloadAction = () => {
stateLoadedFromUrl = undefined
goto(`/flows/edit/${statePath}`)
@@ -113,6 +120,9 @@
}
if (flowWithDraft.draft != undefined && !nobackenddraft) {
flow = flowWithDraft.draft
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
if (!flowWithDraft.draft_only) {
const deployed = cleanValueProperties(flowWithDraft)
const draft = cleanValueProperties(flow)
@@ -216,8 +226,10 @@
{selectedId}
{initialArgs}
{loading}
bind:this={flowBuilder}
bind:savedFlow
{diffDrawer}
{savedPrimarySchedule}
>
<UnsavedConfirmationModal {diffDrawer} savedValue={savedFlow} modifiedValue={$flowStore} />
</FlowBuilder>
@@ -6,15 +6,16 @@
WorkspaceService,
type Flow,
type FlowModule,
type TriggersCount,
type WorkspaceDeployUISettings
} from '$lib/gen'
import { canWrite, defaultIfEmptyString, emptyString } from '$lib/utils'
import { canWrite, copyToClipboard, defaultIfEmptyString, emptyString } from '$lib/utils'
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
import { goto } from '$lib/navigation'
import { base } from '$lib/base'
import { Alert, Button, Badge as HeaderBadge, Skeleton } from '$lib/components/common'
import { Alert, Badge as HeaderBadge, Skeleton } from '$lib/components/common'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
@@ -36,27 +37,29 @@
Columns,
Pen,
Eye,
Calendar,
HistoryIcon
HistoryIcon,
ClipboardCopy
} from 'lucide-svelte'
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import WebhooksPanel from '$lib/components/details/WebhooksPanel.svelte'
import WebhooksPanel from '$lib/components/triggers/WebhooksPanel.svelte'
import CliHelpBox from '$lib/components/CliHelpBox.svelte'
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
import RunPageSchedules from '$lib/components/RunPageSchedules.svelte'
import { createAppFromFlow } from '$lib/components/details/createAppFromScript'
import { importStore } from '$lib/components/apps/store'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
import { loadFlowSchedule, type Schedule } from '$lib/components/flows/scheduleUtils'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte'
import Star from '$lib/components/Star.svelte'
import RoutesPanel from '$lib/components/triggers/RoutesPanel.svelte'
import { Highlight } from 'svelte-highlight'
import json from 'svelte-highlight/languages/json'
import { writable } from 'svelte/store'
import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte'
let flow: Flow | undefined
let can_write = false
@@ -68,6 +71,8 @@
let invisible_to_owner: boolean | undefined = undefined
let overrideTag: string | undefined = undefined
const triggersCount = writable<TriggersCount | undefined>(undefined)
$: cliCommand = `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'`
let previousPath: string | undefined = undefined
@@ -76,6 +81,7 @@
if (previousPath !== path) {
previousPath = path
loadFlow()
loadTriggersCount()
}
}
}
@@ -95,9 +101,15 @@
goto('/')
}
let schedule: Schedule | undefined = undefined
let starred: boolean | undefined = undefined
async function loadTriggersCount() {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: $workspaceStore!,
path
})
}
async function loadFlow(): Promise<void> {
flow = await FlowService.getFlowByPath({
workspace: $workspaceStore!,
@@ -109,9 +121,6 @@
invisible_to_owner = flow.visible_to_runner_only
}
can_write = canWrite(flow.path, flow.extra_perms!, $userStore)
try {
schedule = await loadFlowSchedule(path, $workspaceStore!)
} catch {}
}
let isValid = true
@@ -326,9 +335,7 @@
}
let stepDetail: FlowModule | string | undefined = undefined
let token = 'TOKEN_TO_CREATE'
let detailSelected = 'saved_inputs'
let triggerSelected: 'webhooks' | 'schedule' | 'cli' = 'webhooks'
let rightPaneSelected = 'saved_inputs'
let flowHistory: FlowHistory | undefined = undefined
</script>
@@ -354,8 +361,8 @@
{#if flow}
<DetailPageLayout
bind:triggerSelected
bind:selected={detailSelected}
{triggersCount}
bind:selected={rightPaneSelected}
isOperator={$userStore?.operator}
flow_json={{
value: flow.value,
@@ -363,10 +370,12 @@
description: flow.description,
schema: flow.schema
}}
hasStepDetails={Boolean(stepDetail)}
>
<svelte:fragment slot="header">
<DetailPageHeader
on:seeTriggers={() => {
rightPaneSelected = 'triggers'
}}
{mainButtons}
menuItems={getMenuItems(flow, deployUiSettings)}
title={defaultIfEmptyString(flow.summary, flow.path)}
@@ -375,6 +384,18 @@
errorHandlerKind="flow"
tag={flow.tag}
>
<svelte:fragment slot="trigger-badges">
<TriggersBadge
showOnlyWithCount={true}
{path}
newItem={false}
isFlow
selected={rightPaneSelected == 'triggers'}
on:select={() => {
rightPaneSelected = 'triggers'
}}
/>
</svelte:fragment>
{#if $workspaceStore}
<Star
kind="flow"
@@ -400,21 +421,6 @@
</HeaderBadge>
</div>
{/if}
{#if schedule?.enabled}
<Button
btnClasses="inline-flex"
startIcon={{ icon: Calendar }}
variant="contained"
color="light"
size="xs"
on:click={() => {
detailSelected = 'details'
triggerSelected = 'schedule'
}}
>
{schedule.cron ?? ''}
</Button>
{/if}
</DetailPageHeader>
</svelte:fragment>
<svelte:fragment slot="form">
@@ -474,10 +480,15 @@
on:select={(e) => {
if (e.detail) {
stepDetail = e.detail
rightPaneSelected = 'flow_step'
} else {
stepDetail = undefined
rightPaneSelected = 'saved_inputs'
}
}}
on:triggerDetail={(e) => {
rightPaneSelected = 'triggers'
}}
/>
</div>
</div>
@@ -494,33 +505,47 @@
}}
/>
</svelte:fragment>
<svelte:fragment slot="details">
<div class="p-1">
<SchemaViewer schema={flow.schema} />
<svelte:fragment slot="schema">
<div class="p-1 relative">
<button
on:click={() => copyToClipboard(JSON.stringify(flow?.schema, null, 4))}
class="absolute top-2 right-2"
>
<ClipboardCopy size={14} />
</button>
<Highlight language={json} code={JSON.stringify(flow?.schema, null, 4)} />
</div>
</svelte:fragment>
<svelte:fragment slot="webhooks">
<WebhooksPanel
bind:token
scopes={[`run:flow/${flow?.path}`]}
path={flow?.path}
isFlow={true}
{args}
/>
<div class="p-2">
<WebhooksPanel
bind:token
scopes={[`run:flow/${flow?.path}`]}
path={flow?.path}
isFlow={true}
{args}
/>
</div>
</svelte:fragment>
<svelte:fragment slot="routes">
<RoutesPanel path={flow.path ?? ''} isFlow />
<div class="p-2">
<RoutesPanel path={flow.path ?? ''} isFlow />
</div>
</svelte:fragment>
<svelte:fragment slot="email">
<EmailTriggerPanel
bind:token
scopes={[`run:flow/${flow?.path}`]}
path={flow?.path}
isFlow={true}
/>
<svelte:fragment slot="emails">
<div class="p-2">
<EmailTriggerPanel
bind:token
scopes={[`run:flow/${flow?.path}`]}
path={flow?.path}
isFlow={true}
/>
</div>
</svelte:fragment>
<svelte:fragment slot="schedule">
<RunPageSchedules isFlow={true} path={flow.path ?? ''} {can_write} />
<svelte:fragment slot="schedules">
<div class="p-2">
<RunPageSchedules schema={flow.schema} isFlow={true} path={flow.path ?? ''} {can_write} />
</div>
</svelte:fragment>
<svelte:fragment slot="cli">
<div class="p-2">
@@ -9,6 +9,7 @@
import { goto } from '$lib/navigation'
import { replaceState } from '$app/navigation'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
// Default
let schema: Schema = emptySchema()
@@ -29,10 +30,13 @@
const initialState = $page.url.hash != '' ? $page.url.hash.slice(1) : undefined
let scriptBuilder: ScriptBuilder | undefined = undefined
let savedPrimarySchedule: ScheduleTrigger | undefined = undefined
function decodeStateAndHandleError(state) {
try {
return decodeState(state)
const decoded = decodeState(state)
savedPrimarySchedule = decoded.primarySchedule
return decoded
} catch (e) {
console.error('Error decoding state', e)
return defaultScript
@@ -115,6 +119,7 @@
searchParams={$page.url.searchParams}
{script}
{showMeta}
{savedPrimarySchedule}
replaceStateFn={(path) => replaceState(path, $page.state)}
>
<UnsavedConfirmationModal savedValue={savedScript} modifiedValue={script} />
@@ -10,6 +10,7 @@
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
let initialState = window.location.hash != '' ? window.location.hash.slice(1) : undefined
let initialArgs = {}
@@ -35,6 +36,8 @@
let savedScript: NewScriptWithDraft | undefined = undefined
let fullyLoaded = false
let savedPrimarySchedule: ScheduleTrigger | undefined = scriptLoadedFromUrl?.primarySchedule
async function loadScript(): Promise<void> {
fullyLoaded = false
if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == $page.params.path) {
@@ -94,6 +97,10 @@
savedScript = structuredClone(scriptWithDraft)
if (scriptWithDraft.draft != undefined) {
script = scriptWithDraft.draft
if (script['primary_schedule']) {
savedPrimarySchedule = script['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
if (!scriptWithDraft.draft_only) {
reloadAction = async () => {
scriptLoadedFromUrl = undefined
@@ -199,6 +206,7 @@
bind:savedScript
{initialArgs}
{diffDrawer}
{savedPrimarySchedule}
searchParams={$page.url.searchParams}
on:deploy={(e) => {
let newHash = e.detail
@@ -6,6 +6,7 @@
ScriptService,
WorkspaceService,
type Script,
type TriggersCount,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
@@ -42,7 +43,7 @@
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import SavedInputs from '$lib/components/SavedInputs.svelte'
import WebhooksPanel from '$lib/components/details/WebhooksPanel.svelte'
import WebhooksPanel from '$lib/components/triggers/WebhooksPanel.svelte'
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import CliHelpBox from '$lib/components/CliHelpBox.svelte'
@@ -50,7 +51,6 @@
Activity,
Archive,
ArchiveRestore,
Calendar,
Eye,
FolderOpen,
GitFork,
@@ -75,12 +75,15 @@
import TimeAgo from '$lib/components/TimeAgo.svelte'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import PersistentScriptDrawer from '$lib/components/PersistentScriptDrawer.svelte'
import { loadScriptSchedule, type ScriptSchedule } from '$lib/scripts'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte'
import Star from '$lib/components/Star.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import RoutesPanel from '$lib/components/triggers/RoutesPanel.svelte'
import { Highlight } from 'svelte-highlight'
import json from 'svelte-highlight/languages/json'
import { writable } from 'svelte/store'
import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte'
let script: Script | undefined
let topHash: string | undefined
@@ -106,6 +109,8 @@
}
}
const triggersCount = writable<TriggersCount | undefined>(undefined)
async function deleteScript(hash: string): Promise<void> {
try {
await ScriptService.deleteScriptByHash({ workspace: $workspaceStore!, hash })
@@ -150,9 +155,15 @@
}
}
}
let schedule: ScriptSchedule | undefined = undefined
let starred: boolean | undefined = undefined
async function loadTriggersCount(path: string) {
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: $workspaceStore!,
path: path
})
}
async function loadScript(hash: string): Promise<void> {
try {
script = await ScriptService.getScriptByHash({
@@ -173,7 +184,7 @@
can_write =
script.workspace_id == $workspaceStore &&
canWrite(script.path, script.extra_perms!, $userStore)
schedule = await loadScriptSchedule(script.path, $workspaceStore!)
loadTriggersCount(script.path)
if (script.path && script.archived) {
const script_by_path = await ScriptService.getScriptByPath({
@@ -477,8 +488,7 @@
}
let token = 'TOKEN_TO_CREATE'
let detailSelected = 'saved_inputs'
let triggerSelected: 'webhooks' | 'schedule' | 'cli' = 'webhooks'
let rightPaneSelected = 'saved_inputs'
</script>
<MoveDrawer
@@ -512,8 +522,8 @@
</Drawer>
{#key script.hash}
<DetailPageLayout
bind:triggerSelected
bind:selected={detailSelected}
{triggersCount}
bind:selected={rightPaneSelected}
isOperator={$userStore?.operator}
>
<svelte:fragment slot="header">
@@ -526,6 +536,18 @@
scriptOrFlowPath={script.path}
tag={script.tag}
>
<svelte:fragment slot="trigger-badges">
<TriggersBadge
showOnlyWithCount={true}
path={script.path}
newItem={false}
isFlow={false}
selected={rightPaneSelected == 'triggers'}
on:select={() => {
rightPaneSelected = 'triggers'
}}
/>
</svelte:fragment>
{#if $workspaceStore}
<Star
kind="script"
@@ -565,21 +587,6 @@
</Badge>
</div>
{/if}
{#if schedule?.enabled}
<Button
btnClasses="inline-flex"
startIcon={{ icon: Calendar }}
variant="contained"
color="light"
size="xs"
on:click={() => {
detailSelected = 'details'
triggerSelected = 'schedule'
}}
>
{schedule.cron ?? ''}
</Button>
{/if}
</DetailPageHeader>
</svelte:fragment>
<svelte:fragment slot="form">
@@ -678,6 +685,17 @@
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="schema">
<div class="p-1 relative">
<button
on:click={() => copyToClipboard(JSON.stringify(script?.schema, null, 4))}
class="absolute top-2 right-2"
>
<ClipboardCopy size={14} />
</button>
<Highlight language={json} code={JSON.stringify(script?.schema, null, 4)} />
</div>
</svelte:fragment>
<svelte:fragment slot="save_inputs">
{#if args}
<SavedInputs
@@ -694,27 +712,40 @@
{/if}
</svelte:fragment>
<svelte:fragment slot="webhooks">
<WebhooksPanel
bind:token
scopes={[`run:script/${script?.path}`]}
hash={script.hash}
path={script.path}
{args}
/>
<div class="p-2">
<WebhooksPanel
bind:token
scopes={[`run:script/${script?.path}`]}
hash={script.hash}
path={script.path}
{args}
/>
</div>
</svelte:fragment>
<svelte:fragment slot="routes">
<RoutesPanel path={script.path ?? ''} isFlow={false} />
<div class="p-2">
<RoutesPanel path={script.path ?? ''} isFlow={false} />
</div>
</svelte:fragment>
<svelte:fragment slot="email">
<EmailTriggerPanel
bind:token
scopes={[`run:script/${script?.path}`]}
hash={script.hash}
path={script.path}
/>
<svelte:fragment slot="emails">
<div class="p-2">
<EmailTriggerPanel
bind:token
scopes={[`run:script/${script?.path}`]}
hash={script.hash}
path={script.path}
/>
</div>
</svelte:fragment>
<svelte:fragment slot="schedule">
<RunPageSchedules isFlow={false} path={script.path ?? ''} {can_write} />
<svelte:fragment slot="schedules">
<div class="p-2 mt-2">
<RunPageSchedules
schema={script.schema}
isFlow={false}
path={script.path ?? ''}
{can_write}
/>
</div>
</svelte:fragment>
<svelte:fragment slot="details">
<div>
+12 -14
View File
@@ -6,7 +6,7 @@
import FlowPreviewButtons from '$lib/components/flows/header/FlowPreviewButtons.svelte'
import type { FlowEditorContext, FlowInput } from '$lib/components/flows/types'
import { writable } from 'svelte/store'
import { OpenAPI, type FlowModule, type OpenFlow } from '$lib/gen'
import { OpenAPI, type FlowModule, type OpenFlow, type TriggersCount } from '$lib/gen'
import { initHistory } from '$lib/history'
import type { FlowState } from '$lib/components/flows/flowState'
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
@@ -16,7 +16,7 @@
import { userStore, workspaceStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import type { Schedule } from '$lib/components/flows/scheduleUtils'
import type { ScheduleTrigger, TriggerContext } from '$lib/components/triggers'
let token = $page.url.searchParams.get('wm_token') ?? undefined
let workspace = $page.url.searchParams.get('workspace') ?? undefined
@@ -63,13 +63,6 @@
let initialCode = JSON.stringify($flowStore, null, 4)
const flowStateStore = writable({} as FlowState)
const scheduleStore = writable<Schedule>({
args: {},
cron: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: false,
summary: undefined
})
const previewArgsStore = writable<Record<string, any>>({})
const scriptEditorDrawer = writable(undefined)
@@ -78,14 +71,19 @@
const testStepStore = writable<Record<string, any>>({})
const selectedIdStore = writable('settings-metadata')
// function select(selectedId: string) {
// selectedIdStore.set(selectedId)
// }
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const triggersCount = writable<TriggersCount | undefined>(undefined)
const selectedTriggerStore = writable<'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes'>(
'webhooks'
)
setContext<TriggerContext>('TriggerContext', {
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount
})
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
schedule: scheduleStore,
previewArgs: previewArgsStore,
scriptEditorDrawer,
moving,
+16 -2
View File
@@ -12,8 +12,8 @@ const lightTheme = {
textTertiary: '#505c70',
textDisabled: '#a0aec0',
border: '#ddd',
borderHover: '#ccc'
border: '#dddddd',
borderHover: '#cccccc'
}
const lightThemeRgb = makeRgb(lightTheme)
@@ -384,6 +384,20 @@ const config = {
400: '#394A6D',
500: '#323F5B'
},
nord: {
0: '#2E3440',
100: '#3B4252',
200: '#434C5E',
300: '#4C566A',
400: '#D8DEE9',
500: '#E5E9F0',
600: '#ECEFF4',
700: '#8FBCBB',
800: '#88C0D0',
900: '#81A1C1',
950: '#5E81AC'
},
surface: 'rgb(var(--color-surface) / <alpha-value>)',
'surface-secondary': 'rgb(var(--color-surface-secondary) / <alpha-value>)',
'surface-hover': 'rgb(var(--color-surface-hover) / <alpha-value>)',