feat: add tree view to the schedules page (#11360)

* feat: add tree view to the schedules page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep schedule job previews visible inside tree folders

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep job preview loading while hovered and close it on scroll

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep schedules outside u/ and f/ in the tree view

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add tree view to the trigger list pages (#11400)

* feat: add tree view to the trigger list pages

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: let a TreeViewState own the tree view setting

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop the doubled bottom border at the end of a tree

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-29 10:54:46 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 651a6b01f5
commit e68ff0d969
9 changed files with 801 additions and 611 deletions
@@ -1,6 +1,6 @@
<script lang="ts">
import TreeView from './TreeView.svelte'
import { onDestroy, untrack } from 'svelte'
import { onDestroy, untrack, type Snippet } from 'svelte'
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
import { ChevronDown, ChevronUp, Folder, FolderTree, NetworkIcon, User } from 'lucide-svelte'
@@ -48,6 +48,9 @@
// that loads lazily), so an owner shown inside a grouping row is indented through
// this rather than by raising its depth.
indent?: number
// Renders a leaf in place of the home page's `<Item>`, for lists whose rows are not
// scripts/flows/apps. Receives the leaf and its visual depth.
leaf?: Snippet<[ItemType, number]>
}
let {
@@ -65,7 +68,8 @@
showEditButton = true,
parentPrefix,
ancestorHasMore = false,
indent = 0
indent = 0,
leaf
}: Props = $props()
let visualDepth = $derived(depth + indent)
@@ -344,6 +348,7 @@
{showEditButton}
depth={depth + 1}
{indent}
{leaf}
/>
{/each}
{#if effectiveMax < item.items.length}
@@ -426,6 +431,8 @@
{/if}
</ResizeTransitionWrapper>
</div>
{:else if leaf}
{@render leaf(item, visualDepth)}
{:else}
<Item
{item}
@@ -0,0 +1,30 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import { ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte'
import type { TreeViewState } from './treeViewState.svelte'
interface Props {
tree: TreeViewState
// The tree is showing every node open (a filter is active), so Expand/Collapse all
// would have no effect.
forceExpanded?: boolean
class?: string
}
let { tree, forceExpanded = false, class: clazz = '' }: Props = $props()
</script>
<div class="flex items-center gap-2 {clazz}">
<Toggle size="xs" bind:checked={tree.treeView} options={{ right: 'Tree view' }} />
{#if tree.treeView && !forceExpanded}
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => (tree.collapseAll = !tree.collapseAll)}
startIcon={{ icon: tree.collapseAll ? ChevronsUpDown : ChevronsDownUp }}
>
{tree.collapseAll ? 'Expand all' : 'Collapse all'}
</Button>
{/if}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { untrack, type Snippet } from 'svelte'
import TreeView from './TreeView.svelte'
import { countLeaves, groupItems, type ItemType, type UserItem } from './treeViewUtils'
import { Button } from '$lib/components/common'
@@ -44,6 +44,8 @@
// under it. Only for browsing: a search or filter must not hide its matches behind
// a closed row.
groupOtherUsers?: boolean
// See TreeView's `leaf`.
leaf?: Snippet<[ItemType, number]>
}
let {
@@ -64,7 +66,8 @@
onExpandOwner,
onCollapseOwner,
showEditButton = true,
groupOtherUsers = false
groupOtherUsers = false,
leaf
}: Props = $props()
// How many root nodes render at once. A root node is a collapsed owner row that
@@ -145,6 +148,7 @@
const dir = groupDesc ? -1 : 1
const users = grouped.filter((g) => 'username' in g) as { username: string }[]
const folders = grouped.filter((g) => 'folderName' in g) as { folderName: string }[]
const leaves = grouped.filter((g) => !('username' in g) && !('folderName' in g))
users.push(...missingUsers)
folders.push(...missingFolders)
users.sort((a, b) => dir * a.username.localeCompare(b.username))
@@ -152,7 +156,8 @@
grouped.length = 0
grouped.push(
...(users as unknown as typeof grouped),
...(folders as unknown as typeof grouped)
...(folders as unknown as typeof grouped),
...leaves
)
}
}
@@ -258,6 +263,7 @@
on:reload
{showCode}
{showEditButton}
{leaf}
/>
{/snippet}
@@ -271,98 +277,105 @@
<div class="text-xs font-normal text-hint">No items</div>
</div>
{:else}
<div class="border rounded-md bg-surface-tertiary">
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)}
{#if row.kind === 'otherUsers'}
<!-- Same shape as an owner row, so it reads as part of the tree. It only reveals
<!-- Every row draws its own border-b, so the last one would double the frame's bottom border.
Pulling the content 1px down puts that border under the frame, where overflow-hidden
clips it, whichever row (collapsed owner, leaf, expanded subtree) ends the list. -->
<div class="border rounded-md bg-surface-tertiary overflow-hidden">
<div class="-mb-px">
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)}
{#if row.kind === 'otherUsers'}
<!-- Same shape as an owner row, so it reads as part of the tree. It only reveals
the user rows under it: each still loads its own items when opened. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOtherUsers}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
>
<div class="flex flex-row items-center gap-4">
<Users size={16} class="text-secondary" />
<div>
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">Other users</span>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined
? ` · ${pluralize(otherUsersItemCount, 'item')}`
: ''})
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOtherUsers}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
>
<div class="flex flex-row items-center gap-4">
<Users size={16} class="text-secondary" />
<div>
<span class="whitespace-nowrap text-xs text-emphasis font-semibold"
>Other users</span
>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined
? ` · ${pluralize(otherUsersItemCount, 'item')}`
: ''})
</div>
</div>
</div>
<Button
iconOnly
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: otherUsersOpen ? ChevronUp : ChevronDown }}
title={otherUsersOpen ? 'Hide other users' : 'Show other users'}
aria-label="Other users"
aria-expanded={otherUsersOpen}
onClick={toggleOtherUsers}
/>
</div>
<Button
iconOnly
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: otherUsersOpen ? ChevronUp : ChevronDown }}
title={otherUsersOpen ? 'Hide other users' : 'Show other users'}
aria-label="Other users"
aria-expanded={otherUsersOpen}
onClick={toggleOtherUsers}
/>
</div>
{#if otherUsersOpen}
<!-- Ranked after every root owner, so opening this row can't push folders out
{#if otherUsersOpen}
<!-- Ranked after every root owner, so opening this row can't push folders out
of what "expand all" auto-loads. -->
{#each otherUsers.slice(0, nbOtherUsersDisplayed) as user, i (user.username)}
{@render ownerNode(user, ownerRowCount + i, 1)}
{/each}
{#if nbOtherUsersDisplayed < otherUsers.length}
<div
class="pl-8 pr-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
>
<span class="text-xs text-secondary">
Showing {nbOtherUsersDisplayed} of {otherUsers.length} users
</span>
<Button
unifiedSize="sm"
variant="subtle"
onClick={() =>
(nbOtherUsersDisplayed = Math.min(
nbOtherUsersDisplayed + ROOT_PAGE,
otherUsers.length
))}
{#each otherUsers.slice(0, nbOtherUsersDisplayed) as user, i (user.username)}
{@render ownerNode(user, ownerRowCount + i, 1)}
{/each}
{#if nbOtherUsersDisplayed < otherUsers.length}
<div
class="pl-8 pr-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
>
Show more
</Button>
</div>
<span class="text-xs text-secondary">
Showing {nbOtherUsersDisplayed} of {otherUsers.length} users
</span>
<Button
unifiedSize="sm"
variant="subtle"
onClick={() =>
(nbOtherUsersDisplayed = Math.min(
nbOtherUsersDisplayed + ROOT_PAGE,
otherUsers.length
))}
>
Show more
</Button>
</div>
{/if}
{/if}
{:else}
{@render ownerNode(row.node, row.loadRank, 0)}
{/if}
{:else}
{@render ownerNode(row.node, row.loadRank, 0)}
{/if}
{/each}
{#if nbDisplayed < rows.length || hasMoreServer}
<!-- Last row of the tree's own frame, not a caption under it: what is missing
{/each}
{#if nbDisplayed < rows.length || hasMoreServer}
<!-- Last row of the tree's own frame, not a caption under it: what is missing
has to read as part of the list to be noticed at all. -->
<div
bind:this={footerEl}
class="px-4 py-3 flex flex-row items-center justify-between gap-4 bg-surface-secondary"
>
<span class="text-xs text-secondary">
{#if nbDisplayed < rows.length}
Showing {shownOwnerRowCount} of {ownerRowCount} folders and users
{:else}
<!-- Scoped to one owner: the tree groups the paged browse stream, so what
is missing is items, not root nodes. -->
Not all items are loaded yet
{/if}
</span>
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => {
if (nbDisplayed < rows.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, rows.length)
else onLoadMore?.()
}}
<div
bind:this={footerEl}
class="px-4 py-3 flex flex-row items-center justify-between gap-4 bg-surface-secondary"
>
{nbDisplayed < rows.length ? 'Show more' : 'Load more'}
</Button>
</div>
{/if}
<span class="text-xs text-secondary">
{#if nbDisplayed < rows.length}
Showing {shownOwnerRowCount} of {ownerRowCount} folders and users
{:else}
<!-- Scoped to one owner: the tree groups the paged browse stream, so what
is missing is items, not root nodes. -->
Not all items are loaded yet
{/if}
</span>
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => {
if (nbDisplayed < rows.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, rows.length)
else onLoadMore?.()
}}
>
{nbDisplayed < rows.length ? 'Show more' : 'Load more'}
</Button>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,22 @@
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
/** A list's tree-view toggle, persisted under `settingName`, and its expand/collapse-all state. */
export class TreeViewState {
#settingName: string
#treeView = $state(false)
collapseAll = $state(true)
constructor(settingName: string) {
this.#settingName = settingName
this.#treeView = getLocalSetting(settingName) == 'true'
}
get treeView() {
return this.#treeView
}
set treeView(value: boolean) {
this.#treeView = value
storeLocalSetting(this.#settingName, value ? 'true' : undefined)
}
}
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { groupItems, type ItemType } from './treeViewUtils'
const item = (path: string) => ({ path }) as unknown as ItemType
describe('groupItems', () => {
it('keeps a path outside u/ and f/ as a top-level leaf after users and folders', () => {
const grouped = groupItems([item('g/all/hub_sync'), item('f/ops/a'), item('u/admin/b')])
expect(grouped.map((n) => ('username' in n ? 'u' : 'folderName' in n ? 'f' : n.path))).toEqual([
'u',
'f',
'g/all/hub_sync'
])
})
})
@@ -117,22 +117,25 @@ export function groupItems(
}
} else if (pathSplit[0] === 'f') {
insertItemInFolder(root, item, pathSplit.slice(1))
} else {
// Any other owner (e.g. a schedule at `g/<group>/...`) has no node kind, so it
// stays a top-level leaf rather than vanishing from the tree.
root.push(item)
}
})
const dir = groupDesc ? -1 : 1
// Users, then folders, then ungrouped leaves regardless of direction; only the name
// comparison within users and folders follows `groupDesc`.
const rank = (n: ItemType | FolderItem | UserItem) =>
'username' in n ? 0 : 'folderName' in n ? 1 : 2
root.sort((a, b) => {
// Users always group before folders regardless of direction; only the name
// comparison within each kind follows `groupDesc`.
if ('username' in a && 'folderName' in b) {
return -1
}
if ('folderName' in a && 'username' in b) {
return 1
}
return (
dir * (a['username'] ?? a['folderName'] ?? '').localeCompare(b['username'] ?? b['folderName'])
)
const r = rank(a) - rank(b)
if (r !== 0) return r
if ('username' in a && 'username' in b) return dir * a.username.localeCompare(b.username)
if ('folderName' in a && 'folderName' in b)
return dir * a.folderName.localeCompare(b.folderName)
return 0
})
sortGroup(root, leafCompare, dir)
@@ -17,6 +17,7 @@
import DurationMs from '../DurationMs.svelte'
import { twMerge } from 'tailwind-merge'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import Portal from '../Portal.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -38,13 +39,20 @@
let loaded = false
let wrapper: HTMLElement | undefined = $state()
let popupOnTop = $state(true)
// The popup is portaled to <body> at fixed coordinates so an ancestor that clips
// overflow (a collapsible tree folder) can't cut it off. That makes it a sibling of the
// trigger rather than a descendant: moving onto it leaves the wrapper, so it holds
// itself open, and it closes on scroll since it no longer follows the row.
let anchor: DOMRect | undefined = $state()
let popupEl: HTMLElement | undefined = $state()
let open = $derived($openStore === id)
async function instantOpen() {
if (!open) {
hovered = true
popupOnTop = (wrapper?.getBoundingClientRect()?.top ?? 0) > POPUP_HEIGHT
anchor = wrapper?.getBoundingClientRect()
popupOnTop = (anchor?.top ?? 0) > POPUP_HEIGHT
openStore.set(id)
if (!loaded) {
await tick()
@@ -92,84 +100,100 @@
onDestroy(() => {
timeout && clearTimeout(timeout)
// `openStore` outlives this instance: left set, a remount of the same job would
// open with no anchor to position against.
if (open) openStore.set('')
})
</script>
<svelte:window onkeydown={({ key }) => ['Escape', 'Esc'].includes(key) && close()} />
{#if hovered}
<svelte:window
onkeydown={({ key }) => ['Escape', 'Esc'].includes(key) && close()}
onscrollcapture={(e) => open && !popupEl?.contains(e.target as Node) && close()}
/>
<!-- `open` too: crossing from the trigger onto the portaled popup leaves the wrapper, and
unmounting here would cancel a load still in flight. -->
{#if hovered || open}
<JobLoader bind:job bind:this={jobLoader} />
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onmouseenter={instantOpen} onmouseleave={staggeredClose} bind:this={wrapper} class="relative">
{@render children?.({ open })}
{#if open}
<div
transition:fade|local={{ duration: 50 }}
class={twMerge(
'absolute z-50 -left-10 bg-surface rounded border shadow-md flex flex-col gap-4 items-start w-[600px] h-80 overflow-hidden',
popupOnTop ? 'bottom-[35px]' : 'top-[35px]',
clazz
)}
>
<div class="w-full flex flex-row grow min-h-0 gap-2">
<div class="w-1/2 h-full overflow-auto space-y-1">
<span class="text-xs font-normal text-secondary">Arguments</span>
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $operatingWorkspace ?? 'no_w'}
args={job?.args}
/>
{#if open && anchor}
<Portal>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={popupEl}
transition:fade|local={{ duration: 50 }}
onmouseenter={instantOpen}
onmouseleave={staggeredClose}
class={twMerge(
'fixed z-50 bg-surface rounded border shadow-md flex flex-col gap-4 items-start w-[600px] h-80 overflow-hidden',
clazz
)}
style="left: {anchor.left - 40}px; {popupOnTop
? `bottom: ${window.innerHeight - anchor.bottom + 35}px;`
: `top: ${anchor.top + 35}px;`}"
>
<div class="w-full flex flex-row grow min-h-0 gap-2">
<div class="w-1/2 h-full overflow-auto space-y-1">
<span class="text-xs font-normal text-secondary">Arguments</span>
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $operatingWorkspace ?? 'no_w'}
args={job?.args}
/>
</div>
<div class="w-1/2 h-full overflow-auto space-y-1">
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
<div class="text-xs font-semibold text-emphasis mb-1">
<div>Job is scheduled for</div>
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
</div>
{/if}
{#if job?.type === 'CompletedJob'}
<span class="text-xs font-normal text-secondary mb-1">Result</span>
<DisplayResult
workspaceId={job?.workspace_id}
jobId={job?.id}
{result}
disableExpand
language={job?.language}
/>
{:else if job && `running` in job ? job.running : false}
<div class="text-sm font-semibold text-primary mb-1"> Job is still running </div>
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
content={job?.logs}
isLoading={job?.['running'] == false}
tag={job?.tag}
/>
{/if}
</div>
</div>
<div class="w-1/2 h-full overflow-auto space-y-1">
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
<div class="text-xs font-semibold text-emphasis mb-1">
<div>Job is scheduled for</div>
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
</div>
<div class="flex justify-end gap-2 pb-0.5 z-50 bg-surface-primary">
{#if job?.started_at}
<Badge>{new Date(job?.['started_at']).toLocaleString()}</Badge>
{/if}
{#if job?.type === 'CompletedJob'}
<span class="text-xs font-normal text-secondary mb-1">Result</span>
<DisplayResult
workspaceId={job?.workspace_id}
jobId={job?.id}
{result}
disableExpand
language={job?.language}
/>
{:else if job && `running` in job ? job.running : false}
<div class="text-sm font-semibold text-primary mb-1"> Job is still running </div>
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
content={job?.logs}
isLoading={job?.['running'] == false}
tag={job?.tag}
<Badge>
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{#if job?.['duration_ms']}
<DurationMs
duration_ms={job?.['duration_ms']}
self_wait_time_ms={job?.self_wait_time_ms}
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
/>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<Badge>Label: {label}</Badge>
{/each}
{/if}
</div>
</div>
<div class="flex justify-end gap-2 pb-0.5 z-50 bg-surface-primary">
{#if job?.started_at}
<Badge>{new Date(job?.['started_at']).toLocaleString()}</Badge>
{/if}
<Badge>
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{#if job?.['duration_ms']}
<DurationMs
duration_ms={job?.['duration_ms']}
self_wait_time_ms={job?.self_wait_time_ms}
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
/>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<Badge>Label: {label}</Badge>
{/each}
{/if}
</div>
</div>
</Portal>
{/if}
</div>
@@ -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)
</script>
{#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}
<div
class={twMerge(
'bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 flex flex-col',
inTree
? 'border-b'
: 'first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md'
)}
style={depth > 0 ? `padding-left: ${(depth + 1) * 16}px;` : ''}
>
<div class="w-full flex gap-4 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => {
handledHash = `#${path}`
editSchedule(path, is_flow)
}}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
{summary || script_path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left">
schedule: {path}
</div>
</a>
{#if labels?.length}
{#each labels as label}
<Badge
color="blue"
small
class="px-1"
title="Label: {label}"
clickable
onclick={() => {
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}</Badge
>
{/each}
{/if}
<InheritedLabels labels={inherited_labels} />
{#if paused_until && new Date(paused_until) > new Date()}
<div class="pb-1">
<Badge color="yellow">Paused until {new Date(paused_until).toLocaleString()}</Badge>
</div>
{/if}
<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>
<div class="w-10">
{#if error}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
The schedule disabled itself because there was an error scheduling the next job: {error}
</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#key toggleResetVersions[path] ?? 0}
<Toggle
disabled={draft_only || !!$scheduleLock}
options={{
title: draft_only
? 'Draft only: deploy the schedule to enable it'
: ($scheduleLock ??
(hasDraft
? 'Enables/disables the deployed schedule; the draft is not affected'
: undefined))
}}
checked={!draft_only && enabled}
on:change={(e) => {
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}
</div>
<div class="flex gap-2 items-center justify-end">
<Button
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
disabled={draft_only}
unifiedSize="md"
startIcon={{ icon: List }}
variant="subtle"
>
Runs
</Button>
<Button
on:click={() => editSchedule(path, is_flow)}
unifiedSize="md"
startIcon={{ icon: canEdit ? Pen : Eye }}
variant="subtle"
>
{canEdit ? 'Edit' : 'View'}
</Button>
<Dropdown
size="md"
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
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')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
{#if loadingSchedulesWithJobStats}
<div class="flex gap-1 ml-0.5 text-xs text-secondary items-center">
<Loader2 size={14} class="animate-spin" />
<span>Job stats loading...</span>
</div>
{:else}
<div class="flex gap-1.5 ml-0.5 items-baseline flex-row-reverse">
{#if avg_s}
<div class="pl-2 text-secondary text-xs">Avg: {(avg_s / 1000).toFixed(2)}s</div>
{/if}
{#each jobs ?? [] as job}
{@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
<a href="{base}/run/{job.id}?workspace={$operatingWorkspace}">
<JobPreview id={job.id} class="p-4">
<div>
<div
class="{job.success ? 'bg-green-300' : 'bg-red-300'} mx-auto w-1.5"
style="height: {h}px"
></div>
<!-- <div class="text-[0.6em] mt-0.5 text-center text-primary"
>{(job.duration_ms / 1000).toFixed(2)}s</div
> -->
</div>
</JobPreview>
</a>
{/each}
</div>
{/if}
<div
class="flex flex-wrap text-xs text-secondary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div class="truncate"
>{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/snippet}
{#snippet treeLeaf(item: ItemType, depth: number)}
{@render scheduleRow(item as unknown as ScheduleW, true, depth)}
{/snippet}
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} />
@@ -397,6 +692,7 @@
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="flex flex-row items-center justify-end gap-4 pb-4">
<TreeViewControls {tree} forceExpanded={treeForceExpanded} class="mr-auto" />
<ToggleButtonGroup bind:selected={filterEnabledDisabled} class="w-fit">
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
@@ -439,278 +735,26 @@
/>
{/if}
{:else if items?.length}
<div class="border rounded-md divide-y">
{#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}
<div
class="bg-surface-tertiary hover:bg-surface-hover 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-4 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => {
handledHash = `#${path}`
editSchedule(path, is_flow)
}}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
>
{summary || script_path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left">
schedule: {path}
</div>
</a>
{#if labels?.length}
{#each labels as label}
<Badge
color="blue"
small
class="px-1"
title="Label: {label}"
clickable
onclick={() => {
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}</Badge
>
{/each}
{/if}
<InheritedLabels labels={inherited_labels} />
{#if paused_until && new Date(paused_until) > new Date()}
<div class="pb-1">
<Badge color="yellow"
>Paused until {new Date(paused_until).toLocaleString()}</Badge
>
</div>
{/if}
<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>
<div class="w-10">
{#if error}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
The schedule disabled itself because there was an error scheduling the
next job: {error}
</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#key toggleResetVersions[path] ?? 0}
<Toggle
disabled={draft_only || !!$scheduleLock}
options={{
title: draft_only
? 'Draft only: deploy the schedule to enable it'
: ($scheduleLock ??
(hasDraft
? 'Enables/disables the deployed schedule; the draft is not affected'
: undefined))
}}
checked={!draft_only && enabled}
on:change={(e) => {
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}
</div>
<div class="flex gap-2 items-center justify-end">
<Button
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
disabled={draft_only}
unifiedSize="md"
startIcon={{ icon: List }}
variant="subtle"
>
Runs
</Button>
<Button
on:click={() => editSchedule(path, is_flow)}
unifiedSize="md"
startIcon={{ icon: canEdit ? Pen : Eye }}
variant="subtle"
>
{canEdit ? 'Edit' : 'View'}
</Button>
<Dropdown
size="md"
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
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')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
{#if loadingSchedulesWithJobStats}
<div class="flex gap-1 ml-0.5 text-xs text-secondary items-center">
<Loader2 size={14} class="animate-spin" />
<span>Job stats loading...</span>
</div>
{:else}
<div class="flex gap-1.5 ml-0.5 items-baseline flex-row-reverse">
{#if avg_s}
<div class="pl-2 text-secondary text-xs"
>Avg: {(avg_s / 1000).toFixed(2)}s</div
>
{/if}
{#each jobs ?? [] as job}
{@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
<a href="{base}/run/{job.id}?workspace={$operatingWorkspace}">
<JobPreview id={job.id} class="p-4">
<div>
<div
class="{job.success ? 'bg-green-300' : 'bg-red-300'} mx-auto w-1.5"
style="height: {h}px"
></div>
<!-- <div class="text-[0.6em] mt-0.5 text-center text-primary"
>{(job.duration_ms / 1000).toFixed(2)}s</div
> -->
</div>
</JobPreview>
</a>
{/each}
</div>
{/if}
<div
class="flex flex-wrap text-xs text-secondary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{#if tree.treeView}
<TreeViewRoot
items={items as unknown as ItemType[]}
collapseAll={tree.collapseAll}
isSearching={treeForceExpanded}
showCode={() => {}}
leaf={treeLeaf}
/>
{:else}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as s (s.path)}
{@render scheduleRow(s, false, 0)}
{/each}
</div>
{/if}
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
{#if !tree.treeView && items && items?.length > 15 && nbDisplayed < items.length}
<div class="flex items-center gap-4 text-xs font-semibold text-emphasis">
<span>{nbDisplayed} items out of {items.length}</span>
<Button unifiedSize="sm" variant="subtle" on:click={() => (nbDisplayed += 30)}>
@@ -47,6 +47,11 @@
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import TreeViewRoot from '$lib/components/home/TreeViewRoot.svelte'
import { twMerge } from 'tailwind-merge'
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 ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
@@ -370,6 +375,10 @@
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const tree = new TreeViewState('triggersTreeView')
// A search or an owner filter opens every folder so no match hides behind a closed one.
let treeForceExpanded = $derived(filter !== '' || ownerFilter != undefined)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
@@ -666,6 +675,191 @@
</div>
</ConfirmationModal>
{#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}
<div
class={twMerge(
'bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 flex flex-col',
inTree
? 'border-b'
: 'first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md'
)}
style={depth > 0 ? `padding-left: ${(depth + 1) * 16}px;` : ''}
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={(e) => {
if (hosted) e.preventDefault()
openEdit(path, is_flow)
}}
class="min-w-0 grow hover:underline decoration-gray-400"
>
{@render rowTitle(row, hasDraft)}
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
{#if live}
{@render statusDot(row)}
{/if}
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#if live}
<TriggerModeToggle
disabled={draft_only}
title={$triggerLock ??
(draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined)}
onToggleMode={(newMode) => 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}
</div>
<div class="flex gap-2 items-center justify-end">
{#if copy}
<Button
on:click={() => copyToClipboard(copy.value)}
variant="subtle"
unifiedSize="md"
startIcon={{ icon: ClipboardCopy }}
>
{copy.label}
</Button>
{/if}
<Button
on:click={() => openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canEdit ? { icon: Pen } : { icon: Eye }}
variant="subtle"
>
{canEdit ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => 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)
}
]}
/>
</div>
</div>
<div class="w-full flex justify-end items-baseline">
<div
class="flex flex-wrap text-2xs font-normal text-secondary gap-1 items-center justify-end truncate pr-2"
>
{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}
<div class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div>
</div>
</div>
</div>
{/snippet}
{#snippet treeLeaf(item: ItemType, depth: number)}
{@render triggerRow(item as unknown as TriggerW & { marked?: any }, true, depth)}
{/snippet}
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
{#await EDITORS[triggerKind]() then Editor}
<Editor.default onUpdate={loadTriggers} bind:this={editor} />
@@ -760,6 +954,7 @@
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
<TreeViewControls {tree} forceExpanded={treeForceExpanded} class="mr-auto" />
{#if operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if isAdmin}
@@ -793,189 +988,26 @@
: undefined}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#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}
<div
class="bg-surface-tertiary hover:bg-surface-hover 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}"
onclick={(e) => {
if (hosted) e.preventDefault()
openEdit(path, is_flow)
}}
class="min-w-0 grow hover:underline decoration-gray-400"
>
{@render rowTitle(row, hasDraft)}
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
{#if live}
{@render statusDot(row)}
{/if}
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#if live}
<TriggerModeToggle
disabled={draft_only}
title={$triggerLock ??
(draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined)}
onToggleMode={(newMode) => 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}
</div>
<div class="flex gap-2 items-center justify-end">
{#if copy}
<Button
on:click={() => copyToClipboard(copy.value)}
variant="subtle"
unifiedSize="md"
startIcon={{ icon: ClipboardCopy }}
>
{copy.label}
</Button>
{/if}
<Button
on:click={() => openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canEdit ? { icon: Pen } : { icon: Eye }}
variant="subtle"
>
{canEdit ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => 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)
}
]}
/>
</div>
</div>
<div class="w-full flex justify-end items-baseline">
<div
class="flex flex-wrap text-2xs font-normal text-secondary gap-1 items-center justify-end truncate pr-2"
>
{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}
<div class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{#if tree.treeView}
<TreeViewRoot
items={items as unknown as ItemType[]}
collapseAll={tree.collapseAll}
isSearching={treeForceExpanded}
showCode={() => {}}
leaf={treeLeaf}
/>
{:else}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as row (row.path)}
{@render triggerRow(row, false, 0)}
{/each}
</div>
{/if}
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
{#if !tree.treeView && items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs font-normal text-primary"
>{nbDisplayed} items out of {items.length}
<button class="ml-4 font-semibold text-emphasis" onclick={() => (nbDisplayed += 30)}