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;
+    });
+};

Reply via email to