-
-
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}
+
+
+ {schedule}
+ {timezone}
+
+
+
+
+
+
+
+ {#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}
-
-
- {schedule}
- {timezone}
-
-
-
-
-
-
-
- {#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 triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length}
+ {#each labels as label}
+ {label}
+ {/each}
+ {/if}
+
+
+ {#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 triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
- {#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}