fix(frontend): Improve runs page ux (#6485)

* improve arg layout

* improve runs row (wip)

* Add job badges

* group filters in dropdown

* improve runs row layout

* Improve filter layout

* use select for graph display

* handle width modification

* Remove useless headers

* fix bad display when result is null

* Display all jobs tags

* Improve display for 'step of flow' jobs

* Add empty message for JobAssetsViewer

* Move job preview assets tab to flow result for flows

* Only show tag in the tag column

* Add job kind to rows

* Add padding to the run preview

* nit

* move refresh on top of table

* Move filters into header bar

* move runs table topbar outside table

* Simplify layout

* Use toggle for kind for large screen

* move sync job and add batch actions breakpoint

* revert dropdown to toggle for conurrency/duration

* handle run labels overflow

* improve time display

* fix flow preview with no path display

* Add titles

* Prevent tab shift for script and flow result

* nit

* Allow job deselect

* Make job link more visible

* Fix filtering for queued job

* Fix filter not reseting after select from toggleMore

* Allways show assets for flow status viewer

* Update run chart to svelte 5 and fix reactivity issue

* migrate concurrency chart to svelte 5

* Improve admmin workspace display and fix missing in add filter popover

* nit

* fix run table resize

* Add breakpoint to hide tag in small screens

* use a css file for gathering RunRow and RunTable classes

* nit

* nit

* remove debug log

* nit

* fix typo

* Have too icons for queued workers and suspended

* add gap before auto-refresh

* Replace min max to from to calendar picker

* Add loading state for job preview

* Move duration

* Display kind full width when calendar not set

* Only show 2 digits for jobs duration

* Replace Scheduled for by a clock un the run row

* Fix typpo in dropown select to dropdown select

* Hide sync and previews in toggle more

* Fix runs row padding

* Change notification colors for queued jobs

* use utils debounce function

* fix typo

* nit

* use class instead of classNames

* clean select filter side effects
This commit is contained in:
Guilhem
2025-09-01 09:49:15 +00:00
committed by GitHub
parent 4bb9be09af
commit 2cd9e727ec
31 changed files with 2174 additions and 1577 deletions
+5 -4
View File
@@ -49,7 +49,7 @@
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.399.0",
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~20.2.1",
"monaco-editor-wrapper": "6.12.0",
@@ -7848,9 +7848,10 @@
"optional": true
},
"node_modules/lucide-svelte": {
"version": "0.399.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.399.0.tgz",
"integrity": "sha512-NQ8AxNMKbIJsx7HV//gnAsIY1wJfb3rbXSK2S/ZDjIldvAEdzGngpUT8T8Q8zHYUuii0bavAmVARN8giR4vvpA==",
"version": "0.540.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.540.0.tgz",
"integrity": "sha512-jedJgKrrsT1B6mAMHGMmk6dIZKhorLq4KO7v+j7hYfeGR0LRe3W+SvwpWF1E6WT2/AY9IAm8KT/G+Nzf+0lW3g==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
+1 -1
View File
@@ -116,7 +116,7 @@
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.399.0",
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~20.2.1",
"monaco-editor-wrapper": "6.12.0",
+2 -2
View File
@@ -95,14 +95,14 @@
<div class="relative">
{#if JSON.stringify(value).length > 120}
<button
class="text-xs absolute top-0 right-4 text-tertiary"
class="text-xs absolute top-0 right-8 text-tertiary"
onclick={() => {
jsonViewerContent = value
jsonViewer?.toggleDrawer()
}}><Expand size={18} /></button
>
{/if}
<div class="max-h-60 overflow-auto">
<div class="max-h-60 overflow-auto" style="scrollbar-gutter: stable">
<ObjectViewer collapsed={false} topBrackets={true} pureViewer={true} json={value} />
</div>
</div>
@@ -13,16 +13,24 @@
Tooltip
} from 'chart.js'
import type { CompletedJob, ExtendedJobs } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import { Line } from '$lib/components/chartjs-wrappers/chartJs'
export let extendedJobs: ExtendedJobs | undefined = undefined
export let maxIsNow: boolean = false
export let minTimeSet: string | undefined = undefined
export let maxTimeSet: string | undefined = undefined
interface Props {
extendedJobs?: ExtendedJobs | undefined
maxIsNow?: boolean
minTimeSet?: string | undefined
maxTimeSet?: string | undefined
onZoom: (zoom: { min: Date; max: Date }) => void
}
const dispatch = createEventDispatcher()
let {
extendedJobs = undefined,
maxIsNow = false,
minTimeSet = undefined,
maxTimeSet = undefined,
onZoom
}: Props = $props()
function calculateTimeSeries(extendedJobs: ExtendedJobs): AggregatedInterval[] {
const timeline = new Map<number, { count: number; id_started: string[]; id_ended: string[] }>()
@@ -57,7 +65,7 @@
}
const s = timeline.get(startTime)!
s.count += 1
s.id_started.push('unknoww')
s.id_started.push('unknown')
if (j.duration_ms != undefined) {
const jc = j as CompletedJob
const endTime = startTime + jc.duration_ms
@@ -103,9 +111,6 @@
}
type AggregatedInterval = { time: Date; count: number; msg?: string }
let intervals: AggregatedInterval[] | undefined = undefined
$: intervals = extendedJobs ? calculateTimeSeries(extendedJobs) : undefined
ChartJS.register(
Title,
Tooltip,
@@ -118,31 +123,12 @@
TimeScale
)
$: data = {
datasets: [
{
borderColor: '#4ade80',
backgroundColor: '#f8717100',
pointRadius: 0,
label: 'running',
showLine: true,
stepped: true,
data:
intervals?.map((job) => ({
x: job.time as any,
y: job.count,
id: job.msg
})) ?? []
}
]
}
const zoomOptions = {
pan: {
enabled: true,
modifierKey: 'ctrl' as 'ctrl',
onPanComplete: ({ chart }) => {
dispatch('zoom', {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
@@ -154,17 +140,13 @@
},
mode: 'x' as 'x',
onZoom: ({ chart }) => {
dispatch('zoom', {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
}
}
}
let minTime = addSeconds(new Date(), -300)
let maxTime = getDbClockNow()
$: computeMinMaxTime(intervals, minTimeSet, maxTimeSet)
function minJobTime(intervals: AggregatedInterval[]): Date {
return intervals[0].time
@@ -181,15 +163,13 @@
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
if (minTimeSetDate && maxTimeSetDate) {
minTime = minTimeSetDate
maxTime = maxTimeSetDate
return
return { min: minTimeSetDate, max: maxTimeSetDate }
}
if (intervals == undefined || intervals?.length == 0) {
minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
maxTime = maxTimeSetDate ?? getDbClockNow()
return
const minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
const maxTime = maxTimeSetDate ?? getDbClockNow()
return { min: minTime, max: maxTime }
}
const maxJob = maxIsNow ? getDbClockNow() : maxJobTime(intervals)
@@ -197,12 +177,11 @@
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
if (maxIsNow) {
maxTime = maxTimeSetDate ?? maxJob
} else {
maxTime = maxTimeSetDate ?? addSeconds(maxJob, diff)
}
const minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
const maxTime = maxIsNow
? (maxTimeSetDate ?? maxJob)
: (maxTimeSetDate ?? addSeconds(maxJob, diff))
return { min: minTime, max: maxTime }
}
function addSeconds(date: Date, seconds: number): Date {
@@ -210,7 +189,30 @@
return date
}
$: options = {
const intervals = $derived(extendedJobs ? calculateTimeSeries(extendedJobs) : undefined)
let data = $derived({
datasets: [
{
borderColor: '#4ade80',
backgroundColor: '#f8717100',
pointRadius: 0,
label: 'running',
showLine: true,
stepped: true,
data:
intervals?.map((job) => ({
x: job.time as any,
y: job.count,
id: job.msg
})) ?? []
}
]
})
const minMaxTimes = $derived(computeMinMaxTime(intervals, minTimeSet, maxTimeSet))
let options = $derived({
responsive: true,
maintainAspectRatio: false,
plugins: {
@@ -232,8 +234,8 @@
grid: {
display: false
},
min: minTime,
max: maxTime
min: minMaxTimes.min,
max: minMaxTimes.max
},
y: {
grid: {
@@ -254,7 +256,7 @@
intersect: false,
mode: 'index'
}
} as any
} as any)
</script>
<div class="relative max-h-40">
@@ -0,0 +1,38 @@
<script lang="ts">
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { ChevronDown } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { Item } from '$lib/utils'
interface Props {
items?: Item[]
extraLabel?: import('svelte').Snippet
selected: string
selectedDisplayName?: string
btnClasses?: string
}
let { items = [], extraLabel, selected, selectedDisplayName, btnClasses }: Props = $props()
const filteredItems = $derived(items.filter((item) => item.id !== selected))
</script>
<DropdownV2 items={filteredItems}>
{#snippet buttonReplacement()}
<div
class={twMerge(
'p-2 h-8 flex flex-row items-center gap-2 border hover:bg-surface-hover cursor-pointer rounded-md',
btnClasses
)}
>
<div class="flex flex-row items-center gap-1 pr-2 justify-between w-full">
<span class="text-xs whitespace-nowrap">
{selectedDisplayName ?? items.find((item) => item.id === selected)?.displayName ?? ''}
</span>
{@render extraLabel?.()}
</div>
<ChevronDown size={12} />
</div>
{/snippet}
</DropdownV2>
@@ -47,7 +47,7 @@
aiDescription={item.displayName}
>
{#if item.icon}
<item.icon size={14} color={item.iconColor} />
<item.icon size={14} color={item.iconColor} class="shrink-0" />
{/if}
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{item.displayName}
@@ -50,6 +50,7 @@
AI_TOOL_MESSAGE_PREFIX,
getToolCallId
} from './graph/renderers/nodes/AIToolNode.svelte'
import JobAssetsViewer from './assets/JobAssetsViewer.svelte'
let {
flowStateStore,
@@ -1048,7 +1049,11 @@
}
}
})
let selected = $derived(isListJob ? 'sequence' : 'graph') as 'sequence' | 'graph' | 'logs'
let selected = $derived(isListJob ? 'sequence' : 'graph') as
| 'sequence'
| 'graph'
| 'logs'
| 'assets'
let animateLogsTab = $state(false)
@@ -1076,6 +1081,23 @@
return ''
}
// Set all tabs content to the same height to prevent layout jumps
let tabsHeight = $state({
sequenceHeight: 0,
logsHeight: 0,
assetsHeight: 0,
graphHeight: 0
})
let minTabHeight = $derived(
Math.max(
tabsHeight.sequenceHeight,
tabsHeight.logsHeight,
tabsHeight.assetsHeight,
tabsHeight.graphHeight
)
)
</script>
<JobLoader workspaceOverride={workspaceId} {noLogs} noCode bind:this={jobLoader} />
@@ -1154,12 +1176,17 @@
: ''}><span class="font-semibold">Logs</span></Tab
>
<Tab value="sequence"><span class="font-semibold">Details</span></Tab>
<Tab value="assets"><span class="font-semibold">Assets</span></Tab>
</Tabs>
{:else}
<div class="h-[30px]"></div>
{/if}
{/if}
<div class="{selected != 'sequence' ? 'hidden' : ''} max-w-7xl mx-auto">
<div
class="{selected != 'sequence' ? 'hidden' : ''} max-w-7xl mx-auto"
bind:clientHeight={tabsHeight.sequenceHeight}
style="min-height: {minTabHeight}px"
>
{#if isListJob}
{@const sliceFrom =
globalIterationBounds[buildSubflowKey(flowJobIds?.moduleId ?? '', prefix)]
@@ -1508,7 +1535,11 @@
<div class="p-2 text-tertiary text-sm italic">Empty flow</div>
{/if}
</div>
<div class="{selected != 'logs' ? 'hidden' : ''} mx-auto h-[800px]">
<div
class="{selected != 'logs' ? 'hidden' : ''} mx-auto"
bind:clientHeight={tabsHeight.logsHeight}
style="min-height: {minTabHeight}px"
>
<FlowLogViewerWrapper
{job}
{localModuleStates}
@@ -1517,10 +1548,23 @@
{onSelectedIteration}
/>
</div>
{#if selected == 'assets' && render}
<div
class="p-2"
bind:clientHeight={tabsHeight.assetsHeight}
style="min-height: {minTabHeight}px"
>
<JobAssetsViewer {job} />
</div>
{/if}
</div>
{#if render}
{#if job.raw_flow && !isListJob}
<div class="{selected != 'graph' ? 'hidden' : ''} grow mt-4">
<div
class="{selected != 'graph' ? 'hidden' : ''} grow mt-4"
bind:clientHeight={tabsHeight.graphHeight}
style="min-height: {minTabHeight}px"
>
<div class="grid grid-cols-3 border h-full" bind:clientHeight={wrapperHeight}>
<div class="col-span-2 bg-surface-secondary">
<div class="flex flex-col">
+1 -1
View File
@@ -85,7 +85,7 @@ ${Object.entries(args)
{#each Object.entries(args).sort((a, b) => a[0].localeCompare(b[0])) as [arg, value]}
<Row>
<Cell first>{arg}</Cell>
<Cell last><ArgInfo {value} /></Cell>
<Cell><ArgInfo {value} /></Cell>
</Row>
{/each}
{:else if args}
+107 -101
View File
@@ -15,28 +15,42 @@
LogarithmicScale
} from 'chart.js'
import type { CompletedJob } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import Button from './common/button/Button.svelte'
import { Scatter } from '$lib/components/chartjs-wrappers/chartJs'
import DarkModeObserver from './DarkModeObserver.svelte'
export let jobs: CompletedJob[] | undefined = []
export let maxIsNow: boolean = false
export let minTimeSet: string | undefined = undefined
export let maxTimeSet: string | undefined = undefined
export let selectedIds: string[] = []
export let canSelect: boolean = true
export let lastFetchWentToEnd: boolean = false
interface Props {
jobs?: CompletedJob[] | undefined
maxIsNow?: boolean
minTimeSet?: string | undefined
maxTimeSet?: string | undefined
selectedIds?: string[]
canSelect?: boolean
lastFetchWentToEnd?: boolean
onPointClicked: (ids: string[]) => void
onLoadExtra: () => void
onZoom: (zoom: { min: Date; max: Date }) => void
}
let {
jobs = [],
maxIsNow = false,
minTimeSet = undefined,
maxTimeSet = undefined,
selectedIds = $bindable([]),
canSelect = true,
lastFetchWentToEnd = false,
onPointClicked,
onLoadExtra,
onZoom
}: Props = $props()
const dispatch = createEventDispatcher()
const SUCCESS_COLOR = '#4ade80'
// const SUCCESS_COLOR_TRANSPARENT = '#c9b638'
const SUCCESS_COLOR_TRANSPARENT = mergeColors(SUCCESS_COLOR, getBackgorundColor(), 0.8)
const SUCCESS_COLOR_TRANSPARENT = $derived(mergeColors(SUCCESS_COLOR, getBackgorundColor(), 0.8))
const FAIL_COLOR = '#f87171'
const FAIL_COLOR_TRANSPARENT = mergeColors(FAIL_COLOR, getBackgorundColor(), 0.8)
$: success = jobs?.filter((x) => x.success)
$: failed = jobs?.filter((x) => !x.success)
const FAIL_COLOR_TRANSPARENT = $derived(mergeColors(FAIL_COLOR, getBackgorundColor(), 0.8))
ChartJS.register(
Title,
@@ -51,43 +65,12 @@
TimeScale
)
$: data = {
datasets: [
{
borderColor: 'rgba(99,0,125, 0)',
backgroundColor: FAIL_COLOR as string | string[],
radius: 3,
label: 'Failed',
data:
failed?.map((job) => ({
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
})) ?? []
},
{
borderColor: 'rgba(99,0,125, 0)',
backgroundColor: SUCCESS_COLOR as string | string[],
radius: 3,
label: 'Successful',
data:
success?.map((job) => ({
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
})) ?? []
}
]
}
const zoomOptions = {
pan: {
enabled: true,
modifierKey: 'ctrl' as 'ctrl',
onPanComplete: ({ chart }) => {
dispatch('zoom', {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
@@ -99,7 +82,7 @@
},
mode: 'x' as 'x',
onZoom: ({ chart }) => {
dispatch('zoom', {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
@@ -107,15 +90,14 @@
}
}
function isDark(): boolean {
return document.documentElement.classList.contains('dark')
}
ChartJS.defaults.color = isDark() ? '#ccc' : '#666'
ChartJS.defaults.borderColor = isDark() ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
let darkMode = $state(false)
$effect(() => {
ChartJS.defaults.color = darkMode ? '#ccc' : '#666'
ChartJS.defaults.borderColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
})
function getBackgorundColor(): string {
return isDark() ? '#2e3440' : '#ffffff'
return darkMode ? '#2e3440' : '#ffffff'
}
function hexToRgb(hexColor: string): number[] {
hexColor = hexColor.replace(/^#/, '')
@@ -150,29 +132,10 @@
return rgbToHex(blendedRgb)
}
function highlightSelectedPoints(ids: string[]) {
if (!canSelect || ids.length === 0) {
data.datasets[0].backgroundColor = FAIL_COLOR
data.datasets[1].backgroundColor = SUCCESS_COLOR
} else {
data.datasets[0].backgroundColor = data.datasets[0].data.map((p) =>
ids.includes(p.id) ? FAIL_COLOR : FAIL_COLOR_TRANSPARENT
)
data.datasets[1].backgroundColor = data.datasets[1].data.map((p) =>
ids.includes(p.id) ? SUCCESS_COLOR : SUCCESS_COLOR_TRANSPARENT
)
}
}
function getPath(x: any): string {
return x.path
}
let minTime = addSeconds(new Date(), -300)
let maxTime = getDbClockNow()
$: computeMinMaxTime(jobs, minTimeSet, maxTimeSet)
function minJobTime(jobs: CompletedJob[]): Date {
let min: Date = new Date(jobs[0].started_at)
for (const job of jobs) {
@@ -195,6 +158,7 @@
}
return max
}
function computeMinMaxTime(
jobs: CompletedJob[] | undefined,
minTimeSet: string | undefined,
@@ -203,15 +167,13 @@
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
if (minTimeSetDate && maxTimeSetDate) {
minTime = minTimeSetDate
maxTime = maxTimeSetDate
return
return { minTime: minTimeSetDate, maxTime: maxTimeSetDate }
}
if (jobs == undefined || jobs?.length == 0) {
minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
maxTime = maxTimeSetDate ?? getDbClockNow()
return
const computedMinTime = minTimeSetDate ?? addSeconds(new Date(), -300)
const computedMaxTime = maxTimeSetDate ?? getDbClockNow()
return { minTime: computedMinTime, maxTime: computedMaxTime }
}
const maxJob = maxIsNow ? getDbClockNow() : maxJobTime(jobs)
@@ -219,12 +181,11 @@
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
if (maxIsNow) {
maxTime = maxTimeSetDate ?? maxJob
} else {
maxTime = maxTimeSetDate ?? addSeconds(maxJob, diff)
}
let computedMinTime = minTimeSetDate ?? addSeconds(minJob, -diff)
let computedMaxTime = maxIsNow
? (maxTimeSetDate ?? maxJob)
: (maxTimeSetDate ?? addSeconds(maxJob, diff))
return { minTime: computedMinTime, maxTime: computedMaxTime }
}
function addSeconds(date: Date, seconds: number): Date {
@@ -232,7 +193,56 @@
return date
}
$: scatterOptions = {
let success = $derived(jobs?.filter((x) => x.success))
let failed = $derived(jobs?.filter((x) => !x.success))
let data = $derived.by(() => {
const data = {
datasets: [
{
borderColor: 'rgba(99,0,125, 0)',
backgroundColor: FAIL_COLOR as string | string[],
radius: 3,
label: 'Failed',
data:
failed?.map((job) => ({
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
})) ?? []
},
{
borderColor: 'rgba(99,0,125, 0)',
backgroundColor: SUCCESS_COLOR as string | string[],
radius: 3,
label: 'Successful',
data:
success?.map((job) => ({
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
})) ?? []
}
]
}
if (!canSelect || selectedIds.length === 0) {
data.datasets[0].backgroundColor = FAIL_COLOR
data.datasets[1].backgroundColor = SUCCESS_COLOR
} else {
data.datasets[0].backgroundColor = data.datasets[0].data.map((p) =>
selectedIds.includes(p.id) ? FAIL_COLOR : FAIL_COLOR_TRANSPARENT
)
data.datasets[1].backgroundColor = data.datasets[1].data.map((p) =>
selectedIds.includes(p.id) ? SUCCESS_COLOR : SUCCESS_COLOR_TRANSPARENT
)
}
return data
})
const minMaxTime = $derived.by(() => computeMinMaxTime(jobs, minTimeSet, maxTimeSet))
let scatterOptions = $derived({
responsive: true,
maintainAspectRatio: false,
plugins: {
@@ -242,17 +252,17 @@
},
tooltip: {
callbacks: {
label: function (context) {
label: function (context: any) {
return getPath(context.raw)
}
}
}
},
onClick: (e, u) => {
onClick: (_e: any, u: any) => {
if (canSelect) {
const ids = u.map((j) => data.datasets[j.datasetIndex].data[j.index].id)
const ids = u.map((j: any) => data.datasets[j.datasetIndex].data[j.index].id)
selectedIds = ids
dispatch('pointClicked', ids)
onPointClicked(ids)
}
},
@@ -262,8 +272,8 @@
grid: {
display: false
},
min: minTime,
max: maxTime
min: minMaxTime.minTime,
max: minMaxTime.maxTime
},
y: {
grid: {
@@ -277,11 +287,11 @@
}
},
animation: false
} as any
$: data && scatterOptions && highlightSelectedPoints(selectedIds)
} as any)
</script>
<DarkModeObserver bind:darkMode />
<!-- {JSON.stringify(minTime)}
{JSON.stringify(maxTime)}
@@ -291,12 +301,8 @@
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
<div class="relative max-h-40">
{#if !lastFetchWentToEnd}
<div class="absolute top-[-10px] left-[60px]"
><Button
size="xs"
color="transparent"
variant="contained"
on:click={() => dispatch('loadExtra')}
<div class="absolute top-[-26px] left-[160px]"
><Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}
>Load more <Tooltip2
>There are more jobs to load but only the first 1000 were fetched</Tooltip2
></Button
+1 -1
View File
@@ -6,10 +6,10 @@
export let date: string
export let agoOnlyIfRecent: boolean = false
export let noDate = false
export let isRecent: boolean = true
let computedTimeAgo: string | undefined = undefined
let isRecent = true
let interval
$: date && computeDate()
+3 -1
View File
@@ -12,6 +12,7 @@
right?: string
rightTooltip?: string
rightDocumentationLink?: string
title?: string
} = {}
export let checked: boolean = false
export let disabled = false
@@ -37,6 +38,7 @@
class="{$$props.class || ''} z-auto flex flex-row items-center duration-50 {disabled
? 'grayscale opacity-50'
: 'cursor-pointer'}"
title={options?.title}
>
{#if Boolean(options?.left)}
<span
@@ -103,7 +105,7 @@
class={twMerge(
'ml-2 font-medium duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
size === 'xs' || size === '2sm' ? 'text-xs' : 'text-sm',
size === 'xs' || size === '2sm' ? 'text-xs' : size === '2xs' ? 'text-xs' : 'text-sm',
textClass
)}
style={textStyle}
@@ -65,30 +65,34 @@
let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state()
</script>
<ul class="flex flex-col divide-y mt-1">
{#each assets.value ?? [] as asset}
<li class="flex justify-between py-3 leading-4 text-sm pl-4">
<div class="flex flex-col flex-1 truncate">
{asset.path}
<span class="text-2xs text-tertiary">
{formatAssetKind({
...asset,
...(asset.kind === 'resource'
? { metadata: { resource_type: resourceDataCache[asset.path] } }
: {})
})}
</span>
</div>
<AssetButtons
{asset}
{resourceDataCache}
{dbManagerDrawer}
{resourceEditorDrawer}
{s3FilePicker}
/>
</li>
{/each}
</ul>
{#if assets.value && assets.value.length > 0}
<ul class="flex flex-col divide-y mt-1">
{#each assets.value ?? [] as asset}
<li class="flex justify-between py-3 leading-4 text-sm pl-4">
<div class="flex flex-col flex-1 truncate">
{asset.path}
<span class="text-2xs text-tertiary">
{formatAssetKind({
...asset,
...(asset.kind === 'resource'
? { metadata: { resource_type: resourceDataCache[asset.path] } }
: {})
})}
</span>
</div>
<AssetButtons
{asset}
{resourceDataCache}
{dbManagerDrawer}
{resourceEditorDrawer}
{s3FilePicker}
/>
</li>
{/each}
</ul>
{:else}
<div class="text-sm text-tertiary">No assets found</div>
{/if}
<S3FilePicker bind:this={s3FilePicker} readOnlyMode />
<DbManagerDrawer bind:this={dbManagerDrawer} />
@@ -13,6 +13,7 @@
export let baseClass = 'text-center'
export let capitalize = false
export let icon: BadgeIconProps | undefined = undefined
export let verySmall = false
let hidden = false
const colors: Record<BadgeColor, string> = {
@@ -46,11 +47,18 @@
$: badgeClass = classNames(
baseClass,
small ? 'text-xs' : large ? 'text-sm font-medium' : 'text-xs font-semibold',
small
? 'text-xs'
: verySmall
? 'text-2xs'
: large
? 'text-sm font-medium'
: 'text-xs font-semibold',
colors[color],
href &&
(color.startsWith(ColorModifier) ? hovers[color.replace(ColorModifier, '')] : hovers[color]),
rounded ? 'rounded-full px-2 py-1' : 'rounded px-2.5 py-0.5',
verySmall ? 'px-0.5 py-0.5' : '',
'flex flex-row gap-1 items-center',
$$props.class
)
@@ -4,6 +4,7 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { Placement } from '@floating-ui/core'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import { twMerge } from 'tailwind-merge'
export let date: string | undefined
export let label: string
@@ -25,7 +26,10 @@
<svelte:fragment slot="trigger">
<button
title="Open calendar picker"
class="absolute bottom-1 right-2 top-1 py-1 min-w-min !px-2 items-center text-primary bg-surface border rounded center-center hover:bg-surface-hover transition-all cursor-pointer"
class={twMerge(
'absolute bottom-1 right-2 top-1 py-1 min-w-min !px-2 items-center text-primary bg-surface border rounded center-center hover:bg-surface-hover transition-all cursor-pointer',
$$props.class
)}
aria-label="Open calendar picker"
on:click={() => {
input?.focus()
@@ -7,6 +7,7 @@
type TogglableItem = {
label: string
value: string
tooltip?: string
}
interface Props {
@@ -29,11 +30,11 @@
togglableItems
}: Props = $props()
function select(v: string) {
selected = v
}
let items = togglableItems.map((i) => ({ displayName: i.label, action: () => select(i.value) }))
let items = togglableItems.map((i) => ({
displayName: i.label,
action: () => (selected = i.value),
tooltip: i.tooltip
}))
function isAnOptionSelected(selected: string | undefined) {
return togglableItems.some((i) => i.value === selected)
@@ -48,6 +49,7 @@
>
<div {id} class="flex">
{#if isAnOptionSelected(selected)}
{@const tooltip = togglableItems.find((i) => i.value === selected)?.tooltip}
<ToggleButton
{disabled}
value={selected ?? ''}
@@ -56,6 +58,8 @@
{light}
{id}
label={togglableItems.find((i) => i.value === selected)?.label}
{tooltip}
showTooltipIcon={!!tooltip}
/>
{/if}
<div class="flex items-center">
@@ -1,13 +1,23 @@
<script lang="ts">
interface Props {
height?: string;
width?: string;
height?: string
width?: string
size?: number
}
let { height = '24px', width = '24px' }: Props = $props();
let { height, width, size = 24 }: Props = $props()
let derivedWidth = $derived(width || `${size}px`)
let derivedHeight = $derived(height || `${size}px`)
</script>
<svg {width} {height} viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width={derivedWidth}
height={derivedHeight}
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="#F05133"
d="M92.71 44.408 52.591 4.291c-2.31-2.311-6.057-2.311-8.369 0l-8.33 8.332L46.459 23.19c2.456-.83 5.272-.273 7.229 1.685 1.969 1.97 2.521 4.81 1.67 7.275l10.186 10.185c2.465-.85 5.307-.3 7.275 1.671 2.75 2.75 2.75 7.206 0 9.958-2.752 2.751-7.208 2.751-9.961 0-2.068-2.07-2.58-5.11-1.531-7.658l-9.5-9.499v24.997c.67.332 1.303.774 1.861 1.332 2.75 2.75 2.75 7.206 0 9.959-2.75 2.749-7.209 2.749-9.957 0-2.75-2.754-2.75-7.21 0-9.959.68-.679 1.467-1.193 2.307-1.537v-25.23c-.84-.344-1.625-.853-2.307-1.537-2.083-2.082-2.584-5.14-1.516-7.698L31.798 16.715 4.288 44.222c-2.311 2.313-2.311 6.06 0 8.371l40.121 40.118c2.31 2.311 6.056 2.311 8.369 0L92.71 52.779c2.311-2.311 2.311-6.06 0-8.371z"
@@ -11,7 +11,7 @@
import { fade } from 'svelte/transition'
import { X, Minimize2, Maximize2 } from 'lucide-svelte'
import type { Placement } from '@floating-ui/core'
import { pointerDownOutside } from '$lib/utils'
import { debounce, pointerDownOutside } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { createEventDispatcher } from 'svelte'
import { Button } from '$lib/components/common'
@@ -24,6 +24,7 @@
export let placement: Placement = 'bottom'
export let disablePopup: boolean = false
export let openOnHover: boolean = false
export let debounceDelay: number = 0
export let floatingConfig: any | undefined = undefined
export let usePointerDownOutside: boolean = false
export let closeOnOutsideClick: boolean = true
@@ -41,6 +42,15 @@
let fullScreen = false
const dispatch = createEventDispatcher()
function clearTimers() {
clearDebounceClose()
clearDebounceOpen()
}
// Cleanup timers on component destruction
import { onDestroy } from 'svelte'
onDestroy(clearTimers)
const {
elements: { trigger, content, arrow, close: closeElement, overlay },
states,
@@ -109,6 +119,15 @@
async function getMenuElements(): Promise<HTMLElement[]> {
return Array.from(document.querySelectorAll('[data-popover]')) as HTMLElement[]
}
let { debounced: debounceClose, clearDebounce: clearDebounceClose } = debounce(
() => openOnHover && close(),
debounceDelay
)
let { debounced: debounceOpen, clearDebounce: clearDebounceOpen } = debounce(
() => openOnHover && open(),
debounceDelay
)
</script>
<button
@@ -116,8 +135,8 @@
use:melt={$trigger}
aria-label="Popup button"
disabled={disablePopup || disabled}
on:mouseenter={() => (openOnHover ? open() : null)}
on:mouseleave={() => (openOnHover ? close() : null)}
on:mouseenter={debounceOpen}
on:mouseleave={debounceClose}
use:pointerDownOutside={{
capture: true,
stopPropagation: false,
@@ -141,6 +160,8 @@
{#if isOpen && !disablePopup}
<div
on:mouseenter={debounceOpen}
on:mouseleave={debounceClose}
use:melt={$content}
transition:fade={{ duration: 0 }}
class={twMerge(
@@ -16,7 +16,7 @@
import Popover from '../Popover.svelte'
import { isFlowPreview, isScriptPreview, truncateRev } from '$lib/utils'
import { createEventDispatcher, setContext, untrack } from 'svelte'
import { ListFilter } from 'lucide-svelte'
import { ListFilter, LoaderCircle } from 'lucide-svelte'
import FlowAssetsHandler, { initFlowGraphAssetsCtx } from '../flows/FlowAssetsHandler.svelte'
import JobAssetsViewer from '../assets/JobAssetsViewer.svelte'
@@ -78,199 +78,244 @@
})
let jobLoader: JobLoader | undefined = $state(undefined)
// Set all tabs content to the same height to prevent layout jumps
let tabsHeigh = $state({
codeHeight: 0,
logsHeight: 0,
assetsHeight: 0,
resultHeight: 0
})
let minTabHeight = $derived(
Math.max(
tabsHeigh.codeHeight,
tabsHeigh.logsHeight,
tabsHeigh.assetsHeight,
tabsHeigh.resultHeight
)
)
let jobIsLoading = $state(false)
</script>
<JobLoader workspaceOverride={workspace} bind:job={currentJob} bind:this={jobLoader} />
<JobLoader
workspaceOverride={workspace}
bind:job={currentJob}
bind:isLoading={jobIsLoading}
bind:this={jobLoader}
/>
<div class="p-4 flex flex-col gap-2 items-start h-full">
{#if job}
<div class="flex gap-2 flex-wrap">
{#if job?.['priority']}
<Badge color="red">
priority: {job?.['priority']}
</Badge>
{/if}
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
<DurationMs
duration_ms={job.duration_ms}
self_wait_time_ms={job?.self_wait_time_ms}
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
/>
{/if}
{#if job?.['mem_peak']}
<Badge large>
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{/if}
{#if workspace && $workspaceStore != workspace}
<Badge large>
Workspace: {workspace}
</Badge>
{/if}
{#if job.tag}
<Badge large>
Tag: {job.tag}
</Badge>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<Badge baseClass="text-2xs">Label: {label}</Badge>
{/each}
{/if}
{#if concurrencyKey}
<Popover notClickable>
{#snippet text()}
This job has concurrency limits enabled with the key:
<Button
class="inline-text"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByConcurrencyKey', concurrencyKey)
}}
>
{concurrencyKey}
<ListFilter class="inline-block" size={10} />
</Button>
{/snippet}
<Badge large>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge>
</Popover>
{/if}
{#if job?.worker}
<Popover notClickable>
{#snippet text()}
This job was run on worker:
<Button
class="inline-text"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByWorker', job?.worker)
}}
>
{job?.worker}
<ListFilter class="inline-block" size={10} />
</Button>
{/snippet}
<Badge large>Worker: {truncateRev(job.worker, 20)}</Badge>
</Popover>
{/if}
</div>
<a
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
class="flex flex-row gap-1 items-center"
target={blankLink ? '_blank' : undefined}
>
<span class="font-semibold text-sm leading-6">ID:</span>
<span class="text-sm">{job?.id ?? ''}</span>
</a>
<span class="font-semibold text-xs leading-6">Arguments</span>
<div class="w-full">
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
args={job?.args}
/>
</div>
{#if job?.type === 'CompletedJob'}
<span class="font-semibold text-xs leading-6">Results</span>
{/if}
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
<div class="text-sm font-semibold text-tertiary mb-1">
<div>Job is scheduled for</div>
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
</div>
{/if}
<div class=" w-full rounded-md min-h-full">
{#if job?.workflow_as_code_status}
<WorkflowTimeline
flow_status={asWorkflowStatus(job.workflow_as_code_status)}
flowDone={job.type == 'CompletedJob'}
/>
{/if}
{#if job?.type === 'CompletedJob'}
<Tabs bind:selected={viewTab}>
<Tab size="xs" value="result">Result</Tab>
<Tab size="xs" value="logs">Logs</Tab>
<Tab size="xs" value="assets">Assets</Tab>
{#if isScriptPreview(job?.job_kind)}
<Tab size="xs" value="code">Code</Tab>
{/if}
</Tabs>
<Skeleton loading={!job} layout={[[5]]} />
{#if job}
{#if viewTab == 'result' && (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind))}
<div class="flex flex-col gap-2">
<div class="w-full mt-10 mb-20">
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
</div>
</div>
{:else if viewTab == 'assets'}
<JobAssetsViewer {job} />
{:else}
<div class="flex flex-col border rounded-md p-2 mt-2 h-full overflow-auto">
{#if viewTab == 'logs'}
<div class="w-full">
<LogViewer
jobId={job.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
isLoading={job?.['running'] == false}
content={job?.logs}
tag={job?.tag}
/>
</div>
{:else if viewTab == 'code'}
{#if job && 'raw_code' in job && job.raw_code}
<div class="text-xs">
<HighlightCode lines language={job.language} code={job.raw_code} />
</div>
{:else if job}
No code is available
{:else}
<Skeleton layout={[[5]]} />
{/if}
{:else if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined))}
<DisplayResult
workspaceId={job?.workspace_id}
jobId={job?.id}
{result}
disableExpand
language={job?.language}
/>
{:else if job}
No output is available yet
{/if}
</div>
{/if}
<div class="h-full overflow-y-auto">
<div class="flex flex-col gap-2 items-start p-4 pb-8 min-h-full">
{#if job}
<div class="flex gap-2 flex-wrap">
{#if job?.['priority']}
<Badge color="red">
priority: {job?.['priority']}
</Badge>
{/if}
{:else if job && `running` in job ? job.running : false}
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
<div class="flex flex-col gap-2 w-full">
<FlowProgressBar {job} class="py-4" />
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
</div>
{:else}
<div class="text-sm font-semibold text-tertiary mb-1"> Job is still running </div>
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
content={job?.logs}
isLoading={job?.['running'] == false}
tag={job?.tag}
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
<DurationMs
duration_ms={job.duration_ms}
self_wait_time_ms={job?.self_wait_time_ms}
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
/>
{/if}
{#if job?.['mem_peak']}
<Badge large>
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{/if}
{#if workspace && $workspaceStore != workspace}
<Badge large>
Workspace: {workspace}
</Badge>
{/if}
{#if job.tag}
<Badge large>
Tag: {job.tag}
</Badge>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<Badge baseClass="text-2xs">Label: {label}</Badge>
{/each}
{/if}
{#if concurrencyKey}
<Popover notClickable>
{#snippet text()}
This job has concurrency limits enabled with the key:
<Button
class="inline-text"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByConcurrencyKey', concurrencyKey)
}}
>
{concurrencyKey}
<ListFilter class="inline-block" size={10} />
</Button>
{/snippet}
<Badge large>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge>
</Popover>
{/if}
{#if job?.worker}
<Popover notClickable>
{#snippet text()}
This job was run on worker:
<Button
class="inline-text"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByWorker', job?.worker)
}}
>
{job?.worker}
<ListFilter class="inline-block" size={10} />
</Button>
{/snippet}
<Badge large>Worker: {truncateRev(job.worker, 20)}</Badge>
</Popover>
{/if}
</div>
<a
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
class="flex flex-row gap-1 items-center"
target={blankLink ? '_blank' : undefined}
>
<span class="font-semibold text-sm leading-6">ID:</span>
<span class="text-sm">{job?.id ?? ''}</span>
</a>
<div class="w-full">
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
args={job?.args}
/>
</div>
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
<div class="text-sm font-semibold text-tertiary mb-1">
<div>Job is scheduled for</div>
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
</div>
{/if}
</div>
{/if}
<div class="w-full rounded-md min-h-full">
{#if job?.workflow_as_code_status}
<WorkflowTimeline
flow_status={asWorkflowStatus(job.workflow_as_code_status)}
flowDone={job.type == 'CompletedJob'}
/>
{/if}
{#if job?.type === 'CompletedJob'}
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
<div class="w-full mt-8 mb-20">
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} wideResults
></FlowStatusViewer>
</div>
{:else}
<Tabs bind:selected={viewTab}>
<Tab size="xs" value="result">Results</Tab>
<Tab size="xs" value="logs">Logs</Tab>
<Tab size="xs" value="assets">Assets</Tab>
{#if isScriptPreview(job?.job_kind)}
<Tab size="xs" value="code">Code</Tab>
{/if}
</Tabs>
<Skeleton loading={!job} layout={[[5]]} />
{#if job}
<div class="flex flex-col border rounded-md p-2 mt-2 overflow-auto">
{#if viewTab == 'logs'}
<div
class="w-full"
bind:clientHeight={tabsHeigh.logsHeight}
style="min-height: {minTabHeight}px"
>
<LogViewer
jobId={job.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
isLoading={job?.['running'] == false}
content={job?.logs}
tag={job?.tag}
/>
</div>
{:else if viewTab == 'assets'}
<div
class="w-full h-full"
bind:clientHeight={tabsHeigh.assetsHeight}
style="min-height: {minTabHeight}px"
>
<JobAssetsViewer {job} />
</div>
{:else if viewTab == 'code'}
<div
class="text-xs"
bind:clientHeight={tabsHeigh.codeHeight}
style="min-height: {minTabHeight}px"
>
{#if job && 'raw_code' in job && job.raw_code}
<div class="text-xs">
<HighlightCode lines language={job.language} code={job.raw_code} />
</div>
{:else if job}
<span class="text-sm">No code available</span>
{:else}
<Skeleton layout={[[5]]} />
{/if}
</div>
{:else if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined))}
<div
class="w-full"
bind:clientHeight={tabsHeigh.resultHeight}
style="min-height: {minTabHeight}px"
>
<DisplayResult
workspaceId={job?.workspace_id}
jobId={job?.id}
{result}
disableExpand
language={job?.language}
/>
</div>
{:else if job}
No output is available yet
{/if}
</div>
{/if}
{/if}
{:else if job && `running` in job ? job.running : false}
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
<div class="flex flex-col gap-2 w-full">
<FlowProgressBar {job} class="py-4" />
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
</div>
{:else}
<div class="text-sm font-semibold text-tertiary mb-1"> Job is still running </div>
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
content={job?.logs}
isLoading={job?.['running'] == false}
tag={job?.tag}
/>
{/if}
{/if}
</div>
{:else if jobIsLoading}
<div class="mx-auto my-auto">
<LoaderCircle size={20} class="animate-spin" />
</div>
{/if}
</div>
</div>
<FlowAssetsHandler
modules={job?.raw_flow?.modules ?? []}
@@ -0,0 +1,119 @@
<script lang="ts">
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
import { truncateHash } from '$lib/utils'
import { base } from '$app/paths'
import { truncateRev } from '$lib/utils'
import WorkerHostname from '$lib/components/WorkerHostname.svelte'
import { workspaceStore } from '$lib/stores'
import Badge from '$lib/components/common/badge/Badge.svelte'
import type { Job } from '$lib/gen'
interface Props {
job: Job
displayPersistentScriptDefinition?: boolean
openPersistentScriptDrawer?: () => void
concurrencyKey?: string
showScriptHash?: boolean
verySmall?: boolean
}
let {
job,
displayPersistentScriptDefinition,
openPersistentScriptDrawer,
concurrencyKey,
showScriptHash = true,
verySmall = false
}: Props = $props()
</script>
{#if job.script_hash && showScriptHash && job.job_kind !== 'aiagent'}
{#if job.job_kind == 'script'}
<a href="{base}/scripts/get/{job.script_hash}?workspace={$workspaceStore}"
><Badge color="gray" {verySmall}>{truncateHash(job.script_hash)}</Badge></a
>
{:else}
<div>
<Badge color="gray" {verySmall}>{truncateHash(job.script_hash)}</Badge>
</div>
{/if}
{/if}
{#if job && 'job_kind' in job}
<div>
<Badge color="blue" {verySmall}>{job.job_kind}</Badge>
</div>
{/if}
{#if job && job.flow_status && job.job_kind === 'script'}
<PreprocessedArgsDisplay preprocessed={job.preprocessed} />
{/if}
{#if displayPersistentScriptDefinition}
<button onclick={() => openPersistentScriptDrawer?.()}>
<Badge color="red">persistent</Badge>
</button>
{/if}
{#if job && 'priority' in job}
<div>
<Badge color="blue" {verySmall}>priority: {job.priority}</Badge>
</div>
{/if}
{#if job.tag}
<!-- for related places search: ADD_NEW_LANG -->
<div>
<Badge color="indigo" {verySmall}>Tag: {job.tag}</Badge>
</div>
{/if}
{#if !job.visible_to_owner}
<div>
<Badge color="red" {verySmall}>
only visible to you
<Tooltip>
{#snippet text()}
The option to hide this run from the owner of this script or flow was activated
{/snippet}
</Tooltip>
</Badge>
</div>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<div>
<Badge {verySmall}>Label: {label}</Badge>
</div>
{/each}
{/if}
{#if concurrencyKey}
<div>
<Tooltip notClickable>
{#snippet text()}
This job has concurrency limits enabled with the key
<a
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
>
{concurrencyKey}
</a>
{/snippet}
<a
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
>
<Badge {verySmall}>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge></a
>
</Tooltip>
</div>
{/if}
{#if job?.worker}
<div>
<Tooltip notClickable>
{#snippet text()}
worker:
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
{job?.worker}
</a><br />
<WorkerHostname worker={job?.worker!} minTs={job?.['created_at']} />
{/snippet}
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
<Badge {verySmall}>Worker: {truncateRev(job?.worker, 20)}</Badge></a
>
</Tooltip>
</div>
{/if}
@@ -0,0 +1,112 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import { ListFilterPlus } from 'lucide-svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import type { Job } from '$lib/gen'
import type { Item } from '$lib/utils'
interface Props {
job: Job
activeLabel: string | null
onFilterByLabel: (label: string) => void
labelWidth: number
}
let { job, activeLabel, onFilterByLabel, labelWidth }: Props = $props()
const GAP = 4
const LABEL_MAX_WIDTH = 84
const MORE_LABEL_WIDTH = 30
// Min width for labels columns is 120px
const labels = $derived(job && Array.isArray(job?.['labels']) ? (job['labels'] as string[]) : [])
const labelSplit = $derived.by(() => {
if (!labels || labels.length === 0 || labelWidth <= 0) {
return { visibleLabels: [], hiddenLabels: [] }
}
if (labels.length === 1) {
return { visibleLabels: labels, hiddenLabels: [] }
}
let currentWidth = 0
const visible: string[] = []
const hidden: string[] = []
const margin = 20
for (let i = 0; i < labels.length; i++) {
const label = labels[i]
// Check if we need to reserve space for overflow badge
const needsOverflowBadge = i < labels.length - 1
const remainingWidth = labelWidth - currentWidth
const requiredWidth =
LABEL_MAX_WIDTH + (needsOverflowBadge ? MORE_LABEL_WIDTH + GAP : 0) + margin
if (remainingWidth >= requiredWidth || visible.length === 0) {
visible.push(label)
currentWidth += LABEL_MAX_WIDTH + GAP
} else {
hidden.push(...labels.slice(i))
break
}
}
return { visibleLabels: visible, hiddenLabels: hidden }
})
const visibleLabels = $derived(labelSplit.visibleLabels || [])
const hiddenLabels = $derived(labelSplit.hiddenLabels || [])
const dropdownItems = $derived(
hiddenLabels.map(
(label): Item => ({
displayName: label,
action: () => onFilterByLabel(label),
icon: ListFilterPlus
})
)
)
</script>
{#if labels && labels.length > 0}
<div class="flex flex-row items-center" style="gap: {GAP}px">
{#each visibleLabels as label}
<Tooltip openDelay={500} placement="bottom">
<button
class={twMerge(
activeLabel == label ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'flex flex-row items-center px-2 group py-1 rounded-md bg-surface-secondary hover:bg-surface'
)}
style="gap: {GAP}px; width: {LABEL_MAX_WIDTH}px"
onclick={() => {
onFilterByLabel(label)
}}
>
<span class="truncate text-2xs font-normal">{label}</span>
<ListFilterPlus size={12} class="shrink-0 text-gray-300 group-hover:text-primary" />
</button>
{#snippet text()}
{`Filter by label: ${label}`}
{/snippet}
</Tooltip>
{/each}
{#if hiddenLabels.length > 0}
<DropdownV2 placement="bottom-start" items={dropdownItems} customWidth={224}>
{#snippet buttonReplacement()}
<button
class="flex flex-row items-center justify-center px-2 py-1 text-2xs font-semibold hover:bg-surface bg-surface-secondary text-secondary rounded-md"
style="gap: {GAP}px; width: {MORE_LABEL_WIDTH}px"
>
+{hiddenLabels.length}
</button>
{/snippet}
</DropdownV2>
{/if}
</div>
{:else}
<span class="text-2xs text-secondary">-</span>
{/if}
@@ -0,0 +1,30 @@
<script lang="ts">
import Tooltip from '$lib/components/Tooltip.svelte'
interface Props {
label: string
children: import('svelte').Snippet
tooltip?: import('svelte').Snippet
for?: string
noLabel?: boolean
}
let { label, children, tooltip, for: forAttr = '', noLabel = false }: Props = $props()
</script>
<div class="flex flex-col items-start w-fit">
<div class="text-xs truncate">
{#if forAttr}
<label for={forAttr} class:text-transparent={noLabel}>{label}</label>
{:else}
<span class:text-transparent={noLabel}>{label}</span>
{/if}
{#if tooltip}
<Tooltip small>{@render tooltip()}</Tooltip>
{/if}
</div>
<div class="flex flex-row gap-1 items-center justify-start w-full relative h-[34px]">
{@render children()}
</div>
</div>
+271 -158
View File
@@ -4,24 +4,28 @@
import type { Job } from '$lib/gen'
import {
displayDate,
msToReadableTime,
truncateHash,
truncateRev,
isFlowPreview,
isScriptPreview,
isJobSelectable
isJobSelectable,
msToReadableTime,
isFlowPreview
} from '$lib/utils'
import { Badge, Button } from '../common'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import {
Bot,
Calendar,
Check,
Clock,
Code,
ExternalLink,
FastForward,
Folder,
Hourglass,
ListFilter,
ListFilterPlus,
Package,
Play,
ShieldQuestion,
X
@@ -34,45 +38,104 @@
import WaitTimeWarning from '../common/waitTimeWarning/WaitTimeWarning.svelte'
import type { RunsSelectionMode } from './RunsBatchActionsDropdown.svelte'
import DropdownV2 from '../DropdownV2.svelte'
import { Tooltip } from '../meltComponents'
import { GitIcon } from '../icons'
import RunLabels from './RunLabels.svelte'
import './runs-grid.css'
const dispatch = createEventDispatcher()
export let job: Job
export let selected: boolean = false
export let containerWidth: number = 0
export let containsLabel: boolean = false
export let activeLabel: string | null
export let selectionMode: RunsSelectionMode | false = false
interface Props {
job: Job
selected?: boolean
containerWidth?: number
containsLabel?: boolean
showTag?: boolean
activeLabel: string | null
selectionMode?: RunsSelectionMode | false
}
let scheduleEditor: ScheduleEditor
let {
job,
selected = false,
containerWidth = 0,
containsLabel = false,
showTag = true,
activeLabel,
selectionMode = false
}: Props = $props()
$: isExternal = job && job.id === '-'
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
let isExternal = $derived(job && job.id === '-')
function getJobKindIcon(jobKind: Job['job_kind']) {
if (jobKind === 'flow' || isFlowPreview(jobKind)) {
return BarsStaggered
} else if (jobKind === 'deploymentcallback') {
return GitIcon
} else if (
jobKind === 'dependencies' ||
jobKind === 'appdependencies' ||
jobKind === 'flowdependencies'
) {
return Package
} else if (
jobKind === 'script' ||
isScriptPreview(jobKind) ||
jobKind === 'script_hub' ||
jobKind === 'singlescriptflow'
) {
return Code
} else if (jobKind === 'aiagent') {
return Bot
}
return Code
}
let labelWidth = $state(0)
let isJobRecent = $state(true)
</script>
<Portal name="run-row">
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
</Portal>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'hover:bg-surface-hover cursor-pointer',
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'flex flex-row items-center h-full'
'grid items-center h-full'
)}
class:grid-runs-table={!containsLabel && !selectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !selectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && selectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && selectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !selectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !selectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && selectionMode && !showTag}
class:grid-runs-table-with-labels-selection-no-tag={containsLabel && selectionMode && !showTag}
style="width: {containerWidth}px"
on:click={() => {
onclick={() => {
if (!selectionMode || isJobSelectable(selectionMode)(job)) {
dispatch('select')
}
}}
>
<div class="w-1/12 flex justify-center">
{#if selectionMode && isJobSelectable(selectionMode)(job)}
<div class="px-2">
<input type="checkbox" checked={selected} />
<!-- Selection column (only when in selection mode) -->
{#if selectionMode}
<div class="flex items-center justify-center">
<div class="w-4 h-4">
<input type="checkbox" checked={selected} disabled={!isJobSelectable(selectionMode)(job)} />
</div>
{/if}
</div>
{/if}
<!-- Status -->
<div class="flex items-center justify-start pl-2">
{#if isExternal}
<Badge color="gray" baseClass="!px-1.5">
<ShieldQuestion size={14} />
@@ -114,16 +177,13 @@
{/if}
</div>
<div class="w-4/12 flex justify-start">
<div class="flex flex-row items-center gap-1 text-gray-500 dark:text-gray-300 text-2xs">
<!-- Job time -->
<div class="overflow-hidden min-w-0">
<div class="flex flex-row items-center gap-1 text-secondary text-2xs">
{#if job}
{#if 'started_at' in job && job.started_at}
Started <TimeAgo agoOnlyIfRecent date={job.started_at ?? ''} />
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
(Ran in {msToReadableTime(
job.duration_ms
)}{#if job.job_kind == 'flow' || isFlowPreview(job.job_kind)}&nbsp;total{/if})
{/if}
{isJobRecent ? 'Started' : ''}
<TimeAgo bind:isRecent={isJobRecent} agoOnlyIfRecent date={job.started_at ?? ''} />
{#if job && (job.self_wait_time_ms || job.aggregate_wait_time_ms)}
<WaitTimeWarning
self_wait_time_ms={job.self_wait_time_ms}
@@ -132,7 +192,7 @@
/>
{/if}
{:else if `scheduled_for` in job && job.scheduled_for && forLater(job.scheduled_for)}
Scheduled for {displayDate(job.scheduled_for)}
{displayDate(job.scheduled_for)}<Clock size={12} />
{:else if job.canceled}
{#if job.type == 'CompletedJob'}
Cancelled <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />
@@ -151,154 +211,207 @@
</div>
</div>
<div class="w-4/12 flex justify-start flex-col">
<div class="flex flex-row text-sm">
{#if job === undefined}
No job found
{:else}
<div class="flex flex-row gap-1 min-w-0">
<div class="whitespace-nowrap text-xs font-semibold truncate">
{#if job.script_path}
<div class="flex flex-row gap-1 items-center">
{#if isExternal}
<span class="w-30 justify-center">-</span>
{:else}
<a
href="{base}/run/{job.id}?workspace={job.workspace_id}"
class="truncate w-30 dark:text-blue-400"
>
{job.script_path}
</a>
<Button
title="Filter by path"
size="xs2"
color="light"
on:click={() => {
dispatch('filterByPath', job.script_path)
}}
>
<ListFilter size={10} />
</Button>
{/if}
{#if job.script_path?.startsWith('f/')}
<Button
title="Filter by folder"
size="xs2"
color="light"
on:click={() => {
// split script_path by / and get the second element
const folder = job.script_path?.split('/')[1]
dispatch('filterByFolder', folder)
}}
>
<Folder size={10} />
</Button>
{/if}
</div>
{:else if 'job_kind' in job && isScriptPreview(job.job_kind)}
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">Preview without path </a>
{:else if 'job_kind' in job && job.job_kind == 'dependencies'}
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">
lock deps of {truncateHash(job.script_hash ?? '')}
</a>
{:else if 'job_kind' in job && job.job_kind == 'identity'}
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">no op</a>
{/if}
</div>
</div>
{/if}
</div>
{#if job && job.parent_job}
{#if job.is_flow_step}
<div class="flex flex-row gap-1 items-center">
<BarsStaggered class="text-secondary" size={14} />
<span class="mx-1 text-xs">
Step of flow <a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 6)}
</a>
</span>
</div>
{:else}
<div class="flex flex-row gap-1 items-center">
<span class="text-2xs text-tertiary truncate">
parent <a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 10)}
</a>
</span>
</div>
{/if}
<!-- Job duration-->
<div class="text-2xs font-normal text-secondary pr-2">
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
{msToReadableTime(job.duration_ms, 2)}
{:else}
-
{/if}
</div>
{#if containsLabel}
<div class="w-3/12 flex justify-start px-0.5">
{#if job && job?.['labels']}
<div class="flex flex-row items-center gap-1 overflow-x-auto">
{#if Array.isArray(job?.['labels'])}
{#each job?.['labels'] as label}
<Button
variant="border"
size="xs3"
btnClasses={twMerge(
activeLabel == label ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'!text-2xs !font-normal truncate max-w-28'
)}
color="light"
on:click={() => {
dispatch('filterByLabel', label)
}}
endIcon={{ icon: ListFilter }}
>
{label}
</Button>
{/each}
<!-- Job path-->
<div class="flex justify-start flex-col pr-4">
{#if job === undefined}
No job found
{:else}
{@const JobKindIcon = getJobKindIcon(job.job_kind)}
<div class="flex flex-row gap-3 min-w-0 items-center h-full">
<Tooltip class="h-full">
<div class="relative">
{#if job && job.parent_job}
<span class="absolute -top-1 -right-1 text-xs text-blue-500">*</span>
{/if}
<JobKindIcon size={14} />
</div>
{#snippet text()}
<span>
{#if job && job.job_kind}
{job.job_kind}
{/if}
{#if job && job.is_flow_step && job.parent_job}
<br /> Step of flow
<a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 10)}
</a>
{:else if job && job.parent_job}
<br /> Parent
<a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 10)}
</a>
{/if}
</span>
{/snippet}
</Tooltip>
<div class="whitespace-nowrap text-xs text-secondary truncate">
{#if job.script_path}
<div class="flex flex-row gap-1 items-center">
{#if isExternal}
<span class="w-30 justify-center">-</span>
{:else}
<span class="truncate w-30" title={job.script_path}>
{job.script_path}
</span>
{/if}
{#if !isExternal || job.script_path?.startsWith('f/')}
{@const isFolder = job.script_path?.startsWith('f/')}
<DropdownV2
items={() => {
const items = isExternal
? []
: [
{
displayName: `Filter by path: ${job.script_path}`,
action: () => dispatch('filterByPath', job.script_path),
disabled: isExternal
}
]
if (isFolder) {
const folder = job.script_path?.split('/')[1]
return [
{
displayName: `Filter by folder: ${folder}`,
action: () => dispatch('filterByFolder', folder)
},
...items
]
}
return items
}}
class="w-fit"
>
{#snippet buttonReplacement()}
<div
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
>
<ListFilterPlus size={14} />
</div>
{/snippet}
</DropdownV2>
{/if}
</div>
{:else if 'job_kind' in job && isScriptPreview(job.job_kind)}
Preview without path
{:else if 'job_kind' in job && job.job_kind == 'dependencies'}
lock deps of {truncateHash(job.script_hash ?? '')}
{:else if 'job_kind' in job && job.job_kind == 'identity'}
no op
{:else if 'job_kind' in job && isFlowPreview(job.job_kind)}
Preview without path
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Labels-->
{#if containsLabel}
<div class="flex justify-start overflow-hidden" bind:clientWidth={labelWidth}>
<RunLabels
{job}
{activeLabel}
onFilterByLabel={(label) => dispatch('filterByLabel', label)}
{labelWidth}
/>
</div>
{/if}
<div class="w-3/12 flex justify-start">
<!-- Author and schedule-->
<div class="flex justify-start pr-4 text-secondary">
{#if job && job.schedule_path}
<div class="flex flex-row items-center gap-1">
<Calendar size={14} />
<div class="flex flex-row items-center gap-1 w-full -ml-2">
<Button
size="xs2"
color="light"
btnClasses="font-normal"
btnClasses="font-normal bg-transparent hover:bg-surface hover:text-primary"
on:click={() => scheduleEditor?.openEdit(job.schedule_path ?? '', job.job_kind == 'flow')}
>
<div class="truncate text-ellipsis text-left" title={job.schedule_path}>
{truncateRev(job.schedule_path, 20)}
</div>
<Calendar size={14} />
</Button>
<Button
size="xs2"
color="light"
on:click={() => {
dispatch('filterBySchedule', job.schedule_path)
}}
<div class="text-xs truncate text-ellipsis text-lef" dir="rtl" title={job.schedule_path}>
{job.schedule_path}
</div>
<DropdownV2
items={[
{
displayName: `Filter by schedule: ${truncateRev(job.schedule_path, 20)}`,
action: () => dispatch('filterBySchedule', job.schedule_path)
}
]}
class="w-fit"
>
<ListFilter size={10} />
</Button>
{#snippet buttonReplacement()}
<div
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
>
<ListFilterPlus size={14} />
</div>
{/snippet}
</DropdownV2>
</div>
{:else}
<div class="flex flex-row gap-1 items-center">
<div class="text-xs truncate text-ellipsis text-left" title={job.created_by}>
{truncateRev(job.created_by ?? '', 20)}
<div class="flex flex-row gap-1 items-center w-full">
<div class="text-xs truncate text-ellipsis text-left" dir="rtl" title={job.created_by}>
{job.created_by ?? ''}
</div>
{#if !isExternal}
<Button
size="xs2"
color="light"
on:click={() => {
dispatch('filterByUser', job.created_by ?? '')
}}
<DropdownV2
items={[
{
displayName: `Filter by triggered by: ${job.created_by}`,
action: () => dispatch('filterByUser', job.created_by ?? '')
}
]}
customWidth={256}
class="w-fit"
>
<ListFilter size={10} />
</Button>
{#snippet buttonReplacement()}
<div
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
>
<ListFilterPlus size={14} />
</div>
{/snippet}
</DropdownV2>
{/if}
</div>
{/if}
</div>
<!-- Job tag-->
{#if showTag}
<div class="flex justify-start gap-1">
{#if job.tag}
<span class="text-xs text-secondary truncate" title={job.tag}>{job.tag}</span>
{/if}
</div>
{/if}
<!-- Job link-->
{#if !isExternal}
<div class="flex justify-end pr-2">
<a
target="_blank"
href="{base}/run/{job.id}?workspace={job.workspace_id}"
class={twMerge(
'text-right float-right px-2',
selected
? 'text-blue-500 hover:text-primary'
: 'text-gray-300 dark:text-gray-500 hover:text-primary dark:hover:text-primary'
)}
title="See run detail in a new tab"
>
<ExternalLink size={14} />
</a>
</div>
{/if}
</div>
@@ -1,21 +1,36 @@
<script lang="ts" context="module">
<script lang="ts" module>
export type RunsSelectionMode = 'cancel' | 're-run'
</script>
<script lang="ts">
import { userStore, superadmin } from '$lib/stores'
import { X, Check, ChevronDown, Loader2 } from 'lucide-svelte'
import { X, Check, ChevronDown, Loader2, SquareMousePointer } from 'lucide-svelte'
import { Button } from '../common'
import DropdownV2 from '../DropdownV2.svelte'
export let isLoading = false
export let selectionCount: number
export let selectionMode: RunsSelectionMode | false
export let onSetSelectionMode: (mode: RunsSelectionMode | false) => void
export let onCancelSelectedJobs: () => void
export let onCancelFilteredJobs: () => void
export let onReRunSelectedJobs: () => void
export let onReRunFilteredJobs: () => void
interface Props {
isLoading?: boolean
selectionCount: number
selectionMode: RunsSelectionMode | false
small?: boolean
onSetSelectionMode: (mode: RunsSelectionMode | false) => void
onCancelSelectedJobs: () => void
onCancelFilteredJobs: () => void
onReRunSelectedJobs: () => void
onReRunFilteredJobs: () => void
}
let {
isLoading = false,
selectionCount,
selectionMode,
small = false,
onSetSelectionMode,
onCancelSelectedJobs,
onCancelFilteredJobs,
onReRunSelectedJobs,
onReRunFilteredJobs
}: Props = $props()
function jobCountString(count: number) {
return `${count} ${count == 1 ? 'job' : 'jobs'}`
@@ -27,7 +42,7 @@
<Loader2 class="animate-spin" size={20} />
</Button>
{:else if selectionMode}
<div class="mt-1 p-2 h-8 flex flex-row items-center gap-1">
<div class="h-8 flex flex-row items-center gap-1">
<Button
startIcon={{ icon: X }}
size="xs"
@@ -62,7 +77,7 @@
</div>
{:else}
<DropdownV2
class="w-fit mx-auto"
class="w-fit"
items={[
{
displayName: 'Select jobs to cancel',
@@ -80,13 +95,16 @@
: [])
]}
>
<svelte:fragment slot="buttonReplacement">
{#snippet buttonReplacement()}
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
class="px-2 h-[30px] border flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md gap-2"
>
<span class="text-xs min-w-[5rem]">Batch actions</span>
<ChevronDown class="w-5 h-5" />
<SquareMousePointer size={16} />
{#if !small}
<span class="text-xs min-w-[5rem]">Batch actions</span>
{/if}
<ChevronDown size={16} />
</div>
</svelte:fragment>
{/snippet}
</DropdownV2>
{/if}
+374 -243
View File
@@ -3,7 +3,7 @@
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import Tooltip from '../Tooltip.svelte'
import { AlertCircle, CheckCircle2, Filter, Hourglass, PlayCircle, X } from 'lucide-svelte'
import { CircleAlert, CircleCheck, Hourglass, ListFilterPlus, CirclePlay, X } from 'lucide-svelte'
import JsonEditor from '../JsonEditor.svelte'
import Toggle from '../Toggle.svelte'
import Label from '../Label.svelte'
@@ -14,6 +14,9 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
import RunOption from './RunOption.svelte'
import DropdownSelect from '../DropdownSelect.svelte'
import TooltipV2 from '$lib/components/meltComponents/Tooltip.svelte'
interface Props {
// Filters
@@ -48,6 +51,8 @@
| 'worker'
| 'tag'
| 'schedulePath'
small?: boolean
calendarSmall?: boolean
}
let {
@@ -72,7 +77,9 @@
usernames = [],
folders = [],
allWorkspaces = $bindable(false),
filterBy = $bindable('path')
filterBy = $bindable('path'),
small = false,
calendarSmall = false
}: Props = $props()
let copyArgFilter = $state(argFilter)
@@ -115,106 +122,153 @@
;(path || user || folder || label || worker || concurrencyKey || tag || schedulePath) &&
untrack(() => autosetFilter())
})
function resetFilter() {
path = null
user = null
folder = null
label = null
concurrencyKey = null
tag = null
schedulePath = undefined
worker = null
}
</script>
<div class="flex gap-4">
{#if !mobile}
<div class="flex gap-2">
{#if $workspaceStore == 'admins'}
<div class="relative">
<span class="text-xs absolute -top-4">Workspaces</span>
<ToggleButtonGroup
bind:selected={allWorkspacesValue}
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
>
{#snippet children({ item })}
<ToggleButton value={'admins'} label="Admins" {item} />
<ToggleButton value={'all'} label="All" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
{#snippet runsTooltip()}
<TooltipV2 placement="right">
{#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}
</TooltipV2>
{/snippet}
{#snippet previewsTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Previews are jobs that have been started in the editor as "Tests"'
{/snippet}
</TooltipV2>
{/snippet}
{#snippet dependenciesTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Deploying a script, flow or an app launch a dependency job that create and then attach the
lockfile to the deployed item. This mechanism ensure that logic is always executed with the
exact same direct and indirect dependencies.'
{/snippet}
</TooltipV2>
{/snippet}
{#snippet syncTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git
repository configured in the the workspace settings'
{/snippet}
</TooltipV2>
{/snippet}
<div class="relative">
<span class="text-xs absolute -top-4">Filter by</span>
<ToggleButtonGroup
bind:selected={filterBy}
on:selected={(e) => {
if (e.detail != filterBy) {
path = null
user = null
folder = null
label = null
concurrencyKey = null
tag = null
schedulePath = undefined
{#if !mobile}
{#if $workspaceStore == 'admins'}
<RunOption label="Workspaces" for="workspaces">
<ToggleButtonGroup
bind:selected={allWorkspacesValue}
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
>
{#snippet children({ item })}
<ToggleButton value={'admins'} label="Admins" {item} />
<ToggleButton value={'all'} label="All" {item} />
{/snippet}
</ToggleButtonGroup>
</RunOption>
{/if}
<!-- Filter by -->
<div class="flex flex-row gap-1">
<RunOption label="Filter by" for="filter-by">
<ToggleButtonGroup
bind:selected={filterBy}
on:selected={(e) => {
if (e.detail != filterBy) {
resetFilter()
}
}}
>
{#snippet children({ item })}
<ToggleButton value="path" label="Path" {item} />
<ToggleButton value="user" label="User" {item} />
<ToggleButton value="folder" label="Folder" {item} />
<ToggleButtonMore
togglableItems={[
{ label: 'Schedule path', value: 'schedulePath' },
{ label: 'Concurrency key', value: 'concurrencyKey' },
{ label: 'Label', value: 'label' },
{ label: 'Tag', value: 'tag' },
{ label: 'Worker', value: 'worker' }
]}
{item}
bind:selected={
() => filterBy,
(v) => {
resetFilter()
filterBy = v
}
}
}}
>
{#snippet children({ item })}
<ToggleButton value="path" label="Path" {item} />
<ToggleButton value="user" label="User" {item} />
<ToggleButton value="folder" label="Folder" {item} />
<ToggleButtonMore
togglableItems={[
{ label: 'Schedule path', value: 'schedulePath' },
{ label: 'Concurrency key', value: 'concurrencyKey' },
{ label: 'Label', value: 'label' },
{ label: 'Tag', value: 'tag' },
{ label: 'Worker', value: 'worker' }
]}
{item}
bind:selected={filterBy}
/>
{/snippet}
</ToggleButtonGroup>
</div>
/>
{/snippet}
</ToggleButtonGroup>
</RunOption>
{#if filterBy == 'user'}
{#key user}
<div class="relative">
<span class="text-xs absolute -top-4">User</span>
<Select
items={safeSelectItems(usernames)}
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
clearable
onClear={() => ((user = null), dispatch('reset'))}
inputClass="!h-[32px]"
onCreateItem={(item) => (usernames.push(item), (user = item))}
createText="Press enter to use this value"
/>
</div>
{/key}
{:else if filterBy == 'folder'}
{#if filterBy == 'user'}
{#key user}
<RunOption label="User" for="user">
<Select
items={safeSelectItems(usernames)}
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
clearable
onClear={() => ((user = null), dispatch('reset'))}
inputClass="!h-[32px] min-w-36"
onCreateItem={(item) => (usernames.push(item), (user = item))}
createText="Press enter to use this value"
id="user"
/>
</RunOption>
{/key}
{:else if filterBy == 'folder'}
<RunOption label="Folder" for="folder">
{#key folder}
<div class="relative">
<span class="text-xs absolute -top-4">Folder</span>
<Select
items={safeSelectItems(folders)}
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
clearable
onClear={() => ((folder = null), dispatch('reset'))}
inputClass="!h-[32px]"
/>
</div>
<Select
items={safeSelectItems(folders)}
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
clearable
onClear={() => ((folder = null), dispatch('reset'))}
inputClass="!h-[32px] min-w-36"
id="folder"
/>
{/key}
{:else if filterBy === 'path'}
</RunOption>
{:else if filterBy === 'path'}
<RunOption label="Path" for="path">
{#key path}
<div class="relative">
<span class="text-xs absolute -top-4">Path</span>
<Select
items={safeSelectItems(paths)}
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
clearable
onClear={() => ((path = null), dispatch('reset'))}
inputClass="!h-[32px]"
onCreateItem={(item) => (paths.push(item), (path = item))}
createText="Press enter to use this value"
/>
</div>
<Select
items={safeSelectItems(paths)}
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
clearable
onClear={() => ((path = null), dispatch('reset'))}
inputClass="!h-[32px] min-w-36"
onCreateItem={(item) => (paths.push(item), (path = item))}
createText="Press enter to use this value"
id="path"
/>
{/key}
{:else if filterBy === 'label'}
</RunOption>
{:else if filterBy === 'label'}
<RunOption label="Label" for="label">
{#snippet tooltip()}
<a
href="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#jobs-labels"
target="_blank">Job Labels</a
> are string values in the array at the result field 'wm_labels' to easily filter them.
{/snippet}
{#key label}
<div class="relative">
{#if label}
@@ -229,20 +283,11 @@
</button>
{/if}
<span class="text-xs absolute -top-4"
>Label <Tooltip
><a
href="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#jobs-labels"
target="_blank">Job Labels</a
> are string values in the array at the result field 'wm_labels' to easily filter them.</Tooltip
></span
>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedLabel}
onkeydown={(e) => {
if (labelTimeout) {
@@ -254,58 +299,59 @@
}, 1000)
}}
/>
<div class="absolute top-10">
<div class="absolute -top-4 right-0">
<Toggle
bind:checked={allowWildcards}
size="xs"
options={{ right: 'allow wildcards (*)' }}
size="2xs"
options={{ right: '(*)', title: 'allow wildcards (*)' }}
></Toggle>
</div>
</div>
{/key}
{:else if filterBy === 'concurrencyKey'}
</RunOption>
{:else if filterBy === 'concurrencyKey'}
<RunOption label="Concurrency Key" for="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}
<div class="relative">
{#if concurrencyKey}
<button
class="absolute top-2 right-2 z-50"
onclick={() => {
concurrencyKey = null
dispatch('reset')
}}
>
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"
>Concurrency Key <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}
</Tooltip></span
>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
bind:value={displayedConcurrencyKey}
onkeydown={(e) => {
if (concurrencyKeyTimeout) {
clearTimeout(concurrencyKeyTimeout)
}
concurrencyKeyTimeout = setTimeout(() => {
concurrencyKey = displayedConcurrencyKey
}, 1000)
{#if concurrencyKey}
<button
class="absolute top-2 right-2 z-50"
onclick={() => {
concurrencyKey = null
dispatch('reset')
}}
/>
</div>
>
<X size={14} />
</button>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedConcurrencyKey}
onkeydown={(e) => {
if (concurrencyKeyTimeout) {
clearTimeout(concurrencyKeyTimeout)
}
concurrencyKeyTimeout = setTimeout(() => {
concurrencyKey = displayedConcurrencyKey
}, 1000)
}}
id="concurrencyKey"
/>
{/key}
{:else if filterBy === 'tag'}
</RunOption>
{:else if filterBy === 'tag'}
<RunOption label="Tag" for="tag">
{#key tag}
<div class="relative">
{#if tag}
@@ -319,13 +365,12 @@
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"> Tag </span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedTag}
onkeydown={(e) => {
if (tagTimeout) {
@@ -336,17 +381,20 @@
tag = displayedTag
}, 1000)
}}
id="tag"
/>
<div class="absolute top-10">
<div class="absolute -top-4 right-0">
<Toggle
bind:checked={allowWildcards}
size="xs"
options={{ right: 'allow wildcards (*)' }}
size="2xs"
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
></Toggle>
</div>
</div>
{/key}
{:else if filterBy === 'schedulePath'}
</RunOption>
{:else if filterBy === 'schedulePath'}
<RunOption label="Schedule Path" for="schedulePath">
{#key tag}
<div class="relative">
{#if tag}
@@ -360,13 +408,12 @@
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"> Schedule Path </span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedSchedule}
onkeydown={(e) => {
if (tagTimeout) {
@@ -377,10 +424,13 @@
schedulePath = displayedSchedule
}, 1000)
}}
id="schedulePath"
/>
</div>
{/key}
{:else if filterBy === 'worker'}
</RunOption>
{:else if filterBy === 'worker'}
<RunOption label="Worker" for="worker">
{#key worker}
<div class="relative">
{#if worker}
@@ -394,13 +444,12 @@
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"> Worker </span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
class="!h-[32px] py-1 !text-xs min-w-36"
bind:value={displayedWorker}
onkeydown={(e) => {
if (workerTimeout) {
@@ -411,20 +460,70 @@
worker = displayedWorker
}, 1000)
}}
id="worker"
/>
<div class="absolute top-10">
<div class="absolute -top-4 right-0">
<Toggle
bind:checked={allowWildcards}
size="xs"
options={{ right: 'allow wildcards (*)' }}
size="2xs"
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
></Toggle>
</div>
</div>
{/key}
{/if}
</div>
<div class="relative">
<span class="text-xs absolute -top-4">Kind</span>
</RunOption>
{/if}
</div>
<!-- Kind -->
<RunOption label="Kind" for="kind">
{#if small && !calendarSmall}
<DropdownSelect
btnClasses="min-w-24"
items={[
{
displayName: 'All',
action: () => {
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}
<ToggleButtonGroup bind:selected={jobKindsCat}>
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
@@ -435,13 +534,6 @@
tooltip="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"
{item}
/>
<ToggleButton
value="previews"
label="Previews"
showTooltipIcon
tooltip="Previews are jobs that have been started in the editor as 'Tests'"
{item}
/>
<ToggleButton
value="dependencies"
label="Deps"
@@ -449,75 +541,94 @@
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
{item}
/>
<ToggleButton
value="deploymentcallbacks"
label="Sync"
showTooltipIcon
tooltip="Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings"
<ToggleButtonMore
togglableItems={[
{
label: 'Previews',
value: 'previews',
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
},
{
label: 'Sync',
value: 'deploymentcallbacks',
tooltip:
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
}
]}
{item}
bind:selected={
() => jobKindsCat,
(v) => {
resetFilter()
jobKindsCat = v
}
}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<div class="relative">
<span class="text-xs absolute -top-4">Status</span>
<ToggleButtonGroup
selected={success ?? 'all'}
on:selected={({ detail }) => {
success = detail === 'all' ? undefined : detail
dispatch('successChange', success)
}}
>
{#snippet children({ item })}
<ToggleButton value={'all'} label="All" {item} />
{/if}
</RunOption>
<!-- Status -->
<RunOption label="Status" for="status">
<ToggleButtonGroup
selected={success ?? 'all'}
on:selected={({ detail }) => {
success = detail === 'all' ? undefined : detail
dispatch('successChange', success)
}}
id="status"
>
{#snippet children({ item })}
<ToggleButton value={'all'} label="All" {item} />
<ToggleButton
value={'running'}
tooltip="Running"
class="whitespace-nowrap"
icon={CirclePlay}
iconProps={{ color: success === 'running' ? 'blue' : 'gray' }}
{item}
/>
<ToggleButton
value={'success'}
tooltip="Success"
class="whitespace-nowrap"
icon={CircleCheck}
iconProps={{ color: success === 'success' ? 'green' : 'gray' }}
{item}
/>
<ToggleButton
value={'failure'}
tooltip="Failure"
class="whitespace-nowrap"
icon={CircleAlert}
iconProps={{ color: success === 'failure' ? 'red' : 'gray' }}
{item}
/>
{#if success == 'waiting'}
<ToggleButton
value={'running'}
tooltip="Running"
value={'waiting'}
tooltip="Waiting"
class="whitespace-nowrap"
icon={PlayCircle}
iconProps={{ color: success === 'running' ? 'blue' : 'gray' }}
icon={Hourglass}
iconProps={{ color: 'blue' }}
{item}
/>
{:else if success == 'suspended'}
<ToggleButton
value={'success'}
tooltip="Success"
value={'suspended'}
tooltip="Suspended"
class="whitespace-nowrap"
icon={CheckCircle2}
iconProps={{ color: success === 'success' ? 'green' : 'gray' }}
icon={Hourglass}
iconProps={{ color: 'blue' }}
{item}
/>
<ToggleButton
value={'failure'}
tooltip="Failure"
class="whitespace-nowrap"
icon={AlertCircle}
iconProps={{ color: success === 'failure' ? 'red' : 'gray' }}
{item}
/>
{#if success == 'waiting'}
<ToggleButton
value={'waiting'}
tooltip="Waiting"
class="whitespace-nowrap"
icon={Hourglass}
iconProps={{ color: 'blue' }}
{item}
/>
{:else if success == 'suspended'}
<ToggleButton
value={'suspended'}
tooltip="Suspended"
class="whitespace-nowrap"
icon={Hourglass}
iconProps={{ color: 'blue' }}
{item}
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
{/if}
{/snippet}
</ToggleButtonGroup>
</RunOption>
{/if}
<RunOption label="_" for="more-filters" noLabel>
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
contentClasses="p-4"
@@ -525,15 +636,33 @@
usePointerDownOutside
>
{#snippet trigger()}
<Button color="dark" size="xs" nonCaptureEvent={true} startIcon={{ icon: Filter }}>
More filters
</Button>
<Button
color="light"
variant="border"
size="xs"
nonCaptureEvent={true}
startIcon={{ icon: ListFilterPlus }}
iconOnly
></Button>
{/snippet}
{#snippet content()}
<Section label="Filters">
<div class="w-102 flex flex-col gap-4">
{#if mobile || true}
{#if mobile}
{#if $workspaceStore == 'admins'}
<Label label="Workspaces">
<ToggleButtonGroup
bind:selected={allWorkspacesValue}
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
>
{#snippet children({ item })}
<ToggleButton value={'admins'} label="Admins" {item} />
<ToggleButton value={'all'} label="All" {item} />
{/snippet}
</ToggleButtonGroup>
</Label>
{/if}
<Label label="Filter by">
<ToggleButtonGroup
bind:selected={filterBy}
@@ -739,8 +868,8 @@
{#if filterBy === 'tag' || filterBy === 'label' || filterBy === 'worker'}
<Toggle
bind:checked={allowWildcards}
size="xs"
options={{ right: 'allow wildcards (*)' }}
size="2xs"
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
></Toggle>
{/if}
<Label label="Kind">
@@ -816,15 +945,17 @@
</div>
</Label>
<span class="text-xs leading-6">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<Label label="Filter by args">
<JsonEditor bind:error={argError} bind:code={copyArgFilter} />
</Label>
<Label label="Filter by result">
<JsonEditor bind:error={resultError} bind:code={copyResultFilter} />
</Label>
<div class="flex flex-col gap-1">
<span class="text-xs leading-6">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<Label label="Filter by args">
<JsonEditor bind:error={argError} bind:code={copyArgFilter} />
</Label>
<Label label="Filter by result">
<JsonEditor bind:error={resultError} bind:code={copyResultFilter} />
</Label>
</div>
<div class="flex flex-row gap-2 justify-between">
<Button
@@ -853,4 +984,4 @@
</Section>
{/snippet}
</Popover>
</div>
</RunOption>
+111 -25
View File
@@ -1,36 +1,122 @@
<script lang="ts">
import type { Tweened } from 'svelte/motion'
import { createEventDispatcher } from 'svelte'
import { Button } from '../common'
import { FilterX, ListFilter } from 'lucide-svelte'
import Tooltip from '../Tooltip.svelte'
import { Bot, Hourglass, ListFilterPlus, X } from 'lucide-svelte'
import RunOption from './RunOption.svelte'
import { Popover } from '../meltComponents'
export let queue_count: Tweened<number> | undefined = undefined
export let suspended_count: Tweened<number> | undefined = undefined
export let success: string | undefined
const dispatch = createEventDispatcher()
interface Props {
queue_count?: Tweened<number> | undefined
suspended_count?: Tweened<number> | undefined
success: string | undefined
small?: boolean
onJobsWaiting?: () => void
onJobsSuspended?: () => void
}
let {
queue_count = undefined,
suspended_count = undefined,
success,
small = false,
onJobsWaiting,
onJobsSuspended
}: Props = $props()
</script>
<div class="flex gap-1 relative max-w-36 min-w-[50px] items-baseline">
<div class="text-xs absolute -top-4 truncate flex items-baseline gap-1">Waiting for workers <Tooltip small>Jobs waiting for a worker being available to be executed</Tooltip></div>
<div class="mt-1">{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}</div>
<div class="truncate text-2xs !text-secondary mt-0.5">
{#if queue_count && ($queue_count ?? 0) > 0}
<Button size="xs2" color="light" on:click={() => dispatch('jobs_waiting')}>{#if success == 'waiting'}<FilterX size={12}></FilterX>{:else}<ListFilter size={12}></ListFilter>{/if}</Button>
{/if}
<div class="flex flex-row gap-4 items-center">
{#if small}
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
{#snippet trigger()}
<div class="relative">
<Bot size={16} />
{#if queue_count && ($queue_count ?? 0) > 0}
<div
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 bg-yellow-500 rounded-full text-white text-xs h-4 w-4"
>
{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}
</div>
{/if}
</div>
{/snippet}
</div>
{#snippet content()}
{@render queuedContent()}
{/snippet}
</Popover>
{:else}
{@render queuedContent()}
{/if}
{#if small}
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
{#snippet trigger()}
<div class="relative">
<Hourglass size={16} />
<div
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 bg-surface-secondary-inverse rounded-full text-primary-inverse text-xs h-4 w-4"
>
{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}
</div>
</div>
{/snippet}
{#snippet content()}
{@render suspendedContent()}
{/snippet}
</Popover>
{:else}
{@render suspendedContent()}
{/if}
</div>
{#if suspended_count && ($suspended_count ?? 0) > 0}
<div class="flex gap-1 relative max-w-36 min-w-[50px] items-baseline ml-20 mr-8">
<div class="text-xs absolute -top-4 truncate flex items-baseline gap-1">Suspended <Tooltip small>Jobs waiting for an event or approval before being resumed</Tooltip></div>
<div class="mt-1">{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}</div>
{#snippet queuedContent()}
<RunOption label="Waiting for workers">
{#snippet tooltip()}
Jobs waiting for a worker being available to be executed
{/snippet}
<div
class={queue_count && ($queue_count ?? 0) > 0
? 'bg-yellow-500 text-white rounded-full w-6 h-6 flex center-center'
: ''}>{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}</div
>
<div class="truncate text-2xs !text-secondary mt-0.5">
<Button size="xs2" color="light" on:click={() => dispatch('jobs_suspended')}>{#if success == 'waiting'}<FilterX size={12}></FilterX>{:else}<ListFilter size={12}></ListFilter>{/if}</Button>
<Button size="xs2" color="light" on:click={() => onJobsWaiting?.()}>
{#if success == 'waiting'}
<div class="flex flex-row items-center gap-1">
Reset filter
<X size={12} />
</div>
{:else}
<ListFilterPlus size={14} />
{/if}
</Button>
</div>
</div>
{/if}
</RunOption>
{/snippet}
{#snippet suspendedContent()}
{#if suspended_count && ($suspended_count ?? 0) > 0}
<RunOption label="Suspended">
{#snippet tooltip()}
Jobs waiting for an event or approval before being resumed
{/snippet}
<div
class={suspended_count && ($suspended_count ?? 0) > 0
? 'bg-surface-secondary-inverse text-primary-inverse rounded-full w-6 h-6 flex center-center'
: ''}>{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}</div
>
<div class="truncate text-2xs !text-secondary">
<Button size="xs2" color="light" on:click={() => onJobsSuspended?.()}>
{#if success == 'suspended'}
<div class="flex flex-row items-center gap-1">
Reset filter
<X size={12} />
</div>
{:else}
<ListFilterPlus size={14} />
{/if}
</Button>
</div>
</RunOption>
{/if}
{/snippet}
@@ -1,7 +1,4 @@
<script lang="ts">
import { createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
import type { Job } from '$lib/gen'
import RunRow from './RunRow.svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
@@ -10,9 +7,8 @@
import { AlertTriangle } from 'lucide-svelte'
import Popover from '../Popover.svelte'
import { workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { isJobSelectable } from '$lib/utils'
import type { RunsSelectionMode } from './RunsBatchActionsDropdown.svelte'
import './runs-grid.css'
interface Props {
//import InfiniteLoading from 'svelte-infinite-loading'
@@ -138,25 +134,6 @@
}
*/
let selectableJobCount = $derived.by(() => {
if (!selectionMode) return 0
return jobs?.filter(isJobSelectable(selectionMode)).length ?? 0
})
let allSelected = $derived.by(() => {
return selectionMode && selectedIds.length === selectableJobCount
})
function selectAll() {
if (!selectionMode) return
if (allSelected) {
allSelected = false
selectedIds = []
} else {
allSelected = true
selectedIds = jobs?.filter(isJobSelectable(selectionMode)).map((j) => j.id) ?? []
}
}
function jobCountString(jobCount: number | undefined, lastFetchWentToEnd: boolean): string {
if (jobCount === undefined) {
return ''
@@ -213,64 +190,67 @@
}
return nstickyIndices
})
const showTag = $derived(containerWidth > 700)
</script>
<svelte:window onresize={() => computeHeight()} />
<div
class="divide-y min-w-[640px] h-full"
class="divide-y h-full border min-w-[650px]"
id="runs-table-wrapper"
bind:clientWidth={containerWidth}
>
<div bind:clientHeight={headerHeight}>
{#if selectionMode && selectableJobCount}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'hover:bg-surface-hover bg-surface-primary cursor-pointer',
allSelected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'flex flex-row items-center sticky w-full p-2 pr-4 top-0 font-semibold border-t text-sm'
)}
onclick={selectAll}
>
<div class="px-2">
<input onfocus={bubble('focus')} type="checkbox" checked={allSelected} />
</div>
Select all
</div>
{/if}
<div class="flex flex-row bg-surface-secondary sticky top-0 w-full p-2 pr-4">
{#if showExternalJobs && externalJobs.length > 0}
<div class="w-1/12 text-2xs">
<div
class="grid bg-surface-secondary sticky top-0 w-full py-2 pr-4"
class:grid-runs-table={!containsLabel && !selectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !selectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && selectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && selectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !selectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !selectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && selectionMode && !showTag}
class:grid-runs-table-with-labels-selection-no-tag={containsLabel &&
selectionMode &&
!showTag}
>
{#if selectionMode}
<div class="text-xs font-semibold pl-4"></div>
{/if}
<div class="text-2xs px-2 flex flex-row items-center gap-2">
{#if showExternalJobs && externalJobs.length > 0}
<div class="flex flex-row">
{jobs
? jobCountString(jobs.length + externalJobs.length, lastFetchWentToEnd)
: ''}<Tooltip>{externalJobs.length} jobs obscured</Tooltip>
</div>
</div>
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
<div class="w-1/12 text-2xs flex flex-row">
{: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}
{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}
<div class="w-1/12 text-2xs"
>{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}</div
>
{/if}
<div class="w-4/12 text-xs font-semibold"></div>
<div class="w-4/12 text-xs font-semibold">Path</div>
{/if}
</div>
<div class="text-xs font-semibold">Started</div>
<div class="text-xs font-semibold">Duration</div>
<div class="text-xs font-semibold">Path</div>
{#if containsLabel}
<div class="w-3/12 text-xs font-semibold">Label</div>
<div class="text-xs font-semibold">Label</div>
{/if}
<div class="w-3/12 text-xs font-semibold">Triggered by</div>
<div class="text-xs font-semibold">Triggered by</div>
{#if showTag}
<div class="text-xs font-semibold">Tag</div>
{/if}
<div class=""></div>
</div>
</div>
{#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
@@ -294,13 +274,16 @@
{#if jobOrDate}
{#if jobOrDate?.type === 'date'}
<div class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-5">
<div
class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-2 h-[42px] flex items-center"
>
{jobOrDate.date}
</div>
{:else}
<div class="flex flex-row items-center h-full w-full">
<RunRow
{containsLabel}
{showTag}
job={jobOrDate.job}
selected={jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
{selectionMode}
@@ -314,9 +297,18 @@
selectedIds = selectedIds
}
} else {
selectedWorkspace = jobOrDate.job.workspace_id
selectedIds = [jobOrDate.job.id]
dispatch('select')
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}
@@ -0,0 +1,95 @@
/* Shared CSS Grid Styles for RunsTable and RunRow components */
/* Grid layouts with tag column */
.grid-runs-table {
grid-template-columns:
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(200px, 2.3fr) /* Path (~33% reduced for wider tag) */
minmax(150px, 1.8fr) /* Triggered by (~28% reduced for wider tag) */
minmax(70px, 0.5fr) /* Tag (~7% wider) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-with-labels {
grid-template-columns:
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(150px, 1.6fr) /* Path (~25% reduced for labels and wider tag) */
minmax(120px, 1.3fr) /* Labels (~22% reduced for wider tag) */
minmax(110px, 1.3fr) /* Triggered by (~22% reduced for wider tag) */
minmax(70px, 0.5fr) /* Tag (~7% wider) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-selection {
grid-template-columns:
50px /* Selection checkbox (fixed width) */
80px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(200px, 2.3fr) /* Path (~33% reduced for wider tag) */
minmax(150px, 1.8fr) /* Triggered by (~28% reduced for wider tag) */
minmax(70px, 0.5fr) /* Tag (~7% wider) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-with-labels-selection {
grid-template-columns:
50px /* Selection checkbox (fixed width) */
80px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(150px, 1.6fr) /* Path (~25% reduced for labels and wider tag) */
minmax(120px, 1.3fr) /* Labels (~22% reduced for wider tag) */
minmax(110px, 1.3fr) /* Triggered by (~22% reduced for wider tag) */
minmax(70px, 0.5fr) /* Tag (~7% wider) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
/* Grid layouts without tag column */
.grid-runs-table-no-tag {
grid-template-columns:
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(200px, 2.8fr) /* Path (~38% expanded without tag) */
minmax(150px, 2.2fr) /* Triggered by (~33% expanded without tag) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-with-labels-no-tag {
grid-template-columns:
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(150px, 1.8fr) /* Path (~30% expanded without tag) */
minmax(120px, 1.8fr) /* Labels (~30% expanded without tag) */
minmax(110px, 1.8fr) /* Triggered by (~30% expanded without tag) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-selection-no-tag {
grid-template-columns:
40px /* Selection checkbox (fixed width) */
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(200px, 2.8fr) /* Path (~38% expanded without tag) */
minmax(150px, 2.2fr) /* Triggered by (~33% expanded without tag) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
.grid-runs-table-with-labels-selection-no-tag {
grid-template-columns:
50px /* Selection checkbox (fixed width) */
60px /* Status (fixed width) */
minmax(100px, 0.8fr) /* Started time (~10%) */
minmax(60px, 0.5fr) /* Duration (~7%) */
minmax(150px, 1.8fr) /* Path (~30% expanded without tag) */
minmax(120px, 1.8fr) /* Labels (~30% expanded without tag) */
minmax(110px, 1.8fr) /* Triggered by (~30% expanded without tag) */
minmax(40px, 0.3fr); /* Actions (~5%) */
}
@@ -28,6 +28,7 @@
createText,
noItemsMsg,
open = $bindable(false),
id,
groupBy,
sortBy,
onFocus,
@@ -53,6 +54,7 @@
createText?: string
noItemsMsg?: string
open?: boolean
id?: string
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onFocus?: () => void
@@ -137,6 +139,7 @@
autocomplete="off"
onpointerdown={() => (open = true)}
bind:this={inputEl}
{id}
/>
<SelectDropdown
{disablePortal}
+3 -2
View File
@@ -229,7 +229,7 @@ export function removeTriggerKindIfUnused(
return usedTriggerKinds
}
export function msToReadableTime(ms: number | undefined): string {
export function msToReadableTime(ms: number | undefined, maximumFractionDigits?: number): string {
if (ms === undefined) return '?'
const seconds = Math.floor(ms / 1000)
@@ -244,7 +244,7 @@ export function msToReadableTime(ms: number | undefined): string {
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
} else {
return `${msToSec(ms)}s`
return `${msToSec(ms, maximumFractionDigits)}s`
}
}
@@ -1307,6 +1307,7 @@ export type Item = {
type?: 'action' | 'delete'
hide?: boolean | undefined
extra?: Snippet
id?: string
}
export function isObjectTooBig(obj: any): boolean {
@@ -22,9 +22,7 @@
encodeState,
isFlowPreview,
isNotFlow,
isScriptPreview,
truncateHash,
truncateRev
isScriptPreview
} from '$lib/utils'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
@@ -82,20 +80,21 @@
import { json } from 'svelte-highlight/languages'
import Toggle from '$lib/components/Toggle.svelte'
import WorkflowTimeline from '$lib/components/WorkflowTimeline.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
import ExecutionDuration from '$lib/components/ExecutionDuration.svelte'
import CustomPopover from '$lib/components/CustomPopover.svelte'
import { isWindmillTooBigObject } from '$lib/components/job_args'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import { setContext, untrack } from 'svelte'
import WorkerHostname from '$lib/components/WorkerHostname.svelte'
import FlowAssetsHandler, {
initFlowGraphAssetsCtx
} from '$lib/components/flows/FlowAssetsHandler.svelte'
import JobAssetsViewer from '$lib/components/assets/JobAssetsViewer.svelte'
import { page } from '$app/state'
import RunBadges from '$lib/components/runs/RunBadges.svelte'
let job: (Job & { result?: any; result_stream?: string }) | undefined = $state()
let jobUpdateLastFetch: Date | undefined = $state()
@@ -764,96 +763,14 @@
{/if}
{job.script_path ?? (job.job_kind == 'dependencies' ? 'lock dependencies' : 'No path')}
<div class="flex flex-row gap-2 items-center flex-wrap">
{#if job.script_hash && job.job_kind !== 'aiagent'}
{#if job.job_kind == 'script'}
<a href="{base}/scripts/get/{job.script_hash}?workspace={$workspaceStore}"
><Badge color="gray">{truncateHash(job.script_hash)}</Badge></a
>
{:else}
<div>
<Badge color="gray">{truncateHash(job.script_hash)}</Badge>
</div>
{/if}
{/if}
{#if job && 'job_kind' in job}
<div>
<Badge color="blue">{job.job_kind}</Badge>
</div>
{/if}
{#if job && job.flow_status && job.job_kind === 'script'}
<PreprocessedArgsDisplay preprocessed={job.preprocessed} />
{/if}
{#if persistentScriptDefinition}
<button onclick={() => persistentScriptDrawer?.open?.(persistentScriptDefinition)}
><Badge color="red">persistent</Badge></button
>
{/if}
{#if job && 'priority' in job}
<div>
<Badge color="blue">priority: {job.priority}</Badge>
</div>
{/if}
{#if job.tag && !['deno', 'python3', 'flow', 'other', 'go', 'postgresql', 'mysql', 'bigquery', 'snowflake', 'mssql', 'graphql', 'oracledb', 'nativets', 'bash', 'powershell', 'php', 'rust', 'other', 'ansible', 'csharp', 'nu', 'java', 'duckdb', 'dependency', 'ruby'].includes(job.tag)}
<!-- for related places search: ADD_NEW_LANG -->
<div>
<Badge color="indigo">Tag: {job.tag}</Badge>
</div>
{/if}
{#if !job.visible_to_owner}
<div>
<Badge color="red">
only visible to you
<Tooltip>
{#snippet text()}
The option to hide this run from the owner of this script or flow was
activated
{/snippet}
</Tooltip>
</Badge>
</div>
{/if}
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
{#each job?.['labels'] as label}
<div>
<Badge>Label: {label}</Badge>
</div>
{/each}
{/if}
{#if concurrencyKey}
<div>
<Tooltip notClickable>
{#snippet text()}
This job has concurrency limits enabled with the key
<a
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
>
{concurrencyKey}
</a>
{/snippet}
<a
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
>
<Badge>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge></a
>
</Tooltip>
</div>
{/if}
{#if job?.worker}
<div>
<Tooltip notClickable>
{#snippet text()}
worker:
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
{job?.worker}
</a><br />
<WorkerHostname worker={job?.worker!} minTs={job?.['created_at']} />
{/snippet}
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
<Badge>Worker: {truncateRev(job?.worker, 20)}</Badge></a
>
</Tooltip>
</div>
{/if}
<RunBadges
{job}
displayPersistentScriptDefinition={!!persistentScriptDefinition}
openPersistentScriptDrawer={() => {
persistentScriptDrawer?.open?.(persistentScriptDefinition)
}}
{concurrencyKey}
/>
</div>
{/if}
</div>
@@ -973,7 +890,7 @@
<HighlightCode lines language={job.language} code={job.raw_code} />
</div>
{:else if job}
No code is available
<span class="text-sm">No code available</span>
{:else}
<Skeleton layout={[[5]]} />
{/if}
@@ -981,7 +898,7 @@
<div class="w-full">
<MemoryFootprintViewer jobId={job.id} bind:jobUpdateLastFetch />
</div>
{:else if job !== undefined && (job.result || job.result_stream)}
{:else if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined))}
<DisplayResult
workspaceId={job?.workspace_id}
result_stream={job.result_stream}
File diff suppressed because it is too large Load Diff