Merge remote-tracking branch 'origin/main' into glm/replace_menuV2

This commit is contained in:
Guilhem
2025-02-12 14:27:07 +00:00
117 changed files with 5884 additions and 3191 deletions
+21
View File
@@ -1,5 +1,26 @@
# Changelog
## [1.460.1](https://github.com/windmill-labs/windmill/compare/v1.460.0...v1.460.1) (2025-02-12)
### Bug Fixes
* pin opentelemetry to 0.27.1 ([e92a909](https://github.com/windmill-labs/windmill/commit/e92a90907f41568e4e04c932e1fbef64ab4c48a9))
## [1.460.0](https://github.com/windmill-labs/windmill/compare/v1.459.0...v1.460.0) (2025-02-11)
### Features
* add postgres trigger captures ([#5165](https://github.com/windmill-labs/windmill/issues/5165)) ([57cfa40](https://github.com/windmill-labs/windmill/commit/57cfa4045bf9aa7c2ef625cf3b24067567466aff))
* improve large apps performances ([#5265](https://github.com/windmill-labs/windmill/issues/5265)) ([aae3683](https://github.com/windmill-labs/windmill/commit/aae3683fe90adc0eea055238f7776b96140706bd))
* lazy mode ([7c4b8a7](https://github.com/windmill-labs/windmill/commit/7c4b8a7e1dca870b51b60f33a352d344ef34218f))
### Bug Fixes
* Remove cache dir mount and mount only the cache executable (Rust, C#) ([#5270](https://github.com/windmill-labs/windmill/issues/5270)) ([6357ed3](https://github.com/windmill-labs/windmill/commit/6357ed3d5e1188bb92ccaf4710e526ab2ec7e874))
## [1.459.0](https://github.com/windmill-labs/windmill/compare/v1.458.4...v1.459.0) (2025-02-10)
@@ -0,0 +1,50 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n path,\n is_flow,\n workspace_id,\n owner,\n email,\n trigger_config as \"trigger_config!: _\"\n FROM\n capture_config\n WHERE\n trigger_kind = 'postgres' AND\n last_client_ping > NOW() - INTERVAL '10 seconds' AND\n trigger_config IS NOT NULL AND\n (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')\n ",
"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": "owner",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "trigger_config!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
false,
true
]
},
"hash": "011c7638eeeda710deb86a216a9e10df9c3e9458e85bcdde466b01011a1f2ac2"
}
@@ -18,7 +18,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE \n capture_config \n SET \n last_server_ping = NULL \n WHERE \n workspace_id = $1 AND \n path = $2 AND \n is_flow = $3 AND \n trigger_kind = 'postgres' AND \n server_id IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "12e868b63a7c622c76713db5a5577a927efca4ae49a15c2b999e2410f2a312ff"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE postgres_trigger \n SET \n server_id = $1, \n last_server_ping = now(),\n error = 'Connecting...'\n WHERE \n enabled IS TRUE \n AND workspace_id = $2 \n AND path = $3 \n AND (last_server_ping IS NULL \n OR last_server_ping < now() - INTERVAL '15 seconds'\n ) \n RETURNING true\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n capture_config \n SET \n last_server_ping = now(), \n error = $1 \n WHERE \n workspace_id = $2 AND \n path = $3 AND \n is_flow = $4 AND \n trigger_kind = 'postgres' AND \n server_id = $5 AND \n last_client_ping > NOW() - INTERVAL '10 seconds' \n RETURNING 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Bool",
"Text"
]
},
"nullable": [
null
]
},
"hash": "1974bd65bbf40024773aad4dee1c50b12e110e76bb58e6de25bec094e758a71c"
}
@@ -0,0 +1,118 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path, url, script_path, is_flow, edited_by, email, edited_at, server_id, last_server_ping, extra_perms, error, enabled, filters as \"filters: _\", initial_messages as \"initial_messages: _\", url_runnable_args as \"url_runnable_args: _\", can_return_message FROM websocket_trigger\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "url",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "server_id",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "last_server_ping",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 11,
"name": "error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "enabled",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "filters: _",
"type_info": "JsonbArray"
},
{
"ordinal": 14,
"name": "initial_messages: _",
"type_info": "JsonbArray"
},
{
"ordinal": 15,
"name": "url_runnable_args: _",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"name": "can_return_message",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false,
true,
true,
false
]
},
"hash": "1aa8ead10f8d994f6685d266fcbd409b0fff43111d9600e64b2348401ed8929d"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n capture_config \n SET \n error = $1, \n server_id = NULL, \n last_server_ping = NULL \n WHERE \n workspace_id = $2 AND \n path = $3 AND \n is_flow = $4 AND \n trigger_kind = 'postgres'\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "29f096ec62c4abb1435a5667e2b30e9c1724e419cdc23ef1b300e84c02a20427"
}
@@ -0,0 +1,106 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM postgres_trigger\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "postgres_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "error",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "server_id",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "last_server_ping",
"type_info": "Timestamptz"
},
{
"ordinal": 12,
"name": "replication_slot_name",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "publication_name",
"type_info": "Varchar"
},
{
"ordinal": 14,
"name": "enabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true,
true,
false,
false,
false
]
},
"hash": "4931d4752357078ae3ae01f37742639dba0dde680ab934ba78abcb5fdda8117a"
}
@@ -26,7 +26,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -58,7 +59,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n postgres_trigger \n SET\n last_server_ping = NULL \n WHERE \n workspace_id = $1 \n AND path = $2 \n AND server_id IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "61bed1bc6d3e6a3c1d640eeacc290a85d8b63ee36c39dfbf4348d120f6e561ae"
}
@@ -0,0 +1,118 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM nats_trigger\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "nats_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "subjects",
"type_info": "VarcharArray"
},
{
"ordinal": 3,
"name": "stream_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "consumer_name",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "use_jetstream",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 12,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 13,
"name": "server_id",
"type_info": "Varchar"
},
{
"ordinal": 14,
"name": "last_server_ping",
"type_info": "Timestamptz"
},
{
"ordinal": 15,
"name": "error",
"type_info": "Text"
},
{
"ordinal": 16,
"name": "enabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "61e6070b8a1e3a138818c327d6dbe7efbe27f9e2c8e02258cf7aa06e1779fddb"
}
@@ -18,7 +18,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT slot_name FROM pg_replication_slots where slot_name = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "slot_name",
"type_info": "Name"
}
],
"parameters": {
"Left": [
"Name"
]
},
"nullable": [
true
]
},
"hash": "6f56acb985aa7141ea1891d7ad58a32c35d1b02fe7070c92a2e62c1a5339c396"
}
@@ -29,7 +29,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n capture_config \n SET \n server_id = $1,\n last_server_ping = now(), \n error = 'Connecting...' \n WHERE \n last_client_ping > NOW() - INTERVAL '10 seconds' AND \n workspace_id = $2 AND \n path = $3 AND \n is_flow = $4 AND \n trigger_kind = 'postgres' AND \n (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') \n RETURNING true\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c"
}
@@ -0,0 +1,106 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM kafka_trigger\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "kafka_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "topics",
"type_info": "VarcharArray"
},
{
"ordinal": 3,
"name": "group_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 11,
"name": "server_id",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "last_server_ping",
"type_info": "Timestamptz"
},
{
"ordinal": 13,
"name": "error",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "enabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "8b784784fe63d91cc5ebe27022f803caf85d5916960308cff512047d4f0dcba4"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n postgres_trigger\n SET \n last_server_ping = now(),\n error = $1\n WHERE\n workspace_id = $2\n AND path = $3\n AND server_id = $4 \n AND enabled IS TRUE\n RETURNING 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "9116102c6ccad5b0d752d5d690c233dfe48062aef23072b4f4ae4ab5ca269082"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pubname FROM pg_publication WHERE pubname = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pubname",
"type_info": "Name"
}
],
"parameters": {
"Left": [
"Name"
]
},
"nullable": [
false
]
},
"hash": "baa1dddc616419bf4b923715f0a863bc0ff69c98db0f0c8f55e4ac89fdde7a60"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n postgres_trigger \n SET \n enabled = FALSE, \n error = $1, \n server_id = NULL, \n last_server_ping = NULL \n WHERE \n workspace_id = $2 AND \n path = $3\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "bfc534d87d701d7ac78cc97d0054d829165ba3f22fba75c3161e4cddb72264ee"
}
@@ -21,7 +21,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -18,7 +18,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members \n FROM usr u\n JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id\n RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_\n WHERE g_.workspace_id = $1 AND g_.name != 'all'\n GROUP BY g_.workspace_id, name, summary, extra_perms",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "members",
"type_info": "VarcharArray"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
false,
null
]
},
"hash": "cc6e21bf16d7b92764aa2b261cee94f6daf6a1eedd8a68742a2f510f0452cc32"
}
@@ -0,0 +1,119 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, edited_by, edited_at, email, extra_perms, is_async, requires_auth, http_method as \"http_method: _\", static_asset_config as \"static_asset_config: _\", is_static_website FROM http_trigger\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "route_path_key",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "requires_auth",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "http_method: _",
"type_info": {
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
}
},
{
"ordinal": 13,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 14,
"name": "is_static_website",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "d8aa1a5813fedbb22128112fcdf9d16769811cd5d5ad3b66362cc5bb1750ae6b"
}
@@ -0,0 +1,130 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n -- slack_team_id, \n -- slack_name, \n -- slack_command_script, \n -- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,\n auto_invite_domain IS NOT NULL AS \"auto_invite_enabled!\",\n CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS \"auto_invite_as!\", \n CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS \"auto_invite_mode!\", \n webhook, \n deploy_to, \n error_handler, \n ai_resource, \n ai_models,\n code_completion_model,\n error_handler_extra_args, \n error_handler_muted_on_cancel, \n large_file_storage, \n git_sync,\n default_app,\n default_scripts,\n workspace.name,\n mute_critical_alerts,\n color,\n operator_settings\n FROM workspace_settings\n LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "auto_invite_enabled!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "auto_invite_as!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "auto_invite_mode!",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "webhook",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "deploy_to",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "error_handler",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "ai_resource",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "ai_models",
"type_info": "VarcharArray"
},
{
"ordinal": 8,
"name": "code_completion_model",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "error_handler_extra_args",
"type_info": "Json"
},
{
"ordinal": 10,
"name": "error_handler_muted_on_cancel",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 12,
"name": "git_sync",
"type_info": "Jsonb"
},
{
"ordinal": 13,
"name": "default_app",
"type_info": "Varchar"
},
{
"ordinal": 14,
"name": "default_scripts",
"type_info": "Jsonb"
},
{
"ordinal": 15,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 16,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 17,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 18,
"name": "operator_settings",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null,
null,
true,
true,
true,
true,
false,
true,
true,
false,
true,
true,
true,
true,
false,
true,
true,
true
]
},
"hash": "dc165e2d3e6cfc52d92b48500b5ca7dd94b46263c58163071c0ded1c54535727"
}
@@ -26,7 +26,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -34,7 +34,8 @@
"websocket",
"kafka",
"email",
"nats"
"nats",
"postgres"
]
}
}
@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM usr\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "role",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n schemaname AS schema_name,\n tablename AS table_name,\n CASE\n WHEN array_length(attnames, 1) = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = pg_publication_tables.schemaname AND table_name = pg_publication_tables.tablename)\n THEN NULL\n ELSE attnames\n END AS columns,\n rowfilter AS where_clause\n FROM\n pg_publication_tables\n WHERE\n pubname = $1;\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "schema_name",
"type_info": "Name"
},
{
"ordinal": 1,
"name": "table_name",
"type_info": "Name"
},
{
"ordinal": 2,
"name": "columns",
"type_info": "NameArray"
},
{
"ordinal": 3,
"name": "where_clause",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Name"
]
},
"nullable": [
true,
true,
null,
true
]
},
"hash": "fd5754fe3c6346ae28818a9d60d144a40f8884f47e5bbdd2824e939dafd8f154"
}
+66 -65
View File
@@ -1025,7 +1025,7 @@ dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide 0.8.3",
"miniz_oxide 0.8.4",
"object",
"rustc-demangle",
"windows-targets 0.52.6",
@@ -1474,9 +1474,9 @@ dependencies = [
[[package]]
name = "bzip2-sys"
version = "0.1.11+1.0.8"
version = "0.1.12+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
checksum = "72ebc2f1a417f01e1da30ef264ee86ae31d2dcd2d603ea283d3c244a883ca2a9"
dependencies = [
"cc",
"libc",
@@ -1668,9 +1668,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.28"
version = "4.5.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff"
checksum = "8acebd8ad879283633b343856142139f2da2317c96b05b4dd6181c61e2480184"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1678,9 +1678,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.27"
version = "4.5.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7"
checksum = "f6ba32cbda51c7e1dfd49acc1457ba1a7dec5b64fe360e828acb13ca8dc9c2f9"
dependencies = [
"anstream",
"anstyle",
@@ -2776,7 +2776,7 @@ checksum = "688175eed35e7b3053ec114227894ef24786855405d8844058a48bffa997d85a"
dependencies = [
"deno_core",
"deno_native_certs",
"rustls 0.23.22",
"rustls 0.23.23",
"rustls-pemfile 2.2.0",
"rustls-tokio-stream",
"rustls-webpki 0.102.8",
@@ -3345,7 +3345,7 @@ checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c"
dependencies = [
"crc32fast",
"libz-sys",
"miniz_oxide 0.8.3",
"miniz_oxide 0.8.4",
]
[[package]]
@@ -4302,7 +4302,7 @@ dependencies = [
"http 1.2.0",
"hyper 1.6.0",
"hyper-util",
"rustls 0.23.22",
"rustls 0.23.23",
"rustls-native-certs 0.8.1",
"rustls-pki-types",
"tokio",
@@ -5077,7 +5077,7 @@ dependencies = [
"base64 0.22.1",
"gethostname",
"mail-builder",
"rustls 0.23.22",
"rustls 0.23.23",
"rustls-pki-types",
"smtp-proto",
"tokio",
@@ -5265,9 +5265,9 @@ dependencies = [
[[package]]
name = "miniz_oxide"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924"
checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b"
dependencies = [
"adler2",
]
@@ -5808,9 +5808,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.4.1+3.4.0"
version = "300.4.2+3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faa4eac4138c62414b5622d1b31c5c304f34b406b013c079c2bbc652fdd6678c"
checksum = "168ce4e058f975fe43e89d9ccf78ca668601887ae736090aacc23ae353c298e2"
dependencies = [
"cc",
]
@@ -6425,7 +6425,7 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
dependencies = [
"toml_edit 0.22.23",
"toml_edit 0.22.24",
]
[[package]]
@@ -6576,9 +6576,9 @@ dependencies = [
[[package]]
name = "prost"
version = "0.13.4"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c0fef6c4230e4ccf618a35c59d7ede15dea37de8427500f50aff708806e42ec"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
@@ -6586,12 +6586,12 @@ dependencies = [
[[package]]
name = "prost-derive"
version = "0.13.4"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157c5a9d7ea5c2ed2d9fb8f495b64759f7816c7eaea54ba3978f0d63000162e3"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.13.0",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.98",
@@ -6599,9 +6599,9 @@ dependencies = [
[[package]]
name = "prost-types"
version = "0.13.4"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc2f1e56baa61e93533aebc21af4d2134b70f66275e0fcdf3cbe43d77ff7e8fc"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost",
]
@@ -6708,7 +6708,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.22",
"rustls 0.23.23",
"socket2",
"thiserror 2.0.11",
"tokio",
@@ -6726,7 +6726,7 @@ dependencies = [
"rand 0.8.5",
"ring 0.17.8",
"rustc-hash 2.1.1",
"rustls 0.23.22",
"rustls 0.23.23",
"rustls-pki-types",
"slab",
"thiserror 2.0.11",
@@ -7129,7 +7129,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.22",
"rustls 0.23.23",
"rustls-native-certs 0.8.1",
"rustls-pemfile 2.2.0",
"rustls-pki-types",
@@ -7395,9 +7395,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.22"
version = "0.23.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9263ab4eb695e42321db096e3b8fbd715a59b154d5c88d82db2175b681ba7"
checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395"
dependencies = [
"log",
"once_cell",
@@ -7479,7 +7479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22557157d7395bc30727745b365d923f1ecc230c4c80b176545f3f4f08c46e33"
dependencies = [
"futures",
"rustls 0.23.22",
"rustls 0.23.23",
"socket2",
"tokio",
]
@@ -7786,12 +7786,13 @@ dependencies = [
[[package]]
name = "serde-aux"
version = "4.5.0"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d2e8bfba469d06512e11e3311d4d051a4a387a5b42d010404fecf3200321c95"
checksum = "5290c39c5f6992b9dddbda28541d965dba46468294e6018a408fa297e6c602de"
dependencies = [
"chrono",
"serde",
"serde-value",
"serde_json",
]
@@ -8375,7 +8376,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rustls 0.23.22",
"rustls 0.23.23",
"rustls-pemfile 2.2.0",
"serde",
"serde_json",
@@ -9679,7 +9680,7 @@ version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37"
dependencies = [
"rustls 0.23.22",
"rustls 0.23.23",
"tokio",
]
@@ -9791,7 +9792,7 @@ dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit 0.22.23",
"toml_edit 0.22.24",
]
[[package]]
@@ -9818,15 +9819,15 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.22.23"
version = "0.22.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02a8b472d1a3d7c18e2d61a489aee3453fd9031c33e4f55bd533f4a7adca1bee"
checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
dependencies = [
"indexmap 2.7.1",
"serde",
"serde_spanned",
"toml_datetime",
"winnow 0.7.1",
"winnow 0.7.2",
]
[[package]]
@@ -10453,7 +10454,7 @@ dependencies = [
"log",
"native-tls",
"once_cell",
"rustls 0.23.22",
"rustls 0.23.23",
"rustls-pki-types",
"serde",
"serde_json",
@@ -10858,7 +10859,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"axum",
@@ -10901,7 +10902,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"argon2",
@@ -10994,7 +10995,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -11012,7 +11013,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"chrono",
"serde",
@@ -11025,7 +11026,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"serde",
@@ -11039,7 +11040,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"async-stream",
@@ -11098,7 +11099,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"regex",
"serde",
@@ -11112,7 +11113,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"bytes",
@@ -11135,7 +11136,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -11147,7 +11148,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -11156,7 +11157,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -11168,7 +11169,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"serde_json",
@@ -11180,7 +11181,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"gosyn",
@@ -11192,7 +11193,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -11204,7 +11205,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -11215,7 +11216,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -11226,7 +11227,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -11246,7 +11247,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -11263,7 +11264,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -11275,7 +11276,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -11293,7 +11294,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"getrandom 0.2.15",
@@ -11315,7 +11316,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"serde_json",
@@ -11325,7 +11326,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -11358,7 +11359,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -11368,7 +11369,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.458.4"
version = "1.460.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -11652,9 +11653,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "0.7.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e376c75f4f43f44db463cf729e0d3acbf954d13e22c51e26e4c264b4ab545f"
checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603"
dependencies = [
"memchr",
]
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.458.4"
version = "1.460.1"
authors.workspace = true
edition.workspace = true
@@ -30,7 +30,7 @@ members = [
]
[workspace.package]
version = "1.458.4"
version = "1.460.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -315,10 +315,10 @@ async-stream = "^0"
opentelemetry = "0.27.0"
tracing-opentelemetry = "0.28.0"
opentelemetry_sdk = { version = "*", features = ["rt-tokio"] }
opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.27.0", features = ["grpc-tonic", "tls"] }
opentelemetry-appender-tracing = "0.27.0"
opentelemetry-semantic-conventions = { version = "*", features = ["semconv_experimental"] }
opentelemetry-semantic-conventions = { version = "0.27.0", features = ["semconv_experimental"] }
bollard = "0.18.1"
@@ -0,0 +1,2 @@
-- Add up migration script here
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'postgres';
@@ -0,0 +1 @@
-- Add down migration script here
@@ -0,0 +1,6 @@
-- Add up migration script here
GRANT ALL ON v2_as_queue TO windmill_admin;
GRANT ALL ON v2_as_queue TO windmill_user;
GRANT ALL ON v2_as_completed_job TO windmill_admin;
GRANT ALL ON v2_as_completed_job TO windmill_user;
+31 -3
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.458.4
version: 1.460.1
title: Windmill API
contact:
@@ -8760,6 +8760,34 @@ paths:
schema:
type: string
/w/{workspace}/postgres_triggers/test:
post:
summary: test postgres connection
operationId: testPostgresConnection
tags:
- postgres_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: test postgres connection
required: true
content:
application/json:
schema:
type: object
properties:
database:
type: string
required:
- database
responses:
"200":
description: successfuly connected to postgres
content:
text/plain:
schema:
type: string
/groups/list:
get:
summary: list instance groups
@@ -13323,7 +13351,7 @@ components:
type: string
required:
- transaction_to_track
TableToTrack:
type: array
items:
@@ -14644,7 +14672,7 @@ components:
CaptureTriggerKind:
type: string
enum: [webhook, http, websocket, kafka, email, nats]
enum: [webhook, http, websocket, kafka, email, nats, postgres]
Capture:
type: object
+97 -2
View File
@@ -38,6 +38,16 @@ use crate::http_triggers::{build_http_trigger_extra, HttpMethod};
use crate::kafka_triggers_ee::KafkaTriggerConfigConnection;
#[cfg(all(feature = "enterprise", feature = "nats"))]
use crate::nats_triggers_ee::NatsTriggerConfigConnection;
#[cfg(feature = "postgres_trigger")]
use crate::postgres_triggers::{
create_logical_replication_slot_query, create_publication_query, drop_publication_query,
generate_random_string, get_database_connection, PublicationData,
};
#[cfg(feature = "postgres_trigger")]
use itertools::Itertools;
#[cfg(feature = "postgres_trigger")]
use pg_escape::quote_literal;
use crate::{
args::WebhookArgs,
db::{ApiAuthed, DB},
@@ -78,7 +88,7 @@ pub fn workspaced_unauthed_service() -> Router {
}
}
#[derive(sqlx::Type, Serialize, Deserialize)]
#[derive(sqlx::Type, Serialize, Deserialize, Debug)]
#[sqlx(type_name = "TRIGGER_KIND", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum TriggerKind {
@@ -88,6 +98,7 @@ pub enum TriggerKind {
Kafka,
Email,
Nats,
Postgres,
}
impl fmt::Display for TriggerKind {
@@ -99,6 +110,7 @@ impl fmt::Display for TriggerKind {
TriggerKind::Kafka => "kafka",
TriggerKind::Email => "email",
TriggerKind::Nats => "nats",
TriggerKind::Postgres => "postgres",
};
write!(f, "{}", s)
}
@@ -133,6 +145,16 @@ pub struct NatsTriggerConfig {
pub use_jetstream: bool,
}
#[cfg(feature = "postgres_trigger")]
#[derive(Serialize, Deserialize, Debug)]
pub struct PostgresTriggerConfig {
pub postgres_resource_path: String,
pub publication_name: Option<String>,
pub replication_slot_name: Option<String>,
pub publication: PublicationData,
}
#[cfg(feature = "websocket")]
#[derive(Serialize, Deserialize, Debug)]
pub struct WebsocketTriggerConfig {
pub url: String,
@@ -145,6 +167,9 @@ pub struct WebsocketTriggerConfig {
enum TriggerConfig {
#[cfg(feature = "http_trigger")]
Http(HttpTriggerConfig),
#[cfg(feature = "postgres_trigger")]
Postgres(PostgresTriggerConfig),
#[cfg(feature = "websocket")]
Websocket(WebsocketTriggerConfig),
#[cfg(all(feature = "enterprise", feature = "kafka"))]
Kafka(KafkaTriggerConfig),
@@ -186,18 +211,88 @@ async fn get_configs(
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(configs))
}
#[cfg(feature = "postgres_trigger")]
async fn set_postgres_trigger_config(
w_id: &str,
authed: ApiAuthed,
db: &DB,
user_db: UserDB,
mut capture_config: NewCaptureConfig,
) -> Result<NewCaptureConfig> {
let Some(TriggerConfig::Postgres(mut postgres_config)) = capture_config.trigger_config else {
return Err(windmill_common::error::Error::BadRequest(
"Invalid postgres config".to_string(),
));
};
let mut connection = get_database_connection(
authed,
Some(user_db),
&db,
&postgres_config.postgres_resource_path,
&w_id,
)
.await?;
let publication_name = postgres_config
.publication_name
.get_or_insert(format!("windmill_capture_{}", generate_random_string()));
let replication_slot_name = postgres_config
.replication_slot_name
.get_or_insert(publication_name.clone());
let query = drop_publication_query(&publication_name);
sqlx::query(&query).execute(&mut connection).await?;
let query = create_publication_query(
&publication_name,
postgres_config.publication.table_to_track.as_deref(),
&postgres_config
.publication
.transaction_to_track
.iter()
.map(AsRef::as_ref)
.collect_vec(),
);
sqlx::query(&query).execute(&mut connection).await?;
let query = format!(
"SELECT 1 from pg_replication_slots WHERE slot_name = {}",
quote_literal(replication_slot_name)
);
let row = sqlx::query(&query).fetch_optional(&mut connection).await?;
if row.is_none() {
let query = create_logical_replication_slot_query(&replication_slot_name);
sqlx::query(&query).execute(&mut connection).await?;
}
capture_config.trigger_config = Some(TriggerConfig::Postgres(postgres_config));
Ok(capture_config)
}
async fn set_config(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
#[cfg(feature = "postgres_trigger")] Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(nc): Json<NewCaptureConfig>,
) -> Result<()> {
#[cfg(feature = "postgres_trigger")]
let nc = if let TriggerKind::Postgres = nc.trigger_kind {
set_postgres_trigger_config(&w_id, authed.clone(), &db, user_db.clone(), nc).await?
}
else {
nc
};
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn workspaced_service() -> Router {
.route("/route_exists", post(exists_route))
}
#[derive(Serialize, Deserialize, sqlx::Type)]
#[derive(Serialize, Deserialize, sqlx::Type, Debug)]
#[sqlx(type_name = "HTTP_METHOD", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum HttpMethod {
@@ -1,9 +1,6 @@
use std::{
collections::{
hash_map::Entry::{Occupied, Vacant},
HashMap,
},
str::FromStr,
use std::collections::{
hash_map::Entry::{Occupied, Vacant},
HashMap,
};
use crate::{
@@ -14,29 +11,28 @@ use axum::{
extract::{Path, Query},
Extension, Json,
};
use chrono::Utc;
use http::StatusCode;
use itertools::Itertools;
use pg_escape::{quote_identifier, quote_literal};
use quick_cache::sync::Cache;
use rand::Rng;
use rust_postgres::types::Type;
use serde::{Deserialize, Deserializer, Serialize};
use sql_builder::{bind::Bind, SqlBuilder};
use sqlx::{
postgres::{types::Oid, PgConnectOptions, PgSslMode},
Connection, FromRow, PgConnection, QueryBuilder,
};
use sqlx::{postgres::types::Oid, FromRow, PgConnection};
use windmill_audit::{audit_ee::audit_log, ActionKind};
use windmill_common::error::Error;
use windmill_common::{
db::UserDB,
error::{self, JsonResult},
error::{self, JsonResult, Result},
utils::{not_found_if_none, paginate, Pagination, StripPath},
worker::CLOUD_HOSTED,
};
use super::get_database_resource;
use super::{
create_logical_replication_slot_query, create_publication_query,
drop_logical_replication_slot_query, drop_publication_query, generate_random_string,
get_database_connection, ERROR_PUBLICATION_NAME_NOT_EXISTS, ERROR_REPLICATION_SLOT_NOT_EXISTS,
};
use lazy_static::lazy_static;
#[derive(FromRow, Serialize, Deserialize, Debug)]
@@ -88,7 +84,7 @@ impl Relations {
}
}
#[derive(Deserialize)]
#[derive(Debug, Deserialize)]
pub struct EditPostgresTrigger {
replication_slot_name: String,
publication_name: String,
@@ -112,48 +108,39 @@ pub struct NewPostgresTrigger {
publication: Option<PublicationData>,
}
pub async fn get_database_connection(
authed: ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
) -> Result<PgConnection, windmill_common::error::Error> {
let database = get_database_resource(authed, user_db, db, postgres_resource_path, w_id).await?;
Ok(get_raw_postgres_connection(&database).await?)
#[derive(Serialize, Deserialize)]
pub struct TestPostgres {
pub postgres_resource_path: String,
}
pub async fn get_raw_postgres_connection(db: &Database) -> Result<PgConnection, Error> {
let options = {
let sslmode = if !db.sslmode.is_empty() {
PgSslMode::from_str(&db.sslmode)?
} else {
PgSslMode::Prefer
};
let options = PgConnectOptions::new()
.host(&db.host)
.database(&db.dbname)
.port(db.port)
.ssl_mode(sslmode)
.username(&db.user);
let options = if !db.root_certificate_pem.is_empty() {
options.ssl_root_cert_from_pem(db.root_certificate_pem.as_bytes().to_vec())
} else {
options
};
if !db.password.is_empty() {
options.password(&db.password)
} else {
options
}
};
PgConnection::connect_with(&options)
pub async fn test_postgres_connection(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
Json(test_postgres): Json<TestPostgres>,
) -> Result<()> {
let connect_f = async {
get_database_connection(
authed,
Some(user_db),
&db,
&test_postgres.postgres_resource_path,
&workspace_id,
)
.await
.map_err(|e| e.into())
.map_err(|err| {
error::Error::BadConfig(format!("Error connecting to postgres: {}", err.to_string()))
})
};
tokio::time::timeout(tokio::time::Duration::from_secs(30), connect_f)
.await
.map_err(|_| {
error::Error::BadConfig(format!("Timeout connecting to postgres after 30 seconds"))
})??;
Ok(())
}
#[derive(Deserialize, Debug)]
@@ -165,19 +152,20 @@ pub enum Language {
#[derive(Debug, Deserialize)]
pub struct TemplateScript {
postgres_resource_path: String,
#[serde(deserialize_with = "check_if_not_duplication_relation")]
#[serde(deserialize_with = "check_if_valid_relation")]
relations: Option<Vec<Relations>>,
language: Language,
}
fn check_if_not_duplication_relation<'de, D>(
fn check_if_valid_relation<'de, D>(
relations: D,
) -> std::result::Result<Option<Vec<Relations>>, D::Error>
where
D: Deserializer<'de>,
{
let relations: Option<Vec<Relations>> = Option::deserialize(relations)?;
let mut track_all_table_in_schema = false;
let mut track_specific_columns_in_table = false;
match relations {
Some(relations) => {
for relation in relations.iter() {
@@ -187,12 +175,25 @@ where
));
}
if !track_all_table_in_schema && relation.table_to_track.is_empty() {
track_all_table_in_schema = true;
continue;
}
for table_to_track in relation.table_to_track.iter() {
if table_to_track.table_name.trim().is_empty() {
return Err(serde::de::Error::custom(
"Table name must not be empty".to_string(),
));
}
if !track_specific_columns_in_table && !table_to_track.columns_name.is_empty() {
track_specific_columns_in_table = true;
}
}
if track_all_table_in_schema && track_specific_columns_in_table {
return Err(serde::de::Error::custom("Incompatible tracking options. Schema-level tracking and specific table tracking with column selection cannot be used together. Refer to the documentation for valid configurations."));
}
}
@@ -249,13 +250,105 @@ pub struct SetEnabled {
pub enabled: bool,
}
#[derive(Serialize, Deserialize)]
pub struct PostgresPublicationReplication {
publication_name: String,
replication_slot_name: String,
}
impl PostgresPublicationReplication {
pub fn new(
publication_name: String,
replication_slot_name: String,
) -> PostgresPublicationReplication {
PostgresPublicationReplication { publication_name, replication_slot_name }
}
}
async fn check_if_publication_exist(
connection: &mut PgConnection,
publication_name: &str,
) -> Result<()> {
sqlx::query!(
"SELECT pubname FROM pg_publication WHERE pubname = $1",
publication_name
)
.fetch_one(connection)
.await
.map_err(|err| match err {
sqlx::Error::RowNotFound => {
Error::BadRequest(ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string())
}
err => Error::SqlErr { error: err, location: "pg_trigger".to_string() },
})?;
Ok(())
}
async fn check_if_logical_replication_slot_exist(
connection: &mut PgConnection,
replication_slot_name: &str,
) -> Result<()> {
sqlx::query!(
"SELECT slot_name FROM pg_replication_slots where slot_name = $1",
&replication_slot_name
)
.fetch_one(connection)
.await
.map_err(|err| match err {
_ => Error::BadRequest(ERROR_REPLICATION_SLOT_NOT_EXISTS.to_string()),
})?;
Ok(())
}
async fn create_custom_slot_and_publication_inner(
authed: ApiAuthed,
user_db: UserDB,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
publication: &PublicationData,
) -> Result<PostgresPublicationReplication> {
let publication_name = format!("windmill_trigger_{}", generate_random_string());
let replication_slot_name = publication_name.clone();
let query = create_publication_query(
&publication_name,
publication.table_to_track.as_deref(),
&publication
.transaction_to_track
.iter()
.map(AsRef::as_ref)
.collect_vec(),
);
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await?;
sqlx::query(&query).execute(&mut connection).await?;
let query = create_logical_replication_slot_query(&replication_slot_name);
sqlx::query(&query).execute(&mut connection).await?;
Ok(PostgresPublicationReplication::new(
publication_name,
replication_slot_name,
))
}
pub async fn create_postgres_trigger(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(new_postgres_trigger): Json<NewPostgresTrigger>,
) -> error::Result<(StatusCode, String)> {
) -> Result<(StatusCode, String)> {
if *CLOUD_HOSTED {
return Err(error::Error::BadRequest(
"Postgres triggers are not supported on multi-tenant cloud, use dedicated cloud or self-host".to_string(),
@@ -278,59 +371,32 @@ pub async fn create_postgres_trigger(
"Publication data is missing".to_string(),
));
}
let (pub_name, slot_name) = if publication_name.is_none() && replication_slot_name.is_none() {
if publication.is_none() {
return Err(Error::BadRequest("publication must be set".to_string()));
}
let PostgresPublicationReplication { publication_name, replication_slot_name } =
create_custom_slot_and_publication_inner(
authed.clone(),
user_db.clone(),
&db,
&postgres_resource_path,
&w_id,
&publication.unwrap(),
)
.await?;
let create_slot = replication_slot_name.is_none();
let create_publication = publication_name.is_none();
let name;
let mut pub_name = publication_name.as_deref().unwrap_or_default();
let mut slot_name = replication_slot_name.as_deref().unwrap_or_default();
if create_publication || create_slot {
let generate_random_string = move || {
let timestamp = Utc::now().timestamp_millis().to_string();
let mut rng = rand::rng();
let charset = "abcdefghijklmnopqrstuvwxyz0123456789";
let random_part = (0..10)
.map(|_| {
charset
.chars()
.nth(rng.random_range(0..charset.len()))
.unwrap()
})
.collect::<String>();
format!("{}_{}", timestamp, random_part)
};
name = format!("windmill_{}", generate_random_string());
pub_name = &name;
slot_name = &name;
let publication = publication.unwrap();
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await?;
new_publication(
&mut connection,
pub_name,
publication.table_to_track.as_deref(),
&publication
.transaction_to_track
.iter()
.map(AsRef::as_ref)
.collect_vec(),
)
.await?;
new_slot(&mut connection, slot_name).await?;
}
(publication_name, replication_slot_name)
} else {
if publication_name.is_none() {
return Err(Error::BadRequest("Missing publication name".to_string()));
} else if replication_slot_name.is_none() {
return Err(Error::BadRequest(
"Missing replication slot name".to_string(),
));
}
(replication_slot_name.unwrap(), publication_name.unwrap())
};
let mut tx = user_db.begin(&authed).await?;
@@ -450,10 +516,10 @@ pub async fn list_postgres_triggers(
#[derive(Deserialize, Serialize, Debug)]
pub struct PublicationData {
#[serde(default, deserialize_with = "check_if_not_duplication_relation")]
table_to_track: Option<Vec<Relations>>,
#[serde(default, deserialize_with = "check_if_valid_relation")]
pub table_to_track: Option<Vec<Relations>>,
#[serde(deserialize_with = "check_if_valid_transaction_type")]
transaction_to_track: Vec<String>,
pub transaction_to_track: Vec<String>,
}
fn check_if_valid_transaction_type<'de, D>(
@@ -509,7 +575,7 @@ pub async fn list_slot_name(
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
) -> error::Result<Json<Vec<SlotList>>> {
) -> Result<Json<Vec<SlotList>>> {
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
@@ -543,28 +609,13 @@ pub struct Slot {
name: String,
}
async fn new_slot(connection: &mut PgConnection, name: &str) -> error::Result<()> {
let query = format!(
r#"
SELECT
*
FROM
pg_create_logical_replication_slot({}, 'pgoutput');"#,
quote_literal(&name)
);
sqlx::query(&query).execute(connection).await?;
Ok(())
}
pub async fn create_slot(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
Json(Slot { name }): Json<Slot>,
) -> error::Result<String> {
) -> Result<String> {
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
@@ -574,7 +625,9 @@ pub async fn create_slot(
)
.await?;
new_slot(&mut connection, &name).await?;
let query = create_logical_replication_slot_query(&name);
sqlx::query(&query).execute(&mut connection).await?;
Ok(format!("Slot {} created!", name))
}
@@ -585,7 +638,7 @@ pub async fn drop_slot_name(
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
Json(Slot { name }): Json<Slot>,
) -> error::Result<String> {
) -> Result<String> {
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
@@ -595,7 +648,7 @@ pub async fn drop_slot_name(
)
.await?;
let query = format!("SELECT pg_drop_replication_slot({});", quote_literal(&name));
let query = drop_logical_replication_slot_query(&name);
sqlx::query(&query).execute(&mut connection).await?;
Ok(format!("Slot name {} deleted!", name))
@@ -610,7 +663,7 @@ pub async fn list_database_publication(
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
) -> error::Result<Json<Vec<String>>> {
) -> Result<Json<Vec<String>>> {
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
@@ -640,7 +693,7 @@ pub async fn get_publication_info(
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
) -> error::Result<Json<PublicationData>> {
) -> Result<Json<PublicationData>> {
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
@@ -651,13 +704,13 @@ pub async fn get_publication_info(
.await?;
let publication_data =
get_publication_scope_and_transaction(&publication_name, &mut connection).await;
get_publication_scope_and_transaction(&mut connection, &publication_name).await;
let (all_table, transaction_to_track) = match publication_data {
let (all_table, transaction_to_track) = match publication_data {
Ok(pub_data) => pub_data,
Err(Error::SqlErr { error: sqlx::Error::RowNotFound, .. }) => {
return Err(Error::NotFound(
"Publication was not found, please create a new publication".to_string(),
ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string(),
))
}
Err(e) => return Err(e),
@@ -674,82 +727,13 @@ pub async fn get_publication_info(
)))
}
async fn new_publication(
connection: &mut PgConnection,
publication_name: &str,
table_to_track: Option<&[Relations]>,
transaction_to_track: &[&str],
) -> Result<(), Error> {
let mut query = QueryBuilder::new("CREATE PUBLICATION ");
query.push(quote_identifier(publication_name));
match table_to_track {
Some(database_component) if !database_component.is_empty() => {
query.push(" FOR");
for (i, schema) in database_component.iter().enumerate() {
if schema.table_to_track.is_empty() {
query.push(" TABLES IN SCHEMA ");
query.push(quote_identifier(&schema.schema_name));
} else {
query.push(" TABLE ONLY ");
for (j, table) in schema.table_to_track.iter().enumerate() {
let table_name = quote_identifier(&table.table_name);
let schema_name = quote_identifier(&schema.schema_name);
let full_name = format!("{}.{}", &schema_name, &table_name);
query.push(full_name);
if !table.columns_name.is_empty() {
query.push(" (");
let columns = table
.columns_name
.iter()
.map(|column| quote_identifier(column))
.join(", ");
query.push(&columns);
query.push(")");
}
if let Some(where_clause) = &table.where_clause {
query.push(" WHERE (");
query.push(where_clause);
query.push(')');
}
if j + 1 != schema.table_to_track.len() {
query.push(", ");
}
}
}
if i < database_component.len() - 1 {
query.push(", ");
}
}
}
_ => {
query.push(" FOR ALL TABLES ");
}
};
if !transaction_to_track.is_empty() {
let transactions = || transaction_to_track.iter().join(", ");
query.push(" WITH (publish = '");
query.push(transactions());
query.push("');");
}
let query = query.build();
query.execute(&mut *connection).await?;
Ok(())
}
pub async fn create_publication(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
Json(publication_data): Json<PublicationData>,
) -> error::Result<String> {
) -> Result<String> {
let PublicationData { table_to_track, transaction_to_track } = publication_data;
let mut connection = get_database_connection(
@@ -761,13 +745,13 @@ pub async fn create_publication(
)
.await?;
new_publication(
&mut connection,
let query = create_publication_query(
&publication_name,
table_to_track.as_deref(),
&transaction_to_track.iter().map(AsRef::as_ref).collect_vec(),
)
.await?;
);
sqlx::query(&query).execute(&mut connection).await?;
Ok(format!(
"Publication {} successfully created!",
@@ -775,24 +759,12 @@ pub async fn create_publication(
))
}
async fn drop_publication(
publication_name: &str,
connection: &mut PgConnection,
) -> Result<(), Error> {
let mut query = QueryBuilder::new("DROP PUBLICATION IF EXISTS ");
let quoted_publication_name = quote_identifier(publication_name);
query.push(quoted_publication_name);
query.push(";");
query.build().execute(&mut *connection).await?;
Ok(())
}
pub async fn delete_publication(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
) -> error::Result<String> {
) -> Result<String> {
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
@@ -802,7 +774,9 @@ pub async fn delete_publication(
)
.await?;
drop_publication(&publication_name, &mut connection).await?;
let query = drop_publication_query(&publication_name);
sqlx::query(&query).execute(&mut connection).await?;
Ok(format!(
"Publication {} successfully deleted!",
@@ -810,65 +784,61 @@ pub async fn delete_publication(
))
}
async fn update_publication(
connection: &mut PgConnection,
pub fn get_update_publication_query(
publication_name: &str,
PublicationData { table_to_track, transaction_to_track }: PublicationData,
) -> error::Result<String> {
let (all_table, _) =
get_publication_scope_and_transaction(&publication_name, connection).await?;
let mut query = QueryBuilder::new("");
all_table: bool,
) -> Vec<String> {
let quoted_publication_name = quote_identifier(&publication_name);
let transaction_to_track_as_str = transaction_to_track.iter().join(",");
let mut queries = Vec::with_capacity(2);
match table_to_track {
Some(ref relations) if !relations.is_empty() => {
if all_table {
drop_publication(&publication_name, connection).await?;
new_publication(
connection,
queries.push(drop_publication_query(&publication_name));
queries.push(create_publication_query(
&publication_name,
table_to_track.as_deref(),
&transaction_to_track.iter().map(AsRef::as_ref).collect_vec(),
)
.await?;
));
} else {
query.push("ALTER PUBLICATION ");
query.push(&quoted_publication_name);
query.push(" SET");
let mut query = String::from("");
query.push_str("ALTER PUBLICATION ");
query.push_str(&quoted_publication_name);
query.push_str(" SET");
for (i, schema) in relations.iter().enumerate() {
if schema.table_to_track.is_empty() {
query.push(" TABLES IN SCHEMA ");
query.push_str(" TABLES IN SCHEMA ");
let quoted_schema = quote_identifier(&schema.schema_name);
query.push(&quoted_schema);
query.push_str(&quoted_schema);
} else {
query.push(" TABLE ONLY ");
query.push_str(" TABLE ONLY ");
for (j, table) in schema.table_to_track.iter().enumerate() {
let table_name = quote_identifier(&table.table_name);
let schema_name = quote_identifier(&schema.schema_name);
let full_name = format!("{}.{}", &schema_name, &table_name);
query.push(&full_name);
query.push_str(&full_name);
if !table.columns_name.is_empty() {
query.push(" (");
query.push_str(" (");
let columns = table
.columns_name
.iter()
.map(|column| quote_identifier(column))
.join(", ");
query.push(&columns);
query.push(") ");
query.push_str(&columns);
query.push_str(") ");
}
if let Some(where_clause) = &table.where_clause {
query.push(" WHERE (");
query.push(where_clause);
query.push_str(" WHERE (");
query.push_str(where_clause);
query.push(')');
}
if j + 1 != schema.table_to_track.len() {
query.push(", ");
query.push_str(", ");
}
}
}
@@ -876,36 +846,35 @@ async fn update_publication(
query.push(',');
}
}
query.push(";");
query.build().execute(&mut *connection).await?;
query.reset();
query.push("ALTER PUBLICATION ");
query.push(&quoted_publication_name);
query.push(format!(
query.push(';');
queries.push(query);
let mut query = String::new();
query.push_str("ALTER PUBLICATION ");
query.push_str(&quoted_publication_name);
query.push_str(&format!(
" SET (publish = '{}');",
transaction_to_track_as_str
));
queries.push(query);
}
}
_ => {
drop_publication(&publication_name, connection).await?;
queries.push(drop_publication_query(&publication_name));
let to_execute = format!(
r#"
CREATE
PUBLICATION {} FOR ALL TABLES WITH (publish = '{}')
PUBLICATION {} FOR ALL TABLES WITH (publish = '{}');
"#,
quoted_publication_name, transaction_to_track_as_str
);
query.push(&to_execute);
queries.push(to_execute);
}
};
query.build().execute(&mut *connection).await?;
Ok(format!(
"Publication {} successfully updated!",
publication_name
))
queries
}
pub async fn alter_publication(
@@ -914,7 +883,7 @@ pub async fn alter_publication(
Extension(db): Extension<DB>,
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
Json(publication_data): Json<PublicationData>,
) -> error::Result<String> {
) -> Result<String> {
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
@@ -923,15 +892,28 @@ pub async fn alter_publication(
&w_id,
)
.await?;
let message = update_publication(&mut connection, &publication_name, publication_data).await?;
Ok(message)
check_if_publication_exist(&mut connection, &publication_name).await?;
let (all_table, _) =
get_publication_scope_and_transaction(&mut connection, &publication_name).await?;
let queries = get_update_publication_query(&publication_name, publication_data, all_table);
for query in queries {
sqlx::query(&query).execute(&mut connection).await?;
}
Ok(format!(
"Publication {} updated with success",
publication_name
))
}
async fn get_publication_scope_and_transaction(
publication_name: &str,
connection: &mut PgConnection,
) -> Result<(bool, Vec<String>), Error> {
publication_name: &str,
) -> std::result::Result<(bool, Vec<String>), Error> {
#[derive(Debug, Deserialize, FromRow)]
struct PublicationTransaction {
all_table: bool,
@@ -976,7 +958,7 @@ async fn get_publication_scope_and_transaction(
async fn get_tracked_relations(
connection: &mut PgConnection,
publication_name: &str,
) -> error::Result<Vec<Relations>> {
) -> Result<Vec<Relations>> {
#[derive(Debug, Deserialize, FromRow)]
struct PublicationData {
schema_name: Option<String>,
@@ -989,14 +971,18 @@ async fn get_tracked_relations(
PublicationData,
r#"
SELECT
schemaname AS schema_name,
tablename AS table_name,
attnames AS columns,
rowfilter AS where_clause
schemaname AS schema_name,
tablename AS table_name,
CASE
WHEN array_length(attnames, 1) = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = pg_publication_tables.schemaname AND table_name = pg_publication_tables.tablename)
THEN NULL
ELSE attnames
END AS columns,
rowfilter AS where_clause
FROM
pg_publication_tables
WHERE
pubname = $1
pubname = $1;
"#,
publication_name
)
@@ -1011,7 +997,7 @@ async fn get_tracked_relations(
let table_to_track = TableToTrack::new(
publication.table_name.unwrap(),
publication.where_clause,
publication.columns.unwrap(),
publication.columns.unwrap_or_default(),
);
match entry {
Occupied(mut occuped) => {
@@ -1075,8 +1061,9 @@ pub async fn update_postgres_trigger(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(postgres_trigger): Json<EditPostgresTrigger>,
) -> error::Result<String> {
) -> Result<String> {
let workspace_path = path.to_path();
let EditPostgresTrigger {
replication_slot_name,
publication_name,
@@ -1087,16 +1074,26 @@ pub async fn update_postgres_trigger(
publication,
} = postgres_trigger;
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await?;
check_if_logical_replication_slot_exist(&mut connection, &replication_slot_name).await?;
if let Some(publication) = publication {
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await?;
update_publication(&mut connection, &publication_name, publication).await?;
check_if_publication_exist(&mut connection, &publication_name).await?;
let (all_table, _) =
get_publication_scope_and_transaction(&mut connection, &publication_name).await?;
let queries = get_update_publication_query(&publication_name, publication, all_table);
for query in queries {
sqlx::query(&query).execute(&mut connection).await?;
}
}
let mut tx = user_db.begin(&authed).await?;
@@ -1153,7 +1150,7 @@ pub async fn delete_postgres_trigger(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> error::Result<String> {
) -> Result<String> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
@@ -1213,7 +1210,7 @@ pub async fn set_enabled(
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(payload): Json<SetEnabled>,
) -> error::Result<String> {
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
let path = path.to_path();
@@ -1264,7 +1261,7 @@ pub async fn set_enabled(
))
}
pub async fn get_template_script(Path((_, id)): Path<(String, String)>) -> error::Result<String> {
pub async fn get_template_script(Path((_, id)): Path<(String, String)>) -> Result<String> {
let template = if let Some((_, template)) = TEMPLATE.remove(&id) {
template
} else {
@@ -1279,7 +1276,7 @@ pub async fn create_template_script(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(template_script): Json<TemplateScript>,
) -> error::Result<String> {
) -> Result<String> {
let TemplateScript { postgres_resource_path, relations, language } = template_script;
if relations.is_none() {
return Err(Error::BadRequest(
@@ -1460,4 +1457,4 @@ pub async fn is_database_in_logical_level(
};
Ok(Json(is_logical))
}
}
@@ -4,8 +4,17 @@ use crate::{
resources::get_resource_value_interpolated_internal,
users::fetch_api_authed,
};
use chrono::Utc;
use itertools::Itertools;
use pg_escape::{quote_identifier, quote_literal};
use rand::Rng;
use serde_json::value::RawValue;
use sqlx::{
postgres::{PgConnectOptions, PgSslMode},
Connection, PgConnection,
};
use std::collections::HashMap;
use std::str::FromStr;
use axum::{
routing::{delete, get, post},
@@ -16,8 +25,10 @@ use handler::{
create_template_script, delete_postgres_trigger, delete_publication, drop_slot_name,
exists_postgres_trigger, get_postgres_trigger, get_publication_info, get_template_script,
is_database_in_logical_level, list_database_publication, list_postgres_triggers,
list_slot_name, set_enabled, update_postgres_trigger, Database,
list_slot_name, set_enabled, test_postgres_connection, update_postgres_trigger, Database,
Relations,
};
pub use handler::PostgresTrigger;
use windmill_common::{db::UserDB, error::Error, utils::StripPath};
use windmill_queue::PushArgsOwned;
@@ -30,9 +41,167 @@ mod relation;
mod replication_message;
mod trigger;
pub use handler::PostgresTrigger;
pub use handler::PublicationData;
pub use trigger::start_database;
const ERROR_REPLICATION_SLOT_NOT_EXISTS: &str = r#"The replication slot associated with this trigger no longer exists. Recreate a new replication slot or select an existing one in the advanced tab, or delete and recreate a new trigger"#;
const ERROR_PUBLICATION_NAME_NOT_EXISTS: &str = r#"The publication associated with this trigger no longer exists. Recreate a new publication or select an existing one in the advanced tab, or delete and recreate a new trigger"#;
pub async fn get_database_connection(
authed: ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
) -> std::result::Result<PgConnection, windmill_common::error::Error> {
let database = get_database_resource(authed, user_db, db, postgres_resource_path, w_id).await?;
Ok(get_raw_postgres_connection(&database).await?)
}
pub async fn get_raw_postgres_connection(
db: &Database,
) -> std::result::Result<PgConnection, Error> {
let options = {
let sslmode = if !db.sslmode.is_empty() {
PgSslMode::from_str(&db.sslmode)?
} else {
PgSslMode::Prefer
};
let options = PgConnectOptions::new()
.host(&db.host)
.database(&db.dbname)
.port(db.port)
.ssl_mode(sslmode)
.username(&db.user);
let options = if !db.root_certificate_pem.is_empty() {
options.ssl_root_cert_from_pem(db.root_certificate_pem.as_bytes().to_vec())
} else {
options
};
if !db.password.is_empty() {
options.password(&db.password)
} else {
options
}
};
Ok(PgConnection::connect_with(&options).await?)
}
pub fn create_logical_replication_slot_query(name: &str) -> String {
let query = format!(
r#"
SELECT
*
FROM
pg_create_logical_replication_slot({}, 'pgoutput');"#,
quote_literal(&name)
);
query
}
pub fn create_publication_query(
publication_name: &str,
table_to_track: Option<&[Relations]>,
transaction_to_track: &[&str],
) -> String {
let mut query = String::from("CREATE PUBLICATION ");
query.push_str(&quote_identifier(publication_name));
match table_to_track {
Some(database_component) if !database_component.is_empty() => {
query.push_str(" FOR");
for (i, schema) in database_component.iter().enumerate() {
if schema.table_to_track.is_empty() {
query.push_str(" TABLES IN SCHEMA ");
query.push_str(&quote_identifier(&schema.schema_name));
} else {
query.push_str(" TABLE ONLY ");
for (j, table) in schema.table_to_track.iter().enumerate() {
let table_name = quote_identifier(&table.table_name);
let schema_name = quote_identifier(&schema.schema_name);
let full_name = format!("{}.{}", &schema_name, &table_name);
query.push_str(&full_name);
if !table.columns_name.is_empty() {
query.push_str(" (");
let columns = table
.columns_name
.iter()
.map(|column| quote_identifier(column))
.join(", ");
query.push_str(&columns);
query.push_str(")");
}
if let Some(where_clause) = &table.where_clause {
query.push_str(" WHERE (");
query.push_str(where_clause);
query.push(')');
}
if j + 1 != schema.table_to_track.len() {
query.push_str(", ");
}
}
}
if i < database_component.len() - 1 {
query.push_str(", ");
}
}
}
_ => {
query.push_str(" FOR ALL TABLES ");
}
};
if !transaction_to_track.is_empty() {
let transactions = || transaction_to_track.iter().join(", ");
query.push_str(" WITH (publish = '");
query.push_str(&transactions());
query.push_str("');");
}
query
}
pub fn drop_publication_query(publication_name: &str) -> String {
let mut query = String::from("DROP PUBLICATION IF EXISTS ");
let quoted_publication_name = quote_identifier(publication_name);
query.push_str(&quoted_publication_name);
query.push_str(";");
query
}
pub fn drop_logical_replication_slot_query(replication_slot_name: &str) -> String {
format!(
"SELECT pg_drop_replication_slot({});",
quote_literal(&replication_slot_name)
)
}
pub fn generate_random_string() -> String {
let timestamp = Utc::now().timestamp_millis().to_string();
let mut rng = rand::rng();
let charset = "abcdefghijklmnopqrstuvwxyz0123456789";
let random_part = (0..10)
.map(|_| {
charset
.chars()
.nth(rng.random_range(0..charset.len()))
.unwrap()
})
.collect::<String>();
format!("{}_{}", timestamp, random_part)
}
pub async fn get_database_resource(
authed: ApiAuthed,
user_db: Option<UserDB>,
@@ -87,6 +256,7 @@ fn slot_service() -> Router {
pub fn workspaced_service() -> Router {
Router::new()
.route("/test", post(test_postgres_connection))
.route("/create", post(create_postgres_trigger))
.route("/list", get(list_postgres_triggers))
.route("/get/*path", get(get_postgres_trigger))
@@ -1,7 +1,8 @@
use std::{collections::HashMap, pin::Pin};
use crate::{
db::DB,
capture::{insert_capture_payload, PostgresTriggerConfig, TriggerKind},
db::{ApiAuthed, DB},
postgres_triggers::{
get_database_resource,
relation::RelationConverter,
@@ -21,13 +22,20 @@ use pg_escape::{quote_identifier, quote_literal};
use rand::seq::SliceRandom;
use rust_postgres::{config::SslMode, Client, Config, CopyBothDuplex, SimpleQueryMessage};
use rust_postgres_native_tls::MakeTlsConnector;
use serde::Deserialize;
use serde_json::value::RawValue;
use sqlx::types::Json as SqlxJson;
use windmill_common::{
db::UserDB, utils::report_critical_error, worker::to_raw_value, INSTANCE_NAME,
db::UserDB, error, utils::report_critical_error, worker::to_raw_value, INSTANCE_NAME,
};
use windmill_queue::PushArgsOwned;
use super::{
drop_logical_replication_slot_query, drop_publication_query, get_database_connection,
handler::{Database, PostgresTrigger},
replication_message::PrimaryKeepAliveBody,
ERROR_PUBLICATION_NAME_NOT_EXISTS, ERROR_REPLICATION_SLOT_NOT_EXISTS,
};
pub struct LogicalReplicationSettings {
@@ -106,6 +114,7 @@ impl PostgresSimpleClient {
let connector = MakeTlsConnector::new(TlsConnector::new()?);
let (client, connection) = config.connect(connector).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::debug!("{:#?}", e);
@@ -116,6 +125,13 @@ impl PostgresSimpleClient {
Ok(PostgresSimpleClient(client))
}
async fn execute_query(
&self,
query: &str,
) -> Result<Vec<SimpleQueryMessage>, rust_postgres::Error> {
self.0.simple_query(query).await
}
async fn get_logical_replication_stream(
&self,
publication_name: &str,
@@ -162,75 +178,9 @@ impl PostgresSimpleClient {
}
}
async fn update_ping(
db: &DB,
postgres_trigger: &PostgresTrigger,
error: Option<&str>,
) -> Option<()> {
let updated = sqlx::query_scalar!(
r#"
UPDATE
postgres_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,
&postgres_trigger.workspace_id,
&postgres_trigger.path,
*INSTANCE_NAME
)
.fetch_optional(db)
.await;
match updated {
Ok(updated) => {
if updated.flatten().is_none() {
// allow faster restart of database trigger
sqlx::query!(
r#"
UPDATE
postgres_trigger
SET
last_server_ping = NULL
WHERE
workspace_id = $1
AND path = $2
AND server_id IS NULL"#,
&postgres_trigger.workspace_id,
&postgres_trigger.path,
)
.execute(db)
.await
.ok();
tracing::info!(
"Postgres trigger {} changed, disabled, or deleted, stopping...",
postgres_trigger.path
);
return None;
}
}
Err(err) => {
tracing::warn!(
"Error updating ping of postgres trigger {}: {:?}",
postgres_trigger.path,
err
);
}
};
Some(())
}
async fn loop_ping(db: &DB, postgres_trigger: &PostgresTrigger, error: Option<&str>) {
async fn loop_ping(db: &DB, pg: &PostgresConfig, error: Option<&str>) {
loop {
if update_ping(db, postgres_trigger, error).await.is_none() {
if pg.update_ping(db, error).await.is_none() {
return;
}
@@ -238,78 +188,395 @@ async fn loop_ping(db: &DB, postgres_trigger: &PostgresTrigger, error: Option<&s
}
}
async fn disable_with_error(postgres_trigger: &PostgresTrigger, db: &DB, error: String) -> () {
match sqlx::query!(
"UPDATE postgres_trigger SET enabled = FALSE, error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3",
error,
postgres_trigger.workspace_id,
postgres_trigger.path,
)
.execute(db).await {
Ok(_) => {
report_critical_error(format!("Disabling postgres trigger {} because of error: {}", postgres_trigger.path, error), db.clone(), Some(&postgres_trigger.workspace_id), None).await;
},
Err(disable_err) => {
report_critical_error(
format!("Could not disable postgres trigger {} with err {}, disabling because of error {}", postgres_trigger.path, disable_err, error),
db.clone(),
Some(&postgres_trigger.workspace_id),
None,
).await;
enum PostgresConfig {
Trigger(PostgresTrigger),
Capture(CaptureConfigForPostgresTrigger),
}
impl PostgresTrigger {
async fn try_to_listen_to_database_transactions(
self,
db: DB,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
let postgres_trigger = sqlx::query_scalar!(
r#"
UPDATE postgres_trigger
SET
server_id = $1,
last_server_ping = now(),
error = 'Connecting...'
WHERE
enabled IS TRUE
AND workspace_id = $2
AND path = $3
AND (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;
match postgres_trigger {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tracing::info!("Spawning new task to listen_to_database_transaction");
tokio::spawn(async move {
listen_to_transactions(
PostgresConfig::Trigger(self),
db.clone(),
killpill_rx,
)
.await;
});
} else {
tracing::info!("Postgres trigger {} already being listened to", self.path);
}
}
Err(err) => {
tracing::error!(
"Error acquiring lock for postgres trigger {}: {:?}",
self.path,
err
);
}
};
}
async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> {
let updated = sqlx::query_scalar!(
r#"
UPDATE
postgres_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,
&self.workspace_id,
&self.path,
*INSTANCE_NAME
)
.fetch_optional(db)
.await;
match updated {
Ok(updated) => {
if updated.flatten().is_none() {
// allow faster restart of database trigger
sqlx::query!(
r#"
UPDATE
postgres_trigger
SET
last_server_ping = NULL
WHERE
workspace_id = $1
AND path = $2
AND server_id IS NULL"#,
&self.workspace_id,
&self.path,
)
.execute(db)
.await
.ok();
tracing::info!(
"Postgres trigger {} changed, disabled, or deleted, stopping...",
self.path
);
return None;
}
}
Err(err) => {
tracing::warn!(
"Error updating ping of postgres trigger {}: {:?}",
self.path,
err
);
}
};
Some(())
}
async fn disable_with_error(&self, db: &DB, error: String) -> () {
match sqlx::query!(
r#"
UPDATE
postgres_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 postgres trigger {} because of error: {}",
self.path, error
),
db.clone(),
Some(&self.workspace_id),
None,
)
.await;
}
Err(disable_err) => {
report_critical_error(
format!("Could not disable postgres trigger {} with err {}, disabling because of error {}", self.path, disable_err, error),
db.clone(),
Some(&self.workspace_id),
None,
).await;
}
}
}
async fn fetch_authed(&self, db: &DB) -> error::Result<ApiAuthed> {
fetch_api_authed(
self.edited_by.clone(),
self.email.clone(),
&self.workspace_id,
db,
Some(format!("pg-{}", self.path)),
)
.await
}
async fn handle(
&self,
db: &DB,
args: Option<HashMap<String, Box<RawValue>>>,
extra: Option<HashMap<String, Box<RawValue>>>,
) -> () {
if let Err(err) = run_job(args, extra, db, self).await {
report_critical_error(
format!(
"Failed to trigger job from postgres {}: {:?}",
self.path, err
),
db.clone(),
Some(&self.workspace_id),
None,
)
.await;
};
}
}
async fn listen_to_transactions(
postgres_trigger: &PostgresTrigger,
db: DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
let start_logical_replication_streaming = async {
let authed = fetch_api_authed(
postgres_trigger.edited_by.clone(),
postgres_trigger.email.clone(),
&postgres_trigger.workspace_id,
&db,
None,
)
.await?;
struct PgInfo<'a> {
postgres_resource_path: &'a str,
publication_name: &'a str,
replication_slot_name: &'a str,
workspace_id: &'a str,
}
impl PostgresConfig {
async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> {
match self {
PostgresConfig::Trigger(trigger) => trigger.update_ping(db, error).await,
PostgresConfig::Capture(capture) => capture.update_ping(db, error).await,
}
}
async fn disable_with_error(&self, db: &DB, error: String) -> () {
match self {
PostgresConfig::Trigger(trigger) => trigger.disable_with_error(&db, error).await,
PostgresConfig::Capture(capture) => capture.disable_with_error(db, error).await,
}
}
fn retrieve_info(&self) -> PgInfo {
let postgres_resource_path;
let publication_name;
let replication_slot_name;
let workspace_id;
match self {
PostgresConfig::Trigger(trigger) => {
postgres_resource_path = &trigger.postgres_resource_path;
publication_name = &trigger.publication_name;
replication_slot_name = &trigger.replication_slot_name;
workspace_id = &trigger.workspace_id;
}
PostgresConfig::Capture(capture) => {
postgres_resource_path = &capture.trigger_config.postgres_resource_path;
workspace_id = &capture.workspace_id;
publication_name = capture.trigger_config.publication_name.as_ref().unwrap();
replication_slot_name = capture
.trigger_config
.replication_slot_name
.as_ref()
.unwrap();
}
};
PgInfo { postgres_resource_path, replication_slot_name, workspace_id, publication_name }
}
async fn start_logical_replication_streaming(
&self,
db: &DB,
) -> std::result::Result<(CopyBothDuplex<Bytes>, LogicalReplicationSettings), Error> {
let PgInfo {
publication_name,
replication_slot_name,
workspace_id,
postgres_resource_path,
} = self.retrieve_info();
let authed = match self {
PostgresConfig::Trigger(trigger) => trigger.fetch_authed(db).await?,
PostgresConfig::Capture(capture) => capture.fetch_authed(db).await?,
};
let database = get_database_resource(
authed,
Some(UserDB::new(db.clone())),
&db,
&postgres_trigger.postgres_resource_path,
&postgres_trigger.workspace_id,
postgres_resource_path,
workspace_id,
)
.await?;
let client = PostgresSimpleClient::new(&database).await?;
let (logical_replication_stream, logical_replication_settings) = client
.get_logical_replication_stream(
&postgres_trigger.publication_name,
&postgres_trigger.replication_slot_name,
)
let publication = client
.execute_query(&format!(
"SELECT pubname FROM pg_publication WHERE pubname = {}",
quote_literal(&publication_name)
))
.await?;
Ok::<_, Error>((logical_replication_stream, logical_replication_settings))
};
if !publication.row_exist() {
return Err(Error::Common(error::Error::BadConfig(
ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string(),
)));
}
let replication_slot = client
.execute_query(&format!(
"SELECT slot_name FROM pg_replication_slots WHERE slot_name = {}",
quote_literal(&replication_slot_name)
))
.await?;
if !replication_slot.row_exist() {
return Err(Error::Common(error::Error::BadConfig(
ERROR_REPLICATION_SLOT_NOT_EXISTS.to_string(),
)));
}
let (logical_replication_stream, logical_replication_settings) = client
.get_logical_replication_stream(&publication_name, &replication_slot_name)
.await?;
Ok((logical_replication_stream, logical_replication_settings))
}
fn get_path(&self) -> &str {
match self {
PostgresConfig::Trigger(trigger) => &trigger.path,
PostgresConfig::Capture(capture) => &capture.path,
}
}
async fn handle(
&self,
db: &DB,
args: Option<HashMap<String, Box<RawValue>>>,
extra: Option<HashMap<String, Box<RawValue>>>,
) -> () {
match self {
PostgresConfig::Trigger(trigger) => trigger.handle(&db, args, extra).await,
PostgresConfig::Capture(capture) => capture.handle(&db, args, extra).await,
}
}
async fn cleanup(&self, db: &DB) -> Result<(), Error> {
match self {
PostgresConfig::Trigger(_) => Ok(()),
PostgresConfig::Capture(capture) => {
let publication_name = capture.trigger_config.publication_name.as_ref().unwrap();
let replication_slot_name = capture
.trigger_config
.replication_slot_name
.as_ref()
.unwrap();
let postgres_resource_path = &capture.trigger_config.postgres_resource_path;
let workspace_id = &capture.workspace_id;
let authed = capture.fetch_authed(&db).await?;
let user_db = UserDB::new(db.clone());
let mut connection = get_database_connection(
authed.clone(),
Some(user_db.clone()),
&db,
postgres_resource_path,
workspace_id,
)
.await?;
let query = drop_logical_replication_slot_query(replication_slot_name);
let _ = sqlx::query(&query).execute(&mut connection).await;
let query = drop_publication_query(publication_name);
let _ = sqlx::query(&query).execute(&mut connection).await;
Ok(())
}
}
}
}
async fn listen_to_transactions(
pg: PostgresConfig,
db: DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
let _ = pg.cleanup(&db).await;
return;
}
_ = loop_ping(&db, postgres_trigger, Some("Connecting...")) => {
_ = loop_ping(&db, &pg, Some("Connecting...")) => {
let _ = pg.cleanup(&db).await;
return;
}
result = start_logical_replication_streaming => {
result = pg.start_logical_replication_streaming(&db) => {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
let _ = pg.cleanup(&db).await;
return;
}
_ = loop_ping(&db, postgres_trigger, None) => {
_ = loop_ping(&db, &pg, None) => {
let _ = pg.cleanup(&db).await;
return;
}
_ = {
@@ -318,15 +585,15 @@ async fn listen_to_transactions(
Ok((logical_replication_stream, logical_replication_settings)) => {
pin_mut!(logical_replication_stream);
let mut relations = RelationConverter::new();
tracing::info!("Starting to listen for postgres trigger {}", postgres_trigger.path);
tracing::info!("Starting to listen for postgres trigger {}", pg.get_path());
loop {
let message = logical_replication_stream.next().await;
let message = match message {
Some(message) => message,
None => {
tracing::error!("Stream for postgres trigger {} closed", postgres_trigger.path);
if let None = update_ping(&db, postgres_trigger, Some("Stream closed")).await {
tracing::error!("Stream for postgres trigger {} closed", pg.get_path());
if let None = pg.update_ping(&db, Some("Stream closed")).await {
return;
}
return;
@@ -336,8 +603,8 @@ async fn listen_to_transactions(
let message = match message {
Ok(message) => message,
Err(err) => {
let err = format!("Postgres trigger named {} had an error while receiving a message : {}", &postgres_trigger.path, err.to_string());
disable_with_error(&postgres_trigger, &db, err).await;
let err = format!("Postgres trigger named {} had an error while receiving a message : {}", pg.get_path(), err.to_string());
pg.disable_with_error(&db, err).await;
return;
}
};
@@ -345,8 +612,8 @@ async fn listen_to_transactions(
let logical_message = match ReplicationMessage::parse(message) {
Ok(logical_message) => logical_message,
Err(err) => {
let err = format!("Postgres trigger named: {} had an error while parsing message: {}", postgres_trigger.path, err.to_string());
disable_with_error(&postgres_trigger, &db, err).await;
let err = format!("Postgres trigger named: {} had an error while parsing message: {}", pg.get_path(), err.to_string());
pg.disable_with_error(&db, err).await;
return;
}
};
@@ -362,7 +629,7 @@ async fn listen_to_transactions(
let logical_replication_message = match x_log_data.parse(&logical_replication_settings) {
Ok(logical_replication_message) => logical_replication_message,
Err(err) => {
tracing::error!("Postgres trigger named: {} had an error while trying to parse incomming stream message: {}", &postgres_trigger.path, err.to_string());
tracing::error!("Postgres trigger named: {} had an error while trying to parse incomming stream message: {}", pg.get_path(), err.to_string());
continue;
}
};
@@ -390,7 +657,7 @@ async fn listen_to_transactions(
let relation = match relations.get_relation(o_id) {
Ok(relation) => relation,
Err(err) => {
tracing::error!("Postgres trigger named: {}, error: {}", &postgres_trigger.path, err.to_string());
tracing::error!("Postgres trigger named: {}, error: {}", pg.get_path(), err.to_string());
continue;
}
};
@@ -404,7 +671,9 @@ async fn listen_to_transactions(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({"kind": "postgres", })),
)]));
let _ = run_job(Some(database_info), extra, &db, postgres_trigger).await;
let _ = pg.handle(&db, Some(database_info), extra).await;
}
}
@@ -413,11 +682,12 @@ async fn listen_to_transactions(
}
Err(err) => {
tracing::error!("Postgres trigger error while trying to start logical replication streaming: {}", &err);
disable_with_error(&postgres_trigger, &db, err.to_string()).await
pg.disable_with_error(&db, err.to_string()).await
}
}
}
} => {
let _ = pg.cleanup(&db).await;
return;
}
}
@@ -425,55 +695,204 @@ async fn listen_to_transactions(
}
}
async fn try_to_listen_to_database_transactions(
pg_trigger: PostgresTrigger,
db: DB,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
let postgres_trigger = sqlx::query_scalar!(
r#"
UPDATE postgres_trigger
SET
server_id = $1,
last_server_ping = now(),
error = 'Connecting...'
WHERE
enabled IS TRUE
AND workspace_id = $2
AND path = $3
AND (last_server_ping IS NULL
OR last_server_ping < now() - INTERVAL '15 seconds'
)
RETURNING true
"#,
*INSTANCE_NAME,
pg_trigger.workspace_id,
pg_trigger.path,
)
.fetch_optional(&db)
.await;
match postgres_trigger {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tracing::info!("Spawning new task to listen_to_database_transaction");
tokio::spawn(async move {
listen_to_transactions(&pg_trigger, db.clone(), killpill_rx).await;
});
} else {
tracing::info!(
"Postgres trigger {} already being listened to",
pg_trigger.path
#[derive(Deserialize)]
struct CaptureConfigForPostgresTrigger {
trigger_config: SqlxJson<PostgresTriggerConfig>,
path: String,
is_flow: bool,
workspace_id: String,
owner: String,
email: String,
}
impl CaptureConfigForPostgresTrigger {
async fn try_to_listen_to_database_transactions(
self,
db: DB,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match sqlx::query_scalar!(
r#"
UPDATE
capture_config
SET
server_id = $1,
last_server_ping = now(),
error = 'Connecting...'
WHERE
last_client_ping > NOW() - INTERVAL '10 seconds' AND
workspace_id = $2 AND
path = $3 AND
is_flow = $4 AND
trigger_kind = 'postgres' AND
(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_transactions(
PostgresConfig::Capture(self),
db,
killpill_rx,
));
} else {
tracing::info!("Postgres {} already being listened to", self.path);
}
}
Err(err) => {
tracing::error!(
"Error acquiring lock for capture postgres {}: {:?}",
self.path,
err
);
}
};
}
async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> {
match sqlx::query_scalar!(
r#"
UPDATE
capture_config
SET
last_server_ping = now(),
error = $1
WHERE
workspace_id = $2 AND
path = $3 AND
is_flow = $4 AND
trigger_kind = 'postgres' AND
server_id = $5 AND
last_client_ping > NOW() - INTERVAL '10 seconds'
RETURNING 1
"#,
error,
self.workspace_id,
self.path,
self.is_flow,
*INSTANCE_NAME
)
.fetch_optional(db)
.await
{
Ok(updated) => {
if updated.flatten().is_none() {
// allow faster restart of postgres capture
sqlx::query!(
r#"UPDATE
capture_config
SET
last_server_ping = NULL
WHERE
workspace_id = $1 AND
path = $2 AND
is_flow = $3 AND
trigger_kind = 'postgres' AND
server_id IS NULL
"#,
self.workspace_id,
self.path,
self.is_flow,
)
.execute(db)
.await
.ok();
tracing::info!(
"Postgres capture {} changed, disabled, or deleted, stopping...",
self.path
);
return None;
}
}
Err(err) => {
tracing::warn!(
"Error updating ping of capture postgres {}: {:?}",
self.path,
err
);
}
};
Some(())
}
async fn fetch_authed(&self, db: &DB) -> error::Result<ApiAuthed> {
fetch_api_authed(
self.owner.clone(),
self.email.clone(),
&self.workspace_id,
db,
Some(format!("postgres-{}", self.get_trigger_path())),
)
.await
}
fn get_trigger_path(&self) -> String {
format!(
"{}-{}",
if self.is_flow { "flow" } else { "script" },
self.path
)
}
async fn disable_with_error(&self, db: &DB, error: String) -> () {
if let Err(err) = sqlx::query!(
r#"
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 = 'postgres'
"#,
error,
self.workspace_id,
self.path,
self.is_flow,
)
.execute(db)
.await
{
tracing::error!("Could not disable postgres capture {} ({}) with err {}, disabling because of error {}", self.path, self.workspace_id, err, error);
}
Err(err) => {
tracing::error!(
"Error acquiring lock for postgres trigger {}: {:?}",
pg_trigger.path,
err
);
}
async fn handle(
&self,
db: &DB,
args: Option<HashMap<String, Box<RawValue>>>,
extra: Option<HashMap<String, Box<RawValue>>>,
) -> () {
let args = PushArgsOwned { args: args.unwrap_or_default(), extra };
let extra = args.extra.as_ref().map(to_raw_value);
if let Err(err) = insert_capture_payload(
db,
&self.workspace_id,
&self.path,
self.is_flow,
&TriggerKind::Postgres,
args,
extra,
&self.owner,
)
.await
{
tracing::error!("Error inserting capture payload: {:?}", err);
}
};
}
}
async fn listen_to_unlistened_database_events(
@@ -515,18 +934,51 @@ async fn listen_to_unlistened_database_events(
Ok(mut triggers) => {
triggers.shuffle(&mut rand::rng());
for trigger in triggers {
try_to_listen_to_database_transactions(
trigger,
db.clone(),
killpill_rx.resubscribe(),
)
.await;
trigger
.try_to_listen_to_database_transactions(db.clone(), killpill_rx.resubscribe())
.await;
}
}
Err(err) => {
tracing::error!("Error fetching postgres triggers: {:?}", err);
}
};
let postgres_triggers_capture = sqlx::query_as!(
CaptureConfigForPostgresTrigger,
r#"
SELECT
path,
is_flow,
workspace_id,
owner,
email,
trigger_config as "trigger_config!: _"
FROM
capture_config
WHERE
trigger_kind = 'postgres' AND
last_client_ping > NOW() - INTERVAL '10 seconds' AND
trigger_config IS NOT NULL AND
(last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')
"#
)
.fetch_all(db)
.await;
match postgres_triggers_capture {
Ok(mut captures) => {
captures.shuffle(&mut rand::rng());
for capture in captures {
capture
.try_to_listen_to_database_transactions(db.clone(), killpill_rx.resubscribe())
.await;
}
}
Err(err) => {
tracing::error!("Error fetching captures postgres triggers: {:?}", err);
}
};
}
pub fn start_database(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) {
+91 -91
View File
@@ -336,8 +336,8 @@ pub(crate) async fn tarball_workspace(
{
let scripts = sqlx::query_as::<_, Script>(
"SELECT * FROM script as o WHERE workspace_id = $1 AND archived = false
AND created_at = (select max(created_at) from script where path = o.path AND \
workspace_id = $1)",
AND created_at = (select max(created_at) from script where path = o.path AND \
workspace_id = $1)",
)
.bind(&w_id)
.fetch_all(&mut *tx)
@@ -413,12 +413,12 @@ pub(crate) async fn tarball_workspace(
if !skip_resources.unwrap_or(false) {
let resources = sqlx::query_as!(
Resource,
"SELECT * FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'",
&w_id
)
.fetch_all(&mut *tx)
.await?;
Resource,
"SELECT * FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'",
&w_id
)
.fetch_all(&mut *tx)
.await?;
for resource in resources {
let resource_str = &to_string_without_metadata(&resource, false, None).unwrap();
@@ -450,14 +450,14 @@ pub(crate) async fn tarball_workspace(
{
let flows = sqlx::query_as::<_, Flow>(
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
FROM flow
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.workspace_id = $1 AND flow.archived = false",
)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
FROM flow
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.workspace_id = $1 AND flow.archived = false",
)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
for flow in flows {
let flow_str = &to_string_without_metadata(&flow, false, None).unwrap();
@@ -469,14 +469,14 @@ pub(crate) async fn tarball_workspace(
if !skip_variables.unwrap_or(false) {
let variables =
sqlx::query_as::<_, ExportableListableVariable>(if !skip_secrets.unwrap_or(false) {
"SELECT * FROM variable WHERE workspace_id = $1 AND expires_at IS NULL"
} else {
"SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL"
})
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
sqlx::query_as::<_, ExportableListableVariable>(if !skip_secrets.unwrap_or(false) {
"SELECT * FROM variable WHERE workspace_id = $1 AND expires_at IS NULL"
} else {
"SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL"
})
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let mc = build_crypt(&db, &w_id).await?;
@@ -496,14 +496,14 @@ pub(crate) async fn tarball_workspace(
{
let apps = sqlx::query_as::<_, AppWithLastVersion>(
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
app.extra_perms, app_version.value,
app_version.created_at, app_version.created_by from app, app_version
WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
app.extra_perms, app_version.value,
app_version.created_at, app_version.created_by from app, app_version
WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
for app in apps {
let app_str = &to_string_without_metadata(&app, false, None).unwrap();
@@ -516,7 +516,7 @@ pub(crate) async fn tarball_workspace(
if include_schedules.unwrap_or(false) {
let schedules = sqlx::query_as::<_, Schedule>(
"SELECT * FROM schedule
WHERE workspace_id = $1",
WHERE workspace_id = $1",
)
.bind(&w_id)
.fetch_all(&mut *tx)
@@ -534,13 +534,13 @@ pub(crate) async fn tarball_workspace(
#[cfg(feature = "http_trigger")]
{
let http_triggers = sqlx::query_as!(
crate::http_triggers::HttpTrigger,
"SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, edited_by, edited_at, email, extra_perms, is_async, requires_auth, http_method as \"http_method: _\", static_asset_config as \"static_asset_config: _\", is_static_website FROM http_trigger
WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
.await?;
crate::http_triggers::HttpTrigger,
"SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, edited_by, edited_at, email, extra_perms, is_async, requires_auth, http_method as \"http_method: _\", static_asset_config as \"static_asset_config: _\", is_static_website FROM http_trigger
WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
.await?;
for trigger in http_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
@@ -553,13 +553,13 @@ pub(crate) async fn tarball_workspace(
#[cfg(feature = "websocket")]
{
let websocket_triggers = sqlx::query_as!(
crate::websocket_triggers::WebsocketTrigger,
"SELECT workspace_id, path, url, script_path, is_flow, edited_by, email, edited_at, server_id, last_server_ping, extra_perms, error, enabled, filters as \"filters: _\", initial_messages as \"initial_messages: _\", url_runnable_args as \"url_runnable_args: _\", can_return_message FROM websocket_trigger
WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
.await?;
crate::websocket_triggers::WebsocketTrigger,
"SELECT workspace_id, path, url, script_path, is_flow, edited_by, email, edited_at, server_id, last_server_ping, extra_perms, error, enabled, filters as \"filters: _\", initial_messages as \"initial_messages: _\", url_runnable_args as \"url_runnable_args: _\", can_return_message FROM websocket_trigger
WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
.await?;
for trigger in websocket_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
@@ -577,7 +577,7 @@ pub(crate) async fn tarball_workspace(
let kafka_triggers = sqlx::query_as!(
crate::kafka_triggers_ee::KafkaTrigger,
"SELECT * FROM kafka_trigger
WHERE workspace_id = $1",
WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
@@ -599,7 +599,7 @@ pub(crate) async fn tarball_workspace(
let nats_triggers = sqlx::query_as!(
crate::nats_triggers_ee::NatsTrigger,
"SELECT * FROM nats_trigger
WHERE workspace_id = $1",
WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
@@ -619,7 +619,7 @@ pub(crate) async fn tarball_workspace(
let postgres_triggers = sqlx::query_as!(
crate::postgres_triggers::PostgresTrigger,
"SELECT * FROM postgres_trigger
WHERE workspace_id = $1",
WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
@@ -640,7 +640,7 @@ pub(crate) async fn tarball_workspace(
if include_users.unwrap_or(false) {
let users = sqlx::query!(
"SELECT * FROM usr
WHERE workspace_id = $1",
WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
@@ -668,16 +668,16 @@ pub(crate) async fn tarball_workspace(
if include_groups.unwrap_or(false) {
let groups = sqlx::query!(
r#"SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members
FROM usr u
JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id
RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_
WHERE g_.workspace_id = $1 AND g_.name != 'all'
GROUP BY g_.workspace_id, name, summary, extra_perms"#,
&w_id
)
.fetch_all(&mut *tx)
.await?;
r#"SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members
FROM usr u
JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id
RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_
WHERE g_.workspace_id = $1 AND g_.name != 'all'
GROUP BY g_.workspace_id, name, summary, extra_perms"#,
&w_id
)
.fetch_all(&mut *tx)
.await?;
for group in groups {
let extra_perms: HashMap<String, bool> = serde_json::from_value(group.extra_perms)
@@ -728,36 +728,36 @@ pub(crate) async fn tarball_workspace(
if include_settings.unwrap_or(false) {
let settings = sqlx::query_as!(
SimplifiedSettings,
r#"SELECT
-- slack_team_id,
-- slack_name,
-- slack_command_script,
-- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,
auto_invite_domain IS NOT NULL AS "auto_invite_enabled!",
CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS "auto_invite_as!",
CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS "auto_invite_mode!",
webhook,
deploy_to,
error_handler,
ai_resource,
ai_models,
code_completion_model,
error_handler_extra_args,
error_handler_muted_on_cancel,
large_file_storage,
git_sync,
default_app,
default_scripts,
workspace.name,
mute_critical_alerts,
color,
operator_settings
FROM workspace_settings
LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id
WHERE workspace_id = $1"#,
&w_id
).fetch_one(&mut *tx).await?;
SimplifiedSettings,
r#"SELECT
-- slack_team_id,
-- slack_name,
-- slack_command_script,
-- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,
auto_invite_domain IS NOT NULL AS "auto_invite_enabled!",
CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS "auto_invite_as!",
CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS "auto_invite_mode!",
webhook,
deploy_to,
error_handler,
ai_resource,
ai_models,
code_completion_model,
error_handler_extra_args,
error_handler_muted_on_cancel,
large_file_storage,
git_sync,
default_app,
default_scripts,
workspace.name,
mute_critical_alerts,
color,
operator_settings
FROM workspace_settings
LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id
WHERE workspace_id = $1"#,
&w_id
).fetch_one(&mut *tx).await?;
let settings_str = serde_json::to_value(settings)
.map(|v| serde_json::to_string_pretty(&v).ok())
@@ -104,10 +104,9 @@ mount {
iface_no_lo: true
mount {
src: "{CACHE_DIR}"
dst: "/tmp/.cache/csharp"
src: "{CACHE_DIR}/{CACHE_HASH}"
dst: "/tmp/.cache/csharp/{CACHE_HASH}"
is_bind: true
rw: true
mandatory: false
}
@@ -97,10 +97,9 @@ mount {
iface_no_lo: true
mount {
src: "{CACHE_DIR}"
dst: "/tmp/.cache/rust"
src: "{CACHE_DIR}/{CACHE_HASH}"
dst: "/tmp/.cache/rust/{CACHE_HASH}"
is_bind: true
rw: true
mandatory: false
}
@@ -543,6 +543,7 @@ pub async fn handle_csharp_job(
&NSJAIL_CONFIG_RUN_CSHARP_CONTENT
.replace("{JOB_DIR}", job_dir)
.replace("{CACHE_DIR}", CSHARP_CACHE_DIR)
.replace("{CACHE_HASH}", &hash)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{SHARED_MOUNT}", shared_mount),
)?;
@@ -353,6 +353,7 @@ pub async fn handle_rust_job(
&NSJAIL_CONFIG_RUN_RUST_CONTENT
.replace("{JOB_DIR}", job_dir)
.replace("{CACHE_DIR}", RUST_CACHE_DIR)
.replace("{CACHE_HASH}", &hash)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{SHARED_MOUNT}", shared_mount),
)?;
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.458.4";
export const VERSION = "v1.460.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -62,7 +62,7 @@ export {
// }
// });
export const VERSION = "1.458.4";
export const VERSION = "1.460.1";
const command = new Command()
.name("wmill")
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.458.4",
"version": "1.460.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.458.4",
"version": "1.460.1",
"license": "AGPL-3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.458.4",
"version": "1.460.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
+6 -4
View File
@@ -13,10 +13,12 @@
<div class="flex flex-row justify-between items-center w-full">
{#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}
<span class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6"
>{label}
{#if required}
<Required required={true} />
{/if}
</span>
<slot name="header" />
</div>
{/if}
@@ -46,6 +46,8 @@
<slot name="header" />
{#if tooltip}
<Tooltip {documentationLink}>{tooltip}</Tooltip>
{:else if $$slots.tooltip}
<slot name="tooltip" />
{/if}
{#if eeOnly}
{#if !$enterpriseLicense}
@@ -28,6 +28,7 @@
| 'websocket_trigger'
| 'kafka_trigger'
| 'nats_trigger'
| 'postgres_trigger'
let kind: Kind
let path: string = ''
@@ -89,75 +89,79 @@
configuration={configuration[key]}
/>
{/each}
<AlwaysMountedModal {css} title={resolvedConfig.modalTitle ?? ''} bind:this={modal}>
<div class="flex flex-col gap-2 px-4 w-full pt-2">
<RunnableWrapper
bind:this={runnableWrapper}
bind:loading
{recomputeIds}
{render}
bind:runnableComponent
{componentInput}
{id}
{extraQueryParams}
autoRefresh={false}
forceSchemaDisplay={true}
runnableClass="!block"
{outputs}
doOnSuccess={resolvedConfig.onSuccess}
doOnError={resolvedConfig.onError}
{errorHandledByComponent}
>
{#if noInputs}
<div class="text-secondary italic text-sm my-4">
Run forms are associated with a runnable that has user inputs.
<br />
Once a script or flow is chosen, set some <strong>Runnable Inputs</strong> to
<strong>
User Input
<User size={20} class="rounded-sm bg-gray-200 p-1 ml-0.5" />
</strong>
{#if render}
<AlwaysMountedModal {css} title={resolvedConfig.modalTitle ?? ''} bind:this={modal}>
<div class="flex flex-col gap-2 px-4 w-full pt-2">
<RunnableWrapper
bind:this={runnableWrapper}
bind:loading
{recomputeIds}
{render}
bind:runnableComponent
{componentInput}
{id}
{extraQueryParams}
autoRefresh={false}
forceSchemaDisplay={true}
runnableClass="!block"
{outputs}
doOnSuccess={resolvedConfig.onSuccess}
doOnError={resolvedConfig.onError}
{errorHandledByComponent}
>
{#if noInputs}
<div class="text-secondary italic text-sm my-4">
Run forms are associated with a runnable that has user inputs.
<br />
Once a script or flow is chosen, set some <strong>Runnable Inputs</strong> to
<strong>
User Input
<User size={20} class="rounded-sm bg-gray-200 p-1 ml-0.5" />
</strong>
</div>
{/if}
<div class="flex justify-end gap-3 p-2">
<Button
{loading}
btnClasses="my-1"
on:pointerdown={(e) => {
e?.stopPropagation()
}}
on:click={async () => {
if (!runnableComponent) {
runnableWrapper?.handleSideEffect(true)
} else {
await runnableComponent?.runComponent()
}
modal?.close()
}}
size="xs"
color="dark"
>
Submit
</Button>
</div>
{/if}
<div class="flex justify-end gap-3 p-2">
<Button
{loading}
btnClasses="my-1"
on:pointerdown={(e) => {
e?.stopPropagation()
}}
on:click={async () => {
if (!runnableComponent) {
runnableWrapper?.handleSideEffect(true)
} else {
await runnableComponent?.runComponent()
}
modal?.close()
}}
size="xs"
color="dark"
>
Submit
</Button>
</div>
</RunnableWrapper>
</div>
</AlwaysMountedModal>
</RunnableWrapper>
</div>
</AlwaysMountedModal>
<AlignWrapper {render} {horizontalAlignment} {verticalAlignment}>
{#if errorsMessage}
<div class="text-red-500 text-xs">{errorsMessage}</div>
{/if}
<Button
disabled={resolvedConfig.disabled ?? false}
size={resolvedConfig.size ?? 'md'}
color={resolvedConfig.color}
btnClasses={twMerge(css?.button?.class, 'wm-button', 'wm-modal-form-button')}
style={css?.button?.style ?? ''}
on:click={(e) => {
modal?.open()
}}
>
{resolvedConfig.label}
</Button>
</AlignWrapper>
<AlignWrapper {render} {horizontalAlignment} {verticalAlignment}>
{#if errorsMessage}
<div class="text-red-500 text-xs">{errorsMessage}</div>
{/if}
<Button
disabled={resolvedConfig.disabled ?? false}
size={resolvedConfig.size ?? 'md'}
color={resolvedConfig.color}
btnClasses={twMerge(css?.button?.class, 'wm-button', 'wm-modal-form-button')}
style={css?.button?.style ?? ''}
on:click={(e) => {
modal?.open()
}}
>
{resolvedConfig.label}
</Button>
</AlignWrapper>
{:else}
<RunnableWrapper {outputs} {render} {componentInput} {id} />
{/if}
@@ -18,17 +18,21 @@
export let initializing: boolean | undefined
export let componentContainerHeight: number
type AccordionListValue = { header: string; [key: string]: any };
type AccordionListValue = { header: string; [key: string]: any }
type InternalAccordionListInput = AppInput & {
value: AccordionListValue[];
};
value: AccordionListValue[]
}
$: accordionInput = componentInput as InternalAccordionListInput;
$: accordionInput = componentInput as InternalAccordionListInput
const { app, focusedGrid, selectedComponent, worldStore, connectingInput } =
getContext<AppViewerContext>('AppViewerContext')
let everRender = render
$: render && !everRender && (everRender = true)
let activeIndex: number = 0
const outputs = initOutput($worldStore, id, {
@@ -61,7 +65,6 @@
activeIndex = activeIndex === index ? -1 : index
outputs.activeIndex.set(activeIndex)
}
</script>
{#each Object.keys(css ?? {}) as key (key)}
@@ -85,27 +88,27 @@
bind:initializing
bind:result
>
<div class="w-full flex flex-col overflow-auto max-h-full">
{#if $app.subgrids?.[`${id}-0`]}
{#if Array.isArray(result) && result.length > 0}
{#each result ?? [] as value, index}
<div class="border-b">
<button
on:pointerdown|stopPropagation
on:click={() => toggleAccordion(index)}
class={twMerge(
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
'wm-tabs-alltabs',
activeIndex === index
? twMerge('bg-surface text-primary ', 'wm-tabs-selectedTab')
: 'text-secondary'
)}
>
<span class="mr-2 w-8 font-mono">{activeIndex === index ? '-' : '+'}</span>
{result[index]?.header || `Header ${index}`}
</button>
{#if activeIndex === index}
<div class="p-2 overflow-auto w-full">
{#if everRender}
<div class="w-full flex flex-col overflow-auto max-h-full">
{#if $app.subgrids?.[`${id}-0`]}
{#if Array.isArray(result) && result.length > 0}
{#each result ?? [] as value, index}
<div class="border-b">
<button
on:pointerdown|stopPropagation
on:click={() => toggleAccordion(index)}
class={twMerge(
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
'wm-tabs-alltabs',
activeIndex === index
? twMerge('bg-surface text-primary ', 'wm-tabs-selectedTab')
: 'text-secondary'
)}
>
<span class="mr-2 w-8 font-mono">{activeIndex === index ? '-' : '+'}</span>
{result[index]?.header || `Header ${index}`}
</button>
<div class="overflow-auto w-full">
<ListWrapper
onSet={(id, value) => {
if (!inputs[id]) {
@@ -133,8 +136,8 @@
>
<SubGridEditor
{id}
visible={render}
class={twMerge(css?.container?.class, 'wm-accordion')}
visible={render && index === activeIndex}
class={twMerge(css?.container?.class, 'wm-accordion p-2')}
style={css?.container?.style}
subGridId={`${id}-0`}
containerHeight={componentContainerHeight -
@@ -148,17 +151,21 @@
/>
</ListWrapper>
</div>
{/if}
</div>
{/each}
{:else}
<ListWrapper disabled value={undefined} index={0}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</ListWrapper>
{#if !Array.isArray(result)}
<div class="text-center text-tertiary">Input data is not an array</div>
</div>
{/each}
{:else}
<ListWrapper disabled value={undefined} index={0}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</ListWrapper>
{#if !Array.isArray(result)}
<div class="text-center text-tertiary">Input data is not an array</div>
{/if}
{/if}
{/if}
{/if}
</div>
</div>
{:else if $app.subgrids}
<ListWrapper disabled value={undefined} index={0}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</ListWrapper>
{/if}
</RunnableWrapper>
@@ -27,6 +27,9 @@
const { app, focusedGrid, selectedComponent, worldStore, connectingInput } =
getContext<AppViewerContext>('AppViewerContext')
let everRender = render
$: render && !everRender && (everRender = true)
const outputs = initOutput($worldStore, id, {
result: undefined,
loading: false,
@@ -90,117 +93,123 @@
bind:initializing
bind:result
>
<div class="w-full flex flex-wrap overflow-auto divide-y max-h-full">
{#if $app.subgrids?.[`${id}-0`]}
{#if Array.isArray(result) && result.length > 0}
{#key result}
<Carousel
particlesToShow={1}
particlesToScroll={1}
autoplay={false}
autoplayProgressVisible={false}
timingFunction={resolvedConfig.timingFunction}
dots={true}
arrows={true}
swiping={false}
bind:this={carousel}
on:pageChange={(event) => {
currentPageIndex = event.detail
$focusedGrid = {
parentComponentId: id,
subGridIndex: event.detail
}
}}
>
<div slot="prev" class="h-full flex justify-center flex-col p-2">
<div>
<Button
color="light"
on:click={() => {
const pagesCount = result?.length ?? 0
{#if everRender}
<div class="w-full flex flex-wrap overflow-auto divide-y max-h-full">
{#if $app.subgrids?.[`${id}-0`]}
{#if Array.isArray(result) && result.length > 0}
{#key result}
<Carousel
particlesToShow={1}
particlesToScroll={1}
autoplay={false}
autoplayProgressVisible={false}
timingFunction={resolvedConfig.timingFunction}
dots={true}
arrows={true}
swiping={false}
bind:this={carousel}
on:pageChange={(event) => {
currentPageIndex = event.detail
$focusedGrid = {
parentComponentId: id,
subGridIndex: event.detail
}
}}
>
<div slot="prev" class="h-full flex justify-center flex-col p-2">
<div>
<Button
color="light"
on:click={() => {
const pagesCount = result?.length ?? 0
if (currentPageIndex > 0) {
carousel.goTo(currentPageIndex - 1)
} else {
carousel.goTo(pagesCount - 1)
}
}}
>
<ArrowLeftCircle size={16} />
</Button>
</div>
</div>
<div slot="next" class="h-full flex justify-center flex-col p-2">
<div>
<Button
color="light"
on:click={() => {
const pagesCount = result?.length ?? 0
if (currentPageIndex < pagesCount - 1) {
carousel.goTo(currentPageIndex + 1)
} else {
carousel.goTo(0)
}
}}
>
<ArrowRightCircle size={16} />
</Button>
</div>
</div>
{#each result ?? [] as value, index}
<div class="overflow-auto w-full">
<ListWrapper
onSet={(id, value) => {
if (!inputs[id]) {
inputs[id] = { [index]: value }
} else {
inputs[id] = { ...inputs[id], [index]: value }
}
outputs?.inputs.set(inputs, true)
}}
onRemove={(id) => {
if (inputs?.[id] == undefined) {
return
}
if (index == 0) {
delete inputs[id]
inputs = { ...inputs }
} else {
delete inputs[id][index]
inputs[id] = { ...inputs[id] }
}
outputs?.inputs.set(inputs, true)
}}
{value}
{index}
>
<SubGridEditor
{id}
visible={render}
class={twMerge(css?.container?.class, 'wm-carousel')}
style={css?.container?.style}
subGridId={`${id}-0`}
containerHeight={componentContainerHeight - 40}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
if (currentPageIndex > 0) {
carousel.goTo(currentPageIndex - 1)
} else {
carousel.goTo(pagesCount - 1)
}
onFocus()
}}
/>
</ListWrapper>
>
<ArrowLeftCircle size={16} />
</Button>
</div>
</div>
{/each}
</Carousel>
{/key}
{:else}
<ListWrapper disabled value={undefined} index={0}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</ListWrapper>
{#if !Array.isArray(result)}
<div class="text-center text-tertiary">Input data is not an array</div>
<div slot="next" class="h-full flex justify-center flex-col p-2">
<div>
<Button
color="light"
on:click={() => {
const pagesCount = result?.length ?? 0
if (currentPageIndex < pagesCount - 1) {
carousel.goTo(currentPageIndex + 1)
} else {
carousel.goTo(0)
}
}}
>
<ArrowRightCircle size={16} />
</Button>
</div>
</div>
{#each result ?? [] as value, index}
<div class="overflow-auto w-full">
<ListWrapper
onSet={(id, value) => {
if (!inputs[id]) {
inputs[id] = { [index]: value }
} else {
inputs[id] = { ...inputs[id], [index]: value }
}
outputs?.inputs.set(inputs, true)
}}
onRemove={(id) => {
if (inputs?.[id] == undefined) {
return
}
if (index == 0) {
delete inputs[id]
inputs = { ...inputs }
} else {
delete inputs[id][index]
inputs[id] = { ...inputs[id] }
}
outputs?.inputs.set(inputs, true)
}}
{value}
{index}
>
<SubGridEditor
{id}
visible={render}
class={twMerge(css?.container?.class, 'wm-carousel')}
style={css?.container?.style}
subGridId={`${id}-0`}
containerHeight={componentContainerHeight - 40}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus()
}}
/>
</ListWrapper>
</div>
{/each}
</Carousel>
{/key}
{:else}
<ListWrapper disabled value={undefined} index={0}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</ListWrapper>
{#if !Array.isArray(result)}
<div class="text-center text-tertiary">Input data is not an array</div>
{/if}
{/if}
{/if}
{/if}
</div>
</div>
{:else if $app.subgrids}
<ListWrapper disabled value={undefined} index={0}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</ListWrapper>
{/if}
</RunnableWrapper>
@@ -119,4 +119,6 @@
<div id={divId} />
</RunnableWrapper>
</div>
{:else}
<RunnableWrapper {outputs} {render} autoRefresh {componentInput} {id} />
{/if}
@@ -35,25 +35,29 @@
/>
{/each}
<div
on:pointerdown={(e) => {
e?.preventDefault()
}}
class="h-full w-full"
>
<RunnableWrapper
{outputs}
{render}
autoRefresh
{componentInput}
{id}
bind:initializing
bind:result
{#if render}
<div
on:pointerdown={(e) => {
e?.preventDefault()
}}
class="h-full w-full"
>
<div class="w-full h-full overflow-auto">
{#key result}
{@html result}
{/key}
</div>
</RunnableWrapper>
</div>
<RunnableWrapper
{outputs}
{render}
autoRefresh
{componentInput}
{id}
bind:initializing
bind:result
>
<div class="w-full h-full overflow-auto">
{#key result}
{@html result}
{/key}
</div>
</RunnableWrapper>
</div>
{:else}
<RunnableWrapper {outputs} render={false} autoRefresh {componentInput} {id} />
{/if}
@@ -62,32 +62,36 @@
/>
{/each}
<div
on:pointerdown={(e) => {
e?.preventDefault()
}}
class={classNames(
'h-full w-full overflow-y-auto prose max-w-full',
resolvedConfig?.size ? proseMapping[resolvedConfig.size] : '',
css?.container?.class,
' dark:prose-invert',
'wm-markdown'
)}
style={css?.container?.style}
>
<RunnableWrapper
{outputs}
{render}
autoRefresh
{componentInput}
{id}
bind:initializing
bind:result
{#if render}
<div
on:pointerdown={(e) => {
e?.preventDefault()
}}
class={classNames(
'h-full w-full overflow-y-auto prose max-w-full',
resolvedConfig?.size ? proseMapping[resolvedConfig.size] : '',
css?.container?.class,
' dark:prose-invert',
'wm-markdown'
)}
style={css?.container?.style}
>
{#if result}
{#key result}
<Markdown md={result} plugins={[gfmPlugin()]} />
{/key}
{/if}
</RunnableWrapper>
</div>
<RunnableWrapper
{outputs}
render={true}
autoRefresh
{componentInput}
{id}
bind:initializing
bind:result
>
{#if result}
{#key result}
<Markdown md={result} plugins={[gfmPlugin()]} />
{/key}
{/if}
</RunnableWrapper>
</div>
{:else}
<RunnableWrapper {outputs} render={false} autoRefresh {componentInput} {id} />
{/if}
@@ -217,7 +217,7 @@
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="flex flex-wrap gap-2 pb-0.5 w-full {$mode === 'dnd' &&
class="flex flex-wrap gap-0.5 pb-0.5 w-full {$mode === 'dnd' &&
(componentInput?.type == 'template' || componentInput?.type == 'templatev2')
? 'cursor-text'
: 'overflow-auto'}"
@@ -225,7 +225,7 @@
<svelte:element
this={component}
class={twMerge(
'whitespace-pre-wrap w-full',
'whitespace-pre-wrap grow',
css?.text?.class,
classes,
@@ -244,7 +244,7 @@
</svelte:element>
{#if resolvedConfig.copyButton && result}
<div class="flex">
<div class="flex items-center">
<Button
title="Copy to clipboard"
variant="border"
@@ -103,15 +103,19 @@
/>
{/each}
<div class="w-full h-full" bind:clientHeight={h} bind:clientWidth={w}>
<RunnableWrapper {outputs} {render} {componentInput} {id} bind:initializing bind:result>
{#if error != ''}
<div class="flex flex-col h-full w-full overflow-auto">
<Alert title="Plotly error" type="error" size="xs" class="h-full w-full ">
<pre class="w-full bg-surface p-2 rounded-md whitespace-pre-wrap">{error}</pre>
</Alert>
</div>
{/if}
<div on:pointerdown bind:this={divEl} />
</RunnableWrapper>
</div>
{#if render}
<div class="w-full h-full" bind:clientHeight={h} bind:clientWidth={w}>
<RunnableWrapper {outputs} {render} {componentInput} {id} bind:initializing bind:result>
{#if error != ''}
<div class="flex flex-col h-full w-full overflow-auto">
<Alert title="Plotly error" type="error" size="xs" class="h-full w-full ">
<pre class="w-full bg-surface p-2 rounded-md whitespace-pre-wrap">{error}</pre>
</Alert>
</div>
{/if}
<div on:pointerdown bind:this={divEl} />
</RunnableWrapper>
</div>
{:else}
<RunnableWrapper {outputs} {render} {componentInput} {id} />
{/if}
@@ -169,15 +169,19 @@
{/each}
{/if}
<div class="w-full h-full" bind:clientHeight={h} bind:clientWidth={w}>
<RunnableWrapper {outputs} {render} {componentInput} {id} bind:initializing bind:result>
{#if error != ''}
<div class="flex flex-col h-full w-full overflow-auto">
<Alert title="Plotly error" type="error" size="xs" class="h-full w-full ">
<pre class="w-full bg-surface p-2 rounded-md whitespace-pre-wrap">{error}</pre>
</Alert>
</div>
{/if}
<div on:pointerdown bind:this={divEl} />
</RunnableWrapper>
</div>
{#if render}
<div class="w-full h-full" bind:clientHeight={h} bind:clientWidth={w}>
<RunnableWrapper {outputs} {render} {componentInput} {id} bind:initializing bind:result>
{#if error != ''}
<div class="flex flex-col h-full w-full overflow-auto">
<Alert title="Plotly error" type="error" size="xs" class="h-full w-full ">
<pre class="w-full bg-surface p-2 rounded-md whitespace-pre-wrap">{error}</pre>
</Alert>
</div>
{/if}
<div on:pointerdown bind:this={divEl} />
</RunnableWrapper>
</div>
{:else}
<RunnableWrapper {outputs} {render} {componentInput} {id} />
{/if}
@@ -67,8 +67,12 @@
<InputValue key={'canvas'} {id} input={configuration.canvas} bind:value={canvas} />
<div class="w-full h-full" bind:clientHeight={h} bind:clientWidth={w}>
<RunnableWrapper {outputs} {render} {componentInput} {id} bind:initializing bind:result>
<div on:pointerdown bind:this={divEl} />
</RunnableWrapper>
</div>
{#if render}
<div class="w-full h-full" bind:clientHeight={h} bind:clientWidth={w}>
<RunnableWrapper {outputs} {render} {componentInput} {id} bind:initializing bind:result>
<div on:pointerdown bind:this={divEl} />
</RunnableWrapper>
</div>
{:else}
<RunnableWrapper {outputs} {render} {componentInput} {id} />
{/if}
@@ -695,6 +695,7 @@
{/if}
</div>
</RunnableWrapper>
<Portal name="db-explorer">
<Drawer bind:this={insertDrawer} size="800px">
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer}>
@@ -84,6 +84,4 @@
<RefreshIndicator {loading} />
</div>
</div>
{:else}
<div class="w-full h-full" />
{/if}
@@ -28,17 +28,15 @@ function create_context_function_template(
return `
return async function (context, state, createProxy, goto, setTab, recompute, getAgGrid, setValue, setSelectedIndex, openModal, closeModal, open, close, validate, invalidate, validateAll, clearFiles, showToast, waitJob, askNewResource, downloadFile) {
"use strict";
${
contextKeys && contextKeys.length > 0
? `let ${contextKeys.map((key) => ` ${key} = createProxy('${key}', context['${key}'])`)};`
: ``
}
${
hasReturnAsLastLine
? eval_string
: `
${contextKeys && contextKeys.length > 0
? `let ${contextKeys.map((key) => ` ${key} = createProxy('${key}', context['${key}'])`)};`
: ``
}
${hasReturnAsLastLine
? eval_string
: `
return ${eval_string.startsWith('return ') ? eval_string.substring(7) : eval_string}`
}
}
}
`
@@ -271,9 +269,8 @@ export async function eval_like(
if (typeof input === 'object' && input.s3) {
const workspaceId = computeGlobalContext(worldStore).ctx.workspace
const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${
input?.s3
}${input?.storage ? `&storage=${input.storage}` : ''}`
const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${input?.s3
}${input?.storage ? `&storage=${input.storage}` : ''}`
downloadFile(s3href, filename || input.s3)
} else if (typeof input === 'string') {
if (input.startsWith('data:')) {
@@ -31,6 +31,9 @@
selectedTabIndex: 0
})
let everRender = render
$: render && !everRender && (everRender = true)
function onFocus() {
$focusedGrid = {
parentComponentId: id,
@@ -40,7 +43,7 @@
let css = initCss($app.css?.conditionalwrapper, customCss)
let resolvedConditions: boolean[] = []
let resolvedConditions: boolean[] = conditions.map((_x) => false)
let selectedConditionIndex = 0
function handleResolvedConditions() {
@@ -97,23 +100,29 @@
<InitializeComponent {id} />
<div class="w-full h-full">
{#if $app.subgrids}
{#each resolvedConditions ?? [] as _res, i}
<SubGridEditor
visible={render && i == selectedConditionIndex}
{id}
class={twMerge(css?.container?.class, 'wm-conditional-tabs')}
style={css?.container?.style}
subGridId={`${id}-${i}`}
containerHeight={componentContainerHeight}
on:focus={(e) => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus()
}}
/>
{/each}
{/if}
</div>
{#if everRender}
<div class="w-full h-full">
{#if $app.subgrids}
{#each resolvedConditions ?? [] as _res, i}
<SubGridEditor
visible={render && i == selectedConditionIndex}
{id}
class={twMerge(css?.container?.class, 'wm-conditional-tabs')}
style={css?.container?.style}
subGridId={`${id}-${i}`}
containerHeight={componentContainerHeight}
on:focus={(e) => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus()
}}
/>
{/each}
{/if}
</div>
{:else if $app.subgrids}
{#each resolvedConditions ?? [] as _res, i}
<SubGridEditor visible={false} {id} subGridId={`${id}-${i}`} />
{/each}
{/if}
@@ -21,6 +21,9 @@
const { app, focusedGrid, selectedComponent, worldStore, connectingInput, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
let everRender = render
$: render && !everRender && (everRender = true)
let groupContext = writable({})
let outputs = initOutput($worldStore, id, { group: $groupContext })
@@ -66,23 +69,29 @@
{/if}
{/each}
<div class="w-full h-full">
{#if $app.subgrids?.[`${id}-0`]}
<GroupWrapper {id} context={groupContext}>
<SubGridEditor
visible={render}
{id}
class={twMerge(css?.container?.class, 'wm-container')}
style={css?.container?.style}
subGridId={`${id}-0`}
containerHeight={componentContainerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus()
}}
/>
</GroupWrapper>
{/if}
</div>
{#if everRender}
<div class="w-full h-full">
{#if $app.subgrids?.[`${id}-0`]}
<GroupWrapper {id} context={groupContext}>
<SubGridEditor
visible={render}
{id}
class={twMerge(css?.container?.class, 'wm-container')}
style={css?.container?.style}
subGridId={`${id}-0`}
containerHeight={componentContainerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus()
}}
/>
</GroupWrapper>
{/if}
</div>
{:else if $app.subgrids?.[`${id}-0`]}
<GroupWrapper {id} context={groupContext}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</GroupWrapper>
{/if}
@@ -20,6 +20,9 @@
export let render: boolean
export let nodes: DecisionTreeNode[]
let everRender = render
$: render && !everRender && (everRender = true)
const {
app,
focusedGrid,
@@ -182,55 +185,63 @@
<InitializeComponent {id} />
<div class="w-full overflow-auto">
<div class="w-full">
{#if $app.subgrids}
{#each Object.values(nodes) ?? [] as node, i}
<SubGridEditor
visible={render && node.id === currentNodeId}
{id}
class={twMerge(css?.container?.class, 'wm-decision-tree')}
style={css?.container?.style}
subGridId={`${id}-${i}`}
containerHeight={componentContainerHeight - 40}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus(i)
}}
/>
{/each}
{/if}
{#if everRender}
<div class="w-full overflow-auto">
<div class="w-full">
{#if $app.subgrids}
{#each Object.values(nodes) ?? [] as node, i}
<SubGridEditor
visible={render && node.id === currentNodeId}
{id}
class={twMerge(css?.container?.class, 'wm-decision-tree')}
style={css?.container?.style}
subGridId={`${id}-${i}`}
containerHeight={componentContainerHeight - 40}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus(i)
}}
/>
{/each}
{/if}
</div>
</div>
</div>
<div class="h-8 flex flex-row gap-2 justify-end items-center px-2 bg-surface-primary z-50">
{#if isDebugging($debuggingComponents, id)}
<Badge color="red" size="xs2">
{`Debugging. Actions are disabled.`}
</Badge>
{#if render}
<div class="h-8 flex flex-row gap-2 justify-end items-center px-2 bg-surface-primary z-50">
{#if isDebugging($debuggingComponents, id)}
<Badge color="red" size="xs2">
{`Debugging. Actions are disabled.`}
</Badge>
{/if}
{#if getFirstNode(nodes)?.id !== currentNodeId}
<Button
on:click={prev}
size="xs2"
color="light"
startIcon={{ icon: ArrowLeft }}
disabled={isDebugging($debuggingComponents, id)}
>
Prev
</Button>
{/if}
<Button
on:click={next}
size="xs2"
color="dark"
endIcon={{ icon: ArrowRight }}
disabled={isNextDisabled ||
currentNodeId === lastNodeId ||
isDebugging($debuggingComponents, id)}
>
Next
</Button>
</div>
{/if}
{#if getFirstNode(nodes)?.id !== currentNodeId}
<Button
on:click={prev}
size="xs2"
color="light"
startIcon={{ icon: ArrowLeft }}
disabled={isDebugging($debuggingComponents, id)}
>
Prev
</Button>
{/if}
<Button
on:click={next}
size="xs2"
color="dark"
endIcon={{ icon: ArrowRight }}
disabled={isNextDisabled ||
currentNodeId === lastNodeId ||
isDebugging($debuggingComponents, id)}
>
Next
</Button>
</div>
{:else if $app.subgrids}
{#each Object.values(nodes) ?? [] as _node, i}
<SubGridEditor visible={false} {id} subGridId={`${id}-${i}`} />
{/each}
{/if}
@@ -24,6 +24,9 @@
export let onOpenRecomputeIds: string[] | undefined = undefined
export let onCloseRecomputeIds: string[] | undefined = undefined
let everRender = render
$: render && !everRender && (everRender = true)
const {
app,
focusedGrid,
@@ -77,98 +80,103 @@
{/each}
<InitializeComponent {id} />
<div class="h-full w-full">
<AlignWrapper {noWFull} {horizontalAlignment} {verticalAlignment}>
<Button
btnClasses={twMerge(css?.button?.class, 'wm-drawer-button')}
wrapperClasses={twMerge(
css?.container?.class,
'wm-drawer-button-container',
resolvedConfig?.fillContainer ? 'w-full h-full' : '',
resolvedConfig?.hideButtonOnView && $mode == 'preview'
? 'invisible h-0 overflow-hidden'
: ''
)}
wrapperStyle={css?.container?.style}
disabled={resolvedConfig?.disabled}
on:pointerdown={(e) => {
e?.stopPropagation()
}}
on:click={async (e) => {
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
appDrawer.toggleDrawer()
}}
size={resolvedConfig.size}
color={resolvedConfig.color}
style={css?.button?.style}
>
{#if resolvedConfig.label && resolvedConfig.label?.length > 0}
<div>{resolvedConfig.label}</div>
{/if}
</Button>
</AlignWrapper>
</div>
<Portal target="#app-editor-top-level-drawer" name="app-drawer">
<Drawer
let:open
bind:this={appDrawer}
size="800px"
alwaysOpen
positionClass={$mode == 'dnd' ? '!absolute' : '!fixed'}
shouldUsePortal={false}
on:open={() => {
outputs?.open.set(true)
onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
on:close={() => {
outputs?.open.set(false)
onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
>
<DrawerContent
title={resolvedConfig.drawerTitle}
on:close={() => {
appDrawer?.toggleDrawer()
$focusedGrid = undefined
}}
fullScreen={$mode !== 'dnd'}
>
<div
class={twMerge('h-full', css?.drawer?.class, 'wm-drawer')}
style={css?.drawer?.style}
{#if render}
<div class="h-full w-full">
<AlignWrapper {noWFull} {horizontalAlignment} {verticalAlignment}>
<Button
btnClasses={twMerge(css?.button?.class, 'wm-drawer-button')}
wrapperClasses={twMerge(
css?.container?.class,
'wm-drawer-button-container',
resolvedConfig?.fillContainer ? 'w-full h-full' : '',
resolvedConfig?.hideButtonOnView && $mode == 'preview'
? 'invisible h-0 overflow-hidden'
: ''
)}
wrapperStyle={css?.container?.style}
disabled={resolvedConfig?.disabled}
on:pointerdown={(e) => {
e?.stopPropagation()
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
}
}}
on:click={async (e) => {
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
appDrawer.toggleDrawer()
}}
size={resolvedConfig.size}
color={resolvedConfig.color}
style={css?.button?.style}
>
{#if $app.subgrids?.[`${id}-0`]}
<SubGridEditor
visible={open && render}
{id}
subGridId={`${id}-0`}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
}
}}
/>
{#if resolvedConfig.label && resolvedConfig.label?.length > 0}
<div>{resolvedConfig.label}</div>
{/if}
</div>
</DrawerContent>
</Drawer>
</Portal>
</Button>
</AlignWrapper>
</div>
{/if}
{#if everRender}
<Portal target="#app-editor-top-level-drawer" name="app-drawer">
<Drawer
let:open
bind:this={appDrawer}
size="800px"
alwaysOpen
positionClass={$mode == 'dnd' ? '!absolute' : '!fixed'}
shouldUsePortal={false}
on:open={() => {
outputs?.open.set(true)
onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
on:close={() => {
outputs?.open.set(false)
onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
>
<DrawerContent
title={resolvedConfig.drawerTitle}
on:close={() => {
appDrawer?.toggleDrawer()
$focusedGrid = undefined
}}
fullScreen={$mode !== 'dnd'}
>
<div
class={twMerge('h-full', css?.drawer?.class, 'wm-drawer')}
style={css?.drawer?.style}
on:pointerdown={(e) => {
e?.stopPropagation()
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
}
}}
>
{#if $app.subgrids?.[`${id}-0`]}
<SubGridEditor
visible={open && render}
{id}
subGridId={`${id}-0`}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
}
}}
/>
{/if}
</div>
</DrawerContent>
</Drawer>
</Portal>
{:else if $app.subgrids?.[`${id}-0`]}
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
{/if}
@@ -26,6 +26,9 @@
getContext<AppViewerContext>('AppViewerContext')
let page = 0
let everRender = render
$: render && !everRender && (everRender = true)
const outputs = initOutput($worldStore, id, {
result: undefined,
loading: false,
@@ -72,8 +75,8 @@
const l = initialData ? initialData.length : 0
if (mode === 'auto') {
const pageSize: number = configuration.auto.pageSize ?? 0
const shouldDisplayPagination = pageSize < l ?? false
const total = Math.ceil(l / pageSize ?? 0)
const shouldDisplayPagination = (pageSize ?? 0) < l
const total = Math.ceil(l / (pageSize ?? 0))
return {
shouldDisplayPagination,
@@ -136,21 +139,21 @@
bind:result
bind:loading
>
<div
class={twMerge('w-full h-full', css?.container?.class, 'wm-list')}
style={css?.container?.style}
>
{#if everRender}
<div
class="w-full h-full shrink flex {$allIdsInPath.includes(id) && $mode == 'dnd'
? 'overflow-visible'
: 'overflow-auto'} {isCard
? 'gap-2 flex-wrap'
: resolvedConfig?.displayBorders
? 'divide-y flex-col'
: 'flex-col'}"
class={twMerge('w-full h-full', css?.container?.class, 'wm-list')}
style={css?.container?.style}
>
{#if $app.subgrids?.[`${id}-0`]}
{#if Array.isArray(result) && result.length > 0}
<div
class="w-full h-full shrink flex {$allIdsInPath.includes(id) && $mode == 'dnd'
? 'overflow-visible'
: 'overflow-auto'} {isCard
? 'gap-2 flex-wrap'
: resolvedConfig?.displayBorders
? 'divide-y flex-col'
: 'flex-col'}"
>
{#if $app.subgrids?.[`${id}-0`] && Array.isArray(result) && result.length > 0}
{#each result ?? [] as value, index (index)}
{@const inRange = index <= pagination.maxIndex && index >= pagination.indexOffset}
<div
@@ -219,53 +222,59 @@
<div class="text-center text-tertiary">Input data is not an array</div>
{/if}
{/if}
</div>
{#if pagination.shouldDisplayPagination}
<div
class="bg-surface-secondary z-20 h-8 flex flex-row gap-1 p-1 items-center wm-list-pagination absolute bottom-0 w-full"
>
<Button
size="xs2"
variant="border"
color="light"
btnClasses="flex flex-row gap-1 items-center wm-list-pagination-buttons"
on:click={() => {
isPreviousLoading = true
page = page - 1
outputs?.page.set(page, true)
}}
disabled={page === 0}
>
{#if isPreviousLoading && loading}
<Loader2 size={14} class="animate-spin" />
{:else}
<ChevronLeft size={14} />
{/if}
Previous
</Button>
<Button
size="xs2"
variant="border"
color="light"
btnClasses="flex flex-row gap-1 items-center wm-list-pagination-buttons"
on:click={() => {
isNextLoading = true
page = page + 1
outputs?.page.set(page, true)
}}
disabled={pagination.disableNext && pagination.total > 0}
>
Next
{#if isNextLoading && loading}
<Loader2 size={14} class="animate-spin" />
{:else}
<ChevronRight size={14} />
{/if}
</Button>
<div class="text-xs"
>{page + 1} {pagination.total > 0 ? `of ${pagination.total}` : ''}</div
>
</div>
{/if}
</div>
{#if pagination.shouldDisplayPagination}
<div
class="bg-surface-secondary z-20 h-8 flex flex-row gap-1 p-1 items-center wm-list-pagination absolute bottom-0 w-full"
>
<Button
size="xs2"
variant="border"
color="light"
btnClasses="flex flex-row gap-1 items-center wm-list-pagination-buttons"
on:click={() => {
isPreviousLoading = true
page = page - 1
outputs?.page.set(page, true)
}}
disabled={page === 0}
>
{#if isPreviousLoading && loading}
<Loader2 size={14} class="animate-spin" />
{:else}
<ChevronLeft size={14} />
{/if}
Previous
</Button>
<Button
size="xs2"
variant="border"
color="light"
btnClasses="flex flex-row gap-1 items-center wm-list-pagination-buttons"
on:click={() => {
isNextLoading = true
page = page + 1
outputs?.page.set(page, true)
}}
disabled={pagination.disableNext && pagination.total > 0}
>
Next
{#if isNextLoading && loading}
<Loader2 size={14} class="animate-spin" />
{:else}
<ChevronRight size={14} />
{/if}
</Button>
<div class="text-xs">{page + 1} {pagination.total > 0 ? `of ${pagination.total}` : ''}</div>
</div>
{/if}
</div>
{:else if $app.subgrids}
<ListWrapper disabled value={undefined} index={0}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</ListWrapper>
{/if}
</RunnableWrapper>
@@ -40,6 +40,9 @@
breakpoint
} = getContext<AppViewerContext>('AppViewerContext')
let everRender = render
$: render && !everRender && (everRender = true)
//used so that we can count number of outputs setup for first refresh
const outputs = initOutput($worldStore, id, {
open: false
@@ -110,7 +113,7 @@
/>
{/each}
{#if render}
{#if everRender}
<div class="h-full w-full">
<AlignWrapper {noWFull} {horizontalAlignment} {verticalAlignment}>
<Button
@@ -144,92 +147,94 @@
</Button>
</AlignWrapper>
</div>
{/if}
<Portal target="#app-editor-top-level-drawer" name="app-modal">
<Disposable
{id}
let:handleClickAway
let:zIndex
let:open
bind:this={disposable}
on:open={() => {
outputs?.open.set(true)
onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
on:close={() => {
outputs?.open.set(false)
onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
>
<div
class={twMerge(
`${
$mode == 'dnd' ? 'absolute' : 'fixed'
} top-0 bottom-0 left-0 right-0 transition-all duration-50`,
open ? ' bg-black bg-opacity-60' : 'h-0 overflow-hidden invisible'
)}
style="z-index: {zIndex}"
bind:clientHeight={wrapperHeight}
<Portal target="#app-editor-top-level-drawer" name="app-modal">
<Disposable
{id}
let:handleClickAway
let:zIndex
let:open
bind:this={disposable}
on:open={() => {
outputs?.open.set(true)
onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
on:close={() => {
outputs?.open.set(false)
onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.()))
}}
>
<div
style={css?.popup?.style}
class={twMerge('mx-24 mt-8 bg-surface rounded-lg relative', css?.popup?.class)}
use:clickOutside={false}
on:click_outside={(e) => {
if ($mode !== 'dnd' && !unclickableOutside) {
handleClickAway(e)
}
}}
class={twMerge(
`${
$mode == 'dnd' ? 'absolute' : 'fixed'
} top-0 bottom-0 left-0 right-0 transition-all duration-50`,
open ? ' bg-black bg-opacity-60' : 'h-0 overflow-hidden invisible'
)}
style="z-index: {zIndex}"
bind:clientHeight={wrapperHeight}
>
<div
class="px-4 py-2 border-b flex justify-between items-center"
bind:clientHeight={headerHeight}
>
<div>{resolvedConfig.modalTitle}</div>
<div class="w-8">
<button
on:click|stopPropagation={() => {
disposable?.closeDrawer()
}}
class="hover:bg-surface-hover bg-surface-secondary rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
<X class="text-tertiary" />
</button>
</div>
</div>
<div
class={twMerge('wm-modal h-full', 'overflow-y-auto')}
on:pointerdown={(e) => {
e?.stopPropagation()
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
style={css?.popup?.style}
class={twMerge('mx-24 mt-8 bg-surface rounded-lg relative', css?.popup?.class)}
use:clickOutside={false}
on:click_outside={(e) => {
if ($mode !== 'dnd' && !unclickableOutside) {
handleClickAway(e)
}
}}
>
{#if $app.subgrids?.[`${id}-0`]}
<SubGridEditor
visible={open && render}
{id}
{containerHeight}
subGridId={`${id}-0`}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
<div
class="px-4 py-2 border-b flex justify-between items-center"
bind:clientHeight={headerHeight}
>
<div>{resolvedConfig.modalTitle}</div>
<div class="w-8">
<button
on:click|stopPropagation={() => {
disposable?.closeDrawer()
}}
class="hover:bg-surface-hover bg-surface-secondary rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
<X class="text-tertiary" />
</button>
</div>
</div>
<div
class={twMerge('wm-modal h-full', 'overflow-y-auto')}
on:pointerdown={(e) => {
e?.stopPropagation()
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
}}
/>
{/if}
}
}}
>
{#if $app.subgrids?.[`${id}-0`]}
<SubGridEditor
visible={open && render}
{id}
{containerHeight}
subGridId={`${id}-0`}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
}
}}
/>
{/if}
</div>
</div>
</div>
</div>
</Disposable>
</Portal>
</Disposable>
</Portal>
{:else if $app.subgrids?.[`${id}-0`]}
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
{/if}
@@ -25,6 +25,10 @@
//used so that we can count number of outputs setup for first refresh
initOutput($worldStore, id, {})
let everRender = render
$: render && !everRender && (everRender = true)
function onFocus() {
$focusedGrid = {
parentComponentId: id,
@@ -82,47 +86,53 @@
<InitializeComponent {id} />
<div class="h-full w-full border" on:pointerdown={onFocus}>
{#key sumedup}
<Splitpanes {horizontal}>
{#each sumedup as paneSize, index (index)}
<Pane size={paneSize} minSize={20}>
<div
class="w-full h-full"
on:pointerdown|stopPropagation={() => {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: index
}
}}
>
{#if $app.subgrids?.[`${id}-${index}`]}
<SubGridEditor
visible={render}
{id}
shouldHighlight={$focusedGrid?.subGridIndex === index}
class={twMerge(
css?.container?.class,
horizontal ? 'wm-horizontal-split-panes' : 'wm-vertical-split-panes'
)}
style={css?.container?.style}
subGridId={`${id}-${index}`}
containerHeight={horizontal ? undefined : componentContainerHeight - 8}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: index
{#if everRender}
<div class="h-full w-full border" on:pointerdown={onFocus}>
{#key sumedup}
<Splitpanes {horizontal}>
{#each sumedup as paneSize, index (index)}
<Pane size={paneSize} minSize={20}>
<div
class="w-full h-full"
on:pointerdown|stopPropagation={() => {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: index
}
}}
>
{#if $app.subgrids?.[`${id}-${index}`]}
<SubGridEditor
visible={render}
{id}
shouldHighlight={$focusedGrid?.subGridIndex === index}
class={twMerge(
css?.container?.class,
horizontal ? 'wm-horizontal-split-panes' : 'wm-vertical-split-panes'
)}
style={css?.container?.style}
subGridId={`${id}-${index}`}
containerHeight={horizontal ? undefined : componentContainerHeight - 8}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
$focusedGrid = {
parentComponentId: id,
subGridIndex: index
}
}
}
}}
/>
{/if}
</div>
</Pane>
{/each}
</Splitpanes>
{/key}
</div>
}}
/>
{/if}
</div>
</Pane>
{/each}
</Splitpanes>
{/key}
</div>
{:else}
{#each sumedup as _paneSize, index (index)}
<SubGridEditor visible={false} {id} subGridId={`${id}-${index}`} />
{/each}
{/if}
@@ -36,6 +36,9 @@
runnableComponents
} = getContext<AppViewerContext>('AppViewerContext')
let everRender = render
$: render && !everRender && (everRender = true)
let selected = tabs[0]
let tabHeight: number = 0
let footerHeight: number = 0
@@ -155,97 +158,109 @@
bind:result
errorHandledByComponent={true}
>
<div class="w-full overflow-auto">
<div bind:clientHeight={tabHeight}>
<Stepper
on:click={(e) => {
const index = e.detail.index
if (index <= maxReachedIndex || $mode === 'dnd') {
runStep(index)
}
}}
{tabs}
{selectedIndex}
{maxReachedIndex}
{statusByStep}
hasValidations={Boolean(runnableComponent)}
/>
</div>
<div class="w-full">
{#if $app.subgrids}
{#each tabs ?? [] as _res, i}
<SubGridEditor
{id}
visible={render && i === selectedIndex}
subGridId={`${id}-${i}`}
class={twMerge(css?.container?.class, 'wm-stepper')}
style={css?.container?.style}
containerHeight={componentContainerHeight - tabHeight - footerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
{#if everRender}
<div class="w-full overflow-auto">
{#if render}
<div bind:clientHeight={tabHeight}>
<Stepper
on:click={(e) => {
const index = e.detail.index
if (index <= maxReachedIndex || $mode === 'dnd') {
runStep(index)
}
}}
{tabs}
{selectedIndex}
{maxReachedIndex}
{statusByStep}
hasValidations={Boolean(runnableComponent)}
/>
{/each}
</div>
{/if}
<div class="w-full">
{#if $app.subgrids}
{#each tabs ?? [] as _res, i}
<SubGridEditor
{id}
visible={render && i === selectedIndex}
subGridId={`${id}-${i}`}
class={twMerge(css?.container?.class, 'wm-stepper')}
style={css?.container?.style}
containerHeight={componentContainerHeight - tabHeight - footerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
{/each}
{/if}
</div>
{#if render}
<div bind:clientHeight={footerHeight}>
<div class="flex justify-between h-10 p-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-tertiary">
Step {selectedIndex + 1} of {tabs.length}
</span>
</div>
<div class="flex items-center gap-2">
<Button
size="xs"
color="light"
variant="contained"
disabled={selectedIndex === 0}
on:click={(e) => {
e.preventDefault()
directionClicked = 'left'
onPrevious?.forEach((id) =>
$runnableComponents?.[id]?.cb?.forEach((cb) => cb?.())
)
runStep(selectedIndex - 1)
}}
>
<div class="flex flex-row gap-2">
{#if statusByStep[selectedIndex] === 'pending' && directionClicked === 'left'}
<Loader2 class="w-4 h-4 animate-spin" />
{:else}
<ArrowLeftIcon class="w-4 h-4" />
{/if}
Previous
</div>
</Button>
<Button
size="xs"
color="dark"
variant="contained"
disabled={lastStep}
on:click={(e) => {
e.preventDefault()
directionClicked = 'right'
onNext?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
runStep(selectedIndex + 1)
}}
>
<div class="flex flex-row gap-2">
Next
{#if statusByStep[selectedIndex] === 'pending' && directionClicked === 'right'}
<Loader2 class="w-4 h-4 animate-spin" />
{:else}
<ArrowRightIcon class="w-4 h-4" />
{/if}
</div>
</Button>
</div>
</div>
</div>
{/if}
</div>
<div bind:clientHeight={footerHeight}>
<div class="flex justify-between h-10 p-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-tertiary">
Step {selectedIndex + 1} of {tabs.length}
</span>
</div>
<div class="flex items-center gap-2">
<Button
size="xs"
color="light"
variant="contained"
disabled={selectedIndex === 0}
on:click={(e) => {
e.preventDefault()
directionClicked = 'left'
onPrevious?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
runStep(selectedIndex - 1)
}}
>
<div class="flex flex-row gap-2">
{#if statusByStep[selectedIndex] === 'pending' && directionClicked === 'left'}
<Loader2 class="w-4 h-4 animate-spin" />
{:else}
<ArrowLeftIcon class="w-4 h-4" />
{/if}
Previous
</div>
</Button>
<Button
size="xs"
color="dark"
variant="contained"
disabled={lastStep}
on:click={(e) => {
e.preventDefault()
directionClicked = 'right'
onNext?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
runStep(selectedIndex + 1)
}}
>
<div class="flex flex-row gap-2">
Next
{#if statusByStep[selectedIndex] === 'pending' && directionClicked === 'right'}
<Loader2 class="w-4 h-4 animate-spin" />
{:else}
<ArrowRightIcon class="w-4 h-4" />
{/if}
</div>
</Button>
</div>
</div>
</div>
</div>
{:else if $app.subgrids}
{#each tabs ?? [] as _res, i}
<SubGridEditor {id} visible={false} subGridId={`${id}-${i}`} />
{/each}
{/if}
</RunnableWrapper>
@@ -29,6 +29,8 @@
components['tabscomponent'].initialData.configuration,
configuration
)
let everRender = render
$: render && !everRender && (everRender = true)
const {
app,
@@ -117,128 +119,134 @@
/>
{/each}
<div class={resolvedConfig.tabsKind == 'sidebar' ? 'flex gap-4 w-full h-full' : 'w-full'}>
{#if !resolvedConfig.tabsKind || resolvedConfig.tabsKind == 'tabs' || (resolvedConfig.tabsKind == 'invisibleOnView' && $mode == 'dnd')}
<div bind:clientHeight={tabHeight}>
<Tabs
bind:selected
class={twMerge(css?.tabRow?.class, 'wm-tabs-tabRow')}
{#if everRender}
<div class={resolvedConfig.tabsKind == 'sidebar' ? 'flex gap-4 w-full h-full' : 'w-full'}>
{#if !resolvedConfig.tabsKind || resolvedConfig.tabsKind == 'tabs' || (resolvedConfig.tabsKind == 'invisibleOnView' && $mode == 'dnd')}
<div bind:clientHeight={tabHeight}>
<Tabs
bind:selected
class={twMerge(css?.tabRow?.class, 'wm-tabs-tabRow')}
style={css?.tabRow?.style}
>
{#each tabs ?? [] as res, index}
<Tab
value={res}
class={twMerge(css?.allTabs?.class, 'wm-tabs-alltabs')}
style={css?.allTabs?.style}
selectedClass={twMerge(css?.selectedTab?.class, 'wm-tabs-selectedTab')}
selectedStyle={css?.selectedTab?.style}
disabled={resolvedDisabledTabs[index]}
>
<span class="font-semibold">{res}</span>
</Tab>
{/each}
</Tabs>
</div>
{:else if resolvedConfig.tabsKind == 'sidebar'}
<div
class={twMerge(
'flex gap-y-2 flex-col w-1/6 max-w-[160px] bg-surface text-secondary opacity-80 px-4 pt-4 border-r ',
css?.tabRow?.class,
'wm-tabs-tabRow'
)}
style={css?.tabRow?.style}
>
{#each tabs ?? [] as res, index}
<Tab
value={res}
class={twMerge(css?.allTabs?.class, 'wm-tabs-alltabs')}
style={css?.allTabs?.style}
selectedClass={twMerge(css?.selectedTab?.class, 'wm-tabs-selectedTab')}
selectedStyle={css?.selectedTab?.style}
disabled={resolvedDisabledTabs[index]}
>
<span class="font-semibold">{res}</span>
</Tab>
{/each}
</Tabs>
</div>
{:else if resolvedConfig.tabsKind == 'sidebar'}
<div
class={twMerge(
'flex gap-y-2 flex-col w-1/6 max-w-[160px] bg-surface text-secondary opacity-80 px-4 pt-4 border-r ',
css?.tabRow?.class,
'wm-tabs-tabRow'
)}
style={css?.tabRow?.style}
>
{#each tabs ?? [] as res}
<button
on:pointerdown|stopPropagation
on:click={() => (selected = res)}
class={twMerge(
'rounded-sm !truncate text-sm hover:text-primary px-1 py-2',
css?.allTabs?.class,
'wm-tabs-alltabs',
selected == res
? twMerge(
'border-r border-primary border-l bg-surface text-primary',
css?.selectedTab?.class,
'wm-tabs-selectedTab'
)
: ''
)}
style={selected == res
? [css?.allTabs?.style, css?.selectedTab?.style].filter(Boolean).join(';')
: css?.allTabs?.style}
>
{res}
</button>
{/each}
</div>
{/if}
{#if resolvedConfig.tabsKind == 'accordion'}
<div class="flex flex-col w-full">
{#each tabs ?? [] as res, index}
<div class="border-b">
{#each tabs ?? [] as res}
<button
on:pointerdown|stopPropagation
on:click={() => (selected = res)}
class={twMerge(
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
'rounded-sm !truncate text-sm hover:text-primary px-1 py-2',
css?.allTabs?.class,
'wm-tabs-alltabs',
selected == res
? twMerge(
'bg-surface text-primary ',
'border-r border-primary border-l bg-surface text-primary',
css?.selectedTab?.class,
'wm-tabs-selectedTab'
)
: 'text-secondary'
: ''
)}
style={selected == res
? [css?.allTabs?.style, css?.selectedTab?.style].filter(Boolean).join(';')
: css?.allTabs?.style}
>
<span class="mr-2 w-8 font-mono">{selected == res ? '-' : '+'}</span>
{res}
</button>
{#if selected == res}
<div class="p-2 border-t">
<SubGridEditor
{id}
visible={render && index === selectedIndex}
subGridId={`${id}-${index}`}
class={twMerge(css?.container?.class, 'wm-tabs-container')}
style={css?.container?.style}
containerHeight={componentContainerHeight - (titleBarHeight * tabs.length + 40)}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="w-full">
{#if $app.subgrids}
{#each tabs ?? [] as _res, i}
<SubGridEditor
{id}
visible={render && i === selectedIndex}
subGridId={`${id}-${i}`}
class={twMerge(css?.container?.class, 'wm-tabs-container')}
style={css?.container?.style}
containerHeight={resolvedConfig.tabsKind !== 'sidebar' && $mode !== 'preview'
? componentContainerHeight - tabHeight
: componentContainerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
{/each}
{/if}
</div>
{/if}
</div>
</div>
{/if}
{#if resolvedConfig.tabsKind == 'accordion'}
<div class="flex flex-col w-full">
{#each tabs ?? [] as res, index}
<div class="border-b">
<button
on:pointerdown|stopPropagation
on:click={() => (selected = res)}
class={twMerge(
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
css?.allTabs?.class,
'wm-tabs-alltabs',
selected == res
? twMerge(
'bg-surface text-primary ',
css?.selectedTab?.class,
'wm-tabs-selectedTab'
)
: 'text-secondary'
)}
>
<span class="mr-2 w-8 font-mono">{selected == res ? '-' : '+'}</span>
{res}
</button>
{#if selected == res}
<div class="p-2 border-t">
<SubGridEditor
{id}
visible={render && index === selectedIndex}
subGridId={`${id}-${index}`}
class={twMerge(css?.container?.class, 'wm-tabs-container')}
style={css?.container?.style}
containerHeight={componentContainerHeight - (titleBarHeight * tabs.length + 40)}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="w-full">
{#if $app.subgrids}
{#each tabs ?? [] as _res, i}
<SubGridEditor
{id}
visible={render && i === selectedIndex}
subGridId={`${id}-${i}`}
class={twMerge(css?.container?.class, 'wm-tabs-container')}
style={css?.container?.style}
containerHeight={resolvedConfig.tabsKind !== 'sidebar' && $mode !== 'preview'
? componentContainerHeight - tabHeight
: componentContainerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
{/each}
{/if}
</div>
{/if}
</div>
{:else if $app.subgrids}
{#each tabs ?? [] as _res, i}
<SubGridEditor {id} visible={false} subGridId={`${id}-${i}`} />
{/each}
{/if}
@@ -225,7 +225,7 @@
try {
localStorage.setItem(path != '' ? `app-${path}` : 'app', encodeState($appStore))
} catch (err) {
console.error(err)
console.error('Error storing frontend draft in localStorage', err)
}
}, 500)
}
@@ -31,7 +31,8 @@
FileClock,
Sun,
Moon,
SunMoon
SunMoon,
Zap
} from 'lucide-svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
@@ -92,6 +93,7 @@
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import LazyModePanel from './contextPanel/LazyModePanel.svelte'
async function hash(message) {
try {
@@ -175,6 +177,7 @@
let inputsDrawerOpen = fromHub
let historyBrowserDrawerOpen = false
let debugAppDrawerOpen = false
let lazyDrawerOpen = false
let deploymentMsg: string | undefined = undefined
function closeSaveDrawer() {
@@ -871,6 +874,13 @@
action: () => {
debugAppDrawerOpen = true
}
},
{
displayName: 'Lazy mode',
icon: Zap,
action: () => {
lazyDrawerOpen = true
}
}
]
@@ -1267,6 +1277,11 @@
</DrawerContent>
</Drawer>
<Drawer bind:open={lazyDrawerOpen} size="800px">
<DrawerContent title="Lazy Mode" on:close={() => (lazyDrawerOpen = false)}>
<LazyModePanel />
</DrawerContent>
</Drawer>
<Drawer bind:open={$jobsDrawerOpen} size="900px">
<DrawerContent
noPadding
@@ -15,7 +15,6 @@
import GridViewer from './GridViewer.svelte'
import Component from './component/Component.svelte'
import { twMerge } from 'tailwind-merge'
import { columnConfiguration } from '../gridUtils'
import { deepEqual } from 'fast-equals'
import { dfs, maxHeight } from './appUtils'
import { BG_PREFIX, migrateApp } from '../utils'
@@ -292,8 +291,6 @@
allIdsInPath={$allIdsInPath}
items={app.grid}
let:dataItem
let:hidden
cols={columnConfiguration}
{maxRow}
breakpoint={$breakpoint}
>
@@ -308,7 +305,6 @@
selected={false}
locked={true}
fullHeight={dataItem?.[$breakpoint === 'sm' ? 3 : 12]?.fullHeight}
{hidden}
/>
</div>
</GridViewer>
@@ -1,7 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { AppEditorContext, AppViewerContext } from '../types'
import { columnConfiguration, gridColumns, isFixed, toggleFixed } from '../gridUtils'
import { gridColumns, isFixed, toggleFixed } from '../gridUtils'
import { twMerge } from 'tailwind-merge'
import HiddenComponent from '../components/helpers/HiddenComponent.svelte'
@@ -195,11 +195,9 @@
}}
root
let:dataItem
let:hidden
let:overlapped
let:moveMode
let:componentDraggedId
cols={columnConfiguration}
on:dropped={(e) => {
const { id, overlapped, x, y } = e.detail
@@ -252,7 +250,6 @@
fullHeight={dataItem?.[$breakpoint === 'sm' ? 3 : 12]?.fullHeight}
>
<Component
{hidden}
render={true}
component={dataItem.data}
selected={Boolean($selectedComponent?.includes(dataItem.id))}
@@ -1,9 +1,11 @@
<script lang="ts">
import { columnConfiguration, WIDE_GRID_COLUMNS } from '../gridUtils'
import { ROW_GAP_X, ROW_GAP_Y, ROW_HEIGHT } from './appUtils'
import type { EditorBreakpoint } from '../types'
import type { AppViewerContext, EditorBreakpoint } from '../types'
import { onMount, createEventDispatcher } from 'svelte'
import { onMount, createEventDispatcher, getContext } from 'svelte'
import type { FilledItem } from '../svelte-grid/types'
import { getColumn, throttle } from '../svelte-grid/utils/other'
@@ -14,9 +16,10 @@
type T = $$Generic
const { app } = getContext<AppViewerContext>('AppViewerContext')
export let items: FilledItem<T>[]
export let rowHeight: number = ROW_HEIGHT
export let cols: [number, number][]
export let gap = [ROW_GAP_X, ROW_GAP_Y]
export let throttleUpdate = 100
export let maxRow: number
@@ -27,12 +30,19 @@
export let parentWidth: number | undefined = undefined
let getComputedCols
const cols = columnConfiguration
let showSkeleton = false
let getComputedCols: 3 | 12 | undefined =
$app.mobileViewOnSmallerScreens == false ? WIDE_GRID_COLUMNS : undefined
let container
$: [gapX, gapY] = gap
let xPerPx = 0
let yPerPx = rowHeight
$: containerHeight = getContainerHeight(items, yPerPx, getComputedCols)
@@ -48,15 +58,19 @@
}, throttleUpdate)
onMount(() => {
setTimeout(() => {
showSkeleton = true
}, 100)
const sizeObserver = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
let width = entries[0].contentRect.width
if (width === containerWidth) return
if ($app.mobileViewOnSmallerScreens != false || !getComputedCols) {
getComputedCols = getColumn(parentWidth ?? width, cols)
}
getComputedCols = getColumn(parentWidth ?? width, cols)
xPerPx = width / getComputedCols
xPerPx = width / getComputedCols!
if (!containerWidth) {
items = specifyUndefinedColumns(items, getComputedCols, cols)
@@ -81,7 +95,7 @@
</script>
<div class="svlt-grid-container" style="height: {containerHeight}px" bind:this={container}>
{#if xPerPx}
{#if xPerPx && getComputedCols}
{#each items as item (item.id)}
{@const onTop = allIdsInPath?.includes(item.id)}
{@const width =
@@ -103,10 +117,14 @@
: ''} top: {top}px; left: {left}px;"
>
{#if item[getComputedCols]}
<slot dataItem={item} item={item[getComputedCols]} hidden={false} />
<slot dataItem={item} item={item[getComputedCols]} />
{/if}
</div>
{/each}
{:else if showSkeleton}
<div
class="h-full w-full flex-col animate-skeleton dark:bg-frost-900/50 [animation-delay:1000ms]"
/>
{/if}
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import { getContext, onMount } from 'svelte'
import type { AppEditorContext, AppViewerContext } from '../types'
import { allItems } from '../utils'
import type { App, AppEditorContext, AppViewerContext } from '../types'
import { allItems, BG_PREFIX } from '../utils'
import RecomputeAllButton from './RecomputeAllButton.svelte'
const { runnableComponents, app, initialized, recomputeAllContext } =
@@ -13,10 +13,21 @@
let firstLoad = false
let progressTimer: NodeJS.Timeout | undefined = undefined
$: !firstLoad &&
$initialized.initializedComponents?.length ==
allItems($app.grid, $app.subgrids).length + ($app.hiddenInlineScripts?.length ?? 0) &&
refresh()
$: !firstLoad && canInitializeAll($initialized?.initializedComponents, $app) && refresh()
function canInitializeAll(initialized: string[] | undefined, app: App) {
if (app.lazyInitRequire == undefined) {
return (
initialized?.length ==
allItems(app.grid, app.subgrids).length + (app.hiddenInlineScripts?.length ?? 0)
)
} else {
return (
app.hiddenInlineScripts?.every((x, i) => initialized?.includes(BG_PREFIX + i)) &&
app.lazyInitRequire?.every((x) => initialized?.includes(x))
)
}
}
$: $recomputeAllContext.componentNumber =
Object.values($runnableComponents).filter((x) => x.autoRefresh).length ?? 0
@@ -1,8 +1,6 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { AppViewerContext } from '../types'
import { dfs } from './appUtils'
import { deepEqual } from 'fast-equals'
import { Loader2 } from 'lucide-svelte'
import Popover from '$lib/components/Popover.svelte'
import { twMerge } from 'tailwind-merge'
@@ -11,16 +9,8 @@
export let containerClass: string | undefined = undefined
export let containerStyle: string | undefined = undefined
const { selectedComponent, app, connectingInput, allIdsInPath, bgRuns, recomputeAllContext } =
const { connectingInput, bgRuns, recomputeAllContext } =
getContext<AppViewerContext>('AppViewerContext')
let previousSelectedIds: string[] | undefined = undefined
$: if (!deepEqual(previousSelectedIds, $selectedComponent)) {
previousSelectedIds = $selectedComponent
$allIdsInPath = ($selectedComponent ?? [])
.flatMap((id) => dfs($app.grid, id, $app.subgrids ?? {}))
.filter((x) => x != undefined) as string[]
}
</script>
<div
@@ -3,7 +3,7 @@
import { classNames } from '$lib/utils'
import { createEventDispatcher, getContext, onDestroy } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { columnConfiguration, gridColumns, isFixed, toggleFixed } from '../gridUtils'
import { gridColumns, isFixed, toggleFixed } from '../gridUtils'
import Grid from '../svelte-grid/Grid.svelte'
import type { AppEditorContext, AppViewerContext, GridItem } from '../types'
import {
@@ -53,6 +53,10 @@
let isActive = false
let sber = editorContext?.componentActive?.subscribe((x) => (isActive = x))
let everVisible = visible
$: visible && !everVisible && (everVisible = true)
onDestroy(() => {
sber?.()
})
@@ -173,160 +177,160 @@
}
</script>
<div
class="translate-x-0 translate-y-0 w-full subgrid {visible
? 'visible'
: 'invisible h-0 overflow-hidden'}"
bind:this={container}
on:pointerdown={onpointerdown}
>
{#if everVisible}
<div
class={twMerge(
$allIdsInPath.includes(id) && $mode == 'dnd' ? 'overflow-visible' : 'overflow-auto',
noYPadding ? '' : 'py-2',
classes ?? '',
noPadding ? 'px-0' : 'px-2'
)}
style="{containerHeight ? `height: ${containerHeight - 2}px;` : ''} {style ?? ''}"
class="translate-x-0 translate-y-0 w-full subgrid {visible
? 'visible'
: 'invisible h-0 overflow-hidden'}"
bind:this={container}
on:pointerdown={onpointerdown}
>
{#if $mode !== 'preview'}
<div
class={highlight
? `animate-border border-dashed border-2 min-h-full ${
isActive && !$selectedComponent?.includes(id)
? 'border-orange-600'
: 'dark:border-gray-600 border-gray-400'
}`
: ''}
>
<Grid
<div
class={twMerge(
$allIdsInPath.includes(id) && $mode == 'dnd' ? 'overflow-visible' : 'overflow-auto',
noYPadding ? '' : 'py-2',
classes ?? '',
noPadding ? 'px-0' : 'px-2'
)}
style="{containerHeight ? `height: ${containerHeight - 2}px;` : ''} {style ?? ''}"
>
{#if $mode !== 'preview'}
<div
class={highlight
? `outline !outline-dashed outline-2 min-h-full ${
isActive && !$selectedComponent?.includes(id)
? 'outline-orange-600'
: 'outline-gray-400 dark:outline-gray-600'
}`
: ''}
>
<Grid
allIdsInPath={$allIdsInPath}
items={$app.subgrids?.[subGridId] ?? []}
on:redraw={(e) => {
push(editorContext?.history, $app)
if ($app.subgrids) {
$app.subgrids[subGridId] = e.detail
}
}}
selectedIds={$selectedComponent}
let:dataItem
let:overlapped
let:moveMode
let:componentDraggedId
scroller={container}
parentWidth={$parentWidth - 17}
{containerWidth}
on:dropped={(e) => {
const { id, overlapped, x, y } = e.detail
if (!overlapped) {
moveToRoot(id, { x, y })
} else {
const overlappedComponent = findGridItem($app, overlapped)
if (overlappedComponent && !isContainer(overlappedComponent.data.type)) {
return
}
if (!overlapped) {
return
}
if (id === overlapped) {
return
}
moveComponentBetweenSubgrids(
id,
overlapped,
subGridIndexKey(overlappedComponent?.data?.type, overlapped, $worldStore),
{ x, y }
)
}
}}
disableMove={!!$connectingInput.opened}
>
<ComponentWrapper
id={dataItem.id}
type={dataItem.data.type}
class={classNames(
'h-full w-full center-center',
$selectedComponent?.includes(dataItem.id) ? 'active-grid-item' : '',
'top-0'
)}
>
<GridEditorMenu id={dataItem.id}>
<Component
{overlapped}
fullHeight={dataItem?.[$breakpoint === 'sm' ? 3 : 12]?.fullHeight}
render={visible}
component={dataItem.data}
selected={Boolean($selectedComponent?.includes(dataItem.id))}
locked={isFixed(dataItem)}
on:lock={() => lock(dataItem)}
on:expand={() => {
const parentGridItem = findGridItem($app, id)
if (!parentGridItem) {
return
}
$selectedComponent = [dataItem.id]
push(editorContext?.history, $app)
expandGriditem(
$app.subgrids?.[subGridId] ?? [],
dataItem.id,
$breakpoint,
parentGridItem
)
$app = $app
}}
on:fillHeight={() => {
const gridItem = findGridItem($app, dataItem.id)
const b = $breakpoint === 'sm' ? 3 : 12
if (gridItem?.[b]) {
gridItem[b].fullHeight = !gridItem[b].fullHeight
}
$app = $app
}}
{moveMode}
{componentDraggedId}
/>
</GridEditorMenu>
</ComponentWrapper>
</Grid>
</div>
{:else}
<GridViewer
allIdsInPath={$allIdsInPath}
items={$app.subgrids?.[subGridId] ?? []}
on:redraw={(e) => {
push(editorContext?.history, $app)
if ($app.subgrids) {
$app.subgrids[subGridId] = e.detail
}
}}
selectedIds={$selectedComponent}
let:dataItem
let:hidden
let:overlapped
let:moveMode
let:componentDraggedId
cols={columnConfiguration}
scroller={container}
breakpoint={$breakpoint}
parentWidth={$parentWidth - 17}
{containerWidth}
on:dropped={(e) => {
const { id, overlapped, x, y } = e.detail
if (!overlapped) {
moveToRoot(id, { x, y })
} else {
const overlappedComponent = findGridItem($app, overlapped)
if (overlappedComponent && !isContainer(overlappedComponent.data.type)) {
return
}
if (!overlapped) {
return
}
if (id === overlapped) {
return
}
moveComponentBetweenSubgrids(
id,
overlapped,
subGridIndexKey(overlappedComponent?.data?.type, overlapped, $worldStore),
{ x, y }
)
}
}}
disableMove={!!$connectingInput.opened}
{maxRow}
>
<ComponentWrapper
id={dataItem.id}
type={dataItem.data.type}
class={classNames(
'h-full w-full center-center',
$selectedComponent?.includes(dataItem.id) ? 'active-grid-item' : '',
'top-0'
)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:pointerdown|stopPropagation={(e) => selectComponent(e, dataItem.id)}
class={classNames('h-full w-full center-center', 'top-0')}
>
<GridEditorMenu id={dataItem.id}>
<Component
{hidden}
{overlapped}
fullHeight={dataItem?.[$breakpoint === 'sm' ? 3 : 12]?.fullHeight}
render={visible}
component={dataItem.data}
selected={Boolean($selectedComponent?.includes(dataItem.id))}
locked={isFixed(dataItem)}
on:lock={() => lock(dataItem)}
on:expand={() => {
const parentGridItem = findGridItem($app, id)
if (!parentGridItem) {
return
}
$selectedComponent = [dataItem.id]
push(editorContext?.history, $app)
expandGriditem(
$app.subgrids?.[subGridId] ?? [],
dataItem.id,
$breakpoint,
parentGridItem
)
$app = $app
}}
on:fillHeight={() => {
const gridItem = findGridItem($app, dataItem.id)
const b = $breakpoint === 'sm' ? 3 : 12
if (gridItem?.[b]) {
gridItem[b].fullHeight = !gridItem[b].fullHeight
}
$app = $app
}}
{moveMode}
{componentDraggedId}
/>
</GridEditorMenu>
</ComponentWrapper>
</Grid>
</div>
{:else}
<GridViewer
allIdsInPath={$allIdsInPath}
items={$app.subgrids?.[subGridId] ?? []}
let:dataItem
let:hidden
cols={columnConfiguration}
breakpoint={$breakpoint}
parentWidth={$parentWidth - 17}
{containerWidth}
{maxRow}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:pointerdown|stopPropagation={(e) => selectComponent(e, dataItem.id)}
class={classNames('h-full w-full center-center', 'top-0')}
>
<Component
fullHeight={dataItem?.[$breakpoint === 'sm' ? 3 : 12]?.fullHeight}
render={visible}
component={dataItem.data}
selected={Boolean($selectedComponent?.includes(dataItem.id))}
locked={isFixed(dataItem)}
{hidden}
/>
</div>
</GridViewer>
{/if}
<Component
fullHeight={dataItem?.[$breakpoint === 'sm' ? 3 : 12]?.fullHeight}
render={true}
component={dataItem.data}
selected={Boolean($selectedComponent?.includes(dataItem.id))}
locked={isFixed(dataItem)}
/>
</div>
</GridViewer>
{/if}
</div>
</div>
</div>
{:else if $app.lazyInitRequire == undefined}
{#each $app?.subgrids?.[subGridId] ?? [] as item}
<Component render={false} component={item.data} />
{/each}
{/if}
@@ -1,969 +1,39 @@
<script lang="ts" context="module">
let outTimeout: NodeJS.Timeout | undefined = undefined
</script>
<script lang="ts">
import { getContext } from 'svelte'
import { twMerge } from 'tailwind-merge'
import type { AppEditorContext, AppViewerContext } from '../../types'
import ComponentHeader from '../ComponentHeader.svelte'
import ComponentInner from './ComponentInner.svelte'
import ComponentRendered from './ComponentRendered.svelte'
import type { AppComponent } from './components'
import AppMultiSelect from '../../components/inputs/AppMultiSelect.svelte'
import AppMultiSelectV2 from '../../components/inputs/AppMultiSelectV2.svelte'
import AppModal from '../../components/layout/AppModal.svelte'
import AppSchemaForm from '../../components/buttons/AppSchemaForm.svelte'
import AppStepper from '../../components/layout/AppStepper.svelte'
import AppSelectTab from '../../components/inputs/AppSelectTab.svelte'
import AppConditionalWrapper from '../../components/layout/AppConditionalWrapper.svelte'
import AppSelectStep from '../../components/inputs/AppSelectStep.svelte'
import AppDownload from '../../components/display/AppDownload.svelte'
import AppLogsComponent from '../../components/display/AppLogsComponent.svelte'
import AppFlowStatusComponent from '../../components/display/AppFlowStatusComponent.svelte'
import AppChartJs from '../../components/display/AppChartJs.svelte'
import AppChartJsV2 from '../../components/display/AppChartJsV2.svelte'
import AppQuillEditor from '../../components/inputs/AppQuillEditor.svelte'
import AppList from '../../components/layout/AppList.svelte'
import AppJobIdLogComponent from '../../components/display/AppJobIdLogComponent.svelte'
import AppJobIdFlowStatus from '../../components/display/AppJobIdFlowStatus.svelte'
import AppCarouselList from '../../components/display/AppCarouselList.svelte'
import AppAccordionList from '../../components/display/AppAccordionList.svelte'
import AppAggridTableEe from '../../components/display/table/AppAggridTableEe.svelte'
import AppCustomComponent from '../../components/display/AppCustomComponent.svelte'
import AppStatCard from '../../components/display/AppStatCard.svelte'
import AppMenu from '../../components/display/AppMenu.svelte'
import AppDecisionTree from '../../components/layout/AppDecisionTree.svelte'
import AppAgCharts from '../../components/display/charts/AppAgCharts.svelte'
import AppDbExplorer from '../../components/display/dbtable/AppDbExplorer.svelte'
import AppS3FileInput from '../../components/inputs/AppS3FileInput.svelte'
import AppAlert from '../../components/display/AppAlert.svelte'
import AppDateSliderInput from '../../components/inputs/AppDateSliderInput.svelte'
import AppTimeInput from '../../components/inputs/AppTimeInput.svelte'
import AppDateTimeInput from '../../components/inputs/AppDateTimeInput.svelte'
import AppAggridInfiniteTable from '../../components/display/table/AppAggridInfiniteTable.svelte'
import AppAggridInfiniteTableEe from '../../components/display/table/AppAggridInfiniteTableEe.svelte'
import AppDisplayComponent from '../../components/display/AppDisplayComponent.svelte'
import AppTimeseries from '../../components/display/AppTimeseries.svelte'
import AppHtml from '../../components/display/AppHtml.svelte'
import AppMarkdown from '../../components/display/AppMarkdown.svelte'
import VegaLiteHtml from '../../components/display/VegaLiteHtml.svelte'
import PlotlyHtml from '../../components/display/PlotlyHtml.svelte'
import PlotlyHtmlV2 from '../../components/display/PlotlyHtmlV2.svelte'
import AppScatterChart from '../../components/display/AppScatterChart.svelte'
import AppPieChart from '../../components/display/AppPieChart.svelte'
import AppTable from '../../components/display/table/AppTable.svelte'
import AppAggridTable from '../../components/display/table/AppAggridTable.svelte'
import AppText from '../../components/display/AppText.svelte'
import AppButton from '../../components/buttons/AppButton.svelte'
import AppForm from '../../components/buttons/AppForm.svelte'
import AppFormButton from '../../components/buttons/AppFormButton.svelte'
import AppCheckbox from '../../components/inputs/AppCheckbox.svelte'
import AppTextInput from '../../components/inputs/AppTextInput.svelte'
import AppDateInput from '../../components/inputs/AppDateInput.svelte'
import AppSelect from '../../components/inputs/AppSelect.svelte'
import AppBarChart from '../../components/display/AppBarChart.svelte'
import AppDivider from '../../components/layout/AppDivider.svelte'
import AppRangeInput from '../../components/inputs/AppRangeInput.svelte'
import AppTabs from '../../components/layout/AppTabs.svelte'
import AppContainer from '../../components/layout/AppContainer.svelte'
import AppSplitpanes from '../../components/layout/AppSplitpanes.svelte'
import AppIcon from '../../components/display/AppIcon.svelte'
import AppFileInput from '../../components/inputs/AppFileInput.svelte'
import AppImage from '../../components/display/AppImage.svelte'
import AppDrawer from '../../components/layout/AppDrawer.svelte'
import AppMap from '../../components/display/AppMap.svelte'
import AppPdf from '../../components/display/AppPdf.svelte'
import AppCurrencyInput from '../../components/inputs/currency/AppCurrencyInput.svelte'
import AppSliderInputs from '../../components/inputs/AppSliderInputs.svelte'
import AppNumberInput from '../../components/inputs/AppNumberInput.svelte'
import AppNavbar from '../../components/display/AppNavbar.svelte'
import AppDateSelect from '../../components/inputs/AppDateSelect.svelte'
import AppDisplayComponentByJobId from '../../components/display/AppRecomputeAll.svelte'
import AppRecomputeAll from '../../components/display/AppRecomputeAll.svelte'
import AppUserResource from '../../components/inputs/AppUserResource.svelte'
import { Anchor } from 'lucide-svelte'
import { findGridItemParentGrid, isContainer } from '../appUtils'
export let component: AppComponent
export let selected: boolean
export let locked: boolean = false
export let render: boolean
export let hidden: boolean
export let fullHeight: boolean
export let overlapped: string | undefined = undefined
export let moveMode: string | undefined = undefined
export let componentDraggedId: string | undefined = undefined
const { mode, app, hoverStore, connectingInput } =
getContext<AppViewerContext>('AppViewerContext')
const editorContext = getContext<AppEditorContext>('AppEditorContext')
const componentActive = editorContext?.componentActive
export let moveMode: string | undefined = undefined
let everRender = render
const movingcomponents = editorContext?.movingcomponents
$: ismoving =
movingcomponents != undefined && $mode == 'dnd' && $movingcomponents?.includes(component.id)
let initializing: boolean | undefined = undefined
let errorHandledByComponent: boolean = false
let componentContainerHeight: number = 0
let componentContainerWidth: number = 0
let inlineEditorOpened: boolean = false
function mouseOut() {
outTimeout && clearTimeout(outTimeout)
outTimeout = setTimeout(() => {
if ($hoverStore !== undefined) {
// In order to avoid flickering when hovering over table actions,
// we leave the actions to manage the hover state
if ($hoverStore.startsWith(`${component.id}_`)) {
return
}
$hoverStore = undefined
}
}, 50)
}
function componentDraggedIsNotChild(componentDraggedId: string, componentId: string) {
let parentGrid = findGridItemParentGrid($app, componentDraggedId)
return !parentGrid?.startsWith(`${componentId}-`)
}
function areOnTheSameSubgrid(componentDraggedId: string, componentId: string) {
return (
findGridItemParentGrid($app, componentDraggedId) === findGridItemParentGrid($app, componentId)
)
}
let cachedComponentDraggedIsNotChild: boolean | undefined
let cachedAreOnTheSameSubgrid: boolean | undefined
function updateCache(componentDraggedId: string | undefined) {
if (componentDraggedId) {
cachedComponentDraggedIsNotChild = componentDraggedIsNotChild(
componentDraggedId,
component.id
)
cachedAreOnTheSameSubgrid = areOnTheSameSubgrid(componentDraggedId, component.id)
} else {
cachedComponentDraggedIsNotChild = undefined
cachedAreOnTheSameSubgrid = undefined
}
}
$: updateCache(componentDraggedId)
$: render && !everRender && (everRender = true)
</script>
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:mouseover|stopPropagation={() => {
outTimeout && clearTimeout(outTimeout)
if (component.id !== $hoverStore) {
$hoverStore = component.id
}
}}
on:mouseout|stopPropagation={mouseOut}
class={twMerge(
'h-full flex flex-col w-full component relative',
initializing ? 'overflow-hidden h-0' : '',
hidden && $mode === 'preview' ? 'hidden' : ''
)}
data-connection-button
>
{#if locked && componentActive && $componentActive && moveMode === 'move' && componentDraggedId && componentDraggedId !== component.id && cachedAreOnTheSameSubgrid}
<div
class={twMerge('absolute inset-0 bg-locked center-center flex-col z-50', 'bg-locked-hover')}
>
<div class="bg-surface p-2 shadow-sm rounded-md flex center-center flex-col gap-2">
<Anchor size={24} class="text-primary " />
<div class="text-xs"> Anchored: The component cannot be moved. </div>
</div>
</div>
{:else if moveMode === 'insert' && isContainer(component.type) && componentDraggedId && componentDraggedId !== component.id && cachedComponentDraggedIsNotChild}
<div
class={twMerge(
'absolute inset-0 flex-col rounded-md bg-blue-100 dark:bg-gray-800 bg-opacity-50',
'outline-dashed outline-offset-2 outline-2 outline-blue-300 dark:outline-blue-700',
overlapped === component?.id ? 'bg-draggedover dark:bg-draggedover-dark' : ''
)}
/>
{/if}
{#if $mode !== 'preview'}
<ComponentHeader
on:mouseover={() => {
outTimeout && clearTimeout(outTimeout)
if (component.id !== $hoverStore) {
$hoverStore = component.id
}
}}
hover={$hoverStore === component.id}
{component}
{selected}
{fullHeight}
connecting={$connectingInput.opened}
on:lock
on:expand
on:fillHeight
{locked}
{inlineEditorOpened}
hasInlineEditor={component.type === 'textcomponent' &&
component.componentInput &&
component.componentInput.type !== 'connected'}
on:triggerInlineEditor={() => {
inlineEditorOpened = !inlineEditorOpened
}}
{errorHandledByComponent}
{componentContainerWidth}
/>
{/if}
{#if ismoving}
<div class="absolute -top-8 w-40">
<button
class="border p-0.5 text-xs"
on:click={() => {
$movingcomponents = undefined
}}
>
Cancel move
</button>
</div>
{/if}
<div
class={twMerge(
'h-full outline-1',
$mode === 'dnd' ? 'bg-surface/40' : '',
$hoverStore === component.id && $mode !== 'preview'
? $connectingInput.opened
? 'outline outline-[#f8aa4b]'
: 'outline outline-blue-400'
: '',
selected && $mode !== 'preview' ? 'outline outline-blue-600' : '',
$mode != 'preview' ? 'cursor-pointer' : '',
'relative z-auto',
$app.css?.['app']?.['component']?.class,
'wm-app-component',
ismoving ? 'animate-pulse' : ''
)}
style={$app.css?.['app']?.['component']?.style}
bind:clientHeight={componentContainerHeight}
bind:clientWidth={componentContainerWidth}
>
{#if component.type === 'displaycomponent'}
<AppDisplayComponent
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
configuration={component.configuration}
{render}
/>
{:else if component.type === 'logcomponent'}
<AppLogsComponent />
{:else if component.type === 'jobidlogcomponent'}
<AppJobIdLogComponent
id={component.id}
customCss={component.customCss}
bind:initializing
configuration={component.configuration}
{render}
/>
{:else if component.type === 'flowstatuscomponent'}
<AppFlowStatusComponent />
{:else if component.type === 'jobidflowstatuscomponent'}
<AppJobIdFlowStatus
id={component.id}
customCss={component.customCss}
bind:initializing
configuration={component.configuration}
{render}
/>
{:else if component.type === 'barchartcomponent'}
<AppBarChart
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'timeseriescomponent'}
<AppTimeseries
id={component.id}
customCss={component.customCss}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'htmlcomponent'}
<AppHtml
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'customcomponent'}
<AppCustomComponent
customComponent={component.customComponent}
id={component.id}
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'mardowncomponent'}
<AppMarkdown
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
configuration={component.configuration}
{render}
/>
{:else if component.type === 'vegalitecomponent'}
<VegaLiteHtml
configuration={component.configuration}
id={component.id}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'plotlycomponent'}
<PlotlyHtml
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'plotlycomponentv2'}
<PlotlyHtmlV2
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
datasets={component.datasets}
xData={component.xData}
{render}
/>
{:else if component.type === 'scatterchartcomponent'}
<AppScatterChart
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'piechartcomponent'}
<AppPieChart
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'agchartscomponent'}
<AppAgCharts
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
datasets={component.datasets}
xData={component.xData}
{render}
/>
{:else if component.type === 'agchartscomponentee'}
<AppAgCharts
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
datasets={component.datasets}
xData={component.xData}
license={component.license}
ee={true}
{render}
/>
{:else if component.type === 'tablecomponent'}
<AppTable
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
actionButtons={component.actionButtons}
{render}
/>
{:else if component.type === 'dbexplorercomponent'}
<AppDbExplorer
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
actions={component.actions ?? []}
bind:initializing
{render}
/>
{:else if component.type === 'aggridcomponent'}
<AppAggridTable
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
customCss={component.customCss}
actions={component.actions ?? []}
actionsOrder={component.actionsOrder ?? undefined}
{render}
/>
{:else if component.type === 'aggridcomponentee'}
<AppAggridTableEe
license={component.license}
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
customCss={component.customCss}
actions={component.actions ?? []}
actionsOrder={component.actionsOrder ?? undefined}
{render}
/>
{:else if component.type === 'aggridinfinitecomponent'}
<AppAggridInfiniteTable
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
customCss={component.customCss}
actions={component.actions ?? []}
{render}
/>
{:else if component.type === 'aggridinfinitecomponentee'}
<AppAggridInfiniteTableEe
license={component.license}
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
customCss={component.customCss}
actions={component.actions ?? []}
{render}
/>
{:else if component.type === 'textcomponent'}
<AppText
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
bind:initializing
bind:editorMode={inlineEditorOpened}
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'buttoncomponent'}
<AppButton
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
componentInput={component.componentInput}
recomputeIds={component.recomputeIds}
bind:errorHandledByComponent
{render}
/>
{:else if component.type === 'downloadcomponent'}
<AppDownload
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'selectcomponent' || component.type === 'resourceselectcomponent'}
<AppSelect
recomputeIds={component.recomputeIds}
id={component.id}
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
customCss={component.customCss}
onSelect={component.onSelect}
{render}
/>
{:else if component.type === 'userresourcecomponent'}
<AppUserResource
id={component.id}
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'multiselectcomponent'}
<AppMultiSelect
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
verticalAlignment={component.verticalAlignment}
{render}
/>
{:else if component.type === 'multiselectcomponentv2'}
<AppMultiSelectV2
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
verticalAlignment={component.verticalAlignment}
{render}
/>
{:else if component.type === 'formcomponent'}
<AppForm
id={component.id}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
componentInput={component.componentInput}
recomputeIds={component.recomputeIds}
bind:errorHandledByComponent
{render}
/>
{:else if component.type === 'formbuttoncomponent'}
<AppFormButton
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
componentInput={component.componentInput}
recomputeIds={component.recomputeIds}
bind:errorHandledByComponent
{render}
/>
{:else if component.type === 'checkboxcomponent'}
<AppCheckbox
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
recomputeIds={component.recomputeIds}
onToggle={component.onToggle}
{render}
/>
{:else if component.type === 'textinputcomponent'}
<AppTextInput
id={component.id}
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'quillcomponent'}
<AppQuillEditor id={component.id} configuration={component.configuration} {render} />
{:else if component.type === 'textareainputcomponent'}
<AppTextInput
id={component.id}
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
customCss={component.customCss}
inputType="textarea"
appCssKey="textareainputcomponent"
{render}
/>
{:else if component.type === 'emailinputcomponent'}
<AppTextInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="email"
appCssKey="emailinputcomponent"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'passwordinputcomponent'}
<AppTextInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="password"
appCssKey="passwordinputcomponent"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'dateinputcomponent'}
<AppDateInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="date"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'timeinputcomponent'}
<AppTimeInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'datetimeinputcomponent'}
<AppDateTimeInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="date"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'numberinputcomponent'}
<AppNumberInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'currencycomponent'}
<AppCurrencyInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'slidercomponent'}
<AppSliderInputs
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'dateslidercomponent'}
<AppDateSliderInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'horizontaldividercomponent'}
<AppDivider
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
position="horizontal"
{render}
/>
{:else if component.type === 'verticaldividercomponent'}
<AppDivider
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
position="vertical"
{render}
/>
{:else if component.type === 'rangecomponent'}
<AppRangeInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'tabscomponent' && component.tabs}
<AppTabs
configuration={component.configuration}
id={component.id}
tabs={component.tabs}
disabledTabs={component.disabledTabs}
onTabChange={component.onTabChange}
customCss={component.customCss}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'steppercomponent' && component.tabs}
<AppStepper
id={component.id}
tabs={component.tabs}
customCss={component.customCss}
{componentContainerHeight}
componentInput={component.componentInput}
onNext={component.onNext}
onPrevious={component.onPrevious}
{render}
/>
{:else if component.type === 'conditionalwrapper' && component.conditions}
<AppConditionalWrapper
id={component.id}
conditions={component.conditions}
customCss={component.customCss}
onTabChange={component.onTabChange}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'containercomponent'}
<AppContainer
groupFields={component.groupFields}
id={component.id}
customCss={component.customCss}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'listcomponent'}
<AppList
id={component.id}
customCss={component.customCss}
configuration={component.configuration}
componentInput={component.componentInput}
{render}
bind:initializing
/>
{:else if component.type === 'verticalsplitpanescomponent'}
<AppSplitpanes
id={component.id}
customCss={component.customCss}
panes={component.panes}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'horizontalsplitpanescomponent'}
<AppSplitpanes
id={component.id}
customCss={component.customCss}
panes={component.panes}
{componentContainerHeight}
horizontal={true}
{render}
/>
{:else if component.type === 'iconcomponent'}
<AppIcon
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'fileinputcomponent'}
<AppFileInput
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onFileChange={component.onFileChange}
{render}
/>
{:else if component.type === 's3fileinputcomponent'}
<AppS3FileInput
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onFileChange={component.onFileChange}
{render}
/>
{:else if component.type === 'imagecomponent'}
<AppImage
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'drawercomponent'}
<AppDrawer
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onOpenRecomputeIds={component.onOpenRecomputeIds}
onCloseRecomputeIds={component.onCloseRecomputeIds}
{render}
/>
{:else if component.type === 'mapcomponent'}
<AppMap
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'pdfcomponent'}
<AppPdf
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'modalcomponent'}
<AppModal
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onOpenRecomputeIds={component.onOpenRecomputeIds}
onCloseRecomputeIds={component.onCloseRecomputeIds}
{render}
/>
{:else if component.type === 'schemaformcomponent'}
<AppSchemaForm
id={component.id}
componentInput={component.componentInput}
configuration={component.configuration}
customCss={component.customCss}
{initializing}
{render}
/>
{:else if component.type === 'selecttabcomponent'}
<AppSelectTab
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'selectstepcomponent'}
<AppSelectStep
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'chartjscomponent'}
<AppChartJs
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'chartjscomponentv2'}
<AppChartJsV2
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
datasets={component.datasets}
xData={component.xData}
{render}
/>
{:else if component.type === 'carousellistcomponent'}
<AppCarouselList
id={component.id}
configuration={component.configuration}
componentInput={component.componentInput}
customCss={component.customCss}
{componentContainerHeight}
{render}
bind:initializing
/>
{:else if component.type === 'accordionlistcomponent'}
<AppAccordionList
id={component.id}
componentInput={component.componentInput}
customCss={component.customCss}
{componentContainerHeight}
{render}
bind:initializing
/>
{:else if component.type === 'statcomponent'}
<AppStatCard
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'menucomponent'}
<AppMenu
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
menuItems={component.menuItems}
{render}
/>
{:else if component.type === 'decisiontreecomponent' && component.nodes}
<AppDecisionTree
id={component.id}
nodes={component.nodes}
customCss={component.customCss}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'alertcomponent'}
<AppAlert
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
verticalAlignment={component.verticalAlignment}
{render}
/>
{:else if component.type === 'navbarcomponent'}
<AppNavbar
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
navbarItems={component.navbarItems}
{render}
/>
{:else if component.type === 'dateselectcomponent'}
<AppDateSelect
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
verticalAlignment={component.verticalAlignment}
{render}
/>
{:else if component.type === 'jobiddisplaycomponent'}
<AppDisplayComponentByJobId
id={component.id}
customCss={component.customCss}
bind:initializing
configuration={component.configuration}
{render}
/>
{:else if component.type === 'recomputeallcomponent'}
<AppRecomputeAll
id={component.id}
customCss={component.customCss}
bind:initializing
configuration={component.configuration}
horizontalAlignment={component.horizontalAlignment}
{render}
/>
{/if}
</div>
</div>
{#if initializing}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:mouseover|stopPropagation={() => {
if (component.id !== $hoverStore) {
$hoverStore = component.id
}
}}
on:mouseout|stopPropagation={() => {
if ($hoverStore !== undefined) {
$hoverStore = undefined
}
}}
class="absolute inset-0 center-center flex-col bg- border animate-skeleton"
{#if everRender}
<ComponentRendered
{moveMode}
{componentDraggedId}
{overlapped}
{render}
{component}
{selected}
{locked}
{fullHeight}
/>
{:else}
<ComponentInner
{component}
render={false}
componentContainerHeight={0}
errorHandledByComponent={false}
inlineEditorOpened={false}
/>
{/if}
@@ -0,0 +1,778 @@
<script lang="ts">
import AppMultiSelect from '../../components/inputs/AppMultiSelect.svelte'
import AppMultiSelectV2 from '../../components/inputs/AppMultiSelectV2.svelte'
import AppModal from '../../components/layout/AppModal.svelte'
import AppSchemaForm from '../../components/buttons/AppSchemaForm.svelte'
import AppStepper from '../../components/layout/AppStepper.svelte'
import AppSelectTab from '../../components/inputs/AppSelectTab.svelte'
import AppConditionalWrapper from '../../components/layout/AppConditionalWrapper.svelte'
import AppSelectStep from '../../components/inputs/AppSelectStep.svelte'
import AppDownload from '../../components/display/AppDownload.svelte'
import AppLogsComponent from '../../components/display/AppLogsComponent.svelte'
import AppFlowStatusComponent from '../../components/display/AppFlowStatusComponent.svelte'
import AppChartJs from '../../components/display/AppChartJs.svelte'
import AppChartJsV2 from '../../components/display/AppChartJsV2.svelte'
import AppQuillEditor from '../../components/inputs/AppQuillEditor.svelte'
import AppList from '../../components/layout/AppList.svelte'
import AppJobIdLogComponent from '../../components/display/AppJobIdLogComponent.svelte'
import AppJobIdFlowStatus from '../../components/display/AppJobIdFlowStatus.svelte'
import AppCarouselList from '../../components/display/AppCarouselList.svelte'
import AppAccordionList from '../../components/display/AppAccordionList.svelte'
import AppAggridTableEe from '../../components/display/table/AppAggridTableEe.svelte'
import AppCustomComponent from '../../components/display/AppCustomComponent.svelte'
import AppStatCard from '../../components/display/AppStatCard.svelte'
import AppMenu from '../../components/display/AppMenu.svelte'
import AppDecisionTree from '../../components/layout/AppDecisionTree.svelte'
import AppAgCharts from '../../components/display/charts/AppAgCharts.svelte'
import AppDbExplorer from '../../components/display/dbtable/AppDbExplorer.svelte'
import AppS3FileInput from '../../components/inputs/AppS3FileInput.svelte'
import AppAlert from '../../components/display/AppAlert.svelte'
import AppDateSliderInput from '../../components/inputs/AppDateSliderInput.svelte'
import AppTimeInput from '../../components/inputs/AppTimeInput.svelte'
import AppDateTimeInput from '../../components/inputs/AppDateTimeInput.svelte'
import AppAggridInfiniteTable from '../../components/display/table/AppAggridInfiniteTable.svelte'
import AppAggridInfiniteTableEe from '../../components/display/table/AppAggridInfiniteTableEe.svelte'
import AppDisplayComponent from '../../components/display/AppDisplayComponent.svelte'
import AppTimeseries from '../../components/display/AppTimeseries.svelte'
import AppHtml from '../../components/display/AppHtml.svelte'
import AppMarkdown from '../../components/display/AppMarkdown.svelte'
import VegaLiteHtml from '../../components/display/VegaLiteHtml.svelte'
import PlotlyHtml from '../../components/display/PlotlyHtml.svelte'
import PlotlyHtmlV2 from '../../components/display/PlotlyHtmlV2.svelte'
import AppScatterChart from '../../components/display/AppScatterChart.svelte'
import AppPieChart from '../../components/display/AppPieChart.svelte'
import AppTable from '../../components/display/table/AppTable.svelte'
import AppAggridTable from '../../components/display/table/AppAggridTable.svelte'
import AppText from '../../components/display/AppText.svelte'
import AppButton from '../../components/buttons/AppButton.svelte'
import AppForm from '../../components/buttons/AppForm.svelte'
import AppFormButton from '../../components/buttons/AppFormButton.svelte'
import AppCheckbox from '../../components/inputs/AppCheckbox.svelte'
import AppTextInput from '../../components/inputs/AppTextInput.svelte'
import AppDateInput from '../../components/inputs/AppDateInput.svelte'
import AppSelect from '../../components/inputs/AppSelect.svelte'
import AppBarChart from '../../components/display/AppBarChart.svelte'
import AppDivider from '../../components/layout/AppDivider.svelte'
import AppRangeInput from '../../components/inputs/AppRangeInput.svelte'
import AppTabs from '../../components/layout/AppTabs.svelte'
import AppContainer from '../../components/layout/AppContainer.svelte'
import AppSplitpanes from '../../components/layout/AppSplitpanes.svelte'
import AppIcon from '../../components/display/AppIcon.svelte'
import AppFileInput from '../../components/inputs/AppFileInput.svelte'
import AppImage from '../../components/display/AppImage.svelte'
import AppDrawer from '../../components/layout/AppDrawer.svelte'
import AppMap from '../../components/display/AppMap.svelte'
import AppPdf from '../../components/display/AppPdf.svelte'
import AppCurrencyInput from '../../components/inputs/currency/AppCurrencyInput.svelte'
import AppSliderInputs from '../../components/inputs/AppSliderInputs.svelte'
import AppNumberInput from '../../components/inputs/AppNumberInput.svelte'
import AppNavbar from '../../components/display/AppNavbar.svelte'
import AppDateSelect from '../../components/inputs/AppDateSelect.svelte'
import AppDisplayComponentByJobId from '../../components/display/AppRecomputeAll.svelte'
import AppRecomputeAll from '../../components/display/AppRecomputeAll.svelte'
import AppUserResource from '../../components/inputs/AppUserResource.svelte'
import type { AppComponent } from './components'
export let component: AppComponent
export let render: boolean
export let componentContainerHeight: number
export let initializing: boolean | undefined = undefined
export let errorHandledByComponent: boolean
export let inlineEditorOpened: boolean
</script>
{#if component.type === 'displaycomponent'}
<AppDisplayComponent
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
configuration={component.configuration}
{render}
/>
{:else if component.type === 'logcomponent'}
<AppLogsComponent />
{:else if component.type === 'jobidlogcomponent'}
<AppJobIdLogComponent
id={component.id}
customCss={component.customCss}
bind:initializing
configuration={component.configuration}
{render}
/>
{:else if component.type === 'flowstatuscomponent'}
<AppFlowStatusComponent />
{:else if component.type === 'jobidflowstatuscomponent'}
<AppJobIdFlowStatus
id={component.id}
customCss={component.customCss}
bind:initializing
configuration={component.configuration}
{render}
/>
{:else if component.type === 'barchartcomponent'}
<AppBarChart
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'timeseriescomponent'}
<AppTimeseries
id={component.id}
customCss={component.customCss}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'htmlcomponent'}
<AppHtml
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'customcomponent'}
<AppCustomComponent
customComponent={component.customComponent}
id={component.id}
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'mardowncomponent'}
<AppMarkdown
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
configuration={component.configuration}
{render}
/>
{:else if component.type === 'vegalitecomponent'}
<VegaLiteHtml
configuration={component.configuration}
id={component.id}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'plotlycomponent'}
<PlotlyHtml
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'plotlycomponentv2'}
<PlotlyHtmlV2
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
datasets={component.datasets}
xData={component.xData}
{render}
/>
{:else if component.type === 'scatterchartcomponent'}
<AppScatterChart
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'piechartcomponent'}
<AppPieChart
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'agchartscomponent'}
<AppAgCharts
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
datasets={component.datasets}
xData={component.xData}
{render}
/>
{:else if component.type === 'agchartscomponentee'}
<AppAgCharts
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
datasets={component.datasets}
xData={component.xData}
license={component.license}
ee={true}
{render}
/>
{:else if component.type === 'tablecomponent'}
<AppTable
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
actionButtons={component.actionButtons}
{render}
/>
{:else if component.type === 'dbexplorercomponent'}
<AppDbExplorer
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
actions={component.actions ?? []}
bind:initializing
{render}
/>
{:else if component.type === 'aggridcomponent'}
<AppAggridTable
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
customCss={component.customCss}
actions={component.actions ?? []}
actionsOrder={component.actionsOrder ?? undefined}
{render}
/>
{:else if component.type === 'aggridcomponentee'}
<AppAggridTableEe
license={component.license}
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
customCss={component.customCss}
actions={component.actions ?? []}
actionsOrder={component.actionsOrder ?? undefined}
{render}
/>
{:else if component.type === 'aggridinfinitecomponent'}
<AppAggridInfiniteTable
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
customCss={component.customCss}
actions={component.actions ?? []}
{render}
/>
{:else if component.type === 'aggridinfinitecomponentee'}
<AppAggridInfiniteTableEe
license={component.license}
id={component.id}
configuration={component.configuration}
bind:initializing
componentInput={component.componentInput}
customCss={component.customCss}
actions={component.actions ?? []}
{render}
/>
{:else if component.type === 'textcomponent'}
<AppText
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
bind:initializing
bind:editorMode={inlineEditorOpened}
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'buttoncomponent'}
<AppButton
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
componentInput={component.componentInput}
recomputeIds={component.recomputeIds}
bind:errorHandledByComponent
{render}
/>
{:else if component.type === 'downloadcomponent'}
<AppDownload
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'selectcomponent' || component.type === 'resourceselectcomponent'}
<AppSelect
recomputeIds={component.recomputeIds}
id={component.id}
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
customCss={component.customCss}
onSelect={component.onSelect}
{render}
/>
{:else if component.type === 'userresourcecomponent'}
<AppUserResource
id={component.id}
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'multiselectcomponent'}
<AppMultiSelect
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
verticalAlignment={component.verticalAlignment}
{render}
/>
{:else if component.type === 'multiselectcomponentv2'}
<AppMultiSelectV2
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
verticalAlignment={component.verticalAlignment}
{render}
/>
{:else if component.type === 'formcomponent'}
<AppForm
id={component.id}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
componentInput={component.componentInput}
recomputeIds={component.recomputeIds}
bind:errorHandledByComponent
{render}
/>
{:else if component.type === 'formbuttoncomponent'}
<AppFormButton
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
componentInput={component.componentInput}
recomputeIds={component.recomputeIds}
bind:errorHandledByComponent
{render}
/>
{:else if component.type === 'checkboxcomponent'}
<AppCheckbox
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
recomputeIds={component.recomputeIds}
onToggle={component.onToggle}
{render}
/>
{:else if component.type === 'textinputcomponent'}
<AppTextInput
id={component.id}
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'quillcomponent'}
<AppQuillEditor id={component.id} configuration={component.configuration} {render} />
{:else if component.type === 'textareainputcomponent'}
<AppTextInput
id={component.id}
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
customCss={component.customCss}
inputType="textarea"
appCssKey="textareainputcomponent"
{render}
/>
{:else if component.type === 'emailinputcomponent'}
<AppTextInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="email"
appCssKey="emailinputcomponent"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'passwordinputcomponent'}
<AppTextInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="password"
appCssKey="passwordinputcomponent"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'dateinputcomponent'}
<AppDateInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="date"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'timeinputcomponent'}
<AppTimeInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'datetimeinputcomponent'}
<AppDateTimeInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="date"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'numberinputcomponent'}
<AppNumberInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'currencycomponent'}
<AppCurrencyInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'slidercomponent'}
<AppSliderInputs
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'dateslidercomponent'}
<AppDateSliderInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'horizontaldividercomponent'}
<AppDivider
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
position="horizontal"
{render}
/>
{:else if component.type === 'verticaldividercomponent'}
<AppDivider
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
position="vertical"
{render}
/>
{:else if component.type === 'rangecomponent'}
<AppRangeInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'tabscomponent' && component.tabs}
<AppTabs
configuration={component.configuration}
id={component.id}
tabs={component.tabs}
disabledTabs={component.disabledTabs}
onTabChange={component.onTabChange}
customCss={component.customCss}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'steppercomponent' && component.tabs}
<AppStepper
id={component.id}
tabs={component.tabs}
customCss={component.customCss}
{componentContainerHeight}
componentInput={component.componentInput}
onNext={component.onNext}
onPrevious={component.onPrevious}
{render}
/>
{:else if component.type === 'conditionalwrapper' && component.conditions}
<AppConditionalWrapper
id={component.id}
conditions={component.conditions}
customCss={component.customCss}
onTabChange={component.onTabChange}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'containercomponent'}
<AppContainer
groupFields={component.groupFields}
id={component.id}
customCss={component.customCss}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'listcomponent'}
<AppList
id={component.id}
customCss={component.customCss}
configuration={component.configuration}
componentInput={component.componentInput}
{render}
bind:initializing
/>
{:else if component.type === 'verticalsplitpanescomponent'}
<AppSplitpanes
id={component.id}
customCss={component.customCss}
panes={component.panes}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'horizontalsplitpanescomponent'}
<AppSplitpanes
id={component.id}
customCss={component.customCss}
panes={component.panes}
{componentContainerHeight}
horizontal={true}
{render}
/>
{:else if component.type === 'iconcomponent'}
<AppIcon
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'fileinputcomponent'}
<AppFileInput
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onFileChange={component.onFileChange}
{render}
/>
{:else if component.type === 's3fileinputcomponent'}
<AppS3FileInput
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onFileChange={component.onFileChange}
{render}
/>
{:else if component.type === 'imagecomponent'}
<AppImage
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'drawercomponent'}
<AppDrawer
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onOpenRecomputeIds={component.onOpenRecomputeIds}
onCloseRecomputeIds={component.onCloseRecomputeIds}
{render}
/>
{:else if component.type === 'mapcomponent'}
<AppMap
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'pdfcomponent'}
<AppPdf
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'modalcomponent'}
<AppModal
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onOpenRecomputeIds={component.onOpenRecomputeIds}
onCloseRecomputeIds={component.onCloseRecomputeIds}
{render}
/>
{:else if component.type === 'schemaformcomponent'}
<AppSchemaForm
id={component.id}
componentInput={component.componentInput}
configuration={component.configuration}
customCss={component.customCss}
{initializing}
{render}
/>
{:else if component.type === 'selecttabcomponent'}
<AppSelectTab
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'selectstepcomponent'}
<AppSelectStep
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'chartjscomponent'}
<AppChartJs
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'chartjscomponentv2'}
<AppChartJsV2
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
componentInput={component.componentInput}
datasets={component.datasets}
xData={component.xData}
{render}
/>
{:else if component.type === 'carousellistcomponent'}
<AppCarouselList
id={component.id}
configuration={component.configuration}
componentInput={component.componentInput}
customCss={component.customCss}
{componentContainerHeight}
{render}
bind:initializing
/>
{:else if component.type === 'accordionlistcomponent'}
<AppAccordionList
id={component.id}
componentInput={component.componentInput}
customCss={component.customCss}
{componentContainerHeight}
{render}
bind:initializing
/>
{:else if component.type === 'statcomponent'}
<AppStatCard
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'menucomponent'}
<AppMenu
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
menuItems={component.menuItems}
{render}
/>
{:else if component.type === 'decisiontreecomponent' && component.nodes}
<AppDecisionTree
id={component.id}
nodes={component.nodes}
customCss={component.customCss}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'alertcomponent'}
<AppAlert
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
verticalAlignment={component.verticalAlignment}
{render}
/>
{:else if component.type === 'navbarcomponent'}
<AppNavbar
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
navbarItems={component.navbarItems}
{render}
/>
{:else if component.type === 'dateselectcomponent'}
<AppDateSelect
id={component.id}
configuration={component.configuration}
customCss={component.customCss}
verticalAlignment={component.verticalAlignment}
{render}
/>
{:else if component.type === 'jobiddisplaycomponent'}
<AppDisplayComponentByJobId
id={component.id}
customCss={component.customCss}
bind:initializing
configuration={component.configuration}
{render}
/>
{:else if component.type === 'recomputeallcomponent'}
<AppRecomputeAll
id={component.id}
customCss={component.customCss}
bind:initializing
configuration={component.configuration}
horizontalAlignment={component.horizontalAlignment}
{render}
/>
{/if}
@@ -0,0 +1,219 @@
<script lang="ts" context="module">
let outTimeout: NodeJS.Timeout | undefined = undefined
</script>
<script lang="ts">
import { getContext, onMount } from 'svelte'
import { twMerge } from 'tailwind-merge'
import type { AppEditorContext, AppViewerContext } from '../../types'
import ComponentHeader from '../ComponentHeader.svelte'
import type { AppComponent } from './components'
import { Anchor } from 'lucide-svelte'
import { findGridItemParentGrid, isContainer } from '../appUtils'
import ComponentInner from './ComponentInner.svelte'
export let component: AppComponent
export let selected: boolean
export let locked: boolean = false
export let fullHeight: boolean
export let overlapped: string | undefined = undefined
export let moveMode: string | undefined = undefined
export let componentDraggedId: string | undefined = undefined
export let render: boolean = false
const { mode, app, hoverStore, connectingInput } =
getContext<AppViewerContext>('AppViewerContext')
const editorContext = getContext<AppEditorContext>('AppEditorContext')
const componentActive = editorContext?.componentActive
const movingcomponents = editorContext?.movingcomponents
$: ismoving =
movingcomponents != undefined && $mode == 'dnd' && $movingcomponents?.includes(component.id)
let initializing: boolean | undefined = undefined
let errorHandledByComponent: boolean = false
let componentContainerHeight: number = 0
let componentContainerWidth: number = 0
let inlineEditorOpened: boolean = false
let showSkeleton = false
onMount(() => {
setTimeout(() => {
showSkeleton = true
}, 100)
})
function mouseOut() {
outTimeout && clearTimeout(outTimeout)
outTimeout = setTimeout(() => {
if ($hoverStore !== undefined) {
// In order to avoid flickering when hovering over table actions,
// we leave the actions to manage the hover state
if ($hoverStore.startsWith(`${component.id}_`)) {
return
}
$hoverStore = undefined
}
}, 50)
}
function componentDraggedIsNotChild(componentDraggedId: string, componentId: string) {
let parentGrid = findGridItemParentGrid($app, componentDraggedId)
return !parentGrid?.startsWith(`${componentId}-`)
}
function areOnTheSameSubgrid(componentDraggedId: string, componentId: string) {
return (
findGridItemParentGrid($app, componentDraggedId) === findGridItemParentGrid($app, componentId)
)
}
let cachedComponentDraggedIsNotChild: boolean | undefined
let cachedAreOnTheSameSubgrid: boolean | undefined
function updateCache(componentDraggedId: string | undefined) {
if (componentDraggedId) {
cachedComponentDraggedIsNotChild = componentDraggedIsNotChild(
componentDraggedId,
component.id
)
cachedAreOnTheSameSubgrid = areOnTheSameSubgrid(componentDraggedId, component.id)
} else {
cachedComponentDraggedIsNotChild = undefined
cachedAreOnTheSameSubgrid = undefined
}
}
$: updateCache(componentDraggedId)
</script>
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:mouseover|stopPropagation={() => {
outTimeout && clearTimeout(outTimeout)
if (component.id !== $hoverStore) {
$hoverStore = component.id
}
}}
on:mouseout|stopPropagation={mouseOut}
class={twMerge(
'h-full flex flex-col w-full component relative',
initializing ? 'overflow-hidden h-0' : ''
)}
data-connection-button
>
{#if render}
{#if locked && componentActive && $componentActive && moveMode === 'move' && componentDraggedId && componentDraggedId !== component.id && cachedAreOnTheSameSubgrid}
<div
class={twMerge('absolute inset-0 bg-locked center-center flex-col z-50', 'bg-locked-hover')}
>
<div class="bg-surface p-2 shadow-sm rounded-md flex center-center flex-col gap-2">
<Anchor size={24} class="text-primary " />
<div class="text-xs"> Anchored: The component cannot be moved. </div>
</div>
</div>
{:else if moveMode === 'insert' && isContainer(component.type) && componentDraggedId && componentDraggedId !== component.id && cachedComponentDraggedIsNotChild}
<div
class={twMerge(
'absolute inset-0 flex-col rounded-md bg-blue-100 dark:bg-gray-800 bg-opacity-50',
'outline-dashed outline-offset-2 outline-2 outline-blue-300 dark:outline-blue-700',
overlapped === component?.id ? 'bg-draggedover dark:bg-draggedover-dark' : ''
)}
/>
{/if}
{#if $mode !== 'preview'}
<ComponentHeader
on:mouseover={() => {
outTimeout && clearTimeout(outTimeout)
if (component.id !== $hoverStore) {
$hoverStore = component.id
}
}}
hover={$hoverStore === component.id}
{component}
{selected}
{fullHeight}
connecting={$connectingInput.opened}
on:lock
on:expand
on:fillHeight
{locked}
{inlineEditorOpened}
hasInlineEditor={component.type === 'textcomponent' &&
component.componentInput &&
component.componentInput.type !== 'connected'}
on:triggerInlineEditor={() => {
inlineEditorOpened = !inlineEditorOpened
}}
{errorHandledByComponent}
{componentContainerWidth}
/>
{/if}
{#if ismoving}
<div class="absolute -top-8 w-40">
<button
class="border p-0.5 text-xs"
on:click={() => {
$movingcomponents = undefined
}}
>
Cancel move
</button>
</div>
{/if}
{/if}
<div
class={twMerge(
render ? 'h-full outline-1' : 'h-0 overflow-hidden',
$mode === 'dnd' ? 'bg-surface/40' : '',
$hoverStore === component.id && $mode !== 'preview'
? $connectingInput.opened
? 'outline outline-[#f8aa4b]'
: 'outline outline-blue-400'
: '',
selected && $mode !== 'preview' ? 'outline outline-blue-600' : '',
$mode != 'preview' ? 'cursor-pointer' : '',
'relative z-auto',
$app.css?.['app']?.['component']?.class,
'wm-app-component',
ismoving ? 'animate-pulse' : ''
)}
style={$app.css?.['app']?.['component']?.style}
bind:clientHeight={componentContainerHeight}
bind:clientWidth={componentContainerWidth}
>
<ComponentInner
{component}
{render}
{componentContainerHeight}
bind:initializing
bind:errorHandledByComponent
{inlineEditorOpened}
/>
</div>
</div>
{#if initializing && render && showSkeleton}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:mouseover|stopPropagation={() => {
if (component.id !== $hoverStore) {
$hoverStore = component.id
}
}}
on:mouseout|stopPropagation={() => {
if ($hoverStore !== undefined) {
$hoverStore = undefined
}
}}
class="absolute inset-0 center-center flex-col border animate-skeleton dark:bg-frost-900/50 [animation-delay:1000ms]"
/>
{/if}
@@ -0,0 +1,50 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { AppViewerContext } from '../../types'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import JsonEditor from '$lib/components/JsonEditor.svelte'
import Section from '$lib/components/Section.svelte'
const { app } = getContext<AppViewerContext>('AppViewerContext')
let code = JSON.stringify($app.lazyInitRequire)
</script>
<div class="flex flex-col gap-8" style="all:none;">
<Alert type="info" title="Lazy mode for large apps performance">
Lazy mode is a feature that allows you to lazy render components which is ideal for apps with
many components where most are not directly visible to the user.
<br />
When lazy mode is enabled, components are not rendered until they are needed. This can significantly
improve the performance of your app, especially on mobile devices.
<br />
You can enable lazy mode below, but you will need to declare the list of components whose initialization
is expected to see initialized before the initial refresh of the app happens.
</Alert>
<Toggle
label="Lazy mode"
checked={Boolean($app.lazyInitRequire != undefined)}
on:change={(e) => {
$app.lazyInitRequire = e.detail ? [] : undefined
code = JSON.stringify($app.lazyInitRequire)
}}
options={{
right: 'Lazy mode'
}}
/>
<Section label="Component ids to wait the initialization of before the initial refresh">
{#if $app.lazyInitRequire != undefined}
<JsonEditor bind:value={$app.lazyInitRequire} {code} />
<span class="text-tertiary text-xs">
{'e.g: ["a", "b"]'}, no need to put background runnables ids
</span>
{:else}
<span class="text-tertiary"
>Without lazy mode, all components' initialization will be waited on the initial refresh.</span
>
{/if}
</Section>
</div>
@@ -9,6 +9,8 @@
</script>
<script lang="ts">
import { columnConfiguration, WIDE_GRID_COLUMNS } from '../gridUtils'
import gridHelp from './utils/helper'
import type { AppViewerContext, GridItem } from '../types'
import { twMerge } from 'tailwind-merge'
@@ -41,7 +43,7 @@
export let items: FilledItem<T>[]
export let rowHeight: number = ROW_HEIGHT
export let cols: [number, number][]
export let gap = [ROW_GAP_X, ROW_GAP_Y]
export let throttleUpdate = 100
export let throttleResize = 100
@@ -53,7 +55,10 @@
export let root: boolean = false
export let parentWidth: number | undefined = undefined
export let disableMove: boolean = false
let getComputedCols
const cols = columnConfiguration
let getComputedCols: 3 | 12 | undefined =
$app.mobileViewOnSmallerScreens == false ? WIDE_GRID_COLUMNS : undefined
let container
$: [gapX, gapY] = gap
@@ -73,16 +78,22 @@
})
}, throttleUpdate)
let mounted = false
onMount(() => {
const sizeObserver = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
let width = entries[0].contentRect.width
if (width === containerWidth) return
if (width === containerWidth) {
mounted = true
return
}
getComputedCols = getColumn(parentWidth ?? width, cols)
xPerPx = width / getComputedCols
if ($app.mobileViewOnSmallerScreens != false || !getComputedCols) {
getComputedCols = getColumn(parentWidth ?? width, cols)
}
xPerPx = width / getComputedCols!
if (!containerWidth) {
items = specifyUndefinedColumns(items, getComputedCols, cols)
@@ -97,6 +108,7 @@
}
containerWidth = width
mounted = true
})
})
@@ -106,7 +118,7 @@
})
let sortedItems: FilledItem<T>[] = []
$: sortedItems = JSON.parse(JSON.stringify(items)).sort((a, b) => a.id.localeCompare(b.id))
$: sortedItems = smartCopy(items).sort((a, b) => a.id.localeCompare(b.id))
let resizing: boolean = false
@@ -123,21 +135,31 @@
let initItems: FilledItem<T>[] | undefined = undefined
function smartCopy(items: FilledItem<T>[]) {
return getComputedCols != undefined
? items.map((item) => {
return {
...item,
[getComputedCols as number]: structuredClone(item[getComputedCols as number])
}
})
: []
}
const updateMatrix = ({ detail }) => {
let isPointerUp = detail.isPointerUp
let citems: FilledItem<T>[]
if (isPointerUp) {
try {
citems = JSON.parse(JSON.stringify(initItems))
} catch (e) {
citems = JSON.parse(JSON.stringify(sortedItems))
if (initItems == undefined) {
citems = smartCopy(sortedItems)
} else {
citems = smartCopy(initItems)
}
initItems = undefined
} else {
if (initItems == undefined) {
initItems = JSON.parse(JSON.stringify(sortedItems))
initItems = smartCopy(sortedItems)
}
citems = JSON.parse(JSON.stringify(initItems))
citems = smartCopy(initItems)
}
let nselectedIds = selectedIds ?? []
if (detail.id && !selectedIds?.includes(detail.id)) {
@@ -147,7 +169,7 @@
for (let id of nselectedIds) {
let activeItem = getItemById(id, sortedItems)
if (activeItem) {
if (activeItem && getComputedCols) {
activeItem = {
...activeItem,
[getComputedCols]: {
@@ -202,11 +224,8 @@
}
}
if (isPointerUp) {
dispatch(
'redraw',
sortGridItemsPosition(JSON.parse(JSON.stringify(sortedItems)), getComputedCols)
)
if (isPointerUp && getComputedCols) {
dispatch('redraw', sortGridItemsPosition(smartCopy(sortedItems), getComputedCols))
}
}
@@ -254,7 +273,7 @@
export function handleMove({ detail }) {
Object.entries(moveResizes).forEach(([id, moveResize]) => {
if (selectedIds?.includes(id)) {
moveResize?.updateMove(JSON.parse(JSON.stringify(detail.cordDiff)), detail.eventY)
moveResize?.updateMove(structuredClone(detail.cordDiff), detail.eventY)
}
})
@@ -275,7 +294,7 @@
) {
const draggedItem = sortedItems.find((item) => item.id === $componentDraggedIdStore)
if (draggedItem) {
if (draggedItem && getComputedCols) {
draggedItem[getComputedCols].x = detail.shadow.x
draggedItem[getComputedCols].y = detail.shadow.y
}
@@ -391,33 +410,33 @@
</div>
{/if}
{/if}
{#if xPerPx > 0 && getComputedCols}
{#each sortedItems as item (item.id)}
{#if item[getComputedCols] != undefined}
{#if $isCtrlOrMetaPressedStore && item.id === $overlappedStore && $componentDraggedIdStore && $componentDraggedParentIdStore !== item.id && $fakeShadowStore}
{@const columnGap = gapX}
<!-- gap between the columns in px -->
{@const containerBorder = 0.5 * 16}
<!-- 0.5rem converted to px (1rem = 16px) -->
{@const gridTotalWidth = containerWidth ? containerWidth - 2 * containerBorder : 0}
<!-- subtract borders -->
{@const availableWidth = gridTotalWidth - 11 * columnGap}
<!-- subtract gaps between the 12 columns (11 gaps) -->
{@const columnWidthPx = availableWidth / 12}
<!-- divide by the number of columns -->
{@const maxX = Math.floor(availableWidth / columnWidthPx) - $fakeShadowStore.w}
{#each sortedItems as item (item.id)}
{#if item[getComputedCols] != undefined}
{#if $isCtrlOrMetaPressedStore && item.id === $overlappedStore && $componentDraggedIdStore && $componentDraggedParentIdStore !== item.id && $fakeShadowStore}
{@const columnGap = gapX}
<!-- gap between the columns in px -->
{@const containerBorder = 0.5 * 16}
<!-- 0.5rem converted to px (1rem = 16px) -->
{@const gridTotalWidth = containerWidth ? containerWidth - 2 * containerBorder : 0}
<!-- subtract borders -->
{@const availableWidth = gridTotalWidth - 11 * columnGap}
<!-- subtract gaps between the 12 columns (11 gaps) -->
{@const columnWidthPx = availableWidth / 12}
<!-- divide by the number of columns -->
{@const maxX = Math.floor(availableWidth / columnWidthPx) - $fakeShadowStore.w}
<div
class="absolute"
style={`
<div
class="absolute"
style={`
left: ${item[getComputedCols].x * xPerPx + gapX}px;
top: ${item[getComputedCols].y * yPerPx + gapY}px;
`}
>
<div class="relative h-full w-full">
<div
class={twMerge('absolute transition-all duration-[50ms] bg-blue-300')}
style={`
>
<div class="relative h-full w-full">
<div
class={twMerge('absolute transition-all duration-[50ms] bg-blue-300')}
style={`
left: calc(${
Math.min($fakeShadowStore.x, maxX) * $fakeShadowStore.xPerPx + gapX
}px + 0.5rem + ${getDeltaXByComponent(item.data['type'])});
@@ -427,68 +446,73 @@
width: ${$fakeShadowStore.w * $fakeShadowStore.xPerPx - gapX * 2}px;
height: ${$fakeShadowStore.h * $fakeShadowStore.yPerPx - gapY * 2}px;
`}
/>
/>
</div>
</div>
</div>
{/if}
<MoveResize
on:initmove={() => handleInitMove(item.id)}
on:move={handleMove}
bind:shadow={shadows[item.id]}
bind:this={moveResizes[item.id]}
on:repaint={handleRepaint}
on:resizeStart={() => (resizing = true)}
on:resizeEnd={() => (resizing = false)}
onTop={Boolean(allIdsInPath?.includes(item.id))}
id={item.id}
{xPerPx}
{yPerPx}
fakeShadow={$fakeShadowStore}
on:dropped={(e) => {
$componentDraggedIdStore = undefined
$componentDraggedParentIdStore = undefined
$overlappedStore = undefined
$fakeShadowStore = undefined
lastDetail = undefined
if (!$isCtrlOrMetaPressedStore) {
return
}
dispatch('dropped', e.detail)
}}
width={xPerPx == 0
? 0
: Math.min(getComputedCols, item[getComputedCols] && item[getComputedCols].w) * xPerPx -
gapX * 2}
height={(item[getComputedCols] && item[getComputedCols].h) * yPerPx - gapY * 2}
top={(item[getComputedCols] && item[getComputedCols].y) * yPerPx + gapY}
left={(item[getComputedCols] && item[getComputedCols].x) * xPerPx + gapX}
item={item[getComputedCols]}
cols={getComputedCols}
{gapX}
{gapY}
{sensor}
container={scroller}
nativeContainer={container}
overlapped={$overlappedStore}
moveMode={$isCtrlOrMetaPressedStore ? 'insert' : 'move'}
type={item.data['type']}
{disableMove}
>
{#if item[getComputedCols]}
<slot
dataItem={item}
hidden={false}
overlapped={$overlappedStore}
moveMode={$isCtrlOrMetaPressedStore ? 'insert' : 'move'}
componentDraggedId={$componentDraggedIdStore}
/>
{/if}
</MoveResize>
{/if}
{/each}
<MoveResize
{mounted}
on:initmove={() => handleInitMove(item.id)}
on:move={handleMove}
bind:shadow={shadows[item.id]}
bind:this={moveResizes[item.id]}
on:repaint={handleRepaint}
on:resizeStart={() => (resizing = true)}
on:resizeEnd={() => (resizing = false)}
onTop={Boolean(allIdsInPath?.includes(item.id))}
id={item.id}
{xPerPx}
{yPerPx}
fakeShadow={$fakeShadowStore}
on:dropped={(e) => {
$componentDraggedIdStore = undefined
$componentDraggedParentIdStore = undefined
$overlappedStore = undefined
$fakeShadowStore = undefined
lastDetail = undefined
if (!$isCtrlOrMetaPressedStore) {
return
}
dispatch('dropped', e.detail)
}}
width={xPerPx == 0
? 0
: Math.min(getComputedCols, item[getComputedCols] && item[getComputedCols].w) * xPerPx -
gapX * 2}
height={(item[getComputedCols] && item[getComputedCols].h) * yPerPx - gapY * 2}
top={(item[getComputedCols] && item[getComputedCols].y) * yPerPx + gapY}
left={(item[getComputedCols] && item[getComputedCols].x) * xPerPx + gapX}
item={item[getComputedCols]}
cols={getComputedCols}
{gapX}
{gapY}
{sensor}
container={scroller}
nativeContainer={container}
overlapped={$overlappedStore}
moveMode={$isCtrlOrMetaPressedStore ? 'insert' : 'move'}
type={item.data['type']}
{disableMove}
>
{#if item[getComputedCols]}
<slot
dataItem={item}
hidden={false}
overlapped={$overlappedStore}
moveMode={$isCtrlOrMetaPressedStore ? 'insert' : 'move'}
componentDraggedId={$componentDraggedIdStore}
/>
{/if}
</MoveResize>
{/if}
{/each}
{:else if root}
<div
class="h-full w-full flex-col animate-skeleton dark:bg-frost-900/50 [animation-delay:1000ms]"
/>
{/if}
</div>
<style>
@@ -39,6 +39,7 @@
export let type: string | undefined = undefined
export let fakeShadow: GridShadow | undefined = undefined
export let disableMove: boolean = true
export let mounted: boolean = false
const ctx = getContext<AppEditorContext>('AppEditorContext')
const { mode, app } = getContext<AppViewerContext>('AppViewerContext')
@@ -504,7 +505,7 @@
: trans
? `transform: translate(${cordDiff.x}px, ${cordDiff.y}px); position:absolute; transition: width 0.2s, height 0.2s;`
: `${
xPerPx > 0 ? 'transition: transform 0.1s, opacity 0.1s;' : ''
xPerPx > 0 && mounted ? 'transition: transform 0.1s, opacity 0.1s;' : ''
} transform: translate(${left}px, ${top}px); `} "
>
<slot />
+7 -6
View File
@@ -139,13 +139,13 @@ export type HiddenRunnable = {
export type AppTheme =
| {
type: 'path'
path: string
}
type: 'path'
path: string
}
| {
type: 'inlined'
css: string
}
type: 'inlined'
css: string
}
export type App = {
grid: GridItem[]
@@ -162,6 +162,7 @@ export type App = {
css?: Partial<Record<AppCssItemName, Record<string, ComponentCssProperty>>>
subgrids?: Record<string, GridItem[]>
theme: AppTheme | undefined
lazyInitRequire?: string[] | undefined
hideLegacyTopBar?: boolean | undefined
mobileViewOnSmallerScreens?: boolean | undefined
version?: number
@@ -7,7 +7,10 @@
Terminal,
Webhook,
Unplug,
PlugZap
PlugZap,
Database
} from 'lucide-svelte'
import HighlightTheme from '../HighlightTheme.svelte'
@@ -69,7 +72,7 @@
</Tab>
<Tab value="postgres">
<span class="flex flex-row gap-2 items-center text-xs">
<Unplug size={12} />
<Database size={12} />
Postgres
</span>
</Tab>
@@ -5,6 +5,7 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
let name: string = ''
export let customName: string | undefined = undefined
const dispatch = createEventDispatcher()
@@ -24,7 +25,7 @@
<div class="flex flex-row gap-2 p-2 rounded-md">
<input
bind:value={name}
placeholder="Field name"
placeholder={`${customName ?? 'Field'} name`}
on:keydown={(event) => {
if (event.key === 'Enter') {
addField()
@@ -44,7 +45,7 @@
disabled={!name}
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
>
Add field
Add {customName ? customName.toLowerCase() : 'field'}
</Button>
</div>
</svelte:fragment>
+2
View File
@@ -65,6 +65,8 @@ export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): Trigg
return 'kafka'
case 'nats':
return 'nats'
case 'postgres':
return 'postgres'
default:
throw new Error(`Unknown CaptureTriggerKind: ${kind}`)
}
@@ -1,7 +1,7 @@
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Button } from '$lib/components/common'
import { Webhook, Route, Unplug, Mail, Plus } from 'lucide-svelte'
import { Webhook, Route, Unplug, Mail, Plus, Database } from 'lucide-svelte'
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
import { enterpriseLicense } from '$lib/stores'
import { type CaptureTriggerKind } from '$lib/gen'
@@ -82,6 +82,15 @@
<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('postgres')}
>
<div class="flex flex-row items-center gap-2">
<Database size={16} />
<p class="text-xs text-secondary">Postgres</p>
</div>
</button>
<button
class="hover:bg-surface-hover p-2 transition-colors duration-150"
on:click={() => handleClick('email')}
@@ -14,6 +14,8 @@
import type { CaptureInfo } from './CaptureSection.svelte'
import CaptureTable from './CaptureTable.svelte'
import NatsTriggersConfigSection from './nats/NatsTriggersConfigSection.svelte'
import PostgresEditorConfigSection from './postgres/PostgresEditorConfigSection.svelte'
import { invalidRelations } from './postgres/utils'
export let isFlow: boolean
export let path: string
@@ -26,16 +28,37 @@
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!
})
export async function setConfig(): Promise<boolean> {
if (captureType === 'postgres') {
if (!args?.publication?.table_to_track) {
sendUserToast('Table to track must be set', true)
return false
}
if (
invalidRelations(args.publication.table_to_track, {
showError: true,
trackSchemaTableError: true
}) === true
) {
return false
}
}
try {
await CaptureService.setCaptureConfig({
requestBody: {
trigger_kind: captureType,
path,
is_flow: isFlow,
trigger_config: args && Object.keys(args).length > 0 ? args : undefined
},
workspace: $workspaceStore!
})
return true
} catch (error) {
sendUserToast(error.body, true)
return false
}
}
let captureActive = false
@@ -55,7 +78,10 @@
return acc
}, {})
if ((captureType === 'websocket' || captureType === 'kafka') && captureActive) {
if (
(captureType === 'postgres' || captureType === 'websocket' || captureType === 'kafka') &&
captureActive
) {
const config = captureConfigs[captureType]
if (config && config.error) {
const serverEnabled = getServerEnabled(config)
@@ -114,24 +140,22 @@
if (captureActive || e.detail.disableOnly) {
captureActive = false
} else {
await setConfig()
capture()
const configSet = await setConfig()
if (configSet) {
capture()
}
}
}
let config: CaptureConfig | undefined
$: config = captureConfigs[captureType]
let cloudDisabled =
(captureType === 'websocket' || captureType === 'kafka' || captureType === 'nats') &&
isCloudHosted()
const streamingTrigger = ['postgres', 'websocket', 'kafka', 'nats']
let cloudDisabled = streamingTrigger.includes(captureType) && isCloudHosted()
function updateConnectionInfo(config: CaptureConfig | undefined, captureActive: boolean) {
if (
(captureType === 'websocket' || captureType === 'kafka' || captureType === 'nats') &&
config &&
captureActive
) {
if (streamingTrigger.includes(captureType) && config && captureActive) {
const serverEnabled = getServerEnabled(config)
const connected = serverEnabled && !config.error
const message = connected
@@ -181,6 +205,21 @@
on:captureToggle={handleCapture}
on:testWithArgs
/>
{:else if captureType === 'postgres'}
<PostgresEditorConfigSection
bind:postgres_resource_path={args.postgres_resource_path}
bind:publication={args.publication}
{showCapture}
{captureInfo}
can_write={true}
headless={true}
bind:captureTable
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={handleCapture}
on:testWithArgs
/>
{:else if captureType === 'webhook'}
<WebhooksConfigSection
{isFlow}
@@ -3,24 +3,27 @@
CancelablePromise,
KafkaTriggerService,
NatsTriggerService,
PostgresTriggerService,
WebsocketTriggerService
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import Button from '../common/button/Button.svelte'
export let kind: 'websocket' | 'nats' | 'kafka'
export let kind: 'websocket' | 'nats' | 'kafka' | 'postgres'
export let args: Record<string, any>
export let noButton = false
export let testLoading: boolean = false
const kindToName: { [key: string]: string } = {
websocket: 'WebSocket',
nats: 'NATS server(s)',
kafka: 'Kafka broker(s)'
kafka: 'Kafka broker(s)',
postgres: 'Postgres'
}
let testLoading: boolean = false
let promise: CancelablePromise<any> | null = null
async function testTriggerConnection() {
export async function testTriggerConnection() {
if (testLoading) {
promise?.cancel()
return
@@ -43,6 +46,11 @@
workspace: $workspaceStore!,
requestBody: args as any
})
} else if (kind === 'postgres') {
promise = PostgresTriggerService.testPostgresConnection({
workspace: $workspaceStore!,
requestBody: args as any
})
}
await promise
sendUserToast(`Successfully connected to ${kindToName[kind]}`)
@@ -56,16 +64,18 @@
}
</script>
<div class="flex flex-row justify-end mt-1">
<Button
spacingSize="sm"
size="xs"
color="light"
variant="border"
on:click={testTriggerConnection}
loading={testLoading}
clickableWhileLoading
>
Test connection
</Button>
</div>
{#if !noButton}
<div class="flex flex-row justify-end mt-1">
<Button
spacingSize="sm"
size="xs"
color="light"
variant="border"
on:click={testTriggerConnection}
loading={testLoading}
clickableWhileLoading
>
Test connection
</Button>
</div>
{/if}
@@ -140,7 +140,18 @@
</div>
{:else if $selectedTrigger === 'postgres'}
<div class="p-4">
<PostgresTriggersPanel {newItem} path={currentPath} {isFlow} />
<PostgresTriggersPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
path={currentPath}
{isFlow}
{canHavePreprocessor}
{hasPreprocessor}
isEditor={true}
/>
</div>
{:else if $selectedTrigger === 'kafka' || $selectedTrigger === 'nats'}
<div class="p-4 flex flex-col gap-2">
@@ -31,7 +31,8 @@
webhook: 'Webhook',
kafka: '+ New Kafka trigger',
email: 'Email trigger',
nats: '+ New NATS trigger'
nats: '+ New NATS trigger',
postgres: '+ New Postgres trigger'
}
const { captureOn } = getContext<TriggerContext>('TriggerContext')
@@ -8,6 +8,7 @@
import EmailTriggerConfigSection from '../details/EmailTriggerConfigSection.svelte'
import KafkaTriggersConfigSection from './kafka/KafkaTriggersConfigSection.svelte'
import NatsTriggersConfigSection from './nats/NatsTriggersConfigSection.svelte'
import PostgresEditorConfigSection from './postgres/PostgresEditorConfigSection.svelte'
export let triggerType: CaptureTriggerKind = 'webhook'
export let cloudDisabled: boolean = false
@@ -30,6 +31,14 @@
bind:url_runnable_args={args.url_runnable_args}
showCapture={false}
/>
{:else if triggerType === 'postgres'}
<PostgresEditorConfigSection
can_write={true}
headless={true}
showCapture={false}
bind:publication={args.publication}
bind:postgres_resource_path={args.postgres_resource_path}
/>
{:else if triggerType === 'webhook'}
<WebhooksConfigSection
{isFlow}
@@ -0,0 +1,79 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Tooltip from '$lib/components/Tooltip.svelte'
import { PostgresTriggerService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { emptyString } from '$lib/utils'
let loadingConfiguration = false
const checkDatabaseConfiguration = async () => {
if (emptyString(postgres_resource_path)) {
sendUserToast('You must first pick a database resource', true)
return
}
try {
const invalidConfig = !(await PostgresTriggerService.isValidPostgresConfiguration({
workspace: $workspaceStore!,
path: postgres_resource_path
}))
let msg = 'Database is in logical mode. Triggers can be used.'
if (invalidConfig) {
msg =
'Database is NOT in logical mode. Triggers cannot be used. Refer to the PostgreSQL documentation for configuration requirements.'
}
sendUserToast(msg, invalidConfig)
} catch (error) {
sendUserToast(error.body, true)
}
loadingConfiguration = false
}
const checkConnectionAndDatabaseConfiguration = async () => {
try {
loadingConfiguration = true
if (checkConnection) {
await checkConnection()
}
await checkDatabaseConfiguration()
} catch (error) {
sendUserToast(error.body, true)
}
loadingConfiguration = false
}
export let can_write: boolean
export let postgres_resource_path: string
export let checkConnection: any | undefined = undefined
console.log('dbg check connection', checkConnection)
</script>
{#if postgres_resource_path}
<div class="flex flex-col justify-end mt-1 gap-2">
<Button
disabled={!can_write}
loading={loadingConfiguration}
on:click={checkConnectionAndDatabaseConfiguration}
size="xs"
color="light"
spacingSize="sm"
variant="border"
>
{`Check database configuration ${checkConnection ? 'and connection' : ''}`}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/postgres_triggers#requirements"
>
<p class="text-sm">
Verifies whether the database is configured with the required <strong>settings</strong>.
{checkConnection && 'Also checks whether the connection to the database is working.'}
</p>
</Tooltip>
</Button>
</div>
{/if}

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