@@ -25,9 +25,9 @@ import {
2525 LineElement ,
2626 BarElement ,
2727 Filler ,
28+ Legend ,
2829 Tooltip ,
2930} from "chart.js" ;
30- import type { PartialEventContext } from "chartjs-plugin-annotation" ;
3131import annotationPlugin from "chartjs-plugin-annotation" ;
3232import dayjs from "dayjs" ;
3333import { Bar } from "react-chartjs-2" ;
@@ -37,8 +37,15 @@ import { useNavigate } from "react-router-dom";
3737import type { TaskInstanceResponse , GridRunsResponse } from "openapi/requests/types.gen" ;
3838import { useTimezone } from "src/context/timezone" ;
3939import { getComputedCSSVariableValue } from "src/theme" ;
40- import { DEFAULT_DATETIME_FORMAT , formatDate , renderDuration } from "src/utils/datetimeUtils" ;
40+ import {
41+ DEFAULT_DATETIME_FORMAT ,
42+ formatDate ,
43+ getDurationTickStep ,
44+ renderCompactDuration ,
45+ renderDuration ,
46+ } from "src/utils/datetimeUtils" ;
4147import { buildTaskInstanceUrl } from "src/utils/links" ;
48+ import { median } from "src/utils/median" ;
4249
4350ChartJS . register (
4451 CategoryScale ,
@@ -47,15 +54,12 @@ ChartJS.register(
4754 BarElement ,
4855 LineElement ,
4956 Filler ,
57+ Legend ,
5058 Tooltip ,
5159 annotationPlugin ,
5260) ;
5361
54- const average = ( ctx : PartialEventContext , index : number ) => {
55- const values : Array < number > | undefined = ctx . chart . data . datasets [ index ] ?. data as Array < number > | undefined ;
56-
57- return values === undefined ? 0 : values . reduce ( ( initial , next ) => initial + next , 0 ) / values . length ;
58- } ;
62+ const CHART_HEIGHT = "280px" ;
5963
6064type RunResponse = GridRunsResponse | TaskInstanceResponse ;
6165
@@ -70,6 +74,24 @@ const getDuration = (start: string, end: string | null) => {
7074 return dayjs . duration ( endDate . diff ( startDate ) ) . asSeconds ( ) ;
7175} ;
7276
77+ const getQueuedDuration = ( entry : RunResponse , kind : "Dag Run" | "Task Instance" ) => {
78+ if ( kind === "Dag Run" ) {
79+ const run = entry as GridRunsResponse ;
80+
81+ return run . queued_at !== null && run . start_date !== null && run . queued_at < run . start_date
82+ ? getDuration ( run . queued_at , run . start_date )
83+ : 0 ;
84+ }
85+
86+ const taskInstance = entry as TaskInstanceResponse ;
87+
88+ return taskInstance . queued_when !== null &&
89+ taskInstance . start_date !== null &&
90+ taskInstance . queued_when < taskInstance . start_date
91+ ? getDuration ( taskInstance . queued_when , taskInstance . start_date )
92+ : 0 ;
93+ } ;
94+
7395const getTickLabelFormat = ( entries : Array < RunResponse > ) : string => {
7496 if ( entries . length < 2 ) {
7597 return "HH:mm:ss" ;
@@ -121,28 +143,28 @@ export const DurationChart = ({
121143 }
122144 } ) ;
123145
124- const runAnnotation = {
125- borderColor : "grey" ,
126- borderWidth : 1 ,
127- label : {
128- content : ( ctx : PartialEventContext ) => renderDuration ( average ( ctx , 1 ) , false ) ?? "0" ,
129- display : true ,
130- position : "end" ,
131- } ,
132- scaleID : "y" ,
133- value : ( ctx : PartialEventContext ) => average ( ctx , 1 ) ,
134- } ;
146+ const queuedDurations = entries . map ( ( entry ) => getQueuedDuration ( entry , kind ) ) ;
147+ const runDurations = entries . map ( ( entry ) =>
148+ entry . start_date === null ? 0 : getDuration ( entry . start_date , entry . end_date ) ,
149+ ) ;
150+ // Bars stack queued under run, so the reference line tracks the same total the
151+ // reader sees at the top of each bar.
152+ const totalDurations = runDurations . map ( ( duration , index ) => duration + ( queuedDurations [ index ] ?? 0 ) ) ;
153+ const medianTotal = median ( totalDurations ) ;
135154
136- const queuedAnnotation = {
155+ const medianAnnotation = {
137156 borderColor : "grey" ,
157+ borderDash : [ 6 , 4 ] ,
138158 borderWidth : 1 ,
139159 label : {
140- content : ( ctx : PartialEventContext ) => renderDuration ( average ( ctx , 0 ) , false ) ?? "0" ,
160+ content : translate ( "durationChart.medianTotalDuration" , {
161+ duration : renderCompactDuration ( medianTotal ) ,
162+ } ) ,
141163 display : true ,
142- position : "end " ,
164+ position : "start " ,
143165 } ,
144166 scaleID : "y" ,
145- value : ( ctx : PartialEventContext ) => average ( ctx , 0 ) ,
167+ value : medianTotal ,
146168 } ;
147169
148170 return (
@@ -152,134 +174,122 @@ export const DurationChart = ({
152174 ? translate ( "durationChart.lastDagRun" , { count : entries . length } )
153175 : translate ( "durationChart.lastTaskInstance" , { count : entries . length } ) }
154176 </ Heading >
155- < Bar
156- data = { {
157- datasets : [
158- {
159- backgroundColor : getComputedCSSVariableValue ( queuedColorToken ?? "oklch(0.5 0 0)" ) ,
160- data : entries . map ( ( entry : RunResponse ) => {
161- switch ( kind ) {
162- case "Dag Run" : {
163- const run = entry as GridRunsResponse ;
164-
165- return run . queued_at !== null && run . start_date !== null && run . queued_at < run . start_date
166- ? Number ( getDuration ( run . queued_at , run . start_date ) )
167- : 0 ;
168- }
169- case "Task Instance" : {
170- const taskInstance = entry as TaskInstanceResponse ;
171-
172- return taskInstance . queued_when !== null &&
173- taskInstance . start_date !== null &&
174- taskInstance . queued_when < taskInstance . start_date
175- ? Number ( getDuration ( taskInstance . queued_when , taskInstance . start_date ) )
176- : 0 ;
177- }
178- default :
179- return 0 ;
180- }
181- } ) ,
182- label : translate ( "durationChart.queuedDuration" ) ,
183- } ,
184- {
185- backgroundColor : entries . map (
186- ( entry : RunResponse ) =>
187- ( entry . state ? stateColorMap [ entry . state ] : undefined ) ?? "oklch(0.5 0 0)" ,
188- ) ,
189- data : entries . map ( ( entry : RunResponse ) =>
190- entry . start_date === null ? 0 : Number ( getDuration ( entry . start_date , entry . end_date ) ) ,
191- ) ,
192- label : translate ( "durationChart.runDuration" ) ,
193- } ,
194- ] ,
195- labels : entries . map ( ( entry : RunResponse ) => dayjs ( entry . run_after ) . format ( DEFAULT_DATETIME_FORMAT ) ) ,
196- } }
197- datasetIdKey = "id"
198- options = { {
199- animation : isAutoRefreshing ? false : undefined ,
200- onClick : ( _event , elements ) => {
201- const [ element ] = elements ;
202-
203- if ( ! element ) {
204- return ;
205- }
206-
207- switch ( kind ) {
208- case "Dag Run" : {
209- const entry = entries [ element . index ] as GridRunsResponse | undefined ;
210- const baseUrl = `/dags/${ entry ?. dag_id } /runs/${ entry ?. run_id } ` ;
211-
212- void Promise . resolve ( navigate ( baseUrl ) ) ;
213- break ;
177+ { /* Height is fixed because the chart now flexes horizontally: with Chart.js'
178+ default 2:1 aspect ratio a wide monitor would otherwise scale it past
179+ 1000px tall. */ }
180+ < Box height = { CHART_HEIGHT } >
181+ < Bar
182+ data = { {
183+ datasets : [
184+ {
185+ backgroundColor : getComputedCSSVariableValue ( queuedColorToken ?? "oklch(0.5 0 0)" ) ,
186+ data : queuedDurations ,
187+ label : translate ( "durationChart.queuedDuration" ) ,
188+ } ,
189+ {
190+ backgroundColor : entries . map (
191+ ( entry : RunResponse ) =>
192+ ( entry . state ? stateColorMap [ entry . state ] : undefined ) ?? "oklch(0.5 0 0)" ,
193+ ) ,
194+ data : runDurations ,
195+ label : translate ( "durationChart.runDuration" ) ,
196+ } ,
197+ ] ,
198+ labels : entries . map ( ( entry : RunResponse ) =>
199+ dayjs ( entry . run_after ) . format ( DEFAULT_DATETIME_FORMAT ) ,
200+ ) ,
201+ } }
202+ datasetIdKey = "id"
203+ options = { {
204+ animation : isAutoRefreshing ? false : undefined ,
205+ maintainAspectRatio : false ,
206+ onClick : ( _event , elements ) => {
207+ const [ element ] = elements ;
208+
209+ if ( ! element ) {
210+ return ;
214211 }
215- case "Task Instance" : {
216- const entry = entries [ element . index ] as TaskInstanceResponse | undefined ;
217212
218- if ( entry === undefined ) {
213+ switch ( kind ) {
214+ case "Dag Run" : {
215+ const entry = entries [ element . index ] as GridRunsResponse | undefined ;
216+ const baseUrl = `/dags/${ entry ?. dag_id } /runs/${ entry ?. run_id } ` ;
217+
218+ void Promise . resolve ( navigate ( baseUrl ) ) ;
219219 break ;
220220 }
221+ case "Task Instance" : {
222+ const entry = entries [ element . index ] as TaskInstanceResponse | undefined ;
223+
224+ if ( entry === undefined ) {
225+ break ;
226+ }
221227
222- const baseUrl = buildTaskInstanceUrl ( {
223- currentPathname : location . pathname ,
224- dagId : entry . dag_id ,
225- isMapped : entry . map_index >= 0 ,
226- mapIndex : entry . map_index . toString ( ) ,
227- runId : entry . dag_run_id ,
228- taskId : entry . task_id ,
229- } ) ;
230-
231- void Promise . resolve ( navigate ( baseUrl ) ) ;
232- break ;
228+ const baseUrl = buildTaskInstanceUrl ( {
229+ currentPathname : location . pathname ,
230+ dagId : entry . dag_id ,
231+ isMapped : entry . map_index >= 0 ,
232+ mapIndex : entry . map_index . toString ( ) ,
233+ runId : entry . dag_run_id ,
234+ taskId : entry . task_id ,
235+ } ) ;
236+
237+ void Promise . resolve ( navigate ( baseUrl ) ) ;
238+ break ;
239+ }
240+ default :
233241 }
234- default :
235- }
236- } ,
237- onHover : ( _event , elements , chart ) => {
238- chart . canvas . style . cursor = elements . length > 0 ? "pointer" : "default" ;
239- } ,
240- plugins : {
241- annotation : {
242- annotations : {
243- queuedAnnotation,
244- runAnnotation,
245- } ,
246242 } ,
247- tooltip : {
248- callbacks : {
249- label : ( context ) => {
250- const datasetLabel = context . dataset . label ?? "" ;
243+ onHover : ( _event , elements , chart ) => {
244+ chart . canvas . style . cursor = elements . length > 0 ? "pointer" : "default" ;
245+ } ,
246+ plugins : {
247+ annotation : {
248+ annotations : {
249+ medianAnnotation,
250+ } ,
251+ } ,
252+ legend : {
253+ display : true ,
254+ position : "bottom" ,
255+ } ,
256+ tooltip : {
257+ callbacks : {
258+ label : ( context ) => {
259+ const datasetLabel = context . dataset . label ?? "" ;
251260
252- const formatted = renderDuration ( context . parsed . y , false ) ?? "0" ;
261+ const formatted = renderDuration ( context . parsed . y , false ) ?? "0" ;
253262
254- return datasetLabel ? `${ datasetLabel } : ${ formatted } ` : formatted ;
263+ return datasetLabel ? `${ datasetLabel } : ${ formatted } ` : formatted ;
264+ } ,
255265 } ,
256266 } ,
257267 } ,
258- } ,
259- responsive : true ,
260- scales : {
261- x : {
262- stacked : true ,
263- ticks : {
264- callback : ( _value , index ) =>
265- formatDate ( entries [ index ] ?. run_after , selectedTimezone , getTickLabelFormat ( entries ) ) ,
266- maxTicksLimit : 3 ,
268+ responsive : true ,
269+ scales : {
270+ x : {
271+ stacked : true ,
272+ ticks : {
273+ callback : ( _value , index ) =>
274+ formatDate ( entries [ index ] ?. run_after , selectedTimezone , getTickLabelFormat ( entries ) ) ,
275+ maxTicksLimit : 3 ,
276+ } ,
277+ title : { align : "end" , display : true , text : translate ( "common:dagRun.runAfter" ) } ,
267278 } ,
268- title : { align : "end" , display : true , text : translate ( "common:dagRun.runAfter" ) } ,
269- } ,
270- y : {
271- ticks : {
272- callback : ( value ) => {
273- const num = typeof value === "number" ? value : Number ( value ) ;
274-
275- return renderDuration ( num , false ) ?? "0" ;
279+ y : {
280+ beginAtZero : true ,
281+ stacked : true ,
282+ ticks : {
283+ callback : ( value ) =>
284+ renderCompactDuration ( typeof value === "number" ? value : Number ( value ) ) ,
285+ stepSize : getDurationTickStep ( Math . max ( ...totalDurations , 0 ) ) ,
276286 } ,
287+ title : { align : "end" , display : true , text : translate ( "common:duration" ) } ,
277288 } ,
278- title : { align : "end" , display : true , text : translate ( "common:duration" ) } ,
279289 } ,
280- } ,
281- } }
282- / >
290+ } }
291+ />
292+ </ Box >
283293 </ Box >
284294 ) ;
285295} ;
0 commit comments