mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
Merge remote-tracking branch 'origin/main' into hugo/resource-type-guidance
# Conflicts: # frontend/src/lib/components/copilot/chat/global/core.ts
This commit is contained in:
@@ -15,7 +15,7 @@ on:
|
||||
description: "Space-separated modes (global, cli, flow, app, script)"
|
||||
default: "global cli"
|
||||
models:
|
||||
description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for""
|
||||
description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"
|
||||
default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro"
|
||||
runs:
|
||||
description: "Runs per case"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.827.0"
|
||||
".": "1.828.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [1.828.0](https://github.com/windmill-labs/windmill/compare/v1.827.0...v1.828.0) (2026-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add gpt-6.1-sol to the chat, agent steps and weekly ai evals ([#11584](https://github.com/windmill-labs/windmill/issues/11584)) ([8f7e0c7](https://github.com/windmill-labs/windmill/commit/8f7e0c72848b56fa97f6f0141d930c7d15044864))
|
||||
* add wmill user list and document post-suspend approvers context ([#11600](https://github.com/windmill-labs/windmill/issues/11600)) ([1c2667c](https://github.com/windmill-labs/windmill/commit/1c2667cd5530925893cf69fd622938b6ffa55d1d))
|
||||
* ask before minting the frontend sdk token in the raw app editor ([#11604](https://github.com/windmill-labs/windmill/issues/11604)) ([6e948b0](https://github.com/windmill-labs/windmill/commit/6e948b07d13b85e9251c3a200bf149f167649bc4))
|
||||
* improve data table roles and grants UX ([#11562](https://github.com/windmill-labs/windmill/issues/11562)) ([d7d18f0](https://github.com/windmill-labs/windmill/commit/d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6))
|
||||
* one page header across routes, and one sidebar for operators and developers ([#11310](https://github.com/windmill-labs/windmill/issues/11310)) ([41162b2](https://github.com/windmill-labs/windmill/commit/41162b270c70ddac6a256bde9acf31a629990e24))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add retry to runScriptByPathAsync and runScriptByHashAsync ([#11485](https://github.com/windmill-labs/windmill/issues/11485)) ([3f3b1c2](https://github.com/windmill-labs/windmill/commit/3f3b1c2c308ddaba377db45308711e7c660d8775))
|
||||
* describe exponential retry as multiplier × seconds^attempt ([#11546](https://github.com/windmill-labs/windmill/issues/11546)) ([a2e51cd](https://github.com/windmill-labs/windmill/commit/a2e51cdbaafb7d71b8e8bbd818e40c472098f9ba))
|
||||
* keep capturing job logs after a non-UTF-8 output line ([#11595](https://github.com/windmill-labs/windmill/issues/11595)) ([0e7d974](https://github.com/windmill-labs/windmill/commit/0e7d974ce9b468353ecb6618dace710704690687))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* load public raw apps without the loading screen ([#11608](https://github.com/windmill-labs/windmill/issues/11608)) ([c09764f](https://github.com/windmill-labs/windmill/commit/c09764fa6516badddbdbb520af32e5d1541cf996))
|
||||
|
||||
## [1.827.0](https://github.com/windmill-labs/windmill/compare/v1.826.0...v1.827.0) (2026-10-07)
|
||||
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)\n UPDATE v2_job_queue q SET running = false, started_at = null,\n extras = CASE WHEN q.started_at IS NULL OR q.extras ? 'wac_first_started_at' THEN q.extras\n ELSE jsonb_set(COALESCE(q.extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(q.started_at))\n END\n FROM prev WHERE q.id = $1\n RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "int8",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1333436e3d0466eccdaf99fb70aadad56b50619036fab3f2c1bdbbf280a80217"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "draining",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b"
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,\n (extras->>'wac_first_started_at')::timestamptz AS first_started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , extras\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),\n CASE WHEN d.first_started_at IS NULL THEN d.run_ms\n ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,\n CASE WHEN d.first_started_at IS NOT NULL\n THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING cj.started_at, cj.duration_ms,\n COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms\n ), parent_ping AS (\n UPDATE v2_job_runtime r SET ping = now()\n FROM v2_job_queue q\n WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12\n AND EXISTS (SELECT 1 FROM completed)\n )\n SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",\n last_run_ms AS \"last_run_ms!\" FROM completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "started_at?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "duration_ms!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "last_run_ms!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "71c9e0c0f1d9eba0f24c8e410ca2f2a64dc559e3228d3b763f68b1ad3491ae6e"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT wp.draining FROM v2_job j\n JOIN v2_job_queue q ON q.id = j.id\n JOIN worker_ping wp ON wp.worker = q.worker\n WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL\n AND ($3::text[] IS NULL OR j.tag = ANY($3))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "draining",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13"
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "duration_ms!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d"
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n ), parent_ping AS (\n UPDATE v2_job_runtime r SET ping = now()\n FROM v2_job_queue q\n WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12\n AND EXISTS (SELECT 1 FROM completed)\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "duration_ms!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue\n SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null,\n extras = CASE WHEN started_at IS NULL OR extras ? 'wac_first_started_at' THEN extras\n ELSE jsonb_set(COALESCE(extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(started_at))\n END\n WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Float8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9e7a5e1ebba42217092971dd340cd9182a4812b14735e502958eaafeb4c6b33d"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT usr.email, usage.executions\n FROM usr, LATERAL (\n SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $1\n AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND j.permissioned_as_email = usr.email\n AND now() - '1 week'::interval < j.created_at\n ) usage\n WHERE workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "executions",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665"
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jobs_executed",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc"
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,\n (extras->>'wac_first_started_at')::timestamptz AS first_started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , extras\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),\n CASE WHEN d.first_started_at IS NULL THEN d.run_ms\n ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,\n CASE WHEN d.first_started_at IS NOT NULL\n THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING cj.started_at, cj.duration_ms,\n COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms\n )\n SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",\n last_run_ms AS \"last_run_ms!\" FROM completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "started_at?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "duration_ms!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "last_run_ms!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ce39fd9650500b16f7247c8173a40c7e5d2d8a51aea2d36f2bc8c6f86d53a9b8"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)\n UPDATE v2_job_queue q SET running = false, started_at = null\n FROM prev WHERE q.id = $1\n RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "int8",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE worker_ping SET draining = true WHERE worker = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining)\n RETURNING jobs_executed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jobs_executed",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT usr.email, usage.executions\n FROM usr, LATERAL (\n SELECT COALESCE(SUM(COALESCE((c.extras->>'wac_last_segment_ms')::bigint, c.duration_ms) + 1000)/1000 , 0)::BIGINT executions\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $1\n AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND j.permissioned_as_email = usr.email\n AND now() - '1 week'::interval < j.created_at\n ) usage\n WHERE workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "executions",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e8779f1cddc475554dbbd2e3122db8a21a2eaacf971bf046cef1ee14cf04e323"
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue\n SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null\n WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Float8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3",
|
||||
"query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -107,6 +107,11 @@
|
||||
"ordinal": 20,
|
||||
"name": "native_mode",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "draining",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -138,8 +143,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca"
|
||||
"hash": "fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c"
|
||||
}
|
||||
Generated
+89
-89
@@ -6685,9 +6685,9 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02"
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.24"
|
||||
version = "0.3.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
|
||||
checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -9617,7 +9617,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.15+spec-1.1.0",
|
||||
"toml_edit 0.25.16+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13622,9 +13622,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -13644,21 +13644,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.15+spec-1.1.0"
|
||||
version = "0.25.16+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
|
||||
checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_datetime 1.1.2+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
version = "1.1.4+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
@@ -14829,7 +14829,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14918,7 +14918,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14952,7 +14952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14965,7 +14965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15105,7 +15105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15128,7 +15128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15145,7 +15145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15171,7 +15171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15181,7 +15181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15198,7 +15198,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -15220,7 +15220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15243,7 +15243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15259,7 +15259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15281,7 +15281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15303,7 +15303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15317,7 +15317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15353,7 +15353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15379,7 +15379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15407,7 +15407,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15429,7 +15429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15449,7 +15449,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15491,7 +15491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15520,7 +15520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15532,7 +15532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15556,7 +15556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15570,7 +15570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15609,7 +15609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15624,7 +15624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15643,7 +15643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -15757,7 +15757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -15777,7 +15777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15794,7 +15794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15821,7 +15821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15838,7 +15838,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15854,7 +15854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15875,7 +15875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15906,7 +15906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15931,7 +15931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15967,7 +15967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15985,7 +15985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15994,7 +15994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16006,7 +16006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16018,7 +16018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16030,7 +16030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16042,7 +16042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16054,7 +16054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16065,7 +16065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16076,7 +16076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16088,7 +16088,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16099,7 +16099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16121,7 +16121,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16133,7 +16133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16147,7 +16147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16164,7 +16164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16177,7 +16177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16189,7 +16189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16207,7 +16207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16223,7 +16223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16239,7 +16239,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16253,7 +16253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16292,7 +16292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16332,7 +16332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16343,7 +16343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16378,7 +16378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16402,7 +16402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16435,7 +16435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16462,7 +16462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16495,7 +16495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16515,7 +16515,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16549,7 +16549,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16586,7 +16586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16609,7 +16609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16633,7 +16633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16657,7 +16657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16692,7 +16692,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16720,7 +16720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16745,7 +16745,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -16764,7 +16764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16883,7 +16883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
6f23819e947b18878000e580ddefccfd01a73166
|
||||
017710bdd0a10de857f0f40800dde238cdb1ad24
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE worker_ping DROP COLUMN IF EXISTS draining;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE worker_ping ADD COLUMN IF NOT EXISTS draining BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
+24
-24
@@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6302,7 +6302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6314,7 +6314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6323,7 +6323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6335,7 +6335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6347,7 +6347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6359,7 +6359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6371,7 +6371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6383,7 +6383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6394,7 +6394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6405,7 +6405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6417,7 +6417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6428,7 +6428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6450,7 +6450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6462,7 +6462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6476,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6493,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6506,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6518,7 +6518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6536,7 +6536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6552,7 +6552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6568,7 +6568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6600,7 +6600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6614,7 +6614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), work
|
||||
windmill_migrations: name(text), created_at(ts)
|
||||
worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[])
|
||||
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]), draining(bool)
|
||||
workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char), is_dev_workspace(bool)
|
||||
FK: (parent_workspace_id) -> workspace(id)
|
||||
workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts)
|
||||
|
||||
@@ -411,3 +411,70 @@ async fn test_error_handler_not_triggered_on_success(db: Pool<Postgres>) -> anyh
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job,
|
||||
/// and its failure must reach the workspace error handler like a workspace script's.
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_error_handler_triggered_on_hub_script_failure(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_common::jobs::JobPayload;
|
||||
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
let version = "990000002";
|
||||
let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR;
|
||||
tokio::fs::create_dir_all(hub_dir).await?;
|
||||
tokio::fs::write(
|
||||
format!("{hub_dir}/{version}"),
|
||||
r#"{"content":"exit 1","lockfile":null,"language":"bash","schema":{},"summary":null}"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The workspace handler settings are cached per workspace id across the tests of this
|
||||
// binary, so this reuses the handler path every other test here configures.
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
|
||||
VALUES ('test-workspace', 1111111112, 'f/test/error_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb
|
||||
WHERE workspace_id = 'test-workspace'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let completed_job = RunJob::from(JobPayload::ScriptHub {
|
||||
path: format!("hub/{version}/test/fail"),
|
||||
apply_preprocessor: false,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
assert!(!completed_job.success, "Hub script should have failed");
|
||||
|
||||
let mut handler_parent = None;
|
||||
for _ in 0..50 {
|
||||
handler_parent = sqlx::query_scalar::<_, Option<uuid::Uuid>>(
|
||||
"SELECT parent_job FROM v2_job
|
||||
WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'error_handler@windmill.dev'",
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if handler_parent.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
handler_parent,
|
||||
Some(Some(completed_job.id)),
|
||||
"Workspace error handler should handle the failed hub script"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+26
-1
@@ -176,6 +176,10 @@ async fn test_all_metrics_e2e() {
|
||||
otel_set_worker_uptime("w-uptime", 3600.0);
|
||||
otel_set_health_status_phase("healthy");
|
||||
otel_set_health_db_unresponsive(true);
|
||||
otel_set_worker_memory("w-res", "gpu", Some(512), Some(128), Some(1024));
|
||||
otel_add_worker_cpu("w-res", "gpu", 1.5, 40, 7, 0.35);
|
||||
otel_set_worker_occupancy_rate("w-res", "gpu", "5m", 0.25);
|
||||
otel_record_job_memory_peak("python3", 100 * 1024 * 1024);
|
||||
|
||||
// ── Histograms ──────────────────────────────────────────────────
|
||||
|
||||
@@ -189,7 +193,7 @@ async fn test_all_metrics_e2e() {
|
||||
let metrics = flush_and_get_metrics(&state);
|
||||
let names = metric_names(&metrics);
|
||||
|
||||
// ── Verify all 21 metric names are present ──────────────────────
|
||||
// ── Verify all metric names are present ─────────────────────────
|
||||
|
||||
let expected = [
|
||||
"windmill.queue.push_count",
|
||||
@@ -213,6 +217,15 @@ async fn test_all_metrics_e2e() {
|
||||
"windmill.worker.uptime",
|
||||
"windmill.health.status",
|
||||
"windmill.health.db_unresponsive",
|
||||
"windmill.worker.memory_usage",
|
||||
"windmill.worker.windmill_memory_usage",
|
||||
"windmill.worker.memory_limit",
|
||||
"windmill.worker.cpu_usage",
|
||||
"windmill.worker.cpu_periods",
|
||||
"windmill.worker.cpu_throttled_periods",
|
||||
"windmill.worker.cpu_throttled_time",
|
||||
"windmill.worker.occupancy_rate",
|
||||
"windmill.worker.job_memory_peak",
|
||||
];
|
||||
for name in expected {
|
||||
assert!(
|
||||
@@ -290,6 +303,18 @@ async fn test_all_metrics_e2e() {
|
||||
.expect("worker.busy data point with worker=worker-test-1 not found");
|
||||
assert_eq!(dp.1, 1);
|
||||
|
||||
let m = find_metric(&metrics, "windmill.worker.memory_limit").unwrap();
|
||||
let values = gauge_i64_values(m);
|
||||
let dp = values
|
||||
.iter()
|
||||
.find(|(attrs, _)| {
|
||||
attrs
|
||||
.iter()
|
||||
.any(|kv| kv.key.as_str() == "worker_group" && kv.value.as_str() == "gpu")
|
||||
})
|
||||
.expect("worker.memory_limit data point with worker_group=gpu not found");
|
||||
assert_eq!(dp.1, 1024);
|
||||
|
||||
let m = find_metric(&metrics, "windmill.db.pool.active").unwrap();
|
||||
assert_eq!(gauge_i64_values(m)[0].1, 5);
|
||||
let m = find_metric(&metrics, "windmill.db.pool.idle").unwrap();
|
||||
|
||||
@@ -239,3 +239,70 @@ export async function main(path: string, email: string, job_id: string, is_flow:
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job,
|
||||
/// and its success must reach the workspace success handler like a workspace script's.
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_success_handler_triggered_on_hub_script_success(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_common::jobs::JobPayload;
|
||||
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
let version = "990000003";
|
||||
let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR;
|
||||
tokio::fs::create_dir_all(hub_dir).await?;
|
||||
tokio::fs::write(
|
||||
format!("{hub_dir}/{version}"),
|
||||
r#"{"content":"echo ok","lockfile":null,"language":"bash","schema":{},"summary":null}"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The workspace handler settings are cached per workspace id across the tests of this
|
||||
// binary, so this reuses the handler path every other test here configures.
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
|
||||
VALUES ('test-workspace', 1234567891, 'f/test/success_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings SET success_handler = '{"path": "script/f/test/success_handler"}'::jsonb
|
||||
WHERE workspace_id = 'test-workspace'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let completed_job = RunJob::from(JobPayload::ScriptHub {
|
||||
path: format!("hub/{version}/test/ok"),
|
||||
apply_preprocessor: false,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
assert!(completed_job.success, "Hub script should have succeeded");
|
||||
|
||||
let mut handler_parent = None;
|
||||
for _ in 0..50 {
|
||||
handler_parent = sqlx::query_scalar::<_, Option<uuid::Uuid>>(
|
||||
"SELECT parent_job FROM v2_job
|
||||
WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'success_handler@windmill.dev'",
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if handler_parent.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
handler_parent,
|
||||
Some(Some(completed_job.id)),
|
||||
"Workspace success handler should handle the successful hub script"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -109,3 +109,91 @@ async fn wac_suspend_stands_down_for_a_cancel(db: Pool<Postgres>) -> anyhow::Res
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Each park clears `started_at`, so the completion would otherwise store only the last
|
||||
/// segment: a workflow that waited an hour on its tasks would read as a sub-second job that
|
||||
/// started an hour late. The completed row spans the workflow from its first start, and
|
||||
/// keeps the last segment aside: that is what is metered and what worker-time readers sum.
|
||||
#[sqlx::test]
|
||||
async fn wac_completion_spans_the_workflow_and_keeps_the_last_segment(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \
|
||||
permissioned_as_email, kind, script_lang, runnable_path, tag, visible_to_owner) \
|
||||
VALUES ($1, 'test-workspace', 'test-user', now() - interval '2 hours', 'u/test-user', \
|
||||
'test@windmill.dev', 'script', 'bun', 'u/test-user/wac', 'bun', true)",
|
||||
)
|
||||
.bind(job_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at) \
|
||||
VALUES ($1, 'test-workspace', now(), true, 'bun', now() - interval '1 hour')",
|
||||
)
|
||||
.bind(job_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Two parks, each followed by the pull re-stamping `started_at`: the second park must
|
||||
// not replace the first start with its own segment's.
|
||||
for resumed_ago in ["10 minutes", "3 seconds"] {
|
||||
let mut tx = db.begin().await?;
|
||||
suspend_wac_parent(&mut tx, &job_id, "test-workspace", 1, 3600.0).await?;
|
||||
tx.commit().await?;
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_queue SET suspend = 0, started_at = now() - $2::text::interval \
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(resumed_ago)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let job = windmill_queue::get_mini_completed_job(&job_id, "test-workspace", &db)
|
||||
.await?
|
||||
.unwrap();
|
||||
let result = serde_json::value::to_raw_value(&serde_json::json!("done"))?;
|
||||
let (_, span) = windmill_queue::add_completed_job(
|
||||
&db,
|
||||
&job,
|
||||
true,
|
||||
false,
|
||||
sqlx::types::Json(&result),
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
Some(3000),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (started_ago_s, duration_ms, last_segment_ms): (f64, i64, Option<i64>) = sqlx::query_as(
|
||||
"SELECT extract(epoch FROM now() - started_at)::float8, duration_ms, \
|
||||
(extras->>'wac_last_segment_ms')::bigint \
|
||||
FROM v2_job_completed WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
(started_ago_s - 3600.0).abs() < 60.0,
|
||||
"started_at must be the first start, got {started_ago_s}s ago"
|
||||
);
|
||||
assert!(
|
||||
(duration_ms - 3_600_000).abs() < 60_000,
|
||||
"duration_ms must be the wall time since the first start, got {duration_ms}"
|
||||
);
|
||||
assert_eq!(last_segment_ms, Some(3000));
|
||||
assert_eq!(
|
||||
span.duration_ms, duration_ms,
|
||||
"a flow records the step with the span the row holds"
|
||||
);
|
||||
assert!(span.outlasts_run(job.started_at));
|
||||
assert_eq!(span.last_run_ms, 3000, "only the last segment is left to meter");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,10 +2,19 @@ use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::{
|
||||
external_ip::UNKNOWN_IP,
|
||||
worker::{insert_ping_query, update_worker_ping_main_loop_query},
|
||||
worker::{insert_ping_query, set_worker_draining_query, update_worker_ping_main_loop_query},
|
||||
};
|
||||
|
||||
async fn insert_ping(db: &Pool<Postgres>, worker: &str, ip: Option<&str>) -> anyhow::Result<()> {
|
||||
register(db, worker, ip, None).await
|
||||
}
|
||||
|
||||
async fn register(
|
||||
db: &Pool<Postgres>,
|
||||
worker: &str,
|
||||
ip: Option<&str>,
|
||||
draining: Option<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
insert_ping_query(
|
||||
"test-instance",
|
||||
worker,
|
||||
@@ -19,12 +28,33 @@ async fn insert_ping(db: &Pool<Postgres>, worker: &str, ip: Option<&str>) -> any
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
draining,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A worker re-registers mid-life once its external IP resolves. That registration must not
|
||||
/// clear a draining flag the same process already published; only a worker starting up under
|
||||
/// a reclaimed name resets it.
|
||||
#[sqlx::test]
|
||||
async fn only_a_starting_worker_resets_draining(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let draining = || async {
|
||||
sqlx::query_scalar::<_, bool>("SELECT draining FROM worker_ping WHERE worker = 'wk-drain'")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
};
|
||||
register(&db, "wk-drain", None, Some(false)).await?;
|
||||
set_worker_draining_query("wk-drain", &db).await?;
|
||||
register(&db, "wk-drain", Some("1.2.3.4"), None).await?;
|
||||
assert!(draining().await?);
|
||||
|
||||
register(&db, "wk-drain", None, Some(false)).await?;
|
||||
assert!(!draining().await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The external IP resolves in the background, so the initial ping often has none yet. That must
|
||||
/// not blank the address a previous process wrote to the row this one reclaims — worker names are
|
||||
/// stable across restarts under EXIT_AFTER_N_JOBS.
|
||||
|
||||
@@ -996,6 +996,7 @@ pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uui
|
||||
["job_metrics", "set_progress", ..] => (http_method == "POST", 2),
|
||||
["job_metrics", "get_progress", ..] => (http_method == "GET", 2),
|
||||
["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls", ..] => (http_method == "GET", 2),
|
||||
["jobs", "worker_is_draining", ..] => (http_method == "GET", 2),
|
||||
["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3),
|
||||
["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3),
|
||||
_ => return false,
|
||||
|
||||
@@ -702,3 +702,51 @@ async fn test_list_filtered_job_uuids_resolved_excludes_queue(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_worker_is_draining_follows_the_job_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let job = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
|
||||
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)",
|
||||
)
|
||||
.bind(job)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag, running, started_at, worker)
|
||||
VALUES ($1, 'test-workspace', now(), 'deno', true, now(), 'wk-drain-test')",
|
||||
)
|
||||
.bind(job)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO worker_ping (worker, worker_instance, ip, worker_group, wm_version)
|
||||
VALUES ('wk-drain-test', 'host', 'NO IP', 'default', 'v0')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let url = format!("http://localhost:{port}/api/w/test-workspace/jobs/worker_is_draining/{job}");
|
||||
let draining = || async {
|
||||
let resp = authed(client().get(&url)).send().await?;
|
||||
anyhow::Ok(resp.json::<bool>().await?)
|
||||
};
|
||||
assert!(!draining().await?);
|
||||
|
||||
windmill_common::worker::set_worker_draining_query("wk-drain-test", &db).await?;
|
||||
assert!(draining().await?);
|
||||
|
||||
// A parked job keeps its last worker on the queue row without running on it.
|
||||
sqlx::query("UPDATE v2_job_queue SET started_at = NULL WHERE id = $1")
|
||||
.bind(job)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
assert!(!draining().await?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -58,10 +58,10 @@ use windmill_common::{
|
||||
error::{self, pg_error_message, JsonResult, Result},
|
||||
get_database_url,
|
||||
global_settings::{
|
||||
ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING,
|
||||
AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
EXTERNAL_INSTANCE_PG_SETTING,
|
||||
ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, API_BASE_URL_SETTING,
|
||||
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXTERNAL_INSTANCE_PG_SETTING,
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
|
||||
@@ -1238,26 +1238,22 @@ async fn run_setting_pre_write_hook(
|
||||
}
|
||||
}
|
||||
}
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING => {
|
||||
// A bad value here yields a webhook GitHub can never deliver to, and the
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING | API_BASE_URL_SETTING => {
|
||||
// A bad webhook base yields a webhook GitHub can never deliver to, and the
|
||||
// failure only shows up much later as "falling back to polling" on a
|
||||
// repository — so reject it at the boundary instead.
|
||||
// repository; a bad API base puts an unreachable URL in front of every user.
|
||||
// So reject both at the boundary instead.
|
||||
match value {
|
||||
// Clearing (delete row) is handled by the caller; allow it through.
|
||||
serde_json::Value::Null => {}
|
||||
serde_json::Value::String(s) if s.trim().is_empty() => {}
|
||||
serde_json::Value::String(s) => {
|
||||
windmill_common::global_settings::validate_webhook_base_url(s).map_err(
|
||||
|e| {
|
||||
error::Error::BadRequest(format!(
|
||||
"{GITHUB_APP_WEBHOOK_BASE_URL_SETTING}: {e}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
windmill_common::global_settings::validate_webhook_base_url(s)
|
||||
.map_err(|e| error::Error::BadRequest(format!("{key}: {e}")))?;
|
||||
}
|
||||
_ => {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"{GITHUB_APP_WEBHOOK_BASE_URL_SETTING} must be a URL string"
|
||||
"{key} must be a URL string"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -1394,8 +1390,12 @@ async fn set_instance_config(
|
||||
|
||||
// Applied and audited one setting at a time: the writes are not transactional, so a
|
||||
// batch that fails midway must not leave the settings it did write unrecorded.
|
||||
let instance_config::SettingsDiff { upserts, deletes, mut previous_values, unchanged_count } =
|
||||
settings_diff;
|
||||
let instance_config::SettingsDiff {
|
||||
upserts,
|
||||
deletes,
|
||||
mut previous_values,
|
||||
unchanged_count,
|
||||
} = settings_diff;
|
||||
let steps = upserts
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Some(v)))
|
||||
|
||||
@@ -490,7 +490,7 @@ async fn list_user_usage(
|
||||
"
|
||||
SELECT usr.email, usage.executions
|
||||
FROM usr, LATERAL (
|
||||
SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions
|
||||
SELECT COALESCE(SUM(COALESCE((c.extras->>'wac_last_segment_ms')::bigint, c.duration_ms) + 1000)/1000 , 0)::BIGINT executions
|
||||
FROM v2_job_completed c JOIN v2_job j USING (id)
|
||||
WHERE j.workspace_id = $1
|
||||
AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')
|
||||
|
||||
@@ -86,6 +86,7 @@ struct WorkerPing {
|
||||
job_isolation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
native_mode: Option<bool>,
|
||||
draining: bool,
|
||||
}
|
||||
|
||||
// #[derive(Serialize, Deserialize)]
|
||||
@@ -118,7 +119,7 @@ async fn list_worker_pings(
|
||||
WorkerPing,
|
||||
"SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,
|
||||
CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,
|
||||
custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode
|
||||
custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining
|
||||
FROM worker_ping
|
||||
WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)
|
||||
ORDER BY ping_at desc LIMIT $2 OFFSET $3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.827.0
|
||||
version: 1.828.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -1919,7 +1919,7 @@ paths:
|
||||
|
||||
/settings/instance_ui:
|
||||
get:
|
||||
summary: get the instance-wide announcement banner and accent color
|
||||
summary: get the instance-wide announcement banner, accent color and API base url
|
||||
description: |
|
||||
Readable by any authenticated user; every page load reads it. Each value
|
||||
is the raw stored setting, or null when unset.
|
||||
@@ -1928,7 +1928,7 @@ paths:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: the instance_banner and accent_color settings
|
||||
description: the instance_banner, accent_color and api_base_url settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1936,6 +1936,7 @@ paths:
|
||||
properties:
|
||||
instance_banner: {}
|
||||
accent_color: {}
|
||||
api_base_url: {}
|
||||
|
||||
/settings_u/ruff_config:
|
||||
get:
|
||||
@@ -17322,6 +17323,27 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/worker_is_draining/{id}:
|
||||
get:
|
||||
summary: whether the worker running this job is draining
|
||||
description: |
|
||||
True once the worker running the job has received its shutdown signal. The worker
|
||||
still waits for the job to finish, so a long-running job can poll this to exit early.
|
||||
False when the job is not currently running on a worker.
|
||||
operationId: workerIsDraining
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
responses:
|
||||
"200":
|
||||
description: whether the worker running this job is draining
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/jobs_u/get_logs/{id}:
|
||||
get:
|
||||
summary: get job logs
|
||||
@@ -35901,6 +35923,9 @@ components:
|
||||
type: string
|
||||
native_mode:
|
||||
type: boolean
|
||||
draining:
|
||||
type: boolean
|
||||
description: the worker received its shutdown signal and exits once its current job is done
|
||||
required:
|
||||
- worker
|
||||
- worker_instance
|
||||
|
||||
@@ -979,7 +979,14 @@ async fn get_raw_app_data(
|
||||
// the opaque, sandboxed wrapper (incl. under a cross-origin-isolated
|
||||
// / COEP `require-corp` embedder).
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
// The secret names one app version, whose bundle is written once at deploy,
|
||||
// so the URL's content never changes. `private`: the URL is a capability, so
|
||||
// shared caches must not keep serving it after the app is deleted.
|
||||
.header(
|
||||
http::header::CACHE_CONTROL,
|
||||
"private, max-age=31536000, immutable",
|
||||
);
|
||||
Ok(res.body(body).unwrap())
|
||||
} else {
|
||||
return Err(Error::NotFound("File not found".to_string()));
|
||||
|
||||
@@ -378,6 +378,10 @@ pub fn workspaced_service() -> Router {
|
||||
"/wac_approval_urls/{job_id}/{step_key}",
|
||||
get(get_wac_approval_urls).layer(cors.clone()),
|
||||
)
|
||||
.route(
|
||||
"/worker_is_draining/{id}",
|
||||
get(worker_is_draining).layer(cors.clone()),
|
||||
)
|
||||
.route(
|
||||
"/result_by_id/{job_id}/{node_id}",
|
||||
get(get_result_by_id).layer(cors.clone()),
|
||||
@@ -613,6 +617,53 @@ async fn get_root_job(
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
/// Whether the worker running this job has received its shutdown signal. A draining worker
|
||||
/// never interrupts its job, so a long-running script polls this to exit on its own terms.
|
||||
async fn worker_is_draining(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> windmill_common::error::JsonResult<bool> {
|
||||
// A job always may ask about itself. Anyone else must be able to see the job: the
|
||||
// caller's row policies on v2_job decide, narrowed by the token's tag filter.
|
||||
let draining = if authed.job_id == Some(id) {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker
|
||||
WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL",
|
||||
id,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
} else {
|
||||
let tags = get_scope_tags(&authed)
|
||||
.map(|tags| tags.into_iter().map(str::to_string).collect::<Vec<_>>());
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let draining = sqlx::query_scalar!(
|
||||
"SELECT wp.draining FROM v2_job j
|
||||
JOIN v2_job_queue q ON q.id = j.id
|
||||
JOIN worker_ping wp ON wp.worker = q.worker
|
||||
WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL
|
||||
AND ($3::text[] IS NULL OR j.tag = ANY($3))",
|
||||
id,
|
||||
w_id,
|
||||
tags.as_deref()
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
draining
|
||||
}
|
||||
.unwrap_or(false);
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"worker_draining",
|
||||
"check",
|
||||
if draining { "draining" } else { "not_draining" },
|
||||
);
|
||||
Ok(Json(draining))
|
||||
}
|
||||
|
||||
async fn compute_root_job_for_flow(db: &DB, w_id: &str, job_id: Uuid) -> error::Result<String> {
|
||||
let root_job = sqlx::query_scalar!(
|
||||
r#"SELECT COALESCE(root_job, flow_innermost_root_job, parent_job, id) as "root_job!" FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
|
||||
|
||||
@@ -181,6 +181,12 @@ pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app";
|
||||
/// `base_url` when unset; set it when the browser-facing URL is not reachable
|
||||
/// from GitHub and a separate ingress fronts the API for inbound webhooks.
|
||||
pub const GITHUB_APP_WEBHOOK_BASE_URL_SETTING: &str = "github_app_webhook_base_url";
|
||||
/// Base URL the UI shows for the endpoints external clients call (webhooks, HTTP
|
||||
/// routes, push trigger endpoints, the CLI), instead of the origin the UI is browsed on.
|
||||
/// For deployments that serve the API on a separate domain or behind a gateway.
|
||||
/// EE only. Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]:
|
||||
/// every user is shown URLs built from it.
|
||||
pub const API_BASE_URL_SETTING: &str = "api_base_url";
|
||||
/// Instance-wide announcement rendered above every page of the app (maintenance
|
||||
/// windows, incidents). Readable by any authenticated user, unlike most settings:
|
||||
/// the banner exists to be shown to everyone, so it must never hold anything the
|
||||
@@ -313,12 +319,17 @@ pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> {
|
||||
pub struct InstanceUi {
|
||||
pub instance_banner: Option<serde_json::Value>,
|
||||
pub accent_color: Option<serde_json::Value>,
|
||||
pub api_base_url: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT name, value FROM global_settings WHERE name = ANY($1)",
|
||||
&[INSTANCE_BANNER_SETTING, ACCENT_COLOR_SETTING] as &[&str]
|
||||
&[
|
||||
INSTANCE_BANNER_SETTING,
|
||||
ACCENT_COLOR_SETTING,
|
||||
API_BASE_URL_SETTING
|
||||
] as &[&str]
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
@@ -327,17 +338,22 @@ pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> {
|
||||
match row.name.as_str() {
|
||||
INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value),
|
||||
ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value),
|
||||
API_BASE_URL_SETTING => ui.api_base_url = Some(row.value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(ui)
|
||||
}
|
||||
|
||||
/// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value.
|
||||
const SHELL_ACTIVE_CHARS: [char; 16] = [
|
||||
'$', '`', '\'', '"', '\\', ';', '&', '|', '<', '>', '(', ')', '{', '}', '*', '!',
|
||||
];
|
||||
|
||||
/// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] or [`API_BASE_URL_SETTING`] value.
|
||||
///
|
||||
/// The receiver path is appended to it verbatim, so anything that doesn't
|
||||
/// concatenate into a URL GitHub can POST to must be rejected at write time
|
||||
/// rather than silently producing an unreachable hook: a wrong scheme
|
||||
/// A path is appended to it verbatim, so anything that doesn't concatenate into a
|
||||
/// URL an external caller can reach must be rejected at write time rather than
|
||||
/// silently producing an unreachable hook or endpoint: a wrong scheme
|
||||
/// (`httpss://`), a missing host, embedded whitespace, or a query/fragment
|
||||
/// (appending a path after `?`/`#` keeps it inside the query/fragment).
|
||||
///
|
||||
@@ -353,7 +369,7 @@ pub fn validate_webhook_base_url(value: &str) -> Result<(), String> {
|
||||
url::Url::parse(value).map_err(|e| format!("must be an absolute http(s) URL: {e}"))?;
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(
|
||||
"must not embed a username or password: the receiver URL is stored in workspace settings, where it is readable by workspace admins".to_string(),
|
||||
"must not embed a username or password: the URL is readable by users who are not instance admins".to_string(),
|
||||
);
|
||||
}
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
@@ -366,13 +382,21 @@ pub fn validate_webhook_base_url(value: &str) -> Result<(), String> {
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
return Err(
|
||||
"must not include a query string or fragment, since the webhook path is appended to it"
|
||||
"must not include a query string or fragment, since a path is appended to it"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if value.chars().any(char::is_whitespace) {
|
||||
return Err("must not contain whitespace".to_string());
|
||||
}
|
||||
// The UI pastes the URL into shell commands users copy, where double quotes
|
||||
// do not stop `$(...)` or backticks from running.
|
||||
if value.contains(SHELL_ACTIVE_CHARS) {
|
||||
return Err(
|
||||
"must not contain shell metacharacters, since it is shown inside commands users copy"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// Matches the sibling `base_url` / `hub_base_url` convention. The receiver
|
||||
// builder trims it anyway, so this is about keeping the stored value canonical
|
||||
// rather than about reachability.
|
||||
@@ -1101,6 +1125,9 @@ mod tests {
|
||||
"https://hooks.example.com#",
|
||||
"https://user:password@hooks.example.com",
|
||||
"https://user@hooks.example.com",
|
||||
"https://hooks.example.com/$(uname)",
|
||||
"https://hooks.example.com/`uname`",
|
||||
"https://hooks.example.com/a;b",
|
||||
];
|
||||
for v in accept {
|
||||
assert!(
|
||||
|
||||
@@ -281,6 +281,8 @@ pub struct GlobalSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub github_app_webhook_base_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_base_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email_domain: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hub_base_url: Option<String>,
|
||||
@@ -1375,8 +1377,8 @@ pub fn diff_worker_configs(
|
||||
}
|
||||
|
||||
/// Declaratively replace the global settings, rejecting a
|
||||
/// `github_app_webhook_base_url` or `http_route_default_allowed_origins` the
|
||||
/// API would reject.
|
||||
/// `github_app_webhook_base_url`, `api_base_url` or
|
||||
/// `http_route_default_allowed_origins` the API would reject.
|
||||
///
|
||||
/// Every declarative writer (the `sync-config` CLI, the Kubernetes operator's
|
||||
/// ConfigMap sync) MUST go through this rather than calling
|
||||
@@ -1399,29 +1401,35 @@ pub async fn sync_global_settings_declarative(
|
||||
current: &BTreeMap<String, serde_json::Value>,
|
||||
desired: &BTreeMap<String, serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let webhook_key = crate::global_settings::GITHUB_APP_WEBHOOK_BASE_URL_SETTING;
|
||||
// Non-string shapes are rejected rather than ignored: `as_str()` alone would let a
|
||||
// bool/number/object through as if the key were absent, and the diff below would
|
||||
// then persist it — where the HTTP path answers "must be a URL string".
|
||||
match desired.get(webhook_key) {
|
||||
None | Some(serde_json::Value::Null) => {}
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
|
||||
Some(serde_json::Value::String(s)) => crate::global_settings::validate_webhook_base_url(s)
|
||||
.map_err(|e| anyhow::anyhow!("{webhook_key}: {e}"))?,
|
||||
// Names the JSON kind rather than printing it: this is the last message on
|
||||
// this path that could report submitted content, and an object or array could
|
||||
// carry a secret into `sync-config` output and operator logs.
|
||||
Some(other) => {
|
||||
let kind = match other {
|
||||
serde_json::Value::Bool(_) => "a boolean",
|
||||
serde_json::Value::Number(_) => "a number",
|
||||
serde_json::Value::Array(_) => "an array",
|
||||
serde_json::Value::Object(_) => "an object",
|
||||
_ => "a non-string value",
|
||||
};
|
||||
return Err(anyhow::anyhow!(
|
||||
"{webhook_key} must be a URL string, got {kind}"
|
||||
));
|
||||
for url_key in [
|
||||
crate::global_settings::GITHUB_APP_WEBHOOK_BASE_URL_SETTING,
|
||||
crate::global_settings::API_BASE_URL_SETTING,
|
||||
] {
|
||||
// Non-string shapes are rejected rather than ignored: `as_str()` alone would let a
|
||||
// bool/number/object through as if the key were absent, and the diff below would
|
||||
// then persist it — where the HTTP path answers "must be a URL string".
|
||||
match desired.get(url_key) {
|
||||
None | Some(serde_json::Value::Null) => {}
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
|
||||
Some(serde_json::Value::String(s)) => {
|
||||
crate::global_settings::validate_webhook_base_url(s)
|
||||
.map_err(|e| anyhow::anyhow!("{url_key}: {e}"))?
|
||||
}
|
||||
// Names the JSON kind rather than printing it: this is the last message on
|
||||
// this path that could report submitted content, and an object or array could
|
||||
// carry a secret into `sync-config` output and operator logs.
|
||||
Some(other) => {
|
||||
let kind = match other {
|
||||
serde_json::Value::Bool(_) => "a boolean",
|
||||
serde_json::Value::Number(_) => "a number",
|
||||
serde_json::Value::Array(_) => "an array",
|
||||
serde_json::Value::Object(_) => "an object",
|
||||
_ => "a non-string value",
|
||||
};
|
||||
return Err(anyhow::anyhow!(
|
||||
"{url_key} must be a URL string, got {kind}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,3 +132,36 @@ pub fn otel_set_health_status_phase(_phase: &str) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_health_db_unresponsive(_unresponsive: bool) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_worker_memory(
|
||||
_worker: &str,
|
||||
_worker_group: &str,
|
||||
_usage: Option<i64>,
|
||||
_windmill_usage: Option<i64>,
|
||||
_limit: Option<i64>,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_add_worker_cpu(
|
||||
_worker: &str,
|
||||
_worker_group: &str,
|
||||
_usage_secs: f64,
|
||||
_periods: u64,
|
||||
_throttled_periods: u64,
|
||||
_throttled_secs: f64,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_worker_occupancy_rate(
|
||||
_worker: &str,
|
||||
_worker_group: &str,
|
||||
_window: &'static str,
|
||||
_rate: f64,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_record_job_memory_peak(_tag: &str, _bytes: u64) {}
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::utils::WarnAfterExt;
|
||||
use crate::worker::Connection;
|
||||
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
|
||||
use quick_cache::sync::Cache;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -375,13 +375,15 @@ pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
|
||||
mc.encrypt_str_to_base64(value)
|
||||
}
|
||||
|
||||
const DECRYPT_FAILED: &str =
|
||||
"Could not decrypt value. The value may have been encrypted with a different key.";
|
||||
|
||||
/// Every failure returns the same message so responses carry no signal about why decryption
|
||||
/// failed (padding, base64, UTF-8); the cause is only logged.
|
||||
pub fn decrypt(mc: &MagicCrypt256, value: String) -> error::Result<String> {
|
||||
mc.decrypt_base64_to_string(value).map_err(|e| match e {
|
||||
MagicCryptError::DecryptError(_) => error::Error::internal_err(
|
||||
"Could not decrypt value. The value may have been encrypted with a different key."
|
||||
.to_string(),
|
||||
),
|
||||
_ => error::Error::internal_err(e.to_string()),
|
||||
mc.decrypt_base64_to_string(value).map_err(|e| {
|
||||
tracing::warn!("decryption failed: {e}");
|
||||
error::Error::internal_err(DECRYPT_FAILED.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -746,7 +748,31 @@ pub async fn get_variable_or_self_as<T: Authable + Sync>(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{can_bind_as_prologue_const, escape_js_single_quoted, is_valid_js_identifier};
|
||||
use super::{
|
||||
can_bind_as_prologue_const, decrypt, escape_js_single_quoted, is_valid_js_identifier,
|
||||
DECRYPT_FAILED,
|
||||
};
|
||||
use crate::error::Error;
|
||||
use magic_crypt::MagicCryptTrait;
|
||||
|
||||
#[test]
|
||||
fn decrypt_failures_all_return_the_same_message() {
|
||||
let mc = magic_crypt::new_magic_crypt!("workspace-key", 256);
|
||||
let other = magic_crypt::new_magic_crypt!("another-key", 256);
|
||||
let failures = [
|
||||
"not base64 at all!".to_string(),
|
||||
mc.encrypt_bytes_to_base64(&[0xff, 0xfe, 0xfd]),
|
||||
other.encrypt_str_to_base64("secret"),
|
||||
];
|
||||
for value in failures {
|
||||
match decrypt(&mc, value.clone()) {
|
||||
Err(Error::InternalErrLoc { message, .. }) => {
|
||||
assert_eq!(message, DECRYPT_FAILED, "for {value:?}")
|
||||
}
|
||||
other => panic!("expected the uniform decrypt error for {value:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_js_identifier_gates_prologue_injection() {
|
||||
|
||||
@@ -1783,6 +1783,73 @@ pub fn get_cpu_period() -> Option<i64> {
|
||||
Some(100000)
|
||||
}
|
||||
|
||||
/// Bucket bounds in bytes, 16 MiB to 32 GiB, shared by the Prometheus and OTel job
|
||||
/// peak-memory histograms so the two exports stay comparable.
|
||||
pub const JOB_MEMORY_PEAK_BUCKETS: [f64; 12] = {
|
||||
let mut buckets = [0.0; 12];
|
||||
let mut i = 0;
|
||||
while i < 12 {
|
||||
buckets[i] = (16u64 << (20 + i)) as f64;
|
||||
i += 1;
|
||||
}
|
||||
buckets
|
||||
};
|
||||
|
||||
/// Cumulative CPU accounting of the worker's cgroup. The throttling fields stay at 0
|
||||
/// when the cgroup has no CPU controller, which is also what an unthrottled one reports.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct CpuStat {
|
||||
pub usage_usec: u64,
|
||||
pub nr_periods: u64,
|
||||
pub nr_throttled: u64,
|
||||
pub throttled_usec: u64,
|
||||
}
|
||||
|
||||
fn cpu_stat_field(stat: &str, key: &str) -> Option<u64> {
|
||||
stat.lines().find_map(|line| {
|
||||
let (k, v) = line.split_once(' ')?;
|
||||
(k == key).then(|| v.trim().parse().ok()).flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cgroup_v2_cpu_stat(stat: &str) -> Option<CpuStat> {
|
||||
Some(CpuStat {
|
||||
usage_usec: cpu_stat_field(stat, "usage_usec")?,
|
||||
nr_periods: cpu_stat_field(stat, "nr_periods").unwrap_or(0),
|
||||
nr_throttled: cpu_stat_field(stat, "nr_throttled").unwrap_or(0),
|
||||
throttled_usec: cpu_stat_field(stat, "throttled_usec").unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// cgroup v1 splits the same figures over two controllers and counts time in nanoseconds.
|
||||
fn parse_cgroup_v1_cpu_stat(cpuacct_usage: &str, stat: &str) -> Option<CpuStat> {
|
||||
Some(CpuStat {
|
||||
usage_usec: cpuacct_usage.trim().parse::<u64>().ok()? / 1000,
|
||||
nr_periods: cpu_stat_field(stat, "nr_periods").unwrap_or(0),
|
||||
nr_throttled: cpu_stat_field(stat, "nr_throttled").unwrap_or(0),
|
||||
throttled_usec: cpu_stat_field(stat, "throttled_time").unwrap_or(0) / 1000,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn get_cpu_stat() -> Option<CpuStat> {
|
||||
const V1_USAGE: &str = "/sys/fs/cgroup/cpuacct/cpuacct.usage";
|
||||
if Path::new(V1_USAGE).exists() {
|
||||
parse_cgroup_v1_cpu_stat(
|
||||
&std::fs::read_to_string(V1_USAGE).ok()?,
|
||||
&std::fs::read_to_string("/sys/fs/cgroup/cpu/cpu.stat").unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
let cgroup_path = get_cgroupv2_path()?;
|
||||
parse_cgroup_v2_cpu_stat(&std::fs::read_to_string(format!("{cgroup_path}/cpu.stat")).ok()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn get_cpu_stat() -> Option<CpuStat> {
|
||||
None
|
||||
}
|
||||
|
||||
/// CPUs the process is allowed to run on, ignoring any bandwidth quota — the count
|
||||
/// Go's `NumCPU` reports. `available_parallelism` cannot stand in for it: that folds
|
||||
/// the quota in, so a fraction of a CPU makes it report a single-core machine.
|
||||
@@ -1952,6 +2019,7 @@ pub enum PingType {
|
||||
MainLoop,
|
||||
Job,
|
||||
InitScript,
|
||||
Draining,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Ping {
|
||||
@@ -1974,6 +2042,8 @@ pub struct Ping {
|
||||
pub occupancy_rate_30m: Option<f32>,
|
||||
pub job_isolation: Option<String>,
|
||||
pub native_mode: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub draining: Option<bool>,
|
||||
pub ping_type: PingType,
|
||||
}
|
||||
pub async fn update_ping_http(
|
||||
@@ -2026,6 +2096,7 @@ pub async fn update_ping_http(
|
||||
insert_ping.memory,
|
||||
insert_ping.job_isolation,
|
||||
insert_ping.native_mode.unwrap_or(false),
|
||||
insert_ping.draining,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
@@ -2054,10 +2125,21 @@ pub async fn update_ping_http(
|
||||
)
|
||||
.await?
|
||||
}
|
||||
PingType::Draining => set_worker_draining_query(worker_name, db).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_worker_draining_query(worker_name: &str, db: &DB) -> anyhow::Result<()> {
|
||||
sqlx::query!(
|
||||
"UPDATE worker_ping SET draining = true WHERE worker = $1",
|
||||
worker_name
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct JobCancelled {
|
||||
pub canceled_by: String,
|
||||
@@ -2156,6 +2238,9 @@ pub async fn fetch_raw_script_from_app_query(
|
||||
/// `started_at` and `jobs_executed` are the only two columns carried over, being the
|
||||
/// continuity itself — plus `ip` for as long as `ip` is `None`, which means the external IP
|
||||
/// lookup has not resolved yet and the predecessor's address is still the best guess.
|
||||
///
|
||||
/// `draining` is `Some(false)` from a worker starting up and `None` from one re-registering
|
||||
/// mid-life, which must leave alone a flag its own shutdown may have set in the meantime.
|
||||
pub async fn insert_ping_query(
|
||||
worker_instance: &str,
|
||||
worker_name: &str,
|
||||
@@ -2169,6 +2254,7 @@ pub async fn insert_ping_query(
|
||||
memory: Option<i64>,
|
||||
job_isolation: Option<String>,
|
||||
native_mode: bool,
|
||||
draining: Option<bool>,
|
||||
db: &DB,
|
||||
) -> anyhow::Result<i32> {
|
||||
// A NULL `ip` means the external IP lookup is still in flight; a later ping fills it in, and
|
||||
@@ -2176,8 +2262,8 @@ pub async fn insert_ping_query(
|
||||
// lookup that has failed reports `external_ip::UNRETRIEVABLE_IP`, which does overwrite it. The
|
||||
// literal below must stay equal to `external_ip::UNKNOWN_IP`.
|
||||
let previous_jobs_executed = sqlx::query_scalar!(
|
||||
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)
|
||||
DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL
|
||||
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker)
|
||||
DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining)
|
||||
RETURNING jobs_executed",
|
||||
worker_instance,
|
||||
worker_name,
|
||||
@@ -2191,6 +2277,7 @@ pub async fn insert_ping_query(
|
||||
memory,
|
||||
job_isolation.as_deref(),
|
||||
native_mode,
|
||||
draining,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
@@ -2899,6 +2986,32 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn cgroup_cpu_stat_is_normalized_to_microseconds() {
|
||||
let v2 = "usage_usec 2500000\nuser_usec 2000000\nsystem_usec 500000\n\
|
||||
nr_periods 40\nnr_throttled 7\nthrottled_usec 350000\nnr_bursts 0\n";
|
||||
let expected = CpuStat {
|
||||
usage_usec: 2_500_000,
|
||||
nr_periods: 40,
|
||||
nr_throttled: 7,
|
||||
throttled_usec: 350_000,
|
||||
};
|
||||
assert_eq!(parse_cgroup_v2_cpu_stat(v2), Some(expected));
|
||||
|
||||
let v1_stat = "nr_periods 40\nnr_throttled 7\nthrottled_time 350000000\n";
|
||||
assert_eq!(
|
||||
parse_cgroup_v1_cpu_stat("2500000000\n", v1_stat),
|
||||
Some(expected)
|
||||
);
|
||||
|
||||
// No CPU controller: usage is still reported, throttling reads as zero.
|
||||
assert_eq!(
|
||||
parse_cgroup_v2_cpu_stat("usage_usec 12\nuser_usec 8\nsystem_usec 4\n"),
|
||||
Some(CpuStat { usage_usec: 12, ..Default::default() })
|
||||
);
|
||||
assert_eq!(parse_cgroup_v2_cpu_stat("nr_periods 40\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datatable_role_is_read_from_the_leading_comment_block() {
|
||||
let role = |code| SqlAnnotations::datatable_role(code);
|
||||
|
||||
@@ -1221,7 +1221,7 @@ pub async fn add_completed_job_pre_shaped_failure<T: Serialize + Send + Sync + V
|
||||
worker_name: &str,
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<(), Error> {
|
||||
) -> Result<CompletedSpan, Error> {
|
||||
record_failure_metrics(completed_job, worker_name).await;
|
||||
|
||||
tracing::error!(
|
||||
@@ -1229,7 +1229,7 @@ pub async fn add_completed_job_pre_shaped_failure<T: Serialize + Send + Sync + V
|
||||
completed_job.id,
|
||||
completed_job.workspace_id,
|
||||
);
|
||||
let _ = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
completed_job,
|
||||
false,
|
||||
@@ -1244,7 +1244,7 @@ pub async fn add_completed_job_pre_shaped_failure<T: Serialize + Send + Sync + V
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(span)
|
||||
}
|
||||
|
||||
pub async fn add_completed_job_error(
|
||||
@@ -1257,6 +1257,31 @@ pub async fn add_completed_job_error(
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<WrappedError, Error> {
|
||||
add_completed_job_error_with_span(
|
||||
db,
|
||||
completed_job,
|
||||
mem_peak,
|
||||
canceled_by,
|
||||
e,
|
||||
worker_name,
|
||||
flow_is_done,
|
||||
duration,
|
||||
)
|
||||
.await
|
||||
.map(|(wrapped, _)| wrapped)
|
||||
}
|
||||
|
||||
/// `add_completed_job_error`, also handing back what the completed row holds.
|
||||
pub async fn add_completed_job_error_with_span(
|
||||
db: &Pool<Postgres>,
|
||||
completed_job: &MiniCompletedJob,
|
||||
mem_peak: i32,
|
||||
canceled_by: Option<CanceledBy>,
|
||||
e: serde_json::Value,
|
||||
worker_name: &str,
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<(WrappedError, CompletedSpan), Error> {
|
||||
record_failure_metrics(completed_job, worker_name).await;
|
||||
|
||||
let result = WrappedError { error: e };
|
||||
@@ -1266,7 +1291,7 @@ pub async fn add_completed_job_error(
|
||||
completed_job.workspace_id,
|
||||
serde_json::to_string(&result).unwrap_or_else(|_| "".to_string())
|
||||
);
|
||||
let _ = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
&completed_job,
|
||||
false,
|
||||
@@ -1281,7 +1306,7 @@ pub async fn add_completed_job_error(
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await?;
|
||||
Ok(result)
|
||||
Ok((result, span))
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -1320,7 +1345,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
from_cache: bool,
|
||||
) -> Result<(Uuid, i64), Error> {
|
||||
) -> Result<(Uuid, CompletedSpan), Error> {
|
||||
// tracing::error!("Start");
|
||||
// let start = tokio::time::Instant::now();
|
||||
|
||||
@@ -1354,7 +1379,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
};
|
||||
|
||||
let result_columns = result_columns.as_ref();
|
||||
let (opt_uuid, duration, _skip_downstream_error_handlers, wac_parent_ready) = (|| {
|
||||
let (opt_uuid, span, _skip_downstream_error_handlers, wac_parent_ready) = (|| {
|
||||
commit_completed_job(
|
||||
db,
|
||||
completed_job,
|
||||
@@ -1395,7 +1420,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
|
||||
// if scheduling next job failed, return the job_id early to ensure the job get retried after a timeout
|
||||
if let Some(job_id) = opt_uuid {
|
||||
return Ok((job_id, duration));
|
||||
return Ok((job_id, span));
|
||||
}
|
||||
|
||||
// Auto-resolve a retry chain that ultimately worked, from whichever of the two
|
||||
@@ -1436,7 +1461,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
}
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
apply_completed_job_cloud_usage(db, completed_job, duration);
|
||||
apply_completed_job_cloud_usage(db, completed_job, span.last_run_ms);
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
crate::jobs_ee::apply_completed_job_error_handlers(
|
||||
@@ -1453,7 +1478,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
|
||||
// tracing::error!("4 {:?}", start.elapsed());
|
||||
|
||||
Ok((completed_job.id, duration))
|
||||
Ok((completed_job.id, span))
|
||||
}
|
||||
|
||||
async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
@@ -1471,7 +1496,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
// True when a native script retry was enqueued for this failed attempt, i.e.
|
||||
// this is not the terminal attempt — schedule completion handlers must wait.
|
||||
retry_pending: bool,
|
||||
) -> windmill_common::error::Result<(Option<Uuid>, i64, bool, bool)> {
|
||||
) -> windmill_common::error::Result<(Option<Uuid>, CompletedSpan, bool, bool)> {
|
||||
// let start = std::time::Instant::now();
|
||||
|
||||
let job_id = completed_job.id;
|
||||
@@ -1538,11 +1563,11 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
&& monitor_parent.is_none()
|
||||
&& !is_scheduled
|
||||
{
|
||||
let Some(duration) = completion.execute(&mut *db.acquire().await?).await? else {
|
||||
let Some(completed) = completion.execute(&mut *db.acquire().await?).await? else {
|
||||
return Err(not_in_queue_error(db, job_id).await);
|
||||
};
|
||||
log_completed_job(completed_job, duration, success);
|
||||
return Ok((None, duration, false, false));
|
||||
log_completed_job(completed_job, completed.duration_ms, success);
|
||||
return Ok((None, completed, false, false));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().warn_after_seconds(10).await?;
|
||||
@@ -1550,7 +1575,8 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
// The parent's rows are locked ahead of the child's own queue row (see
|
||||
// `record_child_completion` for the order this must keep), so the duration it stamps is read
|
||||
// before the completion: the one the completed row will hold. `now()` is fixed for the
|
||||
// transaction, and a completed row already there keeps its own duration.
|
||||
// transaction, and a completed row already there keeps its own duration. For a child that
|
||||
// itself parked it is the last run's, matching the last start its timeline entry carries.
|
||||
let mut wac_parent_ready = false;
|
||||
if let Some(parent_job) = wac_parent {
|
||||
let Some(duration) = sqlx::query_scalar!(
|
||||
@@ -1577,7 +1603,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let Some(duration) = completion.execute(&mut *tx).await? else {
|
||||
let Some(completed) = completion.execute(&mut *tx).await? else {
|
||||
return Err(not_in_queue_error(&mut *tx, job_id).await);
|
||||
};
|
||||
|
||||
@@ -1787,16 +1813,35 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
|
||||
tx.commit().warn_after_seconds(10).await?;
|
||||
|
||||
log_completed_job(completed_job, duration, success);
|
||||
log_completed_job(completed_job, completed.duration_ms, success);
|
||||
// tracing::info!("completed job: {:?}", start.elapsed().as_micros());
|
||||
Ok((
|
||||
None,
|
||||
duration,
|
||||
completed,
|
||||
_skip_downstream_error_handlers,
|
||||
wac_parent_ready,
|
||||
))
|
||||
}
|
||||
|
||||
/// What a job's completion wrote. `started_at` and `duration_ms` are the completed row's.
|
||||
/// `last_run_ms` is the time the job's last run held a worker, which is what gets metered;
|
||||
/// it differs from `duration_ms` only for a job that parked.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CompletedSpan {
|
||||
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub duration_ms: i64,
|
||||
pub last_run_ms: i64,
|
||||
}
|
||||
|
||||
impl CompletedSpan {
|
||||
/// Whether the row spans more than the run that started at `last_started_at`, i.e. the
|
||||
/// job parked and resumed. A flow records such a step with the row's span, so the step
|
||||
/// reads like the job does.
|
||||
pub fn outlasts_run(&self, last_started_at: Option<chrono::DateTime<chrono::Utc>>) -> bool {
|
||||
matches!((self.started_at, last_started_at), (Some(first), Some(last)) if first < last)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a job's completion writes, for `Completion::execute`.
|
||||
struct Completion<'a> {
|
||||
completed_job: &'a MiniCompletedJob,
|
||||
@@ -1813,6 +1858,11 @@ impl Completion<'_> {
|
||||
/// Moves the job from the queue to the completed jobs and refreshes a flow step's parent
|
||||
/// ping, as one statement. Returns `None` when the job was no longer in the queue.
|
||||
///
|
||||
/// For a job that parked (a Workflow-as-Code parent, see `suspend_wac_parent`) the row's
|
||||
/// `started_at` and `duration_ms` span the whole workflow from its first start, and the
|
||||
/// last run goes to `extras.wac_last_segment_ms` for readers that sum worker time from
|
||||
/// completed rows.
|
||||
///
|
||||
/// The completion takes its cancellation from the queue row it deletes, not only from
|
||||
/// `canceled_by`: that is what the worker last read, and the delete waits for a cancel still
|
||||
/// being written, so the deleted row is the final word on whether the job was canceled.
|
||||
@@ -1820,7 +1870,7 @@ impl Completion<'_> {
|
||||
/// It locks the queue row before the completed row's key. Any other writer completing a job
|
||||
/// (the monitor's zombie fallback, debounce) must take them in the same order, or the two
|
||||
/// deadlock.
|
||||
async fn execute(&self, conn: &mut sqlx::PgConnection) -> error::Result<Option<i64>> {
|
||||
async fn execute(&self, conn: &mut sqlx::PgConnection) -> error::Result<Option<CompletedSpan>> {
|
||||
let Completion {
|
||||
completed_job,
|
||||
success,
|
||||
@@ -1851,16 +1901,20 @@ impl Completion<'_> {
|
||||
.then_some(completed_job.parent_job)
|
||||
.flatten()
|
||||
else {
|
||||
return sqlx::query_scalar!(
|
||||
return sqlx::query_as!(
|
||||
CompletedSpan,
|
||||
"WITH deleted AS (
|
||||
DELETE FROM v2_job_queue WHERE id = $1
|
||||
RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason
|
||||
RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,
|
||||
(extras->>'wac_first_started_at')::timestamptz AS first_started_at,
|
||||
COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms
|
||||
), completed AS (
|
||||
INSERT INTO v2_job_completed AS cj
|
||||
( workspace_id
|
||||
, id
|
||||
, started_at
|
||||
, duration_ms
|
||||
, extras
|
||||
, result
|
||||
, result_columns
|
||||
, canceled_by
|
||||
@@ -1871,8 +1925,11 @@ impl Completion<'_> {
|
||||
, status
|
||||
, worker
|
||||
)
|
||||
SELECT d.workspace_id, d.id, d.started_at,
|
||||
COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),
|
||||
SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),
|
||||
CASE WHEN d.first_started_at IS NULL THEN d.run_ms
|
||||
ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,
|
||||
CASE WHEN d.first_started_at IS NOT NULL
|
||||
THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,
|
||||
$3::text::jsonb, $10,
|
||||
CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,
|
||||
CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,
|
||||
@@ -1888,9 +1945,11 @@ impl Completion<'_> {
|
||||
THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,
|
||||
canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL
|
||||
THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END
|
||||
RETURNING duration_ms
|
||||
RETURNING cj.started_at, cj.duration_ms,
|
||||
COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms
|
||||
)
|
||||
SELECT duration_ms AS \"duration_ms!\" FROM completed",
|
||||
SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",
|
||||
last_run_ms AS \"last_run_ms!\" FROM completed",
|
||||
/* $1 */ completed_job.id,
|
||||
/* $2 */ success,
|
||||
/* $3 */ result,
|
||||
@@ -1910,16 +1969,20 @@ impl Completion<'_> {
|
||||
// A canceled flow is pinged too: it is completed by its next transition like any other
|
||||
// flow, and the zombie flow monitor needs the ping to finish the cancel if that
|
||||
// transition is lost.
|
||||
sqlx::query_scalar!(
|
||||
sqlx::query_as!(
|
||||
CompletedSpan,
|
||||
"WITH deleted AS (
|
||||
DELETE FROM v2_job_queue WHERE id = $1
|
||||
RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason
|
||||
RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,
|
||||
(extras->>'wac_first_started_at')::timestamptz AS first_started_at,
|
||||
COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms
|
||||
), completed AS (
|
||||
INSERT INTO v2_job_completed AS cj
|
||||
( workspace_id
|
||||
, id
|
||||
, started_at
|
||||
, duration_ms
|
||||
, extras
|
||||
, result
|
||||
, result_columns
|
||||
, canceled_by
|
||||
@@ -1930,8 +1993,11 @@ impl Completion<'_> {
|
||||
, status
|
||||
, worker
|
||||
)
|
||||
SELECT d.workspace_id, d.id, d.started_at,
|
||||
COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),
|
||||
SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),
|
||||
CASE WHEN d.first_started_at IS NULL THEN d.run_ms
|
||||
ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,
|
||||
CASE WHEN d.first_started_at IS NOT NULL
|
||||
THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,
|
||||
$3::text::jsonb, $10,
|
||||
CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,
|
||||
CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,
|
||||
@@ -1947,14 +2013,16 @@ impl Completion<'_> {
|
||||
THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,
|
||||
canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL
|
||||
THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END
|
||||
RETURNING duration_ms
|
||||
RETURNING cj.started_at, cj.duration_ms,
|
||||
COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms
|
||||
), parent_ping AS (
|
||||
UPDATE v2_job_runtime r SET ping = now()
|
||||
FROM v2_job_queue q
|
||||
WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12
|
||||
AND EXISTS (SELECT 1 FROM completed)
|
||||
)
|
||||
SELECT duration_ms AS \"duration_ms!\" FROM completed",
|
||||
SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",
|
||||
last_run_ms AS \"last_run_ms!\" FROM completed",
|
||||
/* $1 */ completed_job.id,
|
||||
/* $2 */ success,
|
||||
/* $3 */ result,
|
||||
@@ -2021,7 +2089,7 @@ async fn check_result_size<T: ValidableJson>(
|
||||
db: &Pool<Postgres>,
|
||||
queued_job: &MiniCompletedJob,
|
||||
result: Json<&T>,
|
||||
) -> Option<Result<(Option<Uuid>, i64, bool, bool), Error>> {
|
||||
) -> Option<Result<(Option<Uuid>, CompletedSpan, bool, bool), Error>> {
|
||||
let result_size = result.size() / 1024 / 1024;
|
||||
if result_size > 2 {
|
||||
if result_size > *MAX_RESULT_SIZE_MB {
|
||||
|
||||
@@ -54,11 +54,45 @@ pub struct VolumeMount {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// A file's size and last change time. Sync-back trusts a pulled file's stored hash only while
|
||||
/// its stamp is the one taken at hand-off: size alone misses a rewrite of the same length. A
|
||||
/// write within the filesystem's timestamp granularity of the hand-off is not seen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FileStamp {
|
||||
size: u64,
|
||||
changed: Option<(i64, i64)>,
|
||||
}
|
||||
|
||||
impl FileStamp {
|
||||
/// Whether the file is as it was at `earlier`. An unreadable change time never matches.
|
||||
pub fn unchanged_since(&self, earlier: &FileStamp) -> bool {
|
||||
self.changed.is_some() && self == earlier
|
||||
}
|
||||
|
||||
pub fn of(meta: &std::fs::Metadata) -> Self {
|
||||
// ctime rather than mtime: a tool that restores mtime (cp -p, tar, rsync -t) cannot set it.
|
||||
#[cfg(unix)]
|
||||
let changed = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
Some((meta.ctime(), meta.ctime_nsec()))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let changed = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| (d.as_secs() as i64, d.subsec_nanos() as i64));
|
||||
Self { size: meta.len(), changed }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VolumeState {
|
||||
pub mount: VolumeMount,
|
||||
pub local_dir: PathBuf,
|
||||
pub manifest: HashMap<String, FileEntry>,
|
||||
pub symlinks: HashMap<String, String>,
|
||||
/// Stamp of each pulled file as the job received it, keyed like `manifest`.
|
||||
pub stamps: HashMap<String, FileStamp>,
|
||||
}
|
||||
|
||||
pub struct DownloadStats {
|
||||
|
||||
@@ -3731,7 +3731,10 @@ pub async fn handle_wac_v2_output(
|
||||
// status row, the order every child completion takes.
|
||||
let segment_ms = sqlx::query_scalar!(
|
||||
"WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)
|
||||
UPDATE v2_job_queue q SET running = false, started_at = null
|
||||
UPDATE v2_job_queue q SET running = false, started_at = null,
|
||||
extras = CASE WHEN q.started_at IS NULL OR q.extras ? 'wac_first_started_at' THEN q.extras
|
||||
ELSE jsonb_set(COALESCE(q.extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(q.started_at))
|
||||
END
|
||||
FROM prev WHERE q.id = $1
|
||||
RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint",
|
||||
job.id,
|
||||
|
||||
@@ -643,6 +643,7 @@ pub async fn update_worker_ping_for_failed_init_script(
|
||||
wm_memory_usage: None,
|
||||
job_isolation: None,
|
||||
native_mode: None,
|
||||
draining: None,
|
||||
ping_type: PingType::InitScript,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1049,6 +1049,7 @@ where
|
||||
let wm_memory_usage = get_windmill_memory_usage();
|
||||
tracing::info!("job {job_id} on {worker_name} in {w_id} worker memory snapshot {}kB/{}kB", memory_usage.unwrap_or_default()/1024, wm_memory_usage.unwrap_or_default()/1024);
|
||||
let occupancy = occupancy_metrics.as_mut().map(|x| x.update_occupancy_metrics());
|
||||
crate::resource_metrics::record_worker_resources(worker_name, memory_usage, wm_memory_usage, occupancy.as_ref());
|
||||
if job_id != Uuid::nil() {
|
||||
if let Err(err) = update_worker_ping_from_job(&conn, &job_id, w_id, worker_name, memory_usage, wm_memory_usage, occupancy).await {
|
||||
tracing::error!("Unable to update worker ping for job {} in workspace {}. Error was: {:?}", job_id, w_id, err);
|
||||
@@ -1140,8 +1141,8 @@ fn child_joined_output_stream(
|
||||
.take()
|
||||
.expect("child did not have a handle to stdout");
|
||||
|
||||
let stdout = BufReader::new(stdout).lines();
|
||||
let stderr = BufReader::new(stderr).lines();
|
||||
let stdout = BufReader::new(stdout).split(b'\n');
|
||||
let stderr = BufReader::new(stderr).split(b'\n');
|
||||
stream::select(
|
||||
lines_to_stream(stderr, true, job_id.clone(), w_id.clone())
|
||||
.map(|l| l.map(|line| OutputLine { stderr: true, line })),
|
||||
@@ -1151,18 +1152,32 @@ fn child_joined_output_stream(
|
||||
}
|
||||
|
||||
pub fn lines_to_stream<R: tokio::io::AsyncBufRead + Unpin>(
|
||||
mut lines: tokio::io::Lines<R>,
|
||||
mut lines: tokio::io::Split<R>,
|
||||
stderr: bool,
|
||||
job_id: Uuid,
|
||||
w_id: String,
|
||||
) -> impl futures::Stream<Item = io::Result<String>> {
|
||||
stream::poll_fn(move |cx| {
|
||||
std::pin::Pin::new(&mut lines)
|
||||
.poll_next_line(cx)
|
||||
.map(|result| process_streaming_log_lines(result, stderr, &job_id, &w_id))
|
||||
.poll_next_segment(cx)
|
||||
.map(|result| {
|
||||
let result = result.map(|segment| segment.map(decode_output_line));
|
||||
process_streaming_log_lines(result, stderr, &job_id, &w_id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Child output is not guaranteed to be UTF-8 (e.g. pwsh on Windows writes in the
|
||||
/// console code page), so decode lossily: a strict decode errors out and ends log
|
||||
/// capture for the rest of the job. Strips a trailing `\r` so CRLF output doesn't keep it.
|
||||
pub(crate) fn decode_output_line(mut segment: Vec<u8>) -> String {
|
||||
if segment.last() == Some(&b'\r') {
|
||||
segment.pop();
|
||||
}
|
||||
String::from_utf8(segment)
|
||||
.unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned())
|
||||
}
|
||||
|
||||
pub fn process_status(
|
||||
program: &str,
|
||||
status: ExitStatus,
|
||||
@@ -1209,4 +1224,33 @@ mod tests {
|
||||
let left = JobDeadline(Some(Instant::now() + Duration::from_secs(120)));
|
||||
assert!(matches!(left.remaining_secs(), Some(s) if (118..=120).contains(&s)));
|
||||
}
|
||||
|
||||
// "Prüfe" in CP850/1252 is not UTF-8. A strict decode errors on that line and
|
||||
// would stop log capture, dropping every line after it.
|
||||
#[tokio::test]
|
||||
async fn a_non_utf8_line_is_decoded_lossily_and_later_lines_still_arrive() {
|
||||
let output: &[u8] =
|
||||
b"start\r\nPr\x81fe Benutzer\nPr\xfcfe\nGr\xc3\xbc\xc3\x9fe\r\nafter\n\nlast";
|
||||
let lines: Vec<String> = lines_to_stream(
|
||||
BufReader::new(output).split(b'\n'),
|
||||
false,
|
||||
Uuid::nil(),
|
||||
"w".into(),
|
||||
)
|
||||
.map(|line| line.expect("reading from a byte slice cannot fail"))
|
||||
.collect()
|
||||
.await;
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"start",
|
||||
"Pr\u{FFFD}fe Benutzer",
|
||||
"Pr\u{FFFD}fe",
|
||||
"Grüße",
|
||||
"after",
|
||||
"",
|
||||
"last",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ mod prepare_deps;
|
||||
mod python_executor;
|
||||
#[cfg(feature = "python")]
|
||||
mod python_versions;
|
||||
mod resource_metrics;
|
||||
pub mod result_processor;
|
||||
#[cfg(feature = "rust")]
|
||||
mod rust_executor;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
use std::sync::{atomic::Ordering, Mutex, OnceLock};
|
||||
|
||||
use windmill_common::{
|
||||
otel_oss::{
|
||||
otel_add_worker_cpu, otel_record_job_memory_peak, otel_set_worker_memory,
|
||||
otel_set_worker_occupancy_rate,
|
||||
},
|
||||
worker::{get_cpu_stat, get_memory, CpuStat, WORKER_GROUP},
|
||||
OTEL_METRICS_ENABLED,
|
||||
};
|
||||
|
||||
use crate::common::OccupancyResult;
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
struct PromMetrics {
|
||||
memory_usage: prometheus::IntGaugeVec,
|
||||
windmill_memory_usage: prometheus::IntGaugeVec,
|
||||
memory_limit: prometheus::IntGaugeVec,
|
||||
cpu_usage: prometheus::CounterVec,
|
||||
cpu_periods: prometheus::IntCounterVec,
|
||||
cpu_throttled_periods: prometheus::IntCounterVec,
|
||||
cpu_throttled_seconds: prometheus::CounterVec,
|
||||
occupancy_rate: prometheus::GaugeVec,
|
||||
job_memory_peak: prometheus::HistogramVec,
|
||||
}
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
lazy_static::lazy_static! {
|
||||
static ref PROM_METRICS: Option<PromMetrics> =
|
||||
if windmill_common::METRICS_ENABLED.load(Ordering::Relaxed) {
|
||||
const WORKER: &[&str] = &["name", "worker_group"];
|
||||
Some(PromMetrics {
|
||||
memory_usage: prometheus::register_int_gauge_vec!(
|
||||
"worker_memory_usage_bytes",
|
||||
"Memory used by the worker's container, page cache excluded",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
windmill_memory_usage: prometheus::register_int_gauge_vec!(
|
||||
"worker_windmill_memory_usage_bytes",
|
||||
"Resident memory of the worker process itself",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
memory_limit: prometheus::register_int_gauge_vec!(
|
||||
"worker_memory_limit_bytes",
|
||||
"Memory limit of the worker's container",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
cpu_usage: prometheus::register_counter_vec!(
|
||||
"worker_cpu_usage_seconds_total",
|
||||
"CPU time consumed by the worker's container",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
cpu_periods: prometheus::register_int_counter_vec!(
|
||||
"worker_cpu_periods_total",
|
||||
"CPU enforcement periods elapsed for the worker's container",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
cpu_throttled_periods: prometheus::register_int_counter_vec!(
|
||||
"worker_cpu_throttled_periods_total",
|
||||
"CPU enforcement periods in which the worker's container was throttled",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
cpu_throttled_seconds: prometheus::register_counter_vec!(
|
||||
"worker_cpu_throttled_seconds_total",
|
||||
"Time the worker's container spent throttled by its CPU limit",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
occupancy_rate: prometheus::register_gauge_vec!(
|
||||
"worker_occupancy_rate",
|
||||
"Share of time the worker spent executing jobs, over the window",
|
||||
&["name", "worker_group", "window"]
|
||||
)
|
||||
.unwrap(),
|
||||
job_memory_peak: prometheus::register_histogram_vec!(
|
||||
"worker_job_memory_peak_bytes",
|
||||
"Peak memory of a job's main process",
|
||||
&["tag"],
|
||||
windmill_common::worker::JOB_MEMORY_PEAK_BUCKETS.to_vec()
|
||||
)
|
||||
.unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
fn metrics_enabled() -> bool {
|
||||
#[cfg(feature = "prometheus")]
|
||||
if PROM_METRICS.is_some() {
|
||||
return true;
|
||||
}
|
||||
OTEL_METRICS_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// Memory and CPU are read from the cgroup, which every worker of a process shares
|
||||
// (native mode runs several). Only the first worker to report exports them, so that
|
||||
// summing the series over workers does not count one container several times.
|
||||
static CONTAINER_SERIES_OWNER: OnceLock<String> = OnceLock::new();
|
||||
static MEMORY_LIMIT: Mutex<Option<Option<i64>>> = Mutex::new(None);
|
||||
|
||||
/// Replaces the exported memory limit, for the ping that re-reads the cgroup limits.
|
||||
pub(crate) fn set_memory_limit(limit: Option<i64>) {
|
||||
*MEMORY_LIMIT.lock().unwrap() = Some(limit);
|
||||
}
|
||||
static LAST_CPU_STAT: Mutex<CpuStat> =
|
||||
Mutex::new(CpuStat { usage_usec: 0, nr_periods: 0, nr_throttled: 0, throttled_usec: 0 });
|
||||
|
||||
/// Exports the worker's resource readings. Meant for the ping ticks, which have already
|
||||
/// read memory usage and occupancy; the only extra work is the cgroup `cpu.stat` read.
|
||||
pub(crate) fn record_worker_resources(
|
||||
worker_name: &str,
|
||||
memory_usage: Option<i64>,
|
||||
wm_memory_usage: Option<i64>,
|
||||
occupancy: Option<&OccupancyResult>,
|
||||
) {
|
||||
if !metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
let worker_group = WORKER_GROUP.as_str();
|
||||
|
||||
if let Some(occupancy) = occupancy {
|
||||
let windows = [
|
||||
("total", Some(occupancy.occupancy_rate)),
|
||||
("15s", occupancy.occupancy_rate_15s),
|
||||
("5m", occupancy.occupancy_rate_5m),
|
||||
("30m", occupancy.occupancy_rate_30m),
|
||||
];
|
||||
for (window, rate) in windows {
|
||||
let Some(rate) = rate.filter(|r| r.is_finite()) else {
|
||||
continue;
|
||||
};
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(m) = PROM_METRICS.as_ref() {
|
||||
m.occupancy_rate
|
||||
.with_label_values(&[worker_name, worker_group, window])
|
||||
.set(rate as f64);
|
||||
}
|
||||
otel_set_worker_occupancy_rate(worker_name, worker_group, window, rate as f64);
|
||||
}
|
||||
}
|
||||
|
||||
if CONTAINER_SERIES_OWNER.get_or_init(|| worker_name.to_string()) != worker_name {
|
||||
return;
|
||||
}
|
||||
|
||||
let memory_limit = *MEMORY_LIMIT.lock().unwrap().get_or_insert_with(get_memory);
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(m) = PROM_METRICS.as_ref() {
|
||||
let labels = &[worker_name, worker_group];
|
||||
if let Some(v) = memory_usage {
|
||||
m.memory_usage.with_label_values(labels).set(v);
|
||||
}
|
||||
if let Some(v) = wm_memory_usage {
|
||||
m.windmill_memory_usage.with_label_values(labels).set(v);
|
||||
}
|
||||
if let Some(v) = memory_limit {
|
||||
m.memory_limit.with_label_values(labels).set(v);
|
||||
}
|
||||
}
|
||||
otel_set_worker_memory(
|
||||
worker_name,
|
||||
worker_group,
|
||||
memory_usage,
|
||||
wm_memory_usage,
|
||||
memory_limit,
|
||||
);
|
||||
|
||||
let Some(stat) = get_cpu_stat() else {
|
||||
return;
|
||||
};
|
||||
let delta = {
|
||||
let mut last = LAST_CPU_STAT.lock().unwrap();
|
||||
// A reading below the previous one means the cgroup changed under the process:
|
||||
// restart from it rather than exporting a negative increase.
|
||||
let delta = if stat.usage_usec < last.usage_usec {
|
||||
CpuStat::default()
|
||||
} else {
|
||||
CpuStat {
|
||||
usage_usec: stat.usage_usec - last.usage_usec,
|
||||
nr_periods: stat.nr_periods.saturating_sub(last.nr_periods),
|
||||
nr_throttled: stat.nr_throttled.saturating_sub(last.nr_throttled),
|
||||
throttled_usec: stat.throttled_usec.saturating_sub(last.throttled_usec),
|
||||
}
|
||||
};
|
||||
*last = stat;
|
||||
delta
|
||||
};
|
||||
let usage_secs = delta.usage_usec as f64 / 1e6;
|
||||
let throttled_secs = delta.throttled_usec as f64 / 1e6;
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(m) = PROM_METRICS.as_ref() {
|
||||
let labels = &[worker_name, worker_group];
|
||||
m.cpu_usage.with_label_values(labels).inc_by(usage_secs);
|
||||
m.cpu_periods
|
||||
.with_label_values(labels)
|
||||
.inc_by(delta.nr_periods);
|
||||
m.cpu_throttled_periods
|
||||
.with_label_values(labels)
|
||||
.inc_by(delta.nr_throttled);
|
||||
m.cpu_throttled_seconds
|
||||
.with_label_values(labels)
|
||||
.inc_by(throttled_secs);
|
||||
}
|
||||
otel_add_worker_cpu(
|
||||
worker_name,
|
||||
worker_group,
|
||||
usage_secs,
|
||||
delta.nr_periods,
|
||||
delta.nr_throttled,
|
||||
throttled_secs,
|
||||
);
|
||||
}
|
||||
|
||||
/// `mem_peak_kb` is the job's `mem_peak`: non-positive when the executor had no process
|
||||
/// to sample or the reading failed, and those are not observations.
|
||||
pub(crate) fn record_job_memory_peak(tag: &str, mem_peak_kb: i32) {
|
||||
if mem_peak_kb <= 0 {
|
||||
return;
|
||||
}
|
||||
let bytes = mem_peak_kb as u64 * 1024;
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(m) = PROM_METRICS.as_ref() {
|
||||
m.job_memory_peak
|
||||
.with_label_values(&[tag])
|
||||
.observe(bytes as f64);
|
||||
}
|
||||
otel_record_job_memory_peak(tag, bytes);
|
||||
}
|
||||
@@ -1776,7 +1776,7 @@ pub async fn process_completed_job(
|
||||
|
||||
add_time!(bench, "pre add_completed_job");
|
||||
|
||||
let (_, duration) = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
&job,
|
||||
true,
|
||||
@@ -1835,7 +1835,10 @@ pub async fn process_completed_job(
|
||||
true,
|
||||
canceled_by,
|
||||
result,
|
||||
started_at.map(|x| FlowJobDuration { started_at: x, duration_ms: duration }),
|
||||
span.started_at.or(started_at).map(|x| FlowJobDuration {
|
||||
started_at: x,
|
||||
duration_ms: span.duration_ms,
|
||||
}),
|
||||
StepFailureKind::Normal,
|
||||
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
|
||||
&worker_dir,
|
||||
@@ -1867,9 +1870,10 @@ pub async fn process_completed_job(
|
||||
// `wm_failure` field) so a real runtime failure whose raw
|
||||
// result happens to contain a `wm_failure` field still goes
|
||||
// through the standard `WrappedError { error: ... }` wrap path.
|
||||
let downstream_result: Arc<Box<RawValue>> = if is_pre_shaped_wm_failure_result(result.get())
|
||||
{
|
||||
windmill_queue::add_completed_job_pre_shaped_failure(
|
||||
let (downstream_result, span): (Arc<Box<RawValue>>, _) = if is_pre_shaped_wm_failure_result(
|
||||
result.get(),
|
||||
) {
|
||||
let span = windmill_queue::add_completed_job_pre_shaped_failure(
|
||||
db,
|
||||
&job,
|
||||
mem_peak.to_owned(),
|
||||
@@ -1880,9 +1884,9 @@ pub async fn process_completed_job(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
result.clone()
|
||||
(result.clone(), span)
|
||||
} else {
|
||||
let wrapped = add_completed_job_error(
|
||||
let (wrapped, span) = windmill_queue::add_completed_job_error_with_span(
|
||||
db,
|
||||
&job,
|
||||
mem_peak.to_owned(),
|
||||
@@ -1895,7 +1899,10 @@ pub async fn process_completed_job(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Arc::new(serde_json::value::to_raw_value(&wrapped).unwrap())
|
||||
(
|
||||
Arc::new(serde_json::value::to_raw_value(&wrapped).unwrap()),
|
||||
span,
|
||||
)
|
||||
};
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
if job.kind == JobKind::DeploymentCallback {
|
||||
@@ -1927,12 +1934,19 @@ pub async fn process_completed_job(
|
||||
false,
|
||||
canceled_by,
|
||||
downstream_result,
|
||||
duration.and_then(|d| {
|
||||
job.started_at.map(|started_at| FlowJobDuration {
|
||||
started_at: started_at,
|
||||
duration_ms: d,
|
||||
if span.outlasts_run(job.started_at) {
|
||||
span.started_at.map(|started_at| FlowJobDuration {
|
||||
started_at,
|
||||
duration_ms: span.duration_ms,
|
||||
})
|
||||
}),
|
||||
} else {
|
||||
duration.and_then(|d| {
|
||||
job.started_at.map(|started_at| FlowJobDuration {
|
||||
started_at: started_at,
|
||||
duration_ms: d,
|
||||
})
|
||||
})
|
||||
},
|
||||
StepFailureKind::Normal,
|
||||
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
|
||||
&worker_dir,
|
||||
|
||||
@@ -113,6 +113,11 @@ pub enum WacPark {
|
||||
/// handler — falls back to `now() - started_at`. Left pointing at the first segment,
|
||||
/// that fallback reports the whole sleep or approval wait as execution time.
|
||||
///
|
||||
/// The first park keeps that first start in `extras.wac_first_started_at`, so the
|
||||
/// completed row can still say when the workflow began and how long it took overall
|
||||
/// (`Completion::execute`). The queue row's `started_at` stays per segment. The inline
|
||||
/// checkpoint requeue clears `started_at` on its own and keeps the first start the same way.
|
||||
///
|
||||
/// Call it before any write to the parent's `v2_job_status` row in the same
|
||||
/// transaction: a child's completion locks the queue row and then the status row
|
||||
/// (`record_child_completion`), and taking them the other way round here can
|
||||
@@ -156,7 +161,10 @@ pub async fn suspend_wac_parent(
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue
|
||||
SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null
|
||||
SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null,
|
||||
extras = CASE WHEN started_at IS NULL OR extras ? 'wac_first_started_at' THEN extras
|
||||
ELSE jsonb_set(COALESCE(extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(started_at))
|
||||
END
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
job_id,
|
||||
w_id,
|
||||
|
||||
@@ -155,7 +155,7 @@ use crate::{
|
||||
handle_app_dependency_job, handle_dependency_job, handle_flow_dependency_job,
|
||||
tally_unfinished_dependency_deploy,
|
||||
},
|
||||
worker_utils::{insert_ping, queue_vacuum, update_worker_ping_full},
|
||||
worker_utils::{insert_ping, mark_worker_draining, queue_vacuum, update_worker_ping_full},
|
||||
};
|
||||
|
||||
#[cfg(feature = "rust")]
|
||||
@@ -2713,7 +2713,7 @@ pub async fn run_worker(
|
||||
let mut last_ping = Instant::now() - Duration::from_secs(NUM_SECS_PING + 1);
|
||||
|
||||
let mut reported_ip = cached_ip();
|
||||
let previous_jobs_executed = insert_ping(hostname, &worker_name, reported_ip, conn)
|
||||
let previous_jobs_executed = insert_ping(hostname, &worker_name, reported_ip, true, conn)
|
||||
.await
|
||||
.expect("initial ping could be sent");
|
||||
|
||||
@@ -3160,6 +3160,31 @@ pub async fn run_worker(
|
||||
|
||||
let mut killpill_rx2 = killpill_rx.resubscribe();
|
||||
|
||||
// The loop below only sees the killpill once the running job is done, which is too late
|
||||
// for that job to learn its worker is going away.
|
||||
{
|
||||
let mut killpill_rx = killpill_rx.resubscribe();
|
||||
let conn = conn.clone();
|
||||
let worker_name = worker_name.clone();
|
||||
tokio::spawn(async move {
|
||||
if matches!(
|
||||
killpill_rx.recv().await,
|
||||
Err(broadcast::error::RecvError::Closed)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for attempt in 1..=3 {
|
||||
match mark_worker_draining(&conn, &worker_name).await {
|
||||
Ok(()) => return,
|
||||
Err(e) => {
|
||||
tracing::warn!(worker = %worker_name, "failed to mark worker as draining (attempt {attempt}/3): {e:#}");
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loop {
|
||||
let last_processing_duration_secs = last_processing_duration.load(Ordering::SeqCst);
|
||||
if last_processing_duration_secs > 5 {
|
||||
@@ -3235,7 +3260,7 @@ pub async fn run_worker(
|
||||
// which costs at most the last job's id here: no job of this worker is in flight at this
|
||||
// point in the loop, and the next one refills them.
|
||||
if ip_just_resolved && conn.as_sql().is_none() {
|
||||
if let Err(e) = insert_ping(hostname, &worker_name, ip, &conn).await {
|
||||
if let Err(e) = insert_ping(hostname, &worker_name, ip, false, &conn).await {
|
||||
tracing::warn!(
|
||||
worker = %worker_name, hostname = %hostname,
|
||||
"failed to re-register with the resolved external IP: {e}"
|
||||
@@ -5105,6 +5130,7 @@ pub async fn handle_queued_job(
|
||||
// WAC v2 job suspended while waiting for child jobs — don't complete it
|
||||
return Ok(JobOutcome::Completed);
|
||||
}
|
||||
crate::resource_metrics::record_job_memory_peak(&cjob.tag, mem_peak);
|
||||
process_result(
|
||||
cjob,
|
||||
result.map(|x| Arc::new(x)),
|
||||
|
||||
@@ -2097,7 +2097,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
)
|
||||
.await?;
|
||||
let duration = if success {
|
||||
let (_, duration) = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
&cflow_job,
|
||||
true,
|
||||
@@ -2111,9 +2111,9 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
duration
|
||||
span.duration_ms
|
||||
} else {
|
||||
let (_, duration) = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
&cflow_job,
|
||||
false,
|
||||
@@ -2131,7 +2131,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
duration
|
||||
span.duration_ms
|
||||
};
|
||||
flow_job_duration = flow_job
|
||||
.started_at
|
||||
|
||||
@@ -7,9 +7,9 @@ use windmill_common::{
|
||||
external_ip::UNKNOWN_IP,
|
||||
worker::{
|
||||
get_memory, get_vcpus, get_windmill_memory_usage, get_worker_memory_usage,
|
||||
insert_ping_query, update_job_ping_query, update_worker_ping_from_job_query,
|
||||
update_worker_ping_main_loop_query, Connection, Ping, PingType, NATIVE_MODE_RESOLVED,
|
||||
WORKER_CONFIG, WORKER_GROUP,
|
||||
insert_ping_query, set_worker_draining_query, update_job_ping_query,
|
||||
update_worker_ping_from_job_query, update_worker_ping_main_loop_query, Connection, Ping,
|
||||
PingType, NATIVE_MODE_RESOLVED, WORKER_CONFIG, WORKER_GROUP,
|
||||
},
|
||||
KillpillSender, DB,
|
||||
};
|
||||
@@ -39,17 +39,26 @@ pub(crate) async fn update_worker_ping_full(
|
||||
let wm_memory_usage = get_windmill_memory_usage();
|
||||
|
||||
let (vcpus, memory) = if read_cgroups {
|
||||
(get_vcpus(), get_memory())
|
||||
let memory = get_memory();
|
||||
crate::resource_metrics::set_memory_limit(memory);
|
||||
(get_vcpus(), memory)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let occupancy = occupancy_metrics.update_occupancy_metrics();
|
||||
crate::resource_metrics::record_worker_resources(
|
||||
worker_name,
|
||||
memory_usage,
|
||||
wm_memory_usage,
|
||||
Some(&occupancy),
|
||||
);
|
||||
let OccupancyResult {
|
||||
occupancy_rate,
|
||||
occupancy_rate_15s,
|
||||
occupancy_rate_5m,
|
||||
occupancy_rate_30m,
|
||||
} = occupancy_metrics.update_occupancy_metrics();
|
||||
} = occupancy;
|
||||
|
||||
let ping_start = std::time::Instant::now();
|
||||
if let Err(e) = (|| {
|
||||
@@ -164,6 +173,7 @@ async fn update_worker_ping_full_inner(
|
||||
wm_memory_usage: get_windmill_memory_usage(),
|
||||
job_isolation: None,
|
||||
native_mode: Some(native_mode),
|
||||
draining: None,
|
||||
ping_type: PingType::MainLoop,
|
||||
},
|
||||
)
|
||||
@@ -179,6 +189,7 @@ pub async fn insert_ping(
|
||||
worker_instance: &str,
|
||||
worker_name: &str,
|
||||
ip: Option<&str>,
|
||||
reset_draining: bool,
|
||||
db: &Connection,
|
||||
) -> anyhow::Result<i32> {
|
||||
let (tags, dw, dws, native_mode) = {
|
||||
@@ -224,6 +235,7 @@ pub async fn insert_ping(
|
||||
memory,
|
||||
job_isolation,
|
||||
native_mode,
|
||||
reset_draining.then_some(false),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -256,6 +268,7 @@ pub async fn insert_ping(
|
||||
wm_memory_usage: get_windmill_memory_usage(),
|
||||
job_isolation,
|
||||
native_mode: Some(native_mode),
|
||||
draining: reset_draining.then_some(false),
|
||||
ping_type: PingType::Initial,
|
||||
},
|
||||
)
|
||||
@@ -314,6 +327,7 @@ pub async fn update_worker_ping_from_job(
|
||||
&Ping {
|
||||
last_job_executed: Some(job_id.clone()),
|
||||
last_job_workspace_id: Some(w_id.to_string()),
|
||||
draining: None,
|
||||
ping_type: PingType::Job,
|
||||
worker_instance: None,
|
||||
ip: None,
|
||||
@@ -342,6 +356,45 @@ pub async fn update_worker_ping_from_job(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publishes on the worker's ping row that it has received its killpill, so the job it is
|
||||
/// still running can find out and exit early.
|
||||
pub async fn mark_worker_draining(conn: &Connection, worker_name: &str) -> anyhow::Result<()> {
|
||||
match conn {
|
||||
Connection::Sql(db) => set_worker_draining_query(worker_name, db).await,
|
||||
Connection::Http(client) => {
|
||||
client
|
||||
.post::<Ping, ()>(
|
||||
UPDATE_PING_URL,
|
||||
None,
|
||||
&Ping {
|
||||
last_job_executed: None,
|
||||
last_job_workspace_id: None,
|
||||
draining: None,
|
||||
ping_type: PingType::Draining,
|
||||
worker_instance: None,
|
||||
ip: None,
|
||||
tags: None,
|
||||
dw: None,
|
||||
dws: None,
|
||||
version: None,
|
||||
vcpus: None,
|
||||
memory: None,
|
||||
memory_usage: None,
|
||||
wm_memory_usage: None,
|
||||
jobs_executed: None,
|
||||
occupancy_rate: None,
|
||||
occupancy_rate_15s: None,
|
||||
occupancy_rate_5m: None,
|
||||
occupancy_rate_30m: None,
|
||||
job_isolation: None,
|
||||
native_mode: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ping_job_status(
|
||||
conn: &Connection,
|
||||
job_id: &Uuid,
|
||||
|
||||
+1
-1
@@ -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.827.0";
|
||||
export const VERSION = "v1.828.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-chat",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "^4.0.102",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.827.0";
|
||||
export const VERSION = "1.828.0";
|
||||
|
||||
Generated
+76
-2
@@ -843,6 +843,14 @@ async setProgress(percent: number, jobId?: any): Promise<void>
|
||||
*/
|
||||
async getProgress(jobId?: any): Promise<number | null>
|
||||
|
||||
/**
|
||||
* Whether the worker running this job has received its shutdown signal.
|
||||
* A draining worker waits for its current job to finish before exiting, so a
|
||||
* long-running script can poll this and return early to let the worker be replaced.
|
||||
* @param jobId? Job to check, defaults to the current job
|
||||
*/
|
||||
async workerIsDraining(jobId?: string): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Set a flow user state
|
||||
* @param key key of the state
|
||||
@@ -1679,6 +1687,14 @@ async setProgress(percent: number, jobId?: any): Promise<void>
|
||||
*/
|
||||
async getProgress(jobId?: any): Promise<number | null>
|
||||
|
||||
/**
|
||||
* Whether the worker running this job has received its shutdown signal.
|
||||
* A draining worker waits for its current job to finish before exiting, so a
|
||||
* long-running script can poll this and return early to let the worker be replaced.
|
||||
* @param jobId? Job to check, defaults to the current job
|
||||
*/
|
||||
async workerIsDraining(jobId?: string): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Set a flow user state
|
||||
* @param key key of the state
|
||||
@@ -2622,6 +2638,14 @@ async setProgress(percent: number, jobId?: any): Promise<void>
|
||||
*/
|
||||
async getProgress(jobId?: any): Promise<number | null>
|
||||
|
||||
/**
|
||||
* Whether the worker running this job has received its shutdown signal.
|
||||
* A draining worker waits for its current job to finish before exiting, so a
|
||||
* long-running script can poll this and return early to let the worker be replaced.
|
||||
* @param jobId? Job to check, defaults to the current job
|
||||
*/
|
||||
async workerIsDraining(jobId?: string): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Set a flow user state
|
||||
* @param key key of the state
|
||||
@@ -4637,6 +4661,18 @@ def set_progress(value: int, job_id: Optional[str] = None)
|
||||
# Progress value (0-100) or None if not set
|
||||
def get_progress(job_id: Optional[str] = None) -> Any
|
||||
|
||||
# Whether the worker running the job has received its shutdown signal.
|
||||
#
|
||||
# A draining worker waits for its current job to finish before exiting. A long-running
|
||||
# script can poll this and return early so the worker can be replaced.
|
||||
#
|
||||
# Args:
|
||||
# job_id: Job ID (defaults to current WM_JOB_ID)
|
||||
#
|
||||
# Returns:
|
||||
# True if the worker running the job is draining
|
||||
def worker_is_draining(job_id: Optional[str] = None) -> bool
|
||||
|
||||
# Set the user state of a flow at a given key
|
||||
def set_flow_user_state(key: str, value: Any) -> None
|
||||
|
||||
@@ -7915,10 +7951,48 @@ A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates step
|
||||
A pipeline node that produces a table should almost always be a **\`duckdb\`** node that materializes its output into a **DuckLake** table with \`-- materialize ducklake://<name>/<table>\` (in a DuckDB node the annotation uses SQL \`--\` comment syntax; write the body as a bare \`SELECT\` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else:
|
||||
|
||||
- \`postgresql\` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live).
|
||||
- \`bun\` / \`python3\` — only for non-tabular work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node still produces tabular data for downstream steps, land it in DuckLake (write it with the wmill SDK / ducklake helpers) rather than inventing a parallel store.
|
||||
- \`bun\` / \`python3\` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — rather than inventing a parallel store.
|
||||
|
||||
Do not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal.
|
||||
|
||||
## Ingesting from Python / TypeScript
|
||||
|
||||
A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with \`wmill.ducklake()\`, or into a data table with \`wmill.datatable()\`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table.
|
||||
|
||||
Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema:
|
||||
|
||||
\`\`\`python
|
||||
# pipeline
|
||||
# on schedule
|
||||
import wmill
|
||||
|
||||
def main():
|
||||
rows = fetch_charges() # list[dict]
|
||||
lake = wmill.ducklake()
|
||||
lake.query("""
|
||||
CREATE OR REPLACE TABLE stripe_charges AS
|
||||
SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)
|
||||
""", rows=rows).fetch()
|
||||
\`\`\`
|
||||
|
||||
\`\`\`ts
|
||||
// pipeline
|
||||
// on schedule
|
||||
import * as wmill from "windmill-client"
|
||||
|
||||
export async function main() {
|
||||
const rows = await fetchCharges()
|
||||
const lake = wmill.ducklake()
|
||||
await lake\`CREATE OR REPLACE TABLE stripe_charges AS
|
||||
SELECT unnest(from_json(\${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)\`.fetch()
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- Use \`INSERT INTO <table> SELECT ...\` instead of \`CREATE OR REPLACE\` to append (an incremental pull), with a one-time \`CREATE TABLE IF NOT EXISTS\` first.
|
||||
- Assign the client to a variable (\`lake = wmill.ducklake()\`) and keep the SQL a **string literal** (Python) or a template without \`sql.raw\` (TS): that is what lets the graph record the node's write to \`ducklake://main/<table>\`. A chained \`wmill.ducklake().query(...)\` or SQL built in a variable records no output, so the downstream edge never forms.
|
||||
- \`wmill.ducklake("<name>")\` targets a non-default catalog; \`wmill.datatable("<name>")\` writes a Postgres data table the same way (Python \`db.query(sql, *args)\` with \`$1\` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above).
|
||||
- The downstream node triggers on the table itself: \`-- on ducklake://main/stripe_charges\`.
|
||||
|
||||
## Storage prerequisites
|
||||
|
||||
A DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and \`s3://\` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build.
|
||||
@@ -7962,7 +8036,7 @@ The key must be a **string literal** — the graph parser is static and cannot f
|
||||
|
||||
## Materialize (the managed output)
|
||||
|
||||
> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake://<name>/<table>\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the output via the SDK (\`wmill.writeS3File(...)\`, ducklake helpers, …) and let it be inferred. Use \`duckdb\` when a node should materialize a DuckLake table.
|
||||
> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake://<name>/<table>\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the table itself with \`wmill.ducklake()\` (see "Ingesting from Python / TypeScript") and let the output be inferred. Use \`duckdb\` when a node should materialize a DuckLake table.
|
||||
>
|
||||
> The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: \`// materialize manual dbt://<warehouse>/<schema>/<name>\`, where \`<warehouse>\` is a warehouse the workspace configures under Settings → dbt. \`manual\` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql \`CREATE TABLE\` / \`INSERT\`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a \`source\`: the declared relation and the dbt model land on ONE graph node, and a downstream \`// on dbt://<warehouse>/<schema>/<name>\` fires when the ingestion node completes.
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ makes the client own the whole cascade so the backend dispatcher never double-fi
|
||||
- `frontend/src/lib/components/assets/AssetGraph/cascadeRun.ts` — reusable run primitives
|
||||
(`makeLaunch`, `makeWaitJobTerminal`, `runDownstreamCascade`, `runBoundedCascade`) wrapping
|
||||
`cascadeOrchestrator` + `graphTraversal`.
|
||||
- `frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte` + `frontend/src/routes/pipeline_dev/+page.svelte`.
|
||||
- `frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte` + `frontend/src/routes/(styled)/pipeline_dev/+page.svelte`.
|
||||
|
||||
**Modified**
|
||||
- `cli/src/commands/pipeline/pipeline.ts` — `--local` on `show`/`run`; `show` split into graph
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
fill: #3b82f6;
|
||||
}
|
||||
|
||||
/* Public app pages render their shell before app.css loads, if at all. */
|
||||
html[data-wm-public-app] #svelte-global-loader {
|
||||
display: none;
|
||||
}
|
||||
html[data-wm-public-app] body {
|
||||
margin: 0;
|
||||
}
|
||||
html[data-wm-public-app='dark'] body {
|
||||
background-color: #3b4252;
|
||||
}
|
||||
|
||||
/* Background color for dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#mainbg {
|
||||
@@ -123,6 +134,64 @@
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<script>
|
||||
// Public app pages (`/public/<ws>/<secret>`, `/a/<custom path>`): skip the loading
|
||||
// screen, and start the API calls the page makes first so they overlap the module
|
||||
// download instead of following it. `publicAppApi.ts` takes these responses by URL,
|
||||
// so each URL here must be exactly what it requests. Only the cookie credential is
|
||||
// prefetched: a share link carrying a JWT, or the opaque viewer frame (`wm_embed`),
|
||||
// sends its own and fetches normally. The bundle of an unsandboxed raw app is
|
||||
// requested as soon as its secret is known, for the iframe to find in the HTTP cache.
|
||||
;(function () {
|
||||
var path = location.pathname
|
||||
var pub = /^\/public\/([^/]+)\/([^/]+)\/?$/.exec(path)
|
||||
var custom = /^\/a\/(.+?)\/?$/.exec(path)
|
||||
if (!pub && !custom && !/^\/public\//.test(path)) return
|
||||
// Dark as PublicApp decides it, painted before any stylesheet so that a dark app
|
||||
// does not load over a white page.
|
||||
var dark = false
|
||||
try {
|
||||
var mode = localStorage.getItem('dark-mode')
|
||||
dark = mode ? mode === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches
|
||||
} catch (_) {}
|
||||
document.documentElement.setAttribute('data-wm-public-app', dark ? 'dark' : '')
|
||||
if (new URLSearchParams(location.search).get('wm_embed') === '1') return
|
||||
var prefetched = (window.__wmPublicAppPrefetch = {})
|
||||
function get(url) {
|
||||
var res = fetch(url)
|
||||
res.catch(function () {})
|
||||
prefetched[url] = res
|
||||
return res
|
||||
}
|
||||
function warmBundle(appRes, workspace) {
|
||||
appRes
|
||||
.then(function (r) {
|
||||
return r.ok ? r.clone().json() : null
|
||||
})
|
||||
.then(function (app) {
|
||||
if (!app || !app.raw_app || !app.bundle_secret || (app.policy || {}).sandbox) return
|
||||
var ws = workspace || app.workspace_id
|
||||
var v = '/api/w/' + ws + '/apps_u/get_data/v/' + app.bundle_secret
|
||||
fetch(v + '.css').catch(function () {})
|
||||
fetch(v + '.js').catch(function () {})
|
||||
})
|
||||
.catch(function () {})
|
||||
}
|
||||
if (pub) {
|
||||
var w = '/api/w/' + pub[1]
|
||||
get(w + '/apps_u/embed_token/' + pub[2])
|
||||
warmBundle(get(w + '/apps_u/public_app/' + pub[2]), pub[1])
|
||||
get(w + '/apps_u/guest_entry/' + pub[2])
|
||||
get(w + '/users/whoami')
|
||||
} else if (custom && custom[1].indexOf('.') === -1) {
|
||||
// A `.` marks a trailing JWT segment, which no custom path contains.
|
||||
get('/api/apps_u/embed_token_by_custom_path/' + custom[1])
|
||||
warmBundle(get('/api/apps_u/public_app_by_custom_path/' + custom[1]))
|
||||
get('/api/apps_u/guest_entry_by_custom_path/' + custom[1])
|
||||
}
|
||||
get('/api/ee_license')
|
||||
})()
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fromStore } from 'svelte/store'
|
||||
import { base } from '$lib/base'
|
||||
import { instanceUi } from '$lib/instanceUi'
|
||||
|
||||
const ui = fromStore(instanceUi)
|
||||
|
||||
/**
|
||||
* Base of every URL shown for an external client to call (webhooks, HTTP routes, push
|
||||
* trigger endpoints, the CLI remote): the `api_base_url` instance setting, else the
|
||||
* origin being browsed. Append `/api/...` to it.
|
||||
*
|
||||
* Not for URLs the editor itself calls or opens (captures, previews, OAuth popups, links
|
||||
* to pages): those must stay on the browsing origin, where the session cookie lives.
|
||||
*
|
||||
* Reactive: the setting lands after page load, so read it inside a `$derived` or the
|
||||
* template rather than caching the result.
|
||||
*/
|
||||
export function apiBaseUrl(): string {
|
||||
const setting = ui.current?.api_base_url
|
||||
const configured = typeof setting === 'string' ? setting.trim() : ''
|
||||
return configured || `${window.location.origin}${base}`
|
||||
}
|
||||
@@ -142,7 +142,13 @@
|
||||
let hintTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const kindLabel = $derived(
|
||||
itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script'
|
||||
itemKind === 'flow'
|
||||
? 'flow'
|
||||
: itemKind === 'app' || itemKind === 'raw_app'
|
||||
? 'app'
|
||||
: itemKind === 'data_pipeline'
|
||||
? 'pipeline'
|
||||
: 'script'
|
||||
)
|
||||
|
||||
// Each false → true transition fires a hint (others > loaded). Seeded to
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import ClipboardPanel from './details/ClipboardPanel.svelte'
|
||||
import Section from './Section.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { apiBaseUrl } from '$lib/apiBaseUrl.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let url = $derived(`${window.location.protocol}//${window.location.hostname}/`)
|
||||
let url = $derived(`${apiBaseUrl()}/`)
|
||||
</script>
|
||||
|
||||
<Section label="CLI quick setup">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
contentClasses="p-4"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="accent" size="xs" nonCaptureEvent={true} startIcon={{ icon: Clock }}>
|
||||
<Button variant="default" unifiedSize="sm" nonCaptureEvent={true} startIcon={{ icon: Clock }}>
|
||||
Cron builder
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
selectInputClass?: string
|
||||
class?: string
|
||||
onClear?: () => void
|
||||
/** Size the input to its content, like Path's folder picker. */
|
||||
useContentEditable?: boolean
|
||||
RightIcon?: any
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -27,7 +30,9 @@
|
||||
placeholder = undefined,
|
||||
selectInputClass = '',
|
||||
class: className = '',
|
||||
onClear = undefined
|
||||
onClear = undefined,
|
||||
useContentEditable = false,
|
||||
RightIcon = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let datatables = usePromise(() =>
|
||||
@@ -47,6 +52,8 @@
|
||||
{placeholder}
|
||||
inputClass={selectInputClass}
|
||||
{onClear}
|
||||
{useContentEditable}
|
||||
{RightIcon}
|
||||
/>
|
||||
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'datatable', path: value })}
|
||||
<ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'datatable', path: value }} />
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
selectInputClass?: string
|
||||
class?: string
|
||||
onClear?: () => void
|
||||
/** Size the input to its content, like Path's folder picker. */
|
||||
useContentEditable?: boolean
|
||||
RightIcon?: any
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -27,7 +30,9 @@
|
||||
placeholder = undefined,
|
||||
selectInputClass = '',
|
||||
class: className = '',
|
||||
onClear = undefined
|
||||
onClear = undefined,
|
||||
useContentEditable = false,
|
||||
RightIcon = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let ducklakes = usePromise(() =>
|
||||
@@ -45,6 +50,8 @@
|
||||
{placeholder}
|
||||
inputClass={selectInputClass}
|
||||
{onClear}
|
||||
{useContentEditable}
|
||||
{RightIcon}
|
||||
/>
|
||||
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'ducklake', path: value })}
|
||||
<ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'ducklake', path: value }} />
|
||||
|
||||
@@ -1523,6 +1523,9 @@
|
||||
} catch (e) {
|
||||
console.log('error initializing services', e)
|
||||
}
|
||||
// Unmounted while the services loaded: the container is detached, and
|
||||
// creating the editor on it throws.
|
||||
if (destroyed || !divEl?.isConnected) return
|
||||
|
||||
// vscode.languages.registerDefinitionProvider('*', {
|
||||
// provideDefinition(document, position, token) {
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
| 'powershell'
|
||||
| 'bunnative'
|
||||
| 'claudesandbox'
|
||||
| 'pisandbox'
|
||||
| 'wac_python'
|
||||
| 'wac_typescript'
|
||||
| 'ci_test_bun'
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
retainLinkedToolsScope
|
||||
} from './flows/linkedAgentToolsStore.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { AGENT_STEP_ID, agentStepLabel } from './flows/conversations/agentEditorChat'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
@@ -1238,6 +1239,8 @@
|
||||
id: string
|
||||
type: FlowModuleValue['type']
|
||||
suspend?: boolean
|
||||
/** Shown in place of the id. */
|
||||
label?: string
|
||||
}
|
||||
|
||||
function allModulesForTimeline(
|
||||
@@ -1250,7 +1253,8 @@
|
||||
({
|
||||
id: x.id,
|
||||
type: x.value.type,
|
||||
suspend: x.suspend != undefined
|
||||
suspend: x.suspend != undefined,
|
||||
label: x.id === AGENT_STEP_ID ? agentStepLabel(job?.script_path) : undefined
|
||||
}) as FlowModuleForTimeline,
|
||||
{
|
||||
skipToolNodes: true
|
||||
|
||||
@@ -179,12 +179,12 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#each flowModules as { id: k, type: typ, suspend: isSuspend } (k)}
|
||||
{#each flowModules as { id: k, type: typ, suspend: isSuspend, label } (k)}
|
||||
{@const subItems = items?.[k]?.filter((x) => x.created_at && x.started_at)}
|
||||
<div class="relative px-3 py-1.5">
|
||||
<div class="flex items-center justify-between mb-0.5">
|
||||
<div class="text-xs font-medium"
|
||||
>{k.startsWith('subflow:') ? k.substring(8) : k}
|
||||
>{label ?? (k.startsWith('subflow:') ? k.substring(8) : k)}
|
||||
{#if localModuleStates[k]?.selectedForloop && (typ == 'forloopflow' || typ == 'whileloopflow')}
|
||||
<span class="text-xs font-mono font-medium inline-flex items-center -my-2">
|
||||
<button onclick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { isWindmillTooBigObject } from './job_args'
|
||||
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
|
||||
import { appendViewToken } from '$lib/viewToken'
|
||||
import { apiBaseUrl } from '$lib/apiBaseUrl.svelte'
|
||||
|
||||
interface Props {
|
||||
id?: string | undefined
|
||||
@@ -246,7 +247,7 @@ ${Object.entries(displayArgs)
|
||||
If using the wmill client in your code, set the following env variables:
|
||||
<pre
|
||||
><code
|
||||
>BASE_URL="{window.location.origin}"
|
||||
>BASE_URL="{apiBaseUrl()}"
|
||||
WM_TOKEN="{'<TOKEN>'}"</code
|
||||
></pre
|
||||
>
|
||||
|
||||
@@ -396,6 +396,15 @@ not this component) — so the lead can run the whole line.
|
||||
onPick={() => {}}
|
||||
/>
|
||||
{:else if section}
|
||||
{#if section.parent}
|
||||
<!-- Gives width back with the workspace part, before the section's own name does. -->
|
||||
<span
|
||||
class={twMerge(SEGMENT, LEAD, 'shrink-[999] hover:bg-transparent hover:text-secondary')}
|
||||
>
|
||||
<span class="truncate">{section.parent}</span>
|
||||
</span>
|
||||
{@render slash()}
|
||||
{/if}
|
||||
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
|
||||
@@ -32,6 +32,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
|
||||
const item = $derived(content?.item)
|
||||
const section = $derived(content?.section)
|
||||
const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary))
|
||||
|
||||
// Below this the trail holds a tighter cap on the path it draws. The names in it truncate on
|
||||
// their own at any width (see NavBreadcrumb), so this is not a breakpoint the look changes at —
|
||||
@@ -135,8 +136,18 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
where the user is.
|
||||
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
|
||||
the trail would offer to navigate the host's workspace, while the name is what says which
|
||||
page the controls beside it belong to. -->
|
||||
<div class="flex shrink-[0.3] min-w-[5rem]">
|
||||
page the controls beside it belong to.
|
||||
The 0.3 only holds against a summary or an actions box that shrinks too: alone, a factor
|
||||
sum below 1 makes flex hand out only that share of the overflow, and the bar overflows
|
||||
instead. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex shrink-[0.3] min-w-[5rem]',
|
||||
!hasSummary &&
|
||||
(actions.length === 0 || (phone && !content?.actionsFlexible && !content?.actionsFill)) &&
|
||||
'shrink'
|
||||
)}
|
||||
>
|
||||
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
|
||||
the tree that named it, and the rename editor in there asks that tree who the acting user
|
||||
is before it offers to rename anything. -->
|
||||
@@ -161,7 +172,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
|
||||
{#if item && (item.summaryContent || item.summary)}
|
||||
{#if item && hasSummary}
|
||||
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
|
||||
editor tells the reader what they can already see. -->
|
||||
<div class={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
|
||||
|
||||
@@ -596,6 +596,7 @@
|
||||
| 'powershell'
|
||||
| 'bunnative'
|
||||
| 'claudesandbox'
|
||||
| 'pisandbox'
|
||||
| 'wac_python'
|
||||
| 'wac_typescript'
|
||||
| 'ci_test_bun'
|
||||
@@ -1543,10 +1544,12 @@
|
||||
(lang == script.language &&
|
||||
template != 'bunnative' &&
|
||||
template != 'docker' &&
|
||||
template != 'claudesandbox') ||
|
||||
template != 'claudesandbox' &&
|
||||
template != 'pisandbox') ||
|
||||
(template == 'bunnative' && lang == 'bunnative') ||
|
||||
(template == 'docker' && lang == 'docker') ||
|
||||
(template == 'claudesandbox' && lang == 'bun')}
|
||||
((template == 'claudesandbox' || template == 'pisandbox') &&
|
||||
lang == 'bun')}
|
||||
<Popover
|
||||
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
|
||||
>
|
||||
@@ -1597,6 +1600,21 @@
|
||||
>
|
||||
Claude Sandbox
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
startIcon={{
|
||||
icon: LanguageIcon,
|
||||
props: { lang: 'pisandbox', width: 16, height: 16 }
|
||||
} as ButtonType.Icon}
|
||||
onclick={() => {
|
||||
template = 'pisandbox'
|
||||
script.language = 'bun'
|
||||
initContent('bun', script.kind, template)
|
||||
}}
|
||||
>
|
||||
Pi Sandbox
|
||||
</Button>
|
||||
<span title="Workflow-as-Code">
|
||||
<Button
|
||||
size="xs2"
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
| 'powershell'
|
||||
| 'bunnative'
|
||||
| 'claudesandbox'
|
||||
| 'pisandbox'
|
||||
| 'wac_python'
|
||||
| 'wac_typescript'
|
||||
| 'ci_test_bun'
|
||||
@@ -2100,43 +2101,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if customUi?.previewPanel?.hideArgs}
|
||||
<!-- Compact preview layout used by the pipeline editor:
|
||||
no args column (the script is known to take no
|
||||
inputs), LogPanel takes the full width, with a
|
||||
small Test/Cancel button at the top-left of the
|
||||
preview band. The earlier `-translate-y-1/2`
|
||||
"float onto the editor" version was clipped in
|
||||
Firefox (transform + overflow: visible interaction
|
||||
on the parent splitpane Pane), so the button is
|
||||
now positioned inside the panel — `top-1` keeps
|
||||
it visually pinned to the top edge without
|
||||
relying on cross-browser overflow behaviour. -->
|
||||
<div class="relative h-full pt-9 flex flex-col">
|
||||
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
|
||||
<!-- Right-side affordances when we're displaying a *completed*
|
||||
job (either the user just ran a test, or the on-mount
|
||||
last-run loader populated the panel). The job-id link
|
||||
opens the full run page in a new tab; the dispatch
|
||||
button mounts a popover that shows what downstream
|
||||
jobs this run triggered (rendered only when the run
|
||||
actually dispatched anything). -->
|
||||
<div class="absolute top-1 right-2 z-10 flex items-center gap-2">
|
||||
<a
|
||||
class="text-3xs text-blue-600 hover:underline font-mono"
|
||||
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open this run"
|
||||
>
|
||||
{testJob.id.slice(0, 8)}… ↗
|
||||
</a>
|
||||
<DispatchEventsButton
|
||||
workspace={testJob.workspace_id ?? opWs}
|
||||
jobId={testJob.id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute top-1 left-2 z-10">
|
||||
<!-- Compact preview layout used by the pipeline editor: no args
|
||||
column (most pipeline scripts take no inputs) and a full-width
|
||||
LogPanel. The controls sit in normal flow rather than floating
|
||||
onto the editor band, which Firefox clipped (transform +
|
||||
overflow: visible on the parent splitpane Pane). -->
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Test sits at the top right, the last run's link under it. -->
|
||||
<div class="shrink-0 flex flex-col items-end gap-1 px-2 pt-2 pb-1">
|
||||
<div>
|
||||
{#if testIsLoading}
|
||||
{@render cancelTestButton('sm', 'shadow-md')}
|
||||
{:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 || customUi?.previewPanel?.onBoundedRun}
|
||||
@@ -2270,6 +2243,31 @@
|
||||
{@render runTestButton('sm', 'shadow-md')}
|
||||
{/if}
|
||||
</div>
|
||||
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
|
||||
<!-- Right-side affordances when we're displaying a *completed*
|
||||
job (either the user just ran a test, or the on-mount
|
||||
last-run loader populated the panel). The job-id link
|
||||
opens the full run page in a new tab; the dispatch
|
||||
button mounts a popover that shows what downstream
|
||||
jobs this run triggered (rendered only when the run
|
||||
actually dispatched anything). -->
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
class="text-3xs text-blue-600 hover:underline font-mono"
|
||||
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open this run"
|
||||
>
|
||||
{testJob.id.slice(0, 8)}… ↗
|
||||
</a>
|
||||
<DispatchEventsButton
|
||||
workspace={testJob.workspace_id ?? opWs}
|
||||
jobId={testJob.id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0}
|
||||
<div
|
||||
class="shrink-0 overflow-auto max-h-[45%] border-b border-gray-200 dark:border-gray-700 px-4 py-3"
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
/** What is being renamed. An agent is a resource, so the path field is scoped to one and
|
||||
* the write goes through the agent branch of the rename manager. */
|
||||
kind?: 'flow' | 'script' | 'agent'
|
||||
/** Saves instead of the API, for an item that exists only locally (a draft).
|
||||
* Returns an error message to keep the popover open. */
|
||||
saveOverride?: (next: {
|
||||
path: string
|
||||
summary: string
|
||||
labels: string[] | undefined
|
||||
}) => string | undefined
|
||||
/** Header variant: no path line (the host shows it) and a lighter summary. */
|
||||
compact?: boolean
|
||||
/** Rendered inside the trigger in place of the summary, for a host that hangs a second copy
|
||||
@@ -51,6 +58,7 @@
|
||||
editable = false,
|
||||
onSaved,
|
||||
kind = 'flow',
|
||||
saveOverride = undefined,
|
||||
compact = false,
|
||||
label,
|
||||
triggerClass = 'block min-w-0 max-w-full px-1 py-0.5 rounded text-left cursor-pointer hover:bg-surface-hover transition-colors',
|
||||
@@ -67,7 +75,9 @@
|
||||
let popoverOpen = $state(false)
|
||||
// Resolved before the popover opens, not on opening it: the path field only exists for an owner,
|
||||
// and `openFocus` looks for it in the same flush the popover renders in.
|
||||
const own = $derived(isOwner(path ?? '', actingUser, $operatingWorkspace))
|
||||
const own = $derived(
|
||||
saveOverride !== undefined || isOwner(path ?? '', actingUser, $operatingWorkspace)
|
||||
)
|
||||
let onBehalfOfEmail = $state<string | undefined>(undefined)
|
||||
let summaryInput: ReturnType<typeof TextInput> | undefined = $state()
|
||||
let labelsDirty = $state(false)
|
||||
@@ -101,6 +111,17 @@
|
||||
async function save(close: () => void) {
|
||||
const initialPath = path ?? ''
|
||||
const newPath = own ? editPath : initialPath
|
||||
if (saveOverride) {
|
||||
const error = saveOverride({ path: newPath, summary: editSummary, labels })
|
||||
if (error) {
|
||||
sendUserToast(error, true)
|
||||
return
|
||||
}
|
||||
labelsDirty = false
|
||||
close()
|
||||
onSaved?.(newPath)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateItemPathAndSummary({
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs bind:selected={usersSubTab} class="mb-4">
|
||||
<Tabs bind:selected={usersSubTab} class="mb-4" wrapperClass="shrink-0">
|
||||
<Tab value="users" label="Users" />
|
||||
{#if extJwtTokens.length > 0}
|
||||
<Tab value="ext_jwt" label="External JWTs" />
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
import TagList from './TagList.svelte'
|
||||
import DedicatedWorkersSelector from './DedicatedWorkersSelector.svelte'
|
||||
import { computeHashedTag } from './dedicated_worker'
|
||||
import { apiBaseUrl } from '$lib/apiBaseUrl.svelte'
|
||||
import ObjectStoreConfigSettings, {
|
||||
type ObjectStoreConfig
|
||||
} from './ObjectStoreConfigSettings.svelte'
|
||||
@@ -1238,7 +1239,7 @@
|
||||
<p class="text-xs text-tertiary">Requires a superadmin token.</p>
|
||||
<pre
|
||||
class="mt-1 p-2 bg-surface-secondary rounded text-xs overflow-x-auto whitespace-pre-wrap break-all"
|
||||
>curl -X POST '{window.location.origin}/api/configs/update/worker__{name}' \
|
||||
>curl -X POST '{apiBaseUrl()}/api/configs/update/worker__{name}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-d '{JSON.stringify(cleanWorkerGroupConfig(nconfig ?? {}), null, 2)}'</pre
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { AppService, OpenAPI } from '$lib/gen'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { userStore, type UserExt } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { ExternalLink, Pen } from 'lucide-svelte'
|
||||
@@ -251,7 +251,7 @@
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
user={appUser}
|
||||
user={appUser ?? $userStore}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { applyDarkModeVariant } from '$lib/darkModeVariant'
|
||||
import { enterpriseLicense, userStore, type UserExt } from '$lib/stores'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/state'
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
|
||||
import { getContext, setContext } from 'svelte'
|
||||
import {
|
||||
EMBED_NAV_CONTEXT_KEY,
|
||||
IS_APP_PUBLIC_CONTEXT_KEY,
|
||||
type EditorBreakpoint,
|
||||
type EmbedNav
|
||||
} from '../types'
|
||||
import { UserService, type AppWithLastVersion, type GlobalWhoamiResponse } from '$lib/gen'
|
||||
import { urlParamsToObject } from '$lib/utils'
|
||||
import { goto } from '$app/navigation'
|
||||
import { loadAppPreview } from './loadAppPreview'
|
||||
import { setContext } from 'svelte'
|
||||
import { IS_APP_PUBLIC_CONTEXT_KEY } from '../types'
|
||||
import type { AppWithLastVersion } from '$lib/gen/types.gen'
|
||||
import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
|
||||
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { writable } from 'svelte/store'
|
||||
import { loadAppCss } from './publicAppApi'
|
||||
|
||||
let {
|
||||
notExists,
|
||||
@@ -33,6 +20,7 @@
|
||||
app,
|
||||
workspace,
|
||||
user = undefined,
|
||||
license = undefined,
|
||||
inWorkspace = false,
|
||||
hideRefreshBar = false,
|
||||
syncHashToUrl = true
|
||||
@@ -46,11 +34,13 @@
|
||||
onLoginSuccess: () => void
|
||||
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
|
||||
workspace: string | undefined
|
||||
/** The viewer's membership in `workspace`, for the app's `ctx`. Without it `ctx` falls
|
||||
* back to `userStore`, which describes the workspace the page is navigated to — the
|
||||
* same one on every standalone viewer route, but not inside an AI session's preview
|
||||
* tab, where the app belongs to the session's workspace. */
|
||||
/** The viewer's membership in `workspace`, for the app's `ctx`. Callers pass it
|
||||
* explicitly — `$userStore` describes the workspace the page is navigated to, which
|
||||
* is not the app's inside an AI session's preview tab. */
|
||||
user?: UserExt | undefined
|
||||
/** The instance license, which fades the "Powered by" badge and lets a low-code app's
|
||||
* custom CSS apply. */
|
||||
license?: string | undefined
|
||||
/**
|
||||
* In-workspace rendering (`/apps/get`, `/app_embed`): keep exact parity
|
||||
* with the pre-sandbox member viewer — no "Powered by Windmill" badge, no
|
||||
@@ -65,23 +55,11 @@
|
||||
// Use workspace from props or from app.workspace_id (for custom path responses)
|
||||
let effectiveWorkspace = $derived(workspace ?? app?.workspace_id)
|
||||
|
||||
// `ctx.username` / `ctx.groups` are per-workspace, and they sit next to `ctx.workspace`
|
||||
// in the same object, so they have to come from the same workspace it names.
|
||||
let ctxUser = $derived(user ?? $userStore)
|
||||
|
||||
// On the public surfaces (untrusted distribution) runnable-authored html/svg needs
|
||||
// the viewer's approval before it renders, unless the app sandbox isolates it. The
|
||||
// in-workspace viewer renders it verbatim. See getAppMarkupTrust.
|
||||
setContext(IS_APP_PUBLIC_CONTEXT_KEY, !inWorkspace)
|
||||
|
||||
// WIN-2006: inside the opaque viewer iframe, navigations to other routes
|
||||
// (navbar "app" items) must happen on the TOP page — the iframe is cookieless,
|
||||
// so navigating it would just show a login screen. PublicAppFrame provides the
|
||||
// relay; outside the opaque viewer this is undefined and goto works directly.
|
||||
const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY)
|
||||
|
||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||
|
||||
const darkMode =
|
||||
window.localStorage.getItem('dark-mode') ??
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
@@ -93,84 +71,46 @@
|
||||
}
|
||||
// This route bypasses the (root) layout, so restore the variant class too.
|
||||
applyDarkModeVariant()
|
||||
|
||||
let globalUser = $state<GlobalWhoamiResponse | undefined>(undefined)
|
||||
async function loadGlobalUser() {
|
||||
try {
|
||||
globalUser = await UserService.globalWhoami()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
// const user = await fetch('/api/global/user')
|
||||
// console.log(user)
|
||||
}
|
||||
|
||||
// Only the no-access page reads it, to tell a signed-in non-member which
|
||||
// workspace the app belongs to.
|
||||
$effect(() => {
|
||||
if (noPermission && !guestAppPath && !$userStore && !globalUser) {
|
||||
loadGlobalUser()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Only the raw-app branch is imported statically: everything else waits for app.css and
|
||||
its own chunk, see publicAppApi.ts. -->
|
||||
{#if !inWorkspace}
|
||||
<div
|
||||
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
|
||||
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
|
||||
: ''}"
|
||||
>
|
||||
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
|
||||
>Powered by <WindmillIcon /> Windmill</a
|
||||
{#await loadAppCss() then}
|
||||
<div
|
||||
class="z-50 text-xs fixed bottom-1 right-2 {license && !isCloudHosted()
|
||||
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
|
||||
: ''}"
|
||||
>
|
||||
</div>
|
||||
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
|
||||
>Powered by <WindmillIcon /> Windmill</a
|
||||
>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
{#if notExists}
|
||||
<div class="px-4 mt-20"
|
||||
><Alert type="error" title="Not found"
|
||||
>There was an error loading the app, is the url correct? <a href={base}>Go to Windmill</a>
|
||||
</Alert></div
|
||||
>
|
||||
{:else if noPermission}
|
||||
{#if guestAppPath && !$userStore}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl"> Sign in to open this app </div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
You do not need a Windmill account. Signing in lets you open this app and nothing else.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
This app requires read access
|
||||
</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
{#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace}
|
||||
You are logged in but are not a member of the workspace <span class="text-xl font-bold"
|
||||
>{effectiveWorkspace}</span
|
||||
> this app is part of
|
||||
{:else}You must be logged in and have read access to this app{/if}</div
|
||||
>
|
||||
{/if}
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
{#if !jwtError}
|
||||
<Login
|
||||
{onLoginSuccess}
|
||||
popup
|
||||
guestApp={guestAppPath}
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if notExists || noPermission}
|
||||
{#await Promise.all([loadAppCss(), import('./PublicAppAccess.svelte')]) then [, { default: PublicAppAccess }]}
|
||||
<PublicAppAccess
|
||||
{notExists}
|
||||
{jwtError}
|
||||
{guestAppPath}
|
||||
{user}
|
||||
workspace={effectiveWorkspace}
|
||||
{onLoginSuccess}
|
||||
/>
|
||||
{/await}
|
||||
{:else if app}
|
||||
{#key app}
|
||||
{#if app.raw_app && effectiveWorkspace}
|
||||
<!-- The bundle's iframe is `h-full`, so every host has to give this box a height to
|
||||
<!-- The bundle's iframe is full-height, so every host has to give this box a height to
|
||||
resolve against: in the workspace the layout hands one down, and the share, embed
|
||||
and custom-path routes floor themselves at the viewport. An iframe with no height
|
||||
above it falls back to 150px. -->
|
||||
<div class="h-full w-full">
|
||||
above it falls back to 150px. Styled inline: app.css may not be loaded here. -->
|
||||
<div style="height: 100%; width: 100%">
|
||||
<RawAppPreview
|
||||
workspace={effectiveWorkspace}
|
||||
user={ctxUser}
|
||||
{user}
|
||||
secret={app.bundle_secret}
|
||||
path={app.path}
|
||||
runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>}
|
||||
@@ -178,58 +118,28 @@
|
||||
/>
|
||||
</div>
|
||||
{:else if app.raw_app && !effectiveWorkspace}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Configuration error">
|
||||
Unable to load raw app: workspace information is missing.
|
||||
</Alert>
|
||||
</div>
|
||||
{#await Promise.all([loadAppCss(), import('$lib/components/common/alert/Alert.svelte')]) then [, { default: Alert }]}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Configuration error">
|
||||
Unable to load raw app: workspace information is missing.
|
||||
</Alert>
|
||||
</div>
|
||||
{/await}
|
||||
{:else}
|
||||
<div
|
||||
class={twMerge(
|
||||
// `flex-col` matches the pre-sandbox in-workspace viewer exactly;
|
||||
// the public viewer always used a plain `flex` wrapper. `min-h-full`, not
|
||||
// `h-full`: the box has to reach the bottom of what the host gives it so the
|
||||
// app's own background covers the window, and still grow with a grid taller
|
||||
// than that. A viewport floor instead would overhang the page box by the
|
||||
// height of the page header band.
|
||||
inWorkspace ? 'min-h-full w-full flex flex-col' : 'min-h-full w-full flex',
|
||||
app?.value?.['css']?.['app']?.['viewer']?.class,
|
||||
'wm-app-viewer'
|
||||
)}
|
||||
style={app?.value?.['css']?.['app']?.['viewer']?.style}
|
||||
>
|
||||
{#await loadAppPreview()}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
noBackend={false}
|
||||
{hideRefreshBar}
|
||||
context={{
|
||||
email: ctxUser?.email,
|
||||
name: ctxUser?.name,
|
||||
groups: ctxUser?.groups,
|
||||
username: ctxUser?.username,
|
||||
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
|
||||
hash: page.url.hash.substring(1)
|
||||
}}
|
||||
workspace={effectiveWorkspace}
|
||||
summary={app.summary}
|
||||
app={app.value}
|
||||
appPath={app.path}
|
||||
{breakpoint}
|
||||
policy={app.policy}
|
||||
isEditor={false}
|
||||
replaceStateFn={(path) => goto(path)}
|
||||
gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))}
|
||||
/>
|
||||
{:catch}
|
||||
<div class="px-4 mt-20 w-full">
|
||||
<Alert type="error" title="Could not load the app">Reload the page to try again.</Alert>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
{#await Promise.all([loadAppCss(), import('./PublicLowCodeApp.svelte')]) then [, { default: PublicLowCodeApp }]}
|
||||
<PublicLowCodeApp
|
||||
{app}
|
||||
workspace={effectiveWorkspace}
|
||||
{user}
|
||||
{license}
|
||||
{inWorkspace}
|
||||
{hideRefreshBar}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
{/key}
|
||||
{:else}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{#await loadAppCss() then}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/state'
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import { UserService, type GlobalWhoamiResponse } from '$lib/gen'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { dropPrefetched } from './publicAppApi'
|
||||
|
||||
let {
|
||||
notExists,
|
||||
jwtError,
|
||||
guestAppPath,
|
||||
user,
|
||||
workspace,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
notExists: boolean
|
||||
jwtError: boolean
|
||||
guestAppPath: string | undefined
|
||||
user: UserExt | undefined
|
||||
workspace: string | undefined
|
||||
onLoginSuccess: () => void
|
||||
} = $props()
|
||||
|
||||
let globalUser = $state<GlobalWhoamiResponse | undefined>(undefined)
|
||||
async function loadGlobalUser() {
|
||||
try {
|
||||
globalUser = await UserService.globalWhoami()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the no-access page reads it, to tell a signed-in non-member which
|
||||
// workspace the app belongs to.
|
||||
$effect(() => {
|
||||
if (!notExists && !guestAppPath && !user && !globalUser) {
|
||||
loadGlobalUser()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if notExists}
|
||||
<div class="px-4 mt-20"
|
||||
><Alert type="error" title="Not found"
|
||||
>There was an error loading the app, is the url correct? <a href={base}>Go to Windmill</a>
|
||||
</Alert></div
|
||||
>
|
||||
{:else}
|
||||
{#if guestAppPath && !user}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl"> Sign in to open this app </div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
You do not need a Windmill account. Signing in lets you open this app and nothing else.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
This app requires read access
|
||||
</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
{#if user}You are logged in but have no read access to this app{:else if globalUser && workspace}
|
||||
You are logged in but are not a member of the workspace <span class="text-xl font-bold"
|
||||
>{workspace}</span
|
||||
> this app is part of
|
||||
{:else}You must be logged in and have read access to this app{/if}</div
|
||||
>
|
||||
{/if}
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
{#if !jwtError}
|
||||
<Login
|
||||
onLoginSuccess={() => {
|
||||
dropPrefetched()
|
||||
onLoginSuccess()
|
||||
}}
|
||||
popup
|
||||
guestApp={guestAppPath}
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -23,24 +23,44 @@
|
||||
* bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`.
|
||||
*/
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { OpenAPI, UserService } from '$lib/gen'
|
||||
import { OpenAPI } from '$lib/gen/core/OpenAPI'
|
||||
import { page } from '$app/state'
|
||||
import { onDestroy, onMount, setContext, type Snippet } from 'svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$app/navigation'
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
|
||||
import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils/queryParams'
|
||||
import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types'
|
||||
import { loadAppPreview } from './loadAppPreview'
|
||||
import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte'
|
||||
import { dropPrefetched, loadAppCss } from './publicAppApi'
|
||||
import {
|
||||
hasStoredSdkConsent,
|
||||
sdkConsentCovers,
|
||||
storeSdkConsent
|
||||
} from '$lib/components/raw_apps/sdkScopes'
|
||||
|
||||
// Everything but a rendered app (sign-in, errors, the SDK consent prompt) loads on
|
||||
// demand: a raw app that renders right away needs none of it. See publicAppApi.ts.
|
||||
let frameUi:
|
||||
| Promise<{
|
||||
Alert: typeof import('$lib/components/common/alert/Alert.svelte').default
|
||||
Login: typeof import('$lib/components/Login.svelte').default
|
||||
RawAppSdkConsent: typeof import('$lib/components/raw_apps/RawAppSdkConsent.svelte').default
|
||||
}>
|
||||
| undefined
|
||||
function loadFrameUi() {
|
||||
return (frameUi ??= Promise.all([
|
||||
loadAppCss(),
|
||||
import('$lib/components/common/alert/Alert.svelte'),
|
||||
import('$lib/components/Login.svelte'),
|
||||
import('$lib/components/raw_apps/RawAppSdkConsent.svelte')
|
||||
]).then(([, alert, login, consent]) => ({
|
||||
Alert: alert.default,
|
||||
Login: login.default,
|
||||
RawAppSdkConsent: consent.default
|
||||
})))
|
||||
}
|
||||
|
||||
type EmbedToken = {
|
||||
token?: string | null
|
||||
raw_app?: boolean
|
||||
@@ -235,7 +255,8 @@
|
||||
if (offerSignIn && accountSession === 'unknown') {
|
||||
// Workspace-less, so it answers for an account and never for a guest
|
||||
// (pinned to its workspace) or for nobody.
|
||||
UserService.getCurrentEmail()
|
||||
import('$lib/gen')
|
||||
.then(({ UserService }) => UserService.getCurrentEmail())
|
||||
.then(() => (accountSession = 'held'))
|
||||
.catch(() => (accountSession = 'none'))
|
||||
}
|
||||
@@ -249,8 +270,12 @@
|
||||
let staleGuestLogoutFailed = $state(false)
|
||||
$effect(() => {
|
||||
if (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared) {
|
||||
UserService.logout()
|
||||
.then(() => (staleGuestCleared = true))
|
||||
import('$lib/gen')
|
||||
.then(({ UserService }) => UserService.logout())
|
||||
.then(() => {
|
||||
dropPrefetched()
|
||||
staleGuestCleared = true
|
||||
})
|
||||
.catch(() => (staleGuestLogoutFailed = true))
|
||||
}
|
||||
})
|
||||
@@ -427,7 +452,10 @@
|
||||
// Render the app directly on this origin: same-origin when unsandboxed
|
||||
// (the default), or a single opaque bundle iframe when it's a sandboxed
|
||||
// raw app. A low-code app's runtime downloads alongside the app payload.
|
||||
if (!isRaw) loadAppPreview().catch(() => {})
|
||||
if (!isRaw) {
|
||||
loadAppCss().catch(() => {})
|
||||
loadAppPreview().catch(() => {})
|
||||
}
|
||||
onViewerReady?.(undefined, requestTokenRefresh)
|
||||
} else {
|
||||
// Sandboxed low-code: hand the scoped token to the opaque viewer iframe.
|
||||
@@ -537,6 +565,7 @@
|
||||
onMount(() => {
|
||||
if (isViewer) {
|
||||
// Only a sandboxed low-code app is ever framed as a viewer.
|
||||
loadAppCss().catch(() => {})
|
||||
loadAppPreview().catch(() => {})
|
||||
window.addEventListener('message', handleViewerMessage)
|
||||
installHashRelay()
|
||||
@@ -569,105 +598,120 @@
|
||||
{#if viewerReady}
|
||||
{@render viewer()}
|
||||
{:else if viewerOrphaned}
|
||||
<div class="px-4 mt-20 max-w-xl mx-auto">
|
||||
<Alert type="info" title="Open this app from Windmill">
|
||||
This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own
|
||||
page, use the app's public URL without the <code>wm_embed</code> parameter.
|
||||
</Alert>
|
||||
</div>
|
||||
{#await loadFrameUi() then { Alert }}
|
||||
<div class="px-4 mt-20 max-w-xl mx-auto">
|
||||
<Alert type="info" title="Open this app from Windmill">
|
||||
This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your
|
||||
own page, use the app's public URL without the <code>wm_embed</code> parameter.
|
||||
</Alert>
|
||||
</div>
|
||||
{/await}
|
||||
{:else}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{#await loadAppCss() then}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{/await}
|
||||
{/if}
|
||||
{:else if status === 'loading'}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{:else if status === 'notExists' && !offerSignIn}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Not found">
|
||||
There was an error loading the app, is the url correct?
|
||||
<a href={base}>Go to Windmill</a>
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if status === 'sdkPrompt'}
|
||||
<!-- Raw-app frontend SDK: ask before the app's code runs, so the viewer sees
|
||||
what it will be able to do with their identity. -->
|
||||
<RawAppSdkConsent
|
||||
scopes={sdkScopes ?? []}
|
||||
onContinue={onSdkConsentContinue}
|
||||
onDecline={onSdkConsentDecline}
|
||||
/>
|
||||
{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Could not check access">
|
||||
The app could not be reached to find out who may open it. Reload to try again.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if offerSignIn}
|
||||
<!-- Login happens here, on the embedder (main) window, so the session cookie
|
||||
is set on the main origin only and never reaches the opaque iframe. -->
|
||||
{#if signInDidNotHelp}
|
||||
<!-- Offering the same sign-in again would loop: they are signed in, and this
|
||||
app still will not open for them. Say why and stop. -->
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
You are signed in, but this app is not open to you
|
||||
</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
It is open to the people it was shared with{guestAppPath
|
||||
? ', and to guests who have no Windmill account'
|
||||
: ''}. Ask the person who shared it to give your account access.
|
||||
</div>
|
||||
{#await loadAppCss() then}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{/await}
|
||||
{:else if status === 'ready'}
|
||||
{#if unsandboxed}
|
||||
<!-- Same-origin (full session): the app was not opted into sandbox isolation
|
||||
(the default). Rendered directly here; RawAppPreview reads
|
||||
IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. -->
|
||||
{@render viewer()}
|
||||
{:else if isRaw}
|
||||
<!-- Variant A: sandboxed raw app rendered directly on the real origin. The
|
||||
untrusted author bundle stays isolated in its own opaque iframe (inside
|
||||
RawAppPreview); no opaque viewer and no embed token are needed. -->
|
||||
{@render viewer()}
|
||||
{:else}
|
||||
{#if guestAppPath}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">Sign in to open this app</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
You do not need a Windmill account. Signing in lets you open this app and nothing else.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
This app requires read access
|
||||
</div>
|
||||
{/if}
|
||||
{#if signInError}
|
||||
<div class="px-2 mx-auto mt-8 max-w-xl w-full">
|
||||
<Alert type="error" title="Could not sign you in">{signInError}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
<Login
|
||||
onLoginSuccess={() => {
|
||||
signInError = undefined
|
||||
accountSession = 'unknown'
|
||||
initEmbedder()
|
||||
}}
|
||||
onLoginError={(message) => (signInError = message)}
|
||||
popup
|
||||
guestApp={guestAppPath}
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
</div>
|
||||
<!-- referrerpolicy: the embedder page URL can carry a viewer credential (the
|
||||
JWT path segment of share links); without this, the same-origin iframe
|
||||
navigation would expose it to app-authored code via document.referrer.
|
||||
Styled inline: it can render before app.css has loaded. -->
|
||||
<iframe
|
||||
bind:this={iframeEl}
|
||||
src={buildViewerUrl()}
|
||||
title="App"
|
||||
style="display: block; width: 100%; height: 100%; border: 0"
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation"
|
||||
allow="clipboard-read; clipboard-write; fullscreen"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
{/if}
|
||||
{:else if unsandboxed}
|
||||
<!-- Same-origin (full session): the app was not opted into sandbox isolation
|
||||
(the default). Rendered directly here; RawAppPreview reads
|
||||
IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. -->
|
||||
{@render viewer()}
|
||||
{:else if isRaw}
|
||||
<!-- Variant A: sandboxed raw app rendered directly on the real origin. The
|
||||
untrusted author bundle stays isolated in its own opaque iframe (inside
|
||||
RawAppPreview); no opaque viewer and no embed token are needed. -->
|
||||
{@render viewer()}
|
||||
{:else}
|
||||
<!-- referrerpolicy: the embedder page URL can carry a viewer credential (the
|
||||
JWT path segment of share links); without this, the same-origin iframe
|
||||
navigation would expose it to app-authored code via document.referrer. -->
|
||||
<iframe
|
||||
bind:this={iframeEl}
|
||||
src={buildViewerUrl()}
|
||||
title="App"
|
||||
class="w-full h-full border-0 block"
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation"
|
||||
allow="clipboard-read; clipboard-write; fullscreen"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
{#await loadFrameUi() then { Alert, Login, RawAppSdkConsent }}
|
||||
{#if status === 'notExists' && !offerSignIn}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Not found">
|
||||
There was an error loading the app, is the url correct?
|
||||
<a href={base}>Go to Windmill</a>
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if status === 'sdkPrompt'}
|
||||
<!-- Raw-app frontend SDK: ask before the app's code runs, so the viewer sees
|
||||
what it will be able to do with their identity. -->
|
||||
<RawAppSdkConsent
|
||||
scopes={sdkScopes ?? []}
|
||||
onContinue={onSdkConsentContinue}
|
||||
onDecline={onSdkConsentDecline}
|
||||
/>
|
||||
{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Could not check access">
|
||||
The app could not be reached to find out who may open it. Reload to try again.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if offerSignIn}
|
||||
<!-- Login happens here, on the embedder (main) window, so the session cookie
|
||||
is set on the main origin only and never reaches the opaque iframe. -->
|
||||
{#if signInDidNotHelp}
|
||||
<!-- Offering the same sign-in again would loop: they are signed in, and this
|
||||
app still will not open for them. Say why and stop. -->
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
You are signed in, but this app is not open to you
|
||||
</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
It is open to the people it was shared with{guestAppPath
|
||||
? ', and to guests who have no Windmill account'
|
||||
: ''}. Ask the person who shared it to give your account access.
|
||||
</div>
|
||||
{:else}
|
||||
{#if guestAppPath}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">Sign in to open this app</div
|
||||
>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
You do not need a Windmill account. Signing in lets you open this app and nothing else.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
This app requires read access
|
||||
</div>
|
||||
{/if}
|
||||
{#if signInError}
|
||||
<div class="px-2 mx-auto mt-8 max-w-xl w-full">
|
||||
<Alert type="error" title="Could not sign you in">{signInError}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
<Login
|
||||
onLoginSuccess={() => {
|
||||
dropPrefetched()
|
||||
signInError = undefined
|
||||
accountSession = 'unknown'
|
||||
initEmbedder()
|
||||
}}
|
||||
onLoginError={(message) => (signInError = message)}
|
||||
popup
|
||||
guestApp={guestAppPath}
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/state'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import type { AppWithLastVersion } from '$lib/gen'
|
||||
import { enterpriseLicense, userStore, type UserExt } from '$lib/stores'
|
||||
import { urlParamsToObject } from '$lib/utils'
|
||||
import { getContext } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { EMBED_NAV_CONTEXT_KEY, type EditorBreakpoint, type EmbedNav } from '../types'
|
||||
import { loadAppPreview } from './loadAppPreview'
|
||||
|
||||
let {
|
||||
app,
|
||||
workspace,
|
||||
user,
|
||||
license,
|
||||
inWorkspace,
|
||||
hideRefreshBar
|
||||
}: {
|
||||
app: AppWithLastVersion & { value: any }
|
||||
workspace: string | undefined
|
||||
user: UserExt | undefined
|
||||
license: string | undefined
|
||||
inWorkspace: boolean
|
||||
hideRefreshBar: boolean
|
||||
} = $props()
|
||||
|
||||
// The public routes hold the viewer and license as props, while low-code components
|
||||
// read them from the stores (the license gates the app's custom CSS, for one).
|
||||
$effect(() => {
|
||||
if (inWorkspace) return
|
||||
userStore.set(user)
|
||||
if (license) enterpriseLicense.set(license)
|
||||
})
|
||||
|
||||
// WIN-2006: inside the opaque viewer iframe, navigations to other routes
|
||||
// (navbar "app" items) must happen on the TOP page — the iframe is cookieless,
|
||||
// so navigating it would just show a login screen. PublicAppFrame provides the
|
||||
// relay; outside the opaque viewer this is undefined and goto works directly.
|
||||
const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY)
|
||||
|
||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
// `flex-col` matches the pre-sandbox in-workspace viewer exactly;
|
||||
// the public viewer always used a plain `flex` wrapper. `min-h-full`, not
|
||||
// `h-full`: the box has to reach the bottom of what the host gives it so the
|
||||
// app's own background covers the window, and still grow with a grid taller
|
||||
// than that. A viewport floor instead would overhang the page box by the
|
||||
// height of the page header band.
|
||||
inWorkspace ? 'min-h-full w-full flex flex-col' : 'min-h-full w-full flex',
|
||||
app?.value?.['css']?.['app']?.['viewer']?.class,
|
||||
'wm-app-viewer'
|
||||
)}
|
||||
style={app?.value?.['css']?.['app']?.['viewer']?.style}
|
||||
>
|
||||
{#await loadAppPreview()}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
noBackend={false}
|
||||
{hideRefreshBar}
|
||||
context={{
|
||||
email: user?.email,
|
||||
name: user?.name,
|
||||
groups: user?.groups,
|
||||
username: user?.username,
|
||||
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
|
||||
hash: page.url.hash.substring(1)
|
||||
}}
|
||||
{workspace}
|
||||
summary={app.summary}
|
||||
app={app.value}
|
||||
appPath={app.path}
|
||||
{breakpoint}
|
||||
policy={app.policy}
|
||||
isEditor={false}
|
||||
replaceStateFn={(path) => goto(path)}
|
||||
gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))}
|
||||
/>
|
||||
{:catch}
|
||||
<div class="px-4 mt-20 w-full">
|
||||
<Alert type="error" title="Could not load the app">Reload the page to try again.</Alert>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
// The public app routes (`/public/...`, `/a/...`) render a raw app as little more than its
|
||||
// bundle's iframe, so they must not statically import the generated client, `$lib/stores`
|
||||
// or `$lib/utils`: each drags in most of the app shell. `vite.config.js`
|
||||
// (`assertLeanPublicAppRoutes`) fails the build if they do. Anything heavier is imported
|
||||
// lazily by the branch that needs it.
|
||||
import { OpenAPI } from '$lib/gen/core/OpenAPI'
|
||||
import type { AppWithLastVersion, User } from '$lib/gen/types.gen'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { mapUserToUserExt } from '$lib/userExt'
|
||||
|
||||
export type PublicAppValue = AppWithLastVersion & { value: any; workspace_id?: string }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Responses `app.html` requested before any module loaded, keyed by URL. */
|
||||
__wmPublicAppPrefetch?: Record<string, Promise<Response>>
|
||||
}
|
||||
}
|
||||
|
||||
/** Carries `status` like the generated client's `ApiError`, which callers branch on. */
|
||||
export class PublicApiError extends Error {
|
||||
constructor(public status: number) {
|
||||
super(`Request failed with status ${status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefetched responses answer for the credential the page loaded with; call this when it
|
||||
* changes (a sign-in or logout on the page) so later reads go to the network. */
|
||||
export function dropPrefetched(): void {
|
||||
delete window.__wmPublicAppPrefetch
|
||||
}
|
||||
|
||||
function takePrefetched(url: string): Promise<Response> | undefined {
|
||||
const prefetched = window.__wmPublicAppPrefetch
|
||||
const res = prefetched?.[url]
|
||||
if (res) delete prefetched![url]
|
||||
return res
|
||||
}
|
||||
|
||||
/** GET an API path with the page credential: the bearer token in `OpenAPI.TOKEN` when the
|
||||
* share link carries one, the session cookie otherwise. A prefetched response is only
|
||||
* taken in the cookie case, which is the only credential `app.html` sends. */
|
||||
export async function publicGet<T>(path: string): Promise<T> {
|
||||
const url = `${OpenAPI.BASE}${path}`
|
||||
const token = typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined
|
||||
const res = await ((!token && takePrefetched(url)) ||
|
||||
fetch(url, {
|
||||
// As the generated client does: the opaque viewer frame is cross-origin to the
|
||||
// API, whose `Access-Control-Allow-Origin: *` refuses a credentialed request.
|
||||
credentials: OpenAPI.WITH_CREDENTIALS ? OpenAPI.CREDENTIALS : undefined,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}))
|
||||
if (!res.ok) throw new PublicApiError(res.status)
|
||||
return (res.headers.get('content-type')?.includes('json') ? res.json() : res.text()) as T
|
||||
}
|
||||
|
||||
export async function getUserExtOrUndefined(workspace: string): Promise<UserExt | undefined> {
|
||||
try {
|
||||
return mapUserToUserExt(
|
||||
await publicGet<User>(`/w/${encodeURI(workspace)}/users/whoami`),
|
||||
workspace
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Only fades the "Powered by" badge, so a failure is not worth surfacing. */
|
||||
export async function getLicenseOrUndefined(): Promise<string | undefined> {
|
||||
try {
|
||||
return (await publicGet<string>('/ee_license')) || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
let appCss: Promise<unknown> | undefined
|
||||
/** The app-wide stylesheet, which these routes need only off the raw-app path: sign-in,
|
||||
* errors, skeletons and low-code apps. A raw app's bundle brings its own styles. */
|
||||
export function loadAppCss(): Promise<unknown> {
|
||||
return (appCss ??= import('$lib/assets/app.css'))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<!--
|
||||
@component
|
||||
The menu that adds a pipeline script started by a data source (a schedule, a
|
||||
webhook, a queue…): the add node's, and the canvas's right-click one.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import PipelineInsertMenu, {
|
||||
type PipelineInsertOptions,
|
||||
type PipelineInsertPick
|
||||
} from './PipelineInsertMenu.svelte'
|
||||
import {
|
||||
Clock,
|
||||
Webhook,
|
||||
Mail,
|
||||
Zap,
|
||||
Radio,
|
||||
MessageSquare,
|
||||
Database,
|
||||
Send,
|
||||
CloudCog,
|
||||
Upload
|
||||
} from 'lucide-svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import type { NativeTriggerKind } from './types'
|
||||
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
|
||||
import type { PipelineOutputKind } from './pipelineTemplates'
|
||||
|
||||
interface Props {
|
||||
onAddPipelineScript: (
|
||||
language: ScriptLang,
|
||||
path: string,
|
||||
source: { kind: NativeTriggerKind; path: string | undefined },
|
||||
outputKind: PipelineOutputKind,
|
||||
aiPrompt?: string,
|
||||
options?: PipelineInsertOptions
|
||||
) => void
|
||||
pathPrefix: string
|
||||
defaultPathSuffix: string
|
||||
trigger: Snippet<[{ open: boolean }]>
|
||||
/** Opens the menu each time it changes. */
|
||||
openSignal?: number
|
||||
}
|
||||
let {
|
||||
onAddPipelineScript,
|
||||
pathPrefix,
|
||||
defaultPathSuffix,
|
||||
trigger,
|
||||
openSignal
|
||||
}: Props = $props()
|
||||
|
||||
function handlePick(pick: PipelineInsertPick) {
|
||||
if (!pick.language || !pick.path) return
|
||||
// Native trigger annotation is marker-only — the binding lives on the
|
||||
// trigger row's own `script_path`, which is created separately.
|
||||
onAddPipelineScript(
|
||||
pick.language as ScriptLang,
|
||||
pick.path,
|
||||
{ kind: pick.kindId as NativeTriggerKind, path: undefined },
|
||||
(pick.outputKind ?? 'none') as PipelineOutputKind,
|
||||
pick.aiPrompt,
|
||||
{ schedule: pick.schedule, outputAsset: pick.outputAsset }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'data_upload',
|
||||
label: 'On data upload',
|
||||
description: 'UI-first: run by uploading a file via the S3 picker',
|
||||
icon: Upload
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
label: 'On schedule',
|
||||
description: 'Triggered by a schedule you create',
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
label: 'On webhook',
|
||||
description: 'Triggered by an HTTP webhook',
|
||||
icon: Webhook
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On email',
|
||||
description: 'Triggered by incoming email',
|
||||
icon: Mail
|
||||
},
|
||||
{
|
||||
id: 'kafka',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On Kafka',
|
||||
description: 'Triggered by a Kafka message',
|
||||
icon: Zap
|
||||
},
|
||||
{
|
||||
id: 'mqtt',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On MQTT',
|
||||
description: 'Triggered by an MQTT message',
|
||||
icon: Radio
|
||||
},
|
||||
{
|
||||
id: 'amqp',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On AMQP',
|
||||
description: 'Triggered by an AMQP (RabbitMQ) message',
|
||||
icon: Radio
|
||||
},
|
||||
{
|
||||
id: 'nats',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On NATS',
|
||||
description: 'Triggered by a NATS message',
|
||||
icon: MessageSquare
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On Postgres',
|
||||
description: 'Triggered by a Postgres event',
|
||||
icon: Database
|
||||
},
|
||||
{
|
||||
id: 'sqs',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On SQS',
|
||||
description: 'Triggered by an SQS message',
|
||||
icon: Send
|
||||
},
|
||||
{
|
||||
id: 'gcp',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On GCP Pub/Sub',
|
||||
description: 'Triggered by a Pub/Sub message',
|
||||
icon: CloudCog
|
||||
}
|
||||
]}
|
||||
languages={PIPELINE_LANGUAGES as any}
|
||||
{pathPrefix}
|
||||
{defaultPathSuffix}
|
||||
onPick={handlePick}
|
||||
{openSignal}
|
||||
{trigger}
|
||||
/>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!--
|
||||
@component
|
||||
The menu adding a pipeline script downstream of an asset: opened from the asset's
|
||||
"+", or where that "+" was dropped on the blank canvas.
|
||||
-->
|
||||
<script lang="ts" module>
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import type { AssetKind } from '$lib/components/assets/lib'
|
||||
import type { PipelineInsertOptions } from './PipelineInsertMenu.svelte'
|
||||
import type { PipelineOutputKind } from './pipelineTemplates'
|
||||
|
||||
export type AddScriptForAsset = (
|
||||
asset: { kind: AssetKind; path: string },
|
||||
language: ScriptLang,
|
||||
scriptPath: string,
|
||||
outputKind: PipelineOutputKind,
|
||||
aiPrompt?: string,
|
||||
options?: PipelineInsertOptions
|
||||
) => void
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Code2 } from 'lucide-svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
|
||||
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
|
||||
|
||||
let {
|
||||
asset,
|
||||
onAddScript,
|
||||
pathPrefix = '',
|
||||
defaultPathSuffix = '',
|
||||
openSignal,
|
||||
trigger
|
||||
}: {
|
||||
asset: { kind: AssetKind; path: string }
|
||||
onAddScript: AddScriptForAsset
|
||||
pathPrefix?: string
|
||||
defaultPathSuffix?: string
|
||||
openSignal?: number
|
||||
trigger: Snippet<[{ open: boolean }]>
|
||||
} = $props()
|
||||
|
||||
function handlePick(pick: PipelineInsertPick) {
|
||||
if (pick.kindId === 'pipeline_script' && pick.language && pick.path) {
|
||||
onAddScript(
|
||||
asset,
|
||||
pick.language as ScriptLang,
|
||||
pick.path,
|
||||
(pick.outputKind ?? 'none') as PipelineOutputKind,
|
||||
pick.aiPrompt,
|
||||
{ outputAsset: pick.outputAsset }
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'pipeline_script',
|
||||
label: 'Add downstream pipeline script',
|
||||
description: 'Triggered when this asset changes',
|
||||
icon: Code2
|
||||
}
|
||||
]}
|
||||
languages={PIPELINE_LANGUAGES as any}
|
||||
{pathPrefix}
|
||||
{defaultPathSuffix}
|
||||
{openSignal}
|
||||
onPick={handlePick}
|
||||
{trigger}
|
||||
/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!--
|
||||
@component
|
||||
The "+" on an asset's bottom edge: a circle at rest, widened into a labelled pill
|
||||
while its menu is open or while it is being dragged. The label animates its
|
||||
max-width (width can't transition to auto) and never wraps.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte'
|
||||
|
||||
let { expanded }: { expanded: boolean } = $props()
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="group/add h-6 min-w-6 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm leading-none overflow-hidden transition-[padding] duration-200 ease-out data-[open=true]:pl-1"
|
||||
data-open={expanded}
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} class="shrink-0" />
|
||||
<span
|
||||
class="max-w-0 opacity-0 whitespace-nowrap text-3xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2"
|
||||
>
|
||||
Add downstream step
|
||||
</span>
|
||||
</span>
|
||||
@@ -1,145 +1,36 @@
|
||||
<script lang="ts">
|
||||
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Webhook,
|
||||
Mail,
|
||||
Zap,
|
||||
Radio,
|
||||
MessageSquare,
|
||||
Database,
|
||||
Send,
|
||||
CloudCog,
|
||||
Upload
|
||||
} from 'lucide-svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import type { NativeTriggerKind } from './types'
|
||||
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
|
||||
import type { PipelineOutputKind } from './pipelineTemplates'
|
||||
|
||||
// Each left-column kind is just "pipeline script triggered by <trigger
|
||||
// source>". id === the SCRIPT_TRIGGER_KIND value, so the handler can
|
||||
// dispatch on it uniformly. Asset-triggered scripts are not in this
|
||||
// menu; those live under the per-asset + inside the graph.
|
||||
type KindId = NativeTriggerKind
|
||||
import AddDataSourceMenu from './AddDataSourceMenu.svelte'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
onAddPipelineScript: (
|
||||
language: ScriptLang,
|
||||
path: string,
|
||||
source: { kind: NativeTriggerKind; path: string | undefined },
|
||||
outputKind: PipelineOutputKind,
|
||||
aiPrompt?: string
|
||||
) => void
|
||||
pathPrefix: string
|
||||
defaultPathSuffix: string
|
||||
}
|
||||
data: Pick<
|
||||
import('svelte').ComponentProps<typeof AddDataSourceMenu>,
|
||||
'onAddPipelineScript' | 'pathPrefix' | 'defaultPathSuffix'
|
||||
>
|
||||
}
|
||||
let { data }: Props = $props()
|
||||
|
||||
function handlePick(pick: PipelineInsertPick) {
|
||||
if (!pick.language || !pick.path) return
|
||||
const kindId = pick.kindId as KindId
|
||||
const outputKind = (pick.outputKind ?? 'none') as PipelineOutputKind
|
||||
// Native trigger annotation is marker-only — the binding lives on
|
||||
// the trigger row's own `script_path`, which the user creates
|
||||
// separately. Seed with `path: undefined`.
|
||||
data.onAddPipelineScript(
|
||||
pick.language as ScriptLang,
|
||||
pick.path,
|
||||
{ kind: kindId, path: undefined },
|
||||
outputKind,
|
||||
pick.aiPrompt
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'data_upload',
|
||||
label: 'On data upload',
|
||||
description: 'UI-first: run by uploading a file via the S3 picker',
|
||||
icon: Upload
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
label: 'On schedule',
|
||||
description: 'Triggered by a schedule you create',
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
label: 'On webhook',
|
||||
description: 'Triggered by an HTTP webhook',
|
||||
icon: Webhook
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'On email',
|
||||
description: 'Triggered by incoming email',
|
||||
icon: Mail
|
||||
},
|
||||
{
|
||||
id: 'kafka',
|
||||
label: 'On Kafka',
|
||||
description: 'Triggered by a Kafka message',
|
||||
icon: Zap
|
||||
},
|
||||
{
|
||||
id: 'mqtt',
|
||||
label: 'On MQTT',
|
||||
description: 'Triggered by an MQTT message',
|
||||
icon: Radio
|
||||
},
|
||||
{
|
||||
id: 'amqp',
|
||||
label: 'On AMQP',
|
||||
description: 'Triggered by an AMQP (RabbitMQ) message',
|
||||
icon: Radio
|
||||
},
|
||||
{
|
||||
id: 'nats',
|
||||
label: 'On NATS',
|
||||
description: 'Triggered by a NATS message',
|
||||
icon: MessageSquare
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
label: 'On Postgres',
|
||||
description: 'Triggered by a Postgres event',
|
||||
icon: Database
|
||||
},
|
||||
{
|
||||
id: 'sqs',
|
||||
label: 'On SQS',
|
||||
description: 'Triggered by an SQS message',
|
||||
icon: Send
|
||||
},
|
||||
{
|
||||
id: 'gcp',
|
||||
label: 'On GCP Pub/Sub',
|
||||
description: 'Triggered by a Pub/Sub message',
|
||||
icon: CloudCog
|
||||
}
|
||||
]}
|
||||
languages={PIPELINE_LANGUAGES as any}
|
||||
pathPrefix={data.pathPrefix}
|
||||
defaultPathSuffix={data.defaultPathSuffix}
|
||||
onPick={handlePick}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- Quiet insert affordance, mirroring the flow editor's inline +
|
||||
buttons (bg-surface + gray border + secondary text) — a filled
|
||||
accent circle outweighed every real node on the canvas. -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer"
|
||||
title="Add to pipeline"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</PipelineInsertMenu>
|
||||
<!-- The layout gives this node a full node-width slot; centering the pill in
|
||||
it lines it up with the nodes below whatever its label's width. -->
|
||||
<div class="flex justify-center" style="width: {NODE.width}px;">
|
||||
<AddDataSourceMenu
|
||||
onAddPipelineScript={data.onAddPipelineScript}
|
||||
pathPrefix={data.pathPrefix}
|
||||
defaultPathSuffix={data.defaultPathSuffix}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- Quiet insert affordance, mirroring the flow editor's inline +
|
||||
buttons (bg-surface + gray border + secondary text) — a filled
|
||||
accent pill would outweigh every real node on the canvas. -->
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 px-3 rounded-full flex items-center gap-1.5 whitespace-nowrap bg-surface border border-gray-400 dark:border-gray-600 text-xs font-normal text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={16} class="shrink-0" />
|
||||
Add data source
|
||||
</button>
|
||||
{/snippet}
|
||||
</AddDataSourceMenu>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,17 @@
|
||||
SquareFunction,
|
||||
Trash2,
|
||||
X,
|
||||
Pencil
|
||||
Pencil,
|
||||
ChevronDown,
|
||||
Zap
|
||||
} from 'lucide-svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { tick } from 'svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import {
|
||||
upstreamScriptItems,
|
||||
upstreamTriggerItems,
|
||||
type UpstreamScript,
|
||||
type UpstreamTriggerAction
|
||||
} from './upstreamScriptItems'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { emptySchema, sendUserToast } from '$lib/utils'
|
||||
import type { Schema } from '$lib/common'
|
||||
@@ -41,6 +48,7 @@
|
||||
import { extractDraftMacros } from './resolveGraph'
|
||||
import { assetColumnNodes, type ColumnLineageGraph } from './columnLineageGraph'
|
||||
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import S3FilePreview from '$lib/components/S3FilePreview.svelte'
|
||||
import DataTablePreview from './DataTablePreview.svelte'
|
||||
import DucklakeAssetPanel from './DucklakeAssetPanel.svelte'
|
||||
@@ -117,7 +125,9 @@
|
||||
// (`onDraftPersist`). `onDraftPersist` stays the authoritative commit on
|
||||
// navigate-away; this is the continuous feed that keeps the draft's
|
||||
// server copy current while the user is still typing.
|
||||
onContentChange?: (scriptPath: string | undefined, content: string) => void
|
||||
// `base`: the deployed copy being edited, so the parent can autosave unsaved
|
||||
// edits to it before they are promoted to a draft. Unset for a draft.
|
||||
onContentChange?: (scriptPath: string | undefined, content: string, base?: Script) => void
|
||||
// Fires when the user navigates away from a draft (selects another
|
||||
// node, closes the pane, etc.) — the parent must persist the new
|
||||
// content + write outputs into its drafts Map. Without this,
|
||||
@@ -133,7 +143,8 @@
|
||||
writes: { kind: AssetWithAltAccessType['kind']; path: string }[]
|
||||
// Body-inferred reads, so the parent's draft keeps its input
|
||||
// lineage while inactive (no live inference runs for it).
|
||||
reads: { kind: AssetWithAltAccessType['kind']; path: string }[]
|
||||
// Undefined when nothing was inferred: keep what the draft has.
|
||||
reads?: { kind: AssetWithAltAccessType['kind']; path: string }[]
|
||||
// Full edited script — set when the source is a persisted
|
||||
// script (needed to seed a brand-new draft entry).
|
||||
script?: Script
|
||||
@@ -156,6 +167,12 @@
|
||||
path: string
|
||||
unsaved?: boolean
|
||||
}>
|
||||
/** The selected asset's producing scripts, for its "Edit script" action,
|
||||
* which calls `onEditScript` with the one picked. */
|
||||
producerScripts?: UpstreamScript[]
|
||||
onEditScript?: (path: string) => void
|
||||
/** The selected asset's upstream triggers that can be opened from here. */
|
||||
producerTriggers?: UpstreamTriggerAction[]
|
||||
// Pipeline-wide column-lineage graph (built by the parent page from the
|
||||
// resolved graph). Drives the transitive column-lineage trace shown for a
|
||||
// selected materialized asset.
|
||||
@@ -215,15 +232,18 @@
|
||||
// the edges. Counter rather than boolean so back-to-back runs
|
||||
// re-fire the effect even if no other prop changed.
|
||||
requestRunSignal?: number
|
||||
// Folder-scoped non-editable prefix shown next to the suffix
|
||||
// editor when the user renames a draft (e.g. `f/<folder>/`). The
|
||||
// new path = pathPrefix + suffix.
|
||||
pathPrefix?: string
|
||||
// Called when the user renames a draft — the parent reseats the
|
||||
// path key in its drafts map and updates activeDraftPath. Returns
|
||||
// true on success; false on collision/validation failure so the
|
||||
// popover can keep itself open and surface the error inline.
|
||||
onDraftPathChange?: (oldPath: string, newPath: string) => boolean | string
|
||||
onDraftMetaChange?: (
|
||||
path: string,
|
||||
meta: { summary: string; labels: string[] | undefined }
|
||||
) => void
|
||||
// The pipeline's folder prefix (`f/<folder>/`). A draft renamed outside it
|
||||
// would be deployed out of the pipeline, so the rename is refused.
|
||||
pathPrefix?: string
|
||||
// Bumped by the parent (e.g. from the runnable-node action menu) to
|
||||
// auto-open the archive/delete confirmation modal for the currently
|
||||
// loaded persisted script. No-ops while the pane is showing a draft
|
||||
@@ -280,6 +300,19 @@
|
||||
// can persist a data-upload entry's staged input (drives node readiness).
|
||||
// `isValid` is the full-schema validity, not just "file present".
|
||||
onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
|
||||
// Show only the open script's inputs and runs (a data-upload click), not
|
||||
// its editor. `onRunInputs` runs it with them, drafts included, and
|
||||
// `onShowScript` leaves this view for the script.
|
||||
inputsOnly?: boolean
|
||||
/** `live` is the pane's buffer when it differs from what is stored: always
|
||||
* for a draft, and for a deployed script edited before switching to the
|
||||
* inputs view. A run with it previews that body. */
|
||||
onRunInputs?: (
|
||||
path: string,
|
||||
args: Record<string, any>,
|
||||
live?: { content: string; language: ScriptLang }
|
||||
) => Promise<string | undefined>
|
||||
onShowScript?: () => void
|
||||
}
|
||||
let {
|
||||
selection,
|
||||
@@ -301,6 +334,9 @@
|
||||
onScriptRenamed,
|
||||
onScriptRemoved,
|
||||
selectionProducers = [],
|
||||
producerScripts = [],
|
||||
onEditScript,
|
||||
producerTriggers = [],
|
||||
selectionColumnGraph,
|
||||
selectionColumnLoading = false,
|
||||
selectionColumnTruncated = false,
|
||||
@@ -315,8 +351,9 @@
|
||||
activeRunnable,
|
||||
onTestStateChange,
|
||||
requestRunSignal,
|
||||
pathPrefix = '',
|
||||
onDraftPathChange,
|
||||
onDraftMetaChange,
|
||||
pathPrefix = '',
|
||||
requestRemoveSignal,
|
||||
downstreamSubscribers = 0,
|
||||
onStartBoundedRun,
|
||||
@@ -328,10 +365,15 @@
|
||||
onRunByPath,
|
||||
onRunCascadeByPath,
|
||||
runFormInitialArgs,
|
||||
onRunFormArgsChange
|
||||
onRunFormArgsChange,
|
||||
inputsOnly = false,
|
||||
onRunInputs,
|
||||
onShowScript
|
||||
}: Props = $props()
|
||||
|
||||
let readOnly = $derived(mode !== 'edit')
|
||||
// Someone who cannot switch the pipeline to edit mode only gets to read it.
|
||||
let editScriptLabel = $derived(readOnly && !onRequestEdit ? 'Open script' : 'Edit script')
|
||||
|
||||
// Root element of the pane — used to scope the S3-input lookup for the
|
||||
// data_upload focus effect.
|
||||
@@ -408,7 +450,7 @@
|
||||
// PipelineScriptView renders instead of the test panel.
|
||||
let viewReadyForTarget = $derived.by(
|
||||
() =>
|
||||
readOnly &&
|
||||
(readOnly || inputsOnly) &&
|
||||
runTargetPath !== undefined &&
|
||||
script?.path === runTargetPath &&
|
||||
(isDraft || (!scriptRes.loading && scriptRes.current?.path === runTargetPath))
|
||||
@@ -484,6 +526,12 @@
|
||||
// alongside `liveBodyAssets` and forwarded so the live graph can show
|
||||
// inferred column lineage on the edited script before it deploys.
|
||||
let liveColumnLineage = $state<ColumnLineage[] | undefined>(undefined)
|
||||
// The script whose editor last bound `liveBodyAssets` (undefined there means it
|
||||
// inferred nothing). They stay bound after that editor unmounts, so a script
|
||||
// that never had one (the inputs-only view) must not report or persist them as
|
||||
// its lineage. Set by an effect declared after the save-on-switch one, whose
|
||||
// cleanup still needs the previous owner when the selection moves.
|
||||
let liveAssetsOwner: string | undefined = undefined
|
||||
|
||||
// Bumped when the runs panel reports a watched job has reached a
|
||||
// terminal state. Drives S3FilePreview's refreshKey so the preview
|
||||
@@ -549,7 +597,9 @@
|
||||
let args = $state<Record<string, any>>({})
|
||||
let argsSeedPath: string | undefined = undefined
|
||||
$effect.pre(() => {
|
||||
const p = script?.path
|
||||
// Re-seeded on leaving the inputs-only view too, so the file picked there
|
||||
// shows in the editor's test form.
|
||||
const p = script?.path === undefined ? undefined : `${script.path}|${inputsOnly}`
|
||||
if (p === argsSeedPath) return
|
||||
argsSeedPath = p
|
||||
args = runFormInitialArgs ? structuredClone($state.snapshot(runFormInitialArgs)) : {}
|
||||
@@ -559,7 +609,8 @@
|
||||
// required field (not just the S3 file) is satisfied.
|
||||
let runFormIsValid = $state(true)
|
||||
$effect(() => {
|
||||
if (readOnly || !script) return
|
||||
// Inputs-only mounts no ScriptEditor: PipelineScriptView reports its own args.
|
||||
if (readOnly || inputsOnly || !script) return
|
||||
onRunFormArgsChange?.(script.path, $state.snapshot(args), runFormIsValid)
|
||||
})
|
||||
|
||||
@@ -596,18 +647,25 @@
|
||||
b: Array<{ kind: string; path: string }>
|
||||
) => a.length === b.length && a.every((x, i) => x.kind === b[i]?.kind && x.path === b[i]?.path)
|
||||
return () => {
|
||||
const writes = (liveBodyAssets ?? [])
|
||||
// No editor inferred this script (the inputs-only view): keep its stored
|
||||
// lineage rather than persisting it as empty.
|
||||
const inferred = liveAssetsOwner === captured.path
|
||||
const writes = !inferred
|
||||
? (writesAtRegister ?? [])
|
||||
: (liveBodyAssets ?? [])
|
||||
.filter((a) => {
|
||||
const t = a.access_type ?? a.alt_access_type
|
||||
return t === 'w' || t === 'rw'
|
||||
})
|
||||
.map((a) => ({ kind: a.kind, path: a.path }))
|
||||
const reads = (liveBodyAssets ?? [])
|
||||
.filter((a) => {
|
||||
const t = a.access_type ?? a.alt_access_type
|
||||
return t === 'r' || t === 'rw'
|
||||
})
|
||||
.map((a) => ({ kind: a.kind, path: a.path }))
|
||||
const reads = !inferred
|
||||
? readsAtRegister
|
||||
: (liveBodyAssets ?? [])
|
||||
.filter((a) => {
|
||||
const t = a.access_type ?? a.alt_access_type
|
||||
return t === 'r' || t === 'rw'
|
||||
})
|
||||
.map((a) => ({ kind: a.kind, path: a.path }))
|
||||
// Draft runs: emit only when content or lineage changed in THIS clone.
|
||||
// An unconditional emit ping-pongs forever when the entry is rewritten
|
||||
// externally while the pane stays mounted (rename rekey, AI edit):
|
||||
@@ -617,6 +675,7 @@
|
||||
(captured.content ?? '') === contentAtRegister &&
|
||||
refsEq(writesAtRegister ?? [], writes) &&
|
||||
readsAtRegister != undefined &&
|
||||
reads != undefined &&
|
||||
refsEq(readsAtRegister, reads)
|
||||
)
|
||||
return
|
||||
@@ -651,12 +710,42 @@
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (script && !readOnly && !inputsOnly) liveAssetsOwner = script.path
|
||||
})
|
||||
|
||||
let saving = $state(false)
|
||||
// What this pane last deployed, so the persist-back cleanup can tell "the
|
||||
// buffer equals the new deployed head" from real unsaved edits (plain
|
||||
// variable: only read inside the untracked cleanup).
|
||||
let deployedFromPane: { path: string; content: string } | undefined = undefined
|
||||
let isDraft = $derived(draftScript != undefined)
|
||||
function liveBody(): { content: string; language: ScriptLang } | undefined {
|
||||
if (!script) return undefined
|
||||
const live = { content: script.content ?? '', language: script.language as ScriptLang }
|
||||
if (isDraft) return live
|
||||
const orig = scriptRes.current
|
||||
return orig?.path === script.path && (orig.content ?? '') !== live.content ? live : undefined
|
||||
}
|
||||
// A draft's stored schema is empty until an editor infers it, and the inputs
|
||||
// view mounts none: infer from the body here so its fields (the file picker)
|
||||
// show up.
|
||||
const inputsSchema = resource(
|
||||
[() => (inputsOnly && isDraft ? script?.content : undefined), () => script?.language],
|
||||
async ([content, language]) => {
|
||||
if (content === undefined || !language) return undefined
|
||||
const schema = emptySchema()
|
||||
try {
|
||||
await inferArgs(language, content, schema as Schema)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return schema
|
||||
}
|
||||
)
|
||||
let inputsScript = $derived(
|
||||
script && inputsSchema.current ? { ...script, schema: inputsSchema.current } : undefined
|
||||
)
|
||||
|
||||
// Single trash-bin button opens one modal that exposes both Archive
|
||||
// (always available) and Delete permanently (admin-only). Archive is
|
||||
@@ -763,12 +852,17 @@
|
||||
onAnnotationsChange?.(script?.path, liveAnnotations)
|
||||
})
|
||||
$effect(() => {
|
||||
if (readOnly) return
|
||||
if (readOnly || (inputsOnly && liveAssetsOwner !== script?.path)) return
|
||||
onAssetsChange?.(script?.path, liveBodyAssets ?? [], liveColumnLineage)
|
||||
})
|
||||
$effect(() => {
|
||||
if (readOnly) return
|
||||
onContentChange?.(script?.path, script?.content ?? '')
|
||||
const base = draftScript ? undefined : scriptRes.current
|
||||
onContentChange?.(
|
||||
script?.path,
|
||||
script?.content ?? '',
|
||||
base && base.path === script?.path ? base : undefined
|
||||
)
|
||||
})
|
||||
|
||||
async function save() {
|
||||
@@ -910,49 +1004,6 @@
|
||||
JSON.stringify(script.schema ?? null) === JSON.stringify(orig.schema ?? null)
|
||||
)
|
||||
})
|
||||
|
||||
// Suffix editor for the draft-path popover. Seeded from the current
|
||||
// path each time the popover opens so the user starts with what they
|
||||
// see, not stale state from an earlier rename.
|
||||
let draftPathSuffix = $state('')
|
||||
let draftPathError = $state<string | undefined>(undefined)
|
||||
let draftPathInput: HTMLInputElement | undefined = $state(undefined)
|
||||
|
||||
function suffixOf(fullPath: string): string {
|
||||
return fullPath.startsWith(pathPrefix) ? fullPath.slice(pathPrefix.length) : fullPath
|
||||
}
|
||||
|
||||
async function openDraftPathEditor() {
|
||||
draftPathSuffix = script ? suffixOf(script.path) : ''
|
||||
draftPathError = undefined
|
||||
await tick()
|
||||
draftPathInput?.focus()
|
||||
draftPathInput?.select()
|
||||
}
|
||||
|
||||
function confirmDraftPath(close: () => void) {
|
||||
if (!script) return
|
||||
const suffix = draftPathSuffix.trim()
|
||||
if (!suffix) {
|
||||
draftPathError = 'Path cannot be empty'
|
||||
return
|
||||
}
|
||||
const newPath = pathPrefix + suffix
|
||||
if (newPath === script.path) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
const result = onDraftPathChange?.(script.path, newPath)
|
||||
if (result === true || result === undefined) {
|
||||
script.path = newPath
|
||||
draftPathError = undefined
|
||||
close()
|
||||
} else if (typeof result === 'string') {
|
||||
draftPathError = result
|
||||
} else {
|
||||
draftPathError = 'Path already in use'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full bg-surface" bind:this={paneEl}>
|
||||
@@ -964,80 +1015,34 @@
|
||||
{@const draftScriptPath = script.path}
|
||||
<Code2 size={16} class="shrink-0 text-tertiary" />
|
||||
{#if onDraftPathChange && !readOnly}
|
||||
<!-- Inline rename popover for drafts. The persisted-script
|
||||
branch uses SummaryPathDisplay which round-trips through
|
||||
updateItemPathAndSummary; drafts have no server row yet,
|
||||
so we just rekey the parent's drafts map locally. -->
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
contentClasses="p-3"
|
||||
usePointerDownOutside
|
||||
on:openChange={(e) => {
|
||||
if (e.detail) openDraftPathEditor()
|
||||
<!-- Same summary/path popover as a deployed script, saved into the
|
||||
drafts map instead of through the API: a draft has no row yet. -->
|
||||
<SummaryPathDisplay
|
||||
summary={script.summary ?? ''}
|
||||
path={draftScriptPath}
|
||||
labels={script.labels ?? []}
|
||||
kind="script"
|
||||
onSaved={() => {}}
|
||||
saveOverride={({ path, summary, labels }) => {
|
||||
if (pathPrefix && (!path.startsWith(pathPrefix) || path === pathPrefix)) {
|
||||
return `A pipeline script stays in ${pathPrefix}: pick a name inside that folder`
|
||||
}
|
||||
// Before the rename, which carries the draft entry over as it is.
|
||||
onDraftMetaChange?.(draftScriptPath, { summary, labels })
|
||||
if (script) {
|
||||
script.summary = summary
|
||||
script.labels = labels
|
||||
}
|
||||
if (path === draftScriptPath) return undefined
|
||||
const result = onDraftPathChange(draftScriptPath, path)
|
||||
if (result !== true && result !== undefined) {
|
||||
return typeof result === 'string' ? result : 'Path already in use'
|
||||
}
|
||||
if (script) script.path = path
|
||||
return undefined
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col min-w-0 text-left px-2 py-1 rounded-md hover:bg-surface-hover transition-colors group"
|
||||
title="Edit draft path"
|
||||
>
|
||||
<span
|
||||
class="text-3xs uppercase tracking-wide text-tertiary flex items-center gap-1"
|
||||
>
|
||||
Draft pipeline script
|
||||
<Pencil size={9} class="opacity-0 group-hover:opacity-60 transition-opacity" />
|
||||
</span>
|
||||
<span class="text-xs font-mono truncate" title={draftScriptPath}
|
||||
>{draftScriptPath}</span
|
||||
>
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<div class="flex flex-col gap-2 w-[420px]">
|
||||
<span class="text-2xs font-normal text-secondary">Path</span>
|
||||
<div
|
||||
class="flex items-stretch border rounded-md bg-surface overflow-hidden focus-within:ring-2 focus-within:ring-blue-400"
|
||||
>
|
||||
{#if pathPrefix}
|
||||
<span
|
||||
class="flex items-center px-2 bg-surface-secondary text-tertiary text-sm font-mono border-r select-none"
|
||||
>
|
||||
{pathPrefix}
|
||||
</span>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={draftPathInput}
|
||||
bind:value={draftPathSuffix}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmDraftPath(close)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
class="flex-1 min-w-0 px-2 py-1.5 text-sm font-mono bg-transparent focus:outline-none"
|
||||
placeholder="my_script"
|
||||
/>
|
||||
</div>
|
||||
{#if draftPathError}
|
||||
<span class="text-2xs text-red-500">{draftPathError}</span>
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!draftPathSuffix.trim()}
|
||||
onClick={() => confirmDraftPath(close)}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
/>
|
||||
<Badge color="gray">Draft</Badge>
|
||||
{:else}
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-3xs uppercase tracking-wide text-tertiary">
|
||||
@@ -1097,6 +1102,66 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{#if selection?.kind === 'asset' && onEditScript && producerScripts.length === 1}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Pencil }}
|
||||
onclick={() => onEditScript(producerScripts[0].path)}
|
||||
title={`Open ${producerScripts[0].path}, which builds this asset`}
|
||||
>
|
||||
{editScriptLabel}
|
||||
</Button>
|
||||
{:else if selection?.kind === 'asset' && onEditScript && producerScripts.length > 1}
|
||||
<DropdownV2
|
||||
items={upstreamScriptItems(producerScripts, onEditScript)}
|
||||
placement="bottom-end"
|
||||
enableFlyTransition
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Pencil }}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
title={`${producerScripts.length} scripts build this asset`}
|
||||
>
|
||||
{editScriptLabel}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
{#if selection?.kind === 'asset' && producerTriggers.length === 1}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Zap }}
|
||||
onclick={() => producerTriggers[0].onOpen()}
|
||||
title={producerTriggers[0].detail}
|
||||
>
|
||||
{producerTriggers[0].label}
|
||||
</Button>
|
||||
{:else if selection?.kind === 'asset' && producerTriggers.length > 1}
|
||||
<DropdownV2
|
||||
items={upstreamTriggerItems(producerTriggers)}
|
||||
placement="bottom-end"
|
||||
enableFlyTransition
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Zap }}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
title={`${producerTriggers.length} triggers start the scripts that build this asset`}
|
||||
>
|
||||
Triggers
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
{#if readOnly && onRequestEdit}
|
||||
<Button
|
||||
variant="accent"
|
||||
@@ -1118,21 +1183,10 @@
|
||||
title="Discard draft"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Action order, left → right: trash, external link, save, close.
|
||||
Save sits closest to Close so the primary commit action is
|
||||
anchored at the right edge of the bar; trash lives at the
|
||||
far left so destructive ops are visually separated from
|
||||
navigation/commit. Mirrors the draft Discard placement. -->
|
||||
{#if !readOnly && !isDraft && isScriptView && script?.hash}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
onclick={() => (removeOpen = true)}
|
||||
iconOnly
|
||||
title="Archive or delete"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Action order, left → right: external link, save, close. Save sits
|
||||
closest to Close so the primary commit action is anchored at the
|
||||
right edge of the bar. Removing a script goes through its node's
|
||||
menu. -->
|
||||
{#if !readOnly && !isDraft && selection?.kind === 'runnable'}
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -1338,14 +1392,31 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !isDraft && scriptRes.loading && !script}
|
||||
<div class="absolute inset-0 flex items-center justify-center gap-2 text-tertiary">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
<span class="text-xs">Loading script…</span>
|
||||
</div>
|
||||
<!-- Blank while the script loads: the fetch is short, and a spinner only flashes. -->
|
||||
{:else if !isDraft && scriptRes.error}
|
||||
<div class="p-3 text-xs text-red-500">
|
||||
Failed to load: {scriptRes.error.message}
|
||||
</div>
|
||||
{:else if script && inputsOnly}
|
||||
{#key script.path}
|
||||
<PipelineScriptView
|
||||
{isDraft}
|
||||
canRun
|
||||
runsDrafts
|
||||
inputsOnly
|
||||
{onShowScript}
|
||||
onRun={onRunInputs && ((path, args) => onRunInputs(path, args, liveBody()))}
|
||||
script={inputsScript ?? script}
|
||||
{runsRefreshKey}
|
||||
{runsPendingJobId}
|
||||
initialArgs={runFormInitialArgs}
|
||||
onArgsChange={onRunFormArgsChange}
|
||||
onRunCompleted={() => {
|
||||
previewRefreshKey += 1
|
||||
onRunCompleted?.()
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
{:else if script && readOnly}
|
||||
<!-- Read-only modes: no Monaco/ScriptEditor (operators are
|
||||
backend-blocked from previews anyway) — highlighted source,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
|
||||
import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte'
|
||||
import type { ColumnLineage, DataTest } from './parsePipelineAnnotations'
|
||||
|
||||
@@ -103,33 +104,42 @@
|
||||
// straight under the nodes in between — the child-and-grandchild-of-the-
|
||||
// same-node case, where the tidy-tree layout puts all three on one
|
||||
// column. Detour those through the gutter beside the column.
|
||||
const SKIP_DY = NODE.gap.vertical * 2 + NODE.height
|
||||
const SKIP_DY = NODE.gap.vertical * 2 + PIPELINE_NODE_HEIGHT
|
||||
const NEAR_VERTICAL_DX = NODE.width / 4
|
||||
// Lane offset: half a node plus a margin that stays inside the
|
||||
// inter-column gutter (gap.horizontal / 2).
|
||||
const LANE = NODE.width / 2 + NODE.gap.horizontal / 2
|
||||
const CORNER = 14
|
||||
|
||||
// Rounded-orthogonal detour: drop out of the source, swing into the
|
||||
// gutter lane at `midX`, run it down past the intermediate rows, swing
|
||||
// back into the target.
|
||||
// Detour through the gutter lane at `midX`, drawn with the same curves as
|
||||
// every other edge: curve out of the source into the lane within the gap
|
||||
// below it, run down the lane past the rows in between, curve into the
|
||||
// target within the gap above it.
|
||||
function gutterPath(midX: number): string {
|
||||
const yTop = sourceY + 24
|
||||
const yBot = targetY - 24
|
||||
const outDir = midX > sourceX ? 1 : -1
|
||||
const inDir = targetX > midX ? 1 : -1
|
||||
return [
|
||||
`M ${sourceX} ${sourceY}`,
|
||||
`L ${sourceX} ${yTop - CORNER}`,
|
||||
`Q ${sourceX} ${yTop} ${sourceX + outDir * CORNER} ${yTop}`,
|
||||
`L ${midX - outDir * CORNER} ${yTop}`,
|
||||
`Q ${midX} ${yTop} ${midX} ${yTop + CORNER}`,
|
||||
`L ${midX} ${yBot - CORNER}`,
|
||||
`Q ${midX} ${yBot} ${midX + inDir * CORNER} ${yBot}`,
|
||||
`L ${targetX - inDir * CORNER} ${yBot}`,
|
||||
`Q ${targetX} ${yBot} ${targetX} ${yBot + CORNER}`,
|
||||
`L ${targetX} ${targetY}`
|
||||
].join(' ')
|
||||
const bend = NODE.gap.vertical
|
||||
const yTop = sourceY + bend
|
||||
// Clamp only downward edges: an upward (feedback) edge must still run up the
|
||||
// lane, or its last curve cuts across the node the detour was clearing.
|
||||
const yBot = targetY > sourceY ? Math.max(yTop, targetY - bend) : targetY - bend
|
||||
const [out] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX: midX,
|
||||
targetY: yTop,
|
||||
targetPosition,
|
||||
curvature: 0.25
|
||||
})
|
||||
const [into] = getBezierPath({
|
||||
sourceX: midX,
|
||||
sourceY: yBot,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
curvature: 0.25
|
||||
})
|
||||
// Continue from the lane rather than lifting the pen.
|
||||
return `${out} L${midX},${yBot} ${into.replace(/^M/, 'L')}`
|
||||
}
|
||||
|
||||
// Long edges bend out of the source within the layer gap and descend in
|
||||
@@ -178,7 +188,7 @@
|
||||
path={edgePath}
|
||||
{markerEnd}
|
||||
{style}
|
||||
interactionWidth={0}
|
||||
interactionWidth={12}
|
||||
label={undefined}
|
||||
labelStyle={undefined}
|
||||
/>
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
import { Handle, Position } from '@xyflow/svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
|
||||
import { formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
|
||||
import { formatAssetKind, formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
|
||||
import PipelineNodeCard from './PipelineNodeCard.svelte'
|
||||
import NodeActionsMenu from './NodeActionsMenu.svelte'
|
||||
import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte'
|
||||
import NodeOnDot from './NodeOnDot.svelte'
|
||||
import { PIPELINE_NODE_HEADER } from './assetGraphLayout'
|
||||
import AddDownstreamMenu from './AddDownstreamMenu.svelte'
|
||||
import AddDownstreamPill from './AddDownstreamPill.svelte'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Code2,
|
||||
@@ -12,19 +17,28 @@
|
||||
History,
|
||||
Play,
|
||||
Loader2,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
CircleSlash,
|
||||
Trash2,
|
||||
Upload,
|
||||
XCircle
|
||||
} from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
|
||||
import { sendUserToast, type Item } from '$lib/utils'
|
||||
import type { PipelineOutputKind } from './pipelineTemplates'
|
||||
import type { DbtAssetProvenance } from './types'
|
||||
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
|
||||
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
|
||||
import RunStateChip from './RunStateChip.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { upstreamScriptItems } from './upstreamScriptItems'
|
||||
import type { RunnableRunState } from './activeRunnables.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -33,6 +47,49 @@
|
||||
// `content` / `language` so the page-level run handler can dispatch to
|
||||
// `runScriptPreview` instead of `runScriptByPath` (which 404s for
|
||||
// non-deployed scripts).
|
||||
/** What starts a producing script, as its chip shows it. */
|
||||
export type TriggerChipData = {
|
||||
label: string
|
||||
/** The native trigger kind; a data upload renders as a call to action. */
|
||||
kind?: string
|
||||
/** No chip: the node's incoming edges already show what starts it. */
|
||||
hidden?: boolean
|
||||
missing?: boolean
|
||||
draft?: boolean
|
||||
/** Opens the trigger's editor; unset for what has none (manual, asset change). */
|
||||
onOpen?: () => void
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export type AssetUpstreamChips =
|
||||
| { none: true }
|
||||
| {
|
||||
multiple: true
|
||||
/** The producing scripts; one when only its triggers are ambiguous. */
|
||||
scripts: Array<{
|
||||
runnableId: string
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
unsaved?: boolean
|
||||
onOpen: () => void
|
||||
}>
|
||||
/** The scripts' own triggers, each named with its script. */
|
||||
triggers: Array<TriggerChipData & { script: string }>
|
||||
selected?: boolean
|
||||
}
|
||||
| {
|
||||
runnableId: string
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
unsaved?: boolean
|
||||
runState?: RunnableRunState
|
||||
trigger: TriggerChipData
|
||||
onOpen: () => void
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export type AssetProducer = {
|
||||
kind: 'script' | 'flow'
|
||||
path: string
|
||||
@@ -51,6 +108,8 @@
|
||||
// "current view of <dim>" marker so it reads as a derived node, not an
|
||||
// unrelated table.
|
||||
derived_from?: string
|
||||
// Why the node is misconfigured, when it is: renders it red.
|
||||
error?: string
|
||||
// dbt provenance when this warehouse table is a dbt node: which model
|
||||
// it is, how dbt materializes it, its tags and its generic tests.
|
||||
dbt?: DbtAssetProvenance
|
||||
@@ -65,10 +124,14 @@
|
||||
language: ScriptLang,
|
||||
scriptPath: string,
|
||||
outputKind: PipelineOutputKind,
|
||||
aiPrompt?: string
|
||||
aiPrompt?: string,
|
||||
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
|
||||
) => void
|
||||
pathPrefix?: string
|
||||
defaultPathSuffix?: string
|
||||
/** Starts dragging the "+" onto a script (subscribing it to this asset)
|
||||
* or onto the blank canvas (opening the add menu there). */
|
||||
onStartFeedDrag?: (e: PointerEvent) => void
|
||||
// Producer scripts/flows that write to this asset, supplied by
|
||||
// the canvas from the graph's write/rw edges. Drives the on-hover
|
||||
// "Run" button. Includes unsaved/draft producers so the button
|
||||
@@ -99,6 +162,41 @@
|
||||
// instead of only settling once the job ends.
|
||||
runStatus?: 'running' | 'materialized' | 'failed'
|
||||
runRowCount?: number | null
|
||||
/** Set in the assets-only view, where the producing script and its
|
||||
* trigger are folded into the asset: `none` for an asset nothing in the
|
||||
* pipeline writes, `multiple` when the producer or its trigger is
|
||||
* ambiguous. */
|
||||
upstream?: AssetUpstreamChips
|
||||
/** What is wrong with the node, and how to fix it: replaces the "+". */
|
||||
fix?: NodeFix
|
||||
/** Assets-only: errors of the scripts and triggers folded into this node. */
|
||||
foldedFixes?: NodeFix[]
|
||||
/** Assets-only: starts dragging the top dot onto an asset, subscribing the
|
||||
* scripts that build this one to it. */
|
||||
onStartOnDrag?: (e: PointerEvent) => void
|
||||
/** The assets-only node for the scripts that build no asset. */
|
||||
noAsset?: boolean
|
||||
/** The No-asset node's script, by its file name. */
|
||||
noAssetName?: string
|
||||
/** Assets-only: the node is only its script's banner. */
|
||||
bannerOnly?: boolean
|
||||
/** Assets-only: a script folded into the node has an error. */
|
||||
scriptError?: boolean
|
||||
/** No script in the workspace writes it; a flow's write fires no asset trigger. */
|
||||
neverWritten?: boolean
|
||||
/** Card width in the assets-only view, sized to its text. */
|
||||
width?: number
|
||||
/** Assets-only view: the producing script and trigger no other asset
|
||||
* shares, which the menu offers to delete one by one or together. */
|
||||
upstreamDelete?: import('./assetsOnlyView').AssetUpstreamDelete
|
||||
/** When no combined delete is possible: the folded scripts, each deletable
|
||||
* on its own. */
|
||||
scriptDeletes?: Array<{ path: string; unsaved: boolean }>
|
||||
triggerDeletes?: Array<NonNullable<import('./assetsOnlyView').AssetUpstreamDelete['trigger']>>
|
||||
onDeleteUpstream?: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void
|
||||
deleteVerb?: 'Delete' | 'Archive'
|
||||
/** Why nothing can be deleted, when the producer is shared. */
|
||||
deleteBlocked?: string
|
||||
}
|
||||
// SvelteFlow injects this on the node component when the user clicks
|
||||
// the node. Combined with our own `hovered` state to drive the
|
||||
@@ -140,19 +238,94 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handlePick(pick: PipelineInsertPick) {
|
||||
if (pick.kindId === 'pipeline_script' && pick.language && pick.path) {
|
||||
data.onAddScript?.(
|
||||
{ kind: data.asset_kind, path: data.path },
|
||||
pick.language as ScriptLang,
|
||||
pick.path,
|
||||
(pick.outputKind ?? 'none') as PipelineOutputKind,
|
||||
pick.aiPrompt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let showAdd = $derived(data.onAddScript != undefined)
|
||||
|
||||
// A red asset needs fixing where its error comes from, not a downstream step;
|
||||
// and a step downstream of what nothing writes would never run on its writes.
|
||||
let showAdd = $derived(data.onAddScript != undefined && !data.error && !data.neverWritten)
|
||||
let menuItems: Item[] = $derived(
|
||||
data.onDeleteUpstream && data.upstreamDelete
|
||||
? deleteItems(data.upstreamDelete, data.onDeleteUpstream)
|
||||
: data.onDeleteUpstream && (data.scriptDeletes?.length || data.triggerDeletes?.length)
|
||||
? [
|
||||
...(data.scriptDeletes ?? []).map((script) => ({ script })),
|
||||
...(data.triggerDeletes ?? []).map((trigger) => ({ trigger }))
|
||||
].flatMap((t) => deleteItems(t, data.onDeleteUpstream!))
|
||||
: data.deleteBlocked
|
||||
? [
|
||||
{
|
||||
displayName: `Can't delete: ${data.deleteBlocked}`,
|
||||
icon: Trash2,
|
||||
disabled: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
const lastSegment = (p: string) => p.split('/').pop() ?? p
|
||||
// A script is archived rather than deleted when the user may not delete it;
|
||||
// a trigger is always deleted.
|
||||
function deleteItems(
|
||||
t: import('./assetsOnlyView').AssetUpstreamDelete,
|
||||
run: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void
|
||||
): Item[] {
|
||||
const items: Item[] = []
|
||||
if (t.script) {
|
||||
const script = t.script
|
||||
items.push({
|
||||
displayName: `${data.deleteVerb ?? 'Delete'} script ${lastSegment(script.path)}`,
|
||||
icon: Trash2,
|
||||
type: 'delete' as const,
|
||||
action: () => run({ script })
|
||||
})
|
||||
}
|
||||
if (t.trigger) {
|
||||
const trigger = t.trigger
|
||||
items.push({
|
||||
displayName: `Delete ${trigger.kind} trigger ${lastSegment(trigger.path)}`,
|
||||
icon: Trash2,
|
||||
type: 'delete' as const,
|
||||
action: () => run({ trigger })
|
||||
})
|
||||
}
|
||||
if (t.script && t.trigger) {
|
||||
items.push({
|
||||
displayName: `${data.deleteVerb === 'Archive' ? 'Remove' : 'Delete'} all 2 items`,
|
||||
icon: Trash2,
|
||||
type: 'delete' as const,
|
||||
action: () => run(t)
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
let upstream = $derived(data.upstream)
|
||||
let HEADER_SELECTED = $derived(
|
||||
data.scriptError
|
||||
? 'bg-red-200 dark:bg-red-600 hover:bg-red-200 dark:hover:bg-red-600'
|
||||
: 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent'
|
||||
)
|
||||
const CHIP_SELECTED =
|
||||
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
|
||||
let producer = $derived(upstream && 'path' in upstream ? upstream : undefined)
|
||||
let coProducers = $derived(upstream && 'scripts' in upstream ? upstream.scripts : [])
|
||||
let hasHeader = $derived(!!producer || coProducers.length > 0)
|
||||
// The corner controls sit on the card's corner, below the header tab:
|
||||
// -top-2 plus PIPELINE_NODE_HEADER (22px).
|
||||
const CARD_CORNER_TOP = 'top-[14px]'
|
||||
let coSelected = $derived(!!(upstream && 'scripts' in upstream && upstream.selected))
|
||||
let coProducerItems = $derived(
|
||||
upstreamScriptItems(coProducers, (path) => coProducers.find((s) => s.path === path)?.onOpen())
|
||||
)
|
||||
let scriptMenuOpen = $state(false)
|
||||
let coTriggers = $derived(upstream && 'triggers' in upstream ? upstream.triggers : [])
|
||||
let coTriggerItems: Item[] = $derived(
|
||||
coTriggers.map((t) => ({
|
||||
displayName: `${t.label}${t.draft ? ' · draft' : ''}`,
|
||||
description: coProducers.length > 1 ? t.script : undefined,
|
||||
disabled: !t.onOpen,
|
||||
action: () => t.onOpen?.()
|
||||
}))
|
||||
)
|
||||
let triggerMenuOpen = $state(false)
|
||||
|
||||
// dbt badge. `materialized` is dbt's own word rather than the Windmill
|
||||
// strategy because `view` and `ephemeral` have no strategy, and showing the
|
||||
@@ -220,6 +393,185 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet scriptHeader()}
|
||||
<!-- The script building this asset, folded into it: a strip across the top
|
||||
naming it, which opens it. Several scripts open a menu to pick one. -->
|
||||
{@const one = producer ?? (coProducers.length === 1 ? coProducers[0] : undefined)}
|
||||
{@const headerClass = twMerge(
|
||||
'flex items-center gap-1.5 w-full min-w-0 px-2 text-3xs font-normal leading-none text-secondary hover:bg-surface-hover hover:text-primary',
|
||||
data.scriptError &&
|
||||
'bg-red-100 dark:bg-red-700 text-red-700 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-600 hover:text-red-800'
|
||||
)}
|
||||
{#if one}
|
||||
{@const isSelected = producer ? !!producer.selected : coSelected}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(headerClass, isSelected && HEADER_SELECTED)}
|
||||
title={`Open ${one.summary ? `${one.summary} (${one.path})` : one.path}${one.unsaved ? ' · draft' : ''}`}
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
one.onOpen()
|
||||
}}
|
||||
>
|
||||
{#if one.language}
|
||||
<LanguageIcon lang={one.language} width={11} height={11} />
|
||||
{:else}
|
||||
<Code2 size={11} class="shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate">{one.summary || one.path}</span>
|
||||
{#if one.unsaved}<span class="shrink-0 text-tertiary">draft</span>{/if}
|
||||
{#if producer?.runState}
|
||||
<RunStateChip runState={producer.runState} class="ml-auto h-4 px-1 rounded" />
|
||||
{/if}
|
||||
<Pencil size={10} class={twMerge('shrink-0 opacity-70', !producer?.runState && 'ml-auto')} />
|
||||
</button>
|
||||
{:else if coProducers.length > 1}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full min-w-0 flex"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownV2
|
||||
items={coProducerItems}
|
||||
placement="bottom-start"
|
||||
bind:open={scriptMenuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
enableFlyTransition
|
||||
class="w-full"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class={twMerge(headerClass, 'h-full', (coSelected || scriptMenuOpen) && HEADER_SELECTED)}
|
||||
title={`Built by ${coProducers.length} scripts: pick one to open`}
|
||||
>
|
||||
{#each coProducers.slice(0, 2) as sc (sc.path)}
|
||||
{#if sc.language}
|
||||
<LanguageIcon lang={sc.language} width={11} height={11} />
|
||||
{:else}
|
||||
<Code2 size={11} class="shrink-0" />
|
||||
{/if}
|
||||
{/each}
|
||||
<span class="truncate">{coProducers.length} scripts</span>
|
||||
<ChevronDown size={11} class="shrink-0 ml-auto" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet upstreamRow()}
|
||||
{#if data.foldedFixes?.length}
|
||||
<!-- A folded script or trigger is misconfigured: its Fix, one at a time. -->
|
||||
<NodeFixButton
|
||||
chip
|
||||
fix={data.foldedFixes[0]}
|
||||
label={`${data.foldedFixes.length} error${data.foldedFixes.length === 1 ? '' : 's'} · Fix`}
|
||||
/>
|
||||
{:else if upstream && coTriggers.length > 1 && !coTriggers.some((t) => t.onOpen)}
|
||||
<!-- Several triggers, none editable here (View mode): a plain label. -->
|
||||
<span
|
||||
class="truncate rounded-md border px-1.5 py-1 text-3xs leading-none font-normal border-gray-300 dark:border-gray-600 text-secondary"
|
||||
>Multiple triggers</span
|
||||
>
|
||||
{:else if upstream && coTriggers.length > 1}
|
||||
<!-- Several triggers start what builds this asset: one chip, a menu to pick
|
||||
the one to edit. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="min-w-0 flex"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownV2
|
||||
items={coTriggerItems}
|
||||
placement="bottom-start"
|
||||
bind:open={triggerMenuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
enableFlyTransition
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class={twMerge(
|
||||
'flex items-center gap-1 min-w-0 rounded-md border px-1.5 py-1 text-3xs leading-none font-normal border-gray-300 dark:border-gray-600 text-secondary bg-surface hover:bg-surface-hover cursor-pointer',
|
||||
(triggerMenuOpen || coTriggers.some((t) => t.selected)) && CHIP_SELECTED
|
||||
)}
|
||||
title="Started by several triggers: pick one to edit"
|
||||
>
|
||||
<span class="truncate">Multiple triggers</span>
|
||||
<ChevronDown size={9} class="shrink-0 opacity-70" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{:else if upstream}
|
||||
{@const trigger = producer?.trigger ?? coTriggers[0]}
|
||||
{@const triggerScript = producer?.path ?? coTriggers[0]?.script}
|
||||
{@const chipClass = twMerge(
|
||||
'truncate rounded-md border px-1.5 py-1 text-3xs leading-none font-normal',
|
||||
trigger?.missing
|
||||
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300'
|
||||
: 'border-gray-300 dark:border-gray-600 text-secondary',
|
||||
trigger?.draft && 'border-dashed',
|
||||
trigger?.onOpen && 'bg-surface hover:bg-surface-hover cursor-pointer',
|
||||
trigger?.selected && CHIP_SELECTED
|
||||
)}
|
||||
{#if trigger?.kind === 'data_upload' && trigger.onOpen}
|
||||
<!-- The one trigger that needs the user: nothing runs until a file is
|
||||
provided, so it reads as the node's main action rather than a chip. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="flex min-w-0"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Upload }}
|
||||
title={`Upload the data ${triggerScript} runs on`}
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation()
|
||||
trigger.onOpen?.()
|
||||
}}
|
||||
>
|
||||
Upload data
|
||||
</Button>
|
||||
</span>
|
||||
{:else if trigger?.onOpen}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(chipClass, 'flex items-center gap-1 min-w-0')}
|
||||
title={trigger.missing
|
||||
? `No ${trigger.label} trigger targets ${triggerScript} yet: click to create one`
|
||||
: `Edit the ${trigger.label} trigger`}
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
trigger.onOpen?.()
|
||||
}}
|
||||
>
|
||||
<span class="truncate">{trigger.label}{trigger.draft ? ' · draft' : ''}</span>
|
||||
{#if !trigger.missing}<Pencil size={9} class="shrink-0 opacity-70" />{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span class={chipClass}>
|
||||
{#if 'none' in upstream}
|
||||
External source
|
||||
{:else if trigger}
|
||||
{trigger.label}{trigger.draft ? ' · draft' : ''}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- onmouseenter/leave on the wrapper (not the inner card) so the run
|
||||
button — which floats outside the card — keeps the hover state alive
|
||||
when the cursor moves between the card and the button. -->
|
||||
@@ -236,123 +588,169 @@
|
||||
<div class="wm-recompute-flash pointer-events-none absolute inset-0 z-10 rounded-md"></div>
|
||||
{/key}
|
||||
{/if}
|
||||
<!-- Mirrors the flow editor's asset pill: quiet surface + gray border at
|
||||
rest, accent reserved for the selected state. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-center rounded-md drop-shadow-sm overflow-hidden border',
|
||||
'bg-surface-secondary border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500 transition-colors',
|
||||
selected && 'bg-surface-accent-selected border-border-selected'
|
||||
<NodeActionsMenu
|
||||
items={menuItems}
|
||||
hover={hovered}
|
||||
kebabClass={twMerge(
|
||||
showGuardBadge && '-right-8',
|
||||
hasHeader && !data.bannerOnly && CARD_CORNER_TOP
|
||||
)}
|
||||
style="width: {NODE.width}px; min-height: {NODE.height}px;"
|
||||
title={data.path}
|
||||
>
|
||||
<!-- Data identity carries the accent (luminance blue), pairing with
|
||||
the blue write edges that produce these assets — scripts stay
|
||||
neutral, so script vs data reads at a glance. -->
|
||||
<AssetGenericIcon
|
||||
assetKind={data.asset_kind}
|
||||
class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-blue-600 dark:text-blue-400'}`}
|
||||
size="14px"
|
||||
/>
|
||||
{#if data.runStatus}
|
||||
<!-- The run in view, per relation: a spinner while its producer is
|
||||
building it, then its outcome. Left of the name so the eye finds
|
||||
the moving nodes first on a wide graph. -->
|
||||
<span
|
||||
class="shrink-0 mr-1 {data.runStatus === 'failed'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: data.runStatus === 'materialized'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-blue-600 dark:text-blue-400'}"
|
||||
title={data.runStatus}
|
||||
{#if data.bannerOnly}
|
||||
<!-- A script that builds nothing and runs only on writes to what it reads:
|
||||
the incoming edges say how it starts, so its banner is the whole node. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-stretch min-w-0 rounded-md overflow-hidden bg-surface border border-gray-200 dark:border-gray-700 drop-shadow-sm',
|
||||
(producer ? producer.selected : coSelected) &&
|
||||
'border-border-selected dark:border-border-selected'
|
||||
)}
|
||||
style="width: {data.width ?? 160}px; height: {PIPELINE_NODE_HEADER}px;"
|
||||
>
|
||||
{#if data.runStatus === 'running'}
|
||||
<Loader2 size={11} class="animate-spin" />
|
||||
{:else if data.runStatus === 'failed'}
|
||||
<XCircle size={11} />
|
||||
{@render scriptHeader()}
|
||||
</div>
|
||||
{:else}
|
||||
<PipelineNodeCard
|
||||
kindLabel={data.noAsset ? 'No asset' : formatAssetKind(asset)}
|
||||
title={data.noAsset ? (data.noAssetName ?? '') : formatShortAssetPath(asset)}
|
||||
tooltip={data.noAsset
|
||||
? 'This script builds no asset'
|
||||
: data.error
|
||||
? `${data.path}: ${data.error}`
|
||||
: data.path}
|
||||
{selected}
|
||||
tone={data.error ? 'error' : undefined}
|
||||
subtitle={data.foldedFixes?.length ||
|
||||
(upstream &&
|
||||
!producer?.trigger?.hidden &&
|
||||
!('triggers' in upstream && upstream.triggers.length === 0))
|
||||
? upstreamRow
|
||||
: undefined}
|
||||
header={hasHeader ? scriptHeader : undefined}
|
||||
headerTone={data.scriptError ? 'error' : undefined}
|
||||
headerSelected={producer ? !!producer.selected : coSelected}
|
||||
width={data.width}
|
||||
surface={upstream ? 'primary' : 'secondary'}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<!-- Data identity carries the accent (luminance blue), pairing with
|
||||
the blue write edges that produce these assets — scripts stay
|
||||
neutral, so script vs data reads at a glance. -->
|
||||
{#if data.noAsset}
|
||||
<CircleSlash size={14} class="text-tertiary" />
|
||||
{:else}
|
||||
<CheckCircle2 size={11} />
|
||||
<AssetGenericIcon
|
||||
assetKind={data.asset_kind}
|
||||
class={data.error
|
||||
? 'text-red-600 dark:text-red-200'
|
||||
: selected
|
||||
? 'text-accent'
|
||||
: 'text-blue-600 dark:text-blue-400'}
|
||||
size="14px"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
|
||||
answer to "did this model actually produce anything" — a model that
|
||||
built green but emitted 0 rows is the failure that looks like a
|
||||
success. Only once settled: mid-build the number is not yet real. -->
|
||||
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
|
||||
<span
|
||||
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
|
||||
title="{data.runRowCount} rows written by this run"
|
||||
>
|
||||
{Intl.NumberFormat().format(data.runRowCount)}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="flex-1 min-w-0 pr-1 py-0.5 text-2xs font-mono text-emphasis truncate">
|
||||
{formatShortAssetPath(asset)}
|
||||
</span>
|
||||
<!-- Fork data-environment chip: in a fork every asset shares its parent's
|
||||
name, so the env it resolves to must read at a glance. Labeled + tinted
|
||||
(amber "parent" = deferred read of the parent's current table via a
|
||||
view; emerald "fork" = the fork's own materialized copy) rather than a
|
||||
bare icon, which was too easy to miss. The title carries the detail. -->
|
||||
{#if data.fork_materialization === 'deferred'}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
|
||||
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
|
||||
>
|
||||
<ArrowUpRight size={10} />
|
||||
parent
|
||||
</span>
|
||||
{:else if data.fork_materialization === 'fork'}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
|
||||
title="Materialized in this fork: reads and writes use the fork's isolated copy."
|
||||
>
|
||||
<GitFork size={10} />
|
||||
fork
|
||||
</span>
|
||||
{/if}
|
||||
<!-- dbt chip: names the materialization dbt declares, plus a test count
|
||||
when the model carries generic tests. Orange keeps it visually
|
||||
separate from the fork/SCD2 chips, which describe Windmill state.
|
||||
Where the project node is on the graph it also stands in for the edge
|
||||
to it: hovering lights that node up, clicking selects it. Where it is
|
||||
not — the run page, and a pipeline holding a dbt project — there is
|
||||
nothing to point at, so the chip renders inert. -->
|
||||
{#if data.dbt}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
|
||||
? 'hover:brightness-95 cursor-pointer'
|
||||
: 'cursor-default'}"
|
||||
title={dbtTitle}
|
||||
onmouseenter={() => data.onDbtHover?.(true)}
|
||||
onmouseleave={() => data.onDbtHover?.(false)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onDbtSelect?.()
|
||||
}}
|
||||
>
|
||||
<DbtIcon width={9} height={9} />
|
||||
{dbtLabel}
|
||||
{#if data.dbt.data_tests?.length}
|
||||
<span class="opacity-70">· {data.dbt.data_tests.length}T</span>
|
||||
{/snippet}
|
||||
{#snippet leading()}
|
||||
{#if data.runStatus}
|
||||
<!-- The run in view, per relation: a spinner while its producer is
|
||||
building it, then its outcome. Left of the name so the eye finds
|
||||
the moving nodes first on a wide graph. -->
|
||||
<span
|
||||
class="shrink-0 mr-1 {data.runStatus === 'failed'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: data.runStatus === 'materialized'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-blue-600 dark:text-blue-400'}"
|
||||
title={data.runStatus}
|
||||
>
|
||||
{#if data.runStatus === 'running'}
|
||||
<Loader2 size={11} class="animate-spin" />
|
||||
{:else if data.runStatus === 'failed'}
|
||||
<XCircle size={11} />
|
||||
{:else}
|
||||
<CheckCircle2 size={11} />
|
||||
{/if}
|
||||
</span>
|
||||
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
|
||||
answer to "did this model actually produce anything" — a model that
|
||||
built green but emitted 0 rows is the failure that looks like a
|
||||
success. Only once settled: mid-build the number is not yet real. -->
|
||||
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
|
||||
<span
|
||||
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
|
||||
title="{data.runRowCount} rows written by this run"
|
||||
>
|
||||
{Intl.NumberFormat().format(data.runRowCount)}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<!-- Fork data-environment chip: in a fork every asset shares its parent's
|
||||
name, so the env it resolves to must read at a glance. Labeled + tinted
|
||||
(amber "parent" = deferred read of the parent's current table via a
|
||||
view; emerald "fork" = the fork's own materialized copy) rather than a
|
||||
bare icon, which was too easy to miss. The title carries the detail. -->
|
||||
{#if data.fork_materialization === 'deferred'}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
|
||||
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
|
||||
>
|
||||
<ArrowUpRight size={10} />
|
||||
parent
|
||||
</span>
|
||||
{:else if data.fork_materialization === 'fork'}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
|
||||
title="Materialized in this fork: reads and writes use the fork's isolated copy."
|
||||
>
|
||||
<GitFork size={10} />
|
||||
fork
|
||||
</span>
|
||||
{/if}
|
||||
<!-- dbt chip: names the materialization dbt declares, plus a test count
|
||||
when the model carries generic tests. Orange keeps it visually
|
||||
separate from the fork/SCD2 chips, which describe Windmill state.
|
||||
Where the project node is on the graph it also stands in for the edge
|
||||
to it: hovering lights that node up, clicking selects it. Where it is
|
||||
not — the run page, and a pipeline holding a dbt project — there is
|
||||
nothing to point at, so the chip renders inert. -->
|
||||
{#if data.dbt}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
|
||||
? 'hover:brightness-95 cursor-pointer'
|
||||
: 'cursor-default'}"
|
||||
title={dbtTitle}
|
||||
onmouseenter={() => data.onDbtHover?.(true)}
|
||||
onmouseleave={() => data.onDbtHover?.(false)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onDbtSelect?.()
|
||||
}}
|
||||
>
|
||||
<DbtIcon width={9} height={9} />
|
||||
{dbtLabel}
|
||||
{#if data.dbt.data_tests?.length}
|
||||
<span class="opacity-70">· {data.dbt.data_tests.length}T</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
|
||||
per key" view its producer maintains alongside the base dimension.
|
||||
Icon-only (the pill already truncates); the title names the base. -->
|
||||
{#if data.derived_from}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
|
||||
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
|
||||
>
|
||||
<History size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
{/if}
|
||||
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
|
||||
per key" view its producer maintains alongside the base dimension.
|
||||
Icon-only (the pill already truncates); the title names the base. -->
|
||||
{#if data.derived_from}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
|
||||
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
|
||||
>
|
||||
<History size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</NodeActionsMenu>
|
||||
{#if showGuardBadge}
|
||||
<!-- Data-test outcome badge. Floats off the TOP-RIGHT corner (opposite the
|
||||
left-edge run button and the bottom + inserter) so it never collides
|
||||
@@ -361,6 +759,7 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute -top-2 -right-2 z-10 rounded-full w-5 h-5 grid place-items-center border shadow-sm',
|
||||
hasHeader && CARD_CORNER_TOP,
|
||||
guardClass
|
||||
)}
|
||||
title={guardTitle}
|
||||
@@ -376,7 +775,13 @@
|
||||
cards. Drafts are runnable too via runScriptPreview, so no
|
||||
greyed-out state — the page-supplied callback handles the
|
||||
dispatch. -->
|
||||
<div class="absolute -left-3 top-1/2 -translate-y-1/2 z-10">
|
||||
<!-- Centred on the card, below the header tab when there is one. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute -left-3 top-1/2 -translate-y-1/2 z-10',
|
||||
hasHeader && !data.bannerOnly && 'top-[calc(50%+11px)]'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={runProducers}
|
||||
@@ -398,53 +803,47 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showAdd}
|
||||
<!-- Always-visible + below the asset for downstream pipeline-script
|
||||
creation. Half-overlapping the bottom edge so it visually attaches
|
||||
to the node like the flow editor's between-step inserter. -->
|
||||
{#if data.onStartOnDrag}
|
||||
<NodeOnDot onStart={data.onStartOnDrag} visible={hovered} />
|
||||
{/if}
|
||||
{#if data.fix}
|
||||
<!-- A red node's way out replaces its "+": adding downstream of a broken
|
||||
node is not what it needs. -->
|
||||
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'pipeline_script',
|
||||
label: 'Add downstream pipeline script',
|
||||
description: 'Triggered when this asset changes',
|
||||
icon: Code2
|
||||
}
|
||||
]}
|
||||
languages={PIPELINE_LANGUAGES as any}
|
||||
pathPrefix={data.pathPrefix ?? ''}
|
||||
defaultPathSuffix={data.defaultPathSuffix ?? ''}
|
||||
onPick={handlePick}
|
||||
<NodeFixButton fix={data.fix} />
|
||||
</div>
|
||||
{:else if showAdd}
|
||||
<!-- Always-visible + on the asset's bottom edge for downstream pipeline-
|
||||
script creation, half-overlapping it like the flow editor's
|
||||
between-step inserter. -->
|
||||
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
|
||||
<AddDownstreamMenu
|
||||
asset={{ kind: data.asset_kind, path: data.path }}
|
||||
onAddScript={data.onAddScript!}
|
||||
pathPrefix={data.pathPrefix}
|
||||
defaultPathSuffix={data.defaultPathSuffix}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!--
|
||||
Sizing notes for the round + button:
|
||||
- w-6/h-6 (24px) chosen so that with border-2 (2px each
|
||||
side) the inner area is exactly 20px — divisible by
|
||||
the 16px icon to leave a 2px gap on every side. At
|
||||
20px / 12px before, the gap was 4px-each but the
|
||||
odd rounding interacted badly with svelte-flow's
|
||||
fractional zoom transforms and the icon drifted
|
||||
half a pixel off-center on certain zoom levels.
|
||||
- `grid place-items-center` instead of flex centering:
|
||||
flex's baseline alignment introduces a sub-pixel
|
||||
nudge on small elements that grid avoids.
|
||||
- `leading-none` strips the default line-height
|
||||
contribution that the SVG inherits via the parent's
|
||||
text rendering, otherwise the icon is shifted
|
||||
downward by ~0.5px at fractional zooms.
|
||||
-->
|
||||
{#snippet trigger({ open })}
|
||||
<!-- A click opens the menu; a drag goes to the canvas, which
|
||||
tells a real drag from a click by how far the pointer moves. -->
|
||||
<button
|
||||
type="button"
|
||||
class="nopan nodrag block"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:bg-surface-hover rounded-full w-6 h-6 grid place-items-center shadow-sm leading-none"
|
||||
title="Add downstream pipeline script"
|
||||
onpointerdown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
data.onStartFeedDrag?.(e)
|
||||
}}
|
||||
aria-label="Add downstream step"
|
||||
title={data.onStartFeedDrag
|
||||
? 'Click to add a downstream step, or drag onto a script to run it after each write'
|
||||
: undefined}
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
<AddDownstreamPill expanded={open} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</PipelineInsertMenu>
|
||||
</AddDownstreamMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
// can't be read), so a selectable version always exists in the table.
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { Table2, History, Columns3 } from 'lucide-svelte'
|
||||
import { Table2, History, Columns3, Database } from 'lucide-svelte'
|
||||
import DBManagerContent from '$lib/components/DBManagerContent.svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { resource } from 'runed'
|
||||
import { fetchDucklakeSnapshots } from '$lib/components/dbOps'
|
||||
@@ -31,7 +32,7 @@
|
||||
}
|
||||
let { path, workspace, schemaCanEvolve = true }: Props = $props()
|
||||
|
||||
let tab = $state<'partitions' | 'history' | 'schema'>('partitions')
|
||||
let tab = $state<'data' | 'partitions' | 'history' | 'schema'>('data')
|
||||
// The user's explicit snapshot pick (undefined until they click a row).
|
||||
let selectedVersion = $state<number | undefined>(undefined)
|
||||
|
||||
@@ -72,24 +73,28 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
{#if !qualifiedTable}
|
||||
<!-- Catalog-level ducklake node (no table segment, e.g. `ducklake://main`):
|
||||
snapshot history / time-travel are per-table, so only the partition
|
||||
grid applies here. -->
|
||||
<PartitionStatusGrid {path} {workspace} />
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||
<ToggleButtonGroup selected={tab} on:selected={(e) => (tab = e.detail)}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value="partitions" label="Partitions" icon={Table2} {item} />
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||
<ToggleButtonGroup selected={tab} on:selected={(e) => (tab = e.detail)}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value="data" label="Data" icon={Database} {item} />
|
||||
<ToggleButton size="sm" value="partitions" label="Partitions" icon={Table2} {item} />
|
||||
<!-- Snapshot history and time-travel are per table: a catalog-level node
|
||||
(`ducklake://main`, no table segment) has only its data and partitions. -->
|
||||
{#if qualifiedTable}
|
||||
<ToggleButton size="sm" value="schema" label="Schema" icon={Columns3} {item} />
|
||||
<ToggleButton size="sm" value="history" label="History" icon={History} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0">
|
||||
{#if tab === 'partitions'}
|
||||
<div class="flex-1 min-h-0">
|
||||
{#if tab === 'data' && parsed}
|
||||
<!-- Keyed on the asset: the manager opens its table once, on mount. -->
|
||||
{#key path}
|
||||
<DBManagerContent input={parsed} {workspace} />
|
||||
{/key}
|
||||
{:else if tab === 'partitions' || !qualifiedTable}
|
||||
<PartitionStatusGrid {path} {workspace} />
|
||||
{:else if tab === 'schema'}
|
||||
<SchemaHistoryPanel {path} {workspace} canEvolve={schemaCanEvolve} />
|
||||
@@ -117,7 +122,6 @@
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
if (!nodesInitialized.current) return
|
||||
if (!nodes.some((n) => n.type !== 'add')) return
|
||||
fittedFor = fitKey
|
||||
void fitView({ padding: 0.1, maxZoom: 1 })
|
||||
// A re-fit over nodes that keep their ids finds them already measured, one
|
||||
// render before their new positions reach the flow.
|
||||
requestAnimationFrame(() => void fitView({ padding: 0.1, maxZoom: 1 }))
|
||||
})
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user