feat: rework schedule page entirely to display jobs informations

This commit is contained in:
Ruben Fiszel
2023-06-12 04:09:34 +02:00
parent 7cdf40f076
commit 71fddcf09c
9 changed files with 458 additions and 182 deletions
+106
View File
@@ -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": [
+50
View File
@@ -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:
+12 -2
View File
@@ -431,6 +431,7 @@ pub struct ListQueueQuery {
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
pub running: Option<bool>,
pub schedule_path: Option<String>,
pub parent_job: Option<String>,
pub order_desc: Option<bool>,
pub job_kinds: Option<String>,
@@ -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<bool>,
pub is_flow_step: Option<bool>,
pub suspended: Option<bool>,
pub schedule_path: Option<String>,
// filter by matching a subset of the args using base64 encoded json subset
pub args: Option<String>,
// filter by matching a subset of the result using base64 encoded json subset
+53 -1
View File
@@ -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<chrono::Utc>,
pub schedule: String,
pub timezone: String,
pub enabled: bool,
pub script_path: String,
pub is_flow: bool,
pub args: Option<serde_json::Value>,
pub extra_perms: serde_json::Value,
pub email: String,
pub error: Option<String>,
pub on_failure: Option<String>,
pub jobs: Option<Vec<serde_json::Value>>,
}
async fn list_schedule_with_jobs(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<ScheduleWJobs>> {
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<UserDB>,
@@ -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]),
@@ -122,7 +122,7 @@
</div>
<div class="flex flex-row space-x-2">
<JobPreview {job}>
<JobPreview id={job.id}>
<div class="whitespace-nowrap">
{#if job.script_path}
<a href="/run/{job.id}?workspace={job.workspace_id}">{job.script_path} </a>
@@ -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<void>
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"
>
<div class="w-1/2 h-full overflow-auto px-2">
<JobArgs {args} tableClass="!pt-0 !min-w-0 !block" />
<div class="absolute bottom-0 right-1 flex justify-end gap-2 pb-0.5">
<Badge>
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
<Badge>
<Icon class="text-gray-700" data={faHourglassHalf} scale={0.5} /><span class="mx-2">
Ran in {msToSec(job?.['duration_ms'])}s</span
>
</Badge>
</div>
<div class="w-1/2 h-full overflow-auto px-2 -mt-3">
<JobArgs args={job?.args} tableClass="!pt-0 !min-w-0 !block" />
</div>
<div class="w-1/2 h-full overflow-auto p-2">
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { JobService, Job, CompletedJob, ScriptService, FlowService } from '$lib/gen'
import { setQueryWithoutLoad } from '$lib/utils'
import { setQueryWithoutLoad } from '$lib/utils'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/toast'
@@ -40,6 +40,7 @@
let resultFilter: any = $page.url.searchParams.get('result') ?? undefined
let minTs = $page.url.searchParams.get('min_ts') ?? undefined
let maxTs = $page.url.searchParams.get('max_ts') ?? undefined
let schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
let nbOfJobs = 30
@@ -81,6 +82,7 @@
workspace: $workspaceStore!,
startedBefore,
startedAfter,
schedulePath,
scriptPathExact: path === '' ? undefined : path,
jobKinds,
success,
@@ -1,5 +1,5 @@
<script lang="ts">
import { ScheduleService, type Schedule, JobService, type ScriptArgs } from '$lib/gen'
import { ScheduleService, JobService, type ScriptArgs, type ScheduleWJobs } from '$lib/gen'
import { canWrite, displayDate } from '$lib/utils'
import CenteredPage from '$lib/components/CenteredPage.svelte'
@@ -10,34 +10,39 @@
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import TableCustom from '$lib/components/TableCustom.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import {
faCircle,
faEdit,
faEye,
faList,
faPen,
faPlay,
faPlus,
faShare,
faToggleOff,
faToggleOn,
faTrash
} from '@fortawesome/free-solid-svg-icons'
import { Icon } from 'svelte-awesome'
import { goto } from '$app/navigation'
import { sendUserToast } from '$lib/toast'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import JobPreview from '$lib/components/jobs/JobPreview.svelte'
type ScheduleW = Schedule & { canWrite: boolean }
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
let schedules: ScheduleW[] = []
let shareModal: ShareModal
let loading = true
async function loadSchedules(): Promise<void> {
schedules = (await ScheduleService.listSchedules({ workspace: $workspaceStore! })).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
schedules = (await ScheduleService.listSchedulesWithJobs({ workspace: $workspaceStore! })).map(
(x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
}
)
loading = false
}
@@ -86,9 +91,20 @@
}
}
let scheduleEditor: ScheduleEditor
let filteredItems: (ScheduleW & { marked?: any })[] | undefined = []
let filter = ''
</script>
<ScheduleEditor on:update={loadSchedules} bind:this={scheduleEditor} />
<SearchItems
{filter}
items={schedules}
bind:filteredItems
f={(x) => x.path + ' ' + x.script_path}
/>
<CenteredPage>
<PageHeader
title="Schedules"
@@ -99,169 +115,198 @@
New&nbsp;schedule
</Button>
</PageHeader>
<div class="mt-10 pb-60 overflow-auto">
<div class="w-full h-full flex flex-col">
<div class="w-12/12 pb-4 pt-6">
<input type="text" placeholder="Search schedule" bind:value={filter} class="search-item" />
</div>
{#if loading}
<Skeleton layout={[0.5, [2.1], 0.7]} />
{#each new Array(6) as _}
<Skeleton layout={[[4], 0.7]} />
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else}
<TableCustom>
<tr slot="header-row">
<th class="!px-0" />
<th>Schedule</th>
<th>Script/Flow</th>
<th>Schedule</th>
<th>Timezone</th>
<th />
<th>Enabled</th>
<th>Last Edit</th>
<th />
</tr>
<tbody slot="body">
{#each schedules as { path, error, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, args }}
<tr class={enabled ? '' : 'bg-gray-50'}>
<td class="!px-0 text-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</td>
<td class="max-w-sm min-w-[100px]">
<button
class="break-all text-left text-sm text-blue-600 font-normal"
on:click={() => scheduleEditor?.openEdit(path, is_flow)}
>
{path}
</button>
</td>
<td class="">
<div class="inline-flex flex-row gap-x-2 align-middle w-full">
<div class="grow !min-w-[100px]">
<a
class="text-sm break-all"
href="{is_flow ? '/flows/get' : '/scripts/get'}/{script_path}"
>
{script_path}
</a>
</div>
<Badge class="text-2xs font-mono">{is_flow ? 'flow' : 'script'}</Badge>
</div>
</td>
<td>
<Badge color="blue">{schedule}</Badge>
</td>
<td>
<Badge color="blue">{timezone}</Badge>
</td>
<td>
<div class="w-10">
{#if error}
<Popover notClickable>
<span class="flex h-4 w-4">
<Icon
class="text-red-600 animate-ping absolute inline-flex "
data={faCircle}
scale={0.7}
label="Error during last job scheduling"
/>
<Icon
class="text-red-600 relative inline-flex"
data={faCircle}
scale={0.7}
label="Error during last job scheduling"
/>
</span>
<div slot="text">
The schedule disabled itself because there was an error scheduling the next
job: {error}
</div>
</Popover>
{:else if !schedules?.length}
<div class="text-center text-sm text-gray-600 mt-2"> No schedules </div>
{:else if filteredItems?.length}
<div class="border rounded-md divide-y divide-gray-200">
{#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}
<div
class="hover:bg-gray-50 w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
on:click={() => scheduleEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-gray-900 flex-wrap text-left text-md font-semibold mb-1 truncate">
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{script_path}
{/if}
</div>
</td>
<td>
<Toggle
checked={enabled}
on:change={(e) => {
if (canWrite) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
}
}}
/>
</td>
<td class="min-w-[100px]">
<span class="text-2xs">By {edited_by} <br />the {displayDate(edited_at)}</span>
</td>
<td>
<div class="inline-flex gap-2">
<Button
href={`/runs/${script_path}`}
size="xs"
startIcon={{ icon: faList }}
color="light"
variant="border"
>
Runs
</Button>
<Dropdown
placement="bottom-end"
dropdownItems={[
{
displayName: enabled ? 'Disable' : 'Enable',
icon: enabled ? faToggleOff : faToggleOn,
disabled: !canWrite,
action: () => {
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')
}
}
]}
/>
<div class="text-gray-600 text-xs truncate text-left font-light">
schedule: {path}
</div>
</td>
</tr>
{/each}
</tbody>
</TableCustom>
</a>
<div class="w-10">
{#if error}
<Popover notClickable>
<span class="flex h-4 w-4">
<Icon
class="text-red-600 animate-ping absolute inline-flex "
data={faCircle}
scale={0.7}
label="Error during last job scheduling"
/>
<Icon
class="text-red-600 relative inline-flex"
data={faCircle}
scale={0.7}
label="Error during last job scheduling"
/>
</span>
<div slot="text">
The schedule disabled itself because there was an error scheduling the next
job: {error}
</div>
</Popover>
{/if}
</div>
<div class="gap-2 items-center hidden md:flex">
<Badge large color="blue">{schedule}</Badge>
<Badge small color="gray">{timezone}</Badge>
</div>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<Toggle
checked={enabled}
on:change={(e) => {
if (canWrite) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
}
}}
/>
<div class="flex gap-2 items-center justify-end">
<Button
href={`/runs/?schedule_path=${path}`}
size="xs"
startIcon={{ icon: faList }}
color="light"
variant="border"
>
Runs
</Button>
<Button
on:click={() => scheduleEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={{ icon: faPen }}
color="dark"
>
Edit
</Button>
<Dropdown
placement="bottom-end"
dropdownItems={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: faEye,
action: () => {
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')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div class="flex gap-1.5 ml-0.5 items-baseline">
{#each jobs ?? [] as job}
{@const h = (avg_s ? job.duration_ms / avg_s : 1) * 10}
<a href="/run/{job.id}">
<JobPreview id={job.id}>
<div>
<div
class="{job.success ? 'bg-green-300' : 'bg-red-300'} mx-auto w-1.5"
style="height: {h}px"
/>
<!-- <div class="text-[0.6em] mt-0.5 text-center text-gray-500"
>{(job.duration_ms / 1000).toFixed(2)}s</div
> -->
</div>
</JobPreview>
</a>
{/each}
{#if avg_s}
<div class="pl-2 text-gray-600 text-2xs">Avg: {(avg_s / 1000).toFixed(2)}s</div>
{/if}
</div>
<div class="flex flex-wrap text-[0.7em] text-gray-500 gap-1 justify-end truncate pr-2"
><div class="truncate">edited by {edited_by}</div><div class="truncate"
>the {displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
</CenteredPage>