From 71fddcf09cd753654f5bb502cae55111039d268f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Jun 2023 04:03:09 +0200 Subject: [PATCH] feat: rework schedule page entirely to display jobs informations --- backend/sqlx-data.json | 106 +++++ backend/windmill-api/openapi.yaml | 50 +++ backend/windmill-api/src/jobs.rs | 14 +- backend/windmill-api/src/schedule.rs | 54 ++- .../lib/components/common/badge/Badge.svelte | 3 +- .../src/lib/components/jobs/JobDetail.svelte | 2 +- .../src/lib/components/jobs/JobPreview.svelte | 32 +- .../(logged)/runs/[...path]/+page.svelte | 4 +- .../(root)/(logged)/schedules/+page.svelte | 375 ++++++++++-------- 9 files changed, 458 insertions(+), 182 deletions(-) diff --git a/backend/sqlx-data.json b/backend/sqlx-data.json index 01ef8790b9..0f95e41119 100644 --- a/backend/sqlx-data.json +++ b/backend/sqlx-data.json @@ -604,6 +604,112 @@ }, "query": "SELECT * FROM workspace LIMIT $1 OFFSET $2" }, + "15e143ab2dc6368238849beb3ee3969cd601b060b5d58d9831e6be7008156d26": { + "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": "enabled", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "script_path", + "ordinal": 6, + "type_info": "Varchar" + }, + { + "name": "args", + "ordinal": 7, + "type_info": "Jsonb" + }, + { + "name": "extra_perms", + "ordinal": 8, + "type_info": "Jsonb" + }, + { + "name": "is_flow", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "email", + "ordinal": 10, + "type_info": "Varchar" + }, + { + "name": "error", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "timezone", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "on_failure", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "jobs", + "ordinal": 14, + "type_info": "JsonArray" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + false, + true, + null + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + } + }, + "query": "SELECT schedule.*, t.jobs FROM schedule, LATERAL ( SELECT ARRAY (SELECT json_build_object('id', id, 'success', success, 'duration_ms', duration_ms) FROM completed_job WHERE\n completed_job.schedule_path = schedule_path AND schedule.workspace_id = completed_job.workspace_id AND parent_job IS NULL ORDER BY created_at DESC LIMIT 20) AS jobs ) t\n WHERE schedule.workspace_id = $1 ORDER BY schedule.edited_at desc LIMIT $2 OFFSET $3" + }, "163f00eb8b1a489d5f382cdba22a5744e88a8e6f1532d7cb02af560f5f5d49f7": { "describe": { "columns": [ diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 61a18760a9..39ff029de1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3555,6 +3555,7 @@ paths: - $ref: "#/components/parameters/ParentJob" - $ref: "#/components/parameters/ScriptExactPath" - $ref: "#/components/parameters/ScriptStartPath" + - $ref: "#/components/parameters/SchedulePath" - $ref: "#/components/parameters/ScriptExactHash" - $ref: "#/components/parameters/StartedBefore" - $ref: "#/components/parameters/StartedAfter" @@ -3588,6 +3589,7 @@ paths: - $ref: "#/components/parameters/ParentJob" - $ref: "#/components/parameters/ScriptExactPath" - $ref: "#/components/parameters/ScriptStartPath" + - $ref: "#/components/parameters/SchedulePath" - $ref: "#/components/parameters/ScriptExactHash" - $ref: "#/components/parameters/StartedBefore" - $ref: "#/components/parameters/StartedAfter" @@ -3628,6 +3630,7 @@ paths: - $ref: "#/components/parameters/ParentJob" - $ref: "#/components/parameters/ScriptExactPath" - $ref: "#/components/parameters/ScriptStartPath" + - $ref: "#/components/parameters/SchedulePath" - $ref: "#/components/parameters/ScriptExactHash" - $ref: "#/components/parameters/StartedBefore" - $ref: "#/components/parameters/StartedAfter" @@ -4293,6 +4296,26 @@ paths: items: $ref: "#/components/schemas/Schedule" + /w/{workspace}/schedules/list_with_jobs: + get: + summary: list schedules with last 20 jobs + operationId: listSchedulesWithJobs + tags: + - schedule + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: schedule list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ScheduleWJobs" + /w/{workspace}/groups/list: get: summary: list groups @@ -5216,6 +5239,12 @@ components: in: query schema: type: string + SchedulePath: + name: schedule_path + description: mask to filter by schedule path + in: query + schema: + type: string ScriptExactPath: name: script_path_exact description: mask to filter exact matching path @@ -6285,6 +6314,27 @@ components: - enabled - email + ScheduleWJobs: + allOf: + - $ref: "#/components/schemas/Schedule" + - type: object + properties: + jobs: + type: array + items: + type: object + properties: + id: + type: string + success: + type: boolean + duration_ms: + type: number + required: + - id + - success + - duration_ms + NewSchedule: type: object properties: diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index bcfd386fed..94e385a0f3 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -431,6 +431,7 @@ pub struct ListQueueQuery { pub started_before: Option>, pub started_after: Option>, pub running: Option, + pub schedule_path: Option, pub parent_job: Option, pub order_desc: Option, pub job_kinds: Option, @@ -454,6 +455,9 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq if let Some(p) = &lq.script_path_exact { sqlb.and_where_eq("script_path", "?".bind(p)); } + if let Some(p) = &lq.schedule_path { + sqlb.and_where_eq("schedule_path", "?".bind(p)); + } if let Some(h) = &lq.script_hash { sqlb.and_where_eq("script_hash", "?".bind(h)); } @@ -597,6 +601,7 @@ async fn list_jobs( suspended: lq.suspended, args: lq.args, tag: lq.tag, + schedule_path: lq.schedule_path, }, &[ "'QueuedJob' as typ", @@ -610,7 +615,7 @@ async fn list_jobs( "running", "script_hash", "script_path", - "CASE WHEN pg_column_size(args) > 1000 THEN '\"too large args\"'::jsonb ELSE args END", + "null as args", "null as duration_ms", "null as success", "false as deleted", @@ -646,7 +651,7 @@ async fn list_jobs( "null as running", "script_hash", "script_path", - "CASE WHEN pg_column_size(args) > 1000 THEN '\"too large args\"'::jsonb ELSE args END", + "null as args", "duration_ms", "success", "deleted", @@ -2123,6 +2128,10 @@ fn list_completed_jobs_query( .limit(per_page) .clone(); + if let Some(p) = &lq.schedule_path { + sqlb.and_where_eq("schedule_path", "?".bind(p)); + } + if let Some(ps) = &lq.script_path_start { sqlb.and_where_like_left("script_path", "?".bind(ps)); } @@ -2188,6 +2197,7 @@ pub struct ListCompletedQuery { pub is_skipped: Option, pub is_flow_step: Option, pub suspended: Option, + pub schedule_path: Option, // filter by matching a subset of the args using base64 encoded json subset pub args: Option, // filter by matching a subset of the result using base64 encoded json subset diff --git a/backend/windmill-api/src/schedule.rs b/backend/windmill-api/src/schedule.rs index 2405957418..cba7d480ff 100644 --- a/backend/windmill-api/src/schedule.rs +++ b/backend/windmill-api/src/schedule.rs @@ -16,7 +16,7 @@ use axum::{ Json, Router, }; use chrono::{DateTime, Utc}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::{Postgres, Transaction}; use std::str::FromStr; use windmill_audit::{audit_log, ActionKind}; @@ -31,6 +31,7 @@ use windmill_queue::{self, schedule::push_scheduled_job, QueueTransaction}; pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_schedule)) + .route("/list_with_jobs", get(list_schedule_with_jobs)) .route("/get/*path", get(get_schedule)) .route("/exists/*path", get(exists_schedule)) .route("/create", post(create_schedule)) @@ -231,6 +232,57 @@ async fn list_schedule( Ok(Json(rows)) } +#[derive(Serialize, Deserialize, Debug)] +pub struct ScheduleWJobs { + pub workspace_id: String, + pub path: String, + pub edited_by: String, + pub edited_at: DateTime, + pub schedule: String, + pub timezone: String, + pub enabled: bool, + pub script_path: String, + pub is_flow: bool, + pub args: Option, + pub extra_perms: serde_json::Value, + pub email: String, + pub error: Option, + pub on_failure: Option, + pub jobs: Option>, +} + +async fn list_schedule_with_jobs( + authed: Authed, + Extension(user_db): Extension, + Path(w_id): Path, + Query(pagination): Query, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + let (per_page, offset) = paginate(pagination); + let rows = sqlx::query_as!(ScheduleWJobs, + "SELECT schedule.*, t.jobs FROM schedule, LATERAL ( SELECT ARRAY (SELECT json_build_object('id', id, 'success', success, 'duration_ms', duration_ms) FROM completed_job WHERE + completed_job.schedule_path = schedule_path AND schedule.workspace_id = completed_job.workspace_id AND parent_job IS NULL ORDER BY created_at DESC LIMIT 20) AS jobs ) t + WHERE schedule.workspace_id = $1 ORDER BY schedule.edited_at desc LIMIT $2 OFFSET $3", + w_id, + per_page as i64, + offset as i64 + ) + .fetch_all(&mut tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +// SELECT id, title AS item_title, t.tag_array +// FROM items i, LATERAL ( -- this is an implicit CROSS JOIN +// SELECT ARRAY ( +// SELECT t.title +// FROM items_tags it +// JOIN tags t ON t.id = it.tag_id +// WHERE it.item_id = i.id +// ) AS tag_array +// ) t; + async fn get_schedule( authed: Authed, Extension(user_db): Extension, diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index 04597021b4..9ed8434a93 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -6,6 +6,7 @@ export let color: BadgeColor = 'gray' export let large = false + export let small = false export let href = '' export let rounded = false export let dismissable = false @@ -45,7 +46,7 @@ $: badgeClass = classNames( baseClass, - large ? 'text-sm font-medium' : 'text-xs font-semibold', + small ? 'text-xs' : large ? 'text-sm font-medium' : 'text-xs font-semibold', colors[color], href && (color.startsWith(ColorModifier) ? hovers[color.replace(ColorModifier, '')] : hovers[color]), diff --git a/frontend/src/lib/components/jobs/JobDetail.svelte b/frontend/src/lib/components/jobs/JobDetail.svelte index d5eb137999..8dbc963d2f 100644 --- a/frontend/src/lib/components/jobs/JobDetail.svelte +++ b/frontend/src/lib/components/jobs/JobDetail.svelte @@ -122,7 +122,7 @@
- +
{#if job.script_path} {job.script_path} diff --git a/frontend/src/lib/components/jobs/JobPreview.svelte b/frontend/src/lib/components/jobs/JobPreview.svelte index b242f4955d..eb43f65111 100644 --- a/frontend/src/lib/components/jobs/JobPreview.svelte +++ b/frontend/src/lib/components/jobs/JobPreview.svelte @@ -11,33 +11,33 @@ import JobArgs from '../JobArgs.svelte' import { writable } from 'svelte/store' import LogViewer from '../LogViewer.svelte' - import { forLater } from '$lib/utils' + import { forLater, msToSec } from '$lib/utils' + import { Icon } from 'svelte-awesome' + import { faHourglassHalf } from '@fortawesome/free-solid-svg-icons' + import { Badge } from '../common' const POPUP_HEIGHT = 240 as const - export let job: Job | undefined + export let id: string + let job: Job | undefined = undefined let hovered = false let timeout: NodeJS.Timeout | undefined let watchJob: (id: string) => Promise - let args = job?.args let result: any let loaded = false let wrapper: HTMLElement let popupOnTop = true - $: open = $openStore === job?.id + $: open = $openStore === id async function instantOpen() { if (!open) { hovered = true - if (!job) { - return - } popupOnTop = wrapper.getBoundingClientRect().top > POPUP_HEIGHT - openStore.set(job.id) + openStore.set(id) if (!loaded) { await tick() - watchJob && watchJob(job.id) + watchJob && watchJob(id) } } else { timeout && clearTimeout(timeout) @@ -99,8 +99,18 @@ border border-gray-300 shadow-xl flex justify-start items-start w-[600px] h-80 overflow-hidden" > -
- +
+ + Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'} + + + + Ran in {msToSec(job?.['duration_ms'])}s + +
+
+
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)} diff --git a/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte index 88abd858e7..68972fdcff 100644 --- a/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte @@ -1,7 +1,7 @@ + + x.path + ' ' + x.script_path} +/> + -
+
+
+ +
{#if loading} - {#each new Array(6) as _} - + {/each} - {:else} - - - - Schedule - Script/Flow - Schedule - Timezone - - Enabled - Last Edit - - - - {#each schedules as { path, error, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, args }} - - - - - - - - -
- - {is_flow ? 'flow' : 'script'} -
- - - {schedule} - - - {timezone} - - -
- {#if error} - - - - - -
- The schedule disabled itself because there was an error scheduling the next - job: {error} -
-
+ {:else if !schedules?.length} +
No schedules
+ {:else if filteredItems?.length} +
+ {#each filteredItems as { path, error, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, args, marked, jobs }} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + {@const avg_s = jobs + ? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length + : undefined} + +
+
+ + + scheduleEditor?.openEdit(path, is_flow)} + class="min-w-0 grow hover:underline decoration-gray-400" + > +
+ {#if marked} + + {@html marked} + + {:else} + {script_path} {/if}
- - - { - if (canWrite) { - setScheduleEnabled(path, e.detail) - } else { - sendUserToast('not enough permission', true) - } - }} - /> - - - By {edited_by}
the {displayDate(edited_at)}
- - -
- - { - setScheduleEnabled(path, enabled ? false : true) - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: faTrash, - disabled: !canWrite, - action: async () => { - await ScheduleService.deleteSchedule({ - workspace: $workspaceStore ?? '', - path - }) - loadSchedules() - } - }, - { - displayName: 'Edit', - icon: faEdit, - disabled: !canWrite, - action: () => { - scheduleEditor?.openEdit(path, is_flow) - } - }, - { - displayName: 'View Runs', - icon: faList, - href: '/runs/' + path - }, - { - displayName: 'Run now', - icon: faPlay, - action: () => { - runScheduleNow(script_path, args, is_flow) - } - }, - { - displayName: canWrite ? 'Share' : 'See Permissions', - icon: faShare, - action: () => { - shareModal.openDrawer(path, 'schedule') - } - } - ]} - /> +
+ schedule: {path}
- - - {/each} - - +
+ +
+ {#if error} + + + + + +
+ The schedule disabled itself because there was an error scheduling the next + job: {error} +
+
+ {/if} +
+ + + + + { + if (canWrite) { + setScheduleEnabled(path, e.detail) + } else { + sendUserToast('not enough permission', true) + } + }} + /> +
+ + + { + goto(href) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: faTrash, + disabled: !canWrite, + action: async () => { + await ScheduleService.deleteSchedule({ + workspace: $workspaceStore ?? '', + path + }) + loadSchedules() + } + }, + { + displayName: 'Edit', + icon: faEdit, + disabled: !canWrite, + action: () => { + scheduleEditor?.openEdit(path, is_flow) + } + }, + { + displayName: 'View Runs', + icon: faList, + href: '/runs/?schedule_path=' + path + }, + { + displayName: 'Run now', + icon: faPlay, + action: () => { + runScheduleNow(script_path, args, is_flow) + } + }, + { + displayName: canWrite ? 'Share' : 'See Permissions', + icon: faShare, + action: () => { + shareModal.openDrawer(path, 'schedule') + } + } + ]} + /> +
+
+
+
+ {#each jobs ?? [] as job} + {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 10} + + +
+
+ +
+ +
+ {/each} + {#if avg_s} +
Avg: {(avg_s / 1000).toFixed(2)}s
+ {/if} +
+
edited by {edited_by}
the {displayDate(edited_at)}
+
+ {/each} +
+ {:else} + {/if}