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 ed55e14b01986..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", @@ -38,7 +43,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", @@ -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 50d1d9d8e6a61..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,11 +17,21 @@ * under the License. */ import "@testing-library/jest-dom"; -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +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,55 @@ 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" })).toBeInTheDocument(); + }); + + 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.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 971354ebe55b5..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"), }, { @@ -225,7 +225,6 @@ export const DagRuns = () => { const { setTableURLState, tableURLState } = useTableURLState({ columnVisibility: { - conf: false, dag_version: false, end_date: false, partition_key: false, 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);