From 039e1fa1e68901ed33db0fd089bf16d3792dfdd7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 11 Dec 2022 18:47:39 +0100 Subject: [PATCH] feat: implement usage tracker + quotas --- .../20221210194626_usage_tracker.down.sql | 1 + .../20221210194626_usage_tracker.up.sql | 8 + .../20221211055346_emails_everywhere.down.sql | 5 + .../20221211055346_emails_everywhere.up.sql | 6 + backend/sqlx-data.json | 796 ++++++++++-------- backend/tests/worker.rs | 1 + backend/windmill-api/openapi.yaml | 8 + backend/windmill-api/src/apps.rs | 73 +- backend/windmill-api/src/flows.rs | 18 +- backend/windmill-api/src/jobs.rs | 8 + backend/windmill-api/src/oauth2.rs | 10 +- backend/windmill-api/src/schedule.rs | 15 +- backend/windmill-api/src/scripts.rs | 46 +- backend/windmill-api/src/utils.rs | 13 +- backend/windmill-api/src/variables.rs | 2 +- backend/windmill-api/src/workspaces.rs | 43 +- backend/windmill-common/src/scripts.rs | 12 +- backend/windmill-common/src/utils.rs | 14 +- backend/windmill-queue/src/jobs.rs | 132 ++- backend/windmill-queue/src/schedule.rs | 22 +- backend/windmill-worker/src/jobs.rs | 106 ++- backend/windmill-worker/src/worker.rs | 72 +- backend/windmill-worker/src/worker_flow.rs | 9 +- frontend/src/lib/components/CronInput.svelte | 2 +- frontend/src/lib/components/Dropdown.svelte | 6 +- .../lib/components/icons/WindmillIcon.svelte | 6 +- frontend/src/lib/stores.ts | 1 + frontend/src/lib/utils.ts | 4 +- frontend/src/routes/apps/get/[...path].svelte | 3 +- frontend/src/routes/schedules.svelte | 38 +- .../src/routes/scripts/run/[...hash].svelte | 2 +- frontend/src/routes/variables.svelte | 20 +- 32 files changed, 860 insertions(+), 642 deletions(-) create mode 100644 backend/migrations/20221210194626_usage_tracker.down.sql create mode 100644 backend/migrations/20221210194626_usage_tracker.up.sql create mode 100644 backend/migrations/20221211055346_emails_everywhere.down.sql create mode 100644 backend/migrations/20221211055346_emails_everywhere.up.sql diff --git a/backend/migrations/20221210194626_usage_tracker.down.sql b/backend/migrations/20221210194626_usage_tracker.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/backend/migrations/20221210194626_usage_tracker.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend/migrations/20221210194626_usage_tracker.up.sql b/backend/migrations/20221210194626_usage_tracker.up.sql new file mode 100644 index 0000000000..da67eccb8a --- /dev/null +++ b/backend/migrations/20221210194626_usage_tracker.up.sql @@ -0,0 +1,8 @@ +-- Add up migration script here +CREATE TABLE usage ( + id VARCHAR(50) NOT NULL, + is_workspace BOOLEAN NOT NULL, + month_ INTEGER NOT NULL, + usage INTEGER NOT NULL, + PRIMARY KEY (id, is_workspace, month_) +); \ No newline at end of file diff --git a/backend/migrations/20221211055346_emails_everywhere.down.sql b/backend/migrations/20221211055346_emails_everywhere.down.sql new file mode 100644 index 0000000000..44f1bdaaca --- /dev/null +++ b/backend/migrations/20221211055346_emails_everywhere.down.sql @@ -0,0 +1,5 @@ +-- Add down migration script here +ALTER TABLE queue DROP COLUMN email; +ALTER TABLE workspace_settings DROP COLUMN slack_email; +ALTER TABLE schedule DROP COLUMN email; +ALTER TABLE schedule DROP COLUMN error; \ No newline at end of file diff --git a/backend/migrations/20221211055346_emails_everywhere.up.sql b/backend/migrations/20221211055346_emails_everywhere.up.sql new file mode 100644 index 0000000000..9ab6b205d2 --- /dev/null +++ b/backend/migrations/20221211055346_emails_everywhere.up.sql @@ -0,0 +1,6 @@ +-- Add up migration script here +ALTER TABLE queue ADD COLUMN email VARCHAR(50) NOT NULL DEFAULT 'missing@email.xyz'; +ALTER TABLE workspace_settings ADD COLUMN slack_email VARCHAR(50) NOT NULL DEFAULT 'missing@email.xyz';; +ALTER TABLE schedule ADD COLUMN email VARCHAR(50) NOT NULL DEFAULT 'missing@email.xyz'; +ALTER TABLE schedule ADD COLUMN error TEXT; + diff --git a/backend/sqlx-data.json b/backend/sqlx-data.json index 8382069786..b77675efa5 100644 --- a/backend/sqlx-data.json +++ b/backend/sqlx-data.json @@ -1,30 +1,5 @@ { "db": "PostgreSQL", - "02cf26c6e81ce21fa84fdc2a431663aa2db7efea3596d7eec984dd707b8ad010": { - "describe": { - "columns": [ - { - "name": "expiration", - "ordinal": 0, - "type_info": "Timestamptz" - } - ], - "nullable": [ - true - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Text", - "Bool" - ] - } - }, - "query": "INSERT INTO token\n (workspace_id, token, owner, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6) RETURNING expiration" - }, "0355b53b1d45955ca56b2829372ce9c656d7f0ad7b8d0709161047f0d8cdc4f4": { "describe": { "columns": [ @@ -258,73 +233,6 @@ }, "query": "DELETE FROM account WHERE id = $1 AND workspace_id = $2" }, - "0eea65f84168d4425d20d24b321cdee98b69dbf1e8a75e916af6d37abc4aee92": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Uuid" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Varchar", - "Uuid", - "Bool", - "Uuid", - "Varchar", - "Varchar", - "Timestamptz", - "Int8", - "Varchar", - "Text", - "Text", - "Jsonb", - { - "Custom": { - "kind": { - "Enum": [ - "script", - "preview", - "flow", - "dependencies", - "flowpreview", - "script_hub", - "identity", - "flowdependencies" - ] - }, - "name": "job_kind" - } - }, - "Varchar", - "Jsonb", - "Jsonb", - "Bool", - { - "Custom": { - "kind": { - "Enum": [ - "python3", - "deno", - "go", - "bash" - ] - }, - "name": "script_lang" - } - }, - "Bool", - "Text" - ] - } - }, - "query": "INSERT INTO queue\n (workspace_id, id, running, parent_job, created_by, permissioned_as, scheduled_for, \n script_hash, script_path, raw_code, raw_lock, args, job_kind, schedule_path, raw_flow, flow_status, is_flow_step, language, started_at, same_worker, pre_run_error)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, CASE WHEN $3 THEN now() END, $19, $20) RETURNING id" - }, "11b1586acdfc180c5a077861ee1f7201fcbcec9d0ebada464f9d952c9c3e400d": { "describe": { "columns": [], @@ -472,13 +380,19 @@ "name": "slack_command_script", "ordinal": 3, "type_info": "Varchar" + }, + { + "name": "slack_email", + "ordinal": 4, + "type_info": "Varchar" } ], "nullable": [ false, true, true, - true + true, + false ], "parameters": { "Left": [ @@ -594,6 +508,74 @@ }, "query": "UPDATE queue SET suspend = $1 WHERE id = $2" }, + "207b9cca0240dd6eea3f3b95c093ead9d7bb48fae2484e1a4086636396c0dcb7": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Uuid" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Varchar", + "Uuid", + "Bool", + "Uuid", + "Varchar", + "Varchar", + "Timestamptz", + "Int8", + "Varchar", + "Text", + "Text", + "Jsonb", + { + "Custom": { + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies" + ] + }, + "name": "job_kind" + } + }, + "Varchar", + "Jsonb", + "Jsonb", + "Bool", + { + "Custom": { + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash" + ] + }, + "name": "script_lang" + } + }, + "Bool", + "Text", + "Varchar" + ] + } + }, + "query": "INSERT INTO queue\n (workspace_id, id, running, parent_job, created_by, permissioned_as, scheduled_for, \n script_hash, script_path, raw_code, raw_lock, args, job_kind, schedule_path, raw_flow, flow_status, is_flow_step, language, started_at, same_worker, pre_run_error, email)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, CASE WHEN $3 THEN now() END, $19, $20, $21) RETURNING id" + }, "20e8a8343adc4a3a28a1c4908a13047cddafc4480cf028d3455fd17347cace73": { "describe": { "columns": [ @@ -754,20 +736,6 @@ }, "query": "SELECT now()" }, - "282655dc568d000379196fae0dee32438a824d93f4ad2fcc155269e76f4ca20d": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar" - ] - } - }, - "query": "INSERT INTO workspace_settings\n (workspace_id, slack_team_id, slack_name)\n VALUES ($1, $2, $3) ON CONFLICT (workspace_id) DO UPDATE SET slack_team_id = $2, slack_name = $3" - }, "28c042adef65c3055edc324fbbd2f267285d3566cbec58404983323d410ace27": { "describe": { "columns": [ @@ -918,6 +886,20 @@ }, "query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth)\n VALUES ($1, $2, $3, $4, $5, $6, $7)" }, + "2ee6d24b95cdda151585dcff19f8e7c931785fc21f7bbe9c3a82671943ced0ea": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + } + }, + "query": "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3" + }, "355dcb2cbebd13f0e3bdd4929b9e431b0e6d72716d1c4f9ab6af6adce5b5e4b3": { "describe": { "columns": [ @@ -1050,6 +1032,26 @@ }, "query": "UPDATE app SET versions = array_append(versions, $1) WHERE id = $2" }, + "3e785610aa9f63241c16affc1f4a20c52f2aeb2aed9fc9e3b8c24874c87b14dc": { + "describe": { + "columns": [ + { + "name": "usage", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Varchar" + ] + } + }, + "query": "INSERT INTO usage (id, is_workspace, month_, usage) \n VALUES ($1, true, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) \n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1 \n RETURNING usage.usage" + }, "3fabb3857c9cf2d057814b54ee54a95d01b6a7d9e89bea239b832a9d70f0044b": { "describe": { "columns": [], @@ -1251,6 +1253,16 @@ "name": "is_flow", "ordinal": 10, "type_info": "Bool" + }, + { + "name": "email", + "ordinal": 11, + "type_info": "Varchar" + }, + { + "name": "error", + "ordinal": 12, + "type_info": "Text" } ], "nullable": [ @@ -1264,7 +1276,9 @@ false, true, false, - false + false, + false, + true ], "parameters": { "Left": [ @@ -1360,6 +1374,23 @@ }, "query": "\n UPDATE queue\n SET flow_status = JSONB_SET(\n JSONB_SET(flow_status, ARRAY['modules', $1::TEXT], $2),\n ARRAY['step'], $3)\n WHERE id = $4\n " }, + "53f2836a652d34aafe6ffb266a092d7390212bc5532dcc226529a61a53deec42": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Bool" + ] + } + }, + "query": "INSERT INTO token\n (workspace_id, token, owner, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6)" + }, "541ebd3bac65431237cf3b882dfdcd61ca97c253d9754d05bba59fda89841067": { "describe": { "columns": [ @@ -1414,13 +1445,19 @@ "name": "slack_command_script", "ordinal": 3, "type_info": "Varchar" + }, + { + "name": "slack_email", + "ordinal": 4, + "type_info": "Varchar" } ], "nullable": [ false, true, true, - true + true, + false ], "parameters": { "Left": [ @@ -1515,6 +1552,19 @@ }, "query": "SELECT app.id, app.path, app.summary, app.versions, app.policy,\n app.extra_perms, app_version.value, \n app_version.created_at, app_version.created_by from app, app_version \n WHERE app_version.id = $1 AND app.id = app_version.flow_id AND app.workspace_id = $2" }, + "576b00c515ee7cbf628b1881596d8a03c1c506b309e39a0048a3a8fe18f37070": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "UPDATE schedule SET error = NULL WHERE workspace_id = $1 AND path = $2" + }, "5850e0c1f7dee98455027a4c0db6ccea0af2c58aee656ef43964b515de054888": { "describe": { "columns": [ @@ -1575,27 +1625,6 @@ }, "query": "SELECT * from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'starter')" }, - "5b7a1d16d8109a65479ab33d411c60d14ea91d870fdff8606d7aa4ad39f0ba00": { - "describe": { - "columns": [ - { - "name": "email", - "ordinal": 0, - "type_info": "Varchar" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - } - }, - "query": "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2" - }, "5b9b58612ca0f703a5d154a76fab82ac2329aef965fa937bfab2810b6e1336a4": { "describe": { "columns": [], @@ -1876,26 +1905,6 @@ }, "query": "SELECT policy from app WHERE path = $1 AND workspace_id = $2" }, - "6eacfedfc1ab2431c318996d2ff480a65d55fc43d8fa95aa4e2e8430722dc82d": { - "describe": { - "columns": [ - { - "name": "email", - "ordinal": 0, - "type_info": "Varchar" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "SELECT email FROM usr WHERE username = $1" - }, "6fc2cfae9df83eb24ea33e4c9567740100f4dd2285afc3ef474fc70041b0567b": { "describe": { "columns": [ @@ -2461,6 +2470,16 @@ "name": "is_flow", "ordinal": 10, "type_info": "Bool" + }, + { + "name": "email", + "ordinal": 11, + "type_info": "Varchar" + }, + { + "name": "error", + "ordinal": 12, + "type_info": "Text" } ], "nullable": [ @@ -2474,7 +2493,9 @@ false, true, false, - false + false, + false, + true ], "parameters": { "Left": [ @@ -2721,27 +2742,6 @@ }, "query": "INSERT INTO app_version\n (flow_id, value, created_by)\n VALUES ($1, $2, $3) RETURNING id" }, - "92ee6274b2287bc4d368bfdb47d6452df75bd265cdd891f55112df2795294dac": { - "describe": { - "columns": [ - { - "name": "count", - "ordinal": 0, - "type_info": "Int8" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - } - }, - "query": "SELECT COUNT(id) FROM queue WHERE permissioned_as = $1 AND workspace_id = $2" - }, "9490a4388f43e45e32911f1129e623f2d73ce2da7a948fe134bea1c87cdbefd1": { "describe": { "columns": [ @@ -2784,6 +2784,101 @@ }, "query": "\n UPDATE queue\n SET flow_status = JSONB_SET(flow_status, ARRAY['modules', $1::TEXT, 'branchall', 'branch'], ((flow_status->'modules'->$1::int->'branchall'->>'branch')::int + 1)::text::jsonb)\n WHERE id = $2\n RETURNING (flow_status->'modules'->$1::int->'branchall'->>'branch')::int\n " }, + "96dc1cd308f3c21cfb50b88048054dc03f93e261d25969d66aa48e9d0502960f": { + "describe": { + "columns": [ + { + "name": "workspace_id", + "ordinal": 0, + "type_info": "Varchar" + }, + { + "name": "path", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "edited_by", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "edited_at", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "schedule", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "offset_", + "ordinal": 5, + "type_info": "Int4" + }, + { + "name": "enabled", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "script_path", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "args", + "ordinal": 8, + "type_info": "Jsonb" + }, + { + "name": "extra_perms", + "ordinal": 9, + "type_info": "Jsonb" + }, + { + "name": "is_flow", + "ordinal": 10, + "type_info": "Bool" + }, + { + "name": "email", + "ordinal": 11, + "type_info": "Varchar" + }, + { + "name": "error", + "ordinal": 12, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Bool", + "Varchar", + "Text", + "Text" + ] + } + }, + "query": "UPDATE schedule SET enabled = $1, email = $2 WHERE path = $3 AND workspace_id = $4 RETURNING *" + }, "97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c": { "describe": { "columns": [ @@ -3008,6 +3103,16 @@ "name": "is_flow", "ordinal": 10, "type_info": "Bool" + }, + { + "name": "email", + "ordinal": 11, + "type_info": "Varchar" + }, + { + "name": "error", + "ordinal": 12, + "type_info": "Text" } ], "nullable": [ @@ -3021,7 +3126,9 @@ false, true, false, - false + false, + false, + true ], "parameters": { "Left": [ @@ -3032,6 +3139,19 @@ }, "query": "SELECT * FROM schedule WHERE path = $1 AND workspace_id = $2" }, + "ab690bfb3fd84eb0a833818352501e1560d22fd47b9a676571ea6efdad1ad300": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int4" + ] + } + }, + "query": "INSERT INTO usage (id, is_workspace, month_, usage) \n VALUES ($1, false, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) \n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + $2" + }, "abc9f034e62ac224894173356aa69e09f3647a45d176e253e4fc8f7206f6a18d": { "describe": { "columns": [ @@ -3181,6 +3301,26 @@ }, "query": "UPDATE usr SET is_admin = $1 WHERE username = $2 AND workspace_id = $3" }, + "b2e14f4fe46b82fe333eb76cd75fdac0ab205a14999becb394b09c6e51af284f": { + "describe": { + "columns": [ + { + "name": "usage", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Varchar" + ] + } + }, + "query": "INSERT INTO usage (id, is_workspace, month_, usage) \n VALUES ($1, false, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) \n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1 \n RETURNING usage.usage" + }, "b3b80de52d0931a2fdb5d38b7603a2d69cc25ab1cda413228c363a5ffd777113": { "describe": { "columns": [ @@ -3464,88 +3604,6 @@ }, "query": "UPDATE flow SET path = $1, summary = $2, description = $3, value = $4, edited_by = $5, edited_at = now(), schema = $6, dependency_job = NULL WHERE path = $7 AND workspace_id = $8" }, - "c213427a32e8903fff649a3e7aa8392d2215665ed04f5a8f2178861e9dba298a": { - "describe": { - "columns": [ - { - "name": "workspace_id", - "ordinal": 0, - "type_info": "Varchar" - }, - { - "name": "path", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "edited_by", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "edited_at", - "ordinal": 3, - "type_info": "Timestamptz" - }, - { - "name": "schedule", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "offset_", - "ordinal": 5, - "type_info": "Int4" - }, - { - "name": "enabled", - "ordinal": 6, - "type_info": "Bool" - }, - { - "name": "script_path", - "ordinal": 7, - "type_info": "Varchar" - }, - { - "name": "args", - "ordinal": 8, - "type_info": "Jsonb" - }, - { - "name": "extra_perms", - "ordinal": 9, - "type_info": "Jsonb" - }, - { - "name": "is_flow", - "ordinal": 10, - "type_info": "Bool" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - false - ], - "parameters": { - "Left": [ - "Bool", - "Text", - "Text" - ] - } - }, - "query": "UPDATE schedule SET enabled = $1 WHERE path = $2 AND workspace_id = $3 RETURNING *" - }, "c2849e67b9fea0dc46e6d7000f5a0c9dab89ae80a183d9255f8fdb356b4bc61c": { "describe": { "columns": [ @@ -4107,26 +4165,106 @@ }, "query": "\n UPDATE queue\n SET flow_status = JSONB_SET(flow_status, ARRAY['modules', $1::TEXT, 'iterator', 'index'], ((flow_status->'modules'->$1::int->'iterator'->>'index')::int + 1)::text::jsonb)\n WHERE id = $2\n RETURNING (flow_status->'modules'->$1::int->'iterator'->>'index')::int\n " }, - "e69142610bd94f6c65022cca5ccf7458b7c5bb77b3ed0278715222dd920d8644": { + "e5ba82116000d2ae71e2099f417b39121178349fda9f8438bdd5556ff632ceb2": { "describe": { "columns": [ { - "name": "sum", + "name": "workspace_id", "ordinal": 0, - "type_info": "Int8" + "type_info": "Varchar" + }, + { + "name": "path", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "edited_by", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "edited_at", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "schedule", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "offset_", + "ordinal": 5, + "type_info": "Int4" + }, + { + "name": "enabled", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "script_path", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "args", + "ordinal": 8, + "type_info": "Jsonb" + }, + { + "name": "extra_perms", + "ordinal": 9, + "type_info": "Jsonb" + }, + { + "name": "is_flow", + "ordinal": 10, + "type_info": "Bool" + }, + { + "name": "email", + "ordinal": 11, + "type_info": "Varchar" + }, + { + "name": "error", + "ordinal": 12, + "type_info": "Text" } ], "nullable": [ - null + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true ], "parameters": { "Left": [ - "Text", - "Text" + "Varchar", + "Varchar", + "Varchar", + "Int4", + "Varchar", + "Varchar", + "Bool", + "Jsonb", + "Bool", + "Varchar" ] } }, - "query": "\n SELECT SUM(duration_ms)\n FROM completed_job\n WHERE permissioned_as = $1\n AND created_at > NOW() - INTERVAL '1200 seconds'\n AND workspace_id = $2" + "query": "INSERT INTO schedule (workspace_id, path, schedule, offset_, edited_by, script_path, is_flow, args, enabled, email) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *" }, "e838b15953b567af2a2da6a4e3a04add5ec2cacabd93171a97c5fc978a64f8aa": { "describe": { @@ -4156,6 +4294,21 @@ }, "query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, path) DO UPDATE SET value = $3" }, + "eaa6e9dc4c0d3d6cc8152515019befa880bb3b69ff340d337edd14b65e74e2a3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + } + }, + "query": "INSERT INTO workspace_settings\n (workspace_id, slack_team_id, slack_name, slack_email)\n VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id) DO UPDATE SET slack_team_id = $2, slack_name = $3, slack_email = $4" + }, "ed400dbf4a1fa597bc984240cfe9069369a8a265b127116187d78c0ba93ae076": { "describe": { "columns": [ @@ -4296,94 +4449,6 @@ }, "query": "\n SELECT payload\n FROM capture\n WHERE workspace_id = $1\n AND path = $2\n " }, - "f4a1bb4dfeafa3f28385c19d10730f2f49b4ebe5bba631cee46c760cee2e6949": { - "describe": { - "columns": [ - { - "name": "workspace_id", - "ordinal": 0, - "type_info": "Varchar" - }, - { - "name": "path", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "edited_by", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "edited_at", - "ordinal": 3, - "type_info": "Timestamptz" - }, - { - "name": "schedule", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "offset_", - "ordinal": 5, - "type_info": "Int4" - }, - { - "name": "enabled", - "ordinal": 6, - "type_info": "Bool" - }, - { - "name": "script_path", - "ordinal": 7, - "type_info": "Varchar" - }, - { - "name": "args", - "ordinal": 8, - "type_info": "Jsonb" - }, - { - "name": "extra_perms", - "ordinal": 9, - "type_info": "Jsonb" - }, - { - "name": "is_flow", - "ordinal": 10, - "type_info": "Bool" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - false - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Int4", - "Varchar", - "Varchar", - "Bool", - "Jsonb", - "Bool" - ] - } - }, - "query": "INSERT INTO schedule (workspace_id, path, schedule, offset_, edited_by, script_path, is_flow, args, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *" - }, "f516ca558816c2cbab3c8ae865ef8f764aa4686e7df9c53d30d49a3dbbf36af4": { "describe": { "columns": [ @@ -4404,6 +4469,19 @@ }, "query": "SELECT SUM(duration_ms) as duration FROM completed_job WHERE id = ANY($1)" }, + "f67ba6ca3ed802ca41e49a310a4161ea8d81f79021f11f3a9f2b65e93ce6312d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int4" + ] + } + }, + "query": "INSERT INTO usage (id, is_workspace, month_, usage) \n VALUES ($1, true, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) \n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + $2" + }, "f7906298e4204ad55ec84021bb2461f369386493519637279f1188227230c580": { "describe": { "columns": [ @@ -4571,6 +4649,24 @@ }, "query": "SELECT * FROM resource WHERE workspace_id = $1" }, + "fa32fb9e4676dd91e20226b2a88672aed65ef33c39be7cdf63c733020a46e212": { + "describe": { + "columns": [ + { + "name": "?column?", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [] + } + }, + "query": "\n SELECT usage.usage + 1 FROM usage \n WHERE is_workspace = false AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)" + }, "fa567c205929f41799a64aefac97c4504c7993b22478530e9345b9bc117e92e9": { "describe": { "columns": [ diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 062edf0c96..c92f0edeb5 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -823,6 +823,7 @@ impl RunJob { payload, args, /* user */ "test-user", + /* email */ "test@windmill.dev", /* permissioned_as */ "u/admin".to_string(), /* scheduled_for_o */ None, /* schedule_path */ None, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 854a70e9b0..79be07f205 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4041,6 +4041,8 @@ components: language: type: string enum: [python3, deno, go, bash] + email: + type: string required: - id - running @@ -4048,6 +4050,7 @@ components: - job_kind - permissioned_as - is_flow_step + - email CompletedJob: type: object @@ -4633,6 +4636,10 @@ components: type: object additionalProperties: type: boolean + email: + type: string + error: + type: string required: - path - edited_by @@ -4643,6 +4650,7 @@ components: - extra_perms - is_flow - enabled + - email NewSchedule: type: object diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index cba52ebada..50321f730f 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -94,6 +94,7 @@ pub enum ExecutionMode { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Policy { pub on_behalf_of: Option, + pub on_behalf_of_email: Option, //paths: // - script/ // - flow/ @@ -457,49 +458,40 @@ async fn execute_component( static_fields, ); } - Policy { execution_mode: ExecutionMode::Viewer, triggerables: hm, on_behalf_of: None } + Policy { + execution_mode: ExecutionMode::Viewer, + triggerables: hm, + on_behalf_of: None, + on_behalf_of_email: None, + } } else { policy }; - let (username, permissioned_as) = match policy.execution_mode { + let (username, permissioned_as, email) = match policy.execution_mode { ExecutionMode::Anonymous => { let username = opt_authed .map(|a| a.username) .unwrap_or_else(|| "anonymous".to_string()); - let permissioned_as = policy - .on_behalf_of - .as_ref() - .ok_or_else(|| { - Error::BadRequest( - "on_behalf_of is missing in the app policy and is required for anonymous execution" - .to_string(), - ) - })? - .to_string(); - (username, permissioned_as) + let (permissioned_as, email) = get_on_behalf_of(&policy)?; + (username, permissioned_as, email) } ExecutionMode::Publisher => { let username = opt_authed.map(|a| a.username).ok_or_else(|| { Error::BadRequest("publisher execution mode requires authentication".to_string()) })?; - let permissioned_as = policy - .on_behalf_of - .as_ref() - .ok_or_else(|| { - Error::BadRequest( - "on_behalf_of is missing in the app policy and is required for publisher execution" - .to_string(), - ) - })? - .to_string(); - (username, permissioned_as) + let (permissioned_as, email) = get_on_behalf_of(&policy)?; + (username, permissioned_as, email) } ExecutionMode::Viewer => { - let username = opt_authed - .map(|a| a.username) - .ok_or_else(|| Error::BadRequest("".to_string()))?; - (username.clone(), owner_to_token_owner(&username, false)) + let (username, email) = opt_authed.map(|a| (a.username, a.email)).ok_or_else(|| { + Error::BadRequest("Required to be authed in viewer mode".to_string()) + })?; + ( + username.clone(), + owner_to_token_owner(&username, false), + email, + ) } }; @@ -535,6 +527,7 @@ async fn execute_component( job_payload, args, &username, + &email, permissioned_as, None, None, @@ -549,6 +542,30 @@ async fn execute_component( Ok(uuid.to_string()) } +fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> { + let permissioned_as = policy + .on_behalf_of + .as_ref() + .ok_or_else(|| { + Error::BadRequest( + "on_behalf_of is missing in the app policy and is required for anonymous execution" + .to_string(), + ) + })? + .to_string(); + let email = policy + .on_behalf_of_email + .as_ref() + .ok_or_else(|| { + Error::BadRequest( + "on_behalf_of is missing in the app policy and is required for anonymous execution" + .to_string(), + ) + })? + .to_string(); + Ok((permissioned_as, email)) +} + async fn exists_app( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index 9f62f5aa41..a485f5d2fe 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -11,7 +11,7 @@ use reqwest::Client; use sql_builder::prelude::*; use axum::{ - extract::{Extension, Host, Path, Query}, + extract::{Extension, Path, Query}, routing::{get, post}, Json, Router, }; @@ -108,33 +108,27 @@ async fn list_flows( } async fn list_hub_flows( - Authed { email, username, .. }: Authed, + Authed { email, .. }: Authed, Extension(http_client): Extension, - Host(host): Host, ) -> JsonResult { let flows = list_elems_from_hub( http_client, "https://hub.windmill.dev/searchFlowData?approved=true", - email, - username, - host, + &email, ) .await?; Ok(Json(flows)) } pub async fn get_hub_flow_by_id( - Authed { email, username, .. }: Authed, + Authed { email, .. }: Authed, Path(id): Path, Extension(http_client): Extension, - Host(host): Host, ) -> JsonResult { let value = http_get_from_hub( http_client, &format!("https://hub.windmill.dev/flows/{id}/json"), - email, - username, - host, + &email, false, ) .await? @@ -194,6 +188,7 @@ async fn create_flow( JobPayload::FlowDependencies { path: nf.path.clone() }, serde_json::Map::new(), &authed.username, + &authed.email, windmill_common::users::owner_to_token_owner(&authed.username, false), None, None, @@ -299,6 +294,7 @@ async fn update_flow( JobPayload::FlowDependencies { path: nf.path.clone() }, serde_json::Map::new(), &authed.username, + &authed.email, windmill_common::users::owner_to_token_owner(&authed.username, false), None, None, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 02e1c34563..c07d6febc4 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -868,6 +868,7 @@ impl From for Job { language: uj.language, same_worker: false, pre_run_error: None, + email: "".to_string(), }), t => panic!("job type {} not valid", t), } @@ -957,6 +958,7 @@ pub async fn run_flow_by_path( JobPayload::Flow(flow_path.to_string()), args, &authed.username, + &authed.email, owner_to_token_owner(&authed.username, false), scheduled_for, None, @@ -990,6 +992,7 @@ pub async fn run_job_by_path( job_payload, args, &authed.username, + &authed.email, owner_to_token_owner(&authed.username, false), scheduled_for, None, @@ -1056,6 +1059,7 @@ pub async fn run_wait_result_job_by_path( job_payload, args, &authed.username, + &authed.email, owner_to_token_owner(&authed.username, false), scheduled_for, None, @@ -1090,6 +1094,7 @@ pub async fn run_wait_result_job_by_hash( JobPayload::ScriptHash { hash: ScriptHash(hash), path }, args, &authed.username, + &authed.email, owner_to_token_owner(&authed.username, false), scheduled_for, None, @@ -1142,6 +1147,7 @@ async fn run_preview_job( }), args, &authed.username, + &authed.email, owner_to_token_owner(&authed.username, false), scheduled_for, None, @@ -1173,6 +1179,7 @@ async fn run_preview_flow_job( JobPayload::RawFlow { value: raw_flow.value, path: raw_flow.path }, args, &authed.username, + &authed.email, owner_to_token_owner(&authed.username, false), scheduled_for, None, @@ -1206,6 +1213,7 @@ pub async fn run_job_by_hash( JobPayload::ScriptHash { hash: ScriptHash(hash), path }, args, &authed.username, + &authed.email, owner_to_token_owner(&authed.username, false), scheduled_for, None, diff --git a/backend/windmill-api/src/oauth2.rs b/backend/windmill-api/src/oauth2.rs index 378d0360ea..5dcf3e53a6 100644 --- a/backend/windmill-api/src/oauth2.rs +++ b/backend/windmill-api/src/oauth2.rs @@ -644,12 +644,13 @@ async fn connect_slack_callback( sqlx::query!( "INSERT INTO workspace_settings - (workspace_id, slack_team_id, slack_name) - VALUES ($1, $2, $3) ON CONFLICT (workspace_id) DO UPDATE SET slack_team_id = $2, \ - slack_name = $3", + (workspace_id, slack_team_id, slack_name, slack_email) + VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id) DO UPDATE SET slack_team_id = $2, \ + slack_name = $3, slack_email = $4", &w_id, token.team_id, - token.team_name + token.team_name, + authed.email ) .execute(&mut tx) .await?; @@ -796,6 +797,7 @@ async fn slack_command( payload, map, &form.user_name, + &settings.slack_email, "g/slack".to_string(), None, None, diff --git a/backend/windmill-api/src/schedule.rs b/backend/windmill-api/src/schedule.rs index 36e9d74511..7ffc351ee8 100644 --- a/backend/windmill-api/src/schedule.rs +++ b/backend/windmill-api/src/schedule.rs @@ -47,7 +47,9 @@ async fn create_schedule( Json(ns): Json, ) -> Result { let tx = user_db.begin(&authed).await?; - let res = windmill_queue::schedule::create_schedule(tx, w_id, ns, &authed.username).await?; + let res = + windmill_queue::schedule::create_schedule(tx, w_id, ns, &authed.username, &authed.email) + .await?; Ok(res) } @@ -110,8 +112,15 @@ pub async fn set_enabled( Json(payload): Json, ) -> Result { let tx = user_db.begin(&authed).await?; - let res = - windmill_queue::schedule::set_enabled(tx, w_id, path, payload, &authed.username).await?; + let res = windmill_queue::schedule::set_enabled( + tx, + w_id, + path, + payload, + &authed.username, + &authed.email, + ) + .await?; Ok(res) } diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index 92fe8a874e..c8a9572bf8 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -12,10 +12,10 @@ use windmill_audit::{audit_log, ActionKind}; use crate::{ db::{UserDB, DB}, - users::{truncate_token, Authed, Tokened}, + users::Authed, }; use axum::{ - extract::{Extension, Host, Path, Query}, + extract::{Extension, Path, Query}, routing::{get, post}, Json, Router, }; @@ -160,16 +160,13 @@ async fn list_scripts( } async fn list_hub_scripts( - Authed { email, username, .. }: Authed, + Authed { email, .. }: Authed, Extension(http_client): Extension, - Host(host): Host, ) -> JsonResult { let asks = list_elems_from_hub( http_client, "https://hub.windmill.dev/searchData?approved=true", - email, - username, - host, + &email, ) .await?; Ok(Json(asks)) @@ -182,7 +179,6 @@ fn hash_script(ns: &NewScript) -> i64 { } async fn create_script( authed: Authed, - Tokened { token }: Tokened, Extension(user_db): Extension, Path(w_id): Path, Json(ns): Json, @@ -293,7 +289,7 @@ async fn create_script( .map(|v| v.1.clone()) .unwrap_or(json!({})); - let lock = if ns.language == ScriptLang::Bash { + let lock = if ns.language == ScriptLang::Bash || ns.language == ScriptLang::Deno { Some(String::new()) } else { ns.lock @@ -339,6 +335,7 @@ async fn create_script( windmill_queue::JobPayload::Dependencies { hash, dependencies, language: ns.language }, serde_json::Map::new(), &authed.username, + &authed.email, owner_to_token_owner(&authed.username, false), None, None, @@ -361,13 +358,7 @@ async fn create_script( ActionKind::Update, &w_id, Some(&ns.path), - Some( - [ - ("hash", hash.to_string().as_str()), - ("token", &truncate_token(&token)), - ] - .into(), - ), + Some([("hash", hash.to_string().as_str())].into()), ) .await?; } else { @@ -382,7 +373,6 @@ async fn create_script( [ ("workspace", w_id.as_str()), ("hash", hash.to_string().as_str()), - ("token", &truncate_token(&token)), ] .into(), ), @@ -399,33 +389,17 @@ pub async fn get_hub_script_by_path( authed: Authed, Path(path): Path, Extension(http_client): Extension, - Host(host): Host, ) -> Result { - windmill_common::scripts::get_hub_script_by_path( - authed.email, - authed.username, - path, - http_client, - host, - ) - .await + windmill_common::scripts::get_hub_script_by_path(&authed.email, path, http_client).await } pub async fn get_full_hub_script_by_path( - Authed { username, email, .. }: Authed, + Authed { email, .. }: Authed, Path(path): Path, Extension(http_client): Extension, - Host(host): Host, ) -> JsonResult { Ok(Json( - windmill_common::scripts::get_full_hub_script_by_path( - email, - username, - path, - http_client, - host, - ) - .await?, + windmill_common::scripts::get_full_hub_script_by_path(&email, path, http_client).await?, )) } diff --git a/backend/windmill-api/src/utils.rs b/backend/windmill-api/src/utils.rs index 156bf9c650..864e05d7fd 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -11,15 +11,12 @@ use windmill_common::error::{self, Error}; pub async fn require_super_admin<'c>( db: &mut Transaction<'c, Postgres>, - email: Option, + email: &str, ) -> error::Result<()> { - let is_admin = sqlx::query_scalar!( - "SELECT super_admin FROM password WHERE email = $1", - email.as_ref() - ) - .fetch_one(db) - .await - .map_err(|e| Error::InternalErr(format!("fetching super admin: {e}")))?; + let is_admin = sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email) + .fetch_one(db) + .await + .map_err(|e| Error::InternalErr(format!("fetching super admin: {e}")))?; if !is_admin { Err(Error::NotAuthorized( "This endpoint require caller to be a super admin".to_owned(), diff --git a/backend/windmill-api/src/variables.rs b/backend/windmill-api/src/variables.rs index b12871838f..4bdc545a38 100644 --- a/backend/windmill-api/src/variables.rs +++ b/backend/windmill-api/src/variables.rs @@ -60,7 +60,7 @@ async fn list_contextual_variables( get_reserved_variables( &w_id, "q1A0qcPuO00yxioll7iph76N9CJDqn", - &email.unwrap_or_else(|| "no email".to_string()), + &email, &username, "017e0ad5-f499-73b6-5488-92a61c5196dd", format!("u/{username}").as_str(), diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 6fad06f1aa..45066b37c9 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -46,6 +46,7 @@ pub fn workspaced_service() -> Router { .route("/get_settings", get(get_settings)) .route("/edit_slack_command", post(edit_slack_command)) .route("/tarball", get(tarball_workspace)) + .route("/usage", get(get_usage)) } pub fn global_service() -> Router { @@ -74,6 +75,16 @@ pub struct WorkspaceSettings { pub slack_team_id: Option, pub slack_name: Option, pub slack_command_script: Option, + pub slack_email: String, +} + +#[derive(FromRow, Serialize, Debug)] +pub struct Usage { + pub workspace_id: String, + pub slack_team_id: Option, + pub slack_name: Option, + pub slack_command_script: Option, + pub slack_email: String, } #[derive(sqlx::Type, Serialize, Deserialize, Debug)] @@ -175,7 +186,7 @@ async fn list_workspaces( Workspace, "SELECT workspace.* FROM workspace, usr WHERE usr.workspace_id = workspace.id AND \ usr.email = $1 AND deleted = false", - authed.email.as_ref() + authed.email ) .fetch_all(&mut tx) .await?; @@ -202,6 +213,25 @@ async fn get_settings( Ok(Json(settings)) } +async fn get_usage( + authed: Authed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let settings = sqlx::query_as!( + WorkspaceSettings, + "SELECT * FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_one(&mut tx) + .await + .map_err(|e| Error::InternalErr(format!("getting settings: {e}")))?; + + tx.commit().await?; + Ok(Json(settings)) +} + async fn edit_slack_command( authed: Authed, Extension(db): Extension, @@ -225,7 +255,7 @@ async fn edit_slack_command( "workspaces.edit_command_script", ActionKind::Update, &w_id, - Some(&authed.email.unwrap()), + Some(&authed.email), Some( [( "script", @@ -249,7 +279,7 @@ async fn list_workspaces_as_super_admin( Authed { email, .. }: Authed, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; - require_super_admin(&mut tx, email).await?; + require_super_admin(&mut tx, &email).await?; let (per_page, offset) = paginate(pagination); let workspaces = sqlx::query_as!( @@ -268,9 +298,6 @@ async fn user_workspaces( Extension(db): Extension, Authed { email, .. }: Authed, ) -> JsonResult { - let email = email - .ok_or("not a personal token") - .map_err(|x| Error::NotAuthorized(x.to_string()))?; let mut tx = db.begin().await?; let workspaces = sqlx::query_as!( UserWorkspace, @@ -406,7 +433,7 @@ async fn edit_workspace( "workspaces.update", ActionKind::Update, &w_id, - Some(&authed.email.unwrap()), + Some(&authed.email), Some( [( "domain", @@ -447,7 +474,7 @@ async fn delete_workspace( "workspaces.delete", ActionKind::Update, &w_id, - Some(&email.unwrap_or("noemail".to_string())), + Some(&email), None, ) .await?; diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index e3a6cb1f40..b93efb3f4a 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -208,11 +208,9 @@ pub fn to_hex_string(i: &i64) -> String { #[cfg(feature = "reqwest")] pub async fn get_hub_script_by_path( - email: Option, - username: String, + email: &str, path: StripPath, http_client: reqwest::Client, - host: String, ) -> crate::error::Result { use crate::{ error::{to_anyhow, Error}, @@ -228,8 +226,6 @@ pub async fn get_hub_script_by_path( http_client, &format!("https://hub.windmill.dev/raw/{path}.ts"), email, - username, - host, true, ) .await? @@ -241,11 +237,9 @@ pub async fn get_hub_script_by_path( #[cfg(feature = "reqwest")] pub async fn get_full_hub_script_by_path( - email: Option, - username: String, + email: &str, path: StripPath, http_client: reqwest::Client, - host: String, ) -> crate::error::Result { use crate::{ error::{to_anyhow, Error}, @@ -261,8 +255,6 @@ pub async fn get_full_hub_script_by_path( http_client, &format!("https://hub.windmill.dev/raw2/{path}"), email, - username, - host, true, ) .await? diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 3cb89bf244..849b8e422e 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -80,11 +80,9 @@ pub fn get_owner_from_path(path: &str) -> String { pub async fn list_elems_from_hub( http_client: reqwest::Client, url: &str, - email: Option, - username: String, - host: String, + email: &str, ) -> Result { - let rows = http_get_from_hub(http_client, url, email, username, host, false) + let rows = http_get_from_hub(http_client, url, email, false) .await? .json::() .await @@ -96,9 +94,7 @@ pub async fn list_elems_from_hub( pub async fn http_get_from_hub( http_client: reqwest::Client, url: &str, - email: Option, - username: String, - host: String, + email: &str, plain: bool, ) -> Result { let response = http_client @@ -111,9 +107,7 @@ pub async fn http_get_from_hub( "application/json" }, ) - .header("X-email", email.unwrap_or_else(|| "".to_string())) - .header("X-username", username) - .header("X-hostname", host) + .header("X-email", email) .send() .await .map_err(crate::error::to_anyhow)?; diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 41d66b3c8e..e00eec925e 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -10,7 +10,7 @@ use std::{collections::HashMap, str::FromStr}; use serde::{Deserialize, Serialize}; use sqlx::{Pool, Postgres, Transaction}; -use tracing::instrument; +use tracing::{instrument, Instrument}; use ulid::Ulid; use uuid::Uuid; use windmill_audit::{audit_log, ActionKind}; @@ -39,10 +39,11 @@ lazy_static::lazy_static! { "Total number of jobs pulled from the queue." ) .unwrap(); + pub static ref CLOUD_HOSTED: bool = std::env::var("CLOUD_HOSTED").is_ok(); + } -const MAX_NB_OF_JOBS_IN_Q_PER_USER: i64 = 10; -const MAX_DURATION_LAST_1200: std::time::Duration = std::time::Duration::from_secs(900); +const MAX_FREE_EXECS: i32 = 1000; pub async fn cancel_job<'c>( username: &str, @@ -245,6 +246,7 @@ pub async fn push<'c>( job_payload: JobPayload, args: serde_json::Map, user: &str, + email: &str, permissioned_as: String, scheduled_for_o: Option>, schedule_path: Option, @@ -257,54 +259,60 @@ pub async fn push<'c>( let args_json = serde_json::Value::Object(args); let job_id: Uuid = Ulid::new().into(); - let premium_workspace = - sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", workspace_id) + // we track only non flow steps + let usage = if !matches!( + job_payload, + JobPayload::Flow(_) | JobPayload::RawFlow { .. } + ) { + sqlx::query_scalar!( + "INSERT INTO usage (id, is_workspace, month_, usage) + VALUES ($1, false, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) + ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1 + RETURNING usage.usage", + email) + .fetch_one(&mut tx) + .await + .map_err(|e| Error::InternalErr(format!("updating usage: {e}")))? + } else { + sqlx::query_scalar!(" + SELECT usage.usage + 1 FROM usage + WHERE is_workspace = false AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)") + .fetch_optional(&mut tx) + .await? + .flatten() + .unwrap_or(0) + }; + + if *CLOUD_HOSTED { + let premium_workspace = + sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", workspace_id) + .fetch_one(&mut tx) + .await + .map_err(|e| { + Error::InternalErr(format!("fetching if {workspace_id} is premium: {e}")) + })?; + + if !premium_workspace { + let is_super_admin = + sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email) + .fetch_optional(&mut tx) + .await? + .unwrap_or(false); + if !is_super_admin && usage > MAX_FREE_EXECS { + return Err(error::Error::BadRequest(format!( + "User {email} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces." + ))); + } + } else { + sqlx::query_scalar!( + "INSERT INTO usage (id, is_workspace, month_, usage) + VALUES ($1, true, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) + ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1 + RETURNING usage.usage", + workspace_id) .fetch_one(&mut tx) .await - .map_err(|e| { - Error::InternalErr(format!("fetching if {workspace_id} is premium: {e}")) - })?; - - if !premium_workspace && std::env::var("CLOUD_HOSTED").is_ok() { - let rate_limiting_queue = sqlx::query_scalar!( - "SELECT COUNT(id) FROM queue WHERE permissioned_as = $1 AND workspace_id = $2", - permissioned_as, - workspace_id - ) - .fetch_one(&mut tx) - .await?; - - if let Some(nb_jobs) = rate_limiting_queue { - if nb_jobs > MAX_NB_OF_JOBS_IN_Q_PER_USER { - return Err(error::Error::ExecutionErr(format!( - "You have exceeded the number of authorized elements of queue at any given \ - time: {}", - MAX_NB_OF_JOBS_IN_Q_PER_USER - ))); - } - } - - let rate_limiting_duration_ms = sqlx::query_scalar!( - " - SELECT SUM(duration_ms) - FROM completed_job - WHERE permissioned_as = $1 - AND created_at > NOW() - INTERVAL '1200 seconds' - AND workspace_id = $2", - permissioned_as, - workspace_id - ) - .fetch_one(&mut tx) - .await?; - - if let Some(sum_duration_ms) = rate_limiting_duration_ms { - if sum_duration_ms as u128 > MAX_DURATION_LAST_1200.as_millis() { - return Err(error::Error::ExecutionErr(format!( - "You have exceeded the scripts cumulative duration limit over the last 20m \ - which is: {} seconds", - MAX_DURATION_LAST_1200.as_secs() - ))); - } + .map_err(|e| Error::InternalErr(format!("updating usage: {e}")))?; } } @@ -334,14 +342,7 @@ pub async fn push<'c>( ) } JobPayload::ScriptHub { path } => { - let email = sqlx::query_scalar!( - "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", - user, - workspace_id - ) - .fetch_optional(&mut tx) - .await?; - let script = get_hub_script(path.clone(), email, user).await?; + let script = get_hub_script(path.clone(), email).await?; ( None, Some(path), @@ -466,8 +467,8 @@ pub async fn push<'c>( "INSERT INTO queue (workspace_id, id, running, parent_job, created_by, permissioned_as, scheduled_for, script_hash, script_path, raw_code, raw_lock, args, job_kind, schedule_path, raw_flow, \ - flow_status, is_flow_step, language, started_at, same_worker, pre_run_error) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, CASE WHEN $3 THEN now() END, $19, $20) \ + flow_status, is_flow_step, language, started_at, same_worker, pre_run_error, email) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, CASE WHEN $3 THEN now() END, $19, $20, $21) \ RETURNING id", workspace_id, job_id, @@ -488,7 +489,8 @@ pub async fn push<'c>( is_flow_step, language: ScriptLang, same_worker, - pre_run_error.map(|e| e.to_string()) + pre_run_error.map(|e| e.to_string()), + email ) .fetch_one(&mut tx) .await @@ -526,6 +528,7 @@ pub async fn push<'c>( script_path.as_ref().map(|x| x.as_str()), Some(hm), ) + .instrument(tracing::info_span!("job_run", email = &email)) .await?; } Ok((uuid, tx)) @@ -540,20 +543,14 @@ pub fn canceled_job_to_result(job: &QueuedJob) -> String { format!("Job canceled: {reason} by {canceler}") } -pub async fn get_hub_script( - path: String, - email: Option, - user: &str, -) -> error::Result { +pub async fn get_hub_script(path: String, email: &str) -> error::Result { get_full_hub_script_by_path( email, - user.to_string(), StripPath(path), reqwest::ClientBuilder::new() .user_agent("windmill/beta") .build() .map_err(to_anyhow)?, - std::env::var("BASE_URL").unwrap_or_else(|_| "".to_string()), ) .await .map(|e| e) @@ -588,6 +585,7 @@ pub struct QueuedJob { pub language: Option, pub same_worker: bool, pub pre_run_error: Option, + pub email: String, } impl QueuedJob { diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 68c9e0d8c7..86189e9542 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -32,6 +32,8 @@ pub struct Schedule { pub is_flow: bool, pub args: Option, pub extra_perms: serde_json::Value, + pub email: String, + pub error: Option, } #[derive(Deserialize)] @@ -100,21 +102,29 @@ pub async fn push_scheduled_job<'c>( } }; - let (_, tx) = push( + let (_, mut tx) = push( tx, &schedule.workspace_id, payload, args, &schedule_to_user(&schedule.path), + &schedule.email, get_owner_from_path(&schedule.path), Some(next), - Some(schedule.path), + Some(schedule.path.clone()), None, false, false, None, ) .await?; + sqlx::query!( + "UPDATE schedule SET error = NULL WHERE workspace_id = $1 AND path = $2", + &schedule.workspace_id, + &schedule.path + ) + .execute(&mut tx) + .await?; Ok(tx) } @@ -123,6 +133,7 @@ pub async fn create_schedule( w_id: String, ns: NewSchedule, username: &str, + email: &str, ) -> Result { cron::Schedule::from_str(&ns.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?; check_flow_conflict(&mut tx, &w_id, &ns.path, ns.is_flow, &ns.script_path).await?; @@ -130,7 +141,7 @@ pub async fn create_schedule( let schedule = sqlx::query_as!( Schedule, "INSERT INTO schedule (workspace_id, path, schedule, offset_, edited_by, script_path, \ - is_flow, args, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *", + is_flow, args, enabled, email) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *", w_id, ns.path, ns.schedule, @@ -140,6 +151,7 @@ pub async fn create_schedule( ns.is_flow, ns.args, ns.enabled.unwrap_or(false), + email ) .fetch_one(&mut tx) .await @@ -360,12 +372,14 @@ pub async fn set_enabled( path: StripPath, SetEnabled { enabled }: SetEnabled, username: &str, + email: &str, ) -> Result { let path = path.to_path(); let schedule_o = sqlx::query_as!( Schedule, - "UPDATE schedule SET enabled = $1 WHERE path = $2 AND workspace_id = $3 RETURNING *", + "UPDATE schedule SET enabled = $1, email = $2 WHERE path = $3 AND workspace_id = $4 RETURNING *", enabled, + email, path, w_id ) diff --git a/backend/windmill-worker/src/jobs.rs b/backend/windmill-worker/src/jobs.rs index 0af990b233..281bf1fe3f 100644 --- a/backend/windmill-worker/src/jobs.rs +++ b/backend/windmill-worker/src/jobs.rs @@ -7,11 +7,11 @@ */ use serde_json::{Map, Value}; -use sqlx::{Pool, Postgres, Transaction}; +use sqlx::{Pool, Postgres}; use tracing::instrument; use uuid::Uuid; use windmill_common::{error::Error, flow_status::FlowStatusModule}; -use windmill_queue::{delete_job, schedule::get_schedule_opt, JobKind, QueuedJob}; +use windmill_queue::{delete_job, schedule::get_schedule_opt, JobKind, QueuedJob, CLOUD_HOSTED}; #[instrument(level = "trace", skip_all)] pub async fn add_completed_job_error( @@ -46,6 +46,24 @@ pub fn error_to_result( ); } +fn flatten_jobs(modules: Vec) -> Vec { + modules + .into_iter() + .filter_map(|m| match m { + FlowStatusModule::Success { job, flow_jobs, .. } + | FlowStatusModule::Failure { job, flow_jobs, .. } => { + if let Some(flow_jobs) = flow_jobs { + Some(flow_jobs) + } else { + Some(vec![job]) + } + } + _ => None, + }) + .flatten() + .collect::>() +} + #[instrument(level = "trace", skip_all)] pub async fn add_completed_job( db: &Pool, @@ -60,14 +78,7 @@ pub async fn add_completed_job( let jobs = queued_job.parse_flow_status().map(|s| { let mut modules = s.modules; modules.extend([s.failure_module.module_status]); - modules - .into_iter() - .filter_map(|m| match m { - FlowStatusModule::Success { job, .. } - | FlowStatusModule::Failure { job, .. } => Some(job), - _ => None, - }) - .collect::>() + flatten_jobs(modules) }); if let Some(jobs) = jobs { sqlx::query_scalar!( @@ -85,6 +96,7 @@ pub async fn add_completed_job( } else { None }; + let mut tx = db.begin().await?; let job_id = queued_job.id.clone(); sqlx::query!( @@ -149,32 +161,69 @@ pub async fn add_completed_job( .await .map_err(|e| Error::InternalErr(format!("Could not add completed job {job_id}: {e}")))?; let _ = delete_job(db, &queued_job.workspace_id, job_id).await?; + tx.commit().await?; + + if duration.unwrap_or(0) > 1000 { + let additional_usage = (duration.unwrap() as i32 / 1000) - 1; + sqlx::query!( + "INSERT INTO usage (id, is_workspace, month_, usage) + VALUES ($1, false, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) + ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + $2", + queued_job.email, + additional_usage) + .execute(db) + .await + .map_err(|e| Error::InternalErr(format!("updating usage: {e}")))?; + + if *CLOUD_HOSTED { + let w_id = &queued_job.workspace_id; + let premium_workspace = + sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", w_id) + .fetch_one(db) + .await + .map_err(|e| { + Error::InternalErr(format!("fetching if {w_id} is premium: {e}")) + })?; + if premium_workspace { + let _ = sqlx::query!( + "INSERT INTO usage (id, is_workspace, month_, usage) + VALUES ($1, true, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) + ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + $2", + w_id, + additional_usage) + .execute(db) + .await + .map_err(|e| Error::InternalErr(format!("updating usage: {e}"))); + } + } + } if !queued_job.is_flow_step && queued_job.job_kind != JobKind::Flow && queued_job.job_kind != JobKind::FlowPreview && queued_job.schedule_path.is_some() && queued_job.script_path.is_some() { - tx = schedule_again_if_scheduled( - tx, + schedule_again_if_scheduled( + db, queued_job.schedule_path.as_ref().unwrap(), queued_job.script_path.as_ref().unwrap(), &queued_job.workspace_id, ) .await?; } - tx.commit().await?; tracing::debug!("Added completed job {}", queued_job.id); Ok(queued_job.id) } #[instrument(level = "trace", skip_all)] -pub async fn schedule_again_if_scheduled<'c>( - mut tx: Transaction<'c, Postgres>, +pub async fn schedule_again_if_scheduled( + db: &Pool, schedule_path: &str, script_path: &str, w_id: &str, -) -> windmill_common::error::Result> { +) -> windmill_common::error::Result<()> { + let mut tx = db.begin().await?; + let schedule = get_schedule_opt(&mut tx, w_id, schedule_path) .await? .ok_or_else(|| { @@ -184,11 +233,11 @@ pub async fn schedule_again_if_scheduled<'c>( )) })?; if schedule.enabled && script_path == schedule.script_path { - tx = windmill_queue::schedule::push_scheduled_job( + let res = windmill_queue::schedule::push_scheduled_job( tx, windmill_queue::schedule::Schedule { workspace_id: w_id.to_owned(), - path: schedule.path, + path: schedule.path.clone(), edited_by: schedule.edited_by, edited_at: schedule.edited_at, schedule: schedule.schedule, @@ -200,10 +249,25 @@ pub async fn schedule_again_if_scheduled<'c>( .args .and_then(|e| serde_json::to_value(e).map_or(None, |v| Some(v))), extra_perms: serde_json::to_value(schedule.extra_perms).expect("hashmap -> json"), + email: schedule.email, + error: None, }, ) - .await?; + .await; + match res { + Ok(tx) => tx.commit().await?, + Err(e) => { + sqlx::query!( + "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3", + e.to_string(), + &schedule.workspace_id, + &schedule.path + ) + .execute(db) + .await?; + tracing::warn!("Could not schedule job for {}: {}", schedule_path, e); + } + } } - - Ok(tx) + Ok(()) } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 1de5f0cb66..0f17e06fe4 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -242,10 +242,10 @@ pub async fn create_token_for_owner<'c>( .await? .unwrap_or(false); - let expiration = sqlx::query_scalar!( + sqlx::query_scalar!( "INSERT INTO token (workspace_id, token, owner, label, expiration, super_admin) - VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6) RETURNING expiration", + VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6)", &w_id, token, owner, @@ -253,32 +253,7 @@ pub async fn create_token_for_owner<'c>( expires_in.to_string(), is_super_admin ) - .fetch_one(&mut tx) - .await?; - - let mut truncated_token = token[..10].to_owned(); - truncated_token.push_str("*****"); - - windmill_audit::audit_log( - &mut tx, - &username, - "users.token.create", - windmill_audit::ActionKind::Create, - w_id, - Some(&truncated_token), - Some( - [ - Some(("label", label)), - expiration - .map(|x| x.to_string()) - .as_ref() - .map(|exp| ("expiration", &exp[..])), - ] - .into_iter() - .flatten() - .collect(), - ), - ) + .execute(&mut tx) .await?; Ok((tx, token)) } @@ -646,6 +621,17 @@ async fn handle_job_error( keep_job_dir: bool, base_internal_url: &str, ) { + add_completed_job_error( + db, + &job, + format!("Unexpected error during job execution:\n{err}"), + &err, + metrics.clone(), + ) + .await + .map(|(_, m)| m) + .unwrap_or_else(|_| Map::new()); + if job.is_flow_step || job.job_kind == JobKind::FlowPreview || job.job_kind == JobKind::Flow { let (flow, job_status_to_update) = if let Some(parent_job_id) = job.parent_job { (parent_job_id, job.id) @@ -693,17 +679,6 @@ async fn handle_job_error( } } } - add_completed_job_error( - db, - &job, - format!("Unexpected error during job execution:\n{err}"), - &err, - metrics, - ) - .await - .map(|(_, m)| m) - .unwrap_or_else(|_| Map::new()); - tracing::error!(job_id = %job.id, err = err.alt(), "error handling job: {} {} {}", job.id, job.workspace_id, job.created_by); } @@ -787,7 +762,7 @@ async fn handle_queued_job( .await?; } - tracing::info!( + tracing::debug!( worker = %worker_name, job_id = %job.id, workspace_id = %job.workspace_id, @@ -1005,7 +980,7 @@ async fn handle_code_execution_job( .map(|x| format!("{x:?}")) .unwrap_or_else(|| "NO_LANG".to_string()); - tracing::info!( + tracing::debug!( worker_name = %worker_name, job_id = %job.id, workspace_id = %job.workspace_id, @@ -2103,17 +2078,6 @@ async fn gen_go_mymod(code: &str, job_dir: &str) -> error::Result<()> { Ok(()) } -// TODO: this really shouldn't be here -pub async fn get_email_from_username( - username: &String, - db: &Pool, -) -> error::Result> { - let email = sqlx::query_scalar!("SELECT email FROM usr WHERE username = $1", username) - .fetch_optional(db) - .await?; - Ok(email) -} - #[tracing::instrument(level = "trace", skip_all)] async fn get_reserved_variables( job: &QueuedJob, @@ -2133,9 +2097,7 @@ async fn get_reserved_variables( let variables = variables::get_reserved_variables( &job.workspace_id, token, - &get_email_from_username(&job.created_by, db) - .await? - .unwrap_or_else(|| "nosuitable@email.xyz".to_string()), + &job.email, &job.created_by, &job.id.to_string(), &job.permissioned_as, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index ace46432a9..0254cc9e9a 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -407,13 +407,15 @@ pub async fn update_flow_status_after_job_completion( false => false, }; + tx.commit().await?; + if old_status.step == 0 && !flow_job.is_flow_step && flow_job.schedule_path.is_some() && flow_job.script_path.is_some() { - tx = schedule_again_if_scheduled( - tx, + schedule_again_if_scheduled( + db, flow_job.schedule_path.as_ref().unwrap(), flow_job.script_path.as_ref().unwrap(), &w_id, @@ -421,8 +423,6 @@ pub async fn update_flow_status_after_job_completion( .await?; } - tx.commit().await?; - let done = if !should_continue_flow { let logs = if flow_job.canceled { "Flow job canceled".to_string() @@ -1233,6 +1233,7 @@ async fn push_next_flow_job( payload, ok.unwrap_or_else(|| Map::new()), &flow_job.created_by, + &flow_job.email, flow_job.permissioned_as.to_owned(), scheduled_for_o, flow_job.schedule_path.clone(), diff --git a/frontend/src/lib/components/CronInput.svelte b/frontend/src/lib/components/CronInput.svelte index 9afcc9e431..b5335ffc02 100644 --- a/frontend/src/lib/components/CronInput.svelte +++ b/frontend/src/lib/components/CronInput.svelte @@ -77,7 +77,7 @@
The next runs will be scheduled at:
    {#each preview.slice(0, limit) as p} -
  • {displayDate(p)}
  • +
  • {displayDate(p, true)}
  • {/each}
  • ...
  • {#if limit != 10} diff --git a/frontend/src/lib/components/Dropdown.svelte b/frontend/src/lib/components/Dropdown.svelte index cdb8d51b44..60e3d95d7a 100644 --- a/frontend/src/lib/components/Dropdown.svelte +++ b/frontend/src/lib/components/Dropdown.svelte @@ -36,7 +36,7 @@ dispatch('click', { item: item?.eventName }) } }} - class="block whitespace-nowrap hover:drop-shadow-sm hover:bg-gray-50 hover:bg-opacity-30 px-4 py-2 text-sm text-gray-700 text-left{item.separatorTop + class="block w-full whitespace-nowrap hover:drop-shadow-sm hover:bg-gray-50 hover:bg-opacity-30 px-4 py-2 text-sm text-gray-700 text-left {item.separatorTop ? 'border-t' : ''} {item.separatorBottom ? 'border-b' : ''} {item.type == 'delete' ? 'text-red-500' @@ -64,7 +64,7 @@ close() } }} - class="block px-4 py-2 text-sm text-gray-700 hover:drop-shadow-sm hover:bg-gray-50 hover:bg-opacity-30" + class="block w-full px-4 py-2 text-sm text-gray-700 hover:drop-shadow-sm hover:bg-gray-50 hover:bg-opacity-30" role="menuitem" tabindex="-1" id="user-menu-item-{name}-{i}}" @@ -81,7 +81,7 @@ {:else} export function load({ params }) { return { - stuff: { title: `Script ${params.hash}` } + stuff: { title: `App ${params.path}` } } }