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 c0c12273f61 UI: align backend state aggregation with 
active-over-pending priority (#67543)
c0c12273f61 is described below

commit c0c12273f61bebddc00b9d09db55e7754052757e
Author: Nathan Hadfield <[email protected]>
AuthorDate: Mon Jun 8 20:05:21 2026 +0100

    UI: align backend state aggregation with active-over-pending priority 
(#67543)
    
    Reorders state_priority in api_fastapi/common/parameters.py so RUNNING /
    RESTARTING / DEFERRED / AWAITING_INPUT outrank QUEUED / SCHEDULED, matching
    the frontend STATE_PRIORITY ordering. A collapsed group with one queued and
    five running children now aggregates as running rather than queued, so its
    badge / border / icon reflect the most-active state.
    
    Also updates the graph state filter to match against any contained child
    state for aggregates (groups / mapped tasks) rather than only the dominant
    state, so filtering for "running" no longer hides collapsed groups that
    contain running children.
---
 .../src/airflow/api_fastapi/common/parameters.py   |  8 ++--
 .../Details/Graph/useGraphFilteredNodes.test.ts    | 49 ++++++++++++++++++++++
 .../layouts/Details/Graph/useGraphFilteredNodes.ts | 30 +++++++++++--
 3 files changed, 79 insertions(+), 8 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters.py 
b/airflow-core/src/airflow/api_fastapi/common/parameters.py
index 54c4471e7ba..685736ed2f4 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters.py
@@ -1617,12 +1617,12 @@ state_priority: list[None | TaskInstanceState] = [
     TaskInstanceState.UPSTREAM_FAILED,
     TaskInstanceState.UP_FOR_RETRY,
     TaskInstanceState.UP_FOR_RESCHEDULE,
-    TaskInstanceState.QUEUED,
-    TaskInstanceState.SCHEDULED,
-    TaskInstanceState.DEFERRED,
-    TaskInstanceState.AWAITING_INPUT,
     TaskInstanceState.RUNNING,
     TaskInstanceState.RESTARTING,
+    TaskInstanceState.DEFERRED,
+    TaskInstanceState.AWAITING_INPUT,
+    TaskInstanceState.QUEUED,
+    TaskInstanceState.SCHEDULED,
     None,
     TaskInstanceState.SUCCESS,
     TaskInstanceState.SKIPPED,
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.test.ts
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.test.ts
index 3ae56dc68f6..150a2a5e55b 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.test.ts
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.test.ts
@@ -171,6 +171,55 @@ describe("useGraphFilteredNodes", () => {
     expect(result.current?.[0]?.data.isFiltered).toBe(true);
   });
 
+  it("state filter: keeps an aggregate node when any contained child state is 
selected", () => {
+    const node = makeNode({
+      taskInstance: {
+        child_states: { queued: 1, running: 5 },
+        dag_version_number: undefined,
+        max_end_date: null,
+        min_start_date: null,
+        // agg_state reports the dominant state, but the filter must match on
+        // membership ("the group contains queued work"), not the dominant one.
+        state: "running",
+        task_display_name: "group_a",
+        task_id: "group_a",
+      },
+    });
+    const runningFilter: GraphFilterValues = { ...noFilters, selectedStates: 
["running"] };
+    const queuedFilter: GraphFilterValues = { ...noFilters, selectedStates: 
["queued"] };
+    const failedFilter: GraphFilterValues = { ...noFilters, selectedStates: 
["failed"] };
+
+    const runningResult = renderHook(() => useGraphFilteredNodes([node], 
runningFilter));
+    const queuedResult = renderHook(() => useGraphFilteredNodes([node], 
queuedFilter));
+    const failedResult = renderHook(() => useGraphFilteredNodes([node], 
failedFilter));
+
+    expect(runningResult.result.current?.[0]?.data.isFiltered).toBe(false);
+    expect(queuedResult.result.current?.[0]?.data.isFiltered).toBe(false);
+    expect(failedResult.result.current?.[0]?.data.isFiltered).toBe(true);
+  });
+
+  it('state filter: matches an aggregate with no-status children against the 
"none" filter', () => {
+    const node = makeNode({
+      taskInstance: {
+        child_states: { none: 2, success: 1 },
+        dag_version_number: undefined,
+        max_end_date: null,
+        min_start_date: null,
+        state: "success",
+        task_display_name: "group_a",
+        task_id: "group_a",
+      },
+    });
+    const noneFilter: GraphFilterValues = { ...noFilters, selectedStates: 
["none"] };
+    const runningFilter: GraphFilterValues = { ...noFilters, selectedStates: 
["running"] };
+
+    const noneResult = renderHook(() => useGraphFilteredNodes([node], 
noneFilter));
+    const runningResult = renderHook(() => useGraphFilteredNodes([node], 
runningFilter));
+
+    expect(noneResult.result.current?.[0]?.data.isFiltered).toBe(false);
+    expect(runningResult.result.current?.[0]?.data.isFiltered).toBe(true);
+  });
+
   it("mapIndex filter: filters a non-mapped node when mapIndex is set", () => {
     const node = makeNode({ isMapped: false });
     const filters: GraphFilterValues = { ...noFilters, mapIndex: 0 };
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.ts
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.ts
index 8952ecfdc6a..07390759f09 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.ts
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.ts
@@ -83,10 +83,32 @@ const isNodeFiltered = (node: 
ReactFlowNode<CustomNodeProps>, filters: GraphFilt
   }
 
   if (filters.selectedStates.length > 0) {
-    const state = taskInstance?.state ?? "none";
-
-    if (!filters.selectedStates.includes(state)) {
-      return true;
+    // For aggregates (groups / mapped tasks), keep the node if *any* contained
+    // child state is selected — operators using the state filter are usually
+    // asking "find work in this state", not "find aggregates whose dominant
+    // rendering is this state", so collapsed groups must not hide matching
+    // children.
+    const childStates = taskInstance?.child_states;
+    const hasChildStates =
+      childStates !== null &&
+      childStates !== undefined &&
+      Object.values(childStates).some((count) => count > 0);
+
+    if (hasChildStates) {
+      const containedStates = Object.entries(childStates)
+        .filter(([, count]) => count > 0)
+        .map(([state]) => state);
+
+      if (!containedStates.some((state) => 
filters.selectedStates.includes(state))) {
+        return true;
+      }
+    } else {
+      // Leaf task (or no instance yet) — fall back to the single state.
+      const state = taskInstance?.state ?? "none";
+
+      if (!filters.selectedStates.includes(state)) {
+        return true;
+      }
     }
   }
 

Reply via email to