Files
windmill/frontend/src/lib/components/ConcurrentJobsChart.svelte
T
Ruben FiszelandClaude Opus 5 a350f7c68e feat: show the date on the runs dashboard chart axes (#10808)
* test: assert the unpacked repo symlink without following it

`unpack_keeps_a_link_that_stays_in_the_repo` read through the link it had just
unpacked. Windows stores a symlink's target verbatim and its object manager
rejects the `/` in a POSIX one, so `read_to_string` came back with
`ERROR_INVALID_NAME` and the release's `cargo_test_windows` job was red.

Pin what the function is responsible for on every platform — the link is kept
and materialized — and read through it only where a POSIX relative target
resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx

* test: key the cli sync-map fixtures with the platform separator

A sync map is keyed with the platform separator on both sides — `FSFSElement`
walks the tree with `path.join`, and the remote `ZipFSElement` starts at
`"." + SEP` and joins from there — while an `!inline` reference is always
forward-slash. `lock_dedup.ts` follows that convention; the fixtures did not,
so on Windows they built a map shape the CLI never produces and 12 of them
failed. `getTypeStrFromPath` is the same story: it matches
`"dependencies" + SEP`, and the test handed it a forward-slashed path.

Build the fixture keys through the separator, leaving the `!inline` references
and the `present` map forward-slash, as `sync.ts` hands them over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx

* ci: skip the discord comment relay when the thread lookup returns none

A rate-limited or unauthorized Discord response carries no thread list, and
under `bash -e` that aborted the step — jq cannot iterate null, nor parse the
HTML error page Cloudflare answers a 429 with — before it reached the "thread
not found, skipping" branch right below. Three comment relays failed that way
on the 1.794.0 head.

Keep the step green for both, but tell them apart: a response with no thread
list is a delivery that was dropped for a reason worth seeing, so it warns with
the body it got, while a PR that genuinely has no thread stays quiet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx

* feat: show the date on the runs dashboard chart axes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UA8Kikr1QD28g2fyWoSbj

* fix: keep the runs chart date visible on sub-day ranges

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UA8Kikr1QD28g2fyWoSbj

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 09:43:29 +00:00

267 lines
6.5 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'
import { timeTicksWithDate } from '$lib/components/chartjs-wrappers/timeTicks'
interface Props {
extendedJobs?: ExtendedJobs | undefined
maxIsNow?: boolean
minTimeSet?: string | null
maxTimeSet?: string | null
onZoom: (zoom: { min: Date; max: Date }) => void
}
let {
extendedJobs = undefined,
maxIsNow = false,
minTimeSet = null,
maxTimeSet = null,
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 | null,
maxTimeSet: string | null
) {
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,
ticks: timeTicksWithDate(minMaxTimes.min, 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 h-44">
<Line {data} {options} />
</div>