Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"lastDagRun_other": "Last {{count}} Dag Runs",
"lastTaskInstance_one": "Last Task Instance",
"lastTaskInstance_other": "Last {{count}} Task Instances",
"medianTotalDuration": "Total: {{duration}}",
"queuedDuration": "Queued Duration",
"runAfter": "Run After",
"runDuration": "Run Duration"
Expand Down
278 changes: 144 additions & 134 deletions airflow-core/src/airflow/ui/src/components/DurationChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ import {
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
} from "chart.js";
import type { PartialEventContext } from "chartjs-plugin-annotation";
import annotationPlugin from "chartjs-plugin-annotation";
import dayjs from "dayjs";
import { Bar } from "react-chartjs-2";
Expand All @@ -37,8 +37,15 @@ import { useNavigate } from "react-router-dom";
import type { TaskInstanceResponse, GridRunsResponse } from "openapi/requests/types.gen";
import { useTimezone } from "src/context/timezone";
import { getComputedCSSVariableValue } from "src/theme";
import { DEFAULT_DATETIME_FORMAT, formatDate, renderDuration } from "src/utils/datetimeUtils";
import {
DEFAULT_DATETIME_FORMAT,
formatDate,
getDurationTickStep,
renderCompactDuration,
renderDuration,
} from "src/utils/datetimeUtils";
import { buildTaskInstanceUrl } from "src/utils/links";
import { median } from "src/utils/median";

ChartJS.register(
CategoryScale,
Expand All @@ -47,15 +54,12 @@ ChartJS.register(
BarElement,
LineElement,
Filler,
Legend,
Tooltip,
annotationPlugin,
);

const average = (ctx: PartialEventContext, index: number) => {
const values: Array<number> | undefined = ctx.chart.data.datasets[index]?.data as Array<number> | undefined;

return values === undefined ? 0 : values.reduce((initial, next) => initial + next, 0) / values.length;
};
const CHART_HEIGHT = "280px";

type RunResponse = GridRunsResponse | TaskInstanceResponse;

Expand All @@ -70,6 +74,24 @@ const getDuration = (start: string, end: string | null) => {
return dayjs.duration(endDate.diff(startDate)).asSeconds();
};

const getQueuedDuration = (entry: RunResponse, kind: "Dag Run" | "Task Instance") => {
if (kind === "Dag Run") {
const run = entry as GridRunsResponse;

return run.queued_at !== null && run.start_date !== null && run.queued_at < run.start_date
? getDuration(run.queued_at, run.start_date)
: 0;
}

const taskInstance = entry as TaskInstanceResponse;

return taskInstance.queued_when !== null &&
taskInstance.start_date !== null &&
taskInstance.queued_when < taskInstance.start_date
? getDuration(taskInstance.queued_when, taskInstance.start_date)
: 0;
};

const getTickLabelFormat = (entries: Array<RunResponse>): string => {
if (entries.length < 2) {
return "HH:mm:ss";
Expand Down Expand Up @@ -121,28 +143,28 @@ export const DurationChart = ({
}
});

const runAnnotation = {
borderColor: "grey",
borderWidth: 1,
label: {
content: (ctx: PartialEventContext) => renderDuration(average(ctx, 1), false) ?? "0",
display: true,
position: "end",
},
scaleID: "y",
value: (ctx: PartialEventContext) => average(ctx, 1),
};
const queuedDurations = entries.map((entry) => getQueuedDuration(entry, kind));
const runDurations = entries.map((entry) =>
entry.start_date === null ? 0 : getDuration(entry.start_date, entry.end_date),
);
// Bars stack queued under run, so the reference line tracks the same total the
// reader sees at the top of each bar.
const totalDurations = runDurations.map((duration, index) => duration + (queuedDurations[index] ?? 0));
const medianTotal = median(totalDurations);

const queuedAnnotation = {
const medianAnnotation = {
borderColor: "grey",
borderDash: [6, 4],
borderWidth: 1,
label: {
content: (ctx: PartialEventContext) => renderDuration(average(ctx, 0), false) ?? "0",
content: translate("durationChart.medianTotalDuration", {
duration: renderCompactDuration(medianTotal),
}),
display: true,
position: "end",
position: "start",
},
scaleID: "y",
value: (ctx: PartialEventContext) => average(ctx, 0),
value: medianTotal,
};

return (
Expand All @@ -152,134 +174,122 @@ export const DurationChart = ({
? translate("durationChart.lastDagRun", { count: entries.length })
: translate("durationChart.lastTaskInstance", { count: entries.length })}
</Heading>
<Bar
data={{
datasets: [
{
backgroundColor: getComputedCSSVariableValue(queuedColorToken ?? "oklch(0.5 0 0)"),
data: entries.map((entry: RunResponse) => {
switch (kind) {
case "Dag Run": {
const run = entry as GridRunsResponse;

return run.queued_at !== null && run.start_date !== null && run.queued_at < run.start_date
? Number(getDuration(run.queued_at, run.start_date))
: 0;
}
case "Task Instance": {
const taskInstance = entry as TaskInstanceResponse;

return taskInstance.queued_when !== null &&
taskInstance.start_date !== null &&
taskInstance.queued_when < taskInstance.start_date
? Number(getDuration(taskInstance.queued_when, taskInstance.start_date))
: 0;
}
default:
return 0;
}
}),
label: translate("durationChart.queuedDuration"),
},
{
backgroundColor: entries.map(
(entry: RunResponse) =>
(entry.state ? stateColorMap[entry.state] : undefined) ?? "oklch(0.5 0 0)",
),
data: entries.map((entry: RunResponse) =>
entry.start_date === null ? 0 : Number(getDuration(entry.start_date, entry.end_date)),
),
label: translate("durationChart.runDuration"),
},
],
labels: entries.map((entry: RunResponse) => dayjs(entry.run_after).format(DEFAULT_DATETIME_FORMAT)),
}}
datasetIdKey="id"
options={{
animation: isAutoRefreshing ? false : undefined,
onClick: (_event, elements) => {
const [element] = elements;

if (!element) {
return;
}

switch (kind) {
case "Dag Run": {
const entry = entries[element.index] as GridRunsResponse | undefined;
const baseUrl = `/dags/${entry?.dag_id}/runs/${entry?.run_id}`;

void Promise.resolve(navigate(baseUrl));
break;
{/* Height is fixed because the chart now flexes horizontally: with Chart.js'
default 2:1 aspect ratio a wide monitor would otherwise scale it past
1000px tall. */}
<Box height={CHART_HEIGHT}>
<Bar
data={{
datasets: [
{
backgroundColor: getComputedCSSVariableValue(queuedColorToken ?? "oklch(0.5 0 0)"),
data: queuedDurations,
label: translate("durationChart.queuedDuration"),
},
{
backgroundColor: entries.map(
(entry: RunResponse) =>
(entry.state ? stateColorMap[entry.state] : undefined) ?? "oklch(0.5 0 0)",
),
data: runDurations,
label: translate("durationChart.runDuration"),
},
],
labels: entries.map((entry: RunResponse) =>
dayjs(entry.run_after).format(DEFAULT_DATETIME_FORMAT),
),
}}
datasetIdKey="id"
options={{
animation: isAutoRefreshing ? false : undefined,
maintainAspectRatio: false,
onClick: (_event, elements) => {
const [element] = elements;

if (!element) {
return;
}
case "Task Instance": {
const entry = entries[element.index] as TaskInstanceResponse | undefined;

if (entry === undefined) {
switch (kind) {
case "Dag Run": {
const entry = entries[element.index] as GridRunsResponse | undefined;
const baseUrl = `/dags/${entry?.dag_id}/runs/${entry?.run_id}`;

void Promise.resolve(navigate(baseUrl));
break;
}
case "Task Instance": {
const entry = entries[element.index] as TaskInstanceResponse | undefined;

if (entry === undefined) {
break;
}

const baseUrl = buildTaskInstanceUrl({
currentPathname: location.pathname,
dagId: entry.dag_id,
isMapped: entry.map_index >= 0,
mapIndex: entry.map_index.toString(),
runId: entry.dag_run_id,
taskId: entry.task_id,
});

void Promise.resolve(navigate(baseUrl));
break;
const baseUrl = buildTaskInstanceUrl({
currentPathname: location.pathname,
dagId: entry.dag_id,
isMapped: entry.map_index >= 0,
mapIndex: entry.map_index.toString(),
runId: entry.dag_run_id,
taskId: entry.task_id,
});

void Promise.resolve(navigate(baseUrl));
break;
}
default:
}
default:
}
},
onHover: (_event, elements, chart) => {
chart.canvas.style.cursor = elements.length > 0 ? "pointer" : "default";
},
plugins: {
annotation: {
annotations: {
queuedAnnotation,
runAnnotation,
},
},
tooltip: {
callbacks: {
label: (context) => {
const datasetLabel = context.dataset.label ?? "";
onHover: (_event, elements, chart) => {
chart.canvas.style.cursor = elements.length > 0 ? "pointer" : "default";
},
plugins: {
annotation: {
annotations: {
medianAnnotation,
},
},
legend: {
display: true,
position: "bottom",
},
tooltip: {
callbacks: {
label: (context) => {
const datasetLabel = context.dataset.label ?? "";

const formatted = renderDuration(context.parsed.y, false) ?? "0";
const formatted = renderDuration(context.parsed.y, false) ?? "0";

return datasetLabel ? `${datasetLabel}: ${formatted}` : formatted;
return datasetLabel ? `${datasetLabel}: ${formatted}` : formatted;
},
},
},
},
},
responsive: true,
scales: {
x: {
stacked: true,
ticks: {
callback: (_value, index) =>
formatDate(entries[index]?.run_after, selectedTimezone, getTickLabelFormat(entries)),
maxTicksLimit: 3,
responsive: true,
scales: {
x: {
stacked: true,
ticks: {
callback: (_value, index) =>
formatDate(entries[index]?.run_after, selectedTimezone, getTickLabelFormat(entries)),
maxTicksLimit: 3,
},
title: { align: "end", display: true, text: translate("common:dagRun.runAfter") },
},
title: { align: "end", display: true, text: translate("common:dagRun.runAfter") },
},
y: {
ticks: {
callback: (value) => {
const num = typeof value === "number" ? value : Number(value);

return renderDuration(num, false) ?? "0";
y: {
beginAtZero: true,
stacked: true,
ticks: {
callback: (value) =>
renderCompactDuration(typeof value === "number" ? value : Number(value)),
stepSize: getDurationTickStep(Math.max(...totalDurations, 0)),
},
title: { align: "end", display: true, text: translate("common:duration") },
},
title: { align: "end", display: true, text: translate("common:duration") },
},
},
}}
/>
}}
/>
</Box>
</Box>
);
};
12 changes: 10 additions & 2 deletions airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,17 @@ export const Overview = () => {
/>
</HStack>
<HStack alignItems="flex-start" flexWrap="wrap">
<Box borderRadius={4} borderStyle="solid" borderWidth={1} p={2} width="350px">
<Box
borderRadius={4}
borderStyle="solid"
borderWidth={1}
flex="1 1 520px"
maxWidth="900px"
minWidth="320px"
p={2}
>
{isLoadingRuns ? (
<Skeleton height="200px" w="full" />
<Skeleton height="310px" w="full" />
) : (
<DurationChart
entries={gridRuns?.slice().reverse()}
Expand Down
Loading
Loading