From 0b2ff894d8609d38b84fcee559b81d5414ff3285 Mon Sep 17 00:00:00 2001 From: ashutosh264 Date: Tue, 23 Jun 2026 19:27:12 +0530 Subject: [PATCH 1/2] UI: Show Dag Run conf column by default in Dag Runs list Airflow 3 hid the conf column even though the API and column definition already existed. Operators triggering Dags with run conf need that context in the list view without toggling columns manually. closes: #53382 --- .../airflow/ui/src/mocks/handlers/dag_runs.ts | 2 +- .../ui/src/pages/DagRuns/DagRuns.test.tsx | 36 ++++++++++++++++++- .../airflow/ui/src/pages/DagRuns/DagRuns.tsx | 1 - 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts b/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts index ed55e14b01986..61426a8481d19 100644 --- a/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts +++ b/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts @@ -38,7 +38,7 @@ const dagRunBeforeFilter = { }; const dagRunInRange = { - conf: null, + conf: { env: "prod" }, dag_display_name: "test_dag", dag_id: "test_dag", dag_run_id: "run_in_range", diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx index 50d1d9d8e6a61..e37005fa68eef 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx @@ -18,10 +18,20 @@ */ import "@testing-library/jest-dom"; import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppWrapper } from "src/utils/AppWrapper"; +vi.mock("src/components/RenderedJsonField", () => ({ + default: ({ content }: { content: object }) => ( +
{JSON.stringify(content)}
+ ), +})); + +const clearDagRunsTablePreferences = () => { + globalThis.localStorage.removeItem("dataTable:common:dagRun:columnVisibility"); +}; + // The dag_runs mock handler (see src/mocks/handlers/dag_runs.ts) returns: // - run_before_filter (logical_date: 2024-12-31) — excluded when filtering Jan 2025 // - run_in_range (logical_date: 2025-01-15) — included when filtering Jan 2025 @@ -46,3 +56,27 @@ describe("DagRuns logical date filter", () => { expect(screen.queryByText("run_before_filter")).not.toBeInTheDocument(); }); }); + +describe("DagRuns conf column", () => { + beforeEach(() => { + clearDagRunsTablePreferences(); + }); + + afterEach(() => { + clearDagRunsTablePreferences(); + }); + + it("shows the conf column by default on the Dag Runs list", async () => { + render(); + + await waitFor(() => expect(screen.getByText("run_in_range")).toBeInTheDocument()); + expect(screen.getByRole("columnheader", { name: "dagRun.conf sortedUnsorted" })).toBeInTheDocument(); + }); + + it("renders run conf values when present", async () => { + render(); + + await waitFor(() => expect(screen.getByText("run_in_range")).toBeInTheDocument()); + expect(screen.getByTestId("rendered-json")).toHaveTextContent('{"env":"prod"}'); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx index 971354ebe55b5..06374d10b27a1 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx @@ -225,7 +225,6 @@ export const DagRuns = () => { const { setTableURLState, tableURLState } = useTableURLState({ columnVisibility: { - conf: false, dag_version: false, end_date: false, partition_key: false, From 748cdec9ed419436094fe0f8207a8bc33974cb8c Mon Sep 17 00:00:00 2001 From: ashutosh264 Date: Tue, 23 Jun 2026 19:40:03 +0530 Subject: [PATCH 2/2] UI: Render Dag Run conf as compact preview with lazy-loaded dialog Inline JSON editors made the Dag Runs table noisy when conf was shown by default. Use a key-count badge and truncated preview in the list, with full conf loaded only when the operator opens the dialog. --- .../newsfragments/68904.improvement.rst | 1 + .../ui/public/i18n/locales/en/common.json | 2 + .../airflow/ui/src/mocks/handlers/dag_runs.ts | 32 +++++++- .../ui/src/pages/DagRuns/DagRunConfCell.tsx | 78 +++++++++++++++++++ .../ui/src/pages/DagRuns/DagRuns.test.tsx | 36 ++++++++- .../airflow/ui/src/pages/DagRuns/DagRuns.tsx | 10 +-- .../ui/src/pages/DagRuns/dagRunConf.test.ts | 56 +++++++++++++ .../ui/src/pages/DagRuns/dagRunConf.ts | 43 ++++++++++ 8 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 airflow-core/newsfragments/68904.improvement.rst create mode 100644 airflow-core/src/airflow/ui/src/pages/DagRuns/DagRunConfCell.tsx create mode 100644 airflow-core/src/airflow/ui/src/pages/DagRuns/dagRunConf.test.ts create mode 100644 airflow-core/src/airflow/ui/src/pages/DagRuns/dagRunConf.ts diff --git a/airflow-core/newsfragments/68904.improvement.rst b/airflow-core/newsfragments/68904.improvement.rst new file mode 100644 index 0000000000000..4b8b4068e50a8 --- /dev/null +++ b/airflow-core/newsfragments/68904.improvement.rst @@ -0,0 +1 @@ +Show Dag Run conf by default in the Dag Runs list. diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index 3aaa5115bb2f5..1fab3fe28dfa1 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -62,6 +62,8 @@ "dagId": "Dag ID", "dagRun": { "conf": "Conf", + "confKeyCount_one": "{{count}} key", + "confKeyCount_other": "{{count}} keys", "dagVersions": "Dag Version(s)", "dataIntervalEnd": "Data Interval End", "dataIntervalStart": "Data Interval Start", diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts b/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts index 61426a8481d19..04c1ac9487bf5 100644 --- a/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts +++ b/airflow-core/src/airflow/ui/src/mocks/handlers/dag_runs.ts @@ -18,6 +18,11 @@ */ import { http, HttpResponse, type HttpHandler } from "msw"; +const dagRunConfMatchesFilter = ( + conf: Record | null, + needle: string, +): boolean => JSON.stringify(conf ?? {}).includes(needle); + const dagRunBeforeFilter = { conf: null, dag_display_name: "test_dag", @@ -56,13 +61,33 @@ const dagRunInRange = { triggering_user_name: "admin", }; +const dagRunEmptyConf = { + conf: {}, + dag_display_name: "test_dag", + dag_id: "test_dag", + dag_run_id: "run_empty_conf", + dag_versions: [], + data_interval_end: null, + data_interval_start: null, + duration: 1.0, + end_date: "2025-01-16T00:00:01Z", + logical_date: "2025-01-16T00:00:00Z", + partition_key: null, + run_after: "2025-01-16T00:00:00Z", + run_type: "manual", + start_date: "2025-01-16T00:00:00Z", + state: "success", + triggering_user_name: "admin", +}; + +const allRuns = [dagRunBeforeFilter, dagRunInRange, dagRunEmptyConf]; + export const handlers: Array = [ http.get("/api/v2/dags/:dagId/dagRuns", ({ request }) => { const url = new URL(request.url); const logicalDateGte = url.searchParams.get("logical_date_gte"); const logicalDateLte = url.searchParams.get("logical_date_lte"); - - const allRuns = [dagRunBeforeFilter, dagRunInRange]; + const confContains = url.searchParams.get("conf_contains"); const filtered = allRuns.filter((run) => { const logicalDate = new Date(run.logical_date); @@ -73,6 +98,9 @@ export const handlers: Array = [ if (logicalDateLte !== null && logicalDate > new Date(logicalDateLte)) { return false; } + if (confContains !== null && confContains !== "" && !dagRunConfMatchesFilter(run.conf, confContains)) { + return false; + } return true; }); diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRunConfCell.tsx b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRunConfCell.tsx new file mode 100644 index 0000000000000..98d6ebc9debca --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRunConfCell.tsx @@ -0,0 +1,78 @@ +/*! + * 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 { Badge, HStack, Text } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; + +import RenderedJsonField from "src/components/RenderedJsonField"; +import { Dialog, Tooltip } from "src/components/ui"; + +import { formatDagRunConfPreview, getDagRunConfKeyCount, isDagRunConfEmpty } from "./dagRunConf"; + +type Props = { + readonly conf: Record | null | undefined; + readonly dagRunId: string; +}; + +export const DagRunConfCell = ({ conf, dagRunId }: Props) => { + const { t: translate } = useTranslation(["common", "components"]); + + if (isDagRunConfEmpty(conf)) { + return ( + + {translate("components:trimText.empty")} + + ); + } + + const keyCount = getDagRunConfKeyCount(conf); + const { isTrimmed, preview } = formatDagRunConfPreview(conf); + + return ( + + + + + + {translate("dagRun.confKeyCount", { count: keyCount })} + + + {preview} + + + + + + + {translate("dagRun.conf")} + + + + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx index e37005fa68eef..50cc65f1130aa 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx @@ -17,7 +17,7 @@ * under the License. */ import "@testing-library/jest-dom"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppWrapper } from "src/utils/AppWrapper"; @@ -70,13 +70,41 @@ describe("DagRuns conf column", () => { render(); await waitFor(() => expect(screen.getByText("run_in_range")).toBeInTheDocument()); - expect(screen.getByRole("columnheader", { name: "dagRun.conf sortedUnsorted" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "dagRun.conf" })).toBeInTheDocument(); }); - it("renders run conf values when present", async () => { + it("renders a compact preview for runs with conf", async () => { render(); + await waitFor(() => expect(screen.getByTestId("dag-run-conf-preview-run_in_range")).toBeInTheDocument()); + expect(screen.getByTestId("dag-run-conf-preview-run_in_range")).toHaveTextContent('{"env":"prod"}'); + expect(screen.queryByTestId("rendered-json")).not.toBeInTheDocument(); + }); + + it("normalizes null and empty conf into the same empty display state", async () => { + render(); + + await waitFor(() => expect(screen.getByText("run_empty_conf")).toBeInTheDocument()); + expect(screen.getByTestId("dag-run-conf-empty-run_before_filter")).toHaveTextContent("trimText.empty"); + expect(screen.getByTestId("dag-run-conf-empty-run_empty_conf")).toHaveTextContent("trimText.empty"); + }); + + it("lazy-loads full conf JSON only after opening the preview dialog", async () => { + render(); + + await waitFor(() => expect(screen.getByTestId("dag-run-conf-preview-run_in_range")).toBeInTheDocument()); + expect(screen.queryByTestId("rendered-json")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("dag-run-conf-preview-run_in_range")); + + await waitFor(() => expect(screen.getByTestId("rendered-json")).toHaveTextContent('{"env":"prod"}')); + }); + + it("filters runs by conf_contains URL param", async () => { + render(); + await waitFor(() => expect(screen.getByText("run_in_range")).toBeInTheDocument()); - expect(screen.getByTestId("rendered-json")).toHaveTextContent('{"env":"prod"}'); + expect(screen.queryByText("run_before_filter")).not.toBeInTheDocument(); + expect(screen.queryByText("run_empty_conf")).not.toBeInTheDocument(); }); }); diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx index 06374d10b27a1..dbea38cf8a247 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx @@ -38,7 +38,6 @@ import { useTableURLState } from "src/components/DataTable/useTableUrlState"; import { ErrorAlert } from "src/components/ErrorAlert"; import { LimitedItemsList } from "src/components/LimitedItemsList"; import { MarkRunAsButton } from "src/components/MarkAs"; -import RenderedJsonField from "src/components/RenderedJsonField"; import { RunTypeIcon } from "src/components/RunTypeIcon"; import { StateBadge } from "src/components/StateBadge"; import Time from "src/components/Time"; @@ -52,6 +51,7 @@ import { renderDuration, useAutoRefresh, isStatePending } from "src/utils"; import BulkClearDagRunsButton from "./BulkClearDagRunsButton"; import BulkDeleteDagRunsButton from "./BulkDeleteDagRunsButton"; import BulkMarkDagRunsAsButton from "./BulkMarkDagRunsAsButton"; +import { DagRunConfCell } from "./DagRunConfCell"; import { DagRunsFilters } from "./DagRunsFilters"; import DeleteRunButton from "./DeleteRunButton"; import RunNoteButton from "./RunNoteButton"; @@ -194,10 +194,10 @@ const runColumns = ({ dagId, translate }: ColumnProps): Array - original.conf && Object.keys(original.conf).length > 0 ? ( - - ) : undefined, + cell: ({ row: { original } }) => ( + + ), + enableSorting: false, header: translate("dagRun.conf"), }, { diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/dagRunConf.test.ts b/airflow-core/src/airflow/ui/src/pages/DagRuns/dagRunConf.test.ts new file mode 100644 index 0000000000000..f6e49a4a88aec --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/dagRunConf.test.ts @@ -0,0 +1,56 @@ +/*! + * 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 { + dagRunConfMatchesFilter, + formatDagRunConfPreview, + getDagRunConfKeyCount, + isDagRunConfEmpty, +} from "./dagRunConf"; + +describe("dagRunConf helpers", () => { + it.each([ + [null, true], + [undefined, true], + [{}, true], + [{ env: "prod" }, false], + ] as const)("isDagRunConfEmpty(%j) is %s", (conf, expected) => { + expect(isDagRunConfEmpty(conf)).toBe(expected); + }); + + it("counts top-level conf keys", () => { + expect(getDagRunConfKeyCount({ a: 1, b: 2 })).toBe(2); + }); + + it("truncates long conf previews", () => { + const conf = { payload: "x".repeat(120) }; + const { isTrimmed, preview } = formatDagRunConfPreview(conf, 40); + + expect(isTrimmed).toBe(true); + expect(preview.endsWith("…")).toBe(true); + expect(preview.length).toBeLessThanOrEqual(41); + }); + + it("matches conf_contains style filters against serialized conf", () => { + expect(dagRunConfMatchesFilter({ env: "prod" }, "prod")).toBe(true); + expect(dagRunConfMatchesFilter(null, "prod")).toBe(false); + expect(dagRunConfMatchesFilter({}, "prod")).toBe(false); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/dagRunConf.ts b/airflow-core/src/airflow/ui/src/pages/DagRuns/dagRunConf.ts new file mode 100644 index 0000000000000..62e127dcc1289 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/dagRunConf.ts @@ -0,0 +1,43 @@ +/*! + * 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. + */ + +const CONF_PREVIEW_CHAR_LIMIT = 80; + +export const isDagRunConfEmpty = (conf: Record | null | undefined): boolean => + conf === null || conf === undefined || Object.keys(conf).length === 0; + +export const getDagRunConfKeyCount = (conf: Record): number => Object.keys(conf).length; + +export const formatDagRunConfPreview = ( + conf: Record, + charLimit = CONF_PREVIEW_CHAR_LIMIT, +): { isTrimmed: boolean; preview: string } => { + const serialized = JSON.stringify(conf); + const isTrimmed = serialized.length > charLimit; + + return { + isTrimmed, + preview: isTrimmed ? `${serialized.slice(0, charLimit)}…` : serialized, + }; +}; + +export const dagRunConfMatchesFilter = ( + conf: Record | null | undefined, + needle: string, +): boolean => JSON.stringify(conf ?? {}).includes(needle);