-
- {#if selectionMode && selectableJobCount}
-
-
-
-
-
+
+
+
+
+ {#if jobs}
+
+ {:else}
+
+ {#each new Array(8) as _}
+
+ {/each}
{/if}
-
-
+
+ {#if !manualSelectionMode}
+
((manualSelectionMode = 'cancel'), (selectedIds = []))
+ },
+ {
+ displayName: 'Re-run jobs',
+ action: () => {
+ manualSelectionMode = 'rerun'
+ selectedIds = []
+ batchRerunOptionsIsOpen = true
+ }
+ },
+ {
+ displayName: 'Cancel all jobs matching filters',
+ action: () => onCancelAllJobsMatchingFilters()
+ },
+ {
+ displayName: 'Re-run all jobs matching filters',
+ action: () => onRerunAllJobsMatchingFilters()
+ }
+ ]}
+ />
+ {:else}
+
+ {/if}
+
+
+
-
-
- {#if !filters.job_trigger_kind}
-
-
-
-
-
-
- {/if}
-
-
-
-
-
-
-
-
- {
- jobsLoader?.loadJobs(true)
- }}
- bind:minTs={filters.min_ts}
- bind:maxTs={filters.max_ts}
- bind:selectedManualDate
- {loading}
- bind:this={manualDatePicker}
- numberOfLastJobsToFetch={filters.per_page}
- />
- {
- localStorage.setItem('auto_refresh_in_runs', autoRefresh ? 'true' : 'false')
- }}
- options={{ right: 'Auto-refresh' }}
- textClass="whitespace-nowrap"
- />
-
-
-
-
-
-
- {#if jobs}
-
- {:else}
-
- {#each new Array(8) as _}
-
- {/each}
-
- {/if}
-
-
- Per page:
-
-
0}>
- {#if selectionMode === 're-run'}
-
- {:else if selectedIds.length === 1}
- {#if selectedIds[0] === '-'}
- There is no information available for this job
- {:else}
- 0 || !!manualSelectionMode}
+ >
+
+ {#if manualSelectionMode === 'cancel'}
+
+
+
+ {:else if batchRerunOptionsIsOpen}
+
(
+ (batchRerunOptionsIsOpen = false),
+ (manualSelectionMode = undefined)
+ )}
+ onConfirm={async (options) => {
+ await onReRunSelectedJobs(options)
+ }}
/>
+ {:else if selectedIds.length === 1}
+ {#if selectedIds[0] === '-'}
+ There is no information available for this job
+ {:else}
+
+ {/if}
+ {:else if selectedIds.length > 1}
+
+
{selectedIds.length} jobs selected
+
{/if}
- {:else if selectedIds.length > 1}
- There are {selectedIds.length} jobs selected. Choose 1 to see detailed information
- {/if}
+
{/if}
+
+
diff --git a/frontend/src/lib/components/ServiceLogsInner.svelte b/frontend/src/lib/components/ServiceLogsInner.svelte
index cdf2ed5c0f..0f4f923cd6 100644
--- a/frontend/src/lib/components/ServiceLogsInner.svelte
+++ b/frontend/src/lib/components/ServiceLogsInner.svelte
@@ -4,12 +4,15 @@
const bubble = createBubbler()
import { IndexSearchService, ServiceLogsService } from '$lib/gen'
- import ManuelDatePicker from './runs/ManuelDatePicker.svelte'
+ import TimeframeSelect, {
+ serviceLogsTimeframes,
+ useUrlSyncedTimeframe
+ } from './runs/TimeframeSelect.svelte'
import CalendarPicker from './common/calendarPicker/CalendarPicker.svelte'
import LogViewer from './LogViewer.svelte'
import Toggle from './Toggle.svelte'
import { sendUserToast } from '$lib/toast'
- import { onDestroy, tick, untrack } from 'svelte'
+ import { onDestroy, tick } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
import LogSnippetViewer from './LogSnippetViewer.svelte'
@@ -20,6 +23,7 @@
import Select from './select/Select.svelte'
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
+ import { watch } from 'runed'
interface Props {
searchTerm: string
@@ -32,9 +36,6 @@
let minTs: undefined | string = $state(undefined)
let maxTs: undefined | string = $state(undefined)
- let minTsManual: undefined | string = $state($page.url.searchParams.get('minTs') ?? undefined)
- let maxTsManual: undefined | string = $state($page.url.searchParams.get('maxTs') ?? undefined)
-
let max_lines: undefined | number = $state(undefined)
// let lastSeen: undefined | string = undefined
@@ -58,15 +59,17 @@
let timeout: number | undefined = $state(undefined)
let allLogs: ByMode | undefined = $state(undefined)
- let manualPicker: ManuelDatePicker | undefined = $state(undefined)
+
+ let _timeframe = useUrlSyncedTimeframe(serviceLogsTimeframes)
+ let timeframe = $derived(_timeframe.timeframe)
+
+ let [minTsManual, maxTsManual] = $derived(
+ timeframe.type === 'manual' ? [timeframe.minTs ?? undefined, timeframe.maxTs ?? undefined] : []
+ )
let upTo: undefined | string = $state(undefined)
let upToIsLatest = $state(true)
- function onManualChanges() {
- getAllLogs(minTsManual ?? maxTs, maxTsManual)
- }
-
function getAllLogs(queryMinTs: string | undefined, queryMaxTs: string | undefined) {
timeout && clearTimeout(timeout)
loading = true
@@ -151,11 +154,6 @@
if (autoRefresh && searchTerm === '' && !maxTsManual) {
timeout = setTimeout(() => {
if (searchTerm !== '') return
- let minMax = manualPicker?.computeMinMax()
- if (minMax) {
- maxTsManual = minMax?.maxTs ?? undefined
- minTsManual = minMax?.minTs ?? undefined
- }
let maxTsPlus1 = maxTs ? new Date(new Date(maxTs).getTime() + 1000) : undefined
getAllLogs(maxTsPlus1?.toISOString(), undefined)
}, 5000)
@@ -315,8 +313,6 @@
) {
const params = new URLSearchParams()
if (searchTerm) params.set('searchTerm', searchTerm)
- if (minTs) params.set('minTs', minTs)
- if (maxTs) params.set('maxTs', maxTs)
if (selected?.mode) params.set('mode', selected.mode)
if (selected?.workerGroup) params.set('workerGroup', selected.workerGroup)
if (selected?.hostname) params.set('hostname', selected.hostname)
@@ -435,13 +431,22 @@
)
}
- $effect(() => {
- minTsManual || maxTsManual || untrack(() => onManualChanges())
- })
- $effect(() => {
- ;[searchTerm, selected, minTsManual, maxTsManual, allLogs]
- untrack(() => searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs))
- })
+ watch(
+ () => timeframe,
+ () => {
+ const ts = timeframe.computeMinMax()
+ minTs = undefined
+ maxTs = undefined
+ allLogs = undefined
+ getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
+ }
+ )
+ watch(
+ () => [searchTerm, selected, timeframe, allLogs],
+ () => {
+ searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs)
+ }
+ )
@@ -477,71 +482,19 @@
class="flex flex-col lg:flex-row gap-y-1 justify-between w-full relative pb-4 gap-x-0.5"
id="service-logs-date-pickers"
>
-
-
- {
- minTs = undefined
- maxTs = undefined
- allLogs = undefined
- minTsManual = detail
- getAllLogs(minTsManual, maxTsManual)
- }}
- placement="top-start"
- />
-
- minTsManual ?? null, (v) => (minTsManual = v ?? undefined)}
- bind:maxTs={() => maxTsManual ?? null, (v) => (maxTsManual = v ?? undefined)}
- bind:this={manualPicker}
+ {
+ wrapperClasses="w-full"
+ onClick={() => {
minTs = undefined
maxTs = undefined
allLogs = undefined
- getAllLogs(minTsManual, maxTsManual)
+ const ts = timeframe.computeMinMax()
+ getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
}}
- serviceLogsChoices
- loadText={searchTerm === '' ? 'Last 1000 logfiles' : 'All time'}
/>
-
-
- {
- minTs = undefined
- maxTs = undefined
- allLogs = undefined
- maxTsManual = detail
- getAllLogs(minTsManual, maxTsManual)
- }}
- />
-
+ let {
+ tags,
+ value = $bindable(''),
+ placeholder = '',
+ highlights,
+ onCurrentTagChange,
+ onTextSegmentAtCursorChange,
+ class: className = ''
+ }: {
+ tags: { regex: RegExp; id: string; onClear?: () => void }[]
+ value?: string
+ placeholder?: string
+ highlights?: { regex: RegExp; classes: string }[]
+ onCurrentTagChange?: (tag: { id: string } | null) => void
+ onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void
+ class?: string
+ } = $props()
+
+ let contentEditableDiv: HTMLDivElement
+ let isUpdating = false
+
+ $effect(() => {
+ if (!value.trim() && value !== '') value = ''
+ })
+
+ let _preventCursorMoveOnNextSync = false
+ // Update the displayed HTML when value changes externally
+ $effect(() => {
+ if (contentEditableDiv && !isUpdating) {
+ const currentText = getTextContent()
+ if (currentText !== value) {
+ updateDisplay(value)
+ if (!_preventCursorMoveOnNextSync) {
+ restoreCursor(value.length)
+ const cursorPos = getCursorPosition()
+ updateCurrentTag(cursorPos)
+ }
+ }
+ }
+ _preventCursorMoveOnNextSync = false
+ })
+
+ export function preventCursorMoveOnNextSync() {
+ _preventCursorMoveOnNextSync = true
+ }
+
+ function getTextContent(): string {
+ if (!contentEditableDiv) return ''
+ return contentEditableDiv.textContent || ''
+ }
+
+ function updateDisplay(text: string) {
+ if (!contentEditableDiv) return
+
+ const html = highlightText(text)
+ contentEditableDiv.innerHTML = html
+ }
+
+ /** Apply secondary highlight spans within a raw-text chunk. Returns HTML. */
+ function applyHighlightsToChunk(rawText: string): string {
+ if (!highlights || highlights.length === 0) return escapeHtml(rawText)
+
+ // Find all highlight matches in the raw text
+ const hlMatches: Array<{ start: number; end: number; classes: string }> = []
+ for (const hl of highlights) {
+ const regex = new RegExp(hl.regex, 'g')
+ let m
+ while ((m = regex.exec(rawText)) !== null) {
+ hlMatches.push({ start: m.index, end: m.index + m[0].length, classes: hl.classes })
+ }
+ }
+ if (hlMatches.length === 0) return escapeHtml(rawText)
+
+ // Sort and deduplicate (keep first on overlap)
+ hlMatches.sort((a, b) => a.start - b.start)
+ const filtered: typeof hlMatches = []
+ let lastEnd = -1
+ for (const m of hlMatches) {
+ if (m.start >= lastEnd) {
+ filtered.push(m)
+ lastEnd = m.end
+ }
+ }
+
+ let result = ''
+ let idx = 0
+ for (const m of filtered) {
+ if (m.start > idx) {
+ result += escapeHtml(rawText.slice(idx, m.start))
+ }
+ result += `${escapeHtml(rawText.slice(m.start, m.end))}`
+ idx = m.end
+ }
+ if (idx < rawText.length) {
+ result += escapeHtml(rawText.slice(idx))
+ }
+ return result
+ }
+
+ function highlightText(text: string): string {
+ if (!text) return ''
+
+ // Create a list of all matches with their positions
+ const matches: Array<{ start: number; end: number; tagIndex: number }> = []
+
+ tags.forEach((tag, tagIndex) => {
+ const regex = new RegExp(tag.regex, 'g')
+ let match
+ while ((match = regex.exec(text)) !== null) {
+ matches.push({
+ start: match.index,
+ end: match.index + match[0].length,
+ tagIndex
+ })
+ }
+ })
+
+ // Sort matches by start position
+ matches.sort((a, b) => a.start - b.start)
+
+ // Remove overlapping matches (keep the first one)
+ const filteredMatches: Array<{ start: number; end: number; tagIndex: number }> = []
+ let lastEnd = -1
+ for (const match of matches) {
+ if (match.start >= lastEnd) {
+ filteredMatches.push(match)
+ lastEnd = match.end
+ }
+ }
+
+ // Build HTML with highlighted segments
+ let html = ''
+ let lastIndex = 0
+
+ for (const match of filteredMatches) {
+ // Add text before the match (apply secondary highlights)
+ if (match.start > lastIndex) {
+ html += applyHighlightsToChunk(text.slice(lastIndex, match.start))
+ }
+
+ // Add highlighted match (with secondary highlights applied inside)
+ const matchedText = text.slice(match.start, match.end)
+ const tagId = tags[match.tagIndex].id
+ const hasClear = !!tags[match.tagIndex].onClear
+ const clearBtn = hasClear
+ ? ``
+ : ''
+ html += `${applyHighlightsToChunk(matchedText)}${clearBtn}`
+
+ lastIndex = match.end
+ }
+
+ // Add remaining text (apply secondary highlights)
+ if (lastIndex < text.length) {
+ html += applyHighlightsToChunk(text.slice(lastIndex))
+ }
+
+ return html
+ }
+
+ function escapeHtml(text: string): string {
+ const div = document.createElement('div')
+ div.textContent = text
+ let html = div.innerHTML
+ html = html.replace(/\\(n|r|.)/g, (match, c) => {
+ const display = c === 'n' ? '↵' : c === 'r' ? '↵' : c
+ return (
+ '\\' +
+ display
+ )
+ })
+ return html
+ }
+
+ let lastText = ''
+
+ function applyTextUpdate(newText: string, newCursorPos: number) {
+ value = newText
+ updateDisplay(newText)
+ restoreCursor(newCursorPos)
+ updateCurrentTag(newCursorPos)
+ lastText = newText
+ isUpdating = false
+ }
+
+ function handleInput() {
+ isUpdating = true
+ const cursorPos = getCursorPosition()
+ let newText = getTextContent()
+
+ // Remove any "\." sequences that were added by browser smart punctuation
+ // These would only be created by macOS/browser when user double-presses space
+ if (newText.includes('\\.')) {
+ const cleanedText = newText.replace(/\\\./g, '')
+ const removedCount = (newText.length - cleanedText.length) / 2 // Each "\." is 2 chars
+ applyTextUpdate(cleanedText, cursorPos - removedCount * 2)
+ return
+ }
+
+ // Escape any literal newlines (e.g. from Shift+Enter or IME input)
+ if (newText.includes('\n') || newText.includes('\r')) {
+ const before = newText.slice(0, cursorPos)
+ const newlinesBefore = (before.match(/[\n\r]/g) || []).length
+ const cleanedText = newText.replace(/\r\n/g, '\\n').replace(/[\n\r]/g, '\\n')
+ // Each newline becomes 2 chars (\n), so cursor shifts by +1 per newline before it
+ applyTextUpdate(cleanedText, cursorPos + newlinesBefore)
+ return
+ }
+
+ // Check if user just typed an escaped character
+ if (
+ newText.length > lastText.length &&
+ (newText[cursorPos - 1] === ' ' ||
+ newText[cursorPos - 1] === '\u00A0' ||
+ newText[cursorPos - 1] === '\\')
+ ) {
+ // Check if there's already an escaped space right before the cursor (e.g., "tag\ |")
+ // If user types another space, just remove the backslash instead of adding "\ \"
+ if (
+ (newText[cursorPos - 1] === ' ' || newText[cursorPos - 1] === '\u00A0') &&
+ newText[cursorPos - 3] === '\\' &&
+ (newText[cursorPos - 2] === ' ' || newText[cursorPos - 2] === '\u00A0')
+ ) {
+ // Remove the backslash before the existing space
+ newText = newText.slice(0, cursorPos - 3) + newText.slice(cursorPos - 2)
+ applyTextUpdate(newText, cursorPos - 1)
+ return
+ }
+
+ // Escape the space/backslash by adding backslash before it
+ newText = newText.slice(0, cursorPos - 1) + '\\' + newText.slice(cursorPos - 1)
+ applyTextUpdate(newText, cursorPos + 1)
+ return
+ }
+
+ applyTextUpdate(newText, cursorPos)
+ }
+
+ function getTextSegmentAtCursor(cursorPos: number): {
+ text: string
+ start: number
+ end: number
+ } | null {
+ // Find all tag positions
+ const tagPositions: Array<{ start: number; end: number }> = []
+ for (const tag of tags) {
+ const regex = new RegExp(tag.regex, 'g')
+ let match
+ while ((match = regex.exec(value)) !== null) {
+ tagPositions.push({
+ start: match.index,
+ end: match.index + match[0].length
+ })
+ }
+ }
+
+ // Sort by start position
+ tagPositions.sort((a, b) => a.start - b.start)
+
+ // Find the text segment containing the cursor
+ let segmentStart = 0
+ let segmentEnd = value.length
+
+ for (const tag of tagPositions) {
+ if (cursorPos <= tag.start) {
+ // Cursor is before this tag
+ segmentEnd = tag.start
+ break
+ } else if (cursorPos > tag.end) {
+ // Cursor is after this tag
+ segmentStart = tag.end
+ } else {
+ // Cursor is inside a tag
+ return null
+ }
+ }
+
+ return {
+ text: value.slice(segmentStart, segmentEnd).trim(),
+ start: segmentStart,
+ end: segmentEnd
+ }
+ }
+
+ function updateCurrentTag(cursorPos: number) {
+ let currentTag: { id: string } | null = null
+
+ for (const tag of tags) {
+ const regex = new RegExp(tag.regex, 'g')
+ let match
+ while ((match = regex.exec(value)) !== null) {
+ const start = match.index
+ const end = match.index + match[0].length
+ if (cursorPos >= start && cursorPos <= end) {
+ currentTag = { id: tag.id }
+
+ onCurrentTagChange?.(currentTag)
+ onTextSegmentAtCursorChange?.({ text: '', start: 0, end: 0 })
+ return
+ }
+ }
+ }
+
+ onCurrentTagChange?.(null)
+
+ // Get text segment at cursor when not in a tag
+ const textSegment = getTextSegmentAtCursor(cursorPos)
+ if (textSegment) {
+ onTextSegmentAtCursorChange?.(textSegment)
+ }
+ }
+
+ function handleClick(e: MouseEvent) {
+ // Check if the click landed on a clear button
+ const target = e.target as HTMLElement | null
+ const clearTarget = target?.closest('[data-clear-tag]')
+ if (clearTarget) {
+ const tagId = clearTarget.dataset.clearTag!
+ const tag = tags.find((t) => t.id === tagId)
+ tag?.onClear?.()
+ return
+ }
+ const cursorPos = getCursorPosition()
+ updateCurrentTag(cursorPos)
+ }
+
+ function handleKeyup(e: KeyboardEvent) {
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
+ const cursorPos = getCursorPosition()
+ updateCurrentTag(cursorPos)
+ }
+
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
+ const cursorPos = getCursorPosition()
+ const text = getTextContent()
+
+ // Handle Backspace key to remove escape sequences
+ if (e.key === 'Backspace') {
+ // Check if we're right after an escaped character (e.g., "abc\ |def")
+ // We want to remove both the backslash and the escaped character
+ if (cursorPos >= 2 && text[cursorPos - 2] === '\\') {
+ e.preventDefault()
+ isUpdating = true
+ const newText = text.slice(0, cursorPos - 2) + text.slice(cursorPos)
+ applyTextUpdate(newText, cursorPos - 2)
+ return
+ }
+ }
+
+ // Handle Delete key to remove escape sequences
+ if (e.key === 'Delete') {
+ // Check if the character at cursor position is a backslash (escape character)
+ if (cursorPos < text.length && text[cursorPos] === '\\' && cursorPos + 1 < text.length) {
+ e.preventDefault()
+ isUpdating = true
+ const newText = text.slice(0, cursorPos) + text.slice(cursorPos + 2)
+ applyTextUpdate(newText, cursorPos)
+ return
+ }
+ }
+
+ // Handle arrow key navigation to skip escape sequences
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
+ if (e.key === 'ArrowLeft' && cursorPos > 0) {
+ // Moving left: check if we're right after an escaped character (e.g., "abc\ |def")
+ // We want to skip over the backslash and the escaped character
+ if (cursorPos >= 2 && text[cursorPos - 2] === '\\') {
+ e.preventDefault()
+ isUpdating = true
+ restoreCursor(cursorPos - 2)
+ updateCurrentTag(cursorPos - 2)
+ isUpdating = false
+ return
+ }
+ } else if (e.key === 'ArrowRight') {
+ // Moving right: check if we're at a backslash (e.g., "abc|\ def")
+ // We want to skip over the backslash and the escaped character
+ if (cursorPos < text.length && text[cursorPos] === '\\' && cursorPos + 1 < text.length) {
+ e.preventDefault()
+ isUpdating = true
+ restoreCursor(cursorPos + 2)
+ updateCurrentTag(cursorPos + 2)
+ isUpdating = false
+ return
+ }
+
+ // If user pressed right arrow and is at the end, add a space if needed
+ if (
+ cursorPos === text.length &&
+ text.length > 0 &&
+ ((text[text.length - 1] !== ' ' && text[text.length - 1] !== '\u00A0') ||
+ text[text.length - 2] === '\\')
+ ) {
+ e.preventDefault()
+ isUpdating = true
+ const newText = text + '\u00A0'
+ applyTextUpdate(newText, newText.length)
+ return
+ }
+ }
+ }
+ }
+
+ function getCursorPosition(): number {
+ if (!contentEditableDiv) return 0
+
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return 0
+
+ const range = selection.getRangeAt(0)
+ const preCaretRange = range.cloneRange()
+ preCaretRange.selectNodeContents(contentEditableDiv)
+ preCaretRange.setEnd(range.endContainer, range.endOffset)
+
+ return preCaretRange.toString().length
+ }
+
+ function restoreCursor(position: number) {
+ if (!contentEditableDiv) return
+
+ const selection = window.getSelection()
+ if (!selection) return
+
+ let currentPos = 0
+ let node: Node | null = null
+ let offset = 0
+
+ function traverse(n: Node): boolean {
+ if (n.nodeType === Node.TEXT_NODE) {
+ const textLength = n.textContent?.length || 0
+ if (currentPos + textLength >= position) {
+ node = n
+ offset = position - currentPos
+ return true
+ }
+ currentPos += textLength
+ } else {
+ for (let i = 0; i < n.childNodes.length; i++) {
+ if (traverse(n.childNodes[i])) {
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ traverse(contentEditableDiv)
+
+ if (node) {
+ const range = document.createRange()
+ range.setStart(node, offset)
+ range.collapse(true)
+ selection.removeAllRanges()
+ selection.addRange(range)
+
+ // Ensure cursor is visible by scrolling if needed
+ ensureCursorVisible()
+ }
+ }
+
+ function ensureCursorVisible() {
+ if (!contentEditableDiv) return
+
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return
+
+ const range = selection.getRangeAt(0)
+ const rect = range.getBoundingClientRect()
+ const containerRect = contentEditableDiv.getBoundingClientRect()
+
+ // Check if cursor is outside the visible area horizontally
+ if (rect.left < containerRect.left) {
+ // Cursor is to the left of visible area
+ contentEditableDiv.scrollLeft -= containerRect.left - rect.left + 10
+ } else if (rect.right > containerRect.right) {
+ // Cursor is to the right of visible area
+ contentEditableDiv.scrollLeft += rect.right - containerRect.right + 10
+ }
+ }
+
+ function handlePaste(e: ClipboardEvent) {
+ e.preventDefault()
+ let text = e.clipboardData?.getData('text/plain') || ''
+ // Escape backslashes, spaces, and newlines
+ text = text
+ .replace(/\\/g, '\\\\')
+ .replace(/ /g, '\\ ')
+ .replace(/\r\n/g, '\\n')
+ .replace(/[\n\r]/g, '\\n')
+ document.execCommand('insertText', false, text)
+ }
+
+ export function focusAtEnd() {
+ if (!contentEditableDiv) return
+ contentEditableDiv.focus()
+ restoreCursor(value.length)
+ updateCurrentTag(value.length)
+ contentEditableDiv.scrollLeft = contentEditableDiv.scrollWidth
+ }
+
+
+
+
+
diff --git a/frontend/src/lib/components/Tooltip.svelte b/frontend/src/lib/components/Tooltip.svelte
index a2a975897c..d9ded1f249 100644
--- a/frontend/src/lib/components/Tooltip.svelte
+++ b/frontend/src/lib/components/Tooltip.svelte
@@ -19,6 +19,7 @@
markdownTooltip?: string | undefined
customSize?: string
class?: string
+ Icon?: typeof InfoIcon
children?: import('svelte').Snippet
}
@@ -31,6 +32,7 @@
markdownTooltip = undefined,
customSize = '100%',
class: classNames = '',
+ Icon = InfoIcon,
children
}: Props = $props()
const plugins = [gfmPlugin()]
@@ -53,7 +55,7 @@
? 'text-primary-inverse'
: 'text-primary'} {classNames} relative"
>
-
+
{#snippet text()}
{#if markdownTooltip}
diff --git a/frontend/src/lib/components/assets/assetsFilter.ts b/frontend/src/lib/components/assets/assetsFilter.ts
new file mode 100644
index 0000000000..6f44770121
--- /dev/null
+++ b/frontend/src/lib/components/assets/assetsFilter.ts
@@ -0,0 +1,51 @@
+import { FileCode, FolderIcon, Box, Braces } from 'lucide-svelte'
+import type { FilterSchemaRec } from '../FilterSearchbar.svelte'
+
+export function buildAssetsFilterSchema({
+ paths,
+ assetKinds
+}: {
+ paths: string[]
+ assetKinds: string[]
+}) {
+ return {
+ asset_path: {
+ type: 'string' as const,
+ label: 'Asset path pattern',
+ icon: FolderIcon,
+ description: 'Filter by asset path pattern (case-insensitive)'
+ },
+ asset_kinds: {
+ type: 'oneof' as const,
+ options: assetKinds.map((s) => ({ label: s, value: s })),
+ allowCustomValue: false,
+ allowNegative: false,
+ allowMultiple: true,
+ label: 'Asset kind',
+ icon: Box,
+ description: 'Filter by asset kind (s3object, resource, variable, etc.)'
+ },
+ usage_path: {
+ type: 'string' as const,
+ label: 'Usage path pattern',
+ icon: FileCode,
+ description: 'Filter by usage path pattern (case-insensitive)'
+ },
+ path: {
+ type: 'oneof' as const,
+ options: paths.map((s) => ({ label: s, value: s })),
+ allowCustomValue: true,
+ allowNegative: false,
+ allowMultiple: false,
+ label: 'Asset path',
+ icon: FileCode,
+ description: 'Filter by exact asset path'
+ },
+ columns: {
+ type: 'string' as const,
+ label: 'Columns',
+ icon: Braces,
+ description: 'Filter by comma-separated column names (e.g., col1,col2,col3)'
+ }
+ } satisfies FilterSchemaRec
+}
diff --git a/frontend/src/lib/components/common/InlineCalendarInput.svelte b/frontend/src/lib/components/common/InlineCalendarInput.svelte
new file mode 100644
index 0000000000..bbcd9e9060
--- /dev/null
+++ b/frontend/src/lib/components/common/InlineCalendarInput.svelte
@@ -0,0 +1,629 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each DAY_LABELS as label (label)}
+
+ {label}
+
+ {/each}
+
+
+
+
+ {#each calendarDays as cell (`${cell.year}-${cell.month}-${cell.day}`)}
+ {@const selected = isDaySelected(cell)}
+ {@const inRange = isDayInRange(cell)}
+ {@const isStart = isDayRangeStart(cell)}
+ {@const isEnd = isDayRangeEnd(cell)}
+ {@const disabled = isDayDisabled(cell)}
+ {@const isToday =
+ cell.day === today.getDate() &&
+ cell.month === today.getMonth() + 1 &&
+ cell.year === today.getFullYear()}
+
+ {/each}
+
+
+
+
+
+ {#if showTime}
+
+
+ {/if}
+
diff --git a/frontend/src/lib/components/flows/flowStore.svelte.ts b/frontend/src/lib/components/flows/flowStore.svelte.ts
index f73747e915..9f504c5c03 100644
--- a/frontend/src/lib/components/flows/flowStore.svelte.ts
+++ b/frontend/src/lib/components/flows/flowStore.svelte.ts
@@ -34,9 +34,11 @@ export async function copyFirstStepSchema(
})
return
}
- return sendUserToast('Only scripts can be used as a input schema', true)
+ sendUserToast('Only scripts can be used as a input schema', true)
+ return
}
- return sendUserToast('No first step found', true)
+ sendUserToast('No first step found', true)
+ return
}
export async function getFirstStepSchema(flowState: FlowState, flow: OpenFlow) {
diff --git a/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte b/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte
index 8764cf45ff..2091f76340 100644
--- a/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte
+++ b/frontend/src/lib/components/flows/pickers/PickHubScriptQuick.svelte
@@ -24,7 +24,7 @@
[])
: undefined
} catch (err) {
- console.error('Error fetching top hub scripts')
+ sendUserToast('Failed to fetch hub scripts: ' + err, 'error')
return undefined
}
},
@@ -38,7 +38,7 @@
-
-
Batch re-run options
-
+
+
-
-
-
- {#await jobGroupsPromise then jobGroup}
- {#each jobGroup as group}
+ {#if !hideRunnableSelector}
+
+
+
+ {#each jobGroups.current ?? [] as group}
{/each}
- {/await}
-
-
-
-
-
- {#if selected}
-
-
- Use the job object to access data about the original job
-
- {
- if (!selected) return
- ;(options[selected.kind][selected.script_path] ??= {}).use_latest_version =
- e.detail as boolean
- }}
- size="sm"
- options={{
- right: 'Always use latest version',
- rightTooltip:
- selected.kind === 'flow'
- ? 'Flow jobs will always run on the latest version of the flow'
- : 'Run all jobs with the latest version of the script even if they originally ran an older version'
- }}
- />
+
+
+ {/if}
+
+
+
+ {#if selected}
+
+
+ Use the job object to access data about the original job
+
+
+ {
+ if (!selected) return
+ ;(options[selected.kind][selected.script_path] ??= {}).use_latest_version =
+ e.detail as boolean
+ }}
+ size="sm"
+ options={{
+ right: 'Always use latest version',
+ rightTooltip:
+ selected.kind === 'flow'
+ ? 'Flow jobs will always run on the latest version of the flow'
+ : 'Run all jobs with the latest version of the script even if they originally ran an older version'
+ }}
+ />
-
-
- {@const displayedSchema = selectedUsesLatestSchema
- ? (selected.latest_schema as Schema)
- : mergeSchemasForBatchReruns(selected.schemas.map((s) => s.schema as Schema))}
- {@const extraLib = buildExtraLibForBatchReruns({
- schemas: selected.schemas,
- script_path: selected.script_path
- })}
-
- {#key [selected, displayedSchema]}
- {#each Object.keys(displayedSchema.properties) as propertyName}
-
- {
- if (!selected) return
- const newArg = e.detail.arg as InputTransform
- ;((options[selected.kind][selected.script_path] ??= {}).input_transforms ??=
- {})[propertyName] = newArg
- }}
- argName={propertyName}
- schema={displayedSchema}
- {extraLib}
- previousModuleId={undefined}
- pickableProperties={{
- hasResume: false,
- previousId: undefined,
- priorIds: {},
- flow_input: {}
- }}
- hideHelpButton
- {...propertyAlwaysExists(propertyName, selected)
- ? {}
- : {
- headerTooltip:
- 'This property does not exist on all versions of the script. You can handle different cases in the code below',
- HeaderTooltipIcon: TriangleAlert,
- headerTooltipIconClass: 'text-orange-500'
- }}
- {...propertyAlwaysHasSameType(propertyName, selected)
- ? {}
- : {
- headerTooltip:
- 'This property does not always have the same type depending on the version of the script. You can handle different cases in the code below',
- HeaderTooltipIcon: TriangleAlert,
- headerTooltipIconClass: 'text-orange-500'
- }}
- />
-
- {/each}
- {/key}
-
- {/if}
-
+
+
+ {@const displayedSchema = selectedUsesLatestSchema
+ ? (selected.latest_schema as Schema | undefined)
+ : mergeSchemasForBatchReruns(
+ selected.schemas.map((s) => (s.schema as Schema) ?? {})
+ )}
+ {@const extraLib = buildExtraLibForBatchReruns({
+ schemas: selected.schemas,
+ script_path: selected.script_path
+ })}
+
+ {#key [selected, displayedSchema]}
+ {#each Object.keys(displayedSchema?.properties ?? {}) as propertyName}
+
+ {
+ if (!selected) return
+ const newArg = e.detail.arg as InputTransform
+ ;((options[selected.kind][selected.script_path] ??=
+ {}).input_transforms ??= {})[propertyName] = newArg
+ }}
+ argName={propertyName}
+ schema={displayedSchema ?? {}}
+ {extraLib}
+ previousModuleId={undefined}
+ pickableProperties={{
+ hasResume: false,
+ previousId: undefined,
+ priorIds: {},
+ flow_input: {}
+ }}
+ hideHelpButton
+ {...propertyAlwaysExists(propertyName, selected)
+ ? {}
+ : {
+ headerTooltip:
+ 'This property does not exist on all versions of the script. You can handle different cases in the code below',
+ HeaderTooltipIcon: TriangleAlert,
+ headerTooltipIconClass: 'text-orange-500'
+ }}
+ {...propertyAlwaysHasSameType(propertyName, selected)
+ ? {}
+ : {
+ headerTooltip:
+ 'This property does not always have the same type depending on the version of the script. You can handle different cases in the code below',
+ HeaderTooltipIcon: TriangleAlert,
+ headerTooltipIconClass: 'text-orange-500'
+ }}
+ />
+
+ {/each}
+ {/key}
+
+ {/if}
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/runs/JobRunsPreview.svelte b/frontend/src/lib/components/runs/JobRunsPreview.svelte
index 9d40dc616d..637848c83c 100644
--- a/frontend/src/lib/components/runs/JobRunsPreview.svelte
+++ b/frontend/src/lib/components/runs/JobRunsPreview.svelte
@@ -119,8 +119,8 @@
bind:this={jobLoader}
/>
-
-
+
+
{#if isLoadingJobDetails}
diff --git a/frontend/src/lib/components/runs/ManuelDatePicker.svelte b/frontend/src/lib/components/runs/ManuelDatePicker.svelte
deleted file mode 100644
index ceb393799b..0000000000
--- a/frontend/src/lib/components/runs/ManuelDatePicker.svelte
+++ /dev/null
@@ -1,125 +0,0 @@
-
-
-
diff --git a/frontend/src/lib/components/runs/RunRow.svelte b/frontend/src/lib/components/runs/RunRow.svelte
index 10eaf9f2f9..3a14c71111 100644
--- a/frontend/src/lib/components/runs/RunRow.svelte
+++ b/frontend/src/lib/components/runs/RunRow.svelte
@@ -7,10 +7,8 @@
truncateHash,
truncateRev,
isScriptPreview,
- isJobSelectable,
msToReadableTime,
isFlowPreview,
- type RunsSelectionMode,
getJobKindIcon
} from '$lib/utils'
import { Button } from '../common'
@@ -39,7 +37,7 @@
containsLabel?: boolean
showTag?: boolean
activeLabel: string | null
- selectionMode?: RunsSelectionMode | false
+ manualSelectionMode?: undefined | 'cancel' | 'rerun'
}
let {
@@ -49,7 +47,7 @@
containsLabel = false,
showTag = true,
activeLabel,
- selectionMode = false
+ manualSelectionMode
}: Props = $props()
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
@@ -68,36 +66,33 @@
{
- if (!selectionMode || isJobSelectable(selectionMode)(job)) {
- dispatch('select')
- }
- }}
+ onclick={() => dispatch('select')}
+ oncontextmenu={(e) => !selected && dispatch('select')}
>
- {#if selectionMode}
-
-
-
-
+ {#if manualSelectionMode}
+
+
{/if}
-
+
diff --git a/frontend/src/lib/components/runs/RunsBatchActionsDropdown.svelte b/frontend/src/lib/components/runs/RunsBatchActionsDropdown.svelte
deleted file mode 100644
index 81892e605a..0000000000
--- a/frontend/src/lib/components/runs/RunsBatchActionsDropdown.svelte
+++ /dev/null
@@ -1,108 +0,0 @@
-
-
-{#if isLoading}
-
-{:else if selectionMode}
-
-
-{:else}
-
onSetSelectionMode('cancel')
- },
- ...($userStore?.is_admin || $superadmin
- ? [{ displayName: 'Cancel all jobs matching filters', action: onCancelFilteredJobs }]
- : []),
- {
- displayName: 'Select jobs to re-run',
- action: () => onSetSelectionMode('re-run')
- },
- ...($userStore?.is_admin || $superadmin
- ? [{ displayName: 'Re-run all jobs matching filters', action: onReRunFilteredJobs }]
- : [])
- ]}
- >
- {#snippet buttonReplacement()}
-
- {#if !small}
- Batch actions
- {/if}
-
- {/snippet}
-
-{/if}
diff --git a/frontend/src/lib/components/runs/RunsFilter.svelte b/frontend/src/lib/components/runs/RunsFilter.svelte
deleted file mode 100644
index 89fc83422b..0000000000
--- a/frontend/src/lib/components/runs/RunsFilter.svelte
+++ /dev/null
@@ -1,1036 +0,0 @@
-
-
-
-
-{#snippet runsTooltip()}
-
- {#snippet text()}
- 'Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they
- start), they have been triggered through the UI, a schedule or webhook'
- {/snippet}
-
-{/snippet}
-{#snippet previewsTooltip()}
-
- {#snippet text()}
- 'Previews are jobs that have been started in the editor as "Tests"'
- {/snippet}
-
-{/snippet}
-{#snippet dependenciesTooltip()}
-
- {#snippet text()}
- 'Deploying a script, flow or an app launch a dependency job that creates and then attaches the
- lockfile to the deployed item. This mechanism ensures that logic is always executed with the
- exact same direct and indirect dependencies.'
- {/snippet}
-
-{/snippet}
-{#snippet syncTooltip()}
-
- {#snippet text()}
- 'Sync jobs that are triggered on every script deployment to sync the workspace with the Git
- repository configured in the workspace settings'
- {/snippet}
-
-{/snippet}
-
-{#if !mobile}
- {#if $workspaceStore == 'admins'}
-
- (allWorkspaces = detail === 'all')}
- >
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
- {/if}
-
-
-
- {
- if (e.detail != filterBy) {
- resetFilter()
- }
- }}
- >
- {#snippet children({ item })}
-
-
-
- filterBy,
- (v) => {
- resetFilter()
- filterBy = v
- }
- }
- />
- {/snippet}
-
-
-
- {#if filterBy == 'user'}
- {#key user}
-
- user ?? undefined, (v) => (user = v ?? null)}
- clearable
- onClear={() => ((user = null), dispatch('reset'))}
- onCreateItem={(item) => (usernames.push(item), (user = item))}
- createText="Press enter to use this value"
- id="user"
- />
-
- {/key}
- {:else if filterBy == 'folder'}
-
- {#key folder}
- folder ?? undefined, (v) => (folder = v ?? null)}
- clearable
- onClear={() => ((folder = null), dispatch('reset'))}
- id="folder"
- />
- {/key}
-
- {:else if filterBy === 'path'}
-
- {#key path}
- path ?? undefined, (v) => (path = v ?? null)}
- clearable
- onClear={() => ((path = null), dispatch('reset'))}
- onCreateItem={(item) => (paths.push(item), (path = item))}
- createText="Press enter to use this value"
- id="path"
- />
- {/key}
-
- {:else if filterBy === 'label'}
-
- {#snippet tooltip()}
- Job Labels are string values in the array at the result field 'wm_labels' to easily filter them.
- {/snippet}
- {#key label}
-
- {#if label}
-
{
- label = null
- dispatch('reset')
- }}
- >
-
-
- {/if}
-
-
-
{
- if (labelTimeout) {
- clearTimeout(labelTimeout)
- }
-
- labelTimeout = setTimeout(() => {
- label = displayedLabel
- }, 1000)
- }
- }}
- bind:value={() => displayedLabel ?? undefined, (v) => (displayedLabel = v ?? null)}
- />
-
-
-
-
- {/key}
-
- {:else if filterBy === 'concurrencyKey'}
-
- {#snippet tooltip()}
- For concurrency limited jobs, the concurrency key defines a group of jobs that share the
- same limits.
- {#if !$enterpriseLicense}
- Concurrency limits are an EE feature.
- {/if}
- {/snippet}
- {#key concurrencyKey}
- {#if concurrencyKey}
- {
- concurrencyKey = null
- dispatch('reset')
- }}
- >
-
-
- {/if}
-
-
- {
- if (concurrencyKeyTimeout) {
- clearTimeout(concurrencyKeyTimeout)
- }
-
- concurrencyKeyTimeout = setTimeout(() => {
- concurrencyKey = displayedConcurrencyKey
- }, 1000)
- }
- }}
- bind:value={
- () => displayedConcurrencyKey ?? undefined,
- (v) => (displayedConcurrencyKey = v ?? null)
- }
- />
- {/key}
-
- {:else if filterBy === 'tag'}
-
- {#key tag}
-
- {#if tag}
-
{
- tag = null
- dispatch('reset')
- }}
- >
-
-
- {/if}
-
-
-
{
- if (tagTimeout) {
- clearTimeout(tagTimeout)
- }
-
- tagTimeout = setTimeout(() => {
- tag = displayedTag
- }, 1000)
- }
- }}
- bind:value={() => displayedTag ?? undefined, (v) => (displayedTag = v ?? null)}
- />
-
-
-
-
- {/key}
-
- {:else if filterBy === 'schedulePath'}
-
- {#key tag}
-
- {#if tag}
- {
- schedulePath = null
- dispatch('reset')
- }}
- >
-
-
- {/if}
-
-
- {
- if (tagTimeout) {
- clearTimeout(tagTimeout)
- }
-
- tagTimeout = setTimeout(() => {
- schedulePath = displayedSchedule ?? null
- }, 1000)
- },
- id: 'schedulePath'
- }}
- bind:value={displayedSchedule}
- />
-
- {/key}
-
- {:else if filterBy === 'worker'}
-
- {#key worker}
-
- {#if worker}
-
{
- worker = null
- dispatch('reset')
- }}
- >
-
-
- {/if}
-
-
-
{
- if (workerTimeout) {
- clearTimeout(workerTimeout)
- }
-
- workerTimeout = setTimeout(() => {
- worker = displayedWorker
- }, 1000)
- },
- id: 'worker'
- }}
- bind:value={() => displayedWorker ?? undefined, (v) => (displayedWorker = v ?? null)}
- />
-
-
-
-
- {/key}
-
- {/if}
-
-
-
-
- {#if small && !calendarSmall}
- {
- jobKindsCat = 'all'
- },
- id: 'all'
- },
- {
- displayName: 'Runs',
- action: () => {
- jobKindsCat = 'runs'
- },
- id: 'runs',
- extra: runsTooltip
- },
- {
- displayName: 'Previews',
- action: () => {
- jobKindsCat = 'previews'
- },
- id: 'previews',
- extra: previewsTooltip
- },
- {
- displayName: 'Deps',
- action: () => {
- jobKindsCat = 'dependencies'
- },
- id: 'dependencies',
- extra: dependenciesTooltip
- },
- {
- displayName: 'Sync',
- action: () => {
- jobKindsCat = 'deploymentcallbacks'
- },
- id: 'deploymentcallbacks',
- extra: syncTooltip
- }
- ]}
- selected={jobKindsCat}
- />
- {:else}
-
- {#snippet children({ item })}
-
-
-
- jobKindsCat,
- (v) => {
- resetFilter()
- jobKindsCat = v
- }
- }
- />
- {/snippet}
-
- {/if}
-
-
-
- {
- success = detail === 'all' ? null : detail
- dispatch('successChange', success)
- }}
- id="status"
- >
- {#snippet children({ item })}
-
-
-
-
- {#if success == 'waiting'}
-
- {:else if success == 'suspended'}
-
- {/if}
- {/snippet}
-
-
-{/if}
-
-
-
- {#snippet trigger()}
-
- {/snippet}
-
- {#snippet content()}
-
-
- {#if mobile}
- {#if $workspaceStore == 'admins'}
-
- {/if}
-
-
- {#if filterBy == 'user'}
-
- {:else if filterBy == 'folder'}
-
- {:else if filterBy === 'path'}
-
- {:else if filterBy === 'tag'}
- {#key tag}
-
- {/key}
- {:else if filterBy === 'label'}
- {#key label}
-
- {/key}
- {:else if filterBy === 'concurrencyKey'}
- {#key concurrencyKey}
-
- {/key}
- {:else if filterBy === 'worker'}
- {#key worker}
-
- {/key}
- {/if}
-
- {#if filterBy === 'tag' || filterBy === 'label' || filterBy === 'worker'}
-
- {/if}
-
-
-
- {/if}
-
-
-
-
-
-
-
-
-
- {/snippet}
-
-
diff --git a/frontend/src/lib/components/runs/RunsTable.svelte b/frontend/src/lib/components/runs/RunsTable.svelte
index 5548bbb9d1..07486a4b16 100644
--- a/frontend/src/lib/components/runs/RunsTable.svelte
+++ b/frontend/src/lib/components/runs/RunsTable.svelte
@@ -2,13 +2,25 @@
import type { Job } from '$lib/gen'
import RunRow from './RunRow.svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
- import { createEventDispatcher, onMount } from 'svelte'
+ import { createEventDispatcher } from 'svelte'
import Tooltip from '../Tooltip.svelte'
- import { AlertTriangle } from 'lucide-svelte'
+ import {
+ AlertTriangle,
+ CircleXIcon,
+ Code2Icon,
+ ExternalLinkIcon,
+ RefreshCwIcon
+ } from 'lucide-svelte'
import Popover from '../Popover.svelte'
import { workspaceStore } from '$lib/stores'
import './runs-grid.css'
- import type { RunsSelectionMode } from '$lib/utils'
+ 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 } from '$lib/utils'
+ import { goto } from '$lib/navigation'
+ import BarsStaggered from '../icons/BarsStaggered.svelte'
interface Props {
//import InfiniteLoading from 'svelte-infinite-loading'
@@ -16,13 +28,15 @@
externalJobs?: Job[]
omittedObscuredJobs: boolean
showExternalJobs?: boolean
- selectionMode?: RunsSelectionMode | false
selectedIds?: string[]
selectedWorkspace?: string | undefined
activeLabel?: string | null
// const loadMoreQuantity: number = 100
lastFetchWentToEnd?: boolean
perPage?: number
+ batchRerunOptionsIsOpen?: boolean
+ manualSelectionMode: undefined | 'cancel' | 'rerun'
+ onCancelJobs: (jobIds: string[]) => void
}
let {
@@ -30,14 +44,44 @@
externalJobs = [],
omittedObscuredJobs,
showExternalJobs = false,
- selectionMode = false,
selectedIds = $bindable([]),
selectedWorkspace = $bindable(undefined),
activeLabel = null,
lastFetchWentToEnd = false,
- perPage = 1000
+ perPage = 1000,
+ manualSelectionMode,
+ onCancelJobs,
+ batchRerunOptionsIsOpen = $bindable()
}: 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]
+ 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']
}
@@ -116,7 +160,6 @@
}
let tableHeight: number = $state(0)
- let headerHeight: number = $state(0)
let containerWidth: number = $state(0)
// const MAX_ITEMS = perPage
@@ -136,22 +179,21 @@
}
*/
- function jobCountString(jobCount: number | undefined, lastFetchWentToEnd: boolean): string {
+ function jobCountString(
+ jobCount: number | undefined,
+ lastFetchWentToEnd: boolean,
+ hideLabel?: boolean
+ ): string {
if (jobCount === undefined) {
return ''
}
const jc = jobCount
const isTruncated = jc >= perPage && !lastFetchWentToEnd
- return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
+ if (hideLabel) return `${jc}${isTruncated ? '+' : ''}`
+ else return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
}
- function computeHeight() {
- tableHeight = document.querySelector('#runs-table-wrapper')!.parentElement?.clientHeight ?? 0
- }
- onMount(() => {
- computeHeight()
- })
const dispatch = createEventDispatcher()
let scrollToIndex = $state(0)
@@ -193,34 +235,129 @@
return nstickyIndices
})
- const showTag = $derived(containerWidth > 700)
+ let showTag = $derived(containerWidth > 700)
+ let selectedIdsPossibleActions = $derived.by(() => {
+ const cancellableJobIds: string[] = []
+ const rerunnableJobIds: 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)
+ }
+ }
+ return { cancellableJobIds, rerunnableJobIds }
+ })
+ let hoveredDropdownAction: 'cancel' | 'rerun' | 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)
+ })
+ 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)
+ return true
+ }
+
+ let selectableJobs = $derived(jobs?.filter(jobIsSelectable) ?? [])
-
computeHeight()} />
-
+
+
(hasClickFocus = true)}
+ use:clickOutside={{ onClickOutside: () => (hasClickFocus = false) }}
bind:clientWidth={containerWidth}
>
-
+
- {#if selectionMode}
-
+ {#if manualSelectionMode}
+ {@const allSelected = selectedIds.length === selectableJobs?.length}
+
+ allSelected,
+ () => (selectedIds = allSelected ? [] : (selectableJobs.map((j) => j.id) ?? []))
+ }
+ />
+
{/if}
-
+
{#if showExternalJobs && externalJobs.length > 0}
{jobs
@@ -239,125 +376,186 @@
{:else}
- {jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
+ {@const jobCount = jobs
+ ? jobCountString(jobs.length, lastFetchWentToEnd, selectedIds.length >= 2)
+ : ''}
+ {selectedIds.length >= 2 ? `${selectedIds.length}/` : ''}
+ {jobCount}
{/if}
-
-
Duration
-
Path
+
Started
+
Duration
+
Path
{#if containsLabel}
-
Label
+
Label
{/if}
-
Triggered by
+
Triggered by
{#if showTag}
-
Tag
+
Tag
{/if}
-
+
- {#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
-
No jobs found for the selected filters.
- {:else}
-
- {#snippet header()}{/snippet}
- {#snippet item({ index, style })}
-
- {#if flatJobs}
- {@const jobOrDate = flatJobs[index]}
-
- {#if jobOrDate}
- {#if jobOrDate?.type === 'date'}
-
- {jobOrDate.date}
-
+
+
+ rightClickPopover?.close()} items={dropdownActions} />
+
+