diff --git a/.claude/skills/svelte-frontend/SKILL.md b/.claude/skills/svelte-frontend/SKILL.md index 41d65a12be..f51d36672a 100644 --- a/.claude/skills/svelte-frontend/SKILL.md +++ b/.claude/skills/svelte-frontend/SKILL.md @@ -226,4 +226,93 @@ When generating Svelte 5 code, prioritize frontend performance by applying the f Hello ``` -5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes. \ No newline at end of file +5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes. + +## Windmill UI Component Rules (MUST follow) + +Always use Windmill's own design-system components instead of raw HTML elements. Using raw HTML elements produces inconsistent styling and breaks the design language. + +### Icons — use `lucide-svelte` + +**Never** write inline SVGs. Import icons from `lucide-svelte`. + +```svelte + + + +``` + +### Buttons — use ` + + + - - {/if} - - - -
- { - if (e.detail == 'running' && filters.max_ts != undefined) { - filters.max_ts = null - } - }} - {usernames} - {folders} - {paths} - mobile={innerWidth < verySmallScreenWidth} - small={innerWidth < smallScreenWidth} - calendarSmall={!filters.min_ts && !filters.max_ts} - /> -
+ {/snippet} + + + jobsLoader?.loadJobs(true)} + loading={jobsLoader?.loading} + items={runsTimeframes} + bind:value={_timeframe.val} + /> + -
-
-
- { - graph = detail - graphIsRunsChart = graph === 'RunChart' - }} - > - {#snippet children({ item })} - - +
+
+ + + + {#snippet extra()} + {#if warnJobLimit} + {warnJobLimitMsg} + {/if} {/snippet} - + + - {#if !graphIsRunsChart} - setLookback(0), - id: '0' - }, - { - displayName: '1 day', - action: () => setLookback(1), - id: '1' - }, - { - displayName: '3 days', - action: () => setLookback(3), - id: '3' - }, - { - displayName: '7 days', - action: () => setLookback(7), - id: '7' - } - ]} - selected={lookback.toString()} - selectedDisplayName={`${lookback} days lookback`} - > - {#snippet extraLabel()} - - {#snippet text()} - How far behind the min datetime to start considering jobs for the concurrency - graph. Change this value to include jobs started before the set time window for - the computation of the graph - {/snippet} - - {/snippet} - - {/if} -
+ {#if graph !== 'RunChart'} + -
- +
+
+ +
+ {#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} +
+ + filters.per_page, - (newPerPage) => { - filters.per_page = newPerPage - if (newPerPage > (jobs?.length ?? 1000)) loadExtra() - } - } - onCreateItem={(v) => (filters.per_page = parseInt(v))} - items={[ - { value: 25, label: '25' }, - { value: 100, label: '100' }, - { value: 1000, label: '1000' }, - { value: 10000, label: '10000' } - ]} - />
- 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 usLocale} + + setMonth((e.target as HTMLInputElement).value)} + style="background: transparent !important;" + class="!border-none !w-8 !h-7 !px-1.5 text-center font-mono" + aria-label="Month" + /> + / + setDay((e.target as HTMLInputElement).value)} + style="background: transparent !important;" + class="!border-none !w-8 !h-7 !px-1.5 text-center font-mono" + aria-label="Day" + /> + {:else} + + setDay((e.target as HTMLInputElement).value)} + style="background: transparent !important;" + class="!border-none !w-8 !h-7 !px-1.5 text-center font-mono" + aria-label="Day" + /> + / + setMonth((e.target as HTMLInputElement).value)} + style="background: transparent !important;" + class="!border-none !w-8 !h-7 !px-1.5 text-center font-mono" + aria-label="Month" + /> + {/if} + / + setYear((e.target as HTMLInputElement).value)} + style="background: transparent !important;" + class="!border-none !w-12 !h-7 !px-1.5 text-center font-mono" + aria-label="Year" + /> +
+
+ { + const h = Math.max(0, Math.min(23, parseInt((e.target as HTMLInputElement).value, 10))) + if (!isNaN(h)) patchTarget({ hour: h }) + }} + class="!border-none !w-8 !h-7 !px-1.5 text-right font-mono" + aria-label="Hour" + /> + : + { + const m = Math.max(0, Math.min(59, parseInt((e.target as HTMLInputElement).value, 10))) + if (!isNaN(m)) patchTarget({ minute: m }) + }} + class="!border-none !w-8 !h-7 !px-1.5 text-left font-mono" + aria-label="Minute" + /> + +
+
+ {/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} -
- - {/if} - {#if selectionMode == 're-run'} - - {/if} -
-{: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()} - - {/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} - - folder ?? undefined, (v) => (folder = v ?? null)} - clearable - onClear={() => ((folder = null), dispatch('reset'))} - id="folder" - /> - {/key} - - {:else if filterBy === 'path'} - - {#key path} - user ?? undefined, (v) => (user = v ?? null)} - clearable - onClear={() => ((user = null), dispatch('reset'))} - inputClass="!h-[32px]" - /> - - {:else if filterBy == 'folder'} - - {: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} - - - -
{/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} -
+
+ {#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)} +
No jobs found for the selected filters.
+ {:else} + + +
{ + e.preventDefault() + rightClickPopover?.open(e) + }} + > + + {#snippet header()}{/snippet} + {#snippet item({ index, style })} +
+ {#if flatJobs} + {@const jobOrDate = flatJobs[index]} + {#if jobOrDate} + {#if jobOrDate?.type === 'date'} +
+ {jobOrDate.date} +
+ {:else} + {@const selected = + jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)} + {@const nonSelectable = !jobIsSelectable(jobOrDate.job)} + + +
+ { + 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} + /> +
+ {/if} + {:else} + {JSON.stringify(jobOrDate)} + {/if} {:else}
- { - const jobId = jobOrDate.job.id - if (selectionMode) { - if (selectedIds.includes(jobOrDate.job.id)) { - selectedIds = selectedIds.filter((id) => id != jobId) - } else { - selectedIds.push(jobId) - selectedIds = selectedIds - } - } else { - if ( - JSON.stringify(selectedIds) !== JSON.stringify([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} - /> +
...
+
...
+
...
+
...
{/if} - {:else} - {JSON.stringify(jobOrDate)} - {/if} - {:else} -
-
...
-
...
-
...
-
...
- {/if} -
- {/snippet} - {#snippet footer()} -
{#if !lastFetchWentToEnd && jobs && jobs.length >= perPage} - + {/if}
- Load next {perPage} jobs - - {/if}
- {/snippet} - - {/if} + {/snippet} + +
+ {/if} +
+ + rightClickPopover?.close()} items={dropdownActions} /> + +