Files
windmill/frontend/src/lib/components/ConcurrentJobsChart.svelte
Guilhem 768c600494 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
2025-09-01 09:49:15 +00:00

265 lines
6.4 KiB
Svelte

<script lang="ts">
import 'chartjs-adapter-date-fns'
import zoomPlugin from 'chartjs-plugin-zoom'
import {
Chart as ChartJS,
CategoryScale,
Legend,
LineElement,
LinearScale,
PointElement,
TimeScale,
Title,
Tooltip
} from 'chart.js'
import type { CompletedJob, ExtendedJobs } from '$lib/gen'
import { getDbClockNow } from '$lib/forLater'
import { Line } from '$lib/components/chartjs-wrappers/chartJs'
interface Props {
extendedJobs?: ExtendedJobs | undefined
maxIsNow?: boolean
minTimeSet?: string | undefined
maxTimeSet?: string | undefined
onZoom: (zoom: { min: Date; max: Date }) => void
}
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[] }>()
extendedJobs.jobs.forEach((j) => {
if (j.started_at != undefined) {
const startTime = new Date(j.started_at).getTime()
if (!timeline.has(startTime)) {
timeline.set(startTime, { count: 0, id_started: [], id_ended: [] })
}
const s = timeline.get(startTime)!
s.count += 1
s.id_started.push(j.id)
if (j.type === 'CompletedJob') {
const jc = j as CompletedJob
const endTime = startTime + jc.duration_ms
if (!timeline.has(endTime)) {
timeline.set(endTime, { count: 0, id_started: [], id_ended: [] })
}
const e = timeline.get(endTime)!
e.count -= 1
e.id_ended.push(j.id)
}
}
})
extendedJobs.obscured_jobs.forEach((j) => {
if (j.started_at != undefined) {
const startTime = new Date(j.started_at).getTime()
if (!timeline.has(startTime)) {
timeline.set(startTime, { count: 0, id_started: [], id_ended: [] })
}
const s = timeline.get(startTime)!
s.count += 1
s.id_started.push('unknown')
if (j.duration_ms != undefined) {
const jc = j as CompletedJob
const endTime = startTime + jc.duration_ms
if (!timeline.has(endTime)) {
timeline.set(endTime, { count: 0, id_started: [], id_ended: [] })
}
const e = timeline.get(endTime)!
e.count -= 1
e.id_ended.push('unknown')
}
}
})
let count = 0
const result: AggregatedInterval[] = []
for (const [time, change] of [...timeline.entries()].sort(
([time1], [time2]) => time1 - time2
)) {
count += change.count
let msg = ''
msg += change.id_started.length != 0 ? `${change.id_started.join(',')} started` : ''
msg += change.id_started.length != 0 && change.id_ended.length != 0 ? '\n' : ''
msg += change.id_ended.length != 0 ? `${change.id_ended.join(',')} ended` : ''
result.push({ time: new Date(time), count, msg } as AggregatedInterval)
}
// Add points to continue the line towards the extremities
if (result.length > 0) {
let start_time = addSeconds(new Date(result[0].time), -1)
let start_count = 0
let end_count = result[result.length - 1].count
result.unshift({
time: start_time,
count: start_count
} as AggregatedInterval)
result.push({
time: new Date(),
count: end_count
} as AggregatedInterval)
}
return result
}
type AggregatedInterval = { time: Date; count: number; msg?: string }
ChartJS.register(
Title,
Tooltip,
Legend,
zoomPlugin,
LineElement,
CategoryScale,
LinearScale,
PointElement,
TimeScale
)
const zoomOptions = {
pan: {
enabled: true,
modifierKey: 'ctrl' as 'ctrl',
onPanComplete: ({ chart }) => {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
}
},
zoom: {
drag: {
enabled: true
},
mode: 'x' as 'x',
onZoom: ({ chart }) => {
onZoom({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
}
}
}
function minJobTime(intervals: AggregatedInterval[]): Date {
return intervals[0].time
}
function maxJobTime(intervals: AggregatedInterval[]): Date {
return intervals[intervals?.length - 1].time
}
function computeMinMaxTime(
intervals: AggregatedInterval[] | undefined,
minTimeSet: string | undefined,
maxTimeSet: string | undefined
) {
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
if (minTimeSetDate && maxTimeSetDate) {
return { min: minTimeSetDate, max: maxTimeSetDate }
}
if (intervals == undefined || intervals?.length == 0) {
const minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
const maxTime = maxTimeSetDate ?? getDbClockNow()
return { min: minTime, max: maxTime }
}
const maxJob = maxIsNow ? getDbClockNow() : maxJobTime(intervals)
const minJob = minJobTime(intervals)
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
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 {
date.setTime(date.getTime() + seconds * 1000)
return date
}
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: {
zoom: zoomOptions,
legend: {
display: false
},
tooltip: {
callbacks: {
footer: function (context) {
return context[context.length - 1].raw.id
}
}
}
},
scales: {
x: {
type: 'time',
grid: {
display: false
},
min: minMaxTimes.min,
max: minMaxTimes.max
},
y: {
grid: {
display: false
},
title: {
display: true,
text: 'concurrent jobs'
},
beginAtZero: true,
ticks: {
stepSize: 1
}
}
},
animation: false,
interaction: {
intersect: false,
mode: 'index'
}
} as any)
</script>
<div class="relative max-h-40">
<Line {data} {options} />
</div>