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®ion_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",
},