feat: captures (#4807)

* feat: captures

* flow UI and improvements

* fix: build

* fix sqlx

* Move Capture WIP

* Add capture to webhook and websocket

* Move connection status viewer in the head

* change trigger section label

* Add popover capture picker using melt ui

* Add shortcut to triggers capture from input form

* remove capture tab in input

* Allways show capture

* remove useless logs

* Add email capture

* Add kafka capture into triggers

* Add edit option in capture table

* use light header for arg input

* Add prefilled group id

* Change button label

* fix logic

* Open resource drawer if prototype has fields

* fix default completion

* Change name Prototype

* Dissociate Editor Mode for triggers

* Fix bug for script

* Fix apply args

* fix apply schema

* Add capture table to script

* fix apply args

* Add capture button for script

* Delete capture tab

* Set capture on when opening triggers capture

* fix connection indicator

* fix minor issues

* Add preprocessor logic

* Use slot in log Panel for captures

* handle capture refresh in script

* Delete capture tab from script editor

* reset kafka resource on toggle static

* fix minor issue

* Allow resource in kafka capture

* use simple capture button in flow

* Remove capture panel

* Polish route trigger editor

* Fix resource saving

* Remove excessive padding

* merge nits

* add workflow_dispatch to build

* add workflow_dispatch to build

* better capture UI

* fix sqlx

* fix build

* fix build

* build

* make initial_messages optional in line with db

* fix npm check

* better handle args for capture webhook and http

* improve migration + http capture fixes

* fix sqlx

* update ee ref

---------

Co-authored-by: Guilhem <guilhemlemouel@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
HugoCasa
2025-01-06 12:07:09 +01:00
committed by GitHub
parent 3f0a818fa3
commit fcb6ced8df
104 changed files with 5457 additions and 1991 deletions
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET last_server_ping = now(), error = $1 WHERE workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket' AND server_id = $5 AND last_client_ping > NOW() - INTERVAL '10 seconds' RETURNING 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Bool",
"Text"
]
},
"nullable": [
null
]
},
"hash": "031d0d70b0aff52feaad487bddb74e5ef0aaa2505facbea8c764003dfc8fffb1"
}
@@ -0,0 +1,33 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, payload, trigger_extra, created_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
{
"Custom": {
"name": "trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email"
]
}
}
},
"Jsonb",
"Jsonb",
"Varchar"
]
},
"nullable": []
},
"hash": "07da723ce5c9ee2d7c236e8eabe254c783fc34b617c8a9a95a0eb0cda535dab5"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO capture\n (workspace_id, path, created_by)\n VALUES ($1, $2, $3)\n ON CONFLICT (workspace_id, path)\n DO UPDATE SET created_at = now()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "0a9a191273c735c41d56ea46a39ffca075a0550eada87df7162c5037164ad6bf"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "0dbd664c906ee3c65856520c22f0828357b36bbcdcc151bd97605d7d7a0489e8"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'kafka'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "203fa78d423ec5a8c5ff6166aed591b28cbf9ea8f61d379b84ee6e14c033035d"
}
@@ -0,0 +1,75 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, created_at, trigger_kind as \"trigger_kind: _\", payload as \"payload: _\", trigger_extra as \"trigger_extra: _\"\n FROM capture\n WHERE workspace_id = $1\n AND path = $2 AND is_flow = $3\n AND ($4::trigger_kind IS NULL OR trigger_kind = $4)\n ORDER BY created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "trigger_kind: _",
"type_info": {
"Custom": {
"name": "trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email"
]
}
}
}
},
{
"ordinal": 3,
"name": "payload: _",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "trigger_extra: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool",
{
"Custom": {
"name": "trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email"
]
}
}
}
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "2f440ab6083764b49e309c1f8dde0c5508da110f54bc5f465f1892afdf851af0"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS FALSE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "3b6ddfe4df620d5e34a01dbbc95067d7dee6a7108cb8a46a20f26236009481da"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM capture_config WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "3c9fc4d8579767f3ce7c3633fca770e6341624e98117d21b9f01e68b4e0ce033"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM capture WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "41e557e1b63b13c9fcc195901c0bd0de7e03c539ee046955543d9693551246f7"
}
@@ -0,0 +1,50 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, is_flow, workspace_id, trigger_config as \"trigger_config!: _\", owner, email FROM capture_config WHERE trigger_kind = 'kafka' AND last_client_ping > NOW() - INTERVAL '10 seconds' AND trigger_config IS NOT NULL 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": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "trigger_config!: _",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "438b8bbf49781fc362eecbdc99cb9d2fafd03a59197477aafc915b0e34a00eb2"
}
@@ -0,0 +1,50 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, is_flow, workspace_id, trigger_config as \"trigger_config!: _\", owner, email FROM capture_config WHERE trigger_kind = 'websocket' AND last_client_ping > NOW() - INTERVAL '10 seconds' AND trigger_config IS NOT NULL 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": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "trigger_config!: _",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "4f1bb3713bc52fb8cd4088947de1e9905387a20bf4b137adf0a33274f820ddc2"
}
@@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "SELECT owner, email\n FROM capture_config\n WHERE workspace_id = $1 AND path = $2 AND is_flow = $3 AND trigger_kind = $4 AND last_client_ping > NOW() - INTERVAL '10 seconds'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool",
{
"Custom": {
"name": "trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email"
]
}
}
}
]
},
"nullable": [
false,
false
]
},
"hash": "71d51bbc35da7b9930e3ea3a634451217ccb9f1bc35b1ad6e10d16bc19c41447"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "78b182167ba19d1dc103577bd3492736e7088ed69b543adbcfd3b3b7a4ece430"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "87e0bee8b78facc62d4c225dc51651d65b1135092067061e7f4050776b733b03"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "90ad280f6744937878b134142825ccb7d7071a80a3f5da59da8948c0106e959c"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "90e74c28b417e73924ed337f6634f574024e641e73072586aca88847259e8cfc"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM capture\n WHERE workspace_id = $1\n AND created_at <=\n (\n SELECT created_at\n FROM capture\n WHERE workspace_id = $1\n ORDER BY created_at DESC\n OFFSET $2\n LIMIT 1\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "97942578df746c8c8103b403cfc4e44ef5a0f082bdde854900064325adc4dd77"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "988b594f70ff7886985a0f90a095aeffac05016abc4dfed37d5ae2872e8da564"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET workspace_id = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "a17b1b8d4f58c58c253e63ec4c2fbb1df4bef54003b01fa901bfa782b5f83342"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE capture\n SET payload = $3\n WHERE workspace_id = $1\n AND path = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "b9468b9e16f55db11b33d8e9793e6e3ae6c5add6ca02414140adb724120a6800"
}
@@ -0,0 +1,55 @@
{
"db_name": "PostgreSQL",
"query": "SELECT trigger_config as \"trigger_config: _\", trigger_kind as \"trigger_kind: _\", error, last_server_ping\n FROM capture_config\n WHERE workspace_id = $1 AND path = $2 AND is_flow = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "trigger_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "trigger_kind: _",
"type_info": {
"Custom": {
"name": "trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email"
]
}
}
}
},
{
"ordinal": 2,
"name": "error",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "last_server_ping",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
},
"nullable": [
true,
false,
true,
true
]
},
"hash": "c223f8b7fa4ef1aa06e1ba2a56d677774aa237508d5610714efd2e9b8b93c7b8"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET last_client_ping = now() WHERE workspace_id = $1 AND path = $2 AND is_flow = $3 AND trigger_kind = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Bool",
{
"Custom": {
"name": "trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email"
]
}
}
}
]
},
"nullable": []
},
"hash": "c5270ee815689e42b65df507b850da43239c9a5aaea41c9aed7ed33a6219a534"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "d08f34000c3d96ccd0f44ca8520f966d751a4dda554d8215eedb8f65be98e100"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET last_server_ping = now(), error = $1 WHERE workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'kafka' AND server_id = $5 AND last_client_ping > NOW() - INTERVAL '10 seconds' RETURNING 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Bool",
"Text"
]
},
"nullable": [
null
]
},
"hash": "d9a6f75e4c4a1f61e55b313cc09bceffac637548841897341672da427a9140fc"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS FALSE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "de0735f0f5b59ebb72fd876f7f46156e26b352511f408fd6464303301383461f"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM capture\n WHERE workspace_id = $1\n AND created_by = $2\n AND created_at <=\n ( SELECT created_at\n FROM capture\n WHERE workspace_id = $1\n AND created_by = $2\n ORDER BY created_at DESC\n OFFSET $3\n LIMIT 1 )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "e02b99525cb1f8737acfec86809f59c8cff67bb1ec3926680fb691cc3573738a"
}
@@ -0,0 +1,50 @@
{
"db_name": "PostgreSQL",
"query": "SELECT trigger_config as \"trigger_config: _\", owner, email\n FROM capture_config\n WHERE workspace_id = $1 AND path = $2 AND is_flow = $3 AND trigger_kind = $4 AND last_client_ping > NOW() - INTERVAL '10 seconds'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "trigger_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool",
{
"Custom": {
"name": "trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email"
]
}
}
}
]
},
"nullable": [
true,
false,
false
]
},
"hash": "e23e110e1f0438d21534fc4323e0e7bc1f0dbeca2e4f44ced05bae0ca5ca1039"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET server_id = $1, last_server_ping = now() WHERE last_client_ping > NOW() - INTERVAL '10 seconds' AND workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket' AND (server_id IS NULL OR last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') RETURNING true",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "e86295e181a82823ffce8234d413ab5a528b0468715238e17ffed7d75e9c0c5c"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET server_id = $1, last_server_ping = now() WHERE last_client_ping > NOW() - INTERVAL '10 seconds' AND workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'kafka' AND (server_id IS NULL OR last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') RETURNING true",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "ee9adcbf82d3f62088a38ff65e8c90ac1c18b5df7aab6a143a328a6bddc6ad32"
}
@@ -0,0 +1,33 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO capture_config\n (workspace_id, path, is_flow, trigger_kind, trigger_config, owner, email)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (workspace_id, path, is_flow, trigger_kind)\n DO UPDATE SET trigger_config = $5, owner = $6, email = $7, server_id = NULL, last_server_ping = NULL, error = NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
{
"Custom": {
"name": "trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email"
]
}
}
},
"Jsonb",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "ef299490c4674c4c76e18d84620a74407b78378d66d8a089407998074059e79b"
}
+1 -1
View File
@@ -1 +1 @@
a1094bec38924de76936392b1148829b02628174
e9bb21fc9d651e4302f9c3b7b3e8f35e00679e4b
@@ -0,0 +1,14 @@
-- Add down migration script here
DROP TABLE capture_config;
DELETE FROM capture;
DROP POLICY see_from_allowed_runnables ON capture;
ALTER TABLE capture DROP CONSTRAINT capture_pkey;
ALTER TABLE capture DROP COLUMN is_flow, DROP COLUMN trigger_kind, DROP COLUMN trigger_extra, DROP COLUMN id;
ALTER TABLE capture ADD CONSTRAINT capture_pkey PRIMARY KEY (workspace_id, path);
DROP TYPE TRIGGER_KIND;
CREATE POLICY see_folder_extra_perms_user ON capture FOR ALL TO windmill_user
USING (SPLIT_PART(capture.path, '/', 1) = 'f' AND SPLIT_PART(capture.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_read'), ',')::text[]))
WITH CHECK (SPLIT_PART(capture.path, '/', 1) = 'f' AND SPLIT_PART(capture.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
CREATE POLICY see_member ON public.capture TO windmill_user USING (((split_part((path)::text, '/'::text, 1) = 'g'::text) AND (split_part((path)::text, '/'::text, 2) = ANY (regexp_split_to_array(current_setting('session.groups'::text), ','::text)))));
CREATE POLICY see_own ON public.capture TO windmill_user USING (((split_part((path)::text, '/'::text, 1) = 'u'::text) AND (split_part((path)::text, '/'::text, 2) = current_setting('session.user'::text))));
@@ -0,0 +1,65 @@
-- Add up migration script here
CREATE TYPE TRIGGER_KIND AS ENUM ('webhook', 'http', 'websocket', 'kafka', 'email');
ALTER TABLE capture ADD COLUMN is_flow BOOLEAN NOT NULL DEFAULT TRUE, ADD COLUMN trigger_kind TRIGGER_KIND NOT NULL DEFAULT 'webhook', ADD COLUMN trigger_extra JSONB;
ALTER TABLE capture ALTER COLUMN is_flow DROP DEFAULT, ALTER COLUMN trigger_kind DROP DEFAULT;
ALTER TABLE capture DROP CONSTRAINT capture_pkey;
ALTER TABLE capture ADD COLUMN id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY;
DROP POLICY see_own ON capture;
DROP POLICY see_member ON capture;
DROP POLICY see_folder_extra_perms_user ON capture;
CREATE POLICY see_from_allowed_runnables ON capture FOR ALL TO windmill_user
USING (
(capture.is_flow AND EXISTS (
SELECT 1
FROM flow
WHERE flow.workspace_id = capture.workspace_id
AND flow.path = capture.path
))
OR (NOT capture.is_flow AND EXISTS (
SELECT 1
FROM script
WHERE script.workspace_id = capture.workspace_id
AND script.path = capture.path
))
);
CREATE TABLE capture_config (
workspace_id VARCHAR(50) NOT NULL,
path VARCHAR(255) NOT NULL,
is_flow BOOLEAN NOT NULL,
trigger_kind TRIGGER_KIND NOT NULL,
trigger_config JSONB NULL,
owner VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
server_id VARCHAR(50) NULL,
last_client_ping TIMESTAMPTZ NULL,
last_server_ping TIMESTAMPTZ NULL,
error TEXT NULL,
PRIMARY KEY (workspace_id, path, is_flow, trigger_kind),
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
);
ALTER TABLE capture_config ENABLE ROW LEVEL SECURITY;
CREATE POLICY see_from_allowed_runnables ON capture_config FOR ALL TO windmill_user
USING (
(capture_config.is_flow AND EXISTS (
SELECT 1
FROM flow
WHERE flow.workspace_id = capture_config.workspace_id
AND flow.path = capture_config.path
))
OR (NOT capture_config.is_flow AND EXISTS (
SELECT 1
FROM script
WHERE script.workspace_id = capture_config.workspace_id
AND script.path = capture_config.path
))
);
GRANT ALL ON capture_config TO windmill_user;
GRANT ALL ON capture_config TO windmill_admin;
+139 -31
View File
@@ -8971,47 +8971,119 @@ paths:
schema:
type: string
/w/{workspace}/capture_u/{path}:
/w/{workspace}/capture/set_config:
post:
summary: update flow preview capture
operationId: updateCapture
summary: set capture config
operationId: setCaptureConfig
tags:
- capture
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
description: capture config
required: true
content:
application/json:
schema:
type: object
properties:
trigger_kind:
$ref: "#/components/schemas/CaptureTriggerKind"
path:
type: string
is_flow:
type: boolean
trigger_config:
type: object
required:
- trigger_kind
- path
- is_flow
responses:
"204":
description: flow preview captured
"200":
description: capture config set
/w/{workspace}/capture/{path}:
put:
summary: create flow preview capture
operationId: createCapture
tags:
- capture
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"201":
description: flow preview capture created
get:
summary: get flow preview capture
operationId: getCapture
/w/{workspace}/capture/ping_config/{trigger_kind}/{runnable_kind}/{path}:
post:
summary: ping capture config
operationId: pingCaptureConfig
tags:
- capture
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: trigger_kind
in: path
required: true
schema:
$ref: "#/components/schemas/CaptureTriggerKind"
- $ref: "#/components/parameters/RunnableKind"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: captured flow preview
description: capture config pinged
/w/{workspace}/capture/get_configs/{runnable_kind}/{path}:
get:
summary: get capture configs for a script or flow
operationId: getCaptureConfigs
tags:
- capture
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/RunnableKind"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: capture configs for a script or flow
content:
application/json:
schema: {}
"404":
description: capture does not exist for this flow
schema:
type: array
items:
$ref: "#/components/schemas/CaptureConfig"
/w/{workspace}/capture/list/{runnable_kind}/{path}:
get:
summary: list captures for a script or flow
operationId: listCaptures
tags:
- capture
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/RunnableKind"
- $ref: "#/components/parameters/Path"
- name: trigger_kind
in: query
schema:
$ref: "#/components/schemas/CaptureTriggerKind"
responses:
"200":
description: list of captures for a script or flow
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Capture"
/w/{workspace}/capture/{id}:
delete:
summary: delete a capture
operationId: deleteCapture
tags:
- capture
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: capture deleted
/w/{workspace}/favorites/star:
post:
@@ -10668,6 +10740,13 @@ components:
required: true
schema:
type: string
RunnableKind:
name: runnable_kind
in: path
required: true
schema:
type: string
enum: [script, flow]
schemas:
$ref: "../../openflow.openapi.yaml#/components/schemas"
@@ -12351,8 +12430,6 @@ components:
- workspace_id
- enabled
- filters
- initial_messages
- url_runnable_args
NewWebsocketTrigger:
type: object
@@ -12391,8 +12468,6 @@ components:
- url
- is_flow
- filters
- initial_messages
- url_runnable_args
EditWebsocketTrigger:
type: object
@@ -12429,8 +12504,6 @@ components:
- url
- is_flow
- filters
- initial_messages
- url_runnable_args
WebsocketTriggerInitialMessage:
anyOf:
@@ -13525,3 +13598,38 @@ components:
type: string
nullable: true
description: Workspace id if the alert is in the scope of a workspace
CaptureTriggerKind:
type: string
enum: [webhook, http, websocket, kafka, email]
Capture:
type: object
properties:
trigger_kind:
$ref: "#/components/schemas/CaptureTriggerKind"
payload: {}
trigger_extra: {}
id:
type: integer
created_at:
type: string
format: date-time
required:
- trigger_kind
- payload
- id
- created_at
CaptureConfig:
type: object
properties:
trigger_config: {}
trigger_kind:
$ref: "#/components/schemas/CaptureTriggerKind"
error:
type: string
last_server_ping:
type: string
format: date-time
required:
- trigger_kind
+482 -83
View File
@@ -7,138 +7,537 @@
*/
use axum::{
extract::{Extension, Path},
routing::{get, post, put},
Router,
extract::{Extension, Path, Query},
routing::{delete, get, head, post},
Json, Router,
};
#[cfg(feature = "http_trigger")]
use http::HeaderMap;
use hyper::StatusCode;
use sqlx::types::Json;
#[cfg(feature = "http_trigger")]
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::types::Json as SqlxJson;
#[cfg(feature = "http_trigger")]
use std::collections::HashMap;
use std::fmt;
#[cfg(feature = "http_trigger")]
use windmill_common::error::Error;
use windmill_common::{
db::UserDB,
error::{JsonResult, Result},
utils::{not_found_if_none, StripPath},
worker::{to_raw_value, CLOUD_HOSTED},
};
use windmill_queue::PushArgs;
use windmill_queue::{PushArgs, PushArgsOwned};
#[cfg(feature = "http_trigger")]
use crate::http_triggers::{build_http_trigger_extra, HttpMethod};
#[cfg(all(feature = "enterprise", feature = "kafka"))]
use crate::kafka_triggers_ee::KafkaResourceSecurity;
use crate::{
args::WebhookArgs,
db::{ApiAuthed, DB},
users::fetch_api_authed,
};
const KEEP_LAST: i64 = 8;
const KEEP_LAST: i64 = 20;
pub fn workspaced_service() -> Router {
Router::new()
.route("/*path", put(new_payload))
.route("/*path", get(get_payload))
.route("/set_config", post(set_config))
.route(
"/ping_config/:trigger_kind/:runnable_kind/*path",
post(ping_config),
)
.route("/get_configs/:runnable_kind/*path", get(get_configs))
.route("/list/:runnable_kind/*path", get(list_captures))
.route("/:id", delete(delete_capture))
}
pub fn global_service() -> Router {
Router::new().route("/*path", post(update_payload))
pub fn workspaced_unauthed_service() -> Router {
let router = Router::new().route(
"/webhook/:runnable_kind/*path",
head(|| async {}).post(webhook_payload),
);
#[cfg(feature = "http_trigger")]
{
router.route("/http/:runnable_kind/:path/*route_path", {
head(|| async {}).fallback(http_payload)
})
}
#[cfg(not(feature = "http_trigger"))]
{
router
}
}
pub async fn new_payload(
#[derive(sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "TRIGGER_KIND", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum TriggerKind {
Webhook,
Http,
Websocket,
Kafka,
Email,
}
impl fmt::Display for TriggerKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
TriggerKind::Webhook => "webhook",
TriggerKind::Http => "http",
TriggerKind::Websocket => "websocket",
TriggerKind::Kafka => "kafka",
TriggerKind::Email => "email",
};
write!(f, "{}", s)
}
}
#[cfg(feature = "http_trigger")]
#[derive(Serialize, Deserialize)]
struct HttpTriggerConfig {
route_path: String,
http_method: HttpMethod,
}
#[cfg(all(feature = "enterprise", feature = "kafka"))]
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum KafkaTriggerConfigConnection {
Resource { kafka_resource_path: String },
Static { brokers: Vec<String>, security: KafkaResourceSecurity },
}
#[cfg(all(feature = "enterprise", feature = "kafka"))]
#[derive(Serialize, Deserialize)]
pub struct KafkaTriggerConfig {
#[serde(flatten)]
pub connection: KafkaTriggerConfigConnection,
pub topics: Vec<String>,
pub group_id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct WebsocketTriggerConfig {
pub url: String,
// have to use Value because RawValue is not supported inside untagged
pub url_runnable_args: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum TriggerConfig {
#[cfg(feature = "http_trigger")]
Http(HttpTriggerConfig),
Websocket(WebsocketTriggerConfig),
#[cfg(all(feature = "enterprise", feature = "kafka"))]
Kafka(KafkaTriggerConfig),
}
#[derive(Serialize, Deserialize)]
struct NewCaptureConfig {
trigger_kind: TriggerKind,
path: String,
is_flow: bool,
trigger_config: Option<TriggerConfig>,
}
#[derive(Serialize, Deserialize)]
struct CaptureConfig {
trigger_config: Option<SqlxJson<Box<RawValue>>>,
trigger_kind: TriggerKind,
error: Option<String>,
last_server_ping: Option<chrono::DateTime<chrono::Utc>>,
}
async fn get_configs(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<StatusCode> {
Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>,
) -> JsonResult<Vec<CaptureConfig>> {
let mut tx = user_db.begin(&authed).await?;
let configs = sqlx::query_as!(
CaptureConfig,
r#"SELECT trigger_config as "trigger_config: _", trigger_kind as "trigger_kind: _", error, last_server_ping
FROM capture_config
WHERE workspace_id = $1 AND path = $2 AND is_flow = $3"#,
&w_id,
&path.to_path(),
matches!(runnable_kind, RunnableKind::Flow),
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(configs))
}
async fn set_config(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(nc): Json<NewCaptureConfig>,
) -> Result<()> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"
INSERT INTO capture
(workspace_id, path, created_by)
VALUES ($1, $2, $3)
ON CONFLICT (workspace_id, path)
DO UPDATE SET created_at = now()
",
"INSERT INTO capture_config
(workspace_id, path, is_flow, trigger_kind, trigger_config, owner, email)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (workspace_id, path, is_flow, trigger_kind)
DO UPDATE SET trigger_config = $5, owner = $6, email = $7, server_id = NULL, last_server_ping = NULL, error = NULL",
&w_id,
&path.to_path(),
&nc.path,
nc.is_flow,
nc.trigger_kind as TriggerKind,
nc.trigger_config.map(|x| SqlxJson(to_raw_value(&x))) as Option<SqlxJson<Box<RawValue>>>,
&authed.username,
)
.execute(&mut *tx)
.await?;
/* Retain only KEEP_LAST most recent captures by this user in this workspace. */
sqlx::query!(
"
DELETE FROM capture
WHERE workspace_id = $1
AND created_by = $2
AND created_at <=
( SELECT created_at
FROM capture
WHERE workspace_id = $1
AND created_by = $2
ORDER BY created_at DESC
OFFSET $3
LIMIT 1 )
",
&w_id,
&authed.username,
KEEP_LAST,
&authed.email,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
Ok(())
}
pub async fn update_payload(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
args: WebhookArgs,
) -> Result<StatusCode> {
let args = args.args;
let mut tx = db.begin().await?;
async fn ping_config(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, trigger_kind, runnable_kind, path)): Path<(
String,
TriggerKind,
RunnableKind,
StripPath,
)>,
) -> Result<()> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"
UPDATE capture
SET payload = $3
WHERE workspace_id = $1
AND path = $2
",
"UPDATE capture_config SET last_client_ping = now() WHERE workspace_id = $1 AND path = $2 AND is_flow = $3 AND trigger_kind = $4",
&w_id,
&path.to_path(),
Json(PushArgs { args: &args.args, extra: args.extra }) as Json<PushArgs>,
matches!(runnable_kind, RunnableKind::Flow),
trigger_kind as TriggerKind,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[derive(Serialize, Deserialize)]
struct Capture {
id: i64,
created_at: chrono::DateTime<chrono::Utc>,
trigger_kind: TriggerKind,
payload: SqlxJson<Box<serde_json::value::RawValue>>,
trigger_extra: Option<SqlxJson<Box<serde_json::value::RawValue>>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum RunnableKind {
Script,
Flow,
}
#[derive(Deserialize)]
struct ListCapturesQuery {
trigger_kind: Option<TriggerKind>,
}
async fn list_captures(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>,
Query(query): Query<ListCapturesQuery>,
) -> JsonResult<Vec<Capture>> {
let mut tx = user_db.begin(&authed).await?;
let captures = sqlx::query_as!(
Capture,
r#"SELECT id, created_at, trigger_kind as "trigger_kind: _", payload as "payload: _", trigger_extra as "trigger_extra: _"
FROM capture
WHERE workspace_id = $1
AND path = $2 AND is_flow = $3
AND ($4::trigger_kind IS NULL OR trigger_kind = $4)
ORDER BY created_at DESC"#,
&w_id,
&path.to_path(),
matches!(runnable_kind, RunnableKind::Flow),
query.trigger_kind as Option<TriggerKind>,
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(captures))
}
async fn delete_capture(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((_, id)): Path<(String, i64)>,
) -> Result<()> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query!("DELETE FROM capture WHERE id = $1", id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[derive(Serialize, Deserialize)]
struct ActiveCaptureOwner {
owner: String,
email: String,
}
pub async fn get_active_capture_owner_and_email(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
kind: &TriggerKind,
) -> Result<(String, String)> {
let capture_config = sqlx::query_as!(
ActiveCaptureOwner,
"SELECT owner, email
FROM capture_config
WHERE workspace_id = $1 AND path = $2 AND is_flow = $3 AND trigger_kind = $4 AND last_client_ping > NOW() - INTERVAL '10 seconds'",
&w_id,
&path,
is_flow,
kind as &TriggerKind,
)
.fetch_optional(db)
.await?;
let capture_config = not_found_if_none(
capture_config,
&format!("capture config for {} trigger", kind),
path,
)?;
Ok((capture_config.owner, capture_config.email))
}
#[cfg(feature = "http_trigger")]
async fn get_capture_trigger_config_and_owner<T: DeserializeOwned>(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
kind: &TriggerKind,
) -> Result<(T, String, String)> {
#[derive(Deserialize)]
struct CaptureTriggerConfigAndOwner {
trigger_config: Option<SqlxJson<Box<RawValue>>>,
owner: String,
email: String,
}
let capture_config = sqlx::query_as!(
CaptureTriggerConfigAndOwner,
r#"SELECT trigger_config as "trigger_config: _", owner, email
FROM capture_config
WHERE workspace_id = $1 AND path = $2 AND is_flow = $3 AND trigger_kind = $4 AND last_client_ping > NOW() - INTERVAL '10 seconds'"#,
&w_id,
&path,
is_flow,
kind as &TriggerKind,
)
.fetch_optional(db)
.await?;
let capture_config = not_found_if_none(
capture_config,
&format!("capture config for {} trigger", kind),
path,
)?;
let trigger_config = not_found_if_none(
capture_config.trigger_config,
&format!("capture {} trigger config", kind),
path,
)?;
Ok((
serde_json::from_str(trigger_config.get()).map_err(|e| {
Error::InternalErr(format!(
"error parsing capture config for {} trigger: {}",
kind, e
))
})?,
capture_config.owner,
capture_config.email,
))
}
async fn clear_captures_history(db: &DB, w_id: &str) -> Result<()> {
if *CLOUD_HOSTED {
/* Retain only KEEP_LAST most recent captures in this workspace. */
sqlx::query!(
"DELETE FROM capture
WHERE workspace_id = $1
AND created_at <=
(
SELECT created_at
FROM capture
WHERE workspace_id = $1
ORDER BY created_at DESC
OFFSET $2
LIMIT 1
)",
&w_id,
KEEP_LAST,
)
.execute(db)
.await?;
}
Ok(())
}
pub async fn insert_capture_payload(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
trigger_kind: &TriggerKind,
payload: PushArgsOwned,
trigger_extra: Option<Box<RawValue>>,
owner: &str,
) -> Result<()> {
sqlx::query!(
"INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, payload, trigger_extra, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
&w_id,
path,
is_flow,
trigger_kind as &TriggerKind,
SqlxJson(to_raw_value(&PushArgs {
args: &payload.args,
extra: payload.extra
})) as SqlxJson<Box<RawValue>>,
trigger_extra.map(SqlxJson) as Option<SqlxJson<Box<RawValue>>>,
owner,
)
.execute(db)
.await?;
clear_captures_history(db, &w_id).await?;
Ok(())
}
async fn webhook_payload(
Extension(db): Extension<DB>,
Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>,
args: WebhookArgs,
) -> Result<StatusCode> {
let (owner, email) = get_active_capture_owner_and_email(
&db,
&w_id,
&path.to_path(),
matches!(runnable_kind, RunnableKind::Flow),
&TriggerKind::Webhook,
)
.await?;
let authed = fetch_api_authed(owner.clone(), email, &w_id, &db, None).await?;
let args = args.to_push_args_owned(&authed, &db, &w_id).await?;
insert_capture_payload(
&db,
&w_id,
&path.to_path(),
matches!(runnable_kind, RunnableKind::Flow),
&TriggerKind::Webhook,
args,
Some(to_raw_value(&serde_json::json!({
"wm_trigger": {
"kind": "webhook",
}
}))),
&owner,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(sqlx::FromRow)]
struct Payload {
payload: sqlx::types::Json<Box<serde_json::value::RawValue>>,
}
pub async fn get_payload(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Box<serde_json::value::RawValue>> {
let mut tx = user_db.begin(&authed).await?;
#[cfg(feature = "http_trigger")]
async fn http_payload(
Extension(db): Extension<DB>,
Path((w_id, kind, path, route_path)): Path<(String, RunnableKind, String, StripPath)>,
Query(query): Query<HashMap<String, String>>,
method: http::Method,
headers: HeaderMap,
args: WebhookArgs,
) -> Result<StatusCode> {
let route_path = route_path.to_path();
let path = path.replace(".", "/");
let payload = sqlx::query_as::<_, Payload>(
"
SELECT payload
FROM capture
WHERE workspace_id = $1
AND path = $2
",
let (http_trigger_config, owner, email): (HttpTriggerConfig, _, _) =
get_capture_trigger_config_and_owner(
&db,
&w_id,
&path,
matches!(kind, RunnableKind::Flow),
&TriggerKind::Http,
)
.await?;
let authed = fetch_api_authed(owner.clone(), email, &w_id, &db, None).await?;
let args = args.to_push_args_owned(&authed, &db, &w_id).await?;
let mut router = matchit::Router::new();
router.insert(&http_trigger_config.route_path, ()).ok();
let match_ = router.at(route_path).ok();
let match_ = not_found_if_none(match_, "capture http trigger", &route_path)?;
let matchit::Match { params, .. } = match_;
let params: HashMap<String, String> = params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let extra: HashMap<String, Box<RawValue>> = HashMap::from_iter(vec![(
"wm_trigger".to_string(),
build_http_trigger_extra(
&http_trigger_config.route_path,
route_path,
&method,
&params,
&query,
&headers,
)
.await,
)]);
insert_capture_payload(
&db,
&w_id,
&path,
matches!(kind, RunnableKind::Flow),
&TriggerKind::Http,
args,
Some(to_raw_value(&extra)),
&owner,
)
.bind(&w_id)
.bind(&path.to_path())
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
not_found_if_none(payload.map(|x| x.payload.0), "capture", path.to_path()).map(axum::Json)
Ok(StatusCode::NO_CONTENT)
}
+34
View File
@@ -740,6 +740,24 @@ async fn update_flow(
"Error updating flow due to deleting old flow: {e:#}"
))
})?;
sqlx::query!(
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE",
nf.path,
flow_path,
w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE",
nf.path,
flow_path,
w_id
)
.execute(&mut *tx)
.await?;
}
let version = sqlx::query_scalar!(
@@ -1123,6 +1141,22 @@ async fn delete_flow_by_path(
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",
path,
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",
path,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
+36 -16
View File
@@ -77,7 +77,7 @@ pub fn workspaced_service() -> Router {
#[derive(Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "HTTP_METHOD", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
enum HttpMethod {
pub enum HttpMethod {
Get,
Post,
Put,
@@ -523,6 +523,32 @@ async fn get_http_route_trigger(
Ok((trigger, route_path.0, params, authed))
}
pub async fn build_http_trigger_extra(
route_path: &str,
called_path: &str,
method: &http::Method,
params: &HashMap<String, String>,
query: &HashMap<String, String>,
headers: &HeaderMap,
) -> Box<serde_json::value::RawValue> {
let headers = headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect::<HashMap<String, String>>();
to_raw_value(&serde_json::json!({
"kind": "http",
"http": {
"route": route_path,
"path": called_path,
"method": method.to_string().to_lowercase(),
"params": params,
"query": query,
"headers": headers
},
}))
}
async fn route_job(
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
@@ -639,24 +665,18 @@ async fn route_job(
}
}
let headers = headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect::<HashMap<String, String>>();
let extra = args.extra.get_or_insert_with(HashMap::new);
extra.insert(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({
"kind": "http",
"http": {
"route": trigger.route_path,
"path": called_path,
"method": method.to_string().to_lowercase(),
"params": params,
"query": query,
"headers": headers
},
})),
build_http_trigger_extra(
&trigger.route_path,
&called_path,
&method,
&params,
&query,
&headers,
)
.await,
);
let http_method = http::Method::from(trigger.http_method);
@@ -1,5 +1,9 @@
use crate::db::DB;
use axum::Router;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct KafkaResourceSecurity {}
pub fn workspaced_service() -> Router {
Router::new()
+1 -4
View File
@@ -250,9 +250,6 @@ pub async fn run_server(
}
}
// #[cfg(feature = "kafka")]
// start_listening().await;
let job_helpers_service = {
#[cfg(feature = "parquet")]
{
@@ -424,7 +421,7 @@ pub async fn run_server(
)
.nest(
"/w/:workspace_id/capture_u",
capture::global_service().layer(cors.clone()),
capture::workspaced_unauthed_service().layer(cors.clone()),
)
.nest(
"/auth",
+34
View File
@@ -662,6 +662,24 @@ async fn create_script_internal<'c>(
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
ns.path,
p_path,
w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
ns.path,
p_path,
w_id
)
.execute(&mut *tx)
.await?;
let mut schedulables = sqlx::query_as::<_, Schedule>(
"UPDATE schedule SET script_path = $1 WHERE script_path = $2 AND path != $2 AND workspace_id = $3 AND is_flow IS false RETURNING *")
.bind(&ns.path)
@@ -1471,6 +1489,22 @@ async fn delete_script_by_path(
.execute(&db)
.await?;
sqlx::query!(
"DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS FALSE",
path,
w_id
)
.execute(&db)
.await?;
sqlx::query!(
"DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS FALSE",
path,
w_id
)
.execute(&db)
.await?;
audit_log(
&mut *tx,
&authed,
+476 -219
View File
@@ -15,6 +15,7 @@ use serde::{
use serde_json::{value::RawValue, Value};
use sql_builder::{bind::Bind, SqlBuilder};
use sqlx::prelude::FromRow;
use sqlx::types::Json as SqlxJson;
use std::{collections::HashMap, fmt};
use tokio::net::TcpStream;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
@@ -30,11 +31,14 @@ use windmill_common::{
use windmill_queue::PushArgsOwned;
use crate::{
capture::{insert_capture_payload, TriggerKind, WebsocketTriggerConfig},
db::{ApiAuthed, DB},
jobs::{run_flow_by_path_inner, run_script_by_path_inner, RunJobQuery},
users::fetch_api_authed,
};
use std::borrow::Cow;
pub fn workspaced_service() -> Router {
Router::new()
.route("/create", post(create_websocket_trigger))
@@ -54,19 +58,19 @@ struct NewWebsocketTrigger {
is_flow: bool,
enabled: Option<bool>,
filters: Vec<Box<RawValue>>,
initial_messages: Vec<Box<RawValue>>,
url_runnable_args: Box<RawValue>,
initial_messages: Option<Vec<Box<RawValue>>>,
url_runnable_args: Option<Box<RawValue>>,
}
#[derive(Deserialize)]
struct JsonFilter {
pub struct JsonFilter {
key: String,
value: serde_json::Value,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Filter {
pub enum Filter {
JsonFilter(JsonFilter),
}
@@ -93,9 +97,9 @@ pub struct WebsocketTrigger {
extra_perms: serde_json::Value,
error: Option<String>,
enabled: bool,
filters: Vec<sqlx::types::Json<Box<RawValue>>>,
initial_messages: Vec<sqlx::types::Json<Box<RawValue>>>,
url_runnable_args: sqlx::types::Json<Box<RawValue>>,
filters: Vec<SqlxJson<Box<RawValue>>>,
initial_messages: Option<Vec<SqlxJson<Box<RawValue>>>>,
url_runnable_args: Option<SqlxJson<Box<RawValue>>>,
}
#[derive(Deserialize)]
@@ -105,8 +109,8 @@ struct EditWebsocketTrigger {
script_path: String,
is_flow: bool,
filters: Vec<Box<RawValue>>,
initial_messages: Vec<Box<RawValue>>,
url_runnable_args: Box<RawValue>,
initial_messages: Option<Vec<Box<RawValue>>>,
url_runnable_args: Option<Box<RawValue>>,
}
#[derive(Deserialize)]
@@ -190,11 +194,12 @@ async fn create_websocket_trigger(
let mut tx = user_db.begin(&authed).await?;
let filters = ct.filters.into_iter().map(sqlx::types::Json).collect_vec();
let filters = ct.filters.into_iter().map(SqlxJson).collect_vec();
let initial_messages = ct
.initial_messages
.unwrap_or_default()
.into_iter()
.map(sqlx::types::Json)
.map(SqlxJson)
.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 *",
@@ -207,7 +212,7 @@ async fn create_websocket_trigger(
.bind(ct.enabled.unwrap_or(true))
.bind(filters.as_slice())
.bind(initial_messages.as_slice())
.bind(sqlx::types::Json(ct.url_runnable_args))
.bind(ct.url_runnable_args.map(SqlxJson))
.bind(&authed.username)
.bind(&authed.email)
.fetch_one(&mut *tx).await?;
@@ -237,11 +242,12 @@ 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 filters = ct.filters.into_iter().map(SqlxJson).collect_vec();
let initial_messages = ct
.initial_messages
.unwrap_or_default()
.into_iter()
.map(sqlx::types::Json)
.map(SqlxJson)
.collect_vec();
// important to update server_id, last_server_ping and error to NULL to stop current websocket listener
@@ -252,9 +258,9 @@ async fn update_websocket_trigger(
ct.script_path,
ct.path,
ct.is_flow,
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>>,
filters.as_slice() as &[SqlxJson<Box<RawValue>>],
initial_messages.as_slice() as &[SqlxJson<Box<RawValue>>],
ct.url_runnable_args.map(SqlxJson) as Option<SqlxJson<Box<RawValue>>>,
&authed.username,
&authed.email,
w_id,
@@ -386,18 +392,36 @@ async fn listen_to_unlistened_websockets(
Ok(mut triggers) => {
triggers.shuffle(&mut rand::thread_rng());
for trigger in triggers {
maybe_listen_to_websocket(trigger, db.clone(), killpill_rx.resubscribe()).await;
trigger.maybe_listen_to_websocket(db.clone(), killpill_rx.resubscribe()).await;
}
}
Err(err) => {
tracing::error!("Error fetching websocket triggers: {:?}", err);
}
};
match sqlx::query_as!(
CaptureConfigForWebsocket,
r#"SELECT path, is_flow, workspace_id, trigger_config as "trigger_config!: _", owner, email FROM capture_config WHERE trigger_kind = 'websocket' AND last_client_ping > NOW() - INTERVAL '10 seconds' AND trigger_config IS NOT NULL AND (server_id IS NULL OR last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')"#
)
.fetch_all(db)
.await
{
Ok(mut captures) => {
captures.shuffle(&mut rand::thread_rng());
for capture in captures {
capture.maybe_listen_to_websocket(db.clone(), killpill_rx.resubscribe()).await;
}
}
Err(err) => {
tracing::error!("Error fetching capture websocket triggers: {:?}", err);
}
}
}
pub async fn start_websockets(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) -> () {
tokio::spawn(async move {
listen_to_unlistened_websockets(&db, &&killpill_rx).await;
listen_to_unlistened_websockets(&db, &killpill_rx).await;
loop {
tokio::select! {
biased;
@@ -405,37 +429,13 @@ pub async fn start_websockets(db: DB, mut killpill_rx: tokio::sync::broadcast::R
return;
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(15)) => {
listen_to_unlistened_websockets(&db, &&killpill_rx).await;
listen_to_unlistened_websockets(&db, &killpill_rx).await;
}
}
}
});
}
async fn maybe_listen_to_websocket(
ws_trigger: WebsocketTrigger,
db: DB,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match sqlx::query_scalar!(
"UPDATE websocket_trigger SET server_id = $1, last_server_ping = now() WHERE enabled IS TRUE AND workspace_id = $2 AND path = $3 AND (server_id IS NULL OR last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') RETURNING true",
*INSTANCE_NAME,
ws_trigger.workspace_id,
ws_trigger.path,
).fetch_optional(&db).await {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tokio::spawn(listen_to_websocket(ws_trigger, db, killpill_rx));
} else {
tracing::info!("Websocket {} already being listened to", ws_trigger.url);
}
},
Err(err) => {
tracing::error!("Error acquiring lock for websocket {}: {:?}", ws_trigger.path, err);
}
};
}
struct SupersetVisitor<'a> {
key: &'a str,
value_to_check: &'a Value,
@@ -505,32 +505,29 @@ where
async fn wait_runnable_result(
path: String,
is_flow: bool,
args: &Box<RawValue>,
ws_trigger: &WebsocketTrigger,
username_override: String,
args: Option<&Box<RawValue>>,
authed: ApiAuthed,
db: &DB,
workspace_id: &str,
trigger_path: &str,
) -> 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,
Some(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 args = if let Some(args) = 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)
} else {
HashMap::new()
};
let label_prefix = Some(format!("ws-{}-", ws_trigger.path));
let label_prefix = Some(format!("ws-{}-", trigger_path));
let (_, job_id) = if is_flow {
run_flow_by_path_inner(
authed,
db.clone(),
user_db,
ws_trigger.workspace_id.clone(),
workspace_id.to_string(),
StripPath(path.clone()),
RunJobQuery::default(),
PushArgsOwned { args, extra: None },
@@ -542,7 +539,7 @@ async fn wait_runnable_result(
authed,
db.clone(),
user_db,
ws_trigger.workspace_id.clone(),
workspace_id.to_string(),
StripPath(path.clone()),
RunJobQuery::default(),
PushArgsOwned { args, extra: None },
@@ -563,7 +560,7 @@ async fn wait_runnable_result(
#[derive(sqlx::FromRow)]
struct RawResult {
result: Option<sqlx::types::Json<Box<RawValue>>>,
result: Option<SqlxJson<Box<RawValue>>>,
success: bool,
}
@@ -571,7 +568,7 @@ async fn wait_runnable_result(
"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)
.bind(workspace_id)
.fetch_optional(db)
.await;
@@ -601,92 +598,34 @@ async fn wait_runnable_result(
}
}
async fn send_initial_messages(
ws_trigger: &WebsocketTrigger,
mut writer: SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
db: &DB,
) -> 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,
)
.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")
})?;
}
async fn loop_ping(db: &DB, ws: &WebsocketEnum, error: Option<&str>) -> () {
loop {
if let None = ws.update_ping(db, error).await {
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
Ok(())
}
async fn get_url_from_runnable(
path: &str,
is_flow: bool,
ws_trigger: &WebsocketTrigger,
db: &DB,
authed: ApiAuthed,
args: Option<&Box<RawValue>>,
workspace_id: &str,
trigger_path: &str,
) -> 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(),
args,
authed,
db,
workspace_id,
trigger_path,
)
.await?;
@@ -697,78 +636,394 @@ async fn get_url_from_runnable(
}
}
async fn update_ping(db: &DB, ws_trigger: &WebsocketTrigger, error: Option<&str>) -> Option<()> {
match sqlx::query_scalar!(
impl WebsocketTrigger {
async fn maybe_listen_to_websocket(
self,
db: DB,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match sqlx::query_scalar!(
"UPDATE websocket_trigger SET server_id = $1, last_server_ping = now() WHERE enabled IS TRUE AND workspace_id = $2 AND path = $3 AND (server_id IS NULL OR last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') RETURNING true",
*INSTANCE_NAME,
self.workspace_id,
self.path,
).fetch_optional(&db).await {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tokio::spawn(listen_to_websocket(WebsocketEnum::Trigger(self), db, killpill_rx));
} else {
tracing::info!("Websocket {} already being listened to", self.url);
}
},
Err(err) => {
tracing::error!("Error acquiring lock for websocket {}: {:?}", self.path, err);
}
};
}
async fn update_ping(&self, db: &DB, 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,
self.workspace_id,
self.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);
tracing::info!("Websocket {} changed, disabled, or deleted, stopping...", self.url);
return None;
}
},
Err(err) => {
tracing::warn!("Error updating ping of websocket {}: {:?}", ws_trigger.url, err);
}
};
Err(err) => {
tracing::warn!("Error updating ping of websocket {}: {:?}", self.url, err);
}
};
Some(())
}
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;
async fn disable_with_error(&self, db: &DB, 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,
self.workspace_id,
self.path,
)
.execute(db).await {
Ok(_) => {
report_critical_error(format!("Disabling websocket {} because of error: {}", self.url, error), db.clone(), Some(&self.workspace_id), None).await;
},
Err(disable_err) => {
report_critical_error(
format!("Could not disable websocket {} with err {}, disabling because of error {}", self.path, disable_err, error),
db.clone(),
Some(&self.workspace_id),
None,
).await;
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
async fn get_url_from_runnable(
&self,
path: &str,
is_flow: bool,
db: &DB,
) -> error::Result<String> {
get_url_from_runnable(
&path,
is_flow,
db,
self.fetch_authed(db, Some("url".to_string())).await?,
self.url_runnable_args.as_ref().map(|r| &r.0),
&self.workspace_id,
&self.path,
)
.await
}
async fn send_initial_messages(
&self,
mut writer: SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
db: &DB,
) -> error::Result<()> {
let initial_messages: Vec<InitialMessage> = self
.initial_messages
.as_deref()
.unwrap_or_default()
.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 {}: {}",
self.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 {}",
self.url,
);
let result = wait_runnable_result(
path.clone(),
is_flow,
Some(&args),
self.fetch_authed(db, Some("init".to_string())).await?,
db,
&self.workspace_id,
&self.path,
)
.await?;
tracing::info!(
"Sending runnable {path} (is_flow: {is_flow}) result to websocket {}",
self.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 handle(&self, db: &DB, args: PushArgsOwned) -> () {
if let Err(err) = run_job(db, self, args).await {
report_critical_error(
format!(
"Failed to trigger job from websocket {}: {:?}",
self.url, err
),
db.clone(),
Some(&self.workspace_id),
None,
)
.await;
};
}
async fn fetch_authed(
&self,
db: &DB,
username_override: Option<String>,
) -> error::Result<ApiAuthed> {
fetch_api_authed(
self.edited_by.clone(),
self.email.clone(),
&self.workspace_id,
db,
username_override,
)
.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",
#[derive(Deserialize)]
struct CaptureConfigForWebsocket {
trigger_config: SqlxJson<WebsocketTriggerConfig>,
path: String,
is_flow: bool,
workspace_id: String,
owner: String,
email: String,
}
impl CaptureConfigForWebsocket {
async fn maybe_listen_to_websocket(
self,
db: DB,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match sqlx::query_scalar!(
"UPDATE capture_config SET server_id = $1, last_server_ping = now() WHERE last_client_ping > NOW() - INTERVAL '10 seconds' AND workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket' AND (server_id IS NULL OR last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') RETURNING true",
*INSTANCE_NAME,
self.workspace_id,
self.path,
self.is_flow,
).fetch_optional(&db).await {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tokio::spawn(listen_to_websocket(WebsocketEnum::Capture(self), db, killpill_rx));
} else {
tracing::info!("Websocket {} already being listened to", self.trigger_config.url);
}
},
Err(err) => {
tracing::error!("Error acquiring lock for capture websocket {}: {:?}", self.path, err);
}
};
}
async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> {
match sqlx::query_scalar!(
"UPDATE capture_config SET last_server_ping = now(), error = $1 WHERE workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket' AND server_id = $5 AND last_client_ping > NOW() - INTERVAL '10 seconds' RETURNING 1",
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(), Some(&ws_trigger.workspace_id), None).await;
self.workspace_id,
self.path,
self.is_flow,
*INSTANCE_NAME
).fetch_optional(db).await {
Ok(updated) => {
if updated.flatten().is_none() {
tracing::info!("Websocket capture {} changed, disabled, or deleted, stopping...", self.trigger_config.url);
return None;
}
},
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(),
Some(&ws_trigger.workspace_id),
None,
).await;
Err(err) => {
tracing::warn!("Error updating ping of capture websocket {}: {:?}", self.trigger_config.url, err);
}
};
Some(())
}
async fn handle(&self, db: &DB, args: PushArgsOwned) -> () {
if let Err(err) = insert_capture_payload(
db,
&self.workspace_id,
&self.path,
self.is_flow,
&TriggerKind::Websocket,
PushArgsOwned { args: args.args, extra: None },
args.extra.as_ref().map(to_raw_value),
&self.owner,
)
.await
{
tracing::error!("Error inserting capture payload: {:?}", err);
}
}
async fn get_url_from_runnable(
&self,
path: &str,
is_flow: bool,
db: &DB,
) -> error::Result<String> {
let url_runnable_args = self
.trigger_config
.url_runnable_args
.as_ref()
.map(to_raw_value);
get_url_from_runnable(
&path,
is_flow,
db,
self.fetch_authed(db, Some("url".to_string())).await?,
url_runnable_args.as_ref(),
&self.workspace_id,
&self.get_trigger_path(),
)
.await
}
async fn fetch_authed(
&self,
db: &DB,
username_override: Option<String>,
) -> error::Result<ApiAuthed> {
fetch_api_authed(
self.owner.clone(),
self.email.clone(),
&self.workspace_id,
db,
username_override,
)
.await
}
async fn disable_with_error(&self, db: &DB, error: String) -> () {
if let Err(err) = sqlx::query!(
"UPDATE capture_config SET error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket'",
error,
self.workspace_id,
self.path,
self.is_flow,
)
.execute(db).await {
tracing::error!("Could not disable websocket capture {} ({}) with err {}, disabling because of error {}", self.path, self.workspace_id, err, error);
}
}
fn get_trigger_path(&self) -> String {
format!(
"{}-{}",
if self.is_flow { "flow" } else { "script" },
self.path
)
}
}
enum WebsocketEnum {
Trigger(WebsocketTrigger),
Capture(CaptureConfigForWebsocket),
}
impl WebsocketEnum {
async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> {
match self {
WebsocketEnum::Trigger(ws) => ws.update_ping(db, error).await,
WebsocketEnum::Capture(capture) => capture.update_ping(db, error).await,
}
}
async fn get_url_from_runnable(
&self,
path: &str,
is_flow: bool,
db: &DB,
) -> error::Result<String> {
match self {
WebsocketEnum::Trigger(ws) => ws.get_url_from_runnable(path, is_flow, db).await,
WebsocketEnum::Capture(capture) => {
capture.get_url_from_runnable(path, is_flow, db).await
}
}
}
async fn disable_with_error(&self, db: &DB, error: String) -> () {
match self {
WebsocketEnum::Trigger(ws) => ws.disable_with_error(db, error).await,
WebsocketEnum::Capture(capture) => capture.disable_with_error(db, error).await,
}
}
}
async fn listen_to_websocket(
ws_trigger: WebsocketTrigger,
ws: WebsocketEnum,
db: DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
if let None = update_ping(&db, &ws_trigger, Some("Connecting...")).await {
if let None = ws.update_ping(&db, Some("Connecting")).await {
return;
}
let url = ws_trigger.url.as_str();
let url = match &ws {
WebsocketEnum::Trigger(ws_trigger) => &ws_trigger.url,
WebsocketEnum::Capture(capture) => &capture.trigger_config.url,
};
let filters: Vec<Filter> = ws_trigger
.filters
.iter()
.filter_map(|m| serde_json::from_str(m.get()).ok())
.collect_vec();
let filters: Vec<Filter> = match &ws {
WebsocketEnum::Trigger(ws_trigger) => ws_trigger
.filters
.iter()
.filter_map(|m| serde_json::from_str(m.get()).ok())
.collect_vec(),
WebsocketEnum::Capture(_) => vec![],
};
loop {
let connect_url = if url.starts_with("$") {
let connect_url: Cow<str> = if url.starts_with("$") {
if url.starts_with("$flow:") || url.starts_with("$script:") {
let path = url.splitn(2, ':').nth(1).unwrap();
tokio::select! {
@@ -776,18 +1031,17 @@ async fn listen_to_websocket(
_ = killpill_rx.recv() => {
return;
},
_ = loop_ping(&db, &ws_trigger, Some(
_ = loop_ping(&db, &ws, Some(
"Waiting on runnable to return websocket URL..."
)) => {
return;
},
url_result = get_url_from_runnable(path, url.starts_with("$flow:"), &ws_trigger, &db) => match url_result {
Ok(url) => url,
url_result = ws.get_url_from_runnable(path, url.starts_with("$flow:"), &db) => match url_result {
Ok(url) => Cow::Owned(url),
Err(err) => {
disable_with_error(
&db,
&ws_trigger,
format!(
ws.disable_with_error(&db, format!(
"Error getting websocket URL from runnable after 5 tries: {:?}",
err
),
@@ -798,16 +1052,12 @@ async fn listen_to_websocket(
},
}
} else {
disable_with_error(
&db,
&ws_trigger,
format!("Invalid websocket runnable path: {}", url),
)
.await;
ws.disable_with_error(&db, format!("Invalid websocket runnable path: {}", url))
.await;
return;
}
} else {
url.to_string()
Cow::Borrowed(url)
};
tokio::select! {
@@ -815,14 +1065,14 @@ async fn listen_to_websocket(
_ = killpill_rx.recv() => {
return;
},
_ = loop_ping(&db, &ws_trigger, Some("Connecting...")) => {
_ = loop_ping(&db, &ws, Some("Connecting...")) => {
return;
},
connection = connect_async(connect_url) => {
connection = connect_async(connect_url.as_ref()) => {
match connection {
Ok((ws_stream, _)) => {
tracing::info!("Listening to websocket {}", url);
if let None = update_ping(&db, &ws_trigger, None).await {
if let None = ws.update_ping(&db, None).await {
return;
}
let (writer, mut reader) = ws_stream.split();
@@ -834,12 +1084,19 @@ async fn listen_to_websocket(
return;
}
_ = async {
if let Err(err) = send_initial_messages(&ws_trigger, writer, &db).await {
disable_with_error(&db, &ws_trigger, format!("Error sending initial messages: {:?}", err)).await;
} else {
tracing::debug!("Initial messages sent successfully to websocket {}", url);
// if initial messages sent successfully, wait forever
futures::future::pending::<()>().await;
match &ws {
WebsocketEnum::Trigger(ws_trigger) => {
if let Err(err) = ws_trigger.send_initial_messages(writer, &db).await {
ws_trigger.disable_with_error(&db, format!("Error sending initial messages: {:?}", err)).await;
} else {
tracing::debug!("Initial messages sent successfully to websocket {}", url);
// if initial messages sent successfully, wait forever
futures::future::pending::<()>().await;
}
},
WebsocketEnum::Capture(_) => {
futures::future::pending::<()>().await;
}
}
} => {
// was disabled => exit
@@ -852,7 +1109,7 @@ async fn listen_to_websocket(
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 {
if let None = ws.update_ping(&db, None).await {
return;
}
last_ping = tokio::time::Instant::now();
@@ -883,9 +1140,22 @@ async fn listen_to_websocket(
}
}
if should_handle {
if let Err(err) = run_job(&db, &ws_trigger, text).await {
report_critical_error(format!("Failed to trigger job from websocket {}: {:?}", ws_trigger.url, err), db.clone(), Some(&ws_trigger.workspace_id), None).await;
};
let args = HashMap::from([("msg".to_string(), to_raw_value(&text))]);
let extra = Some(HashMap::from([(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({"kind": "websocket", "websocket": { "url": url }})),
)]));
let args = PushArgsOwned { args, extra };
match &ws {
WebsocketEnum::Trigger(ws_trigger) => {
ws_trigger.handle(&db, args).await;
},
WebsocketEnum::Capture(capture) => {
capture.handle(&db, args).await;
}
}
}
},
a @ _ => {
@@ -899,9 +1169,7 @@ async fn listen_to_websocket(
}
} else {
tracing::error!("Websocket {} closed", url);
if let None =
update_ping(&db, &ws_trigger, Some("Websocket closed")).await
{
if let None = ws.update_ping(&db, Some("Websocket closed")).await {
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
@@ -910,7 +1178,7 @@ async fn listen_to_websocket(
},
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
tracing::debug!("Sending ping to websocket {}", url);
if let None = update_ping(&db, &ws_trigger, None).await {
if let None = ws.update_ping(&db, None).await {
return;
}
last_ping = tokio::time::Instant::now();
@@ -924,9 +1192,7 @@ async fn listen_to_websocket(
}
Err(err) => {
tracing::error!("Error connecting to websocket {}: {:?}", url, err);
if let None =
update_ping(&db, &ws_trigger, Some(err.to_string().as_str())).await
{
if let None = ws.update_ping(&db, Some(err.to_string().as_str())).await {
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
@@ -937,16 +1203,7 @@ async fn listen_to_websocket(
}
}
async fn run_job(db: &DB, 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", "websocket": { "url": trigger.url }}),
),
)])),
};
async fn run_job(db: &DB, trigger: &WebsocketTrigger, args: PushArgsOwned) -> anyhow::Result<()> {
let label_prefix = Some(format!("ws-{}-", trigger.path));
let authed = fetch_api_authed(
@@ -103,6 +103,14 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE capture_config SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE completed_job SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
@@ -396,6 +404,9 @@ pub(crate) async fn delete_workspace(
sqlx::query!("DELETE FROM capture WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM capture_config WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM draft WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
+95 -3
View File
@@ -86,6 +86,8 @@
"devDependencies": {
"@floating-ui/core": "^1.3.1",
"@hey-api/openapi-ts": "^0.43.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.2",
"@playwright/test": "^1.34.3",
"@rgossiaux/svelte-headlessui": "^2.0.0",
"@sveltejs/adapter-static": "^3.0.6",
@@ -3560,6 +3562,16 @@
"integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==",
"dev": true
},
"node_modules/@internationalized/date": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.6.0.tgz",
"integrity": "sha512-+z6ti+CcJnRlLHok/emGEsWQhe7kfSmEW+/6qCzvKY67YPh7YOBfvc7+/+NXq+zJlbArg30tYpqLjNgcAYv2YQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
@@ -3672,6 +3684,58 @@
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz",
"integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA=="
},
"node_modules/@melt-ui/pp": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@melt-ui/pp/-/pp-0.3.2.tgz",
"integrity": "sha512-xKkPvaIAFinklLXcQOpwZ8YSpqAFxykjWf8Y/fSJQwsixV/0rcFs07hJ49hJjPy5vItvw5Qa0uOjzFUbXzBypQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"estree-walker": "^3.0.3",
"magic-string": "^0.30.5"
},
"peerDependencies": {
"@melt-ui/svelte": ">= 0.29.0",
"svelte": "^3.55.0 || ^4.0.0 || ^5.0.0-next.1"
}
},
"node_modules/@melt-ui/svelte": {
"version": "0.86.2",
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.86.2.tgz",
"integrity": "sha512-wRVN603oIt1aXvx2QRmKqVDJgTScSvr/WJLLokkD8c4QzHgn6pfpPtUKmhV6Dvkk+OY89OG/1Irkd6ouA50Ztw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.3.1",
"@floating-ui/dom": "^1.4.5",
"@internationalized/date": "^3.5.0",
"dequal": "^2.0.3",
"focus-trap": "^7.5.2",
"nanoid": "^5.0.4"
},
"peerDependencies": {
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118"
}
},
"node_modules/@melt-ui/svelte/node_modules/nanoid": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz",
"integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/@mistralai/mistralai": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.3.0.tgz",
@@ -4323,6 +4387,16 @@
"vite": "^5.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz",
@@ -7302,6 +7376,16 @@
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
"dev": true
},
"node_modules/focus-trap": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.2.tgz",
"integrity": "sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"tabbable": "^6.2.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
@@ -12839,6 +12923,13 @@
"node": ">= 10"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"dev": true,
"license": "MIT"
},
"node_modules/table": {
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz",
@@ -13123,9 +13214,10 @@
"dev": true
},
"node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsutils": {
"version": "3.21.0",
+2
View File
@@ -19,6 +19,8 @@
"devDependencies": {
"@floating-ui/core": "^1.3.1",
"@hey-api/openapi-ts": "^0.43.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.2",
"@playwright/test": "^1.34.3",
"@rgossiaux/svelte-headlessui": "^2.0.0",
"@sveltejs/adapter-static": "^3.0.6",
@@ -95,6 +95,7 @@
| { type: 'hash'; hash: string }
| undefined = undefined
export let otherArgs: Record<string, any> = {}
export let lightHeader = false
let oneOfSelected: string | undefined = undefined
async function updateOneOfSelected(oneOf: SchemaProperty[] | undefined) {
@@ -344,6 +345,7 @@
{contentEncoding}
{format}
{simpleTooltip}
{lightHeader}
/>
{/if}
+16 -2
View File
@@ -488,7 +488,9 @@
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount,
simplifiedPoll: writable(false)
simplifiedPoll: writable(false),
defaultValues: writable(undefined),
captureOn: writable(undefined)
})
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
@@ -715,7 +717,19 @@
</Pane>
<Pane size={33}>
{#key reload}
<FlowEditorPanel enableAi noEditor />
<FlowEditorPanel
enableAi
noEditor
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
$selectedIdStore = 'preprocessor'
} else {
$previewArgsStore = ev.detail.args ?? {}
flowPreviewButtons?.openPreview()
}
}}
/>
{/key}
</Pane>
</Splitpanes>
@@ -14,10 +14,18 @@
export let labelClass: string = ''
export let prettify = false
export let simpleTooltip: string | undefined = undefined
export let lightHeader = false
</script>
<div class="inline-flex flex-row items-baseline truncated">
<span class={twMerge(disabled ? 'text-tertiary' : '', 'font-semibold', labelClass)}>
<span
class={twMerge(
disabled ? 'text-tertiary' : '',
'font-semibold',
lightHeader ? 'text-secondary text-sm font-normal' : '',
labelClass
)}
>
{#if prettify}
{label.replace(/_/g, ' ').split(' ').map(capitalize).join(' ')}
{:else}
+14 -2
View File
@@ -536,7 +536,9 @@
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll
simplifiedPoll,
defaultValues: writable(undefined),
captureOn: writable(undefined)
})
async function loadTriggers() {
@@ -1204,6 +1206,7 @@
let deploymentMsg = ''
let msgInput: HTMLInputElement | undefined = undefined
let flowPreviewButtons: FlowPreviewButtons
</script>
<svelte:window on:keydown={onKeyDown} />
@@ -1415,7 +1418,7 @@
{abortController}
/>
{/if}
<FlowPreviewButtons />
<FlowPreviewButtons bind:this={flowPreviewButtons} />
<Button
loading={loadingDraft}
size="xs"
@@ -1480,6 +1483,15 @@
renderCount += 1
}}
{newFlow}
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
$selectedIdStore = 'preprocessor'
} else {
$previewArgsStore = ev.detail.args ?? {}
flowPreviewButtons?.openPreview()
}
}}
/>
{:else}
<CenteredPage>Loading...</CenteredPage>
@@ -8,7 +8,6 @@
import SchemaForm from './SchemaForm.svelte'
import FlowStatusViewer from '../components/FlowStatusViewer.svelte'
import FlowProgressBar from './flows/FlowProgressBar.svelte'
import CapturePayload from './flows/content/CapturePayload.svelte'
import { AlertTriangle, ArrowRight, CornerDownLeft, Play, RefreshCw, X } from 'lucide-svelte'
import { emptyString, sendUserToast } from '$lib/utils'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -16,7 +15,6 @@
import { dfs } from './flows/dfs'
import { sliceModules } from './flows/flowStateUtils'
let capturePayload: CapturePayload
export let previewMode: 'upTo' | 'whole'
export let open: boolean
@@ -125,8 +123,6 @@
let renderCount: number = 0
</script>
<CapturePayload bind:this={capturePayload} />
<svelte:window on:keydown={onKeyDown} />
<Drawer bind:this={inputLibraryDrawer}>
@@ -294,15 +290,6 @@
}}>Past runs/Input library</Button
>
{/if}
<Button
btnClasses="h-full truncate"
size="sm"
variant="border"
on:click={() => {
capturePayload.openDrawer()
}}>Fill args from a request</Button
>
</div>
</div>
<div class="w-full flex flex-col gap-y-1">
+16 -5
View File
@@ -1,14 +1,25 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import Required from './Required.svelte'
export let label: string | undefined = undefined
export let primary = false
export let disabled = false
export let headless = false
export let required = false
</script>
<div class={$$props.class}>
<div class={twMerge(disabled ? 'opacity-60 pointer-events-none' : '', $$props.class)}>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex flex-row items-center gap-1">
<span class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6">{label}</span>
<slot name="header" />
</div>
{#if !headless}
<div class="flex flex-row items-center gap-2">
<span class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6">{label}</span>
{#if required}
<Required required={true} />
{/if}
<slot name="header" />
</div>
{/if}
<slot name="error" />
<slot name="action" />
</div>
@@ -24,6 +24,11 @@
export let newResource: boolean = false
export let hidePath: boolean = false
export let watchChanges: boolean = false
export let defaultValues: Record<string, any> | undefined = undefined
$: if (defaultValues && Object.keys(defaultValues).length > 0) {
args = defaultValues
}
let isValid = true
let jsonError = ''
@@ -8,8 +8,10 @@
let drawer: Drawer
let canSave = true
let resource_type: string | undefined = undefined
let defaultValues: Record<string, any> | undefined = undefined
let resourceEditor: { editResource: () => void } | undefined = undefined
let resourceEditor: { editResource: () => void; createResource: () => void } | undefined =
undefined
let path: string | undefined = undefined
@@ -21,16 +23,27 @@
drawer.openDrawer?.()
}
export async function initNew(resourceType: string): Promise<void> {
export async function initNew(
resourceType: string,
nDefaultValues?: Record<string, any>
): Promise<void> {
newResource = true
path = undefined
resource_type = resourceType
defaultValues = nDefaultValues
drawer.openDrawer?.()
}
let mode: 'edit' | 'new' = newResource ? 'new' : 'edit'
$: path ? (mode = 'edit') : (mode = 'new')
</script>
<Drawer bind:this={drawer} size="800px">
<DrawerContent title={path ? 'Edit ' + path : 'Add a resource'} on:close={drawer.closeDrawer}>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : 'Add a resourcee'}
on:close={drawer.closeDrawer}
>
{#await import('./ResourceEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
@@ -38,6 +51,7 @@
{newResource}
{path}
{resource_type}
{defaultValues}
on:refresh
bind:this={resourceEditor}
bind:canSave
@@ -47,7 +61,11 @@
<Button
startIcon={{ icon: Save }}
on:click={() => {
resourceEditor?.editResource()
if (mode == 'edit') {
resourceEditor?.editResource()
} else {
resourceEditor?.createResource()
}
drawer.closeDrawer()
}}
disabled={!canSave}
@@ -1,7 +1,7 @@
<script lang="ts">
import { ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, onMount } from 'svelte'
import Select from './apps/svelte-select/lib/index'
import { SELECT_INPUT_DEFAULT_STYLE } from '../defaults'
import AppConnect from './AppConnectDrawer.svelte'
@@ -23,6 +23,15 @@
export let showSchemaExplorer = false
export let selectFirst = false
export let expressOAuthSetup = false
export let defaultValues: Record<string, any> | undefined = undefined
onMount(() => {
setTimeout(() => {
if (Object.keys(defaultValues ?? {}).length > 0 && resourceType) {
resourceEditor?.initNew?.(resourceType, defaultValues)
}
}, 500)
})
let valueSelect =
initialValue || value
@@ -185,7 +194,7 @@
{disabled}
color="light"
variant="border"
size="sm"
size="xs"
on:click={() => appConnect?.open?.(resourceType)}
startIcon={{ icon: Plus }}
iconOnly={collection?.length > 0}
@@ -45,6 +45,7 @@
| { type: 'inline'; path?: string; lang: Script['language']; code: string }
| { type: 'hash'; hash: string }
| undefined = undefined
export let lightHeader = false
const dispatch = createEventDispatcher()
@@ -262,6 +263,7 @@
orderEditable={dndConfig != undefined}
otherArgs={args}
{helperScript}
{lightHeader}
>
<svelte:fragment slot="actions">
<slot name="actions" />
@@ -67,7 +67,12 @@
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import TriggersEditor from './triggers/TriggersEditor.svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import type { ScheduleTrigger, TriggerContext, TriggerKind } from './triggers'
import {
BUN_PREPROCESSOR_MODULE_CODE,
PYTHON_PREPROCESSOR_MODULE_CODE
} from '$lib/script_helpers'
import CaptureTable from './triggers/CaptureTable.svelte'
export let script: NewScript
export let fullyLoaded: boolean = true
@@ -90,6 +95,9 @@
let deployedBy: string | undefined = undefined // Author
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
let open: boolean = false // Is confirmation modal open
let args: Record<string, any> = initialArgs // Test args input
let selectedInputTab: 'main' | 'preprocessor' = 'main'
let hasPreprocessor = false
let metadataOpen =
!neverShowMeta &&
@@ -100,6 +108,7 @@
let editor: Editor | undefined = undefined
let scriptEditor: ScriptEditor | undefined = undefined
let captureTable: CaptureTable | undefined = undefined
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
const triggersCount = writable<TriggersCount | undefined>(
@@ -108,9 +117,7 @@
: undefined
)
const simplifiedPoll = writable(false)
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>('webhooks')
const selectedTriggerStore = writable<TriggerKind>('webhooks')
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
@@ -137,11 +144,16 @@
}
}
const triggerDefaultValuesStore = writable<Record<string, any> | undefined>(undefined)
const captureOn = writable<boolean | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll
simplifiedPoll,
defaultValues: triggerDefaultValuesStore,
captureOn: captureOn
})
const enterpriseLangs = ['bigquery', 'snowflake', 'mssql']
@@ -612,6 +624,41 @@
}
return lang
}
async function applyArgs(e) {
selectedInputTab = e.detail.kind
metadataOpen = false
args = e.detail.args ?? {}
}
function openTriggers(ev) {
metadataOpen = true
selectedTab = 'triggers'
selectedTriggerStore.set(ev.detail.kind)
triggerDefaultValuesStore.set(ev.detail.config)
captureOn.set(true)
}
function addPreprocessor() {
const code = editor?.getCode()
if (code) {
const preprocessorCode =
script.language === 'python3'
? PYTHON_PREPROCESSOR_MODULE_CODE
: BUN_PREPROCESSOR_MODULE_CODE
const mainIndex = code.indexOf(
script.language === 'python3' ? 'def main' : 'export async function main'
)
if (mainIndex === -1) {
editor?.setCode(code + preprocessorCode)
} else {
editor?.setCode(
code.slice(0, mainIndex) + preprocessorCode + '\n' + code.slice(mainIndex) + '\n\n'
)
}
}
selectedInputTab = 'preprocessor'
}
</script>
<svelte:window on:keydown={onKeyDown} />
@@ -1206,12 +1253,22 @@
</TabContent>
<TabContent value="triggers">
<TriggersEditor
on:applyArgs={applyArgs}
on:addPreprocessor={addPreprocessor}
on:exitTriggers={() => {
captureTable?.refreshCaptures()
}}
{initialPath}
schema={script.schema}
noEditor={true}
isFlow={false}
currentPath={script.path}
hash={script.parent_hash}
newItem={initialPath == ''}
canHavePreprocessor={script.language === 'bun' ||
script.language === 'deno' ||
script.language === 'python3'}
{hasPreprocessor}
/>
<!-- <ScriptSchedules {initialPath} schema={script.schema} schedule={scheduleStore} /> -->
</TabContent>
@@ -1390,6 +1447,7 @@
</div>
<ScriptEditor
bind:selectedTab={selectedInputTab}
{customUi}
collabMode
edit={initialPath != ''}
@@ -1399,6 +1457,9 @@
on:saveDraft={() => {
saveDraft()
}}
on:openTriggers={openTriggers}
on:applyArgs={applyArgs}
on:addPreprocessor={addPreprocessor}
bind:editor
bind:this={scriptEditor}
bind:schema={script.schema}
@@ -1409,6 +1470,9 @@
kind={script.kind}
{template}
tag={script.tag}
bind:args
bind:hasPreprocessor
bind:captureTable
/>
</div>
{:else}
+75 -41
View File
@@ -28,6 +28,8 @@
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
import { slide } from 'svelte/transition'
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import CaptureButton from './triggers/CaptureButton.svelte'
// Exported
export let schema: Schema | any = emptySchema()
@@ -49,6 +51,10 @@
export let saveToWorkspace = false
export let watchChanges = false
export let customUi: ScriptEditorWhitelabelCustomUi = {}
export let args: Record<string, any> = initialArgs
export let selectedTab: 'main' | 'preprocessor' = 'main'
export let hasPreprocessor = false
export let captureTable: CaptureTable | undefined = undefined
let jobProgressReset: () => void
@@ -70,9 +76,6 @@
let testJobLoader: TestJobLoader
// Test args input
let args: Record<string, any> = initialArgs
let isValid: boolean = true
let scriptProgress = undefined
@@ -126,8 +129,6 @@
})
}
let hasPreprocessor = false
export async function inferSchema(code: string, nlang?: SupportedLanguage, resetArgs = false) {
let nschema = schema ?? emptySchema()
@@ -141,6 +142,10 @@
hasPreprocessor =
(selectedTab === 'preprocessor' ? !result?.no_main_func : result?.has_preprocessor) ?? false
if (!hasPreprocessor && selectedTab === 'preprocessor') {
selectedTab = 'main'
}
validCode = true
if (resetArgs) {
args = {}
@@ -238,11 +243,12 @@
url.search = ''
return `${url}?collab=1` + (edit ? '' : `&path=${path}`)
}
let selectedTab: 'main' | 'preprocessor' = 'main'
$: showTabs = hasPreprocessor
$: !hasPreprocessor && (selectedTab = 'main')
$: selectedTab && inferSchema(code)
let argsRender = 0
</script>
<TestJobLoader
@@ -371,10 +377,15 @@
<div transition:slide={{ duration: 200 }}>
<Tabs bind:selected={selectedTab}>
<Tab value="main">Main</Tab>
<Tab value="preprocessor">Preprocessor</Tab>
{#if hasPreprocessor}
<div transition:slide={{ duration: 200, axis: 'x' }}>
<Tab value="preprocessor">Preprocessor</Tab>
</div>
{/if}
</Tabs>
</div>
{/if}
<div class="flex justify-center pt-1">
{#if testIsLoading}
<Button on:click={testJobLoader?.cancelJob} btnClasses="w-full" color="red" size="xs">
@@ -388,48 +399,53 @@
Cancel
</Button>
{:else}
<Button
color="dark"
on:click={() => {
runTest()
}}
btnClasses="w-full"
size="xs"
startIcon={{
icon: Play,
classes: 'animate-none'
}}
shortCut={{ Icon: CornerDownLeft, hide: testIsLoading }}
>
{#if testIsLoading}
Running
{:else}
Test
{/if}
</Button>
<div class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch">
<Button
color="dark"
on:click={() => {
runTest()
}}
btnClasses="w-full rounded-r-none"
size="xs"
startIcon={{
icon: Play,
classes: 'animate-none'
}}
shortCut={{ Icon: CornerDownLeft, hide: testIsLoading }}
>
{#if testIsLoading}
Running
{:else}
Test
{/if}
</Button>
<CaptureButton on:openTriggers />
</div>
{/if}
</div>
<Splitpanes horizontal class="!max-h-[calc(100%-43px)]">
<Pane size={33}>
<div class="px-2">
<div class="break-words relative font-sans">
<SchemaForm
helperScript={{
type: 'inline',
code,
//@ts-ignore
lang
}}
compact
{schema}
bind:args
bind:isValid
showSchemaExplorer
/>
{#key argsRender}
<SchemaForm
helperScript={{
type: 'inline',
code,
//@ts-ignore
lang
}}
compact
{schema}
bind:args
bind:isValid
showSchemaExplorer
/>
{/key}
</div>
</div>
</Pane>
<Pane size={67}>
<Pane size={67} class="relative">
<LogPanel
{lang}
previewJob={testJob}
@@ -438,6 +454,7 @@
{editor}
{diffEditor}
{args}
showCaptures={true}
>
{#if scriptProgress}
<!-- Put to the slot in logpanel -->
@@ -448,6 +465,23 @@
compact={true}
/>
{/if}
<svelte:fragment slot="capturesTab">
<div class="h-full p-2">
<CaptureTable
bind:this={captureTable}
captureType="all"
{hasPreprocessor}
canHavePreprocessor={lang === 'bun' || lang === 'deno' || lang === 'python3'}
isFlow={false}
path={path ?? ''}
hideCapturesWhenEmpty={false}
canEdit={true}
on:applyArgs
on:updateSchema
on:addPreprocessor
/>
</div>
</svelte:fragment>
</LogPanel>
</Pane>
</Splitpanes>
+39 -34
View File
@@ -10,46 +10,51 @@
export let small: boolean = false
export let collapsable: boolean = false
let collapsed: boolean = true
export let collapsed: boolean = true
export let headless: boolean = false
</script>
<div class="w-full">
<div class="flex flex-row justify-between items-center mb-2">
<h2
class={twMerge(
'font-semibold flex flex-row items-center gap-1',
small ? 'text-sm' : 'text-base'
)}
>
{#if collapsable}
<button class="flex items-center gap-1" on:click={() => (collapsed = !collapsed)}>
{#if collapsed}
<ChevronRight size={16} />
{:else}
<ChevronDown size={16} />
{/if}
{#if !headless}
<div class="flex flex-row justify-between items-center mb-2">
<h2
class={twMerge(
'font-semibold flex flex-row items-center gap-1',
small ? 'text-sm' : 'text-base'
)}
>
{#if collapsable}
<button class="flex items-center gap-1" on:click={() => (collapsed = !collapsed)}>
{#if collapsed}
<ChevronRight size={16} />
{:else}
<ChevronDown size={16} />
{/if}
{label}
</button>
{:else}
{label}
</button>
{:else}
{label}
{/if}
<slot name="header" />
{#if tooltip}
<Tooltip>{tooltip}</Tooltip>
{/if}
{#if eeOnly}
{#if !$enterpriseLicense}
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap ml-8">
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</div>
{/if}
<slot name="header" />
{#if tooltip}
<Tooltip>{tooltip}</Tooltip>
{/if}
{#if eeOnly}
{#if !$enterpriseLicense}
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap ml-8">
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</div>
{/if}
{/if}
</h2>
<slot name="action" />
{#if collapsable && collapsed}
<slot name="badge" />
{/if}
</h2>
<slot name="action" />
<slot name="badge" />
</div>
</div>
{/if}
<div class={collapsable && collapsed ? `hidden ${$$props.class}` : `${$$props.class}`}>
<slot />
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
export let label: string | undefined = undefined
export let tooltip: string | undefined = undefined
export let eeOnly = false
export let collapsable: boolean = false
export let collapsed: boolean = true
export let headless: boolean = false
</script>
<div class="w-full">
{#if !headless}
<div class="flex flex-row justify-between items-center mb-1">
<h3 class={twMerge('font-semibold flex flex-row items-center gap-2', 'text-sm')}>
{#if collapsable}
<button class="flex items-center gap-1" on:click={() => (collapsed = !collapsed)}>
{#if collapsed}
<ChevronRight size={16} />
{:else}
<ChevronDown size={16} />
{/if}
{label}
</button>
{:else}
{label}
{/if}
<slot name="header" />
{#if tooltip}
<Tooltip>{tooltip}</Tooltip>
{/if}
{#if eeOnly}
{#if !$enterpriseLicense}
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap ml-8">
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</div>
{/if}
{/if}
</h3>
<slot name="action" />
{#if collapsable && collapsed}
<slot name="badge" />
{/if}
</div>
{/if}
<div class={collapsable && collapsed ? `hidden ${$$props.class}` : `${$$props.class}`}>
<slot />
</div>
</div>
@@ -0,0 +1,36 @@
<script context="module" lang="ts">
export type ConnectionInfo = {
connected: boolean
message?: string
}
</script>
<script lang="ts">
import Popover from '$lib/components/Popover.svelte'
import { Circle } from 'lucide-svelte'
export let connectionInfo: ConnectionInfo | undefined = undefined
</script>
{#if connectionInfo}
<div class="center-center">
{#if connectionInfo.connected}
<Popover notClickable>
<span class="h-4 w-4 center-center">
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
</span>
<div slot="text"> {connectionInfo.message ?? ''} </div>
</Popover>
{:else}
<Popover notClickable>
<span class="h-4 w-4 center-center">
<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"> {connectionInfo.message ?? ''} </div>
</Popover>
{/if}
</div>
{/if}
@@ -46,7 +46,7 @@
background: inherit;
z-index: 1;
overflow: hidden;
padding: var(--margin-width, 2px);
padding: var(--margin-width, 1px);
}
/* Circular gradient */
@@ -48,7 +48,7 @@
{#if icon}
<svelte:component
this={icon}
size={14}
size={small ? 12 : 14}
color={$selected === value ? selectedColor : '#9CA3AF'}
{...iconProps}
/>
@@ -6,6 +6,7 @@
export let content: string
export let title: string | undefined = undefined
export let size: 'sm' | 'md' = 'sm'
export let disabled = false
</script>
{#if title !== undefined}
@@ -15,8 +16,13 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="grow min-w-0 w-full px-2 py-1 border flex items-center cursor-pointer bg-surface-secondary text-primary justify-between rounded-md"
class="grow min-w-0 w-full px-2 py-1 border flex items-center bg-surface-secondary text-primary justify-between rounded-md"
class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled}
on:click={(e) => {
if (disabled) {
return
}
e.preventDefault()
copyToClipboard(content)
}}
@@ -0,0 +1,27 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils'
import { Clipboard } from 'lucide-svelte'
import Highlight from 'svelte-highlight'
import type { LanguageType } from 'svelte-highlight/languages'
export let code: string
export let language: LanguageType<string>
export let disabled = false
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex flex-row flex-1 border p-2 rounded-md overflow-auto relative"
class:cursor-not-allowed={disabled}
on:click={(e) => {
if (disabled) {
return
}
e.preventDefault()
copyToClipboard(code)
}}
>
<Highlight {language} {code} class="pointer-events-none" />
<Clipboard size={14} class="w-8 top-2 right-2 absolute" />
</div>
@@ -28,7 +28,9 @@
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll
simplifiedPoll,
defaultValues: writable(undefined),
captureOn: writable(undefined)
})
</script>
@@ -0,0 +1,135 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { AlertTriangle } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import { SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON } from '$lib/consts'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ClipboardPanel from './ClipboardPanel.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { base32 } from 'rfc4648'
import CaptureSection, { type CaptureInfo } from '../triggers/CaptureSection.svelte'
import CaptureTable from '../triggers/CaptureTable.svelte'
export let token: string = ''
export let isFlow: boolean = false
export let hash: string | undefined = undefined
export let path: string
export let userSettings: any
export let emailDomain: string | null = null
export let showCapture: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
let requestType: 'hash' | 'path' = 'path'
function getCaptureEmail() {
const cleanedPath = path.replaceAll('/', '.')
const plainPrefix = `capture+${$workspaceStore}+${(isFlow ? 'flow.' : '') + cleanedPath}`
const encodedPrefix = base32
.stringify(new TextEncoder().encode(plainPrefix), {
pad: false
})
.toLowerCase()
console.log(encodedPrefix)
return `${encodedPrefix}@${emailDomain}`
}
$: captureEmail = getCaptureEmail()
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
}+${token}`
const encodedPrefix = base32
.stringify(new TextEncoder().encode(plainPrefix), {
pad: false
})
.toLowerCase()
return `${pathOrHash}+${encodedPrefix}@${emailDomain}`
}
export let email: string = ''
$: email = emailAddress(requestType, path, hash, isFlow, token)
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
bind:captureTable
captureType="email"
disabled={false}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
>
<Label label="Email address">
<ClipboardPanel content={captureEmail} disabled={!captureInfo.active} />
</Label>
</CaptureSection>
{/if}
<div class="flex flex-col gap-4">
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<Label label="Token">
<div class="flex flex-row justify-between gap-2">
<input
bind:value={token}
placeholder="paste your token here once created to alter examples below"
class="!text-xs"
/>
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
Create an Email-specific Token
<Tooltip light>
The token will have a scope such that it can only be used to trigger this script. It
is safe to share as it cannot be used to impersonate you.
</Tooltip>
</Button>
</div>
{#if token === 'TOKEN_TO_CREATE'}
<div class="flex flex-row gap-1 text-xs text-red-500 items-center mt-1">
<AlertTriangle size="12" />
Create/input a valid token before copying the email address below
</div>
{/if}
</Label>
{/if}
{#if !isFlow}
<div class="flex flex-col gap-2">
<div class="flex flex-row justify-between">
<div class="text-xs font-semibold flex flex-row items-center">Call method</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={requestType}>
<ToggleButton label="By path" value="path" />
<ToggleButton label="By hash" value="hash" />
</ToggleButtonGroup>
</div>
</div>
{/if}
{#key requestType}
{#key token}
<Label label="Email address">
<ClipboardPanel content={email} />
</Label>
{/key}
{/key}
<Alert title="Email trigger" size="xs">
To trigger the job by email, send an email to the address above. The job will receive two
arguments: `raw_email` containing the raw email as string, and `parsed_email` containing the
parsed email as an object.
</Alert>
</div>
</div>
@@ -1,69 +1,40 @@
<script lang="ts">
import Tooltip from '$lib/components/Tooltip.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON } from '$lib/consts'
import UserSettings from '../UserSettings.svelte'
import ClipboardPanel from './ClipboardPanel.svelte'
import { generateRandomString } from '$lib/utils'
import HighlightTheme from '../HighlightTheme.svelte'
import Alert from '../common/alert/Alert.svelte'
import { SettingService } from '$lib/gen'
import { base32 } from 'rfc4648'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import { AlertTriangle } from 'lucide-svelte'
import Skeleton from '../common/skeleton/Skeleton.svelte'
import Label from '$lib/components/Label.svelte'
import TriggerTokens from '../triggers/TriggerTokens.svelte'
import TriggersEditorSection from '../triggers/TriggersEditorSection.svelte'
import Description from '../Description.svelte'
let userSettings: UserSettings
import Description from '$lib/components/Description.svelte'
export let token: string
export let scopes: string[] = []
export let isFlow: boolean = false
export let hash: string | undefined = undefined
export let path: string
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
export let newItem: boolean = false
let emailDomain: string | null = null
let triggerTokens: TriggerTokens | undefined = undefined
let loading = true
async function getEmailDomain() {
emailDomain =
((await SettingService.getGlobal({
key: 'email_domain'
})) as any) ?? null
})) as any) ?? 'mail.test.com'
loading = false
}
getEmailDomain()
let requestType: 'hash' | 'path' = 'path'
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
}+${token}`
const encodedPrefix = base32
.stringify(new TextEncoder().encode(plainPrefix), {
pad: false
})
.toLowerCase()
return `${pathOrHash}+${encodedPrefix}@${emailDomain}`
}
export let email: string = ''
$: email = emailAddress(requestType, path, hash, isFlow, token)
let triggerTokens: TriggerTokens | undefined = undefined
</script>
<HighlightTheme />
@@ -84,60 +55,24 @@
Email triggers execute scripts and flows when emails are sent to specific addresses. Each
trigger has its own unique email address that can be used to invoke the script or flow.
</Description>
{#if loading}
<Skeleton layout={[[18]]} />
{:else}
{#if emailDomain}
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<Label label="Token">
<div class="flex flex-row justify-between gap-2">
<input
bind:value={token}
placeholder="paste your token here once created to alter examples below"
class="!text-xs"
/>
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
Create an email-specific token
<Tooltip light>
The token will have a scope such that it can only be used to trigger this script. It
is safe to share as it cannot be used to impersonate you.
</Tooltip>
</Button>
</div>
{#if token === 'TOKEN_TO_CREATE'}
<div class="flex flex-row gap-1 text-xs text-red-500 items-center mt-1">
<AlertTriangle size="12" />
Create/input a valid token before copying the email address below
</div>
{/if}
</Label>
{/if}
{#if !isFlow}
<div class="flex flex-col gap-2">
<div class="flex flex-row justify-between">
<div class="text-xs font-semibold flex flex-row items-center">Call method</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={requestType}>
<ToggleButton label="By path" value="path" />
<ToggleButton label="By hash" value="hash" />
</ToggleButtonGroup>
</div>
</div>
{/if}
{#key requestType}
{#key token}
<Label label="Email address">
<ClipboardPanel content={email} />
</Label>
{/key}
{/key}
<Alert title="Email trigger" size="xs">
To trigger the job by email, send an email to the address above. The job will receive two
arguments: `raw_email` containing the raw email as string, and `parsed_email` containing the
parsed email as an object.
</Alert>
<TriggersEditorSection
cloudDisabled={false}
triggerType="email"
{isFlow}
noSave
data={{ emailDomain, userSettings, token, hash, path }}
{isEditor}
{path}
{canHavePreprocessor}
{hasPreprocessor}
on:applyArgs
on:addPreprocessor
{newItem}
/>
{:else}
<div>
<Alert title="Email triggers are disabled" size="xs" type="warning">
@@ -72,7 +72,7 @@
</div>
</div>
{:else}
<FlowEditorPanel {disabledFlowInputs} {newFlow} enableAi={!disableAi} />
<FlowEditorPanel {disabledFlowInputs} {newFlow} enableAi={!disableAi} on:applyArgs />
{/if}
</Pane>
</Splitpanes>
@@ -1,125 +0,0 @@
<script lang="ts">
import { BROWSER } from 'esm-env'
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte'
import { CaptureService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { convert } from '@redocly/json-to-json-schema'
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import { copyToClipboard, sendUserToast } from '$lib/utils'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { Clipboard } from 'lucide-svelte'
const { previewArgs, flowStore, pathStore } = getContext<FlowEditorContext>('FlowEditorContext')
let drawer: Drawer
let interval: NodeJS.Timeout | undefined = undefined
let captureInput: any | undefined = undefined
let jsonSchema: any = undefined
export function openDrawer() {
drawer.openDrawer()
}
async function startCapturePoint() {
await CaptureService.createCapture({
workspace: $workspaceStore!,
path: $pathStore
})
}
async function getCaptureInput() {
const capture = await CaptureService.getCapture({
workspace: $workspaceStore!,
path: $pathStore
})
captureInput = capture
jsonSchema = { required: [], properties: {}, ...convert(capture) }
}
let hostname = BROWSER ? window.location.protocol + '//' + window.location.host : 'SSR'
</script>
<Drawer
bind:this={drawer}
size="900px"
on:open={() => {
startCapturePoint()
interval = setInterval(() => {
getCaptureInput()
}, 1000)
}}
on:close={() => interval && clearInterval(interval)}
>
<DrawerContent title="Capture request" on:close={drawer.closeDrawer}>
Send a payload at: <div>
<a
class="text-xs border p-2 flex flex-row items-center gap-2 justify-between"
on:click={(e) => {
e.preventDefault()
copyToClipboard(`${hostname}/api/w/${$workspaceStore}/capture_u/${$pathStore}`)
}}
href="{hostname}/api/w/{$workspaceStore}/capture_u/{$pathStore}"
>
{hostname}/api/w/{$workspaceStore}/capture_u/{$pathStore}
<Clipboard size={14} />
</a>
</div>
<p class="text-secondary mt-4 text-xs">CURL example</p>
<div class="text-xs box mb-4 b">
<pre class="overflow-auto"
>{`curl -X POST ${hostname}/api/w/${$workspaceStore}/capture_u/${$pathStore} \\
-H 'Content-Type: application/json' \\
-d '{"foo": 42}'`}</pre
>
</div>
<div class="items-center flex flex-row gap-x-2 text-xs text-secondary">
Listening for new requests
<span class="relative flex h-3 w-3">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
</div>
<div class="box p-2 my-2 mb-4">
<ObjectViewer topBrackets={true} json={captureInput} />
</div>
<svelte:fragment slot="actions">
<Button
size="sm"
variant="border"
on:click={() => {
$previewArgs = captureInput
sendUserToast('Copied as test args')
}}
>
Copy only as test args
</Button>
<Button
size="sm"
on:click={() => {
$previewArgs = captureInput
$flowStore.schema = jsonSchema
sendUserToast('Copied as flow inputs and test args')
}}
>
Copy as flow inputs and test args
</Button>
</svelte:fragment>
<h3 class="my-2 mt-8">Derived schema</h3>
<div class="box p-2">
<SchemaViewer schema={jsonSchema} />
</div>
<h3 class="mt-8">Test args</h3>
<SchemaForm class="pt-4" schema={$flowStore.schema} args={$previewArgs} />
</DrawerContent>
</Drawer>
@@ -12,6 +12,8 @@
import { initFlowStepWarnings } from '../utils'
import { dfs } from '../dfs'
import FlowPreprocessorModule from './FlowPreprocessorModule.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { insertNewPreprocessorModule } from '../flowStateUtils'
export let noEditor = false
export let enableAi = false
@@ -21,6 +23,7 @@
const { selectedId, flowStore, flowStateStore, flowInputsStore, pathStore, initialPath } =
getContext<FlowEditorContext>('FlowEditorContext')
const { selectedTrigger, defaultValues, captureOn } = getContext<TriggerContext>('TriggerContext')
function checkDup(modules: FlowModule[]): string | undefined {
let seenModules: string[] = []
for (const m of modules) {
@@ -60,7 +63,24 @@
{#if $selectedId?.startsWith('settings')}
<FlowSettings {noEditor} />
{:else if $selectedId === 'Input'}
<FlowInput {noEditor} disabled={disabledFlowInputs} />
<FlowInput
{noEditor}
disabled={disabledFlowInputs}
on:openTriggers={(ev) => {
$selectedId = 'triggers'
selectedTrigger.set(ev.detail.kind)
defaultValues.set(ev.detail.config)
captureOn.set(true)
}}
on:applyArgs
on:updateSchema={(e) => {
const { schema, redirect } = e.detail
$flowStore.schema = schema
if (redirect) {
$selectedId = 'Input'
}
}}
/>
{:else if $selectedId === 'Result'}
<p class="p-4 text-secondary">The result of the flow will be the result of the last node.</p>
{:else if $selectedId === 'constants'}
@@ -71,12 +91,22 @@
<FlowPreprocessorModule {noEditor} />
{:else if $selectedId === 'triggers'}
<TriggersEditor
on:applyArgs
on:addPreprocessor={async () => {
await insertNewPreprocessorModule(flowStore, flowStateStore, {
language: 'bun',
subkind: 'preprocessor'
})
$selectedId = 'preprocessor'
}}
currentPath={$pathStore}
{initialPath}
schema={$flowStore.schema}
{noEditor}
newItem={newFlow}
isFlow={true}
hasPreprocessor={!!$flowStore.value.preprocessor_module}
canHavePreprocessor={true}
/>
{:else if $selectedId.startsWith('subflow:')}
<div class="p-4"
@@ -4,7 +4,6 @@
import FlowCard from '../common/FlowCard.svelte'
import { copyFirstStepSchema } from '../flowStore'
import type { FlowEditorContext } from '../types'
import CapturePayload from './CapturePayload.svelte'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { convert } from '@redocly/json-to-json-schema'
@@ -20,7 +19,6 @@
const { flowStore, flowStateStore, previewArgs, initialPath } =
getContext<FlowEditorContext>('FlowEditorContext')
let capturePayload: CapturePayload
let inputLibraryDrawer: Drawer
let jsonPayload: Drawer
let pendingJson: string
@@ -41,21 +39,10 @@
const yOffset = 191
</script>
<CapturePayload bind:this={capturePayload} />
<FlowCard {noEditor} title="Flow Input">
{#if !disabled}
<div class="flex flex-row items-center gap-2 px-4 py-2 border-b">
<div>Copy input's schema from</div>
<Button
color="dark"
size="xs"
on:click={() => {
capturePayload.openDrawer()
}}
>
A request
</Button>
<div class="text-sm">Copy input's schema from</div>
<Button
color="dark"
size="xs"
@@ -21,7 +21,9 @@
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount,
simplifiedPoll: writable(false)
simplifiedPoll: writable(false),
defaultValues: writable(undefined),
captureOn: writable(undefined)
})
async function loadFlow(path: string) {
@@ -18,6 +18,7 @@ import { NEVER_TESTED_THIS_FAR } from './models'
import { loadSchemaFromModule } from './flowInfers'
import { nextId } from './flowModuleNextId'
import { findNextAvailablePath } from '$lib/path'
import type { ExtendedOpenFlow } from './types'
export async function loadFlowModuleState(flowModule: FlowModule): Promise<FlowModuleState> {
try {
@@ -74,7 +75,7 @@ export async function pickFlow(
export async function createInlineScriptModule(
language: RawScript['language'],
kind: Script['kind'],
subkind: 'pgsql' | 'flow',
subkind: 'pgsql' | 'flow' | 'preprocessor',
id: string,
summary?: string
): Promise<[FlowModule, FlowModuleState]> {
@@ -293,3 +294,40 @@ export function sliceModules(
return m
})
}
export async function insertNewPreprocessorModule(
flowStore: Writable<ExtendedOpenFlow>,
flowStateStore: Writable<FlowState>,
inlineScript?: {
language: RawScript['language']
subkind: 'preprocessor'
},
wsScript?: { path: string; summary: string; hash: string | undefined }
) {
var module: FlowModule = {
id: 'preprocessor',
value: { type: 'identity' }
}
var state = emptyFlowModuleState()
if (inlineScript) {
;[module, state] = await createInlineScriptModule(
inlineScript.language,
'script',
inlineScript.subkind,
'preprocessor'
)
} else if (wsScript) {
;[module, state] = await pickScript(wsScript.path, wsScript.summary, module.id, wsScript.hash)
}
flowStore.update((fs) => {
fs.value.preprocessor_module = module
return fs
})
flowStateStore.update((fss) => {
fss[module.id] = state
return fss
})
}
@@ -10,7 +10,8 @@
deleteFlowStateById,
emptyModule,
pickScript,
pickFlow
pickFlow,
insertNewPreprocessorModule
} from '$lib/components/flows/flowStateUtils'
import type { FlowModule, RawScript, Script } from '$lib/gen'
import { emptyFlowModuleState, initFlowStepWarnings } from '../utils'
@@ -133,34 +134,6 @@
return modules
}
async function insertNewPreprocessorModule(
inlineScript?: {
language: RawScript['language']
subkind: 'pgsql' | 'flow'
},
wsScript?: { path: string; summary: string; hash: string | undefined }
) {
var module: FlowModule = {
id: 'preprocessor',
value: { type: 'identity' }
}
var state = emptyFlowModuleState()
if (inlineScript) {
;[module, state] = await createInlineScriptModule(
inlineScript.language,
'script',
inlineScript.subkind,
'preprocessor'
)
} else if (wsScript) {
;[module, state] = await pickScript(wsScript.path, wsScript.summary, module.id, wsScript.hash)
}
$flowStore.value.preprocessor_module = module
$flowStateStore[module.id] = state
}
function removeAtId(modules: FlowModule[], id: string): FlowModule[] {
const index = modules.findIndex((mod) => mod.id == id)
if (index != -1) {
@@ -391,7 +364,12 @@
$moving = undefined
} else {
if (detail.detail === 'preprocessor') {
insertNewPreprocessorModule(detail.inlineScript, detail.script)
insertNewPreprocessorModule(
flowStore,
flowStateStore,
detail.inlineScript,
detail.script
)
$selectedId = 'preprocessor'
} else {
const index = detail.index ?? 0
@@ -22,7 +22,7 @@
<div style={`width: ${NODE.width}px;`}>
<button
style="background-color: {bgColor} !important;"
class="flex w-full flex-row gap-1.5 px-2 p-1 items-center {selected
class="flex w-full flex-row gap-1 px-2 p-1 items-center {selected
? 'outline outline-2 outline-gray-600 rounded-sm dark:bg-white/5 dark:outline-gray-400'
: ''}"
on:click={() => {
@@ -0,0 +1,50 @@
<script lang="ts">
import { createPopover, createSync, melt } from '@melt-ui/svelte'
import { fade } from 'svelte/transition'
import { X } from 'lucide-svelte'
export let open = false
export let closeButton: boolean = true
export let displayArrow: boolean = false
const {
elements: { trigger, content, arrow, close },
states
} = createPopover({
forceVisible: true
})
const sync = createSync(states)
$: sync.open(open, (v) => (open = v))
</script>
<button type="button" use:melt={$trigger} aria-label="Popup button">
<slot name="trigger" />
</button>
{#if open}
<div use:melt={$content} transition:fade={{ duration: 100 }} class="content">
{#if displayArrow}
<div use:melt={$arrow} />
{/if}
<slot name="content" />
{#if closeButton}
<button class="close" use:melt={$close}>
<X class="size-3" />
</button>
{/if}
</div>
{/if}
<style lang="postcss">
.close {
@apply absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full;
@apply text-primary transition-colors hover:bg-surface-hover;
@apply focus-visible:ring focus-visible:ring-gray-400 focus-visible:ring-offset-2;
@apply bg-surface p-0 text-sm font-medium;
}
.content {
@apply z-10 w-fit rounded-[4px] bg-surface p-0 overflow-hidden shadow-md;
}
</style>
@@ -37,6 +37,7 @@
export let diffEditor: DiffEditor | undefined = undefined
export let args: Record<string, any> | undefined = undefined
export let workspace: string | undefined = undefined
export let showCaptures: boolean = false
type DrawerContent = {
mode: 'json' | Preview['language'] | 'plain'
@@ -87,170 +88,181 @@
</DrawerContent>
</Drawer>
<Tabs bind:selected={selectedTab} class="mt-1">
<Tab value="logs" size="xs">Logs & Result</Tab>
<Tab value="history" size="xs">History</Tab>
<div class="h-full flex flex-col">
<Tabs bind:selected={selectedTab} class="pt-1" wrapperClass="flex-none">
<Tab value="logs" size="xs">Logs & Result</Tab>
<Tab value="history" size="xs">History</Tab>
{#if showCaptures}
<Tab value="captures" size="xs">Captures</Tab>
{/if}
<svelte:fragment slot="content">
<!--
TabContent would wrap it in an extra div which is undesirable in this situation
because SplitPanesWrapper uses the parent element as a reference point.
-->
{#if selectedTab === 'logs'}
<SplitPanesWrapper>
<Splitpanes horizontal>
{#if previewJob?.is_flow_step == false && previewJob?.flow_status && !(typeof previewJob.flow_status == 'object' && '_metadata' in previewJob.flow_status)}
<Pane class="relative">
<WorkflowTimeline
flow_status={asWorkflowStatus(previewJob.flow_status)}
flowDone={previewJob.type == 'CompletedJob'}
/>
</Pane>
{/if}
<Pane class="relative">
<LogViewer
jobId={previewJob?.id}
duration={previewJob?.['duration_ms']}
mem={previewJob?.['mem_peak']}
content={previewJob?.logs}
isLoading={previewJob?.['running'] == false && previewIsLoading}
tag={previewJob?.tag}
/>
</Pane>
<Pane>
<slot></slot>
{#if previewJob != undefined && 'result' in previewJob}
<div class="relative w-full h-full p-2">
<div class="relative">
<DisplayResult
bind:forceJson
workspaceId={previewJob?.workspace_id}
jobId={previewJob?.id}
result={previewJob.result}
>
<svelte:fragment slot="copilot-fix">
{#if lang && editor && diffEditor && args && previewJob?.result && typeof previewJob?.result == 'object' && `error` in previewJob?.result && previewJob?.result.error}
<ScriptFix
error={JSON.stringify(previewJob.result.error)}
{lang}
{editor}
{diffEditor}
{args}
/>
<svelte:fragment slot="content">
<div class="grow min-h-0">
{#if selectedTab === 'logs'}
<SplitPanesWrapper>
<Splitpanes horizontal>
{#if previewJob?.is_flow_step == false && previewJob?.flow_status && !(typeof previewJob.flow_status == 'object' && '_metadata' in previewJob.flow_status)}
<Pane class="relative">
<WorkflowTimeline
flow_status={asWorkflowStatus(previewJob.flow_status)}
flowDone={previewJob.type == 'CompletedJob'}
/>
</Pane>
{/if}
<Pane class="relative">
<LogViewer
jobId={previewJob?.id}
duration={previewJob?.['duration_ms']}
mem={previewJob?.['mem_peak']}
content={previewJob?.logs}
isLoading={previewJob?.['running'] == false && previewIsLoading}
tag={previewJob?.tag}
/>
</Pane>
<Pane>
<slot />
{#if previewJob != undefined && 'result' in previewJob}
<div class="relative w-full h-full p-2">
<div class="relative">
<DisplayResult
bind:forceJson
workspaceId={previewJob?.workspace_id}
jobId={previewJob?.id}
result={previewJob.result}
>
<svelte:fragment slot="copilot-fix">
{#if lang && editor && diffEditor && args && previewJob?.result && typeof previewJob?.result == 'object' && `error` in previewJob?.result && previewJob?.result.error}
<ScriptFix
error={JSON.stringify(previewJob.result.error)}
{lang}
{editor}
{diffEditor}
{args}
/>
{/if}
</svelte:fragment>
</DisplayResult>
</div>
</div>
{:else}
<div class="text-sm text-tertiary p-2 flex justify-between items-center">
<span>
{#if previewIsLoading}
<Loader2 class="animate-spin" />
{:else}
Test to see the result here
{/if}
</svelte:fragment>
</DisplayResult>
</div>
</div>
{:else}
<div class="text-sm text-tertiary p-2 flex justify-between items-center">
<span>
{#if previewIsLoading}
<Loader2 class="animate-spin" />
{:else}
Test to see the result here
{/if}
</span>
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/rich_display_rendering"
>
The result renderer in Windmill supports rich display rendering, allowing you to
customize the display format of your results.
</Tooltip>
</div>
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
{/if}
{#if selectedTab === 'history'}
<div>
<DataTable size="xs" noBorder>
<Head>
<tr>
<Cell first>Id</Cell>
<Cell>Created at</Cell>
<Cell>Success</Cell>
<Cell>Result</Cell>
<Cell>Code</Cell>
<Cell last>Logs</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each pastPreviews as { id, created_at, success }}
<tr>
<Cell first>
<a
class="pr-3"
href="{base}/run/{id}?workspace={workspace ?? $workspaceStore}"
target="_blank">{id.substring(30)}</a
>
</Cell>
<Cell>{displayDate(created_at)}</Cell>
<Cell>
{#if success}
<CheckCircle2 size={10} class="text-green-600" />
{:else}
<XCircle size={10} class="text-red-700" />
{/if}
</Cell>
<Cell>
<button
class="text-xs"
on:click|preventDefault={() => {
openDrawer({ mode: 'json', content: undefined, title: 'Result' })
JobService.getCompletedJobResult({
workspace: workspace ?? $workspaceStore ?? 'NO_W',
id
}).then((res) => {
drawerContent && (drawerContent.content = res)
})
}}
>
See Result
</button>
</Cell>
<Cell>
<button
class="text-xs"
on:click|preventDefault={async () => {
const code = (
await JobService.getCompletedJob({
workspace: workspace ?? $workspaceStore ?? 'NO_W',
id
})
).raw_code
openDrawer({
mode: lang ?? 'plain',
content: String(code),
title: `Code ${lang}`
})
}}
>
View code
</button>
</Cell>
<Cell last>
<button
class="text-xs"
on:click|preventDefault={async () => {
const logs = await (
await fetch(
OpenAPI.BASE + `/w/${workspace ?? $workspaceStore}/jobs_u/get_logs/${id}`
)
).text()
console.log(logs)
openDrawer({ mode: 'plain', content: String(logs), title: `Logs for ${id}` })
}}
>
View logs
</button>
</Cell>
</tr>
{/each}
</tbody>
</DataTable>
</span>
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/rich_display_rendering"
>
The result renderer in Windmill supports rich display rendering, allowing you
to customize the display format of your results.
</Tooltip>
</div>
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
{/if}
{#if selectedTab === 'history'}
<div>
<DataTable size="xs" noBorder>
<Head>
<tr>
<Cell first>Id</Cell>
<Cell>Created at</Cell>
<Cell>Success</Cell>
<Cell>Result</Cell>
<Cell>Code</Cell>
<Cell last>Logs</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each pastPreviews as { id, created_at, success }}
<tr>
<Cell first>
<a
class="pr-3"
href="{base}/run/{id}?workspace={workspace ?? $workspaceStore}"
target="_blank">{id.substring(30)}</a
>
</Cell>
<Cell>{displayDate(created_at)}</Cell>
<Cell>
{#if success}
<CheckCircle2 size={10} class="text-green-600" />
{:else}
<XCircle size={10} class="text-red-700" />
{/if}
</Cell>
<Cell>
<button
class="text-xs"
on:click|preventDefault={() => {
openDrawer({ mode: 'json', content: undefined, title: 'Result' })
JobService.getCompletedJobResult({
workspace: workspace ?? $workspaceStore ?? 'NO_W',
id
}).then((res) => {
drawerContent && (drawerContent.content = res)
})
}}
>
See Result
</button>
</Cell>
<Cell>
<button
class="text-xs"
on:click|preventDefault={async () => {
const code = (
await JobService.getCompletedJob({
workspace: workspace ?? $workspaceStore ?? 'NO_W',
id
})
).raw_code
openDrawer({
mode: lang ?? 'plain',
content: String(code),
title: `Code ${lang}`
})
}}
>
View code
</button>
</Cell>
<Cell last>
<button
class="text-xs"
on:click|preventDefault={async () => {
const logs = await (
await fetch(
OpenAPI.BASE +
`/w/${workspace ?? $workspaceStore}/jobs_u/get_logs/${id}`
)
).text()
console.log(logs)
openDrawer({
mode: 'plain',
content: String(logs),
title: `Logs for ${id}`
})
}}
>
View logs
</button>
</Cell>
</tr>
{/each}
</tbody>
</DataTable>
</div>
{/if}
{#if selectedTab === 'captures'}
<slot name="capturesTab" />
{/if}
</div>
{/if}
</svelte:fragment>
</Tabs>
</svelte:fragment>
</Tabs>
</div>
+31 -11
View File
@@ -1,4 +1,4 @@
import type { TriggersCount } from '$lib/gen'
import type { CaptureTriggerKind, TriggersCount } from '$lib/gen'
import type { Writable } from 'svelte/store'
export type ScheduleTrigger = {
@@ -10,19 +10,12 @@ export type ScheduleTrigger = {
}
export type TriggerContext = {
selectedTrigger: Writable<
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'scheduledPoll'
| 'kafka'
>
selectedTrigger: Writable<TriggerKind>
primarySchedule: Writable<ScheduleTrigger | undefined | false>
triggersCount: Writable<TriggersCount | undefined>
simplifiedPoll: Writable<boolean | undefined>
defaultValues: Writable<Record<string, any> | undefined>
captureOn: Writable<boolean | undefined>
}
export function setScheduledPollSchedule(
@@ -45,3 +38,30 @@ export function setScheduledPollSchedule(
}
})
}
export type TriggerKind =
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'scheduledPoll'
| 'kafka'
export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): TriggerKind {
switch (kind) {
case 'webhook':
return 'webhooks'
case 'email':
return 'emails'
case 'http':
return 'routes'
case 'websocket':
return 'websockets'
case 'kafka':
return 'kafka'
default:
throw new Error(`Unknown CaptureTriggerKind: ${kind}`)
}
}
@@ -0,0 +1,81 @@
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Button } from '$lib/components/common'
import { Webhook, Route, Unplug, Mail } from 'lucide-svelte'
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
import { enterpriseLicense } from '$lib/stores'
import { type CaptureTriggerKind } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import { captureTriggerKindToTriggerKind } from '../triggers'
import CaptureIcon from './CaptureIcon.svelte'
let isOpen = false
const dispatch = createEventDispatcher()
function handleClick(kind: CaptureTriggerKind) {
dispatch('openTriggers', {
kind: captureTriggerKindToTriggerKind(kind),
config: {}
})
isOpen = false
}
</script>
<Popover closeButton={false} bind:open={isOpen}>
<svelte:fragment slot="trigger">
<Button color="dark" btnClasses="rounded-l-none" wrapperClasses="h-full" nonCaptureEvent>
<CaptureIcon />
</Button>
</svelte:fragment>
<svelte:fragment slot="content">
<div class="flex flex-col bg-surface">
<button
class="hover:bg-surface-hover p-2 transition-colors duration-150"
on:click={() => handleClick('webhook')}
>
<div class="flex flex-row items-center gap-2">
<Webhook size={16} />
<p class="text-xs text-secondary">Webhook</p>
</div>
</button>
<button
class="hover:bg-surface-hover p-2 transition-colors duration-150"
on:click={() => handleClick('http')}
>
<div class="flex flex-row items-center gap-2">
<Route size={16} />
<p class="text-xs text-secondary">HTTP</p>
</div>
</button>
<button
class="hover:bg-surface-hover p-2 transition-colors duration-150"
on:click={() => handleClick('websocket')}
>
<div class="flex flex-row items-center gap-2">
<Unplug size={16} />
<p class="text-xs text-secondary">Websocket</p>
</div>
</button>
<button
class="hover:bg-surface-hover p-2 transition-colors duration-150"
on:click={() => handleClick('email')}
>
<div class="flex flex-row items-center gap-2">
<Mail size={16} />
<p class="text-xs text-secondary">Email</p>
</div>
</button>
<button
disabled={!$enterpriseLicense}
class="hover:bg-surface-hover p-2 transition-colors duration-150"
on:click={() => handleClick('kafka')}
>
<div class="flex flex-row items-center gap-2">
<KafkaIcon size={16} />
<p class="text-xs text-secondary">Kafka</p>
</div>
</button>
</div>
</svelte:fragment>
</Popover>
@@ -0,0 +1,17 @@
<script>
import { ScanIcon } from 'lucide-svelte'
</script>
<div class="relative">
<ScanIcon size="14" />
<div class="absolute inset-0 flex items-center justify-center">
<svg
class="text-red-500"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="3" />
</svg>
</div>
</div>
@@ -0,0 +1,91 @@
<script context="module" lang="ts">
export type CaptureInfo = {
active: boolean
hasPreprocessor: boolean
canHavePreprocessor: boolean
isFlow: boolean
path: string
connectionInfo: ConnectionInfo | undefined
}
</script>
<script lang="ts">
import { slide } from 'svelte/transition'
import AnimatedButton from '../common/button/AnimatedButton.svelte'
import Button from '../common/button/Button.svelte'
import { CircleStop } from 'lucide-svelte'
import ConnectionIndicator, {
type ConnectionInfo
} from '../common/alert/ConnectionIndicator.svelte'
import CaptureTable from './CaptureTable.svelte'
import { createEventDispatcher } from 'svelte'
import type { CaptureTriggerKind } from '$lib/gen'
import CaptureIcon from './CaptureIcon.svelte'
import Tooltip from '../Tooltip.svelte'
export let disabled: boolean
export let captureType: CaptureTriggerKind
export let captureInfo: CaptureInfo
export let captureTable: CaptureTable | undefined
const dispatch = createEventDispatcher<{
captureToggle: undefined
}>()
</script>
<div transition:slide class="pb-4">
<div class="border p-4 rounded-lg">
<div class="flex flex-col gap-1 mb-4">
<div class="flex flex-row items-center justify-start gap-1">
<AnimatedButton animate={captureInfo.active} baseRadius="6px" wrapperClasses="ml-[-2px]">
<Button
size="xs2"
on:click={() => dispatch('captureToggle')}
variant="border"
{disabled}
color="light"
startIcon={{ icon: captureInfo.active ? CircleStop : CaptureIcon }}
btnClasses={captureInfo.active ? 'text-blue-500 hover:text-blue-500' : ''}
>
{captureInfo.active ? 'Stop' : 'Start capturing'}
</Button>
</AnimatedButton>
{#if captureInfo.active}
<ConnectionIndicator connectionInfo={captureInfo.connectionInfo} />
{:else}
<Tooltip>
Start capturing to test your runnables with real data. Once active, all incoming
payloads will be captured and displayed below, allowing you to test your runnables
effectively.
</Tooltip>
{/if}
</div>
{#if disabled}
<div class="text-sm font-normal text-red-600 dark:text-red-400" transition:slide>
Enter a valid configuration to start capturing.
</div>
{/if}
</div>
{#if $$slots.default}
<div class:opacity-50={disabled || !captureInfo.active} class="flex flex-col gap-4 mb-4">
<slot />
</div>
{/if}
<CaptureTable
bind:this={captureTable}
{captureType}
hasPreprocessor={captureInfo.hasPreprocessor}
canHavePreprocessor={captureInfo.canHavePreprocessor}
isFlow={captureInfo.isFlow}
path={captureInfo.path}
canEdit={true}
on:applyArgs
on:updateSchema
on:addPreprocessor
maxHeight={300}
/>
</div>
</div>
@@ -0,0 +1,245 @@
<script lang="ts">
import Label from '../Label.svelte'
import { Clipboard, Info, Trash2, Plus } from 'lucide-svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import { copyToClipboard } from '$lib/utils'
import Button from '../common/button/Button.svelte'
import CustomPopover from '../CustomPopover.svelte'
import { convert } from '@redocly/json-to-json-schema'
import SchemaViewer from '../SchemaViewer.svelte'
import { isObject } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { type TriggerKind } from '../triggers'
import { CaptureService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { type CaptureTriggerKind } from '$lib/gen'
import type { Capture } from '$lib/gen'
import { captureTriggerKindToTriggerKind } from '../triggers'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
export let path: string
export let hasPreprocessor = false
export let canHavePreprocessor = false
export let isFlow = false
export let captureType: CaptureTriggerKind | 'all' = 'webhook'
export let headless = false
export let addButton = false
export let hideCapturesWhenEmpty = false
export let canEdit = false
export let maxHeight: number | undefined = undefined
let captures: Capture[] = []
let testKind: 'preprocessor' | 'main' = 'main'
$: hasPreprocessor && (testKind = 'preprocessor')
let deleteLoading: number | null = null
async function deleteCapture(id: number) {
deleteLoading = id
try {
await CaptureService.deleteCapture({
workspace: $workspaceStore!,
id
})
refreshCaptures()
} finally {
deleteLoading = null
}
}
const dispatch = createEventDispatcher<{
openTriggers: {
kind: TriggerKind
config: Record<string, any>
}
applyArgs: {
kind: 'main' | 'preprocessor'
args: Record<string, any> | undefined
}
addPreprocessor: null
updateSchema: {
schema: any
redirect: boolean
}
}>()
export async function refreshCaptures() {
captures = await CaptureService.listCaptures({
workspace: $workspaceStore!,
runnableKind: isFlow ? 'flow' : 'script',
path,
triggerKind: captureType !== 'all' ? captureType : undefined
})
}
refreshCaptures()
</script>
{#if captures.length > 0 || !hideCapturesWhenEmpty}
<Label
label="Captures"
{headless}
class={twMerge(
'flex flex-col h-full divide-y gap-1',
maxHeight ? `max-h-[${maxHeight}px]` : ''
)}
>
<svelte:fragment slot="header">
{#if addButton && captureType !== 'all'}
<Button
size="xs2"
color="light"
variant="contained"
iconOnly
startIcon={{ icon: Plus }}
on:click={() => {
if (captureType !== 'all') {
dispatch('openTriggers', {
kind: captureTriggerKindToTriggerKind(captureType),
config: {}
})
}
}}
/>
{/if}
</svelte:fragment>
<svelte:fragment slot="action">
{#if canHavePreprocessor}
<div>
<ToggleButtonGroup bind:selected={testKind} class="h-full">
<ToggleButton value="main" label={isFlow ? 'Flow' : 'Main'} small />
<ToggleButton
value="preprocessor"
label="Preprocessor"
small
tooltip="When the runnable has a preprocessor, it receives additional information about the request"
/>
</ToggleButtonGroup>
</div>
{/if}
</svelte:fragment>
<div class="flex flex-col gap-1 pt-2 grow overflow-y-auto">
{#if captures.length === 0}
<div class="text-xs text-secondary">
{captureType === 'all' ? 'No captures yet' : `No ${captureType} captures yet`}
</div>
{:else}
{#each captures as capture}
{@const payload = isObject(capture.payload) ? capture.payload : {}}
{@const triggerExtra = isObject(capture.trigger_extra) ? capture.trigger_extra : {}}
{@const payloadData =
testKind === 'preprocessor'
? {
...payload,
...triggerExtra
}
: payload}
{@const schema =
isFlow && testKind === 'main'
? { required: [], properties: {}, ...convert(payloadData) }
: {}}
<div class="flex flex-row gap-1" transition:slide>
<div class="text-xs border p-2 rounded-md overflow-auto grow whitespace-nowrap">
{JSON.stringify(payloadData)}
</div>
<Button
size="xs2"
color="light"
variant="border"
on:click={() => {
copyToClipboard(JSON.stringify(payloadData))
}}
iconOnly
startIcon={{ icon: Clipboard }}
/>
{#if isFlow && testKind === 'main'}
<CustomPopover>
<Button
size="xs"
color="light"
variant="border"
on:click={() => {
dispatch('updateSchema', { schema, redirect: true })
}}
wrapperClasses="h-full"
>
Apply schema
</Button>
<svelte:fragment slot="overlay">
{#if schema}
<div class="min-w-[400px]">
<SchemaViewer {schema} />
</div>
{/if}
</svelte:fragment>
</CustomPopover>
{/if}
{#if testKind === 'preprocessor' && !hasPreprocessor}
<CustomPopover noPadding>
<Button
size="xs"
color="dark"
disabled
endIcon={{
icon: Info
}}
wrapperClasses="h-full"
>
Apply args
</Button>
<svelte:fragment slot="overlay">
<div class="text-sm p-2 flex flex-col gap-1 items-start">
<p>You need to add a preprocessor to use preprocessor captures as args</p>
<Button
size="xs"
color="dark"
on:click={() => {
dispatch('addPreprocessor')
}}
>
Add preprocessor
</Button>
</div>
</svelte:fragment>
</CustomPopover>
{:else}
<Button
size="xs"
color="dark"
on:click={() => {
if (isFlow && testKind === 'main') {
dispatch('updateSchema', { schema, redirect: false })
}
dispatch('applyArgs', {
kind: testKind,
args: payloadData
})
}}
disabled={testKind === 'preprocessor' && !hasPreprocessor}
>
{isFlow && testKind === 'main' ? 'Apply schema and args' : 'Apply args'}
</Button>
{/if}
{#if canEdit}
<Button
size="xs2"
color="red"
iconOnly
startIcon={{ icon: Trash2 }}
loading={deleteLoading === capture.id}
on:click={() => {
deleteCapture(capture.id)
}}
/>
{/if}
</div>
{/each}
{/if}
</div>
</Label>
{/if}
@@ -0,0 +1,255 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { CaptureService, type CaptureConfig, type CaptureTriggerKind } from '$lib/gen'
import { onDestroy } from 'svelte'
import { capitalize, isObject, sendUserToast, sleep } from '$lib/utils'
import { isCloudHosted } from '$lib/cloud'
import Alert from '../common/alert/Alert.svelte'
import RouteEditorConfigSection from './RouteEditorConfigSection.svelte'
import WebsocketEditorConfigSection from './WebsocketEditorConfigSection.svelte'
import WebhooksConfigSection from './WebhooksConfigSection.svelte'
import EmailTriggerConfigSection from '../details/EmailTriggerConfigSection.svelte'
import KafkaTriggersConfigSection from './KafkaTriggersConfigSection.svelte'
import type { ConnectionInfo } from '../common/alert/ConnectionIndicator.svelte'
import type { CaptureInfo } from './CaptureSection.svelte'
import CaptureTable from './CaptureTable.svelte'
export let isFlow: boolean
export let path: string
export let hasPreprocessor: boolean
export let canHavePreprocessor: boolean
export let captureType: CaptureTriggerKind = 'webhook'
export let showCapture = false
export let data: any = {}
export let connectionInfo: ConnectionInfo | undefined = undefined
export let args: Record<string, any> = {}
export let captureTable: CaptureTable | undefined = undefined
export async function setConfig() {
await CaptureService.setCaptureConfig({
requestBody: {
trigger_kind: captureType,
path,
is_flow: isFlow,
trigger_config: args && Object.keys(args).length > 0 ? args : undefined
},
workspace: $workspaceStore!
})
}
let captureActive = false
let captureConfigs: {
[key: string]: CaptureConfig
} = {}
async function getCaptureConfigs() {
const captureConfigsList = await CaptureService.getCaptureConfigs({
workspace: $workspaceStore!,
runnableKind: isFlow ? 'flow' : 'script',
path
})
captureConfigs = captureConfigsList.reduce((acc, c) => {
acc[c.trigger_kind] = c
return acc
}, {})
if ((captureType === 'websocket' || captureType === 'kafka') && captureActive) {
const config = captureConfigs[captureType]
if (config && config.error) {
const serverEnabled = getServerEnabled(config)
if (!serverEnabled) {
sendUserToast('Capture was stopped because of error: ' + config.error, true)
captureActive = false
}
}
}
return captureConfigs
}
getCaptureConfigs().then((captureConfigs) => setDefaultArgs(captureConfigs))
async function capture() {
let i = 0
captureActive = true
while (captureActive) {
if (i % 3 === 0) {
await CaptureService.pingCaptureConfig({
workspace: $workspaceStore!,
triggerKind: captureType,
runnableKind: isFlow ? 'flow' : 'script',
path
})
await getCaptureConfigs()
}
captureTable?.refreshCaptures()
i++
await sleep(1000)
}
}
function setDefaultArgs(captureConfigs: { [key: string]: CaptureConfig }) {
if (captureType in captureConfigs) {
const triggerConfig = captureConfigs[captureType].trigger_config
args = isObject(triggerConfig) ? triggerConfig : {}
} else if (captureType === 'kafka') {
args = {
...args,
brokers: [''],
topics: [''],
group_id: `windmill_consumer-${$workspaceStore}-${path.replaceAll('/', '__')}`
}
} else if (captureType === 'http') {
args = {
route_path: '',
http_method: 'post'
}
} else {
args = {}
}
}
onDestroy(() => {
captureActive = false
})
function getServerEnabled(config: CaptureConfig) {
return (
!!config.last_server_ping &&
new Date(config.last_server_ping).getTime() > new Date().getTime() - 15 * 1000
)
}
export async function handleCapture() {
if (!captureActive) {
await setConfig()
capture()
} else {
captureActive = false
}
}
let config: CaptureConfig | undefined
$: config = captureConfigs[captureType]
let cloudDisabled = (captureType === 'websocket' || captureType === 'kafka') && isCloudHosted()
function updateConnectionInfo(config: CaptureConfig | undefined, captureActive: boolean) {
if ((captureType === 'websocket' || captureType === 'kafka') && config && captureActive) {
const serverEnabled = getServerEnabled(config)
const connected = serverEnabled && !config.error
const message = connected
? `${capitalize(captureType)} is connected`
: `${capitalize(captureType)} is not connected${config.error ? ': ' + config.error : ''}`
connectionInfo = {
connected,
message
}
} else {
connectionInfo = undefined
}
}
$: updateConnectionInfo(config, captureActive)
let captureInfo: CaptureInfo
$: captureInfo = {
active: captureActive,
hasPreprocessor,
canHavePreprocessor,
isFlow,
path,
connectionInfo
}
$: args && (captureActive = false)
</script>
<div class="flex flex-col gap-4 w-full">
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
{capitalize(captureType)} triggers are disabled in the multi-tenant cloud.
</Alert>
{:else if captureType === 'websocket'}
<WebsocketEditorConfigSection
can_write={true}
headless={true}
bind:url={args.url}
bind:url_runnable_args={args.url_runnable_args}
{showCapture}
{captureInfo}
bind:captureTable
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={() => {
handleCapture()
}}
/>
{:else if captureType === 'webhook'}
<WebhooksConfigSection
{isFlow}
{path}
hash={data?.hash}
token={data?.token}
{args}
scopes={data?.scopes}
{showCapture}
{captureInfo}
bind:captureTable
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={() => {
handleCapture()
}}
/>
{:else if captureType === 'http'}
<RouteEditorConfigSection
{showCapture}
can_write={true}
bind:route_path={args.route_path}
bind:http_method={args.http_method}
headless
{captureInfo}
bind:captureTable
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={() => {
handleCapture()
}}
/>
{:else if captureType === 'email'}
<EmailTriggerConfigSection
hash={data?.hash}
token={data?.token}
{path}
{isFlow}
userSettings={data?.userSettings}
emailDomain={data?.emailDomain}
{showCapture}
{captureInfo}
bind:captureTable
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={() => {
handleCapture()
}}
/>
{:else if captureType === 'kafka'}
<KafkaTriggersConfigSection
headless={true}
bind:args
staticInputDisabled={false}
{showCapture}
{captureInfo}
bind:captureTable
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={() => {
handleCapture()
}}
/>
{/if}
</div>
@@ -9,10 +9,14 @@
drawer?.openEdit(ePath, isFlow)
}
export async function openNew(is_flow: boolean, initial_script_path?: string) {
export async function openNew(
is_flow: boolean,
initial_script_path?: string,
defaultValues?: Record<string, any>
) {
open = true
await tick()
drawer?.openNew(is_flow, initial_script_path)
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
let drawer: KafkaTriggerEditorInner
@@ -10,11 +10,10 @@
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import Section from '$lib/components/Section.svelte'
import { Loader2, Save, X, Plus } from 'lucide-svelte'
import { Loader2, Save } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
import Toggle from '../Toggle.svelte'
import { fade } from 'svelte/transition'
import ResourcePicker from '../ResourcePicker.svelte'
import KafkaTriggersConfigSection from './KafkaTriggersConfigSection.svelte'
let drawer: Drawer
let is_flow: boolean = false
@@ -26,14 +25,12 @@
let fixedScriptPath = ''
let path: string = ''
let pathError = ''
let kafka_resource_path = ''
let group_id = ''
let topics: string[] = ['']
let dirtyGroupId = false
let enabled = false
let dirtyPath = false
let can_write = true
let drawerLoading = true
let defaultValues: Record<string, any> | undefined = undefined
let args: Record<string, any> = {}
const dispatch = createEventDispatcher()
@@ -47,7 +44,6 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
dirtyGroupId = false
await loadTrigger()
} catch (err) {
sendUserToast(`Could not load kafka trigger: ${err}`, true)
@@ -56,23 +52,27 @@
}
}
export async function openNew(nis_flow: boolean, fixedScriptPath_?: string) {
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
nDefaultValues?: Record<string, any>
) {
drawerLoading = true
try {
drawer?.openDrawer()
is_flow = nis_flow
edit = false
itemKind = nis_flow ? 'flow' : 'script'
kafka_resource_path = ''
group_id = ''
topics = ['']
dirtyGroupId = false
args.kafka_resource_path = nDefaultValues?.kafka_resource_path ?? ''
args.group_id = nDefaultValues?.group_id ?? ''
args.topics = nDefaultValues?.topics ?? ['']
initialScriptPath = ''
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = ''
initialPath = ''
dirtyPath = false
defaultValues = nDefaultValues
} finally {
drawerLoading = false
}
@@ -88,9 +88,9 @@
is_flow = s.is_flow
path = s.path
kafka_resource_path = s.kafka_resource_path
group_id = s.group_id
topics = s.topics
args.kafka_resource_path = s.kafka_resource_path
args.group_id = s.group_id
args.topics = s.topics
enabled = s.enabled
can_write = canWrite(s.path, s.extra_perms, $userStore)
@@ -105,9 +105,9 @@
path,
script_path,
is_flow,
kafka_resource_path,
group_id,
topics
kafka_resource_path: args.kafka_resource_path,
group_id: args.group_id,
topics: args.topics
}
})
sendUserToast(`Kafka trigger ${path} updated`)
@@ -119,9 +119,9 @@
script_path,
is_flow,
enabled: true,
kafka_resource_path,
group_id,
topics
kafka_resource_path: args.kafka_resource_path,
group_id: args.group_id,
topics: args.topics
}
})
sendUserToast(`Kafka trigger ${path} created`)
@@ -133,12 +133,21 @@
drawer.closeDrawer()
}
$: topicsError = topics.some((b) => /[^[a-zA-Z0-9-_.]/.test(b)) ? 'Invalid topics' : ''
$: groupIdError = /[^a-zA-Z0-9-_.]/.test(group_id) ? 'Invalid group ID' : ''
function useDefaultValues() {
if (args.kafka_resource_path && args.kafka_resource_path != '') {
return false
}
if (!defaultValues) {
return false
}
return (
defaultValues.brokers &&
defaultValues.brokers.length > 0 &&
defaultValues.brokers.some((broker: string) => broker.trim() !== '')
)
}
$: !dirtyGroupId &&
path &&
(group_id = `windmill_consumer-${$workspaceStore}-${path.replaceAll('/', '__')}`)
let isValid = false
</script>
<Drawer size="800px" bind:this={drawer}>
@@ -172,15 +181,7 @@
{#if can_write}
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' ||
emptyString(script_path) ||
emptyString(kafka_resource_path) ||
topics.length < 1 ||
topics.some((t) => emptyString(t)) ||
topicsError != '' ||
emptyString(group_id) ||
groupIdError != '' ||
!can_write}
disabled={pathError != '' || emptyString(script_path) || !can_write || !isValid}
on:click={updateTrigger}
>
Save
@@ -214,81 +215,11 @@
</Label>
</div>
<Section label="Kafka">
<div class="flex flex-col w-full gap-4">
<div class="block grow w-full">
<div class="text-secondary text-sm mb-1">
Resource
<Required required={true} />
</div>
<ResourcePicker resourceType="kafka" bind:value={kafka_resource_path} />
</div>
<label class="block grow w-full">
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>
Topics
<Required required={true} />
</div>
</div>
<div class="flex flex-col gap-4 mt-1">
{#each topics as v, i}
<div class="flex w-full gap-2 items-center">
<input type="text" bind:value={v} />
<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={() => {
topics = topics.filter((_, index) => index !== i)
}}
class:hidden={topics.length === 1}
>
<X size={14} />
</button>
</div>
{/each}
<div class="flex items-baseline">
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
if (topics == undefined || !Array.isArray(topics)) {
topics = []
}
topics = topics.concat('')
}}
startIcon={{ icon: Plus }}
>
Add topic
</Button>
</div>
</div>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5">
{topicsError}
</div>
</label>
<label class="block grow w-full">
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>
Group ID
<Required required={true} />
</div>
</div>
<div class="mt-1">
<input type="text" bind:value={group_id} on:focus={() => (dirtyGroupId = true)} />
</div>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5">
{groupIdError}
</div>
</label>
</div>
</Section>
<KafkaTriggersConfigSection
bind:args
bind:isValid
defaultValues={useDefaultValues() ? defaultValues : undefined}
/>
<Section label="Runnable">
<p class="text-xs mb-1 text-tertiary">
@@ -0,0 +1,222 @@
<script lang="ts">
import ResourcePicker from '../ResourcePicker.svelte'
import { Boxes } from 'lucide-svelte'
import Section from '$lib/components/Section.svelte'
import Subsection from '$lib/components/Subsection.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import SchemaForm from '../SchemaForm.svelte'
import CaptureSection, { type CaptureInfo } from './CaptureSection.svelte'
import CaptureTable from './CaptureTable.svelte'
export let defaultValues: Record<string, any> | undefined = undefined
export let headless: boolean = false
export let args: Record<string, any> = {}
export let staticInputDisabled: boolean = true
export let showCapture: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
export let isValid: boolean = false
let selected: 'resource' | 'static' = staticInputDisabled ? 'resource' : 'static'
const connnectionSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
brokers: {
type: 'array',
items: {
type: 'string'
},
nullable: false,
title: 'Brokers'
},
security: {
type: 'object',
title: 'Security',
oneOf: [
{
type: 'object',
title: 'PLAINTEXT',
properties: {
label: {
enum: ['PLAINTEXT'],
type: 'string'
}
}
},
{
type: 'object',
order: ['mechanism', 'username', 'password'],
title: 'SASL_PLAINTEXT',
required: ['mechanism', 'username', 'password'],
properties: {
label: {
enum: ['SASL_PLAINTEXT'],
type: 'string'
},
password: {
type: 'string',
password: true
},
username: {
type: 'string'
},
mechanism: {
enum: ['PLAIN', 'SCRAM-SHA-256', 'SCRAM-SHA-512'],
type: 'string',
disableCreate: true
}
}
},
{
type: 'object',
title: 'SSL',
properties: {
label: {
enum: ['SSL'],
type: 'string'
}
}
},
{
type: 'object',
order: ['mechanism', 'username', 'password'],
title: 'SASL_SSL',
required: ['mechanism', 'username', 'password'],
properties: {
label: {
enum: ['SASL_SSL'],
type: 'string'
},
password: {
type: 'string',
password: true
},
username: {
type: 'string'
},
mechanism: {
enum: ['PLAIN', 'SCRAM-SHA-256', 'SCRAM-SHA-512'],
type: 'string',
disableCreate: true
}
}
}
]
}
},
required: ['brokers', 'security']
}
const argsSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
topics: {
type: 'array',
items: {
type: 'string'
},
nullable: false,
title: 'Topics'
},
group_id: {
type: 'string',
title: 'Group ID',
pattern: '^[a-zA-Z0-9-_.]+$',
customErrorMessage: 'Invalid group ID'
}
},
required: ['topics', 'group_id']
}
let connectionValid = false
let otherArgsValid = false
$: isValid =
(selected === 'resource'
? !!args.kafka_resource_path
: connectionValid &&
args.brokers &&
args.brokers.length > 0 &&
args.brokers.every((b) => b.length > 0)) &&
otherArgsValid &&
args.topics &&
args.topics.length > 0 &&
args.topics.every((b) => /^[a-zA-Z0-9-_.]+$/.test(b))
$: args.kafka_resource_path && (selected = 'resource')
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
captureType="kafka"
disabled={!isValid}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
bind:captureTable
/>
{/if}
<Section label="Kafka" {headless}>
<div class="flex flex-col w-full gap-4">
<div class="block grow w-full">
<Subsection label="Connection">
<svelte:fragment slot="header">
{#if !staticInputDisabled}
<ToggleButtonGroup
bind:selected
class="h-full"
on:selected={(ev) => {
if (ev.detail === 'static') {
delete args.kafka_resource_path
args.brokers = ['']
args.security = {
label: 'PLAINTEXT'
}
} else {
delete args.brokers
delete args.security
}
}}
>
<ToggleButton value="static" label="Static" small={true} />
<ToggleButton value="resource" label="Resource" icon={Boxes} small={true} />
</ToggleButtonGroup>
{/if}
</svelte:fragment>
{#if selected === 'resource'}
<ResourcePicker
resourceType="kafka"
bind:value={args.kafka_resource_path}
{defaultValues}
/>
{:else}
<SchemaForm
schema={connnectionSchema}
bind:args
bind:isValid={connectionValid}
lightHeader={true}
/>
{/if}
</Subsection>
</div>
<div class="block grow w-full">
<Subsection headless={true}>
<SchemaForm
schema={argsSchema}
bind:args
bind:isValid={otherArgsValid}
lightHeader={true}
/>
</Subsection>
</div>
</div>
</Section>
</div>
@@ -1,26 +1,41 @@
<script lang="ts">
import { Button } from '../common'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { KafkaTriggerService, type KafkaTrigger } from '$lib/gen'
import Skeleton from '../common/skeleton/Skeleton.svelte'
import { canWrite } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
import type { TriggerContext } from '../triggers'
import { getContext } from 'svelte'
import { getContext, onMount } from 'svelte'
import KafkaTriggerEditor from './KafkaTriggerEditor.svelte'
import { isCloudHosted } from '$lib/cloud'
import KafkaIcon from '../icons/KafkaIcon.svelte'
import Description from '$lib/components/Description.svelte'
import TriggersEditorSection from './TriggersEditorSection.svelte'
import Section from '$lib/components/Section.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import Description from '../Description.svelte'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
let kafkaTriggerEditor: KafkaTriggerEditor
$: path && loadTriggers()
const { triggersCount } = getContext<TriggerContext>('TriggerContext')
const { triggersCount, selectedTrigger, defaultValues } =
getContext<TriggerContext>('TriggerContext')
onMount(() => {
if (
defaultValues &&
$selectedTrigger === 'kafka' &&
Object.keys($defaultValues ?? {}).length > 0
) {
kafkaTriggerEditor.openNew(isFlow, path, $defaultValues)
defaultValues.set(undefined)
}
})
let kafkaTriggers: (KafkaTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
@@ -39,6 +54,15 @@
console.error('impossible to load kafka triggers', e)
}
}
let data = {
kafkaTriggers: [],
newItem
}
function saveTrigger(path: string, args?: Record<string, any>) {
kafkaTriggerEditor?.openNew(isFlow, path, args)
}
</script>
<KafkaTriggerEditor
@@ -48,38 +72,41 @@
bind:this={kafkaTriggerEditor}
/>
<div class="flex flex-col w-full gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/kafka_triggers">
Kafka triggers execute scripts and flows in response to messages published to Kafka topics.
</Description>
{#if !$enterpriseLicense}
<Alert title="EE Only" type="warning" size="xs">
Kafka triggers are an enterprise only feature.
</Alert>
{:else if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
Kafka triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<div class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/kafka_triggers">
Kafka triggers execute scripts and flows in response to messages published to Kafka topics.
</Description>
<TriggersEditorSection
on:saveTrigger={(e) => {
saveTrigger(path, e.detail.config)
}}
on:applyArgs
on:addPreprocessor
cloudDisabled={false}
triggerType="kafka"
{isFlow}
{data}
{path}
{isEditor}
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
/>
{#if !$enterpriseLicense}
<Alert title="EE Only" type="warning" size="xs">
Kafka triggers are an enterprise only feature.
</Alert>
{:else if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
Kafka triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<div class="flex flex-col gap-4">
{#if newItem}
<Alert title="Triggers disabled" type="warning" size="xs">
Deploy the {isFlow ? 'flow' : 'script'} to add kafka triggers.
</Alert>
{:else}
<Button
on:click={() => kafkaTriggerEditor?.openNew(isFlow, path)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: KafkaIcon }}
>
New Kafka trigger
</Button>
{#if kafkaTriggers}
{#if !newItem}
{#if kafkaTriggers}
<Section label="Kafka Triggers">
{#if kafkaTriggers.length == 0}
<div class="text-xs text-secondary"> No kafka triggers </div>
<div class="text-xs text-secondary text-center"> No kafka triggers </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each kafkaTriggers as kafkaTrigger (kafkaTrigger.path)}
@@ -104,10 +131,10 @@
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</Section>
{:else}
<Skeleton layout={[[8]]} />
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
@@ -9,10 +9,14 @@
drawer?.openEdit(ePath, isFlow)
}
export async function openNew(is_flow: boolean, initial_script_path?: string) {
export async function openNew(
is_flow: boolean,
initial_script_path?: string,
defaultValues?: Record<string, any>
) {
open = true
await tick()
drawer?.openNew(is_flow, initial_script_path)
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
export async function getTriggers() {
@@ -0,0 +1,172 @@
<script lang="ts">
import { Alert, Badge } from '$lib/components/common'
import Required from '$lib/components/Required.svelte'
import Section from '$lib/components/Section.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import Label from '$lib/components/Label.svelte'
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
import { bash } from 'svelte-highlight/languages'
import { HttpTriggerService } from '$lib/gen'
import { page } from '$app/stores'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import type { CaptureInfo } from './CaptureSection.svelte'
import CaptureSection from './CaptureSection.svelte'
import CaptureTable from './CaptureTable.svelte'
import ClipboardPanel from '../details/ClipboardPanel.svelte'
export let dirtyRoutePath: boolean = false
export let route_path = ''
export let http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'post'
export let can_write: boolean = false
export let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined =
undefined
export let showCapture = false
export let initialRoutePath: string = ''
export let headless: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
export let isValid = false
let validateTimeout: NodeJS.Timeout | undefined = undefined
let routeError: string = ''
async function validateRoute(path: string, method: typeof http_method): Promise<void> {
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!/^[\w-:]+(\/[\w-:]+)*$/.test(path)) {
routeError = 'Endpoint not valid'
} else if (initialRoutePath !== path && (await routeExists(path, method))) {
routeError = 'Endpoint already taken'
} else {
routeError = ''
}
validateTimeout = undefined
}, 500)
}
async function routeExists(route_path: string, method: typeof http_method) {
return await HttpTriggerService.existsRoute({
workspace: $workspaceStore!,
requestBody: {
route_path,
http_method: method
}
})
}
function getHttpRoute(route_path: string | undefined) {
return `${$page.url.origin}${base}/api/r/${
isCloudHosted() ? $workspaceStore + '/' : ''
}${route_path}`
}
$: validateRoute(route_path, http_method)
$: isValid = routeError === ''
$: fullRoute = getHttpRoute(route_path)
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
captureType="http"
disabled={!isValid}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
bind:captureTable
>
<Label label="URL">
<ClipboardPanel content={fullRoute} disabled={!captureInfo.active} />
</Label>
<Label label="Example cUrl">
<CopyableCodeBlock
disabled={!captureInfo.active}
code={`curl \\
-X POST ${fullRoute} \\
-H 'Content-Type: application/json' \\
-d '{"foo": 42}'`}
language={bash}
/>
</Label>
</CaptureSection>
{/if}
<Section label="HTTP" {headless}>
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="info" title="Admin only" collapsible>
Route endpoints can only be edited by workspace admins
</Alert>
<div class="my-2" />
{/if}
<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>
Path
<Required required={true} />
</div>
<div class="text-2xs text-tertiary"> ':myparam' for path params </div>
</div>
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
autocomplete="off"
bind:value={route_path}
disabled={!($userStore?.is_admin || $userStore?.is_super_admin) || !can_write}
class={routeError === ''
? ''
: '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'}
on:input={() => {
dirtyRoutePath = true
}}
/>
</label>
<ToggleButtonGroup
class="w-auto"
bind:selected={http_method}
disabled={!($userStore?.is_admin || $userStore?.is_super_admin) ||
!can_write ||
!!static_asset_config}
>
<ToggleButton label="GET" value="get" />
<ToggleButton label="POST" value="post" />
<ToggleButton label="PUT" value="put" />
<ToggleButton label="PATCH" value="patch" />
<ToggleButton label="DELETE" value="delete" />
</ToggleButtonGroup>
<div class="flex flex-col w-full mt-2">
<div class="flex justify-start w-full">
<Badge
color="gray"
class="center-center !bg-surface-secondary !text-tertiary !w-[90px] !h-[24px] rounded-r-none border"
>
Full endpoint
</Badge>
<input
type="text"
readonly
value={fullRoute}
size={fullRoute.length || 50}
class="font-mono !text-xs max-w-[calc(100%-70px)] !w-auto !h-[24px] !py-0 !border-l-0 !rounded-l-none"
on:focus={({ currentTarget }) => {
currentTarget.select()
}}
/>
</div>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyRoutePath ? routeError : ''}</div
>
</div>
</div>
</Section>
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Alert, Badge, Button } from '$lib/components/common'
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
@@ -14,8 +14,6 @@
import Label from '$lib/components/Label.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import S3FilePicker from '../S3FilePicker.svelte'
import Toggle from '../Toggle.svelte'
import JsonEditor from '../apps/editor/settingsPanel/inputEditor/JsonEditor.svelte'
@@ -23,6 +21,7 @@
import SimpleEditor from '../SimpleEditor.svelte'
import { json } from 'svelte-highlight/languages'
import { Highlight } from 'svelte-highlight'
import RouteEditorConfigSection from './RouteEditorConfigSection.svelte'
let is_flow: boolean = false
let initialPath = ''
@@ -54,7 +53,11 @@
}
}
export async function openNew(nis_flow: boolean, fixedScriptPath_?: string) {
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>
) {
drawerLoading = true
try {
drawer?.openDrawer()
@@ -64,9 +67,9 @@
is_async = false
requires_auth = false
initialRoutePath = ''
route_path = ''
route_path = defaultValues?.route_path ?? ''
dirtyRoutePath = false
http_method = 'post'
http_method = defaultValues?.http_method ?? 'post'
initialScriptPath = ''
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
@@ -82,7 +85,7 @@
let path: string = ''
let pathError = ''
let routeError = ''
let isValid = false
let dirtyRoutePath = false
let is_async = false
let requires_auth = false
@@ -163,40 +166,6 @@
let drawer: Drawer
let dirtyPath = false
$: fullRoute = `${window.location.origin}${base}/api/r/${
isCloudHosted() ? $workspaceStore + '/' : ''
}${route_path}`
async function routeExists(route_path: string, method: typeof http_method) {
return await HttpTriggerService.existsRoute({
workspace: $workspaceStore!,
requestBody: {
route_path,
http_method
}
})
}
let validateTimeout: NodeJS.Timeout | undefined = undefined
async function validateRoute(path: string, method: typeof http_method): Promise<void> {
routeError = ''
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!/^[\w-:]+(\/[\w-:]+)*$/.test(path)) {
routeError = 'Endpoint not valid'
} else if (initialRoutePath !== path && (await routeExists(path, method))) {
routeError = 'Endpoint already taken'
} else {
routeError = ''
}
validateTimeout = undefined
}, 500)
}
$: validateRoute(route_path, http_method)
</script>
{#if static_asset_config}
@@ -220,7 +189,7 @@
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' ||
routeError != '' ||
!isValid ||
(!static_asset_config && emptyString(script_path)) ||
(static_asset_config && emptyString(static_asset_config.s3)) ||
!can_write}
@@ -250,76 +219,15 @@
</Label>
</div>
<Section label="HTTP">
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="info" title="Admin only" collapsible>
Route endpoints can only be edited by workspace admins
</Alert>
<div class="my-2" />
{/if}
<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>
Path
<Required required={true} />
</div>
<div class="text-2xs text-tertiary"> ':myparam' for path params </div>
</div>
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
autocomplete="off"
bind:value={route_path}
disabled={!($userStore?.is_admin || $userStore?.is_super_admin) || !can_write}
class={routeError === ''
? ''
: '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'}
on:input={() => {
dirtyRoutePath = true
}}
/>
</label>
<ToggleButtonGroup
class="w-auto"
bind:selected={http_method}
disabled={!($userStore?.is_admin || $userStore?.is_super_admin) ||
!can_write ||
!!static_asset_config}
>
<ToggleButton label="GET" value="get" />
<ToggleButton label="POST" value="post" />
<ToggleButton label="PUT" value="put" />
<ToggleButton label="PATCH" value="patch" />
<ToggleButton label="DELETE" value="delete" />
</ToggleButtonGroup>
<div class="flex flex-col w-full mt-2">
<div class="flex justify-start w-full">
<Badge
color="gray"
class="center-center !bg-surface-secondary !text-tertiary !w-[90px] !h-[24px] rounded-r-none border"
>
Full endpoint
</Badge>
<input
type="text"
readonly
value={fullRoute}
size={fullRoute.length || 50}
class="font-mono !text-xs max-w-[calc(100%-70px)] !w-auto !h-[24px] !py-0 !border-l-0 !rounded-l-none"
on:focus={({ currentTarget }) => {
currentTarget.select()
}}
/>
</div>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyRoutePath ? routeError : ''}</div
>
</div>
</div>
</Section>
<RouteEditorConfigSection
bind:route_path
bind:isValid
bind:dirtyRoutePath
bind:http_method
{can_write}
bind:static_asset_config
{initialRoutePath}
/>
<Section label="Target">
<ToggleButtonGroup
@@ -436,34 +344,46 @@
{/if}
</Section>
<Section label="Settings">
<Section label="Advanced">
<div class="flex flex-col gap-4">
<div class="flex flex-row justify-between">
<div class="text-sm font-semibold flex flex-row items-center">Request type</div>
<ToggleButtonGroup class="w-auto" bind:selected={is_async} disabled={!can_write}>
<ToggleButton
label="Async"
value={true}
tooltip="The returning value is the uuid of the job assigned to execute the job."
/>
<ToggleButton
label="Sync"
value={false}
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
/>
</ToggleButtonGroup>
</div>
<div class="flex flex-row justify-between">
<div class="text-sm font-semibold flex flex-row items-center">Authentication</div>
<ToggleButtonGroup class="w-auto" bind:selected={requires_auth} disabled={!can_write}>
<ToggleButton label="None" value={false} />
<ToggleButton
label="Required"
value={true}
tooltip="Requires authentication with read access on the route"
/>
</ToggleButtonGroup>
<Label label="Request type" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={is_async}
disabled={!can_write}
>
<ToggleButton
label="Async"
value={true}
tooltip="The returning value is the uuid of the job assigned to execute the job."
/>
<ToggleButton
label="Sync"
value={false}
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
<Label label="Authentication" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={requires_auth}
disabled={!can_write}
>
<ToggleButton label="None" value={false} />
<ToggleButton
label="Required"
value={true}
tooltip="Requires authentication with read access on the route"
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
</Section>
</div>
@@ -1,25 +1,38 @@
<script lang="ts">
import { Button } from '../common'
import { userStore, workspaceStore } from '$lib/stores'
import { HttpTriggerService, type HttpTrigger } from '$lib/gen'
import { RouteIcon } from 'lucide-svelte'
import Skeleton from '../common/skeleton/Skeleton.svelte'
import RouteEditor from './RouteEditor.svelte'
import { canWrite } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
import type { TriggerContext } from '../triggers'
import { getContext } from 'svelte'
import Description from '$lib/components/Description.svelte'
import { getContext, onMount } from 'svelte'
import Section from '$lib/components/Section.svelte'
import TriggersEditorSection from './TriggersEditorSection.svelte'
import Description from '../Description.svelte'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
let routeEditor: RouteEditor
$: path && loadTriggers()
const { triggersCount, selectedTrigger, defaultValues } =
getContext<TriggerContext>('TriggerContext')
const { triggersCount } = getContext<TriggerContext>('TriggerContext')
onMount(() => {
if (
defaultValues &&
$selectedTrigger === 'routes' &&
Object.keys($defaultValues ?? {}).length > 0
) {
routeEditor.openNew(isFlow, path, $defaultValues)
defaultValues.set(undefined)
}
})
let httpTriggers: (HttpTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
@@ -47,62 +60,61 @@
bind:this={routeEditor}
/>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-8">
<Description link="https://www.windmill.dev/docs/core_concepts/http_routing">
Routes expose your scripts and flows as HTTP endpoints. Each route can be configured with a
specific HTTP method and path.
</Description>
<TriggersEditorSection
on:saveTrigger={(e) => {
routeEditor?.openNew(isFlow, path, e.detail.config)
}}
on:applyArgs
on:addPreprocessor
cloudDisabled={false}
triggerType="http"
{isFlow}
{path}
{isEditor}
{canHavePreprocessor}
{newItem}
/>
{#if !newItem}
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Button
on:click={() => routeEditor?.openNew(isFlow, path)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: RouteIcon }}
>
New route
</Button>
{:else}
<Alert title="Only workspace admins can create routes" type="warning" size="xs" />
{/if}
{/if}
<Section label="Routes">
{#if !$userStore?.is_admin && !$userStore?.is_super_admin}
<Alert title="Only workspace admins can create routes" type="warning" size="xs" />
{/if}
{#if httpTriggers}
{#if httpTriggers.length == 0}
<div class="text-xs text-secondary"> No http routes </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each httpTriggers as httpTriggers (httpTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{httpTriggers.path}</div>
<div class="col-span-2 truncate">
{httpTriggers.http_method.toUpperCase()} /{httpTriggers.route_path}
</div>
<div class="flex justify-end">
<button
on:click={() => routeEditor?.openEdit(httpTriggers.path, isFlow)}
class="px-2"
>
{#if httpTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
{#if httpTriggers}
{#if httpTriggers.length == 0}
<div class="text-xs text-secondary text-center"> No http routes </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each httpTriggers as httpTriggers (httpTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{httpTriggers.path}</div>
<div class="col-span-2 truncate">
{httpTriggers.http_method.toUpperCase()} /{httpTriggers.route_path}
</div>
<div class="flex justify-end">
<button
on:click={() => routeEditor?.openEdit(httpTriggers.path, isFlow)}
class="px-2"
>
{#if httpTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
{#if newItem}
<Alert title="Triggers disabled" type="warning" size="xs">
Deploy the {isFlow ? 'flow' : 'script'} to add http routes.
</Alert>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</Section>
{/if}
</div>
@@ -8,7 +8,7 @@
import { canWrite } from '$lib/utils'
import { userStore } from '$lib/stores'
import FlowCard from '../flows/common/FlowCard.svelte'
import { getContext } from 'svelte'
import { getContext, onDestroy, createEventDispatcher } from 'svelte'
import type { TriggerContext } from '$lib/components/triggers'
import WebsocketTriggersPanel from './WebsocketTriggersPanel.svelte'
import ScheduledPollPanel from './ScheduledPollPanel.svelte'
@@ -17,101 +17,133 @@
export let noEditor: boolean
export let newItem = false
export let currentPath: string
export let hash: string | undefined = undefined
export let initialPath: string
export let schema: any
export let isFlow: boolean
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
const { selectedTrigger, simplifiedPoll } = getContext<TriggerContext>('TriggerContext')
const dispatch = createEventDispatcher()
onDestroy(() => {
dispatch('exitTriggers')
})
</script>
<FlowCard {noEditor} title="Triggers">
<div class="pt-4">
{#if !$simplifiedPoll}
<Tabs bind:selected={$selectedTrigger}>
<Tab value="webhooks" selectedClass="text-primary font-semibold">Webhooks</Tab>
<Tab value="schedules" selectedClass="text-primary text-sm font-semibold">Schedules</Tab>
<Tab value="routes" selectedClass="text-primary text-sm font-semibold">Routes</Tab>
<Tab value="websockets" selectedClass="text-primary text-sm font-semibold">Websockets</Tab>
<Tab value="kafka" selectedClass="text-primary text-sm font-semibold">Kafka</Tab>
<Tab value="emails" selectedClass="text-primary text-sm font-semibold">Email</Tab>
{#if isFlow}
<Tab value="scheduledPoll" selectedClass="text-primary text-sm font-semibold"
>Scheduled Poll</Tab
>
{#if !$simplifiedPoll}
<Tabs bind:selected={$selectedTrigger}>
<Tab value="webhooks" selectedClass="text-primary font-semibold">Webhooks</Tab>
<Tab value="schedules" selectedClass="text-primary text-sm font-semibold">Schedules</Tab>
<Tab value="routes" selectedClass="text-primary text-sm font-semibold">HTTP</Tab>
<Tab value="websockets" selectedClass="text-primary text-sm font-semibold">Websockets</Tab>
<Tab value="kafka" selectedClass="text-primary text-sm font-semibold">Kafka</Tab>
<Tab value="emails" selectedClass="text-primary text-sm font-semibold">Email</Tab>
{#if isFlow}
<Tab value="scheduledPoll" selectedClass="text-primary text-sm font-semibold"
>Scheduled Poll</Tab
>
{/if}
<svelte:fragment slot="content">
{#if $selectedTrigger === 'webhooks'}
<div class="p-4">
<WebhooksPanel
on:applyArgs
on:addPreprocessor
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={currentPath}
{hash}
{isFlow}
args={{}}
token=""
{newItem}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
</div>
{:else if $selectedTrigger === 'emails'}
<div class="p-4">
<EmailTriggerPanel
on:applyArgs
on:addPreprocessor
token=""
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={currentPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
/>
</div>
{:else if $selectedTrigger === 'routes'}
<div class="p-4">
<RoutesPanel
on:applyArgs
on:addPreprocessor
{newItem}
path={currentPath}
{isFlow}
isEditor={true}
/>
</div>
{:else if $selectedTrigger === 'websockets'}
<div class="p-4">
<WebsocketTriggersPanel
on:applyArgs
on:addPreprocessor
{newItem}
path={currentPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
</div>
{:else if $selectedTrigger === 'kafka'}
<div class="p-4">
<KafkaTriggersPanel
on:applyArgs
on:addPreprocessor
{newItem}
path={currentPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
</div>
{:else if $selectedTrigger === 'schedules'}
<div class="p-4">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{:else if $selectedTrigger === 'scheduledPoll'}
<div class="p-4">
<ScheduledPollPanel />
</div>
{/if}
<svelte:fragment slot="content">
{#if $selectedTrigger === 'webhooks'}
<div class="p-4">
<WebhooksPanel
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={currentPath}
{isFlow}
args={{}}
token=""
{newItem}
/>
</div>
{/if}
{#if $selectedTrigger === 'emails'}
<div class="p-4">
<EmailTriggerPanel
token=""
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={currentPath}
{isFlow}
/>
</div>
{/if}
{#if $selectedTrigger === 'routes'}
<div class="p-4">
<RoutesPanel {newItem} path={currentPath} {isFlow} />
</div>
{/if}
{#if $selectedTrigger === 'websockets'}
<div class="p-4">
<WebsocketTriggersPanel {newItem} path={currentPath} {isFlow} />
</div>
{/if}
{#if $selectedTrigger === 'kafka'}
<div class="p-4">
<KafkaTriggersPanel {newItem} path={currentPath} {isFlow} />
</div>
{/if}
{#if $selectedTrigger === 'schedules'}
<div class="p-4">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{/if}
{#if $selectedTrigger === 'scheduledPoll'}
<div class="p-4">
<ScheduledPollPanel />
</div>
{/if}
</svelte:fragment>
</Tabs>
{:else}
<div class="px-4 pb-2">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{/if}
</div>
</svelte:fragment>
</Tabs>
{:else}
<div class="px-4 pb-2">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{/if}
</FlowCard>
@@ -0,0 +1,118 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { ChevronDown, Save } from 'lucide-svelte'
import CaptureWrapper from './CaptureWrapper.svelte'
import { capitalize } from '$lib/utils'
import Popover from '../Popover.svelte'
import Section from '../Section.svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { type CaptureTriggerKind } from '$lib/gen'
import type { FlowEditorContext } from '../flows/types'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersWrapper from './TriggersWrapper.svelte'
import { twMerge } from 'tailwind-merge'
export let cloudDisabled: boolean
export let triggerType: CaptureTriggerKind
export let isFlow: boolean = false
export let data: any = {}
export let noSave = false
export let isEditor: boolean = false
export let path: string = ''
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
export let newItem: boolean
const captureTypeLabels: Record<CaptureTriggerKind, string> = {
http: 'New custom HTTP route',
websocket: 'New websocket trigger',
webhook: 'Webhook',
kafka: 'New kafka trigger',
email: 'Email trigger'
}
const { captureOn } = getContext<TriggerContext>('TriggerContext')
let args: Record<string, any> = {}
const dispatch = createEventDispatcher()
const { flowStore, selectedId } = getContext<FlowEditorContext>('FlowEditorContext') || {}
</script>
<Section label={captureTypeLabels[triggerType]}>
<svelte:fragment slot="action">
<div class="flex flex-row grow w-min-0 gap-2 items-center justify-end">
{#if isEditor}
<Button
size="xs2"
on:click={() => {
$captureOn = !$captureOn
}}
variant="border"
color="light"
endIcon={{
icon: ChevronDown,
classes: twMerge('transition', $captureOn ? 'rotate-180' : '')
}}
>
Capture
</Button>
{/if}
{#if !noSave}
{@const disabled = newItem || cloudDisabled}
<Popover notClickable>
<Button
size="xs2"
{disabled}
startIcon={{ icon: Save }}
on:click={() => {
dispatch('saveTrigger', {
config: args
})
}}
>
Save
</Button>
<svelte:fragment slot="text">
{#if disabled}
{#if newItem}
Deploy the runnable to enable trigger creation
{:else if cloudDisabled}
{capitalize(triggerType)} triggers are disabled in the multi-tenant cloud
{/if}
{:else}
Create new {captureTypeLabels[triggerType].toLowerCase()}
{/if}
</svelte:fragment>
</Popover>
{/if}
</div>
</svelte:fragment>
{#if isEditor}
<CaptureWrapper
{path}
{isFlow}
captureType={triggerType}
{hasPreprocessor}
{canHavePreprocessor}
on:applyArgs
on:addPreprocessor
on:updateSchema={(e) => {
const { schema, redirect } = e.detail
$flowStore.schema = schema
if (redirect) {
$selectedId = 'Input'
}
}}
on:saveTrigger
bind:args
{data}
showCapture={$captureOn}
/>
{:else}
<TriggersWrapper {path} {isFlow} {triggerType} {cloudDisabled} {args} {data} />
{/if}
</Section>
@@ -0,0 +1,62 @@
<script lang="ts">
import { type CaptureTriggerKind } from '$lib/gen'
import { capitalize } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
import RouteEditorConfigSection from './RouteEditorConfigSection.svelte'
import WebsocketEditorConfigSection from './WebsocketEditorConfigSection.svelte'
import WebhooksConfigSection from './WebhooksConfigSection.svelte'
import EmailTriggerConfigSection from '../details/EmailTriggerConfigSection.svelte'
import KafkaTriggersConfigSection from './KafkaTriggersConfigSection.svelte'
export let triggerType: CaptureTriggerKind = 'webhook'
export let cloudDisabled: boolean = false
export let args: any
export let isFlow: boolean = false
export let path: string = ''
export let data: any = {}
</script>
<div class="flex flex-col gap-4 w-full">
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
{capitalize(triggerType)} triggers are disabled in the multi-tenant cloud.
</Alert>
{:else if triggerType === 'websocket'}
<WebsocketEditorConfigSection
can_write={true}
headless={true}
bind:url={args.url}
bind:url_runnable_args={args.url_runnable_args}
showCapture={false}
/>
{:else if triggerType === 'webhook'}
<WebhooksConfigSection
{isFlow}
{path}
hash={data?.hash}
token={data?.token}
{args}
scopes={data?.scopes}
showCapture={false}
/>
{:else if triggerType === 'http'}
<RouteEditorConfigSection
showCapture={false}
can_write={true}
bind:route_path={args.route_path}
bind:http_method={args.http_method}
headless
/>
{:else if triggerType === 'email'}
<EmailTriggerConfigSection
hash={data?.hash}
token={data?.token}
{path}
{isFlow}
userSettings={data?.userSettings}
emailDomain={data?.emailDomain}
/>
{:else if triggerType === 'kafka'}
<KafkaTriggersConfigSection headless={true} bind:args staticInputDisabled={false} />
{/if}
</div>
@@ -0,0 +1,434 @@
<script lang="ts">
import Label from '../Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import {
DEFAULT_WEBHOOK_TYPE,
SCRIPT_VIEW_SHOW_EXAMPLE_CURL,
SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON
} from '$lib/consts'
import bash from 'svelte-highlight/languages/bash'
import { Tabs, Tab, TabContent, Button } from '$lib/components/common'
import { ArrowDownRight, ArrowUpRight, Clipboard } from 'lucide-svelte'
import { Highlight } from 'svelte-highlight'
import { typescript } from 'svelte-highlight/languages'
import ClipboardPanel from '../details/ClipboardPanel.svelte'
import { copyToClipboard } from '$lib/utils'
import { page } from '$app/stores'
import { base } from '$lib/base'
import TriggerTokens from './TriggerTokens.svelte'
import { workspaceStore, userStore } from '$lib/stores'
import UserSettings from '../UserSettings.svelte'
import { generateRandomString } from '$lib/utils'
import CopyableCodeBlock from '../details/CopyableCodeBlock.svelte'
import CaptureSection, { type CaptureInfo } from './CaptureSection.svelte'
import CaptureTable from './CaptureTable.svelte'
export let isFlow: boolean = false
export let path: string = ''
export let hash: string | undefined = undefined
export let token: string = ''
export let args: any
export let triggerTokens: TriggerTokens | undefined = undefined
export let scopes: string[] = []
export let showCapture: boolean = false
export let captureTable: CaptureTable | undefined = undefined
export let captureInfo: CaptureInfo | undefined = undefined
let webhooks: {
async: {
hash?: string
path: string
}
sync: {
hash?: string
path: string
get_path?: string
}
}
let selectedTab: string = 'rest'
let userSettings: UserSettings
let url: string = ''
$: webhooks = isFlow ? computeFlowWebhooks(path) : computeScriptWebhooks(hash, path)
function computeScriptWebhooks(hash: string | undefined, path: string) {
let webhookBase = `${$page.url.origin}${base}/api/w/${$workspaceStore}/jobs`
return {
async: {
hash: `${webhookBase}/run/h/${hash}`,
path: `${webhookBase}/run/p/${path}`
},
sync: {
hash: `${webhookBase}/run_wait_result/h/${hash}`,
path: `${webhookBase}/run_wait_result/p/${path}`,
get_path: `${webhookBase}/run_wait_result/p/${path}`
}
}
}
function computeFlowWebhooks(path: string) {
let webhooksBase = `${$page.url.origin}${base}/api/w/${$workspaceStore}/jobs`
let urlAsync = `${webhooksBase}/run/f/${path}`
let urlSync = `${webhooksBase}/run_wait_result/f/${path}`
return {
async: {
path: urlAsync
},
sync: {
path: urlSync,
get_path: urlSync
}
}
}
let webhookType: 'async' | 'sync' = DEFAULT_WEBHOOK_TYPE
let requestType: 'hash' | 'path' | 'get_path' = isFlow ? 'path' : 'path'
let tokenType: 'query' | 'headers' = 'headers'
$: if (webhookType === 'async' && requestType === 'get_path') {
requestType = hash ? 'hash' : 'path'
}
function headers() {
const headers = {}
if (requestType != 'get_path') {
headers['Content-Type'] = 'application/json'
}
if (tokenType === 'headers') {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
function fetchCode() {
if (webhookType === 'sync') {
return `
export async function main() {
const jobTriggerResponse = await triggerJob();
const data = await jobTriggerResponse.json();
return data;
}
async function triggerJob() {
${
requestType === 'get_path'
? '// Payload is a base64 encoded string of the arguments'
: `const body = JSON.stringify(${JSON.stringify(args, null, 2).replaceAll('\n', '\n\t')});`
}
const endpoint = \`${url}\`;
return await fetch(endpoint, {
method: '${requestType === 'get_path' ? 'GET' : 'POST'}',
headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t')}${
requestType === 'get_path' ? '' : `,\n\t\tbody`
}
});
}`
}
// Main function
let mainFunction = `
export async function main() {
const jobTriggerResponse = await triggerJob();
const UUID = await jobTriggerResponse.text();
const jobCompletionData = await waitForJobCompletion(UUID);
return jobCompletionData;
}`
// triggerJob function
let triggerJobFunction = `
async function triggerJob() {
const body = JSON.stringify(${JSON.stringify(args, null, 2).replaceAll('\n', '\n\t')});
const endpoint = \`${url}\`;
return await fetch(endpoint, {
method: '${requestType === 'get_path' ? 'GET' : 'POST'}',
headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t')},
body
});
}`
// waitForJobCompletion function
let waitForJobCompletionFunction = `
function waitForJobCompletion(UUID) {
return new Promise(async (resolve, reject) => {
try {
const endpoint = \`${
$page.url.origin
}/api/w/${$workspaceStore}/jobs_u/completed/get_result_maybe/\${UUID}\`;
const checkResponse = await fetch(endpoint, {
method: 'GET',
headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t\t\t')}
});
const checkData = await checkResponse.json();
if (checkData.completed) {
resolve(checkData);
} else {
// If not completed, wait for a second then try again
setTimeout(async () => {
const result = await waitForJobCompletion(UUID);
resolve(result);
}, 1000);
}
} catch (error) {
reject(error);
}
});
}`
// Combine and return
return `${mainFunction}\n\n${triggerJobFunction}\n\n${waitForJobCompletionFunction}`
}
let captureUrl = `${$page.url.origin}/api/w/${$workspaceStore}/capture_u/webhook/${
isFlow ? 'flow' : 'script'
}/${path}`
function captureCurlCode() {
return `curl \\
-X POST ${captureUrl} \\
-H 'Content-Type: application/json' \\
-d '{"foo": 42}'`
}
function curlCode() {
return `TOKEN='${token}'
${requestType !== 'get_path' ? `BODY='${JSON.stringify(args)}'` : ''}
URL='${url}'
${webhookType === 'sync' ? 'RESULT' : 'UUID'}=$(curl -s ${
requestType != 'get_path' ? "-H 'Content-Type: application/json'" : ''
} ${tokenType === 'headers' ? `-H "Authorization: Bearer $TOKEN"` : ''} -X ${
requestType === 'get_path' ? 'GET' : 'POST'
} ${requestType !== 'get_path' ? `-d "$BODY" ` : ''}$URL)
${
webhookType === 'sync'
? 'echo -E $RESULT | jq'
: `
URL="${$page.url.origin}/api/w/${$workspaceStore}/jobs_u/completed/get_result_maybe/$UUID"
while true; do
curl -s -H "Authorization: Bearer $TOKEN" $URL -o res.json
COMPLETED=$(cat res.json | jq .completed)
if [ "$COMPLETED" = "true" ]; then
cat res.json | jq .result
break
else
sleep 1
fi
done`
}`
}
$: url =
webhooks[webhookType][requestType] +
(tokenType === 'query'
? `?token=${token}${
requestType === 'get_path'
? `&payload=${encodeURIComponent(btoa(JSON.stringify(args)))}`
: ''
}`
: `${
requestType === 'get_path'
? `?payload=${encodeURIComponent(btoa(JSON.stringify(args)))}`
: ''
}`)
</script>
<UserSettings
bind:this={userSettings}
on:tokenCreated={(e) => {
token = e.detail
triggerTokens?.listTokens()
}}
newTokenWorkspace={$workspaceStore}
newTokenLabel={`webhook-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
{scopes}
/>
<div>
{#if showCapture && captureInfo}
<CaptureSection
{captureInfo}
disabled={false}
on:captureToggle
captureType="webhook"
bind:captureTable
on:applyArgs
on:updateSchema
on:addPreprocessor
>
<Label label="URL">
<ClipboardPanel content={captureUrl} disabled={!captureInfo.active} />
</Label>
<Label label="Example cURL">
<CopyableCodeBlock
code={captureCurlCode()}
language={bash}
disabled={!captureInfo.active}
/>
</Label>
</CaptureSection>
{/if}
<div class="flex flex-col gap-8">
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<Label label="Token">
<div class="flex flex-row justify-between gap-2">
<input
bind:value={token}
placeholder="paste your token here once created to alter examples below"
class="!text-xs !font-normal"
/>
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
Create a Webhook-specific Token
<Tooltip light>
The token will have a scope such that it can only be used to trigger this script. It
is safe to share as it cannot be used to impersonate you.
</Tooltip>
</Button>
</div>
</Label>
{/if}
<div class="flex flex-col gap-2">
<div class="flex flex-row justify-between">
<div class="text-sm font-normal text-secondary flex flex-row items-center">Request type</div
>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={webhookType}>
<ToggleButton
label="Async"
value="async"
tooltip="The returning value is the uuid of the job assigned to execute the job."
/>
<ToggleButton
label="Sync"
value="sync"
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
/>
</ToggleButtonGroup>
</div>
<div class="flex flex-row justify-between">
<div class="text-sm font-normal text-secondary flex flex-row items-center">Call method</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={requestType}>
<ToggleButton
label="POST by path"
value="path"
icon={ArrowUpRight}
selectedColor="#fb923c"
/>
{#if !isFlow}
<ToggleButton
label="POST by hash"
value="hash"
icon={ArrowUpRight}
selectedColor="#fb923c"
disabled={!hash}
/>
{/if}
<ToggleButton
label="GET by path"
value="get_path"
icon={ArrowDownRight}
disabled={webhookType !== 'sync'}
selectedColor="#14b8a6"
/>
</ToggleButtonGroup>
</div>
<div class="flex flex-row justify-between">
<div class="text-sm font-normal text-secondary flex flex-row items-center"
>Token configuration</div
>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={tokenType}>
<ToggleButton label="Token in Headers" value="headers" />
<ToggleButton label="Token in Query" value="query" />
</ToggleButtonGroup>
</div>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div>
<Tabs bind:selected={selectedTab}>
<Tab value="rest" size="xs">REST</Tab>
{#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL}
<Tab value="curl" size="xs">Curl</Tab>
{/if}
<Tab value="fetch" size="xs">Fetch</Tab>
<svelte:fragment slot="content">
{#key token}
<TabContent value="rest" class="flex flex-col flex-1 h-full ">
<div class="flex flex-col gap-2">
<Label label="Url">
<ClipboardPanel content={url} />
</Label>
{#if requestType !== 'get_path'}
<Label label="Body">
<ClipboardPanel content={JSON.stringify(args, null, 2)} />
</Label>
{/if}
{#key requestType}
{#key tokenType}
<Label label="Headers">
<ClipboardPanel content={JSON.stringify(headers(), null, 2)} />
</Label>
{/key}
{/key}
</div>
</TabContent>
<TabContent value="curl" class="flex flex-col flex-1 h-full">
<div class="relative">
{#key args}
{#key requestType}
{#key webhookType}
{#key tokenType}
<div
class="flex flex-row flex-1 h-full border p-2 rounded-md overflow-auto relative"
on:click={(e) => {
e.preventDefault()
copyToClipboard(curlCode())
}}
>
<Highlight language={bash} code={curlCode()} />
<Clipboard size={14} class="w-8 top-2 right-2 absolute" />
</div>
{/key}
{/key}
{/key}
{/key}
</div>
</TabContent>
<TabContent value="fetch">
{#key args}
{#key requestType}
{#key webhookType}
{#key tokenType}
{#key token}
<div
class="flex flex-row flex-1 h-full border p-2 rounded-md overflow-auto relative"
on:click={(e) => {
e.preventDefault()
copyToClipboard(fetchCode())
}}
>
<Highlight language={typescript} code={fetchCode()} />
<Clipboard size={14} class="w-8 top-2 right-2 absolute" />
</div>
{/key}{/key}{/key}{/key}
{/key}
</TabContent>
{/key}
</svelte:fragment>
</Tabs>
</div>
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="webhook" />
</div>
</div>
@@ -1,27 +1,8 @@
<script lang="ts">
import Tooltip from '$lib/components/Tooltip.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import bash from 'svelte-highlight/languages/bash'
import { Tabs, Tab, TabContent, Button, Alert } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import {
DEFAULT_WEBHOOK_TYPE,
SCRIPT_VIEW_SHOW_EXAMPLE_CURL,
SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON
} from '$lib/consts'
import { ArrowDownRight, ArrowUpRight, Clipboard } from 'lucide-svelte'
import UserSettings from '../UserSettings.svelte'
import { Highlight } from 'svelte-highlight'
import { typescript } from 'svelte-highlight/languages'
import ClipboardPanel from '../details/ClipboardPanel.svelte'
import { copyToClipboard, generateRandomString } from '$lib/utils'
import { Alert } from '$lib/components/common'
import Description from '../Description.svelte'
import HighlightTheme from '../HighlightTheme.svelte'
import { base } from '$lib/base'
import Label from '$lib/components/Label.svelte'
import TriggerTokens from './TriggerTokens.svelte'
import Description from '$lib/components/Description.svelte'
let userSettings: UserSettings
import TriggersEditorSection from './TriggersEditorSection.svelte'
export let token: string
export let args: any
@@ -29,201 +10,17 @@
export let isFlow: boolean = false
export let hash: string | undefined = undefined
export let path: string
export let url: string = ''
export let newItem: boolean = false
let selectedTab: string = 'rest'
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
let webhooks: {
async: {
hash?: string
path: string
}
sync: {
hash?: string
path: string
get_path?: string
}
let data: any = {
hash,
token,
scopes,
args
}
$: webhooks = isFlow ? computeFlowWebhooks(path) : computeScriptWebhooks(hash, path)
function computeScriptWebhooks(hash: string | undefined, path: string) {
let webhookBase = `${window.location.origin}${base}/api/w/${$workspaceStore}/jobs`
return {
async: {
hash: `${webhookBase}/run/h/${hash}`,
path: `${webhookBase}/run/p/${path}`
},
sync: {
hash: `${webhookBase}/run_wait_result/h/${hash}`,
path: `${webhookBase}/run_wait_result/p/${path}`,
get_path: `${webhookBase}/run_wait_result/p/${path}`
}
}
}
function computeFlowWebhooks(path: string) {
let webhooksBase = `${window.location.origin}${base}/api/w/${$workspaceStore}/jobs`
let urlAsync = `${webhooksBase}/run/f/${path}`
let urlSync = `${webhooksBase}/run_wait_result/f/${path}`
return {
async: {
path: urlAsync
},
sync: {
path: urlSync,
get_path: urlSync
}
}
}
let webhookType: 'async' | 'sync' = DEFAULT_WEBHOOK_TYPE
let requestType: 'hash' | 'path' | 'get_path' = isFlow ? 'path' : 'path'
let tokenType: 'query' | 'headers' = 'headers'
$: if (webhookType === 'async' && requestType === 'get_path') {
// Request type is not supported for async webhooks
requestType = 'hash'
}
$: url =
webhooks[webhookType][requestType] +
(tokenType === 'query'
? `?token=${token}${
requestType === 'get_path'
? `&payload=${encodeURIComponent(btoa(JSON.stringify(args)))}`
: ''
}`
: `${
requestType === 'get_path'
? `?payload=${encodeURIComponent(btoa(JSON.stringify(args)))}`
: ''
}`)
function headers() {
const headers = {}
if (requestType != 'get_path') {
headers['Content-Type'] = 'application/json'
}
if (tokenType === 'headers') {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
function fetchCode() {
if (webhookType === 'sync') {
return `
export async function main() {
const jobTriggerResponse = await triggerJob();
const data = await jobTriggerResponse.json();
return data;
}
async function triggerJob() {
${
requestType === 'get_path'
? '// Payload is a base64 encoded string of the arguments'
: `const body = JSON.stringify(${JSON.stringify(args, null, 2).replaceAll('\n', '\n\t')});`
}
const endpoint = \`${url}\`;
return await fetch(endpoint, {
method: '${requestType === 'get_path' ? 'GET' : 'POST'}',
headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t')}${
requestType === 'get_path' ? '' : `,\n\t\tbody`
}
});
}`
}
// Main function
let mainFunction = `
export async function main() {
const jobTriggerResponse = await triggerJob();
const UUID = await jobTriggerResponse.text();
const jobCompletionData = await waitForJobCompletion(UUID);
return jobCompletionData;
}`
// triggerJob function
let triggerJobFunction = `
async function triggerJob() {
const body = JSON.stringify(${JSON.stringify(args, null, 2).replaceAll('\n', '\n\t')});
const endpoint = \`${url}\`;
return await fetch(endpoint, {
method: '${requestType === 'get_path' ? 'GET' : 'POST'}',
headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t')},
body
});
}`
// waitForJobCompletion function
let waitForJobCompletionFunction = `
function waitForJobCompletion(UUID) {
return new Promise(async (resolve, reject) => {
try {
const endpoint = \`${
window.location.origin
}/api/w/${$workspaceStore}/jobs_u/completed/get_result_maybe/\${UUID}\`;
const checkResponse = await fetch(endpoint, {
method: 'GET',
headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t\t\t')}
});
const checkData = await checkResponse.json();
if (checkData.completed) {
resolve(checkData);
} else {
// If not completed, wait for a second then try again
setTimeout(async () => {
const result = await waitForJobCompletion(UUID);
resolve(result);
}, 1000);
}
} catch (error) {
reject(error);
}
});
}`
// Combine and return
return `${mainFunction}\n\n${triggerJobFunction}\n\n${waitForJobCompletionFunction}`
}
function curlCode() {
return `TOKEN='${token}'
${requestType !== 'get_path' ? `BODY='${JSON.stringify(args)}'` : ''}
URL='${url}'
${webhookType === 'sync' ? 'RESULT' : 'UUID'}=$(curl -s ${
requestType != 'get_path' ? "-H 'Content-Type: application/json'" : ''
} ${tokenType === 'headers' ? `-H "Authorization: Bearer $TOKEN"` : ''} -X ${
requestType === 'get_path' ? 'GET' : 'POST'
} ${requestType !== 'get_path' ? `-d "$BODY" ` : ''}$URL)
${
webhookType === 'sync'
? 'echo -E $RESULT | jq'
: `
URL="${window.location.origin}/api/w/${$workspaceStore}/jobs_u/completed/get_result_maybe/$UUID"
while true; do
curl -s -H "Authorization: Bearer $TOKEN" $URL -o res.json
COMPLETED=$(cat res.json | jq .completed)
if [ "$COMPLETED" = "true" ]; then
cat res.json | jq .result
break
else
sleep 1
fi
done`
}`
}
let triggerTokens: TriggerTokens | undefined = undefined
</script>
<HighlightTheme />
@@ -233,173 +30,26 @@ done`
Webhooks trigger scripts or flows via HTTP requests. Each webhook can be configured to run
synchronously or asynchronously. You can secure webhooks using tokens with specific permissions.
</Description>
<UserSettings
bind:this={userSettings}
on:tokenCreated={(e) => {
token = e.detail
triggerTokens?.listTokens()
}}
newTokenWorkspace={$workspaceStore}
newTokenLabel={`webhook-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
{scopes}
/>
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<Label label="Token">
<div class="flex flex-row justify-between gap-2">
<input
bind:value={token}
placeholder="paste your token here once created to alter examples below"
class="!text-xs !font-normal"
/>
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
Create a Webhook-specific Token
<Tooltip light>
The token will have a scope such that it can only be used to trigger this script. It is
safe to share as it cannot be used to impersonate you.
</Tooltip>
</Button>
</div>
</Label>
{/if}
<div class="flex flex-col gap-2">
<div class="flex flex-row justify-between">
<div class="text-sm font-normal text-secondary flex flex-row items-center">Request type</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={webhookType}>
<ToggleButton
label="Async"
value="async"
tooltip="The returning value is the uuid of the job assigned to execute the job."
/>
<ToggleButton
label="Sync"
value="sync"
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
/>
</ToggleButtonGroup>
</div>
<div class="flex flex-row justify-between">
<div class="text-sm font-normal text-secondary flex flex-row items-center">Call method</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={requestType}>
<ToggleButton
label="POST by path"
value="path"
icon={ArrowUpRight}
selectedColor="#fb923c"
/>
{#if !isFlow}
<ToggleButton
label="POST by hash"
value="hash"
icon={ArrowUpRight}
selectedColor="#fb923c"
/>
{/if}
<ToggleButton
label="GET by path"
value="get_path"
icon={ArrowDownRight}
disabled={webhookType !== 'sync'}
selectedColor="#14b8a6"
/>
</ToggleButtonGroup>
</div>
<div class="flex flex-row justify-between">
<div class="text-sm font-normal text-secondary flex flex-row items-center"
>Token configuration</div
>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={tokenType}>
<ToggleButton label="Token in Headers" value="headers" />
<ToggleButton label="Token in Query" value="query" />
</ToggleButtonGroup>
</div>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<Tabs bind:selected={selectedTab}>
<Tab value="rest" size="xs">REST</Tab>
{#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL}
<Tab value="curl" size="xs">Curl</Tab>
{/if}
<Tab value="fetch" size="xs">Fetch</Tab>
<svelte:fragment slot="content">
{#key token}
<TabContent value="rest" class="flex flex-col flex-1 h-full ">
<div class="flex flex-col gap-2">
<Label label="Url">
<ClipboardPanel content={url} />
</Label>
{#if requestType !== 'get_path'}
<Label label="Body">
<ClipboardPanel content={JSON.stringify(args, null, 2)} />
</Label>
{/if}
{#key requestType}
{#key tokenType}
<Label label="Headers">
<ClipboardPanel content={JSON.stringify(headers(), null, 2)} />
</Label>
{/key}
{/key}
</div>
</TabContent>
<TabContent value="curl" class="flex flex-col flex-1 h-full">
<div class="relative">
{#key args}
{#key requestType}
{#key webhookType}
{#key tokenType}
<div
class="flex flex-row flex-1 h-full border p-2 rounded-md overflow-auto relative"
on:click={(e) => {
e.preventDefault()
copyToClipboard(curlCode())
}}
>
<Highlight language={bash} code={curlCode()} />
<Clipboard size={14} class="w-8 top-2 right-2 absolute" />
</div>
{/key}
{/key}
{/key}
{/key}
</div>
</TabContent>
<TabContent value="fetch">
{#key args}
{#key requestType}
{#key webhookType}
{#key tokenType}
{#key token}
<div
class="flex flex-row flex-1 h-full border p-2 rounded-md overflow-auto relative"
on:click={(e) => {
e.preventDefault()
copyToClipboard(fetchCode())
}}
>
<Highlight language={typescript} code={fetchCode()} />
<Clipboard size={14} class="w-8 top-2 right-2 absolute" />
</div>
{/key}{/key}{/key}{/key}
{/key}
</TabContent>
{/key}
</svelte:fragment>
</Tabs>
<div class="mt-10" />
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="webhook" />
{#if newItem}
<div class="mt-10" />
<Alert type="warning" title="Attached to a deployed path">
The webhooks are only valid for a given path and will only trigger the deployed version of the
{isFlow ? 'flow' : 'script'}.
</Alert>
{/if}
<TriggersEditorSection
on:applyArgs
on:addPreprocessor
on:refreshCaptures
cloudDisabled={false}
triggerType="webhook"
{isFlow}
{data}
noSave
{path}
{isEditor}
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
/>
</div>
@@ -0,0 +1,184 @@
<script lang="ts">
import Section from '$lib/components/Section.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Required from '$lib/components/Required.svelte'
import { Loader2 } from 'lucide-svelte'
import CaptureSection, { type CaptureInfo } from './CaptureSection.svelte'
import CaptureTable from './CaptureTable.svelte'
import { sendUserToast } from '$lib/utils'
import type { Schema } from '$lib/common'
import { FlowService, ScriptService, type Flow, type Script } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
export let url: string | undefined
export let url_runnable_args: Record<string, unknown> | undefined
export let dirtyUrl: boolean = false
export let can_write: boolean = false
export let headless: boolean = false
export let showCapture: boolean = false
export let captureTable: CaptureTable | undefined = undefined
export let captureInfo: CaptureInfo | undefined = undefined
export let isValid: boolean = false
let areRunnableArgsValid: boolean = true
let urlRunnableSchema: Schema | undefined = undefined
async function loadUrlRunnableSchema(url: string | undefined) {
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)
let urlError: string = ''
let validateTimeout: NodeJS.Timeout | undefined = undefined
function validateUrl(url: string | undefined) {
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(() => {
if (url?.startsWith('$')) {
if (/^(\$script|\$flow):[^\s]+$/.test(url) === false) {
urlError = 'Invalid runnable path'
} else {
urlError = ''
}
} else if (!url || /^(ws:|wss:)\/\/[^\s]+$/.test(url) === false) {
urlError = 'Invalid websocket URL'
} else {
urlError = ''
}
validateTimeout = undefined
}, 500)
}
$: validateUrl(url)
$: isValid = urlError === '' && (!url?.startsWith('$') || areRunnableArgsValid)
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
disabled={!isValid}
on:captureToggle
captureType="websocket"
{captureInfo}
on:applyArgs
on:updateSchema
on:addPreprocessor
bind:captureTable
/>
{/if}
<Section label="Websocket" {headless}>
<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>
</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}
{#key urlRunnableSchema}
<Module.default
schema={urlRunnableSchema}
bind:args={url_runnable_args}
bind:isValid={areRunnableArgsValid}
shouldHideNoInputs
class="text-xs"
/>
{/key}
{/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>
</div>
@@ -9,10 +9,14 @@
drawer?.openEdit(ePath, isFlow)
}
export async function openNew(is_flow: boolean, initial_script_path?: string) {
export async function openNew(
is_flow: boolean,
initial_script_path?: string,
defaultValues?: Record<string, any>
) {
open = true
await tick()
drawer?.openNew(is_flow, initial_script_path)
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
let drawer: WebsocketTriggerEditorInner
@@ -24,8 +24,7 @@
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'
import WebsocketEditorConfigSection from './WebsocketEditorConfigSection.svelte'
let drawer: Drawer
let is_flow: boolean = false
@@ -38,7 +37,6 @@
let path: string = ''
let pathError = ''
let url = ''
let urlError = ''
let dirtyUrl = false
let enabled = false
let filters: {
@@ -46,7 +44,7 @@
value: any
}[] = []
let initial_messages: WebsocketTriggerInitialMessage[] = []
let url_runnable_args: Record<string, any> = {}
let url_runnable_args: Record<string, any> | undefined = {}
let dirtyPath = false
let can_write = true
let drawerLoading = true
@@ -72,14 +70,18 @@
}
}
export async function openNew(nis_flow: boolean, fixedScriptPath_?: string) {
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>
) {
drawerLoading = true
try {
drawer?.openDrawer()
is_flow = nis_flow
edit = false
itemKind = nis_flow ? 'flow' : 'script'
url = ''
url = defaultValues?.url ?? ''
dirtyUrl = false
initialScriptPath = ''
fixedScriptPath = fixedScriptPath_ ?? ''
@@ -88,7 +90,7 @@
initialPath = ''
filters = []
initial_messages = []
url_runnable_args = {}
url_runnable_args = defaultValues?.url_runnable_args ?? {}
dirtyPath = false
} finally {
drawerLoading = false
@@ -108,7 +110,7 @@
url = s.url
enabled = s.enabled
filters = s.filters
initial_messages = s.initial_messages
initial_messages = s.initial_messages ?? []
url_runnable_args = s.url_runnable_args
can_write = canWrite(s.path, s.extra_perms, $userStore)
@@ -147,35 +149,6 @@
.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
@@ -222,25 +195,7 @@
drawer.closeDrawer()
}
let validateTimeout: NodeJS.Timeout | undefined = undefined
function validateUrl(url: string) {
urlError = ''
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(() => {
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
}, 500)
}
$: validateUrl(url)
let isValid = false
</script>
<Drawer size="800px" bind:this={drawer}>
@@ -277,7 +232,7 @@
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' ||
urlError != '' ||
!isValid ||
invalidInitialMessages ||
emptyString(script_path) ||
!can_write}
@@ -314,92 +269,13 @@
</Label>
</div>
<Section label="Websocket">
<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>
</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>
<WebsocketEditorConfigSection
bind:url
bind:url_runnable_args
{dirtyUrl}
{can_write}
bind:isValid
/>
<Section label="Runnable">
<p class="text-xs mb-1 text-tertiary">
@@ -1,26 +1,41 @@
<script lang="ts">
import { Button } from '../common'
import { userStore, workspaceStore } from '$lib/stores'
import { WebsocketTriggerService, type WebsocketTrigger } from '$lib/gen'
import { UnplugIcon } from 'lucide-svelte'
import Skeleton from '../common/skeleton/Skeleton.svelte'
import { canWrite } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
import type { TriggerContext } from '../triggers'
import { getContext } from 'svelte'
import { getContext, onMount } from 'svelte'
import WebsocketTriggerEditor from './WebsocketTriggerEditor.svelte'
import { isCloudHosted } from '$lib/cloud'
import Description from '$lib/components/Description.svelte'
import TriggersEditorSection from './TriggersEditorSection.svelte'
import Section from '$lib/components/Section.svelte'
import Description from '../Description.svelte'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
let wsTriggerEditor: WebsocketTriggerEditor
$: path && loadTriggers()
const { triggersCount } = getContext<TriggerContext>('TriggerContext')
const { triggersCount, selectedTrigger, defaultValues } =
getContext<TriggerContext>('TriggerContext')
onMount(() => {
if (
defaultValues &&
$selectedTrigger === 'websockets' &&
Object.keys($defaultValues ?? {}).length > 0
) {
wsTriggerEditor.openNew(isFlow, path, $defaultValues)
defaultValues.set(undefined)
}
})
let wsTriggers: (WebsocketTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
@@ -48,61 +63,67 @@
bind:this={wsTriggerEditor}
/>
<div class="flex flex-col w-full gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/websocket_triggers">
Websocket triggers allow real-time bidirectional communication between your scripts/flows and
external systems. Each trigger creates a unique websocket endpoint.
</Description>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
Websocket triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<div class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/websocket_triggers">
Websocket triggers allow real-time bidirectional communication between your scripts/flows and
external systems. Each trigger creates a unique websocket endpoint.
</Description>
<TriggersEditorSection
on:applyArgs
on:saveTrigger={(e) => {
wsTriggerEditor?.openNew(isFlow, path, e.detail.config)
}}
on:addPreprocessor
cloudDisabled={false}
triggerType="websocket"
{isFlow}
{path}
{isEditor}
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
/>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
Websocket triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<div class="flex flex-col gap-4">
{#if newItem}
<Alert title="Triggers disabled" type="warning" size="xs">
Deploy the {isFlow ? 'flow' : 'script'} to add WS triggers.
</Alert>
{:else if wsTriggers}
<Button
on:click={() => wsTriggerEditor?.openNew(isFlow, path)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: UnplugIcon }}
>
New WS trigger
</Button>
{#if wsTriggers.length == 0}
<div class="text-xs text-secondary"> No WS triggers </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each wsTriggers as wsTriggers (wsTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{wsTriggers.path}</div>
<div class="col-span-2 truncate">
{wsTriggers.url}
</div>
<div class="flex justify-end">
<button
on:click={() => wsTriggerEditor?.openEdit(wsTriggers.path, isFlow)}
class="px-2"
>
{#if wsTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
{#if !newItem}
<Section label="Websockets">
<div class="flex flex-col gap-4">
{#if wsTriggers}
{#if wsTriggers.length == 0}
<div class="text-xs text-secondary text-center"> No WS triggers </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each wsTriggers as wsTriggers (wsTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{wsTriggers.path}</div>
<div class="col-span-2 truncate">
{wsTriggers.url}
</div>
<div class="flex justify-end">
<button
on:click={() => wsTriggerEditor?.openEdit(wsTriggers.path, isFlow)}
class="px-2"
>
{#if wsTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</div>
{/if}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</div>
</Section>
{/if}
</div>
{/if}
+5 -5
View File
@@ -561,7 +561,7 @@ export async function main(approver?: string) {
// add a form in Advanced - Suspend
// all on approval steps: https://www.windmill.dev/docs/flows/flow_approval`
const BUN_PREPROCESSOR_MODULE_CODE = `
export const BUN_PREPROCESSOR_MODULE_CODE = `
export async function preprocessor(
wm_trigger: {
kind: 'http' | 'email' | 'webhook' | 'websocket' | 'kafka',
@@ -585,7 +585,7 @@ export async function preprocessor(
/* your other args */
) {
return {
// return the args to be passed to the flow
// return the args to be passed to the runnable
}
}
`
@@ -614,7 +614,7 @@ export async function preprocessor(
/* your other args */
) {
return {
// return the args to be passed to the flow
// return the args to be passed to the runnable
}
}
`
@@ -646,7 +646,7 @@ def main():
# add a form in Advanced - Suspend
# all on approval steps: https://www.windmill.dev/docs/flows/flow_approval`
const PYTHON_PREPROCESSOR_MODULE_CODE = `from typing import TypedDict, Literal
export const PYTHON_PREPROCESSOR_MODULE_CODE = `from typing import TypedDict, Literal
class Http(TypedDict):
route: str # The route path, e.g. "/users/:id"
@@ -675,7 +675,7 @@ def preprocessor(
# your other args
):
return {
# return the args to be passed to the flow
# return the args to be passed to the runnable
}
`

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