Files
windmill/frontend/src/lib/components/runs/RunsTable.svelte
T
GuilhemandClaude Opus 5.5 fd8de45cc7 feat: open a run as a pushed page on the narrow runs page (#11541)
* feat: open a run as a pushed page on narrow runs page

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

* fix: keep modifier-click multi-select and arrow keys on the narrow runs list

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

* fix: leave the narrow re-run page once the re-run is sent, and clear the opened run on reset

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

* fix: close the re-run options once a table re-run completes

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 14:45:03 +02:00

605 lines
19 KiB
Svelte

<script lang="ts">
import type { Job } from '$lib/gen'
import RunRow from './RunRow.svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
import { createEventDispatcher } from 'svelte'
import Tooltip from '../Tooltip.svelte'
import {
AlertTriangle,
CircleXIcon,
Code2Icon,
ExternalLinkIcon,
RefreshCwIcon,
Wrench
} from 'lucide-svelte'
import Popover from '../Popover.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import './runs-grid.css'
import { useKeyPressed } from '$lib/svelte5Utils.svelte'
import { twMerge } from 'tailwind-merge'
import RightClickPopover from '../RightClickPopover.svelte'
import DropdownMenu, { type Props as DropdownMenuProps } from '../DropdownMenu.svelte'
import { clickOutside, isJobCancelable, isJobReRunnable, isJobResolvable } from '$lib/utils'
import { goto } from '$lib/navigation'
import BarsStaggered from '../icons/BarsStaggered.svelte'
interface Props {
//import InfiniteLoading from 'svelte-infinite-loading'
jobs?: Job[] | undefined
externalJobs?: Job[]
omittedObscuredJobs: boolean
showExternalJobs?: boolean
selectedIds?: string[]
selectedWorkspace?: string | undefined
activeLabel?: string | null
// const loadMoreQuantity: number = 100
lastFetchWentToEnd?: boolean
perPage?: number
batchRerunOptionsIsOpen?: boolean
manualSelectionMode: undefined | 'cancel' | 'rerun' | 'resolve'
onCancelJobs: (jobIds: string[]) => void
onSetJobsResolution: (jobIds: string[], resolved: boolean) => void
loadingExtra?: boolean
}
let {
jobs = undefined,
externalJobs = [],
omittedObscuredJobs,
showExternalJobs = false,
selectedIds = $bindable([]),
selectedWorkspace = $bindable(undefined),
activeLabel = null,
lastFetchWentToEnd = false,
perPage = 1000,
manualSelectionMode,
onCancelJobs,
onSetJobsResolution,
batchRerunOptionsIsOpen = $bindable(),
loadingExtra = false
}: Props = $props()
let hasClickFocus = $state(false)
const keysPressed = useKeyPressed(['Shift', 'Control', 'Meta', 'A', 'ArrowDown', 'ArrowUp'], {
onKeyDown(key, e) {
if (!hasClickFocus) return
if (key === 'A' && (keysPressed.Control || keysPressed.Meta)) {
if (batchRerunOptionsIsOpen) return
e.preventDefault()
e.stopPropagation()
selectedIds = flatJobs
? flatJobs
.filter((jobOrDate) => jobOrDate.type === 'job')
.map((jobOrDate) => jobOrDate.job.id)
: []
} else if ((key === 'ArrowDown' || key === 'ArrowUp') && selectedIds.length === 1) {
const idx = flatJobs?.findIndex(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
)
if (idx == undefined) return
let nextJob = flatJobs?.[idx + (key === 'ArrowDown' ? 1 : -1)]
if (nextJob?.type === 'date') nextJob = flatJobs?.[idx + (key === 'ArrowDown' ? 2 : -2)]
if (nextJob?.type !== 'job') return
selectedIds = [nextJob.job.id]
dispatch('select')
e.preventDefault()
}
}
})
let rightClickPopover: RightClickPopover | undefined = $state(undefined)
function getTime(job: Job): string | undefined {
return job['completed_at'] ?? job['started_at'] ?? job['scheduled_for'] ?? job['created_at']
}
function groupJobsByDay(jobs: Job[]): {
groupedJobs: Record<string, Job[]>
newContainsLabel: boolean | undefined
} {
const groupedJobs: Record<string, Job[]> = {}
if (!jobs) return { groupedJobs, newContainsLabel: undefined }
let newContainsLabel = false
for (const job of jobs) {
if (job?.['labels'] != undefined) {
newContainsLabel = true
}
const field: string | undefined = getTime(job)
if (field) {
const date = new Date(field)
date.setMilliseconds(date.getMilliseconds())
const day = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
if (!groupedJobs[day]) {
groupedJobs[day] = []
}
groupedJobs[day].push(job)
}
}
for (const day in groupedJobs) {
groupedJobs[day].sort((a, b) => {
return new Date(getTime(b)!).getTime() - new Date(getTime(a)!).getTime()
})
}
const sortedJobs: Record<string, Job[]> = {}
Object.keys(groupedJobs)
.sort((a, b) => {
return new Date(b).getTime() - new Date(a).getTime()
})
.forEach((key) => {
sortedJobs[key] = groupedJobs[key]
})
return { groupedJobs: sortedJobs, newContainsLabel }
}
type FlatJobs =
| {
type: 'date'
date: string
}
| {
type: 'job'
job: Job
}
function flattenJobs(groupedJobs: Record<string, Job[]>): Array<FlatJobs> {
const flatJobs: Array<FlatJobs> = []
for (const [date, jobsByDay] of Object.entries(groupedJobs)) {
flatJobs.push({ type: 'date', date })
for (const job of jobsByDay) {
flatJobs.push({ type: 'job', job })
}
}
return flatJobs
}
let tableHeight: number = $state(0)
let containerWidth: number = $state(0)
// const MAX_ITEMS = perPage
/*
function infiniteHandler({ detail: { loaded, error, complete } }) {
try {
nbOfJobs += loadMoreQuantity
if (nbOfJobs >= MAX_ITEMS) {
complete()
} else {
loaded()
}
} catch (e) {
error()
}
}
*/
function jobCountString(
jobCount: number | undefined,
lastFetchWentToEnd: boolean,
hideLabel?: boolean
): string {
if (jobCount === undefined) {
return ''
}
const jc = jobCount
const isTruncated = jc >= perPage && !lastFetchWentToEnd
if (hideLabel) return `${jc}${isTruncated ? '+' : ''}`
else return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
}
const dispatch = createEventDispatcher()
let scrollToIndex = $state(0)
export function scrollToRun(ids: string[]) {
if (flatJobs && ids.length > 0) {
const i = flatJobs.findIndex(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === ids[0]
)
if (i !== -1) {
scrollToIndex = i
}
}
}
let { groupedJobs, newContainsLabel } = $derived(
jobs
? showExternalJobs
? groupJobsByDay([...jobs, ...externalJobs])
: groupJobsByDay(jobs)
: { groupedJobs: undefined, newContainsLabel: undefined }
)
let containsLabel = $state(false)
$effect(() => {
if (newContainsLabel != undefined) {
containsLabel = newContainsLabel
}
})
let flatJobs = $derived(groupedJobs ? flattenJobs(groupedJobs) : undefined)
let stickyIndices = $derived.by(() => {
const nstickyIndices: number[] = []
let index = 0
for (const entry of flatJobs ?? []) {
if (entry.type === 'date') {
nstickyIndices.push(index)
}
index++
}
return nstickyIndices
})
let showTag = $derived(containerWidth > 700)
let selectedIdsPossibleActions = $derived.by(() => {
const cancellableJobIds: string[] = []
const rerunnableJobIds: string[] = []
const resolvableJobIds: string[] = []
const unresolvableJobIds: string[] = []
for (const jobId of selectedIds) {
const job = flatJobs?.find(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === jobId
)
if (job?.type === 'job') {
if (isJobCancelable(job.job)) cancellableJobIds.push(job.job.id)
if (isJobReRunnable(job.job)) rerunnableJobIds.push(job.job.id)
if (isJobResolvable(job.job)) {
if (job.job.resolved) unresolvableJobIds.push(job.job.id)
else resolvableJobIds.push(job.job.id)
}
}
}
return { cancellableJobIds, rerunnableJobIds, resolvableJobIds, unresolvableJobIds }
})
let hoveredDropdownAction: 'cancel' | 'rerun' | 'resolve' | null = $state(null)
let dropdownActions: DropdownMenuProps['items'] = $derived.by(() => {
let rerunnable = selectedIdsPossibleActions.rerunnableJobIds.length
let cancellable = selectedIdsPossibleActions.cancellableJobIds.length
const actions: DropdownMenuProps['items'] = []
if (selectedIds.length === 1) {
actions.push({
label: 'Show run details',
icon: ExternalLinkIcon,
onClick: () => goto(`/run/${selectedIds[0]}`)
})
const job = flatJobs?.find(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
)
if (job?.type === 'job') {
if (job.job.job_kind === 'script') {
actions.push({
label: 'Go to script page',
icon: Code2Icon,
onClick: () => goto(`/scripts/get/${job.job.script_hash}`)
})
}
if (job.job.job_kind === 'flow') {
actions.push({
label: 'Go to flow page',
icon: BarsStaggered,
onClick: () => goto(`/flows/get/${job.job.script_path}`)
})
}
}
}
if (rerunnable)
actions.push({
label: 'Run again',
icon: RefreshCwIcon,
right: selectedIds.length >= 2 ? `${rerunnable}` : undefined,
onClick: () => {
selectedIds = selectedIdsPossibleActions.rerunnableJobIds
batchRerunOptionsIsOpen = true
},
onHover: (hover) => (hoveredDropdownAction = hover ? 'rerun' : null)
})
if (cancellable)
actions.push({
label: 'Cancel',
icon: CircleXIcon,
right: selectedIds.length >= 2 ? `${cancellable}` : undefined,
onClick: () => onCancelJobs?.(selectedIdsPossibleActions.cancellableJobIds),
onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null)
})
// Operators are rejected by the endpoint, so offering the action would only 403.
const canResolve = !$userStore?.operator
const resolvable = canResolve ? selectedIdsPossibleActions.resolvableJobIds.length : 0
const unresolvable = canResolve ? selectedIdsPossibleActions.unresolvableJobIds.length : 0
if (resolvable)
actions.push({
label: 'Mark resolved',
icon: Wrench,
right: selectedIds.length >= 2 ? `${resolvable}` : undefined,
onClick: () => onSetJobsResolution?.(selectedIdsPossibleActions.resolvableJobIds, true),
onHover: (hover) => (hoveredDropdownAction = hover ? 'resolve' : null)
})
if (unresolvable)
actions.push({
label: 'Unresolve',
icon: Wrench,
right: selectedIds.length >= 2 ? `${unresolvable}` : undefined,
onClick: () => onSetJobsResolution?.(selectedIdsPossibleActions.unresolvableJobIds, false),
onHover: (hover) => (hoveredDropdownAction = hover ? 'resolve' : null)
})
return actions
})
function jobIsSelectable(job: Job) {
if (
(rightClickPopover?.isOpen() && hoveredDropdownAction === 'cancel') ||
manualSelectionMode === 'cancel'
)
return isJobCancelable(job)
if (
(rightClickPopover?.isOpen() && hoveredDropdownAction === 'rerun') ||
manualSelectionMode === 'rerun' ||
batchRerunOptionsIsOpen
)
return isJobReRunnable(job)
if (
(rightClickPopover?.isOpen() && hoveredDropdownAction === 'resolve') ||
manualSelectionMode === 'resolve'
)
return isJobResolvable(job)
return true
}
let selectableJobs = $derived(jobs?.filter(jobIsSelectable) ?? [])
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="divide-y h-full flex flex-col min-w-[650px]"
id="runs-table-wrapper"
onclick={() => (hasClickFocus = true)}
use:clickOutside={{ onClickOutside: () => (hasClickFocus = false) }}
bind:clientWidth={containerWidth}
>
<div>
<div
class="grid sticky top-0 w-full min-h-6 my-2 pr-4 items-end"
class:grid-runs-table={!containsLabel && !manualSelectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !manualSelectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && manualSelectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && manualSelectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !manualSelectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !manualSelectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && manualSelectionMode && !showTag}
class:grid-runs-table-with-labels-selection-no-tag={containsLabel &&
manualSelectionMode &&
!showTag}
>
{#if manualSelectionMode}
{@const allSelected = selectedIds.length === selectableJobs?.length}
<div class="w-4 h-4 ml-4">
<input
type="checkbox"
bind:checked={
() => allSelected,
() => (selectedIds = allSelected ? [] : (selectableJobs.map((j) => j.id) ?? []))
}
/>
</div>
{/if}
<div class="text-2xs px-4 flex flex-row items-center gap-2 leading-3 whitespace-nowrap">
{#if showExternalJobs && externalJobs.length > 0}
<div class="flex flex-row">
{jobs
? jobCountString(jobs.length + externalJobs.length, lastFetchWentToEnd)
: ''}<Tooltip>{externalJobs.length} jobs obscured</Tooltip>
</div>
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
<div class="flex flex-row">
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
<Popover>
<AlertTriangle size={16} class="ml-0.5 text-yellow-500" />
{#snippet text()}
Too specific filtering may have caused the omission of obscured jobs. This is done
for security reasons. To see obscured jobs, try removing some filters.
{/snippet}
</Popover>
</div>
{:else}
{@const jobCount = jobs
? jobCountString(jobs.length, lastFetchWentToEnd, selectedIds.length >= 2)
: ''}
{selectedIds.length >= 2 ? `${selectedIds.length}/` : ''}<wbr />
{jobCount}
{/if}
</div>
<div></div>
<div class="text-xs font-semibold leading-3">Duration</div>
<div class="text-xs font-semibold leading-3">Path</div>
{#if containsLabel}
<div class="text-xs font-semibold leading-3">Label</div>
{/if}
<div class="text-xs font-semibold leading-3">Triggered by</div>
{#if showTag}
<div class="text-xs font-semibold leading-3">Tag</div>
{/if}
<div> </div>
</div>
</div>
<div
bind:clientHeight={tableHeight}
class="relative flex-1 border rounded-t-md overflow-clip bg-surface-tertiary [&>.virtual-list-wrapper::-webkit-scrollbar-track]:bg-surface-tertiary"
>
{#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
<div class="text-xs text-secondary p-8"> No jobs found for the selected filters. </div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 -mt-3"
oncontextmenu={(e) => {
e.preventDefault()
rightClickPopover?.open(e)
}}
>
<VirtualList
width="100%"
height={tableHeight}
itemCount={flatJobs?.length ?? 3}
itemSize={42}
overscanCount={20}
{stickyIndices}
{scrollToIndex}
scrollToAlignment="center"
>
{#snippet header()}{/snippet}
{#snippet item({ index, style })}
<div {style} class="w-full bg-surface-tertiary">
{#if flatJobs}
{@const jobOrDate = flatJobs[index]}
{#if jobOrDate}
{#if jobOrDate?.type === 'date'}
<div
class={twMerge(
'border-b py-1.5 font-semibold text-xs pl-4 h-[42px] flex items-end bg-surface-tertiary'
)}
>
{jobOrDate.date}
</div>
{:else}
{@const selected =
jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
{@const nonSelectable = !jobIsSelectable(jobOrDate.job)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'flex flex-row items-center h-full w-full select-none transition-opacity',
nonSelectable || (rightClickPopover?.isOpen() && !selected)
? 'opacity-20'
: ''
)}
>
<RunRow
{manualSelectionMode}
{containsLabel}
{showTag}
job={jobOrDate.job}
{selected}
on:select={() => {
const jobId = jobOrDate.job.id
if (keysPressed.Shift && selectedIds.length > 0) {
if (nonSelectable) return
const lastSelectedId = selectedIds[selectedIds.length - 1]
const lastSelectedIndex = flatJobs?.findIndex(
(jobOrDate) =>
jobOrDate.type === 'job' && jobOrDate.job.id === lastSelectedId
)
if (lastSelectedIndex != undefined && flatJobs) {
const [start, end] =
index < lastSelectedIndex
? [index, lastSelectedIndex]
: [lastSelectedIndex, index]
const newSelectedIds = flatJobs
.slice(start, end + 1)
.filter((jobOrDate) => jobOrDate.type === 'job')
.map((jobOrDate) => jobOrDate.job.id)
selectedIds = Array.from(new Set([...selectedIds, ...newSelectedIds]))
}
} else if (
keysPressed.Control ||
keysPressed.Meta ||
manualSelectionMode
) {
if (nonSelectable) return
if (selectedIds.includes(jobOrDate.job.id)) {
selectedIds = selectedIds.filter((id) => id != jobId)
} else {
selectedIds.push(jobId)
selectedIds = selectedIds
}
} else {
if (batchRerunOptionsIsOpen) batchRerunOptionsIsOpen = false
if (
selectedIds.length !== 1 ||
selectedIds[0] !== jobOrDate.job.id ||
selectedWorkspace !== jobOrDate.job.workspace_id
) {
selectedWorkspace = jobOrDate.job.workspace_id
selectedIds = [jobOrDate.job.id]
dispatch('select')
} else {
selectedIds = []
selectedWorkspace = undefined
dispatch('select')
}
}
}}
{activeLabel}
on:filterByLabel
on:filterByPath
on:filterByUser
on:filterByFolder
on:filterByConcurrencyKey
on:filterBySchedule
on:filterByWorker
{containerWidth}
/>
</div>
{/if}
{:else}
{JSON.stringify(jobOrDate)}
{/if}
{:else}
<div class="flex flex-row items-center h-full w-full">
<div class="w-1/12 text-2xs">...</div>
<div class="w-4/12 text-xs">...</div>
<div class="w-4/12 text-xs">...</div>
<div class="w-3/12 text-xs">...</div>
</div>
{/if}
</div>
{/snippet}
{#snippet footer()}
<div
>{#if !lastFetchWentToEnd && jobs && jobs.length >= perPage}
<button
class="text-xs text-accent text-center w-full pb-2"
disabled={loadingExtra}
onclick={() => {
dispatch('loadExtra')
}}
>
{#if loadingExtra}
Loading...
{:else}
Load next {perPage} jobs
{/if}
</button>
{/if}</div
>
{/snippet}
</VirtualList>
</div>
{/if}
</div>
</div>
<RightClickPopover bind:this={rightClickPopover}>
<DropdownMenu closeCallback={() => rightClickPopover?.close()} items={dropdownActions} />
</RightClickPopover>
<style>
:global(.virtual-list-wrapper::-webkit-scrollbar) {
width: 8px !important;
height: 8px !important;
}
</style>