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