Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e593169
Add FailedIcon component and DurationChart failed icon plugin
imrichardwu Feb 13, 2026
963ad54
feat: add fail icon if status is failed
imrichardwu Feb 14, 2026
15d67f4
fix: adjust fail icon padding to be dynamic
imrichardwu Feb 14, 2026
b75c411
allow user to click on fail icon and redirect to task instances
imrichardwu Feb 14, 2026
80351f9
feat: add deadlines UI - DeadlineIcon, Deadlines page, i18n, and router
imrichardwu Feb 19, 2026
fe586c8
Merge branch 'main' into error-ui-frontend
imrichardwu Feb 20, 2026
71ccaad
fix: ensure icons are positioned correctly above the rendered bar by …
imrichardwu Feb 20, 2026
d932933
Merge branch 'main' into error-ui-frontend
imrichardwu Feb 24, 2026
af9d4d2
feat(ui): add deadline translations and refactor Deadlines component …
imrichardwu Feb 24, 2026
c851cb1
refactor(ui): consolidate imports in Bar component for cleaner code
imrichardwu Feb 25, 2026
e8099a6
Merge branch 'main' into error-ui-frontend
imrichardwu Feb 25, 2026
d78dc22
Merge branch 'main' into error-ui-frontend
imrichardwu Feb 25, 2026
ea0649e
refactor(ui): remove unused DeadlineIcon and FailedIcon components, r…
imrichardwu Feb 25, 2026
435cb3e
refactor(ui): simplify failed state icon rendering in Bar component
imrichardwu Feb 25, 2026
9e17bf0
Merge branch 'main' into error-ui-frontend
imrichardwu Feb 25, 2026
d083286
Merge branch 'main' into error-ui-frontend
imrichardwu Feb 25, 2026
d847834
Merge branch 'main' into error-ui-frontend
imrichardwu Feb 26, 2026
709a4cc
Merge branch 'main' into error-ui-frontend
imrichardwu Mar 17, 2026
7b9c20a
refactor: remove redundant BAR_HEIGHT constant from Bar component
imrichardwu Feb 27, 2026
5d348eb
Merge branch 'main' into error-ui-frontend
imrichardwu Mar 17, 2026
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 @@ -74,6 +74,8 @@
"dagRun_other": "Dag Runs",
"dagRunId": "Dag Run ID",
"dagWarnings": "Dag warnings/errors",
"deadline_one": "Deadline",
"deadline_other": "Deadlines",
"defaultToGraphView": "Default to graph view",
"defaultToGridView": "Default to grid view",
"delete": "Delete",
Expand Down
12 changes: 12 additions & 0 deletions airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@
"parseDuration": "Parse Duration:",
"parsedAt": "Parsed at:"
},
"deadlines": {
"createdAt": "Created At",
"deadlineTime": "Deadline Time",
"description": "Description",
"missed": "Missed",
"name": "Name",
"noDeadlines": "No deadlines for this DAG run.",
"onTrack": "On Track",
"showMissedOnly": "Show missed only",
"status": "Status"
},
"extraLinks": "Extra Links",
"grid": {
"buttons": {
Expand Down Expand Up @@ -161,6 +172,7 @@
"backfills": "Backfills",
"calendar": "Calendar",
"code": "Code",
"deadlines": "Deadlines",
"details": "Details",
"logs": "Logs",
"mappedTaskInstances_one": "Task Instance [{{count}}]",
Expand Down
42 changes: 42 additions & 0 deletions airflow-core/src/airflow/ui/src/assets/DeadlineIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createIcon } from "@chakra-ui/react";

/**
* Clock with warning indicator icon for missed deadlines.
* Visually distinct from the triangle FailedIcon.
*/
export const DeadlineIcon = createIcon({
defaultProps: {
height: "1em",
width: "1em",
},
displayName: "Deadline Icon",
path: (
<g fill="currentColor">
<path
clipRule="evenodd"
d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1ZM2.5 8a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0Z"
fillRule="evenodd"
/>
<path d="M8 3.75a.75.75 0 0 1 .75.75v3.69l2.28 2.28a.75.75 0 1 1-1.06 1.06l-2.5-2.5A.75.75 0 0 1 7.25 8.5V4.5A.75.75 0 0 1 8 3.75Z" />
</g>
),
viewBox: "0 0 16 16",
});
41 changes: 41 additions & 0 deletions airflow-core/src/airflow/ui/src/assets/FailedIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createIcon } from "@chakra-ui/react";

/**
* Warning/error icon (triangle with exclamation) for failed state.
* Use in UI components; for Canvas (e.g. Chart.js) use canvas drawing or an image.
*/
export const FailedIcon = createIcon({
defaultProps: {
height: "1em",
width: "1em",
},
displayName: "Failed Icon",
path: (
<g fill="currentColor">
<path
clipRule="evenodd"
d="M8 1.5a1 1 0 0 1 .87.51l6.5 11a1 1 0 0 1-.87 1.49H1.5a1 1 0 0 1-.87-1.49l6.5-11A1 1 0 0 1 8 1.5ZM8 4a.75.75 0 0 0-.75.75v3.5a.75.75 0 0 0 1.5 0v-3.5A.75.75 0 0 0 8 4Zm0 8a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
fillRule="evenodd"
/>
</g>
),
viewBox: "0 0 16 16",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Chart } from "chart.js";

const FAILED_ICON_PLUGIN_ID = "durationChartFailedIcon";
const ICON_SIZE = 14;
const ICON_OFFSET = 4;

export const createFailedIconPlugin = (failedIndices: Array<number>, failedIconColor: string) => ({
afterDatasetsDraw(chart: Chart) {
if (failedIndices.length === 0) {
return;
}

const { ctx } = chart;
const meta = chart.getDatasetMeta(1);

if (meta.data.length === 0) {
return;
}

failedIndices.forEach((index) => {
const element = meta.data[index];

if (!element) {
return;
}

const { x, y } = element.getProps(["x", "y"], true) as { x: number; y: number };
const iconX = x;
const iconY = y - ICON_OFFSET - ICON_SIZE;

ctx.save();

const half = ICON_SIZE / 2;

ctx.beginPath();
ctx.moveTo(iconX, iconY + ICON_SIZE);
ctx.lineTo(iconX - half, iconY);
ctx.lineTo(iconX + half, iconY);
ctx.closePath();
ctx.fillStyle = failedIconColor;
ctx.fill();
ctx.strokeStyle = failedIconColor;
ctx.lineWidth = 1;
ctx.stroke();

ctx.fillStyle = "white";
ctx.font = `bold ${ICON_SIZE * 0.6}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("!", iconX, iconY + ICON_SIZE * 0.55);

ctx.restore();
});
},
id: FAILED_ICON_PLUGIN_ID,
});
64 changes: 62 additions & 2 deletions airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Flex, Box } from "@chakra-ui/react";
import { useParams, useSearchParams } from "react-router-dom";
import { Flex, Box, Center, Button } from "@chakra-ui/react";
import { useParams, useSearchParams, useNavigate } from "react-router-dom";

import type { GridRunsResponse } from "openapi/requests";
import { DeadlineIcon } from "src/assets/DeadlineIcon";
import { FailedIcon } from "src/assets/FailedIcon";
import { RunTypeIcon } from "src/components/RunTypeIcon";
import { useHover } from "src/context/hover";

import { GridButton } from "./GridButton";

const BAR_HEIGHT = 100;
const ICON_GAP_PX = 4;
const ICON_HEIGHT_PX = 16;
const BAR_PADDING_BOTTOM_PX = 2;
const BAR_MIN_HEIGHT_PX = 14;

type Props = {
readonly max: number;
Expand All @@ -41,10 +47,28 @@ export const Bar = ({ max, onClick, run }: Props) => {
const isSelected = runId === run.run_id;
const isHovered = hoveredRunId === run.run_id;
const search = searchParams.toString();
const isFailed = (run.state ?? "").toLowerCase() === "failed";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why that 'isFailed' based on the 'run.state' ? We do not set an icon for each possible dag run states, what is the motivation here?

Copy link
Contributor Author

@imrichardwu imrichardwu Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is to prevent the failed icon and deadline icon from overlapping

const hasMissedDeadline = Boolean(run.has_missed_deadline);
const barHeightPx = max > 0 ? (run.duration / max) * BAR_HEIGHT : 0;

const handleMouseEnter = () => setHoveredRunId(run.run_id);
const handleMouseLeave = () => setHoveredRunId(undefined);

const navigate = useNavigate();

const handleFailedIconClick = () => {
void navigate({ pathname: `/dags/${dagId}/runs/${run.run_id}`, search });
};

const handleDeadlineIconClick = () => {
void navigate({ pathname: `/dags/${dagId}/runs/${run.run_id}/deadlines`, search });
};

// Account for minHeight and padding-bottom so icons always appear above the rendered bar
const effectiveBarHeightPx = Math.max(barHeightPx, BAR_MIN_HEIGHT_PX) + BAR_PADDING_BOTTOM_PX;
const failedIconBottom = effectiveBarHeightPx + ICON_GAP_PX;
const deadlineIconBottom = isFailed ? failedIconBottom + ICON_HEIGHT_PX : failedIconBottom;

Comment on lines +70 to +73
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could break alignment with the 'Gantt', can you show a picture with deadline alerts failed and the Gantt chart displayed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is 2 images

Screenshot 2026-02-25 at 11 35 38 AM Screenshot 2026-02-25 at 11 35 30 AM

return (
<Box
bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : undefined}
Expand All @@ -53,6 +77,42 @@ export const Bar = ({ max, onClick, run }: Props) => {
position="relative"
transition="background-color 0.2s"
>
{hasMissedDeadline ? (
<Center bottom={`${deadlineIconBottom}px`} left={0} position="absolute" right={0} zIndex={2}>
<Button
_focusVisible={{ boxShadow: "none" }}
borderRadius={0}
h="auto"
lineHeight={1}
m={0}
minH={0}
minW={0}
onClick={handleDeadlineIconClick}
p={0}
variant="ghost"
>
<DeadlineIcon boxSize={3} color="warning.solid" />
</Button>
</Center>
) : undefined}
{isFailed ? (
<Center bottom={`${failedIconBottom}px`} left={0} position="absolute" right={0} zIndex={2}>
<Button
_focusVisible={{ boxShadow: "none" }}
borderRadius={0}
h="auto"
lineHeight={1}
m={0}
minH={0}
minW={0}
onClick={handleFailedIconClick}
p={0}
variant="ghost"
>
<FailedIcon boxSize={3} color="failed.solid" />
</Button>
</Center>
) : undefined}
<Flex
alignItems="flex-end"
height={BAR_HEIGHT}
Expand Down
29 changes: 20 additions & 9 deletions airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { TaskInstancesColumn } from "./TaskInstancesColumn";
import { TaskNames } from "./TaskNames";
import {
GRID_HEADER_HEIGHT_PX,
GRID_HEADER_ICON_SPACE_PX,
GRID_HEADER_PADDING_PX,
GRID_OUTER_PADDING_PX,
ROW_HEIGHT,
Expand Down Expand Up @@ -150,22 +151,32 @@ export const Grid = ({ dagRunState, limit, runType, showGantt, triggeringUser }:
{/* Grid header, both bgs are needed to hide elements during horizontal and vertical scroll */}
<Flex bg="bg" display="flex" position="sticky" pt={`${GRID_HEADER_PADDING_PX}px`} top={0} zIndex={2}>
<Box bg="bg" flexGrow={1} left={0} minWidth="200px" position="sticky" zIndex={1}>
<Flex flexDirection="column-reverse" height={`${GRID_HEADER_HEIGHT_PX}px`} position="relative">
<Flex
flexDirection="column-reverse"
height={`${GRID_HEADER_HEIGHT_PX + GRID_HEADER_ICON_SPACE_PX}px`}
position="relative"
>
{Boolean(gridRuns?.length) && (
<>
<DurationTick bottom={`${GRID_HEADER_HEIGHT_PX - 8}px`} duration={max} />
<DurationTick bottom={`${GRID_HEADER_HEIGHT_PX / 2 - 4}px`} duration={max / 2} />
<DurationTick
bottom={`${GRID_HEADER_HEIGHT_PX + GRID_HEADER_ICON_SPACE_PX - 8}px`}
duration={max}
/>
<DurationTick
bottom={`${GRID_HEADER_HEIGHT_PX / 2 + GRID_HEADER_ICON_SPACE_PX - 4}px`}
duration={max / 2}
/>
</>
)}
</Flex>
</Box>
{/* Duration bars */}
<Flex flexDirection="row-reverse" flexShrink={0}>
<Flex flexShrink={0} position="relative">
<DurationAxis top={`${GRID_HEADER_HEIGHT_PX}px`} />
<DurationAxis top={`${GRID_HEADER_HEIGHT_PX / 2}px`} />
<DurationAxis top="4px" />
<Flex flexDirection="row-reverse">
<Flex flexShrink={0} overflow="visible" position="relative">
<DurationAxis top={`${GRID_HEADER_HEIGHT_PX + GRID_HEADER_ICON_SPACE_PX}px`} />
<DurationAxis top={`${GRID_HEADER_HEIGHT_PX / 2 + GRID_HEADER_ICON_SPACE_PX}px`} />
<DurationAxis top={`${4 + GRID_HEADER_ICON_SPACE_PX}px`} />
<Flex flexDirection="row-reverse" pt={`${GRID_HEADER_ICON_SPACE_PX}px`}>
{gridRuns?.map((dr: GridRunsResponse) => (
<Bar key={dr.run_id} max={max} onClick={handleColumnClick} run={dr} />
))}
Expand All @@ -174,7 +185,7 @@ export const Grid = ({ dagRunState, limit, runType, showGantt, triggeringUser }:
<Link to={`/dags/${dagId}`}>
<IconButton
aria-label={translate("grid.buttons.resetToLatest")}
height={`${GRID_HEADER_HEIGHT_PX - 2}px`}
height={`${GRID_HEADER_HEIGHT_PX + GRID_HEADER_ICON_SPACE_PX - 2}px`}
loading={isLoading}
minW={0}
ml={1}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ export const ROW_HEIGHT = 20;
export const GRID_OUTER_PADDING_PX = 64; // pt={16} = 16 * 4 = 64px
export const GRID_HEADER_PADDING_PX = 8; // pt={2} = 2 * 4 = 8px
export const GRID_HEADER_HEIGHT_PX = 100; // height="100px" for duration bars
// Space above bars for failed-run icon so it is not clipped
export const GRID_HEADER_ICON_SPACE_PX = 28;
Comment on lines +25 to +26
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is making computation complex and easy to mess up in the Grid component.

We should probably refactor to make it more straight forward.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to use flexbox before, but the ui kept breaking. Will try to figure out how to get it working without the calculation


// Gantt chart's x-axis height (time labels at top of chart)
export const GANTT_AXIS_HEIGHT_PX = 36;

// Total offset from top of Grid component to where task rows begin,
// minus the Gantt axis height since the chart includes its own top axis
export const GRID_BODY_OFFSET_PX =
GRID_OUTER_PADDING_PX + GRID_HEADER_PADDING_PX + GRID_HEADER_HEIGHT_PX - GANTT_AXIS_HEIGHT_PX;
GRID_OUTER_PADDING_PX +
GRID_HEADER_PADDING_PX +
GRID_HEADER_HEIGHT_PX +
GRID_HEADER_ICON_SPACE_PX -
GANTT_AXIS_HEIGHT_PX;
Loading
Loading