mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
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:
co-authored by
Claude Opus 5.5
parent
651a6b01f5
commit
e68ff0d969
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user