diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 0581700316..4c6f3e8ae6 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -1,6 +1,6 @@ + +
+ + {#if tree.treeView && !forceExpanded} + + {/if} +
diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index 062dfb69f8..753feb6a8b 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -1,5 +1,5 @@ - ['Escape', 'Esc'].includes(key) && close()} /> -{#if hovered} + ['Escape', 'Esc'].includes(key) && close()} + onscrollcapture={(e) => open && !popupEl?.contains(e.target as Node) && close()} +/> + +{#if hovered || open} {/if}
{@render children?.({ open })} - {#if open} -
-
-
- Arguments - + {#if open && anchor} + + +
+
+
+ Arguments + +
+
+ {#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)} +
+
Job is scheduled for
+
{new Date(job?.['scheduled_for']).toLocaleString()}
+
+ {/if} + {#if job?.type === 'CompletedJob'} + Result + + {:else if job && `running` in job ? job.running : false} +
Job is still running
+ + {/if} +
-
- {#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)} -
-
Job is scheduled for
-
{new Date(job?.['scheduled_for']).toLocaleString()}
-
+
+ {#if job?.started_at} + {new Date(job?.['started_at']).toLocaleString()} {/if} - {#if job?.type === 'CompletedJob'} - Result - - {:else if job && `running` in job ? job.running : false} -
Job is still running
- + Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'} + + {#if job?.['duration_ms']} + {/if} + {#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0} + {#each job?.['labels'] as label} + Label: {label} + {/each} + {/if}
-
- {#if job?.started_at} - {new Date(job?.['started_at']).toLocaleString()} - {/if} - - Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'} - - {#if job?.['duration_ms']} - - {/if} - {#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0} - {#each job?.['labels'] as label} - Label: {label} - {/each} - {/if} -
-
+
{/if}
diff --git a/frontend/src/lib/components/schedules/SchedulesList.svelte b/frontend/src/lib/components/schedules/SchedulesList.svelte index 05b6cdd556..2214a0d6a9 100644 --- a/frontend/src/lib/components/schedules/SchedulesList.svelte +++ b/frontend/src/lib/components/schedules/SchedulesList.svelte @@ -44,6 +44,11 @@ } from '$lib/components/FilterSearchbar.svelte' import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter' import NoItemFound from '$lib/components/home/NoItemFound.svelte' + import TreeViewRoot from '$lib/components/home/TreeViewRoot.svelte' + import TreeViewControls from '$lib/components/home/TreeViewControls.svelte' + import { TreeViewState } from '$lib/components/home/treeViewState.svelte' + import type { ItemType } from '$lib/components/home/treeViewUtils' + import { twMerge } from 'tailwind-merge' import RowIcon from '$lib/components/common/table/RowIcon.svelte' import JobPreview from '$lib/components/jobs/JobPreview.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' @@ -307,7 +312,12 @@ ]) let nbDisplayed = $state(15) + + const tree = new TreeViewState('schedulesTreeView') let filterEnabledDisabled: 'all' | 'enabled' | 'disabled' = $state('all') + // A filter opens every folder so no match hides behind a closed one; Expand/Collapse all + // has no effect then. + let treeForceExpanded = $derived(activeFilters || filterEnabledDisabled !== 'all') const SCHEDULE_PATH_KIND_FILTER_SETTING = 'schedulePathKindFilter' let selectedFilterKind = $state( @@ -367,6 +377,291 @@ let items = $derived(filteredItems) +{#snippet scheduleRow(s: ScheduleW, inTree: boolean, depth: number)} + {@const { + path, + error, + summary, + edited_by, + edited_at, + schedule, + timezone, + enabled, + script_path, + is_flow, + extra_perms, + canWrite, + jobs, + paused_until, + labels, + inherited_labels, + draft_only, + is_draft + } = s} + {@const hasDraft = getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft} + {@const canEdit = canWrite && !$scheduleLock} + {@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} + +
0 ? `padding-left: ${(depth + 1) * 16}px;` : ''} + > +
+ + + { + handledHash = `#${path}` + editSchedule(path, is_flow) + }} + class="min-w-0 grow hover:underline decoration-gray-400" + > +
+ {summary || script_path}{hasDraft ? '*' : ''} +
+
+ schedule: {path} +
+
+ {#if labels?.length} + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} + {/if} + + + {#if paused_until && new Date(paused_until) > new Date()} +
+ Paused until {new Date(paused_until).toLocaleString()} +
+ {/if} + + + + + +
+ {#if error} + + + + + + {#snippet text()} +
+ The schedule disabled itself because there was an error scheduling the next job: {error} +
+ {/snippet} +
+ {/if} +
+ +
+ + {#key toggleResetVersions[path] ?? 0} + { + if (canWrite) { + setScheduleEnabled(path, e.detail) + } else { + sendUserToast('not enough permission', true) + // Permission denied — bump the row's reset + // counter so the Toggle remounts back to the + // prop value. Without this, the local + // `bind:checked` flip from the user's click + // stays stuck on. + bumpToggleReset(path) + } + }} + /> + {/key} +
+
+ + + { + if (hosted) hosted.openLink(`${base}${href}`) + else goto(href) + } + }, + { + displayName: `Duplicate schedule`, + icon: Copy, + disabled: !!$scheduleLock, + tooltip: $scheduleLock, + action: () => { + scheduleEditor?.openNew(is_flow, script_path, path) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: !canEdit, + tooltip: $scheduleLock, + action: async () => { + await ScheduleService.deleteSchedule({ + workspace: $operatingWorkspace ?? '', + path + }) + loadSchedules() + } + }, + { + displayName: canEdit ? 'Edit' : 'View', + icon: canEdit ? Pen : Eye, + action: () => { + editSchedule(path, is_flow) + } + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { + kind: 'schedules' + } + }) + } + } + ] + : []), + { + displayName: 'View runs', + icon: List, + href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true` + }, + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: 'Run now', + icon: Play, + action: () => { + runScheduleNow(script_path, path, is_flow, $operatingWorkspace!) + } + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal?.openDrawer(path, 'schedule') + } + } + ]} + /> +
+
+
+ {#if loadingSchedulesWithJobStats} +
+ + Job stats loading... +
+ {:else} +
+ {#if avg_s} +
Avg: {(avg_s / 1000).toFixed(2)}s
+ {/if} + {#each jobs ?? [] as job} + {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3} + + +
+
+ +
+
+
+ {/each} +
+ {/if} +
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'at ' : ''}{displayDate(edited_at)}
+
+{/snippet} + +{#snippet treeLeaf(item: ItemType, depth: number)} + {@render scheduleRow(item as unknown as ScheduleW, true, depth)} +{/snippet} + @@ -397,6 +692,7 @@
+ {#snippet children({ item })} @@ -439,278 +735,26 @@ /> {/if} {:else if items?.length} -
- {#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft} - {@const canEdit = canWrite && !$scheduleLock} - {@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} - -
-
- - - { - handledHash = `#${path}` - editSchedule(path, is_flow) - }} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
- {summary || script_path}{hasDraft ? '*' : ''} -
-
- schedule: {path} -
-
- {#if labels?.length} - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} - {/if} - - - {#if paused_until && new Date(paused_until) > new Date()} -
- Paused until {new Date(paused_until).toLocaleString()} -
- {/if} - - - - - -
- {#if error} - - - - - - {#snippet text()} -
- The schedule disabled itself because there was an error scheduling the - next job: {error} -
- {/snippet} -
- {/if} -
- -
- - {#key toggleResetVersions[path] ?? 0} - { - if (canWrite) { - setScheduleEnabled(path, e.detail) - } else { - sendUserToast('not enough permission', true) - // Permission denied — bump the row's reset - // counter so the Toggle remounts back to the - // prop value. Without this, the local - // `bind:checked` flip from the user's click - // stays stuck on. - bumpToggleReset(path) - } - }} - /> - {/key} -
-
- - - { - if (hosted) hosted.openLink(`${base}${href}`) - else goto(href) - } - }, - { - displayName: `Duplicate schedule`, - icon: Copy, - disabled: !!$scheduleLock, - tooltip: $scheduleLock, - action: () => { - scheduleEditor?.openNew(is_flow, script_path, path) - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canEdit, - tooltip: $scheduleLock, - action: async () => { - await ScheduleService.deleteSchedule({ - workspace: $operatingWorkspace ?? '', - path - }) - loadSchedules() - } - }, - { - displayName: canEdit ? 'Edit' : 'View', - icon: canEdit ? Pen : Eye, - action: () => { - editSchedule(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'schedules' - } - }) - } - } - ] - : []), - { - displayName: 'View runs', - icon: List, - href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true` - }, - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Run now', - icon: Play, - action: () => { - runScheduleNow(script_path, path, is_flow, $operatingWorkspace!) - } - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'schedule') - } - } - ]} - /> -
-
-
- {#if loadingSchedulesWithJobStats} -
- - Job stats loading... -
- {:else} -
- {#if avg_s} -
Avg: {(avg_s / 1000).toFixed(2)}s
- {/if} - {#each jobs ?? [] as job} - {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3} - - -
-
- -
-
-
- {/each} -
- {/if} -
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'at ' : ''}{displayDate(edited_at)}
-
- {/each} -
+ {#if tree.treeView} + {}} + leaf={treeLeaf} + /> + {:else} +
+ {#each items.slice(0, nbDisplayed) as s (s.path)} + {@render scheduleRow(s, false, 0)} + {/each} +
+ {/if} {:else} {/if}
- {#if items && items?.length > 15 && nbDisplayed < items.length} + {#if !tree.treeView && items && items?.length > 15 && nbDisplayed < items.length}
{nbDisplayed} items out of {items.length}
+{#snippet triggerRow(row: TriggerW & { marked?: any }, inTree: boolean, depth: number)} + {@const { + path, + edited_by, + edited_at, + script_path, + is_flow, + extra_perms, + canWrite, + mode, + retry, + error_handler_path, + error_handler_args, + labels, + draft_only, + is_draft + } = row} + {@const hasDraft = getLocalDraftHint($operatingWorkspace, config.draftKind, path) ?? is_draft} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + {@const effectiveMode = draft_only ? 'disabled' : mode} + {@const live = isLive(row)} + {@const copy = copyUrl(row)} + {@const canEdit = canWrite && !$triggerLock} + +
0 ? `padding-left: ${(depth + 1) * 16}px;` : ''} + > +
+ + + { + if (hosted) e.preventDefault() + openEdit(path, is_flow) + }} + class="min-w-0 grow hover:underline decoration-gray-400" + > + {@render rowTitle(row, hasDraft)} + + + + + {#if live} + {@render statusDot(row)} + {/if} + +
+ + {#if live} + onToggleMode(path, newMode)} + triggerMode={effectiveMode} + includeModalConfig={{ + triggerPath: path, + triggerKind, + runnableConfig: { + path: script_path, + kind: is_flow ? 'flow' : 'script', + retry, + errorHandlerPath: error_handler_path, + errorHandlerArgs: error_handler_args + } + }} + canWrite={canEdit} + hideToggleLabels + hideDropdown + /> + {/if} +
+ +
+ {#if copy} + + {/if} + + openLink(href) + }, + ...(canEdit && !draft_only && mode !== 'suspended' + ? [ + { + displayName: 'Suspend job execution', + icon: Pause, + action: () => { + onToggleMode(path, 'suspended') + } + } + ] + : []), + { + displayName: canEdit ? 'Edit' : 'View', + icon: canEdit ? Pen : Eye, + action: () => openEdit(path, is_flow) + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { kind: config.deployKind } + }) + } + } + ] + : []), + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal?.openDrawer(path, config.shareKind as any) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: !canEdit || (config.adminOnly && !isAdmin), + tooltip: $triggerLock, + action: () => deleteTrigger(row) + } + ]} + /> +
+
+
+
+ {#if edited_by}
edited by {edited_by}
{/if} +
{edited_by ? 'at ' : ''}{displayDate(edited_at)}
+
+
+
+{/snippet} + +{#snippet treeLeaf(item: ItemType, depth: number)} + {@render triggerRow(item as unknown as TriggerW & { marked?: any }, true, depth)} +{/snippet} + {#await EDITORS[triggerKind]() then Editor} @@ -760,6 +954,7 @@
+ {#if operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')} {:else if isAdmin} @@ -793,189 +988,26 @@ : undefined} /> {:else if items?.length} -
- {#each items.slice(0, nbDisplayed) as row (row.path)} - {@const { - path, - edited_by, - edited_at, - script_path, - is_flow, - extra_perms, - canWrite, - mode, - retry, - error_handler_path, - error_handler_args, - labels, - draft_only, - is_draft - } = row} - {@const hasDraft = - getLocalDraftHint($operatingWorkspace, config.draftKind, path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const live = isLive(row)} - {@const copy = copyUrl(row)} - {@const canEdit = canWrite && !$triggerLock} - -
-
- - - { - if (hosted) e.preventDefault() - openEdit(path, is_flow) - }} - class="min-w-0 grow hover:underline decoration-gray-400" - > - {@render rowTitle(row, hasDraft)} - - - - - {#if live} - {@render statusDot(row)} - {/if} - -
- - {#if live} - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind, - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - canWrite={canEdit} - hideToggleLabels - hideDropdown - /> - {/if} -
- -
- {#if copy} - - {/if} - - openLink(href) - }, - ...(canEdit && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canEdit ? 'Edit' : 'View', - icon: canEdit ? Pen : Eye, - action: () => openEdit(path, is_flow) - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { kind: config.deployKind } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, config.shareKind as any) - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canEdit || (config.adminOnly && !isAdmin), - tooltip: $triggerLock, - action: () => deleteTrigger(row) - } - ]} - /> -
-
-
-
- {#if edited_by}
edited by {edited_by}
{/if} -
{edited_by ? 'at ' : ''}{displayDate(edited_at)}
-
-
-
- {/each} -
+ {#if tree.treeView} + {}} + leaf={treeLeaf} + /> + {:else} +
+ {#each items.slice(0, nbDisplayed) as row (row.path)} + {@render triggerRow(row, false, 0)} + {/each} +
+ {/if} {:else} {/if}
- {#if items && items?.length > 15 && nbDisplayed < items.length} + {#if !tree.treeView && items && items?.length > 15 && nbDisplayed < items.length} {nbDisplayed} items out of {items.length}