This is an automated email from the ASF dual-hosted git repository.

vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit ae879e3c5abe3d69eb46036ced8541726b0c6f0e
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 30 11:04:52 2026 +0000

    [v3-4-test] UI: Show a loop's iterations and why each one decided (#75984) 
(#75993)
    
    A loop's gate can explain itself -- LoopDecision carries a reason -- but the
    only places that showed one were a single task instance's header and the
    group header's final outcome. Seeing why a loop ran as long as it did meant
    opening each pass in turn and reading them off one at a time.
    
    Loops already report every pass in one summary, so this needs nothing new 
from
    the API. Picking a pass scopes the group's task instances to it, since 
reading
    why a pass decided as it did tends to lead to the tasks it ran.
    (cherry picked from commit 6e6ae1ab7d26161fa9f7530287f3681c67431740)
    
    Co-authored-by: Brent Bovenzi <[email protected]>
    Co-authored-by: Claude <[email protected]>
---
 .../pages/GroupTaskInstance/GroupTaskInstance.tsx  |  11 +-
 .../LoopIterations/LoopIterations.test.tsx         | 136 ++++++++++++++++++++
 .../LoopIterations/LoopIterations.tsx              | 137 +++++++++++++++++++++
 .../GroupTaskInstance/LoopIterations/loopUtils.ts  |   2 +-
 .../ui/src/pages/GroupTaskInstance/index.ts        |   1 +
 airflow-core/src/airflow/ui/src/router.tsx         |   7 +-
 6 files changed, 289 insertions(+), 5 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
index 1b7110d0a23..d59745b9159 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
@@ -18,12 +18,13 @@
  */
 import { ReactFlowProvider } from "@xyflow/react";
 import { useTranslation } from "react-i18next";
-import { MdOutlineTask } from "react-icons/md";
+import { MdLoop, MdOutlineTask } from "react-icons/md";
 import { useParams } from "react-router-dom";
 
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 
 import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
+import { useIsLoopGroup } from "src/queries/useIsLoopGroup";
 import { useDocumentTitle } from "src/utils";
 
 import { Header } from "./Header";
@@ -38,7 +39,13 @@ export const GroupTaskInstance = () => {
   const gridTISummaries = summariesByRunId.get(runId);
   const taskInstance = gridTISummaries?.task_instances.find((ti) => ti.task_id 
=== groupId);
 
-  const tabs = [{ icon: <MdOutlineTask />, label: 
translate("tabs.taskInstances"), value: "" }];
+  const isLoopGroup = useIsLoopGroup(groupId);
+
+  const tabs = [
+    { icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value: 
"" },
+    // Only a looped group has passes to list.
+    ...(isLoopGroup ? [{ icon: <MdLoop />, label: 
translate("loop.iterations"), value: "iterations" }] : []),
+  ];
 
   return (
     <ReactFlowProvider>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/LoopIterations.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/LoopIterations.test.tsx
new file mode 100644
index 00000000000..21f6d0378bd
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/LoopIterations.test.tsx
@@ -0,0 +1,136 @@
+/*!
+ * 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 { PropsWithChildren } from "react";
+
+import "@testing-library/jest-dom/vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { LoopSummaryResponse } from "openapi/requests/types.gen";
+
+import { TimezoneProvider } from "src/context/timezone";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { LoopIterations } from "./LoopIterations";
+
+let mockSummary: LoopSummaryResponse | undefined;
+
+vi.mock("src/queries/useLoopSummary", () => ({
+  useLoopSummary: () => ({ data: mockSummary }),
+}));
+
+const GROUP_PATH = "/dags/my_dag/runs/run_1/tasks/group/refine";
+
+const buildSummary = (overrides: Partial<LoopSummaryResponse> = {}): 
LoopSummaryResponse => ({
+  dag_id: "my_dag",
+  group_id: "refine",
+  iterations: [],
+  iterations_ran: 2,
+  max_iterations: 3,
+  run_id: "run_1",
+  status: "stopped_early",
+  ...overrides,
+});
+
+const renderAt = (search = "") => {
+  const wrapper = ({ children }: PropsWithChildren) => (
+    <BaseWrapper>
+      <MemoryRouter initialEntries={[`${GROUP_PATH}/iterations${search}`]}>
+        <TimezoneProvider>
+          <Routes>
+            {/* Parameterised so useParams gives the component its dag, run 
and group. */}
+            <Route element={children} 
path="/dags/:dagId/runs/:runId/tasks/group/:groupId/iterations" />
+          </Routes>
+        </TimezoneProvider>
+      </MemoryRouter>
+    </BaseWrapper>
+  );
+
+  return render(<LoopIterations />, { wrapper });
+};
+
+beforeEach(() => {
+  mockSummary = undefined;
+});
+
+describe("LoopIterations", () => {
+  it("renders a row per pass with its decision reason", () => {
+    mockSummary = buildSummary({
+      iterations: [
+        { decision_reason: "error 0.17, needs < 1e-06", index: 1, state: 
"success", task_count: 2 },
+        { decision_reason: "error 8.9e-07, needs < 1e-06", index: 2, state: 
"success", task_count: 2 },
+      ],
+      stopped_at_iteration: 2,
+    });
+
+    renderAt();
+
+    expect(screen.getByText("error 0.17, needs < 1e-06")).toBeInTheDocument();
+    expect(screen.getByText("error 8.9e-07, needs < 
1e-06")).toBeInTheDocument();
+  });
+
+  it("links each pass to the group's task instances scoped to it", () => {
+    mockSummary = buildSummary({
+      iterations: [
+        { index: 1, state: "success" },
+        { index: 2, state: "failed" },
+      ],
+    });
+
+    renderAt();
+
+    // The i18n mock echoes the key, so every row's link carries the same 
accessible name.
+    const links = screen.getAllByRole("link");
+    // Newest first, so the top row is the last pass that ran.
+    const href = links[0]?.getAttribute("href") ?? "";
+    const [pathname, search] = href.split("?");
+
+    expect(links).toHaveLength(2);
+    expect(links.map((link) => link.textContent)).toStrictEqual(["2", "1"]);
+    const params = new URLSearchParams(search);
+
+    expect(pathname).toBe(GROUP_PATH);
+    expect(params.get("loop_id")).toBe("refine");
+    expect(params.get("iteration")).toBe("2");
+  });
+
+  it("drops a pinned row and any paging when a pass is picked", () => {
+    mockSummary = buildSummary({ iterations: [{ index: 1, state: "success" }] 
});
+
+    renderAt("?region_id=r1&region_index=0&offset=20&dag_run_state=failed");
+
+    const href = screen.getAllByRole("link")[0]?.getAttribute("href") ?? "";
+    const params = new URLSearchParams(href.split("?")[1]);
+
+    expect(params.get("region_id")).toBeNull();
+    expect(params.get("region_index")).toBeNull();
+    expect(params.get("offset")).toBeNull();
+    // Filters the page was reached with are not this control's to discard.
+    expect(params.get("dag_run_state")).toBe("failed");
+  });
+
+  it("says so when the loop has reported no passes yet", () => {
+    mockSummary = buildSummary({ iterations: [] });
+
+    renderAt();
+
+    expect(screen.getByText(/noItemsFound/iu)).toBeInTheDocument();
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/LoopIterations.tsx
 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/LoopIterations.tsx
new file mode 100644
index 00000000000..31876ec788f
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/LoopIterations.tsx
@@ -0,0 +1,137 @@
+/*!
+ * 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 { Link as ChakraLink, Table, Text } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { Link as RouterLink, useParams, useSearchParams } from 
"react-router-dom";
+
+import { LoopDecisionReason } from "src/components/LoopDecisionReason";
+import { StateBadge } from "src/components/StateBadge";
+import Time from "src/components/Time";
+
+import { SearchParamsKeys } from "src/constants/searchParams";
+import { useLoopSummary } from "src/queries/useLoopSummary";
+import { useDurationFormat } from "src/utils";
+
+import { reasonSentence } from "./loopUtils";
+
+/**
+ * Every pass the loop ran, with the gate's reason for each.
+ *
+ * The header reports only the loop's final outcome, and the iteration 
switcher only labels a pass,
+ * so this is where a reason per pass is readable -- which is what you need to 
see why a loop ran
+ * as long as it did.
+ */
+export const LoopIterations = () => {
+  const { dagId = "", groupId = "", runId = "" } = useParams();
+  // "dag" leads so reasonSentence's unprefixed loop.* keys resolve; common 
keys are prefixed.
+  const { t: translate } = useTranslation(["dag", "common"]);
+  const { formatElapsed } = useDurationFormat();
+  const [searchParams] = useSearchParams();
+  const { data: summary } = useLoopSummary({ dagId, groupId, runId });
+
+  const iterations = summary?.iterations ?? [];
+
+  // Picking a pass scopes the group's task instances to it, the same move the 
breadcrumb's
+  // iteration switcher makes. A coordinate addresses one row and this names a 
whole pass, so the
+  // old one is dropped rather than sending you to a row that pass may not 
have; paging restarts
+  // because a cursor into one pass means nothing in another.
+  const iterationSearch = (index: number) => {
+    const next = new URLSearchParams(searchParams);
+
+    next.set(SearchParamsKeys.LOOP_ID, groupId);
+    next.set(SearchParamsKeys.ITERATION, String(index));
+    next.delete(SearchParamsKeys.REGION_ID);
+    next.delete(SearchParamsKeys.REGION_INDEX);
+    next.delete(SearchParamsKeys.CURSOR);
+    next.delete(SearchParamsKeys.OFFSET);
+
+    return next.toString();
+  };
+
+  if (iterations.length === 0) {
+    return (
+      <Text color="fg.muted" py={4}>
+        {translate("common:noItemsFound", { modelName: 
translate("loop.iterations") })}
+      </Text>
+    );
+  }
+
+  // The pass that ended the loop, whichever way it ended; its row carries the 
outcome sentence.
+  const endedAt = summary?.failed_at_iteration ?? 
summary?.stopped_at_iteration;
+  // Newest first: the pass you came to read about is the one that just ran, 
and a loop near its
+  // limit would otherwise bury it. The summary counts them up, so this 
reverses a copy.
+  const newestFirst = [...iterations].reverse();
+
+  return (
+    <Table.Root minW="max-content" striped>
+      <Table.Header>
+        <Table.Row>
+          <Table.ColumnHeader 
w="110px">{translate("common:filters.iteration")}</Table.ColumnHeader>
+          <Table.ColumnHeader 
w="120px">{translate("common:state")}</Table.ColumnHeader>
+          <Table.ColumnHeader 
w="170px">{translate("common:startDate")}</Table.ColumnHeader>
+          <Table.ColumnHeader 
w="110px">{translate("common:duration")}</Table.ColumnHeader>
+          <Table.ColumnHeader 
w="90px">{translate("common:task_other")}</Table.ColumnHeader>
+          
<Table.ColumnHeader>{translate("common:taskInstance.loopDecision")}</Table.ColumnHeader>
+        </Table.Row>
+      </Table.Header>
+      <Table.Body>
+        {newestFirst.map((iteration) => {
+          const ended = iteration.index === endedAt;
+
+          return (
+            <Table.Row
+              key={iteration.index}
+              title={ended && summary ? reasonSentence(translate, summary) : 
undefined}
+            >
+              <Table.Cell>
+                <ChakraLink asChild color="fg.info">
+                  {/* ".." is the group's task instances tab, this tab's 
sibling. */}
+                  <RouterLink
+                    aria-label={translate("loop.iterationLabel", { index: 
iteration.index })}
+                    relative="path"
+                    to={{ pathname: "..", search: 
iterationSearch(iteration.index) }}
+                  >
+                    {iteration.index}
+                  </RouterLink>
+                </ChakraLink>
+              </Table.Cell>
+              <Table.Cell>
+                {iteration.state === null || iteration.state === undefined ? 
undefined : (
+                  <StateBadge fontSize="xs" state={iteration.state}>
+                    {translate(`common:states.${iteration.state}`)}
+                  </StateBadge>
+                )}
+              </Table.Cell>
+              <Table.Cell>
+                <Time datetime={iteration.start_date} />
+              </Table.Cell>
+              <Table.Cell>{formatElapsed(iteration.start_date, 
iteration.end_date)}</Table.Cell>
+              <Table.Cell>{iteration.task_count}</Table.Cell>
+              <Table.Cell>
+                {iteration.decision_reason === undefined ? undefined : (
+                  <LoopDecisionReason reason={iteration.decision_reason} />
+                )}
+              </Table.Cell>
+            </Table.Row>
+          );
+        })}
+      </Table.Body>
+    </Table.Root>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/loopUtils.ts
 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/loopUtils.ts
index 1d2833138e6..fcde1c43035 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/loopUtils.ts
+++ 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/LoopIterations/loopUtils.ts
@@ -18,7 +18,7 @@
  */
 import type { LoopSummaryResponse } from "openapi/requests/types.gen";
 
-/** Value of the iteration URL parameter that selects every iteration. */
+/** One sentence saying how the loop ended, for the group header and the 
iterations table. */
 export const reasonSentence = (
   translate: (key: string, options?: Record<string, unknown>) => string,
   summary: LoopSummaryResponse,
diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts
index 5f9e069feee..b89e107d718 100644
--- a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts
@@ -18,3 +18,4 @@
  */
 export { GroupTaskInstance } from "./GroupTaskInstance";
 export { GroupTaskInstances } from "./GroupTaskInstances";
+export { LoopIterations } from "./LoopIterations/LoopIterations";
diff --git a/airflow-core/src/airflow/ui/src/router.tsx 
b/airflow-core/src/airflow/ui/src/router.tsx
index 13998d22cb8..b7d43ad4115 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -48,7 +48,7 @@ import { Deadlines } from "src/pages/Deadlines";
 import { ErrorPage } from "src/pages/Error";
 import { Events } from "src/pages/Events";
 import { ExternalView } from "src/pages/ExternalView";
-import { GroupTaskInstance, GroupTaskInstances } from 
"src/pages/GroupTaskInstance";
+import { GroupTaskInstance, GroupTaskInstances, LoopIterations } from 
"src/pages/GroupTaskInstance";
 import { HITLTaskInstances } from "src/pages/HITLTaskInstances";
 import { Jobs } from "src/pages/Jobs";
 import { LandingPage } from "src/pages/LandingPage";
@@ -271,7 +271,10 @@ export const routerConfig = [
         path: "dags/:dagId/runs/:runId/tasks/:taskId/mapped",
       },
       {
-        children: [{ element: <GroupTaskInstances />, index: true }],
+        children: [
+          { element: <GroupTaskInstances />, index: true },
+          { element: <LoopIterations />, path: "iterations" },
+        ],
         element: <GroupTaskInstance />,
         path: "dags/:dagId/runs/:runId/tasks/group/:groupId",
       },

Reply via email to