Skip to content

Commit ae756a9

Browse files
authored
UI: Make duration charts readable at a glance (#70142)
* UI: Make duration charts readable at a glance The Airflow 3 duration chart dropped several things the Airflow 2 version did well, and what is left is hard to read at the size it is given. Durations on the value axis are the main cost: HH:mm:ss has to be decoded tick by tick before the magnitude is apparent, and Chart.js picks decimal steps, so a busy Dag lands on ticks like 26m 40s. The two reference lines report the mean, which one stuck run drags well above where runs actually sit, and both labels are pinned to the same edge so they overlap each other and the most recent bars. Nothing names the two series. Finally the card is pinned to 350px on a page with room to spare, which is what makes the bars too small to compare in the first place. The bars were also stacked on the index axis only, so queued time was drawn behind run time rather than underneath it, and was effectively invisible. * Add newsfragment for the duration chart changes * UI: Pin the duration chart height Letting the card flex horizontally handed its height to Chart.js' default 2:1 aspect ratio, which the old fixed 350px width had been capping as a side effect. On a 1400px row the chart came out 496px tall, and on a 2560px monitor it would have passed 1000px and swallowed the page it sits on. * UI: Apply duration chart review feedback The two overview pages had drifted apart for no reason: one card flexed, the other sat in a single-column grid with only a max width, so the same chart answered to two different sets of rules. They now share one Box, which also gives the Task overview the cap the Dag overview needed to stop the chart spanning an ultrawide monitor. The reference line reads "Total:" rather than "Median total:", and the newsfragment is dropped as not warranted for this change.
1 parent fd0a398 commit ae756a9

8 files changed

Lines changed: 349 additions & 142 deletions

File tree

airflow-core/src/airflow/ui/public/i18n/locales/en/components.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"lastDagRun_other": "Last {{count}} Dag Runs",
6666
"lastTaskInstance_one": "Last Task Instance",
6767
"lastTaskInstance_other": "Last {{count}} Task Instances",
68+
"medianTotalDuration": "Total: {{duration}}",
6869
"queuedDuration": "Queued Duration",
6970
"runAfter": "Run After",
7071
"runDuration": "Run Duration"

airflow-core/src/airflow/ui/src/components/DurationChart.tsx

Lines changed: 144 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
3131
import annotationPlugin from "chartjs-plugin-annotation";
3232
import dayjs from "dayjs";
3333
import { Bar } from "react-chartjs-2";
@@ -37,8 +37,15 @@ import { useNavigate } from "react-router-dom";
3737
import type { TaskInstanceResponse, GridRunsResponse } from "openapi/requests/types.gen";
3838
import { useTimezone } from "src/context/timezone";
3939
import { 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";
4147
import { buildTaskInstanceUrl } from "src/utils/links";
48+
import { median } from "src/utils/median";
4249

4350
ChartJS.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

6064
type 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+
7395
const 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
};

airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,17 @@ export const Overview = () => {
128128
/>
129129
</HStack>
130130
<HStack alignItems="flex-start" flexWrap="wrap">
131-
<Box borderRadius={4} borderStyle="solid" borderWidth={1} p={2} width="350px">
131+
<Box
132+
borderRadius={4}
133+
borderStyle="solid"
134+
borderWidth={1}
135+
flex="1 1 520px"
136+
maxWidth="900px"
137+
minWidth="320px"
138+
p={2}
139+
>
132140
{isLoadingRuns ? (
133-
<Skeleton height="200px" w="full" />
141+
<Skeleton height="310px" w="full" />
134142
) : (
135143
<DurationChart
136144
entries={gridRuns?.slice().reverse()}

0 commit comments

Comments
 (0)