mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
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:
+26
@@ -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"
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+75
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM capture_config WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3c9fc4d8579767f3ce7c3633fca770e6341624e98117d21b9f01e68b4e0ce033"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM capture WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "41e557e1b63b13c9fcc195901c0bd0de7e03c539ee046955543d9693551246f7"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+44
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+55
@@ -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"
|
||||
}
|
||||
+30
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+33
@@ -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 @@
|
||||
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
¶ms,
|
||||
&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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
¶ms,
|
||||
&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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Generated
+95
-3
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user