This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new d39aca4a32d Add segmented state bar for collapsed task groups and
mapped tasks (#61854)
d39aca4a32d is described below
commit d39aca4a32da68d113946cf561077e0ae20e0edb
Author: Nathan Hadfield <[email protected]>
AuthorDate: Tue Feb 17 15:27:25 2026 +0000
Add segmented state bar for collapsed task groups and mapped tasks (#61854)
Replace the single-color state indicator on collapsed TaskGroup and mapped
task nodes with a multi-colored segmented bar that shows the proportional
distribution of child task instance states. Also add a child state breakdown
to the TaskInstanceTooltip.
- Create stateUtils.ts with STATE_PRIORITY ordering and sortStateEntries
helper
- Create SegmentedStateBar component using CSS flex for proportional
segments
- Modify TaskNode to render SegmentedStateBar inside the node card
- Increase node height from 80px to 90px for nodes with state bars
- Add child state breakdown with color swatches to TaskInstanceTooltip
- Add unit tests for sortStateEntries and SegmentedStateBar
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../components/Graph/SegmentedStateBar.test.tsx | 81 ++++++++++++++++++
.../ui/src/components/Graph/SegmentedStateBar.tsx | 48 +++++++++++
.../airflow/ui/src/components/Graph/TaskNode.tsx | 13 ++-
.../ui/src/components/Graph/useGraphLayout.ts | 3 +-
.../ui/src/components/TaskInstanceTooltip.tsx | 28 ++++++-
airflow-core/src/airflow/ui/src/utils/index.ts | 1 +
.../src/airflow/ui/src/utils/stateUtils.test.ts | 97 ++++++++++++++++++++++
.../src/airflow/ui/src/utils/stateUtils.ts | 64 ++++++++++++++
8 files changed, 329 insertions(+), 6 deletions(-)
diff --git
a/airflow-core/src/airflow/ui/src/components/Graph/SegmentedStateBar.test.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/SegmentedStateBar.test.tsx
new file mode 100644
index 00000000000..ac842ce7204
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/Graph/SegmentedStateBar.test.tsx
@@ -0,0 +1,81 @@
+/*!
+ * 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 { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { SegmentedStateBar } from "./SegmentedStateBar";
+
+describe("SegmentedStateBar", () => {
+ it("renders nothing when childStates is null and no fallbackState", () => {
+ const { container } = render(<SegmentedStateBar childStates={null} />, {
+ wrapper: Wrapper,
+ });
+
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders a single solid bar when childStates is null with fallbackState",
() => {
+ const { container } = render(<SegmentedStateBar childStates={null}
fallbackState="running" />, {
+ wrapper: Wrapper,
+ });
+
+ const boxes = container.querySelectorAll("[class]");
+
+ // Should render a single box element (the fallback bar)
+ expect(boxes.length).toBeGreaterThan(0);
+ // Should not contain a flex container with multiple children
+ const flexContainer = container.querySelector("[style*='flex']");
+
+ expect(flexContainer).toBeNull();
+ });
+
+ it("renders proportional segments for mixed states", () => {
+ const childStates = { running: 3, scheduled: 4, success: 1 };
+ const { container } = render(<SegmentedStateBar childStates={childStates}
/>, {
+ wrapper: Wrapper,
+ });
+
+ // The flex container should have exactly 3 child segments (one per
non-zero state)
+ const flexContainer = container.firstChild;
+
+ expect(flexContainer?.childNodes.length).toBe(3);
+ });
+
+ it("excludes zero-count states from segments", () => {
+ const childStates = { failed: 0, running: 2, success: 0 };
+ const { container } = render(<SegmentedStateBar childStates={childStates}
/>, {
+ wrapper: Wrapper,
+ });
+
+ // Only "running" has count > 0, so exactly 1 segment child
+ const flexContainer = container.firstChild;
+
+ expect(flexContainer?.childNodes.length).toBe(1);
+ });
+
+ it("renders nothing when childStates is empty object and no fallbackState",
() => {
+ const { container } = render(<SegmentedStateBar childStates={{}} />, {
+ wrapper: Wrapper,
+ });
+
+ expect(container.innerHTML).toBe("");
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/Graph/SegmentedStateBar.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/SegmentedStateBar.tsx
new file mode 100644
index 00000000000..3a03ec7fe95
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Graph/SegmentedStateBar.tsx
@@ -0,0 +1,48 @@
+/*!
+ * 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 { Box, Flex } from "@chakra-ui/react";
+
+import type { TaskInstanceState } from "openapi/requests/types.gen";
+import { sortStateEntries } from "src/utils";
+
+type Props = {
+ readonly childStates: Record<string, number> | null;
+ readonly fallbackState?: TaskInstanceState | null;
+ readonly height?: number | string;
+};
+
+export const SegmentedStateBar = ({ childStates, fallbackState, height = "6px"
}: Props) => {
+ const entries = sortStateEntries(childStates);
+
+ if (entries.length === 0) {
+ if (!fallbackState) {
+ return undefined;
+ }
+
+ return <Box bg={`${fallbackState}.solid`} borderRadius="2px"
height={height} mt="auto" />;
+ }
+
+ return (
+ <Flex borderRadius="2px" height={height} mt="auto" overflow="hidden">
+ {entries.map(([state, count]) => (
+ <Box bg={`${state}.solid`} flex={count} key={state} minWidth="2px" />
+ ))}
+ </Flex>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
index 3ccd556313c..a81fd9b773d 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
@@ -26,6 +26,7 @@ import TaskInstanceTooltip from
"src/components/TaskInstanceTooltip";
import { useOpenGroups } from "src/context/openGroups";
import { NodeWrapper } from "./NodeWrapper";
+import { SegmentedStateBar } from "./SegmentedStateBar";
import { TaskLink } from "./TaskLink";
import type { CustomNodeProps } from "./reactflowUtils";
@@ -89,7 +90,7 @@ export const TaskNode = ({
}}
taskInstance={taskInstance}
>
- <Box
+ <Flex
// Alternate background color for nested open groups
bg={isOpen && depth !== undefined && depth % 2 === 0 ? "bg.muted"
: "bg"}
borderColor={
@@ -97,8 +98,8 @@ export const TaskNode = ({
}
borderRadius={5}
borderWidth={isSelected ? 4 : 2}
+ direction="column"
height={`${height + (isSelected ? 4 : 0)}px`}
- justifyContent="space-between"
overflow="hidden"
position="relative"
px={isSelected ? 1 : 2}
@@ -151,7 +152,13 @@ export const TaskNode = ({
{translate("graph.taskCount", { count: childCount ?? 0 })}
</Button>
) : undefined}
- </Box>
+ {Boolean(isMapped) || Boolean(isGroup && !isOpen) ? (
+ <SegmentedStateBar
+ childStates={taskInstance?.child_states ?? null}
+ fallbackState={taskInstance?.state}
+ />
+ ) : undefined}
+ </Flex>
</TaskInstanceTooltip>
{Boolean(isMapped) || Boolean(isGroup && !isOpen) ? (
<>
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
b/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
index 7d1711efe64..31fbff3e394 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
@@ -197,7 +197,8 @@ const generateElkGraph = ({
const label = `${node.label}${node.is_mapped ? "[1000]" :
""}${node.children ? ` + ${node.children.length} tasks` : ""}`;
let width = getTextWidth(label, font);
- let height = 80;
+ const hasStateBar = Boolean(node.is_mapped) || Boolean(node.children);
+ let height = hasStateBar ? 90 : 80;
if (node.type === "join") {
width = 10;
diff --git a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
index a3494ee4a0b..6dbec7a6b4b 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Text } from "@chakra-ui/react";
+import { Box, HStack, Text, VStack } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import type {
@@ -26,7 +26,7 @@ import type {
} from "openapi/requests/types.gen";
import Time from "src/components/Time";
import { Tooltip, type TooltipProps } from "src/components/ui";
-import { renderDuration } from "src/utils";
+import { renderDuration, sortStateEntries } from "src/utils";
type Props = {
readonly taskInstance?: LightGridTaskInstanceSummary |
TaskInstanceHistoryResponse | TaskInstanceResponse;
@@ -35,6 +35,11 @@ type Props = {
const TaskInstanceTooltip = ({ children, positioning, taskInstance, ...rest }:
Props) => {
const { t: translate } = useTranslation("common");
+ const childEntries =
+ taskInstance !== undefined && "child_states" in taskInstance &&
taskInstance.child_states !== null
+ ? sortStateEntries(taskInstance.child_states)
+ : [];
+
return taskInstance === undefined ? (
children
) : (
@@ -48,6 +53,25 @@ const TaskInstanceTooltip = ({ children, positioning,
taskInstance, ...rest }: P
? translate(`common:states.${taskInstance.state}`)
: translate("common:states.no_status")}
</Text>
+ {childEntries.length > 0 ? (
+ <VStack align="start" gap={1} mt={1}>
+ {childEntries.map(([state, count]) => (
+ <HStack gap={2} key={state}>
+ <Box
+ bg={`${state}.solid`}
+ border="1px solid"
+ borderColor="border.emphasized"
+ borderRadius="2px"
+ height="10px"
+ width="10px"
+ />
+ <Text fontSize="xs">
+ {count} {translate(`common:states.${state}`)}
+ </Text>
+ </HStack>
+ ))}
+ </VStack>
+ ) : undefined}
{"dag_run_id" in taskInstance ? (
<Text>
{translate("runId")}: {taskInstance.dag_run_id}
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts
b/airflow-core/src/airflow/ui/src/utils/index.ts
index c8d15c7cdb5..e9f6904d96e 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/index.ts
@@ -23,3 +23,4 @@ export { getMetaKey } from "./getMetaKey";
export { useContainerWidth } from "./useContainerWidth";
export { useFiltersHandler, type FilterableSearchParamsKeys } from
"./useFiltersHandler";
export * from "./query";
+export { STATE_PRIORITY, sortStateEntries } from "./stateUtils";
diff --git a/airflow-core/src/airflow/ui/src/utils/stateUtils.test.ts
b/airflow-core/src/airflow/ui/src/utils/stateUtils.test.ts
new file mode 100644
index 00000000000..c123e43c700
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/stateUtils.test.ts
@@ -0,0 +1,97 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import { sortStateEntries } from "./stateUtils";
+
+describe("sortStateEntries", () => {
+ it("returns empty array for null input", () => {
+ expect(sortStateEntries(null)).toEqual([]);
+ });
+
+ it("returns empty array for undefined input", () => {
+ expect(sortStateEntries(undefined)).toEqual([]);
+ });
+
+ it("filters out zero-count entries", () => {
+ const result = sortStateEntries({ failed: 0, running: 2, success: 0 });
+
+ expect(result).toEqual([["running", 2]]);
+ });
+
+ it("sorts entries by state priority (highest priority first)", () => {
+ const result = sortStateEntries({
+ running: 3,
+ scheduled: 4,
+ success: 1,
+ });
+
+ expect(result).toEqual([
+ ["running", 3],
+ ["scheduled", 4],
+ ["success", 1],
+ ]);
+ });
+
+ it("places deferred above queued and scheduled", () => {
+ const result = sortStateEntries({
+ deferred: 2,
+ queued: 3,
+ scheduled: 1,
+ });
+
+ expect(result).toEqual([
+ ["deferred", 2],
+ ["queued", 3],
+ ["scheduled", 1],
+ ]);
+ });
+
+ it("places failed before running and success", () => {
+ const result = sortStateEntries({
+ failed: 1,
+ running: 2,
+ success: 5,
+ });
+
+ expect(result).toEqual([
+ ["failed", 1],
+ ["running", 2],
+ ["success", 5],
+ ]);
+ });
+
+ it("sorts unknown states to the end", () => {
+ const result = sortStateEntries({
+ running: 2,
+ success: 1,
+ unknown_state: 3,
+ });
+
+ expect(result).toEqual([
+ ["running", 2],
+ ["success", 1],
+ ["unknown_state", 3],
+ ]);
+ });
+
+ it("returns empty array when all counts are zero", () => {
+ expect(sortStateEntries({ failed: 0, running: 0, success: 0
})).toEqual([]);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/utils/stateUtils.ts
b/airflow-core/src/airflow/ui/src/utils/stateUtils.ts
new file mode 100644
index 00000000000..ea4de7a7b40
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/stateUtils.ts
@@ -0,0 +1,64 @@
+/*!
+ * 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.
+ */
+
+/**
+ * Priority ordering of task instance states for visual display (segmented
state
+ * bar, tooltip breakdown). Failure/error states first, then active states
+ * (running, deferred), then pending states (queued, scheduled), then terminal
+ * states (success, skipped, removed).
+ *
+ * Keep in sync with state_priority in api_fastapi/common/parameters.py.
+ */
+export const STATE_PRIORITY: Array<string> = [
+ "failed",
+ "upstream_failed",
+ "up_for_retry",
+ "up_for_reschedule",
+ "running",
+ "restarting",
+ "deferred",
+ "queued",
+ "scheduled",
+ "success",
+ "skipped",
+ "removed",
+];
+
+/**
+ * Sort child_states entries by priority (highest priority first) and filter
out
+ * entries with zero counts. Unknown states are sorted to the end.
+ */
+export const sortStateEntries = (
+ childStates: Record<string, number> | null | undefined,
+): Array<[string, number]> => {
+ if (!childStates) {
+ return [];
+ }
+
+ return Object.entries(childStates)
+ .filter(([, count]) => count > 0)
+ .sort(([stateA], [stateB]) => {
+ const idxA = STATE_PRIORITY.indexOf(stateA);
+ const idxB = STATE_PRIORITY.indexOf(stateB);
+ const priorityA = idxA === -1 ? STATE_PRIORITY.length : idxA;
+ const priorityB = idxB === -1 ? STATE_PRIORITY.length : idxB;
+
+ return priorityA - priorityB;
+ });
+};