Merge remote-tracking branch 'upstream/main' into appeditor-multiselect-input-type
This commit is contained in:
@@ -175,9 +175,9 @@ RUN /usr/local/bin/python3 -m pip install pip-tools
|
||||
COPY --from=builder /frontend/build /static_frontend
|
||||
COPY --from=builder /windmill/target/release/windmill ${APP}/windmill
|
||||
|
||||
COPY --from=denoland/deno:2.0.2 --chmod=755 /usr/bin/deno /usr/bin/deno
|
||||
COPY --from=denoland/deno:2.0.4 --chmod=755 /usr/bin/deno /usr/bin/deno
|
||||
|
||||
COPY --from=oven/bun:1.1.32 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.1.34 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php
|
||||
COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE websocket_trigger SET url = $1, script_path = $2, path = $3, is_flow = $4, filters = $5, edited_by = $6, email = $7, edited_at = now(), server_id = NULL, last_server_ping = NULL, error = NULL\n WHERE workspace_id = $8 AND path = $9",
|
||||
"query": "UPDATE websocket_trigger SET url = $1, script_path = $2, path = $3, is_flow = $4, filters = $5, initial_messages = $6, url_runnable_args = $7, edited_by = $8, email = $9, edited_at = now(), server_id = NULL, last_server_ping = NULL, error = NULL\n WHERE workspace_id = $10 AND path = $11",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -10,6 +10,8 @@
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"JsonbArray",
|
||||
"JsonbArray",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
@@ -18,5 +20,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "acbf74cf3302bfcf7615285070d3f8958932bb8a2dda715f1b9152ab44442780"
|
||||
"hash": "0b94bd4c98a11ca1b7e5e34dd1ee6fcb0b7a54ed4218fa3cf23cc929d009d50f"
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO websocket_trigger (workspace_id, path, url, script_path, is_flow, enabled, filters, edited_by, email, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) RETURNING *",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_flow",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "edited_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "server_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "last_server_ping",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "filters",
|
||||
"type_info": "JsonbArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4e9668a46bad9e82baa51422946d373b18b6577198df7545c94bd19be3446775"
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT *\n FROM websocket_trigger\n WHERE enabled IS TRUE AND (server_id IS NULL OR last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_flow",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "edited_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "server_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "last_server_ping",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "filters",
|
||||
"type_info": "JsonbArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5303cb9dd5903aa4791ef8e5e5881a50a832e65c8c9632e2e12cd9c2747f2fc7"
|
||||
}
|
||||
16
backend/.sqlx/query-cd33a9d63f4706a7e3b1e23cd0a4b2e3ecb30aae6510d1fcd08493b07c8b0952.json
generated
Normal file
16
backend/.sqlx/query-cd33a9d63f4706a7e3b1e23cd0a4b2e3ecb30aae6510d1fcd08493b07c8b0952.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE websocket_trigger SET enabled = FALSE, error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cd33a9d63f4706a7e3b1e23cd0a4b2e3ecb30aae6510d1fcd08493b07c8b0952"
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT *\n FROM websocket_trigger\n WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_flow",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "edited_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "server_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "last_server_ping",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "filters",
|
||||
"type_info": "JsonbArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f2baee15e6d1fecd6d2d7b39fda1b50a15ec8bd349f1081af54da5dc2f5e3021"
|
||||
}
|
||||
2
backend/migrations/20241030150214_ws_auth.down.sql
Normal file
2
backend/migrations/20241030150214_ws_auth.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add down migration script here
|
||||
ALTER TABLE websocket_trigger DROP COLUMN initial_messages, DROP COLUMN url_runnable_args;
|
||||
2
backend/migrations/20241030150214_ws_auth.up.sql
Normal file
2
backend/migrations/20241030150214_ws_auth.up.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add up migration script here
|
||||
ALTER TABLE websocket_trigger ADD COLUMN initial_messages JSONB[] DEFAULT '{}', ADD COLUMN url_runnable_args JSONB DEFAULT '{}';
|
||||
@@ -11625,6 +11625,12 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
initial_messages:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WebsocketTriggerInitialMessage"
|
||||
url_runnable_args:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -11638,6 +11644,8 @@ components:
|
||||
- workspace_id
|
||||
- enabled
|
||||
- filters
|
||||
- initial_messages
|
||||
- url_runnable_args
|
||||
|
||||
NewWebsocketTrigger:
|
||||
type: object
|
||||
@@ -11663,6 +11671,12 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
initial_messages:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WebsocketTriggerInitialMessage"
|
||||
url_runnable_args:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -11670,6 +11684,8 @@ components:
|
||||
- url
|
||||
- is_flow
|
||||
- filters
|
||||
- initial_messages
|
||||
- url_runnable_args
|
||||
|
||||
EditWebsocketTrigger:
|
||||
type: object
|
||||
@@ -11693,6 +11709,12 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
initial_messages:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WebsocketTriggerInitialMessage"
|
||||
url_runnable_args:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -11700,6 +11722,34 @@ components:
|
||||
- url
|
||||
- is_flow
|
||||
- filters
|
||||
- initial_messages
|
||||
- url_runnable_args
|
||||
|
||||
WebsocketTriggerInitialMessage:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
raw_message:
|
||||
type: string
|
||||
required:
|
||||
- raw_message
|
||||
- type: object
|
||||
properties:
|
||||
runnable_result:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
args:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
is_flow:
|
||||
type: boolean
|
||||
required:
|
||||
- path
|
||||
- args
|
||||
- is_flow
|
||||
required:
|
||||
- runnable_result
|
||||
|
||||
Group:
|
||||
type: object
|
||||
|
||||
@@ -129,7 +129,8 @@ fn username_override_from_label(label: Option<String>) -> Option<String> {
|
||||
Some(label)
|
||||
if label.starts_with("webhook-")
|
||||
|| label.starts_with("http-")
|
||||
|| label.starts_with("email-") =>
|
||||
|| label.starts_with("email-")
|
||||
|| label.starts_with("ws-") =>
|
||||
{
|
||||
Some(label)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
routing::{delete, get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use futures::{stream::SplitSink, SinkExt, StreamExt};
|
||||
use http::StatusCode;
|
||||
use itertools::Itertools;
|
||||
use rand::seq::SliceRandom;
|
||||
@@ -11,16 +12,20 @@ use serde::{
|
||||
de::{self, MapAccess, Visitor},
|
||||
Deserialize, Deserializer, Serialize,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{value::RawValue, Value};
|
||||
use sql_builder::{bind::Bind, SqlBuilder};
|
||||
use sqlx::prelude::FromRow;
|
||||
use std::{collections::HashMap, fmt};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use uuid::Uuid;
|
||||
use windmill_audit::{audit_ee::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{self, JsonResult},
|
||||
utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath},
|
||||
error::{self, to_anyhow, JsonResult},
|
||||
utils::{
|
||||
not_found_if_none, paginate, report_critical_error, require_admin, Pagination, StripPath,
|
||||
},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
INSTANCE_NAME,
|
||||
};
|
||||
@@ -28,9 +33,7 @@ use windmill_queue::PushArgsOwned;
|
||||
|
||||
use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
jobs::{
|
||||
run_wait_result_flow_by_path_internal, run_wait_result_script_by_path_internal, RunJobQuery,
|
||||
},
|
||||
jobs::{run_flow_by_path_inner, run_script_by_path_inner, RunJobQuery},
|
||||
users::fetch_api_authed,
|
||||
};
|
||||
|
||||
@@ -52,7 +55,29 @@ struct NewWebsocketTrigger {
|
||||
script_path: String,
|
||||
is_flow: bool,
|
||||
enabled: Option<bool>,
|
||||
filters: Vec<serde_json::Value>,
|
||||
filters: Vec<Box<RawValue>>,
|
||||
initial_messages: Vec<Box<RawValue>>,
|
||||
url_runnable_args: Box<RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JsonFilter {
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Filter {
|
||||
JsonFilter(JsonFilter),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum InitialMessage {
|
||||
#[serde(rename = "raw_message")]
|
||||
RawMessage(String),
|
||||
#[serde(rename = "runnable_result")]
|
||||
RunnableResult { path: String, args: Box<RawValue>, is_flow: bool },
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Clone)]
|
||||
@@ -70,7 +95,9 @@ pub struct WebsocketTrigger {
|
||||
extra_perms: serde_json::Value,
|
||||
error: Option<String>,
|
||||
enabled: bool,
|
||||
filters: Vec<serde_json::Value>,
|
||||
filters: Vec<sqlx::types::Json<Box<RawValue>>>,
|
||||
initial_messages: Vec<sqlx::types::Json<Box<RawValue>>>,
|
||||
url_runnable_args: sqlx::types::Json<Box<RawValue>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -79,7 +106,9 @@ struct EditWebsocketTrigger {
|
||||
url: String,
|
||||
script_path: String,
|
||||
is_flow: bool,
|
||||
filters: Vec<serde_json::Value>,
|
||||
filters: Vec<Box<RawValue>>,
|
||||
initial_messages: Vec<Box<RawValue>>,
|
||||
url_runnable_args: Box<RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -133,14 +162,13 @@ async fn get_websocket_trigger(
|
||||
) -> error::JsonResult<WebsocketTrigger> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let path = path.to_path();
|
||||
let trigger = sqlx::query_as!(
|
||||
WebsocketTrigger,
|
||||
let trigger = sqlx::query_as::<_, WebsocketTrigger>(
|
||||
r#"SELECT *
|
||||
FROM websocket_trigger
|
||||
WHERE workspace_id = $1 AND path = $2"#,
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(path)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -164,19 +192,27 @@ async fn create_websocket_trigger(
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
sqlx::query_as!(
|
||||
WebsocketTrigger,
|
||||
"INSERT INTO websocket_trigger (workspace_id, path, url, script_path, is_flow, enabled, filters, edited_by, email, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) RETURNING *",
|
||||
w_id,
|
||||
ct.path,
|
||||
ct.url,
|
||||
ct.script_path,
|
||||
ct.is_flow,
|
||||
ct.enabled.unwrap_or(true),
|
||||
&ct.filters,
|
||||
&authed.username,
|
||||
&authed.email
|
||||
|
||||
let filters = ct.filters.into_iter().map(sqlx::types::Json).collect_vec();
|
||||
let initial_messages = ct
|
||||
.initial_messages
|
||||
.into_iter()
|
||||
.map(sqlx::types::Json)
|
||||
.collect_vec();
|
||||
sqlx::query_as::<_, WebsocketTrigger>(
|
||||
"INSERT INTO websocket_trigger (workspace_id, path, url, script_path, is_flow, enabled, filters, initial_messages, url_runnable_args, edited_by, email, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now()) RETURNING *",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(&ct.path)
|
||||
.bind(ct.url)
|
||||
.bind(ct.script_path)
|
||||
.bind(ct.is_flow)
|
||||
.bind(ct.enabled.unwrap_or(true))
|
||||
.bind(filters.as_slice())
|
||||
.bind(initial_messages.as_slice())
|
||||
.bind(sqlx::types::Json(ct.url_runnable_args))
|
||||
.bind(&authed.username)
|
||||
.bind(&authed.email)
|
||||
.fetch_one(&mut *tx).await?;
|
||||
|
||||
audit_log(
|
||||
@@ -204,15 +240,24 @@ async fn update_websocket_trigger(
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let filters = ct.filters.into_iter().map(sqlx::types::Json).collect_vec();
|
||||
let initial_messages = ct
|
||||
.initial_messages
|
||||
.into_iter()
|
||||
.map(sqlx::types::Json)
|
||||
.collect_vec();
|
||||
|
||||
// important to update server_id, last_server_ping and error to NULL to stop current websocket listener
|
||||
sqlx::query!(
|
||||
"UPDATE websocket_trigger SET url = $1, script_path = $2, path = $3, is_flow = $4, filters = $5, edited_by = $6, email = $7, edited_at = now(), server_id = NULL, last_server_ping = NULL, error = NULL
|
||||
WHERE workspace_id = $8 AND path = $9",
|
||||
"UPDATE websocket_trigger SET url = $1, script_path = $2, path = $3, is_flow = $4, filters = $5, initial_messages = $6, url_runnable_args = $7, edited_by = $8, email = $9, edited_at = now(), server_id = NULL, last_server_ping = NULL, error = NULL
|
||||
WHERE workspace_id = $10 AND path = $11",
|
||||
ct.url,
|
||||
ct.script_path,
|
||||
ct.path,
|
||||
ct.is_flow,
|
||||
&ct.filters,
|
||||
filters.as_slice() as &[sqlx::types::Json<Box<RawValue>>],
|
||||
initial_messages.as_slice() as &[sqlx::types::Json<Box<RawValue>>],
|
||||
sqlx::types::Json(ct.url_runnable_args) as sqlx::types::Json<Box<RawValue>>,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
w_id,
|
||||
@@ -335,8 +380,7 @@ async fn listen_to_unlistened_websockets(
|
||||
rsmq: &Option<rsmq_async::MultiplexedRsmq>,
|
||||
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
||||
) -> () {
|
||||
match sqlx::query_as!(
|
||||
WebsocketTrigger,
|
||||
match sqlx::query_as::<_, WebsocketTrigger>(
|
||||
r#"SELECT *
|
||||
FROM websocket_trigger
|
||||
WHERE enabled IS TRUE AND (server_id IS NULL OR last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')"#
|
||||
@@ -422,8 +466,6 @@ impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> {
|
||||
if key == self.key {
|
||||
// Deserialize the value for the key and check if it's a superset
|
||||
let json_value: Value = map.next_value()?;
|
||||
tracing::info!("json_value: {:?}", json_value);
|
||||
tracing::info!("value_to_check: {:?}", self.value_to_check);
|
||||
return Ok(is_superset(&json_value, self.value_to_check));
|
||||
} else {
|
||||
// Skip the value if it's not the one we're interested in
|
||||
@@ -470,149 +512,429 @@ where
|
||||
deserializer.deserialize_map(SupersetVisitor { key, value_to_check })
|
||||
}
|
||||
|
||||
async fn wait_runnable_result(
|
||||
path: String,
|
||||
is_flow: bool,
|
||||
args: &Box<RawValue>,
|
||||
ws_trigger: &WebsocketTrigger,
|
||||
username_override: String,
|
||||
db: &DB,
|
||||
rsmq: Option<rsmq_async::MultiplexedRsmq>,
|
||||
) -> error::Result<String> {
|
||||
let user_db = UserDB::new(db.clone());
|
||||
let authed = fetch_api_authed(
|
||||
ws_trigger.edited_by.clone(),
|
||||
ws_trigger.email.clone(),
|
||||
&ws_trigger.workspace_id,
|
||||
&db,
|
||||
username_override,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let args = serde_json::from_str::<Option<HashMap<String, Box<RawValue>>>>(args.get())
|
||||
.map_err(|e| error::Error::BadRequest(format!("invalid json: {}", e)))?
|
||||
.unwrap_or_else(HashMap::new);
|
||||
|
||||
let label_prefix = Some(format!("ws-{}-", ws_trigger.path));
|
||||
let (_, job_id) = if is_flow {
|
||||
run_flow_by_path_inner(
|
||||
authed,
|
||||
db.clone(),
|
||||
user_db,
|
||||
rsmq.clone(),
|
||||
ws_trigger.workspace_id.clone(),
|
||||
StripPath(path.clone()),
|
||||
RunJobQuery::default(),
|
||||
PushArgsOwned { args, extra: None },
|
||||
label_prefix,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
run_script_by_path_inner(
|
||||
authed,
|
||||
db.clone(),
|
||||
user_db,
|
||||
rsmq.clone(),
|
||||
ws_trigger.workspace_id.clone(),
|
||||
StripPath(path.clone()),
|
||||
RunJobQuery::default(),
|
||||
PushArgsOwned { args, extra: None },
|
||||
label_prefix,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let start_time = tokio::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if start_time.elapsed() > tokio::time::Duration::from_secs(300) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Timed out after 5m waiting for runnable {path} (is_flow: {is_flow}) to complete",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RawResult {
|
||||
result: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
success: bool,
|
||||
}
|
||||
|
||||
let result = sqlx::query_as::<_, RawResult>(
|
||||
"SELECT result, success FROM completed_job WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(Uuid::parse_str(&job_id).unwrap())
|
||||
.bind(&ws_trigger.workspace_id)
|
||||
.fetch_optional(db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(r)) => {
|
||||
if !r.success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Runnable {path} (is_flow: {is_flow}) failed: {:?}",
|
||||
r.result
|
||||
)
|
||||
.into());
|
||||
} else {
|
||||
return Ok(r.result.map(|r| r.get().to_owned()).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// not yet done, wait for 5s and check again
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Error fetching job result for runnable {path} (is_flow: {is_flow}): {err}",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_initial_messages(
|
||||
ws_trigger: &WebsocketTrigger,
|
||||
mut writer: SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
|
||||
db: &DB,
|
||||
rsmq: Option<rsmq_async::MultiplexedRsmq>,
|
||||
) -> error::Result<()> {
|
||||
let initial_messages: Vec<InitialMessage> = ws_trigger
|
||||
.initial_messages
|
||||
.iter()
|
||||
.filter_map(|m| serde_json::from_str(m.get()).ok())
|
||||
.collect_vec();
|
||||
|
||||
for start_message in initial_messages {
|
||||
match start_message {
|
||||
InitialMessage::RawMessage(msg) => {
|
||||
let msg = if msg.starts_with("\"") && msg.ends_with("\"") {
|
||||
msg[1..msg.len() - 1].to_string()
|
||||
} else {
|
||||
msg
|
||||
};
|
||||
tracing::info!(
|
||||
"Sending raw message initial message to websocket {}: {}",
|
||||
ws_trigger.url,
|
||||
msg
|
||||
);
|
||||
writer
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(msg))
|
||||
.await
|
||||
.map_err(to_anyhow)
|
||||
.with_context(|| "failed to send raw message")?;
|
||||
}
|
||||
InitialMessage::RunnableResult { path, is_flow, args } => {
|
||||
tracing::info!(
|
||||
"Running runnable {path} (is_flow: {is_flow}) for initial message to websocket {}",
|
||||
ws_trigger.url,
|
||||
);
|
||||
|
||||
let result = wait_runnable_result(
|
||||
path.clone(),
|
||||
is_flow,
|
||||
&args,
|
||||
ws_trigger,
|
||||
"init".to_string(),
|
||||
db,
|
||||
rsmq.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
"Sending runnable {path} (is_flow: {is_flow}) result to websocket {}",
|
||||
ws_trigger.url
|
||||
);
|
||||
|
||||
let result = if result.starts_with("\"") && result.ends_with("\"") {
|
||||
result[1..result.len() - 1].to_string()
|
||||
} else {
|
||||
result
|
||||
};
|
||||
|
||||
writer
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(result))
|
||||
.await
|
||||
.map_err(to_anyhow)
|
||||
.with_context(|| {
|
||||
format!("Failed to send runnable {path} (is_flow: {is_flow}) result")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_url_from_runnable(
|
||||
path: &str,
|
||||
is_flow: bool,
|
||||
ws_trigger: &WebsocketTrigger,
|
||||
db: &DB,
|
||||
rsmq: Option<rsmq_async::MultiplexedRsmq>,
|
||||
) -> error::Result<String> {
|
||||
tracing::info!("Running runnable {path} (is_flow: {is_flow}) to get websocket URL",);
|
||||
|
||||
let result = wait_runnable_result(
|
||||
path.to_string(),
|
||||
is_flow,
|
||||
&ws_trigger.url_runnable_args.0,
|
||||
ws_trigger,
|
||||
"url".to_string(),
|
||||
db,
|
||||
rsmq,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if result.starts_with("\"") && result.ends_with("\"") {
|
||||
Ok(result[1..result.len() - 1].to_string())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Runnable {path} (is_flow: {is_flow}) did not return a string").into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_ping(db: &DB, ws_trigger: &WebsocketTrigger, error: Option<&str>) -> Option<()> {
|
||||
match sqlx::query_scalar!(
|
||||
"UPDATE websocket_trigger SET last_server_ping = now(), error = $1 WHERE workspace_id = $2 AND path = $3 AND server_id = $4 AND enabled IS TRUE RETURNING 1",
|
||||
error,
|
||||
ws_trigger.workspace_id,
|
||||
ws_trigger.path,
|
||||
*INSTANCE_NAME
|
||||
).fetch_optional(db).await {
|
||||
Ok(updated) => {
|
||||
if updated.flatten().is_none() {
|
||||
tracing::info!("Websocket {} changed, disabled, or deleted, stopping...", ws_trigger.url);
|
||||
return None;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!("Error updating ping of websocket {}: {:?}", ws_trigger.url, err);
|
||||
}
|
||||
};
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
async fn loop_ping(db: &DB, ws_trigger: &WebsocketTrigger, error: Option<&str>) -> () {
|
||||
loop {
|
||||
if let None = update_ping(db, ws_trigger, error).await {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn disable_with_error(db: &DB, ws_trigger: &WebsocketTrigger, error: String) {
|
||||
match sqlx::query!(
|
||||
"UPDATE websocket_trigger SET enabled = FALSE, error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3",
|
||||
error,
|
||||
ws_trigger.workspace_id,
|
||||
ws_trigger.path,
|
||||
)
|
||||
.execute(db).await {
|
||||
Ok(_) => {
|
||||
report_critical_error(format!("Disabling websocket {} because of error: {}", ws_trigger.url, error), db.clone()).await;
|
||||
},
|
||||
Err(disable_err) => {
|
||||
report_critical_error(
|
||||
format!("Could not disable websocket {} with err {}, disabling because of error {}", ws_trigger.path, disable_err, error),
|
||||
db.clone()
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn listen_to_websocket(
|
||||
ws_trigger: WebsocketTrigger,
|
||||
db: DB,
|
||||
rsmq: Option<rsmq_async::MultiplexedRsmq>,
|
||||
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) -> () {
|
||||
async fn update_ping(db: DB, ws_trigger: &WebsocketTrigger, error: Option<&str>) -> Option<()> {
|
||||
match sqlx::query_scalar!(
|
||||
"UPDATE websocket_trigger SET last_server_ping = now(), error = $1 WHERE workspace_id = $2 AND path = $3 AND server_id = $4 AND enabled IS TRUE RETURNING 1",
|
||||
error,
|
||||
ws_trigger.workspace_id,
|
||||
ws_trigger.path,
|
||||
*INSTANCE_NAME
|
||||
).fetch_optional(&db).await {
|
||||
Ok(updated) => {
|
||||
if updated.flatten().is_none() {
|
||||
tracing::info!("Websocket {} changed, disabled, or deleted, stopping...", ws_trigger.url);
|
||||
return None;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!("Error updating ping of websocket {}: {:?}", ws_trigger.url, err);
|
||||
}
|
||||
};
|
||||
|
||||
Some(())
|
||||
}
|
||||
update_ping(&db, &ws_trigger, Some("Connecting...")).await;
|
||||
|
||||
let url = ws_trigger.url.as_str();
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JsonFilter {
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Filter {
|
||||
JsonFilter(JsonFilter),
|
||||
}
|
||||
let filters: Vec<Filter> = ws_trigger
|
||||
.filters
|
||||
.iter()
|
||||
.filter_map(|m| serde_json::from_value(m.clone()).ok())
|
||||
.filter_map(|m| serde_json::from_str(m.get()).ok())
|
||||
.collect_vec();
|
||||
|
||||
loop {
|
||||
let connect_url = if url.starts_with("$") {
|
||||
if url.starts_with("$flow:") || url.starts_with("$script:") {
|
||||
let path = url.splitn(2, ':').nth(1).unwrap();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = killpill_rx.recv() => {
|
||||
return;
|
||||
},
|
||||
_ = loop_ping(&db, &ws_trigger, Some(
|
||||
"Waiting on runnable to return websocket URL..."
|
||||
)) => {
|
||||
return;
|
||||
},
|
||||
url_result = get_url_from_runnable(path, url.starts_with("$flow:"), &ws_trigger, &db, rsmq.clone()) => match url_result {
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
disable_with_error(
|
||||
&db,
|
||||
&ws_trigger,
|
||||
format!(
|
||||
"Error getting websocket URL from runnable after 5 tries: {:?}",
|
||||
err
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
disable_with_error(
|
||||
&db,
|
||||
&ws_trigger,
|
||||
format!("Invalid websocket runnable path: {}", url),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = killpill_rx.recv() => {
|
||||
return;
|
||||
},
|
||||
connection = connect_async(url) => {
|
||||
_ = loop_ping(&db, &ws_trigger, Some("Connecting...")) => {
|
||||
return;
|
||||
},
|
||||
connection = connect_async(connect_url) => {
|
||||
match connection {
|
||||
Ok((ws_stream, _)) => {
|
||||
tracing::info!("Listening to websocket {}", url);
|
||||
if let None = update_ping(db.clone(), &ws_trigger, None).await {
|
||||
if let None = update_ping(&db, &ws_trigger, None).await {
|
||||
return;
|
||||
}
|
||||
let (writer, mut reader) = ws_stream.split();
|
||||
let mut last_ping = tokio::time::Instant::now();
|
||||
let (_, mut read) = ws_stream.split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = killpill_rx.recv() => {
|
||||
return;
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = killpill_rx.recv() => {
|
||||
return;
|
||||
}
|
||||
_ = async {
|
||||
if let Err(err) = send_initial_messages(&ws_trigger, writer, &db, rsmq.clone()).await {
|
||||
disable_with_error(&db, &ws_trigger, format!("Error sending initial messages: {:?}", err)).await;
|
||||
} else {
|
||||
// if initial messages sent successfully, wait forever
|
||||
futures::future::pending::<()>().await;
|
||||
}
|
||||
msg = read.next() => {
|
||||
if let Some(msg) = msg {
|
||||
if last_ping.elapsed() > tokio::time::Duration::from_secs(5) {
|
||||
if let None = update_ping(db.clone(), &ws_trigger, None).await {
|
||||
} => {
|
||||
// was disabled => exit
|
||||
return;
|
||||
},
|
||||
_ = async {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
msg = reader.next() => {
|
||||
if let Some(msg) = msg {
|
||||
if last_ping.elapsed() > tokio::time::Duration::from_secs(5) {
|
||||
if let None = update_ping(&db, &ws_trigger, None).await {
|
||||
return;
|
||||
}
|
||||
last_ping = tokio::time::Instant::now();
|
||||
}
|
||||
match msg {
|
||||
Ok(msg) => {
|
||||
match msg {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
let mut should_handle = true;
|
||||
for filter in &filters {
|
||||
match filter {
|
||||
Filter::JsonFilter(JsonFilter { key, value }) => {
|
||||
let mut deserializer = serde_json::Deserializer::from_str(text.as_str());
|
||||
should_handle = match is_value_superset(&mut deserializer, key, &value) {
|
||||
Ok(filter_match) => {
|
||||
filter_match
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!("Error deserializing filter for websocket {}: {:?}", url, err);
|
||||
false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
if !should_handle {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if should_handle {
|
||||
if let Err(err) = run_job(&db, rsmq.clone(), &ws_trigger, text).await {
|
||||
report_critical_error(format!("Failed to trigger job from websocket {}: {:?}", ws_trigger.url, err), db.clone()).await;
|
||||
};
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Error reading from websocket {}: {:?}", url, err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Websocket {} closed", url);
|
||||
if let None =
|
||||
update_ping(&db, &ws_trigger, Some("Websocket closed")).await
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
break;
|
||||
}
|
||||
},
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
|
||||
if let None = update_ping(&db, &ws_trigger, None).await {
|
||||
return;
|
||||
}
|
||||
last_ping = tokio::time::Instant::now();
|
||||
}
|
||||
match msg {
|
||||
Ok(msg) => {
|
||||
match msg {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
let mut should_handle = true;
|
||||
for filter in &filters {
|
||||
match filter {
|
||||
Filter::JsonFilter(JsonFilter { key, value }) => {
|
||||
let mut deserializer = serde_json::Deserializer::from_str(text.as_str());
|
||||
should_handle = match is_value_superset(&mut deserializer, key, &value) {
|
||||
Ok(filter_match) => {
|
||||
filter_match
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!("Error deserializing filter for websocket {}: {:?}", url, err);
|
||||
false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
if !should_handle {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if should_handle {
|
||||
let db_ = db.clone();
|
||||
let rsmq_ = rsmq.clone();
|
||||
let ws_trigger_ = ws_trigger.clone();
|
||||
tokio::spawn(async move {
|
||||
let url = ws_trigger_.url.clone();
|
||||
if let Err(err) = run_job(db_, rsmq_, ws_trigger_, text).await {
|
||||
tracing::error!("Error running job on websocket {}: {:?}", url, err);
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Error reading from websocket {}: {:?}", url, err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Websocket {} closed", url);
|
||||
if let None =
|
||||
update_ping(db.clone(), &ws_trigger, Some("Websocket closed")).await
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
break;
|
||||
},
|
||||
}
|
||||
},
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
|
||||
if let None = update_ping(db.clone(), &ws_trigger, None).await {
|
||||
return;
|
||||
}
|
||||
last_ping = tokio::time::Instant::now();
|
||||
},
|
||||
}
|
||||
} => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error connecting to websocket {}: {:?}", url, err);
|
||||
if let None =
|
||||
update_ping(db.clone(), &ws_trigger, Some(err.to_string().as_str())).await
|
||||
update_ping(&db, &ws_trigger, Some(err.to_string().as_str())).await
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -625,16 +947,18 @@ async fn listen_to_websocket(
|
||||
}
|
||||
|
||||
async fn run_job(
|
||||
db: DB,
|
||||
db: &DB,
|
||||
rsmq: Option<rsmq_async::MultiplexedRsmq>,
|
||||
trigger: WebsocketTrigger,
|
||||
trigger: &WebsocketTrigger,
|
||||
msg: String,
|
||||
) -> anyhow::Result<()> {
|
||||
let args = PushArgsOwned {
|
||||
args: HashMap::from([("msg".to_string(), to_raw_value(&msg))]),
|
||||
extra: Some(HashMap::from([(
|
||||
"wm_trigger".to_string(),
|
||||
to_raw_value(&serde_json::json!({"kind": "websocket"})),
|
||||
to_raw_value(
|
||||
&serde_json::json!({"kind": "websocket", "websocket": { "url": trigger.url }}),
|
||||
),
|
||||
)])),
|
||||
};
|
||||
let label_prefix = Some(format!("ws-{}-", trigger.path));
|
||||
@@ -643,7 +967,7 @@ async fn run_job(
|
||||
trigger.edited_by.clone(),
|
||||
trigger.email.clone(),
|
||||
&trigger.workspace_id,
|
||||
&db,
|
||||
db,
|
||||
"anonymous".to_string(),
|
||||
)
|
||||
.await?;
|
||||
@@ -653,27 +977,27 @@ async fn run_job(
|
||||
let run_query = RunJobQuery::default();
|
||||
|
||||
if trigger.is_flow {
|
||||
run_wait_result_flow_by_path_internal(
|
||||
db,
|
||||
run_query,
|
||||
StripPath(trigger.script_path.to_owned()),
|
||||
run_flow_by_path_inner(
|
||||
authed,
|
||||
rsmq,
|
||||
db.clone(),
|
||||
user_db,
|
||||
args,
|
||||
rsmq,
|
||||
trigger.workspace_id.clone(),
|
||||
StripPath(trigger.script_path.to_owned()),
|
||||
run_query,
|
||||
args,
|
||||
label_prefix,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
run_wait_result_script_by_path_internal(
|
||||
db,
|
||||
run_query,
|
||||
StripPath(trigger.script_path.to_owned()),
|
||||
run_script_by_path_inner(
|
||||
authed,
|
||||
rsmq,
|
||||
db.clone(),
|
||||
user_db,
|
||||
rsmq,
|
||||
trigger.workspace_id.clone(),
|
||||
StripPath(trigger.script_path.to_owned()),
|
||||
run_query,
|
||||
args,
|
||||
label_prefix,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN /usr/local/bin/python3 -m pip install pip-tools
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.4.18/uv-installer.sh | sh && mv /root/.cargo/bin/uv /usr/local/bin/uv
|
||||
|
||||
COPY --from=oven/bun:1.1.32 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.1.34 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
# add the docker client to call docker from a worker if enabled
|
||||
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||
|
||||
@@ -19,7 +19,7 @@ RUN /usr/local/bin/python3 -m pip install pip-tools
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.4.18/uv-installer.sh | sh && mv /root/.cargo/bin/uv /usr/local/bin/uv
|
||||
|
||||
COPY --from=oven/bun:1.1.32 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.1.34 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
# add the docker client to call docker from a worker if enabled
|
||||
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||
|
||||
@@ -128,8 +128,10 @@
|
||||
let s3FilePicker: S3FilePicker
|
||||
let s3FileUploadRawMode: false
|
||||
let isListJson = false
|
||||
let hasIsListJsonChanged = false
|
||||
|
||||
let el: HTMLTextAreaElement | undefined = undefined
|
||||
|
||||
let inputCat = computeInputCat(type, format, itemsType?.type, enum_, contentEncoding)
|
||||
|
||||
$: inputCat = computeInputCat(type, format, itemsType?.type, enum_, contentEncoding)
|
||||
@@ -170,6 +172,43 @@
|
||||
|
||||
$: computeDefaultValue(value, inputCat, defaultValue, nullable)
|
||||
|
||||
let lastValue: any = undefined
|
||||
|
||||
// By setting isListJson to true, we can render inputs even if the value is not an array of the correct type
|
||||
// This avoids the issue of the input being rendered as a string with value: [object Object], or as a number with value: NaN
|
||||
function checkArrayValueType() {
|
||||
try {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const firstItem = value?.[0]
|
||||
const type = itemsType?.type
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
if (typeof firstItem !== 'string') {
|
||||
isListJson = true
|
||||
}
|
||||
break
|
||||
case 'number':
|
||||
if (typeof firstItem !== 'number') {
|
||||
isListJson = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
lastValue = value
|
||||
}
|
||||
|
||||
$: !isListJson &&
|
||||
inputCat === 'list' &&
|
||||
value != lastValue &&
|
||||
itemsType?.type &&
|
||||
!hasIsListJsonChanged &&
|
||||
checkArrayValueType()
|
||||
|
||||
$: defaultValue != undefined && handleDefaultValueChange()
|
||||
|
||||
let oldDefaultValue = defaultValue
|
||||
@@ -518,6 +557,11 @@
|
||||
<div class="mt-2 mr-4">
|
||||
<Toggle
|
||||
on:change={(e) => {
|
||||
// Once the user has changed the input type, we should not change it back automatically
|
||||
if (!hasIsListJsonChanged) {
|
||||
hasIsListJsonChanged = true
|
||||
}
|
||||
|
||||
evalValueToRaw()
|
||||
isListJson = !isListJson
|
||||
}}
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
import type { PropPickerWrapperContext } from './flows/propPicker/PropPickerWrapper.svelte'
|
||||
import { codeToStaticTemplate, getDefaultExpr } from './flows/utils'
|
||||
import SimpleEditor from './SimpleEditor.svelte'
|
||||
import { Button } from './common'
|
||||
import { Button } from '$lib/components/common'
|
||||
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
|
||||
import type VariableEditor from './VariableEditor.svelte'
|
||||
import type ItemPicker from './ItemPicker.svelte'
|
||||
@@ -23,7 +25,7 @@
|
||||
import type { FlowCopilotContext } from './copilot/flow'
|
||||
import StepInputGen from './copilot/StepInputGen.svelte'
|
||||
import type { PickableProperties } from './flows/previousResults'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
export let schema: Schema | { properties?: Record<string, any>; required?: string[] }
|
||||
export let arg: InputTransform | any
|
||||
export let argName: string
|
||||
@@ -186,11 +188,22 @@
|
||||
let stepInputGen: StepInputGen | undefined = undefined
|
||||
|
||||
loadResourceTypes()
|
||||
|
||||
$: connecting =
|
||||
$propPickerConfig?.propName == argName && $propPickerConfig?.insertionMode == 'connect'
|
||||
</script>
|
||||
|
||||
{#if arg != undefined}
|
||||
<div class={$$props.class}>
|
||||
<div class="flex flex-row justify-between gap-1 pb-1">
|
||||
<div
|
||||
class={twMerge(
|
||||
'pl-2 pt-2 pb-2 ml-2 relative hover:bg-surface hover:shadow-md transition-all duration-200',
|
||||
$propPickerConfig?.propName == argName
|
||||
? 'bg-surface border-l-4 border-blue-500 shadow-md rounded-l-md z-2000'
|
||||
: 'hover:rounded-md',
|
||||
$$props.class
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-row justify-between gap-1 pb-1 px-2">
|
||||
<div class="flex flex-wrap grow">
|
||||
<FieldHeader
|
||||
label={argName}
|
||||
@@ -322,147 +335,172 @@
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
title="Connect to another node's output"
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs2"
|
||||
on:click={() => {
|
||||
focusProp(argName, 'connect', (path) => {
|
||||
connectProperty(path)
|
||||
dispatch('change', { argName })
|
||||
return true
|
||||
})
|
||||
}}
|
||||
id="flow-editor-plug"
|
||||
<AnimatedButton
|
||||
animate={connecting}
|
||||
baseRadius="6px"
|
||||
animationDuration="2s"
|
||||
marginWidth="2px"
|
||||
>
|
||||
<Plug size={16} /> →
|
||||
</Button>
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs2"
|
||||
btnClasses={connecting ? 'text-blue-500' : 'text-primary'}
|
||||
on:click={() => {
|
||||
focusProp(argName, 'connect', (path) => {
|
||||
connectProperty(path)
|
||||
dispatch('change', { argName })
|
||||
return true
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Plug size={16} /> →
|
||||
</Button>
|
||||
</AnimatedButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="max-w-xs" />
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="relative {$propPickerConfig?.propName == argName
|
||||
? 'outline outline-offset-1 outline-1 outline-blue-500 rounded-md'
|
||||
: ''}"
|
||||
on:keyup={stepInputGen?.onKeyUp}
|
||||
>
|
||||
{#if $propPickerConfig?.propName == argName && $propPickerConfig?.insertionMode == 'connect'}
|
||||
<div class="relative" on:keyup={stepInputGen?.onKeyUp}>
|
||||
<!-- {#if $propPickerConfig?.propName == argName && $propPickerConfig?.insertionMode == 'connect'}
|
||||
<span
|
||||
class={'text-white z-50 px-1 text-2xs py-0.5 font-bold rounded-t-sm w-fit absolute top-0 right-0 bg-blue-500'}
|
||||
>
|
||||
Connect input →
|
||||
</span>
|
||||
{/if}
|
||||
{/if} -->
|
||||
<!-- {inputCat}
|
||||
{propertyType} -->
|
||||
{#if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
<div class="text-xs italic pb-1 text-secondary">
|
||||
<pre class="font-main">{schema.properties[argName].description}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-2 min-h-[28px]">
|
||||
{#if arg}
|
||||
<TemplateEditor
|
||||
bind:this={monacoTemplate}
|
||||
{extraLib}
|
||||
<div class="relative flex flex-row items-top gap-2 justify-between">
|
||||
<div class="min-w-0 grow">
|
||||
{#if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
<div class="text-xs italic pb-1 text-secondary">
|
||||
<pre class="font-main">{schema.properties[argName].description}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-2 min-h-[28px]">
|
||||
{#if arg}
|
||||
<TemplateEditor
|
||||
bind:this={monacoTemplate}
|
||||
{extraLib}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
bind:code={arg.value}
|
||||
fontSize={14}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if (propertyType === undefined || propertyType == 'static') && schema?.properties?.[argName]}
|
||||
<ArgInput
|
||||
{resourceTypes}
|
||||
noMargin
|
||||
compact
|
||||
bind:this={argInput}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
bind:code={arg.value}
|
||||
fontSize={14}
|
||||
shouldDispatchChanges
|
||||
on:change={() => {
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
label={argName}
|
||||
bind:editor={monaco}
|
||||
bind:description={schema.properties[argName].description}
|
||||
bind:value={arg.value}
|
||||
type={schema.properties[argName].type}
|
||||
oneOf={schema.properties[argName].oneOf}
|
||||
required={schema.required?.includes(argName)}
|
||||
bind:pattern={schema.properties[argName].pattern}
|
||||
bind:valid={inputCheck}
|
||||
defaultValue={schema.properties[argName].default}
|
||||
bind:enum_={schema.properties[argName].enum}
|
||||
bind:format={schema.properties[argName].format}
|
||||
contentEncoding={schema.properties[argName].contentEncoding}
|
||||
bind:itemsType={schema.properties[argName].items}
|
||||
properties={schema.properties[argName].properties}
|
||||
nestedRequired={schema.properties[argName].required}
|
||||
displayHeader={false}
|
||||
extra={argExtra}
|
||||
{variableEditor}
|
||||
{itemPicker}
|
||||
bind:pickForField
|
||||
showSchemaExplorer
|
||||
nullable={schema.properties[argName].nullable}
|
||||
bind:title={schema.properties[argName].title}
|
||||
bind:placeholder={schema.properties[argName].placeholder}
|
||||
/>
|
||||
{:else if arg.expr != undefined}
|
||||
<div class="border mt-2">
|
||||
<SimpleEditor
|
||||
bind:this={monaco}
|
||||
bind:code={arg.expr}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
{extraLib}
|
||||
lang="javascript"
|
||||
shouldBindKey={false}
|
||||
on:focus={() => {
|
||||
focused = true
|
||||
focusProp(argName, 'insert', (path) => {
|
||||
monaco?.insertAtCursor(path)
|
||||
return false
|
||||
})
|
||||
}}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
autoHeight
|
||||
/>
|
||||
</div>
|
||||
<DynamicInputHelpBox />
|
||||
<div class="mb-2" />
|
||||
{:else}
|
||||
Not recognized input type {argName} ({arg.expr}, {propertyType})
|
||||
<div class="flex mt-2">
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
arg.expr = ''
|
||||
}}>Set expr to empty string</Button
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if (propertyType === undefined || propertyType == 'static') && schema?.properties?.[argName]}
|
||||
<ArgInput
|
||||
{resourceTypes}
|
||||
noMargin
|
||||
compact
|
||||
bind:this={argInput}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
shouldDispatchChanges
|
||||
on:change={() => {
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
label={argName}
|
||||
bind:editor={monaco}
|
||||
bind:description={schema.properties[argName].description}
|
||||
bind:value={arg.value}
|
||||
type={schema.properties[argName].type}
|
||||
oneOf={schema.properties[argName].oneOf}
|
||||
required={schema.required?.includes(argName)}
|
||||
bind:pattern={schema.properties[argName].pattern}
|
||||
bind:valid={inputCheck}
|
||||
defaultValue={schema.properties[argName].default}
|
||||
bind:enum_={schema.properties[argName].enum}
|
||||
bind:format={schema.properties[argName].format}
|
||||
contentEncoding={schema.properties[argName].contentEncoding}
|
||||
bind:itemsType={schema.properties[argName].items}
|
||||
properties={schema.properties[argName].properties}
|
||||
nestedRequired={schema.properties[argName].required}
|
||||
displayHeader={false}
|
||||
extra={argExtra}
|
||||
{variableEditor}
|
||||
{itemPicker}
|
||||
bind:pickForField
|
||||
showSchemaExplorer
|
||||
nullable={schema.properties[argName].nullable}
|
||||
bind:title={schema.properties[argName].title}
|
||||
bind:placeholder={schema.properties[argName].placeholder}
|
||||
/>
|
||||
{:else if arg.expr != undefined}
|
||||
<div class="border mt-2">
|
||||
<SimpleEditor
|
||||
bind:this={monaco}
|
||||
bind:code={arg.expr}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
{extraLib}
|
||||
lang="javascript"
|
||||
shouldBindKey={false}
|
||||
on:focus={() => {
|
||||
focused = true
|
||||
focusProp(argName, 'insert', (path) => {
|
||||
monaco?.insertAtCursor(path)
|
||||
return false
|
||||
})
|
||||
}}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
autoHeight
|
||||
/>
|
||||
</div>
|
||||
<DynamicInputHelpBox />
|
||||
<div class="mb-2" />
|
||||
{:else}
|
||||
Not recognized input type {argName} ({arg.expr}, {propertyType})
|
||||
<div class="flex mt-2">
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
arg.expr = ''
|
||||
}}>Set expr to empty string</Button
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if $propPickerConfig?.propName == argName}
|
||||
<div class="text-blue-500 mt-2" in:fade={{ duration: 200 }}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="24 24 12 12 24 0" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-0" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -67,24 +67,26 @@
|
||||
|
||||
<div class="w-full {clazz}">
|
||||
{#if enableAi}
|
||||
<StepInputsGen
|
||||
{pickableProperties}
|
||||
argNames={keys
|
||||
? keys.filter(
|
||||
(argName) =>
|
||||
Object.keys(schema.properties ?? {}).includes(argName) &&
|
||||
Object.keys(args ?? {}).includes(argName) &&
|
||||
((args[argName].type === 'static' && !args[argName].value) ||
|
||||
(args[argName].type === 'javascript' && !args[argName].expr))
|
||||
)
|
||||
: []}
|
||||
{schema}
|
||||
/>
|
||||
<div class="px-0.5 pt-0.5">
|
||||
<StepInputsGen
|
||||
{pickableProperties}
|
||||
argNames={keys
|
||||
? keys.filter(
|
||||
(argName) =>
|
||||
Object.keys(schema.properties ?? {}).includes(argName) &&
|
||||
Object.keys(args ?? {}).includes(argName) &&
|
||||
((args[argName].type === 'static' && !args[argName].value) ||
|
||||
(args[argName].type === 'javascript' && !args[argName].expr))
|
||||
)
|
||||
: []}
|
||||
{schema}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if keys.length > 0}
|
||||
{#each keys as argName (argName)}
|
||||
{#if (!filter || filter.includes(argName)) && Object.keys(schema.properties ?? {}).includes(argName)}
|
||||
<div class="z-10 pt-4">
|
||||
<div class="z-10 pt-2 relative">
|
||||
<InputTransformForm
|
||||
{previousModuleId}
|
||||
bind:arg={args[argName]}
|
||||
@@ -128,7 +130,7 @@
|
||||
>
|
||||
<div
|
||||
slot="submission"
|
||||
class="flex flex-row-reverse w-full bg-surface border-t border-gray-200 rounded-bl-lg rounded-br-lg"
|
||||
class="flex flex-row-reverse w-full border-t border-gray-200 rounded-bl-lg rounded-br-lg"
|
||||
>
|
||||
<Button
|
||||
variant="border"
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
path: valueSelect.label
|
||||
})
|
||||
await loadResources(resourceType)
|
||||
value = undefined
|
||||
valueSelect = undefined
|
||||
}
|
||||
}}
|
||||
@@ -216,6 +217,7 @@
|
||||
btnClasses="w-8 px-0.5 py-1.5"
|
||||
size="sm"
|
||||
on:click={() => {
|
||||
refreshCount += 1
|
||||
open = true
|
||||
drawer?.openDrawer?.()
|
||||
}}
|
||||
|
||||
@@ -95,11 +95,11 @@
|
||||
value={items?.find((x) => x.value == initialPath)}
|
||||
class="grow shrink max-w-full"
|
||||
on:change={() => {
|
||||
dispatch('select', { path: scriptPath })
|
||||
dispatch('select', { path: scriptPath, itemKind })
|
||||
}}
|
||||
on:input={(ev) => {
|
||||
if (!ev.detail) {
|
||||
dispatch('select', { path: undefined })
|
||||
dispatch('select', { path: undefined, itemKind })
|
||||
}
|
||||
}}
|
||||
bind:justValue={scriptPath}
|
||||
|
||||
@@ -41,12 +41,14 @@
|
||||
|
||||
{#if object != undefined && Object.keys(object).length > 0}
|
||||
{#if $hasResult[componentId] || $search == ''}
|
||||
<ObjectViewer
|
||||
json={filtered}
|
||||
on:select
|
||||
topBrackets={false}
|
||||
pureViewer={!$connectingInput.opened}
|
||||
/>
|
||||
<div class="pl-2">
|
||||
<ObjectViewer
|
||||
json={filtered}
|
||||
on:select
|
||||
topBrackets={false}
|
||||
pureViewer={!$connectingInput.opened}
|
||||
/>
|
||||
</div>
|
||||
{:else if $search.length > 0}
|
||||
<div class="text-xs pl-2 text-tertiary">No results</div>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
|
||||
export let marginWidth = '2px'
|
||||
export let animationDuration = '2s'
|
||||
export let baseRadius = '4px'
|
||||
export let animate = true
|
||||
export let wrapperClasses = ''
|
||||
export let ringColor = 'transparent'
|
||||
export let darkMode = false
|
||||
|
||||
const gradientColors = {
|
||||
light: ['#d6e5ff', '#0073ff', '#5aa2fa', '#0272fa', '#d6e5ff'],
|
||||
dark: ['#0469db', '#15498a', '#031ea3', '#0073ff', '#0469db']
|
||||
}
|
||||
|
||||
let clientWidth = 0
|
||||
let clientHeight = 0
|
||||
|
||||
$: circleRadius = Math.ceil(
|
||||
Math.sqrt(clientWidth * clientWidth + clientHeight * clientHeight) / 2
|
||||
)
|
||||
|
||||
$: gradientString = `from 0deg, ${gradientColors[darkMode ? 'dark' : 'light'].join(', ')}`
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div
|
||||
class={twMerge('gradient-button', wrapperClasses)}
|
||||
style="--margin-width: {marginWidth}; --animation-duration: {animationDuration}; --base-radius: {baseRadius}; --circle-radius: {circleRadius}; --ring-color: {ringColor}; --gradient: {gradientString}"
|
||||
class:animate
|
||||
bind:clientWidth
|
||||
bind:clientHeight
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gradient-button {
|
||||
position: relative;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
border-radius: calc(var(--base-radius) + var(--margin-width, 2px));
|
||||
color: currentColor;
|
||||
background: inherit;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
padding: var(--margin-width, 2px);
|
||||
}
|
||||
|
||||
/* Circular gradient */
|
||||
.gradient-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(var(--circle-radius, 300px) * 2px);
|
||||
height: calc(var(--circle-radius, 300px) * 2px);
|
||||
background: var(--ring-color, transparent);
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.gradient-button.animate::before {
|
||||
background: conic-gradient(var(--gradient));
|
||||
animation: rotate var(--animation-duration, 2s) linear infinite;
|
||||
}
|
||||
|
||||
/* inner background */
|
||||
.gradient-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: var(--margin-width, 2px);
|
||||
right: var(--margin-width, 2px);
|
||||
bottom: var(--margin-width, 2px);
|
||||
left: var(--margin-width, 2px);
|
||||
background: inherit;
|
||||
border-radius: var(--base-radius);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -60,8 +60,8 @@
|
||||
{#if value && isHovered}
|
||||
<button
|
||||
transition:fade|local={{ duration: 80 }}
|
||||
class="absolute z-10 top-[9.5px] right-2 rounded-full p-0.5 text-white bg-gray-300
|
||||
duration-200 hover:bg-gray-600 focus:bg-gray-600 {buttonClass}"
|
||||
class="absolute z-10 top-[9.5px] right-2 rounded-full p-0.5 text-primary bg-surface-secondary
|
||||
duration-200 hover:bg-surface-hovr focus:bg-surface-hover {buttonClass}"
|
||||
aria-label="Clear"
|
||||
on:click|preventDefault|stopPropagation={clear}
|
||||
>
|
||||
|
||||
@@ -39,7 +39,13 @@
|
||||
|
||||
let requestType: 'hash' | 'path' = 'path'
|
||||
|
||||
function emailAddress() {
|
||||
function emailAddress(
|
||||
requestType: 'hash' | 'path',
|
||||
path: string,
|
||||
hash: string | undefined,
|
||||
isFlow: boolean,
|
||||
token: string
|
||||
) {
|
||||
const pathOrHash = requestType === 'hash' ? hash : path.replaceAll('/', '.')
|
||||
const plainPrefix = `${$workspaceStore}+${
|
||||
(requestType === 'hash' ? 'hash.' : isFlow ? 'flow.' : '') + pathOrHash
|
||||
@@ -54,7 +60,7 @@
|
||||
|
||||
export let email: string = ''
|
||||
|
||||
$: email = emailAddress()
|
||||
$: email = emailAddress(requestType, path, hash, isFlow, token)
|
||||
|
||||
let triggerTokens: TriggerTokens | undefined = undefined
|
||||
</script>
|
||||
|
||||
@@ -378,10 +378,11 @@
|
||||
class={advancedSelected === 'runtime' ? 'h-[calc(100%-68px)]' : 'h-[calc(100%-34px)]'}
|
||||
>
|
||||
{#if selected === 'inputs' && (flowModule.value.type == 'rawscript' || flowModule.value.type == 'script' || flowModule.value.type == 'flow')}
|
||||
<div class="h-full overflow-auto px-2" id="flow-editor-step-input">
|
||||
<div class="h-full overflow-auto px-2 bg-surface" id="flow-editor-step-input">
|
||||
<PropPickerWrapper
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
error={failureModule}
|
||||
noPadding
|
||||
>
|
||||
<InputTransformSchemaForm
|
||||
bind:this={inputTransformSchemaForm}
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
<script lang="ts">
|
||||
import PropPicker from '$lib/components/propertyPicker/PropPicker.svelte'
|
||||
import PropPickerResult from '$lib/components/propertyPicker/PropPickerResult.svelte'
|
||||
import { clickOutside, sendUserToast } from '$lib/utils'
|
||||
import { clickOutside } from '$lib/utils'
|
||||
import { createEventDispatcher, setContext } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
|
||||
|
||||
export let pickableProperties: PickableProperties | undefined
|
||||
export let result: any = undefined
|
||||
@@ -58,51 +59,70 @@
|
||||
use:clickOutside
|
||||
on:click_outside={() => propPickerConfig.set(undefined)}
|
||||
>
|
||||
<Splitpanes>
|
||||
<Splitpanes class={$propPickerConfig ? 'splitpanes-remove-splitter' : ''}>
|
||||
<Pane
|
||||
minSize={20}
|
||||
size={60}
|
||||
class={twMerge('relative !transition-none', noPadding ? '' : 'p-2')}
|
||||
class={twMerge('relative !transition-none ', noPadding ? '' : 'p-2')}
|
||||
>
|
||||
<slot />
|
||||
</Pane>
|
||||
<Pane
|
||||
minSize={20}
|
||||
size={40}
|
||||
class="pt-2 relative !transition-none {$propPickerConfig ? 'border-2 border-blue-500' : ''}"
|
||||
class="!transition-none z-1000 {$propPickerConfig ? 'ml-[-1px]' : ''}"
|
||||
>
|
||||
{#if result}
|
||||
<PropPickerResult
|
||||
{result}
|
||||
{extraResults}
|
||||
{flow_input}
|
||||
on:select={({ detail }) => {
|
||||
if (!notSelectable && !$propPickerConfig) {
|
||||
sendUserToast('Set cursor within an input or click on the plug first', true)
|
||||
}
|
||||
dispatch('select', detail)
|
||||
if ($propPickerConfig?.onSelect(detail)) {
|
||||
propPickerConfig.set(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else if pickableProperties}
|
||||
<PropPicker
|
||||
{displayContext}
|
||||
{error}
|
||||
{pickableProperties}
|
||||
{notSelectable}
|
||||
on:select={({ detail }) => {
|
||||
if (!notSelectable && !$propPickerConfig) {
|
||||
sendUserToast('Set cursor within an input or click on the plug first', true)
|
||||
}
|
||||
dispatch('select', detail)
|
||||
if ($propPickerConfig?.onSelect(detail)) {
|
||||
propPickerConfig.set(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<AnimatedButton
|
||||
animate={$propPickerConfig?.insertionMode == 'connect'}
|
||||
baseRadius="4px"
|
||||
wrapperClasses="h-full w-full pt-2 !bg-surface"
|
||||
marginWidth="3px"
|
||||
ringColor={$propPickerConfig?.insertionMode == 'insert' ||
|
||||
$propPickerConfig?.insertionMode == 'append'
|
||||
? '#3b82f6'
|
||||
: 'transparent'}
|
||||
animationDuration="4s"
|
||||
>
|
||||
{#if result}
|
||||
<PropPickerResult
|
||||
{result}
|
||||
{extraResults}
|
||||
{flow_input}
|
||||
allowCopy={!notSelectable && !$propPickerConfig}
|
||||
on:select={({ detail }) => {
|
||||
dispatch('select', detail)
|
||||
if ($propPickerConfig?.onSelect(detail)) {
|
||||
propPickerConfig.set(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else if pickableProperties}
|
||||
<PropPicker
|
||||
{displayContext}
|
||||
{error}
|
||||
{pickableProperties}
|
||||
{notSelectable}
|
||||
allowCopy={!notSelectable && !$propPickerConfig}
|
||||
on:select={({ detail }) => {
|
||||
dispatch('select', detail)
|
||||
if ($propPickerConfig?.onSelect(detail)) {
|
||||
propPickerConfig.set(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</AnimatedButton>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.splitpanes-remove-splitter > .splitpanes__pane) {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
:global(.splitpanes-remove-splitter > .splitpanes__splitter) {
|
||||
background-color: transparent !important;
|
||||
width: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard, pluralize, truncate } from '$lib/utils'
|
||||
import { copyToClipboard, truncate } from '$lib/utils'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Badge } from '../common'
|
||||
import { computeKey } from './utils'
|
||||
import WarningMessage from './WarningMessage.svelte'
|
||||
import { NEVER_TESTED_THIS_FAR } from '../flows/models'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Download, PanelRightOpen } from 'lucide-svelte'
|
||||
import S3FilePicker from '../S3FilePicker.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -19,9 +17,9 @@
|
||||
export let collapsed = (level != 0 && level % 3 == 0) || Array.isArray(json)
|
||||
export let rawKey = false
|
||||
export let topBrackets = false
|
||||
export let topLevelNode = false
|
||||
export let allowCopy = true
|
||||
export let collapseLevel: number | undefined = undefined
|
||||
export let prefix = ''
|
||||
|
||||
let s3FileViewer: S3FilePicker
|
||||
|
||||
@@ -50,12 +48,25 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function selectProp(key: string, value: any | undefined = undefined) {
|
||||
if (pureViewer && allowCopy) {
|
||||
const valueToCopy = value !== undefined ? value : computeKey(key, isArray, currentPath)
|
||||
copyToClipboard(valueToCopy)
|
||||
function computeFullKey(key: string, rawKey: boolean) {
|
||||
if (rawKey) {
|
||||
return `${prefix}('${key}')`
|
||||
}
|
||||
dispatch('select', rawKey ? key : computeKey(key, isArray, currentPath))
|
||||
const keyToSelect = computeKey(key, isArray, currentPath)
|
||||
const separator = !prefix || keyToSelect.startsWith('[') ? '' : '.'
|
||||
return prefix + separator + keyToSelect
|
||||
}
|
||||
|
||||
function selectProp(key: string, value: any | undefined, clickedValue: boolean) {
|
||||
const fullKey = computeFullKey(key, rawKey)
|
||||
if (allowCopy) {
|
||||
if (pureViewer && clickedValue) {
|
||||
copyToClipboard(typeof value == 'string' ? value : JSON.stringify(value))
|
||||
} else {
|
||||
copyToClipboard(fullKey)
|
||||
}
|
||||
}
|
||||
dispatch('select', fullKey)
|
||||
}
|
||||
|
||||
$: keyLimit = isArray ? 1 : 100
|
||||
@@ -66,40 +77,44 @@
|
||||
<Portal name="object-viewer">
|
||||
<S3FilePicker bind:this={s3FileViewer} readOnlyMode={true} />
|
||||
</Portal>
|
||||
|
||||
{#if keys.length > 0}
|
||||
{#if !fullyCollapsed}
|
||||
<span>
|
||||
{#if level != 0 && keys.length > 1}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<span class="cursor-pointer border hover:bg-surface-hover px-1 rounded" on:click={collapse}>
|
||||
-
|
||||
</span>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={collapse}
|
||||
wrapperClasses="inline-flex w-fit h-4"
|
||||
btnClasses="font-semibold text-primary rounded-[0.275rem]">-</Button
|
||||
>
|
||||
{/if}
|
||||
{#if level == 0 && topBrackets}<span class="h-0">{openBracket}</span>{/if}
|
||||
<ul class={`w-full pl-2 ${level === 0 ? 'border-none' : 'border-l border-dotted'}`}>
|
||||
<ul class={`w-full ${level === 0 ? 'border-none' : 'pl-2 border-l border-dotted'}`}>
|
||||
{#each keys.length > keyLimit ? keys.slice(0, keyLimit) : keys as key, index (key)}
|
||||
<li>
|
||||
<button on:click={() => selectProp(key)} class="whitespace-nowrap">
|
||||
{#if topLevelNode}
|
||||
<Badge baseClass="border border-blue-600" color="indigo">{key}</Badge>
|
||||
{:else}
|
||||
<span
|
||||
class="key {pureViewer
|
||||
? 'cursor-auto'
|
||||
: 'border '} font-semibold rounded px-1 hover:bg-surface-hover text-2xs text-secondary"
|
||||
>
|
||||
{!isArray ? key : index}</span
|
||||
>
|
||||
{/if}:
|
||||
</button>
|
||||
|
||||
<div class="inline-flex items-baseline">
|
||||
<Button
|
||||
on:click={() => selectProp(key, undefined, false)}
|
||||
size="xs2"
|
||||
color="light"
|
||||
variant="border"
|
||||
wrapperClasses="inline-flex p-0 whitespace-nowrap w-fit"
|
||||
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]"
|
||||
title={computeFullKey(key, rawKey)}
|
||||
>
|
||||
<span class={pureViewer ? 'cursor-auto' : ''}>{!isArray ? key : index} </span>
|
||||
</Button>
|
||||
<span class="text-2xs text-tertiary">:</span>
|
||||
</div>
|
||||
{#if getTypeAsString(json[key]) === 'object'}
|
||||
<svelte:self
|
||||
json={json[key]}
|
||||
level={level + 1}
|
||||
currentPath={computeKey(key, isArray, currentPath)}
|
||||
currentPath={computeFullKey(key, rawKey)}
|
||||
{pureViewer}
|
||||
{allowCopy}
|
||||
on:select
|
||||
@@ -110,21 +125,25 @@
|
||||
<button
|
||||
class="val text-left {pureViewer
|
||||
? 'cursor-auto'
|
||||
: ''} rounded px-1 hover:bg-blue-100 dark:hover:bg-blue-100/10 {getTypeAsString(
|
||||
json[key]
|
||||
)}"
|
||||
on:click={() => selectProp(key, json[key])}
|
||||
: ''} rounded px-1 {getTypeAsString(json[key])}"
|
||||
on:click={() => {
|
||||
selectProp(key, json[key], true)
|
||||
}}
|
||||
title={JSON.stringify(json[key])}
|
||||
disabled={false}
|
||||
>
|
||||
{#if json[key] === NEVER_TESTED_THIS_FAR}
|
||||
<WarningMessage />
|
||||
<span class="text-2xs text-tertiary font-normal">
|
||||
Test the flow to see a value
|
||||
</span>
|
||||
{:else if json[key] == undefined}
|
||||
<span class="text-2xs">undefined</span>
|
||||
{:else if json[key] == null}
|
||||
<span class="text-2xs">null</span>
|
||||
{:else if typeof json[key] == 'string'}
|
||||
<span title={json[key]} class="text-2xs">"{truncate(json[key], 200)}"</span>
|
||||
<span class="text-2xs">"{truncate(json[key], 200)}"</span>
|
||||
{:else}
|
||||
<span title={JSON.stringify(json[key])} class="text-2xs">
|
||||
<span class="text-2xs">
|
||||
{truncate(JSON.stringify(json[key]), 200)}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -134,7 +153,7 @@
|
||||
{/each}
|
||||
{#if keys.length > keyLimit}
|
||||
{@const increment = Math.min(100, keys.length - keyLimit)}
|
||||
<button on:click={() => (keyLimit += increment)} class="text-xs py-2 text-blue-600">
|
||||
<button on:click={() => (keyLimit += increment)} class="text-2xs px-2 text-secondary">
|
||||
{keyLimit}/{keys.length}: Load {increment} more...
|
||||
</button>
|
||||
{/if}
|
||||
@@ -167,24 +186,27 @@
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<span
|
||||
class="border border-blue-600 rounded px-1 cursor-pointer hover:bg-gray-200"
|
||||
class:hidden={!fullyCollapsed}
|
||||
on:click={collapse}
|
||||
>
|
||||
{openBracket}{collapsedSymbol}{closeBracket}
|
||||
</span>
|
||||
|
||||
{#if fullyCollapsed}
|
||||
<span class="text-tertiary text-xs">
|
||||
{pluralize(Object.keys(json).length, Array.isArray(json) ? 'item' : 'key')}
|
||||
</span>
|
||||
<div class="inline-flex relative">
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={collapse}
|
||||
wrapperClasses="inline-flex w-fit h-4"
|
||||
btnClasses="font-normal rounded-[0.275rem] h-4 p-1"
|
||||
>
|
||||
{openBracket}{collapsedSymbol}{closeBracket}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if topBrackets}
|
||||
<span class="text-primary">{openBracket}{closeBracket}</span>
|
||||
{:else if json == undefined}
|
||||
<span class="text-tertiary text-2xs ml-2">undefined</span>
|
||||
{:else}
|
||||
<span class="text-tertiary text-2xs ml-2">No items ([])</span>
|
||||
<span class="text-tertiary text-2xs ml-2">No items</span>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { ResourceService, VariableService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { getContext } from 'svelte'
|
||||
import { Badge, Button } from '../common'
|
||||
import { Button } from '../common'
|
||||
import type { PropPickerWrapperContext } from '../flows/propPicker/PropPickerWrapper.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
import ObjectViewer from './ObjectViewer.svelte'
|
||||
import { keepByKey } from './utils'
|
||||
@@ -15,33 +14,45 @@
|
||||
export let displayContext = true
|
||||
export let notSelectable: boolean
|
||||
export let error: boolean = false
|
||||
export let allowCopy = false
|
||||
|
||||
$: previousId = pickableProperties?.previousId
|
||||
let variables: Record<string, string> = {}
|
||||
let resources: Record<string, any> = {}
|
||||
let displayVariable = false
|
||||
let displayResources = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let allResultsCollapsed = true
|
||||
|
||||
const EMPTY_STRING = ''
|
||||
let search = ''
|
||||
|
||||
const { propPickerConfig } = getContext<PropPickerWrapperContext>('PropPickerWrapper')
|
||||
|
||||
$: flowInputsFiltered =
|
||||
search === EMPTY_STRING
|
||||
? pickableProperties.flow_input
|
||||
: keepByKey(pickableProperties.flow_input, search)
|
||||
let flowInputsFiltered = pickableProperties.flow_input
|
||||
let resultByIdFiltered = pickableProperties.priorIds
|
||||
|
||||
$: resultByIdFiltered =
|
||||
search === EMPTY_STRING
|
||||
? pickableProperties.priorIds
|
||||
: keepByKey(pickableProperties.priorIds, search)
|
||||
let timeout: NodeJS.Timeout
|
||||
function onSearch(search: string) {
|
||||
clearTimeout(timeout)
|
||||
setTimeout(() => {
|
||||
flowInputsFiltered =
|
||||
search === EMPTY_STRING
|
||||
? pickableProperties.flow_input
|
||||
: keepByKey(pickableProperties.flow_input, search)
|
||||
|
||||
resultByIdFiltered =
|
||||
search === EMPTY_STRING
|
||||
? pickableProperties.priorIds
|
||||
: keepByKey(pickableProperties.priorIds, search)
|
||||
|
||||
console.log(resultByIdFiltered, search)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
$: suggestedPropsFiltered = $propPickerConfig
|
||||
? keepByKey(pickableProperties.priorIds, $propPickerConfig.propName)
|
||||
: undefined
|
||||
$: search != undefined && onSearch(search)
|
||||
|
||||
async function loadVariables() {
|
||||
variables = Object.fromEntries(
|
||||
@@ -64,50 +75,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="px-2">
|
||||
{#if !notSelectable}
|
||||
<div class="flex flex-row space-x-1">
|
||||
{#if $propPickerConfig}
|
||||
<Badge large color="blue">
|
||||
{`Selected: ${$propPickerConfig?.propName}`}
|
||||
</Badge>
|
||||
<Badge large color="blue">
|
||||
{`Mode: ${$propPickerConfig?.insertionMode}`}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge large color="blue">← Edit or connect an input</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<ClearableInput bind:value={search} placeholder="Search prop..." wrapperClass="py-2" />
|
||||
<div class="flex flex-col h-full rounded overflow-hidden">
|
||||
<div class="px-2 py-2">
|
||||
<ClearableInput bind:value={search} placeholder="Search prop..." />
|
||||
</div>
|
||||
<div
|
||||
class="overflow-y-auto px-2 pt-2 grow"
|
||||
class:bg-surface-secondary={!$propPickerConfig && !notSelectable}
|
||||
>
|
||||
<div class="overflow-y-auto px-2 pt-2 grow">
|
||||
<div class="flex justify-between items-center space-x-1">
|
||||
<span class="font-bold text-sm">Flow Input</span>
|
||||
<span class="font-normal text-sm text-secondary">Flow Input</span>
|
||||
<div class="flex space-x-2 items-center" />
|
||||
</div>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
json={flowInputsFiltered}
|
||||
on:select={(e) => {
|
||||
dispatch(
|
||||
'select',
|
||||
e.detail?.startsWith('[') ? `flow_input${e.detail}` : `flow_input.${e.detail}`
|
||||
)
|
||||
}}
|
||||
prefix="flow_input"
|
||||
on:select
|
||||
/>
|
||||
</div>
|
||||
{#if error}
|
||||
<span class="font-bold text-sm">Error</span>
|
||||
<span class="font-normal text-sm text-secondary">Error</span>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
json={{
|
||||
error: {
|
||||
@@ -122,158 +112,178 @@
|
||||
</div>
|
||||
{#if Object.keys(pickableProperties.priorIds).length > 0}
|
||||
{#if suggestedPropsFiltered && Object.keys(suggestedPropsFiltered).length > 0}
|
||||
<span class="font-bold text-sm">Suggested Results</span>
|
||||
<span class="font-normal text-sm text-secondary">Suggested Results</span>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
topLevelNode
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
collapsed={false}
|
||||
json={suggestedPropsFiltered}
|
||||
on:select={(e) => {
|
||||
dispatch('select', `results.${e.detail}`)
|
||||
}}
|
||||
prefix="results"
|
||||
on:select
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="font-bold text-sm">All Results</span>
|
||||
<span class="font-normal text-sm text-secondary">All Results</span>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
topLevelNode
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
collapsed={true}
|
||||
json={resultByIdFiltered}
|
||||
on:select={(e) => {
|
||||
dispatch('select', `results.${e.detail}`)
|
||||
}}
|
||||
prefix="results"
|
||||
on:select
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if previousId}
|
||||
<span class="font-bold text-sm">Previous Result</span>
|
||||
<span class="font-normal text-sm text-secondary">Previous Result</span>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
topLevelNode
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
json={Object.fromEntries(
|
||||
Object.entries(resultByIdFiltered).filter(([k, v]) => k == previousId)
|
||||
)}
|
||||
on:select={(e) => {
|
||||
dispatch('select', `results.${e.detail}`)
|
||||
}}
|
||||
prefix="results"
|
||||
on:select
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if pickableProperties.hasResume}
|
||||
<span class="font-bold text-sm">Resume payloads</span>
|
||||
<span class="font-normal text-sm text-secondary">Resume payloads</span>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
topLevelNode
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
json={{
|
||||
resume: 'The resume payload',
|
||||
resumes: 'All resume payloads from all approvers',
|
||||
approvers: 'The list of approvers'
|
||||
}}
|
||||
on:select={(e) => {
|
||||
dispatch('select', `${e.detail}`)
|
||||
}}
|
||||
on:select
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if Object.keys(pickableProperties.priorIds).length > 0}
|
||||
{#if suggestedPropsFiltered && Object.keys(suggestedPropsFiltered).length > 0}
|
||||
<span class="font-bold text-sm">Suggested Results</span>
|
||||
<span class="font-normal text-sm text-secondary">Suggested Results</span>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
topLevelNode
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
collapsed={false}
|
||||
json={suggestedPropsFiltered}
|
||||
on:select={(e) => {
|
||||
dispatch('select', `results.${e.detail}`)
|
||||
}}
|
||||
prefix="results"
|
||||
on:select
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="font-bold text-sm">All Results</span>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<span class="font-normal text-sm text-secondary">All Results</span>
|
||||
{#if !allResultsCollapsed}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="contained"
|
||||
on:click={() => {
|
||||
allResultsCollapsed = true
|
||||
}}
|
||||
wrapperClasses="inline-flex w-fit h-4"
|
||||
btnClasses="font-normal text-primary rounded-[0.275rem]">-</Button
|
||||
>
|
||||
{/if}
|
||||
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
topLevelNode
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
collapsed={true}
|
||||
bind:collapsed={allResultsCollapsed}
|
||||
json={resultByIdFiltered}
|
||||
on:select={(e) => {
|
||||
dispatch('select', `results.${e.detail}`)
|
||||
}}
|
||||
prefix="results"
|
||||
on:select
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if displayContext}
|
||||
<span class="font-bold text-sm">Variables </span>
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<span class="font-normal text-sm text-secondary">Variables:</span>
|
||||
|
||||
{#if displayVariable}
|
||||
<div class="flex">
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
displayVariable = false
|
||||
}}>-</Button
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
displayVariable = false
|
||||
}}
|
||||
wrapperClasses="inline-flex w-fit h-4"
|
||||
btnClasses="font-normal text-primary rounded-[0.275rem]">-</Button
|
||||
>
|
||||
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
rawKey={true}
|
||||
json={variables}
|
||||
on:select={(e) => dispatch('select', `variable('${e.detail}')`)}
|
||||
prefix="variable"
|
||||
on:select
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="border border-blue-600 key font-normal rounded hover:bg-blue-100 px-1"
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={async () => {
|
||||
await loadVariables()
|
||||
displayVariable = true
|
||||
}}>{'{...}'}</button
|
||||
}}
|
||||
wrapperClasses="inline-flex w-fit h-4"
|
||||
btnClasses="font-normal rounded-[0.275rem] p-1"
|
||||
>
|
||||
{'{...}'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="font-bold text-sm">Resources</span>
|
||||
|
||||
<div class="overflow-y-auto mb-2">
|
||||
<span class="font-normal text-sm text-secondary">Resources:</span>
|
||||
|
||||
{#if displayResources}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
displayResources = false
|
||||
}}>-</Button
|
||||
}}
|
||||
wrapperClasses="inline-flex w-fit h-4"
|
||||
btnClasses="font-normal text-primary rounded-[0.275rem]">-</Button
|
||||
>
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
{allowCopy}
|
||||
pureViewer={!$propPickerConfig}
|
||||
rawKey={true}
|
||||
json={resources}
|
||||
on:select={(e) => dispatch('select', `resource('${e.detail}')`)}
|
||||
prefix="resource"
|
||||
on:select
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="border border-blue-600 px-1 key font-normal rounded hover:bg-blue-100"
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={async () => {
|
||||
await loadResources()
|
||||
displayResources = true
|
||||
}}>{'{...}'}</button
|
||||
}}
|
||||
wrapperClasses="inline-flex w-fit h-4"
|
||||
btnClasses="font-normal rounded-[0.275rem] p-1"
|
||||
>
|
||||
{'{...}'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import ObjectViewer from './ObjectViewer.svelte'
|
||||
|
||||
export let allowCopy = false
|
||||
export let result: any
|
||||
export let extraResults: any = undefined
|
||||
export let flow_input: any = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<div class="w-full px-2">
|
||||
<span class="font-bold text-sm">Result</span>
|
||||
<span class="font-normal text-sm text-secondary">Result</span>
|
||||
<div class="overflow-y-auto mb-2 w-full">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
json={{ result, ...(extraResults ? extraResults : {}) }}
|
||||
on:select
|
||||
/>
|
||||
<ObjectViewer {allowCopy} json={{ result, ...(extraResults ? extraResults : {}) }} on:select />
|
||||
</div>
|
||||
{#if flow_input}
|
||||
<span class="font-bold text-sm">Flow Input</span>
|
||||
<span class="font-normal text-sm text-secondary">Flow Input</span>
|
||||
<div class="overflow-y-auto w-full">
|
||||
<ObjectViewer
|
||||
allowCopy={false}
|
||||
json={flow_input}
|
||||
on:select={(e) => dispatch('select', `flow_input.${e.detail}`)}
|
||||
/>
|
||||
<ObjectViewer {allowCopy} json={flow_input} prefix="flow_input" on:select />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,35 @@
|
||||
function filterByKey(obj: Object, key: string): Object {
|
||||
if (Object(obj) !== obj) {
|
||||
return obj
|
||||
function filterByKey(obj: Object, key: string): any {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return undefined
|
||||
} else if (Array.isArray(obj)) {
|
||||
return obj.map((o) => filterByKey(o, key))
|
||||
} else {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj)
|
||||
.filter(([k, v]) => !k.includes(key))
|
||||
.map(([k, v]) => [k, filterByKey(v, key)])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function diff(target: Object, source: Object): Object {
|
||||
if (Array.isArray(target)) {
|
||||
return target
|
||||
}
|
||||
|
||||
const result = {}
|
||||
|
||||
Object.keys(target ?? {}).forEach((key: string) => {
|
||||
if (typeof source[key] === 'object') {
|
||||
const difference = diff(target[key], source[key])
|
||||
|
||||
if (Object.keys(difference).length > 0) {
|
||||
result[key] = difference
|
||||
}
|
||||
} else if (source[key] !== target[key]) {
|
||||
result[key] = target[key]
|
||||
let a = obj
|
||||
.map((k, o) =>
|
||||
typeof o == 'object' ? filterByKey(o, key) : String(k - 1).includes(key) ? o : undefined
|
||||
)
|
||||
.filter((v) => v !== undefined)
|
||||
if (a.length === 0) {
|
||||
return undefined
|
||||
} else {
|
||||
return a
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
} else {
|
||||
let o = Object.fromEntries(
|
||||
Object.entries(obj)
|
||||
.map(([k, v]) => (k.includes(key) ? [k, v] : [k, filterByKey(v, key)]))
|
||||
.filter(([k, v]) => v !== undefined)
|
||||
)
|
||||
if (Object.keys(o).length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return o
|
||||
}
|
||||
}
|
||||
|
||||
export function keepByKey(json: Object | undefined, key: string): Object {
|
||||
if (!json) {
|
||||
return {}
|
||||
}
|
||||
return diff(json, filterByKey(json, key))
|
||||
return filterByKey(json, key) ?? {}
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/23377217/way-to-test-if-a-string-is-valid-identifier-name-in-javascript
|
||||
|
||||
@@ -5,9 +5,17 @@
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Required from '$lib/components/Required.svelte'
|
||||
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
|
||||
import { WebsocketTriggerService } from '$lib/gen'
|
||||
import {
|
||||
FlowService,
|
||||
ScriptService,
|
||||
WebsocketTriggerService,
|
||||
type Flow,
|
||||
type Script,
|
||||
type ScriptArgs,
|
||||
type WebsocketTriggerInitialMessage
|
||||
} from '$lib/gen'
|
||||
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2, Save, X, Plus } from 'lucide-svelte'
|
||||
@@ -15,6 +23,9 @@
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import JsonEditor from '../apps/editor/settingsPanel/inputEditor/JsonEditor.svelte'
|
||||
import type { Schema } from '$lib/common'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
|
||||
let drawer: Drawer
|
||||
let is_flow: boolean = false
|
||||
@@ -34,6 +45,8 @@
|
||||
key: string
|
||||
value: any
|
||||
}[] = []
|
||||
let initial_messages: WebsocketTriggerInitialMessage[] = []
|
||||
let url_runnable_args: Record<string, any> = {}
|
||||
let dirtyPath = false
|
||||
let can_write = true
|
||||
let drawerLoading = true
|
||||
@@ -74,6 +87,8 @@
|
||||
path = ''
|
||||
initialPath = ''
|
||||
filters = []
|
||||
initial_messages = []
|
||||
url_runnable_args = {}
|
||||
dirtyPath = false
|
||||
} finally {
|
||||
drawerLoading = false
|
||||
@@ -93,10 +108,81 @@
|
||||
url = s.url
|
||||
enabled = s.enabled
|
||||
filters = s.filters
|
||||
initial_messages = s.initial_messages
|
||||
url_runnable_args = s.url_runnable_args
|
||||
|
||||
can_write = canWrite(s.path, s.extra_perms, $userStore)
|
||||
}
|
||||
|
||||
let initialMessageRunnableSchemas: Record<string, Schema> = {}
|
||||
async function loadInitialMessageRunnableSchemas(
|
||||
initialMessageRunnables: {
|
||||
path: string
|
||||
is_flow: boolean
|
||||
}[]
|
||||
) {
|
||||
for (const { path, is_flow } of initialMessageRunnables) {
|
||||
if (!path) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
let schema: Schema | undefined = emptySchema()
|
||||
let scriptOrFlow: Script | Flow = is_flow
|
||||
? await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
|
||||
: await ScriptService.getScriptByPath({ workspace: $workspaceStore!, path })
|
||||
schema = scriptOrFlow.schema as Schema
|
||||
if (schema && schema.properties) {
|
||||
initialMessageRunnableSchemas[(is_flow ? 'flow/' : '') + path] = schema
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Could not query runnable schema for ${is_flow ? 'flow' : 'script'} ${path}: ${err}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
$: initialMessageRunnables = initial_messages
|
||||
.map((v) => ('runnable_result' in v ? v.runnable_result : undefined))
|
||||
.filter((v): v is { path: string; is_flow: boolean; args: ScriptArgs } => !!v)
|
||||
$: loadInitialMessageRunnableSchemas(initialMessageRunnables)
|
||||
|
||||
let urlRunnableSchema: Schema | undefined = emptySchema()
|
||||
async function loadUrlRunnableSchema(url: string) {
|
||||
if (url.startsWith('$')) {
|
||||
const path = url.split(':')[1]
|
||||
if (path && path.length > 0) {
|
||||
try {
|
||||
let scriptOrFlow: Script | Flow = url.startsWith('$flow:')
|
||||
? await FlowService.getFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: url.split(':')[1]
|
||||
})
|
||||
: await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: url.split(':')[1]
|
||||
})
|
||||
urlRunnableSchema = scriptOrFlow.schema as Schema
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Could not query runnable schema for ${url.startsWith('$flow:') ? 'flow' : 'script'} ${
|
||||
url.split(':')[1]
|
||||
}: ${err}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$: loadUrlRunnableSchema(url)
|
||||
|
||||
$: invalidInitialMessages = initial_messages.some((v) => {
|
||||
if ('runnable_result' in v) {
|
||||
return !v.runnable_result.path
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
if (edit) {
|
||||
await WebsocketTriggerService.updateWebsocketTrigger({
|
||||
@@ -107,7 +193,9 @@
|
||||
script_path,
|
||||
is_flow,
|
||||
url,
|
||||
filters
|
||||
filters,
|
||||
initial_messages,
|
||||
url_runnable_args
|
||||
}
|
||||
})
|
||||
sendUserToast(`Route ${path} updated`)
|
||||
@@ -120,7 +208,9 @@
|
||||
is_flow,
|
||||
url,
|
||||
enabled: true,
|
||||
filters
|
||||
filters,
|
||||
initial_messages,
|
||||
url_runnable_args
|
||||
}
|
||||
})
|
||||
sendUserToast(`Route ${path} created`)
|
||||
@@ -139,7 +229,12 @@
|
||||
clearTimeout(validateTimeout)
|
||||
}
|
||||
validateTimeout = setTimeout(() => {
|
||||
if (/^(ws:|wss:)\/\/[^\s]+$/.test(url) === false) {
|
||||
console.log('validating ' + url)
|
||||
if (url.startsWith('$')) {
|
||||
if (/^(\$script|\$flow):[^\s]+$/.test(url) === false) {
|
||||
urlError = 'Invalid runnable path'
|
||||
}
|
||||
} else if (/^(ws:|wss:)\/\/[^\s]+$/.test(url) === false) {
|
||||
urlError = 'Invalid websocket URL'
|
||||
}
|
||||
validateTimeout = undefined
|
||||
@@ -148,7 +243,7 @@
|
||||
$: validateUrl(url)
|
||||
</script>
|
||||
|
||||
<Drawer size="700px" bind:this={drawer}>
|
||||
<Drawer size="800px" bind:this={drawer}>
|
||||
<DrawerContent
|
||||
title={edit
|
||||
? can_write
|
||||
@@ -180,7 +275,11 @@
|
||||
{/if}
|
||||
<Button
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={pathError != '' || urlError != '' || emptyString(script_path) || !can_write}
|
||||
disabled={pathError != '' ||
|
||||
urlError != '' ||
|
||||
invalidInitialMessages ||
|
||||
emptyString(script_path) ||
|
||||
!can_write}
|
||||
on:click={updateTrigger}
|
||||
>
|
||||
Save
|
||||
@@ -214,31 +313,90 @@
|
||||
</div>
|
||||
|
||||
<Section label="Websocket">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<label class="block grow w-full">
|
||||
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
|
||||
<div>
|
||||
URL
|
||||
<Required required={true} />
|
||||
<div class="mb-2">
|
||||
<ToggleButtonGroup
|
||||
selected={url.startsWith('$') ? 'runnable' : 'static'}
|
||||
on:selected={(ev) => {
|
||||
url = ev.detail === 'runnable' ? '$script:' : ''
|
||||
url_runnable_args = {}
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="static" label="Static URL" />
|
||||
<ToggleButton value="runnable" label="Runnable result as URL" />
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{#if url.startsWith('$')}
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="block grow w-full">
|
||||
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
|
||||
<div>
|
||||
Runnable
|
||||
<Required required={true} />
|
||||
</div>
|
||||
</div>
|
||||
<ScriptPicker
|
||||
allowFlow={true}
|
||||
itemKind={url.startsWith('$flow:') ? 'flow' : 'script'}
|
||||
initialPath={url.split(':')[1] ?? ''}
|
||||
on:select={(ev) => {
|
||||
dirtyUrl = true
|
||||
const { path, itemKind } = ev.detail
|
||||
url = `$${itemKind}:${path ?? ''}`
|
||||
}}
|
||||
/>
|
||||
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5">
|
||||
{dirtyUrl ? urlError : ''}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={url}
|
||||
disabled={!can_write}
|
||||
on:input={() => {
|
||||
dirtyUrl = true
|
||||
}}
|
||||
class={urlError === ''
|
||||
? ''
|
||||
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
|
||||
/>
|
||||
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5">
|
||||
{dirtyUrl ? urlError : ''}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if url.split(':')[1]?.length > 0}
|
||||
{#if urlRunnableSchema}
|
||||
<p class="font-semibold text-sm mt-4 mb-2">Arguments</p>
|
||||
{#await import('$lib/components/SchemaForm.svelte')}
|
||||
<Loader2 class="animate-spin mt-2" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
schema={urlRunnableSchema}
|
||||
bind:args={url_runnable_args}
|
||||
shouldHideNoInputs
|
||||
class="text-xs"
|
||||
/>
|
||||
{/await}
|
||||
{#if urlRunnableSchema.properties && Object.keys(urlRunnableSchema.properties).length === 0}
|
||||
<div class="text-xs texg-gray-700">This runnable takes no arguments</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Loader2 class="animate-spin mt-2" />
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<label class="block grow w-full">
|
||||
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
|
||||
<div>
|
||||
URL
|
||||
<Required required={true} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={url}
|
||||
disabled={!can_write}
|
||||
on:input={() => {
|
||||
dirtyUrl = true
|
||||
}}
|
||||
class={urlError === ''
|
||||
? ''
|
||||
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
|
||||
/>
|
||||
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5">
|
||||
{dirtyUrl ? urlError : ''}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
<Section label="Runnable">
|
||||
@@ -258,6 +416,144 @@
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label="Initial messages">
|
||||
<p class="text-xs mb-1 text-tertiary">
|
||||
Initial messages are sent at the beginning of the connection. They are sent in order.<br
|
||||
/>
|
||||
Raw messages and runnable results are supported.
|
||||
</p>
|
||||
<div class="flex flex-col gap-4 mt-1">
|
||||
{#each initial_messages as v, i}
|
||||
<div class="flex w-full gap-2 items-center">
|
||||
<div class="w-full flex flex-col gap-2 border p-2 rounded-md">
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<label class="flex flex-col w-full">
|
||||
<div class="text-secondary text-sm">Type</div>
|
||||
<select
|
||||
class="w-20"
|
||||
on:change={(e) => {
|
||||
if (e.target?.['value'] === 'raw_message') {
|
||||
initial_messages[i] = {
|
||||
raw_message: '""'
|
||||
}
|
||||
} else {
|
||||
initial_messages[i] = {
|
||||
runnable_result: {
|
||||
path: '',
|
||||
args: {},
|
||||
is_flow: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
value={'runnable_result' in v ? 'runnable_result' : 'raw_message'}
|
||||
>
|
||||
<option value="raw_message">Raw message</option>
|
||||
<option value="runnable_result">Runnable result</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{#if 'raw_message' in v}
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="text-secondary text-sm">
|
||||
Raw JSON message (if a string, wrapping quotes will be discarded)
|
||||
</div>
|
||||
<JsonEditor
|
||||
on:change={(ev) => {
|
||||
const { code } = ev.detail
|
||||
v = {
|
||||
raw_message: code
|
||||
}
|
||||
}}
|
||||
code={v.raw_message}
|
||||
/>
|
||||
</div>
|
||||
{:else if 'runnable_result' in v}
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="text-secondary text-sm">Runnable</div>
|
||||
<ScriptPicker
|
||||
allowFlow={true}
|
||||
itemKind={v.runnable_result?.is_flow ? 'flow' : 'script'}
|
||||
initialPath={v.runnable_result?.path ?? ''}
|
||||
on:select={(ev) => {
|
||||
const { path, itemKind } = ev.detail
|
||||
v = {
|
||||
runnable_result: {
|
||||
path: path ?? '',
|
||||
args: {},
|
||||
is_flow: itemKind === 'flow'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if v.runnable_result?.path}
|
||||
{@const schema =
|
||||
initialMessageRunnableSchemas[
|
||||
v.runnable_result.is_flow
|
||||
? 'flow/' + v.runnable_result.path
|
||||
: v.runnable_result.path
|
||||
]}
|
||||
{#if schema}
|
||||
<p class="font-semibold text-sm mt-4 mb-2">Arguments</p>
|
||||
{#await import('$lib/components/SchemaForm.svelte')}
|
||||
<Loader2 class="animate-spin mt-2" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{schema}
|
||||
bind:args={v.runnable_result.args}
|
||||
shouldHideNoInputs
|
||||
class="text-xs"
|
||||
/>
|
||||
{/await}
|
||||
{#if schema && schema.properties && Object.keys(schema.properties).length === 0}
|
||||
<div class="text-xs texg-gray-700">This runnable takes no arguments</div
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<Loader2 class="animate-spin mt-2" />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
Unknown type
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
transition:fade|local={{ duration: 100 }}
|
||||
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover"
|
||||
aria-label="Clear"
|
||||
on:click={() => {
|
||||
initial_messages = initial_messages.filter((_, index) => index !== i)
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex items-baseline">
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs"
|
||||
btnClasses="mt-1"
|
||||
on:click={() => {
|
||||
if (initial_messages == undefined || !Array.isArray(initial_messages)) {
|
||||
initial_messages = []
|
||||
}
|
||||
initial_messages = initial_messages.concat({
|
||||
raw_message: '""'
|
||||
})
|
||||
}}
|
||||
startIcon={{ icon: Plus }}
|
||||
>
|
||||
Add item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label="Filters">
|
||||
<p class="text-xs mb-1 text-tertiary">
|
||||
Filters will limit the execution of the trigger to only messages that match all
|
||||
@@ -266,8 +562,8 @@
|
||||
</p>
|
||||
<div class="flex flex-col gap-4 mt-1">
|
||||
{#each filters as v, i}
|
||||
<div class="flex w-full gap-4 items-center">
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="flex w-full gap-2 items-center">
|
||||
<div class="w-full flex flex-col gap-2 border p-2 rounded-md">
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<label class="flex flex-col w-full">
|
||||
<div class="text-secondary text-sm">Type</div>
|
||||
|
||||
@@ -522,6 +522,9 @@ export async function preprocessor(
|
||||
params: Record<string, string>
|
||||
query: Record<string, string>
|
||||
headers: Record<string, string>
|
||||
},
|
||||
websocket?: {
|
||||
url: string // The websocket url
|
||||
}
|
||||
},
|
||||
/* your other args */
|
||||
@@ -543,6 +546,9 @@ export async function preprocessor(
|
||||
params: Record<string, string>
|
||||
query: Record<string, string>
|
||||
headers: Record<string, string>
|
||||
},
|
||||
websocket?: {
|
||||
url: string // The websocket url
|
||||
}
|
||||
},
|
||||
/* your other args */
|
||||
@@ -590,9 +596,13 @@ class Http(TypedDict):
|
||||
query: dict[str, str]
|
||||
headers: dict[str, str]
|
||||
|
||||
class Websocket(TypedDict):
|
||||
url: str # The websocket url
|
||||
|
||||
class WmTrigger(TypedDict):
|
||||
kind: Literal["http", "email", "webhook", "websocket"]
|
||||
http: Http | None
|
||||
websocket: Websocket | None
|
||||
|
||||
def preprocessor(
|
||||
wm_trigger: WmTrigger,
|
||||
|
||||
@@ -45,20 +45,22 @@
|
||||
loading = false
|
||||
}
|
||||
|
||||
let status: {
|
||||
[path: string]: { error: string | undefined; last_server_ping: string | undefined }
|
||||
} = {}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
status = (
|
||||
await WebsocketTriggerService.listWebsocketTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
).reduce((acc, x) => {
|
||||
acc[x.path] = x
|
||||
return acc
|
||||
}, {})
|
||||
const newTriggers = await WebsocketTriggerService.listWebsocketTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
enabled: newTrigger.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
@@ -258,7 +260,7 @@
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, url, is_flow, extra_perms, canWrite, marked, error, last_server_ping, enabled } (path)}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const wsStatus = status[path] ?? { error, last_server_ping }}
|
||||
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
@@ -278,7 +280,11 @@
|
||||
{@html marked}
|
||||
</span>
|
||||
{:else}
|
||||
{url}
|
||||
{url.startsWith('$script:')
|
||||
? 'URL: ' + url.replace('$script:', 'result of script ')
|
||||
: url.startsWith('$flow:')
|
||||
? 'URL: ' + url.replace('$flow:', 'result of flow ')
|
||||
: url}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
@@ -294,34 +300,30 @@
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if enabled}
|
||||
{@const ping = wsStatus.last_server_ping
|
||||
? new Date(wsStatus.last_server_ping)
|
||||
: undefined}
|
||||
{#if !ping || ping.getTime() < new Date().getTime() - 15 * 1000 || wsStatus.error}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
<div slot="text">
|
||||
Websocket is not connected{wsStatus.error ? ': ' + wsStatus.error : ''}
|
||||
</div>
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-green-600 relative inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
<div slot="text"> Websocket is connected </div>
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if (enabled && (!ping || ping.getTime() < new Date().getTime() - 15 * 1000 || error)) || (!enabled && error)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
<div slot="text">
|
||||
{#if enabled}
|
||||
Websocket is not connected{error ? ': ' + error : ''}
|
||||
{:else}
|
||||
Websocket was disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
<div slot="text"> Websocket is connected </div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user