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 c1a9854e75b Feat: Add version change indicators for Dag and bundle
versions in Grid view (#53216)
c1a9854e75b is described below
commit c1a9854e75bcfb1aa68e53508d2340e5ade8bc37
Author: Yeonguk Choo <[email protected]>
AuthorDate: Thu Feb 26 06:25:13 2026 +0900
Feat: Add version change indicators for Dag and bundle versions in Grid
view (#53216)
* feat: add version indicator for DAG and bundle versions in Grid view
* Refactor version indicator handling and add VersionIndicatorSelect
component
* fix static check
* Refactor VersionIndicatorSelect placement in PanelButtons component
* Update
airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts
Co-authored-by: Pierre Jeambrun <[email protected]>
* Update
airflow-core/src/airflow/ui/src/constants/showVersionIndicatorOptions.ts
Co-authored-by: Pierre Jeambrun <[email protected]>
* Refactor version indicator options to unify naming and improve
consistency across components
* Refactor GridRunsResponse to use a list of DagVersionResponse for
versioning details
* Refactor version indicator handling to improve consistency and clarity in
local storage and component usage
---------
Co-authored-by: Pierre Jeambrun <[email protected]>
---
.../api_fastapi/core_api/datamodels/ui/common.py | 2 +
.../api_fastapi/core_api/datamodels/ui/grid.py | 1 +
.../api_fastapi/core_api/openapi/_private_ui.yaml | 11 ++
.../airflow/api_fastapi/core_api/routes/ui/grid.py | 53 ++++++--
.../api_fastapi/core_api/services/ui/grid.py | 7 ++
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 19 +++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +
.../src/airflow/ui/public/i18n/locales/en/dag.json | 9 ++
.../src/airflow/ui/src/constants/localStorage.ts | 1 +
.../src/constants/showVersionIndicatorOptions.ts | 46 +++++++
.../ui/src/layouts/Details/DetailsLayout.tsx | 11 ++
.../airflow/ui/src/layouts/Details/Grid/Bar.tsx | 27 +++-
.../airflow/ui/src/layouts/Details/Grid/Grid.tsx | 33 ++++-
.../layouts/Details/Grid/TaskInstancesColumn.tsx | 42 ++++++-
.../src/layouts/Details/Grid/VersionIndicator.tsx | 140 +++++++++++++++++++++
.../ui/src/layouts/Details/Grid/constants.ts | 10 +-
.../Details/Grid/useGridRunsWithVersionFlags.ts | 74 +++++++++++
.../ui/src/layouts/Details/PanelButtons.tsx | 12 ++
.../src/layouts/Details/VersionIndicatorSelect.tsx | 94 ++++++++++++++
.../api_fastapi/core_api/routes/ui/test_grid.py | 100 +++++++++------
20 files changed, 630 insertions(+), 64 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
index 2c5832f3246..86dd7d74bd2 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
@@ -24,6 +24,7 @@ from pydantic import computed_field
from airflow._shared.timezones import timezone
from airflow.api_fastapi.core_api.base import BaseModel
+from airflow.api_fastapi.core_api.datamodels.dag_versions import
DagVersionResponse
from airflow.utils.state import DagRunState
from airflow.utils.types import DagRunType
@@ -79,6 +80,7 @@ class GridRunsResponse(BaseModel):
run_after: datetime
state: DagRunState | None
run_type: DagRunType
+ dag_versions: list[DagVersionResponse] = []
has_missed_deadline: bool
@computed_field
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
index 46e275a803c..70b0c590c3f 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
@@ -32,6 +32,7 @@ class LightGridTaskInstanceSummary(BaseModel):
child_states: dict[TaskInstanceState | None, int] | None
min_start_date: datetime | None
max_end_date: datetime | None
+ dag_version_number: int | None = None
class GridTISummaries(BaseModel):
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index e6698ad40d1..52f237b29f6 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -2305,6 +2305,12 @@ components:
- type: 'null'
run_type:
$ref: '#/components/schemas/DagRunType'
+ dag_versions:
+ items:
+ $ref: '#/components/schemas/DagVersionResponse'
+ type: array
+ title: Dag Versions
+ default: []
has_missed_deadline:
type: boolean
title: Has Missed Deadline
@@ -2575,6 +2581,11 @@ components:
format: date-time
- type: 'null'
title: Max End Date
+ dag_version_number:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Dag Version Number
type: object
required:
- task_id
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
index 7762656415e..ca363c3adab 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
@@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Annotated, Any
import structlog
from fastapi import Depends, HTTPException, status
from sqlalchemy import exists, select
-from sqlalchemy.orm import joinedload
+from sqlalchemy.orm import joinedload, load_only, selectinload
from airflow.api_fastapi.auth.managers.models.resource_details import
DagAccessEntity
from airflow.api_fastapi.common.db.common import SessionDep, paginated_select
@@ -58,11 +58,13 @@ from airflow.api_fastapi.core_api.services.ui.task_group
import (
get_task_group_children_getter,
task_group_to_dict_grid,
)
+from airflow.models.dag import DagModel
from airflow.models.dag_version import DagVersion
from airflow.models.dagrun import DagRun
from airflow.models.deadline import Deadline
from airflow.models.serialized_dag import SerializedDagModel
from airflow.models.taskinstance import TaskInstance
+from airflow.models.taskinstancehistory import TaskInstanceHistory
log = structlog.get_logger(logger_name=__name__)
grid_router = AirflowRouter(prefix="/grid", tags=["Grid"])
@@ -282,17 +284,33 @@ def get_grid_runs(
.correlate(DagRun)
.label("has_missed_deadline")
)
- base_query = select(
- DagRun.dag_id,
- DagRun.run_id,
- DagRun.queued_at,
- DagRun.start_date,
- DagRun.end_date,
- DagRun.run_after,
- DagRun.state,
- DagRun.run_type,
- has_missed_deadline,
- ).where(DagRun.dag_id == dag_id)
+ base_query = (
+ select(DagRun, has_missed_deadline)
+ .where(DagRun.dag_id == dag_id)
+ .options(
+ load_only(
+ DagRun.dag_id,
+ DagRun.run_id,
+ DagRun.queued_at,
+ DagRun.start_date,
+ DagRun.end_date,
+ DagRun.run_after,
+ DagRun.state,
+ DagRun.run_type,
+ DagRun.bundle_version,
+ ),
+
joinedload(DagRun.dag_model).load_only(DagModel._dag_display_property_value),
+
joinedload(DagRun.created_dag_version).joinedload(DagVersion.bundle),
+ selectinload(DagRun.task_instances)
+ .load_only(TaskInstance.dag_version_id)
+ .joinedload(TaskInstance.dag_version)
+ .joinedload(DagVersion.bundle),
+ selectinload(DagRun.task_instances_histories)
+ .load_only(TaskInstanceHistory.dag_version_id)
+ .joinedload(TaskInstanceHistory.dag_version)
+ .joinedload(DagVersion.bundle),
+ )
+ )
# This comparison is to fall back to DAG timetable when no order_by is
provided
if order_by.value == [order_by.get_primary_key_string()]:
@@ -309,8 +327,14 @@ def get_grid_runs(
offset=offset,
filters=[run_after, run_type, state, triggering_user],
limit=limit,
+ return_total_entries=False,
)
- return [GridRunsResponse(**row._mapping) for row in
session.execute(dag_runs_select_filter)]
+ results = session.execute(dag_runs_select_filter).unique().all()
+ grid_runs = []
+ for run, has_missed in results:
+ run.has_missed_deadline = has_missed
+ grid_runs.append(GridRunsResponse.model_validate(run,
from_attributes=True))
+ return grid_runs
@grid_router.get(
@@ -363,7 +387,9 @@ def get_grid_ti_summaries(
TaskInstance.dag_version_id,
TaskInstance.start_date,
TaskInstance.end_date,
+ DagVersion.version_number,
)
+ .outerjoin(DagVersion, TaskInstance.dag_version_id ==
DagVersion.id)
.where(TaskInstance.dag_id == dag_id)
.where(
TaskInstance.run_id == run_id,
@@ -386,6 +412,7 @@ def get_grid_ti_summaries(
"state": ti.state,
"start_date": ti.start_date,
"end_date": ti.end_date,
+ "dag_version_number": ti.version_number,
}
)
serdag = _get_serdag(
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
index 46ec2c45d98..4ee93f4f10e 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
@@ -72,11 +72,18 @@ def _get_aggs_for_node(detail):
max_end_date = max(x["end_date"] for x in detail if x["end_date"])
except ValueError:
max_end_date = None
+
+ dag_version_numbers = [
+ x.get("dag_version_number") for x in detail if
x.get("dag_version_number") is not None
+ ]
+ dag_version_number = max(dag_version_numbers) if dag_version_numbers else
None
+
return {
"state": agg_state(states),
"min_start_date": min_start_date,
"max_end_date": max_end_date,
"child_states": dict(Counter(states)),
+ "dag_version_number": dag_version_number,
}
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 2e1a39ad880..1efacb6d54c 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -8193,6 +8193,14 @@ export const $GridRunsResponse = {
run_type: {
'$ref': '#/components/schemas/DagRunType'
},
+ dag_versions: {
+ items: {
+ '$ref': '#/components/schemas/DagVersionResponse'
+ },
+ type: 'array',
+ title: 'Dag Versions',
+ default: []
+ },
has_missed_deadline: {
type: 'boolean',
title: 'Has Missed Deadline'
@@ -8308,6 +8316,17 @@ export const $LightGridTaskInstanceSummary = {
}
],
title: 'Max End Date'
+ },
+ dag_version_number: {
+ anyOf: [
+ {
+ type: 'integer'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Dag Version Number'
}
},
type: 'object',
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index a0e7c2e8d77..2309fbac85d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -2009,6 +2009,7 @@ export type GridRunsResponse = {
run_after: string;
state: DagRunState | null;
run_type: DagRunType;
+ dag_versions?: Array<DagVersionResponse>;
has_missed_deadline: boolean;
readonly duration: number;
};
@@ -2043,6 +2044,7 @@ export type LightGridTaskInstanceSummary = {
} | null;
min_start_date: string | null;
max_end_date: string | null;
+ dag_version_number?: number | null;
};
/**
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index 04380b149a8..c38b2a88870 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -121,6 +121,15 @@
"graphDirection": {
"label": "Graph Direction"
},
+ "showVersionIndicator": {
+ "label": "Show Version Indicator",
+ "options": {
+ "hideAll": "Hide All",
+ "showAll": "Show All",
+ "showBundleVersion": "Show Bundle Version",
+ "showDagVersion": "Show Dag Version"
+ }
+ },
"taskStreamFilter": {
"activeFilter": "Active filter",
"clearFilter": "Clear Filter",
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 46482baddd3..72fd47b0909 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -26,6 +26,7 @@ export const CALENDAR_VIEW_MODE_KEY = "calendar-view-mode";
export const LOG_WRAP_KEY = "log_wrap";
export const LOG_SHOW_TIMESTAMP_KEY = "log_show_timestamp";
export const LOG_SHOW_SOURCE_KEY = "log_show_source";
+export const VERSION_INDICATOR_DISPLAY_MODE_KEY =
"version_indicator_display_mode";
// Dag-scoped keys
export const dagViewKey = (dagId: string) => `dag_view-${dagId}`;
diff --git
a/airflow-core/src/airflow/ui/src/constants/showVersionIndicatorOptions.ts
b/airflow-core/src/airflow/ui/src/constants/showVersionIndicatorOptions.ts
new file mode 100644
index 00000000000..ceaa3c0d246
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/constants/showVersionIndicatorOptions.ts
@@ -0,0 +1,46 @@
+/*!
+ * 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 { createListCollection } from "@chakra-ui/react";
+
+export enum VersionIndicatorOptions {
+ ALL = "all",
+ BUNDLE_VERSION = "bundle",
+ DAG_VERSION = "dag",
+ NONE = "none",
+}
+
+const validOptions = new Set<string>(Object.values(VersionIndicatorOptions));
+
+export const isVersionIndicatorOption = (value: unknown): value is
VersionIndicatorOptions =>
+ typeof value === "string" && validOptions.has(value);
+
+export const showVersionIndicatorOptions = createListCollection({
+ items: [
+ { label: "dag:panel.showVersionIndicator.options.showAll", value:
VersionIndicatorOptions.ALL },
+ {
+ label: "dag:panel.showVersionIndicator.options.showBundleVersion",
+ value: VersionIndicatorOptions.BUNDLE_VERSION,
+ },
+ {
+ label: "dag:panel.showVersionIndicator.options.showDagVersion",
+ value: VersionIndicatorOptions.DAG_VERSION,
+ },
+ { label: "dag:panel.showVersionIndicator.options.hideAll", value:
VersionIndicatorOptions.NONE },
+ ],
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index 09d116a8370..71635ea6dbf 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -1,3 +1,5 @@
+/* eslint-disable max-lines */
+
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -50,6 +52,7 @@ import {
showGanttKey,
triggeringUserFilterKey,
} from "src/constants/localStorage";
+import { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
import { HoverProvider } from "src/context/hover";
import { OpenGroupsProvider } from "src/context/openGroups";
@@ -88,6 +91,11 @@ export const DetailsLayout = ({ children, error, isLoading,
tabs }: Props) => {
);
const [showGantt, setShowGantt] =
useLocalStorage<boolean>(showGanttKey(dagId), false);
+ // Global setting: applies to all Dags (intentionally not scoped to dagId)
+ const [showVersionIndicatorMode, setShowVersionIndicatorMode] =
useLocalStorage<VersionIndicatorOptions>(
+ `version_indicator_display_mode`,
+ VersionIndicatorOptions.ALL,
+ );
const { fitView, getZoom } = useReactFlow();
const { data: warningData } = useDagWarningServiceListDagWarnings({ dagId });
const { onClose, onOpen, open } = useDisclosure();
@@ -161,8 +169,10 @@ export const DetailsLayout = ({ children, error,
isLoading, tabs }: Props) => {
setLimit={setLimit}
setRunTypeFilter={setRunTypeFilter}
setShowGantt={setShowGantt}
+ setShowVersionIndicatorMode={setShowVersionIndicatorMode}
setTriggeringUserFilter={setTriggeringUserFilter}
showGantt={showGantt}
+ showVersionIndicatorMode={showVersionIndicatorMode}
triggeringUserFilter={triggeringUserFilter}
/>
{dagView === "graph" ? (
@@ -174,6 +184,7 @@ export const DetailsLayout = ({ children, error, isLoading,
tabs }: Props) => {
limit={limit}
runType={runTypeFilter}
showGantt={Boolean(runId) && showGantt}
+ showVersionIndicatorMode={showVersionIndicatorMode}
triggeringUser={triggeringUserFilter}
/>
{showGantt ? (
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
index 6b52198f510..c2bb3ed6d04 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
@@ -19,21 +19,27 @@
import { Flex, Box } from "@chakra-ui/react";
import { useParams, useSearchParams } from "react-router-dom";
-import type { GridRunsResponse } from "openapi/requests";
import { RunTypeIcon } from "src/components/RunTypeIcon";
+import { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
import { useHover } from "src/context/hover";
import { GridButton } from "./GridButton";
-
-const BAR_HEIGHT = 100;
+import { BundleVersionIndicator, DagVersionIndicator } from
"./VersionIndicator";
+import { BAR_HEIGHT } from "./constants";
+import {
+ getBundleVersion,
+ getMaxVersionNumber,
+ type GridRunWithVersionFlags,
+} from "./useGridRunsWithVersionFlags";
type Props = {
readonly max: number;
readonly onClick?: () => void;
- readonly run: GridRunsResponse;
+ readonly run: GridRunWithVersionFlags;
+ readonly showVersionIndicatorMode?: VersionIndicatorOptions;
};
-export const Bar = ({ max, onClick, run }: Props) => {
+export const Bar = ({ max, onClick, run, showVersionIndicatorMode }: Props) =>
{
const { dagId = "", runId } = useParams();
const [searchParams] = useSearchParams();
const { hoveredRunId, setHoveredRunId } = useHover();
@@ -53,6 +59,17 @@ export const Bar = ({ max, onClick, run }: Props) => {
position="relative"
transition="background-color 0.2s"
>
+ {run.isBundleVersionChange &&
+ (showVersionIndicatorMode === VersionIndicatorOptions.BUNDLE_VERSION ||
+ showVersionIndicatorMode === VersionIndicatorOptions.ALL) ? (
+ <BundleVersionIndicator bundleVersion={getBundleVersion(run)} />
+ ) : undefined}
+ {run.isDagVersionChange &&
+ (showVersionIndicatorMode === VersionIndicatorOptions.DAG_VERSION ||
+ showVersionIndicatorMode === VersionIndicatorOptions.ALL) ? (
+ <DagVersionIndicator dagVersionNumber={getMaxVersionNumber(run)}
orientation="vertical" />
+ ) : undefined}
+
<Flex
alignItems="flex-end"
height={BAR_HEIGHT}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
index 481dcf08033..517f5d9f7d4 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
@@ -20,12 +20,13 @@ import { Box, Flex, IconButton } from "@chakra-ui/react";
import { useVirtualizer } from "@tanstack/react-virtual";
import dayjs from "dayjs";
import dayjsDuration from "dayjs/plugin/duration";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FiChevronsRight } from "react-icons/fi";
import { Link, useParams, useSearchParams } from "react-router-dom";
import type { DagRunState, DagRunType, GridRunsResponse } from
"openapi/requests";
+import type { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
import { useOpenGroups } from "src/context/openGroups";
import { NavigationModes, useNavigation } from "src/hooks/navigation";
import { useGridRuns } from "src/queries/useGridRuns.ts";
@@ -43,6 +44,7 @@ import {
GRID_OUTER_PADDING_PX,
ROW_HEIGHT,
} from "./constants";
+import { useGridRunsWithVersionFlags } from "./useGridRunsWithVersionFlags";
import { flattenNodes } from "./utils";
dayjs.extend(dayjsDuration);
@@ -52,10 +54,18 @@ type Props = {
readonly limit: number;
readonly runType?: DagRunType | undefined;
readonly showGantt?: boolean;
+ readonly showVersionIndicatorMode?: VersionIndicatorOptions;
readonly triggeringUser?: string | undefined;
};
-export const Grid = ({ dagRunState, limit, runType, showGantt, triggeringUser
}: Props) => {
+export const Grid = ({
+ dagRunState,
+ limit,
+ runType,
+ showGantt,
+ showVersionIndicatorMode,
+ triggeringUser,
+}: Props) => {
const { t: translate } = useTranslation("dag");
const gridRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -107,7 +117,13 @@ export const Grid = ({ dagRunState, limit, runType,
showGantt, triggeringUser }:
.filter((duration: number | null): duration is number => duration
!== null),
);
- const { flatNodes } = flattenNodes(dagStructure, openGroupIds);
+ // calculate version change flags
+ const runsWithVersionFlags = useGridRunsWithVersionFlags({
+ gridRuns,
+ showVersionIndicatorMode,
+ });
+
+ const { flatNodes } = useMemo(() => flattenNodes(dagStructure,
openGroupIds), [dagStructure, openGroupIds]);
const { setMode } = useNavigation({
onToggleGroup: toggleGroupId,
@@ -166,8 +182,14 @@ export const Grid = ({ dagRunState, limit, runType,
showGantt, triggeringUser }:
<DurationAxis top={`${GRID_HEADER_HEIGHT_PX / 2}px`} />
<DurationAxis top="4px" />
<Flex flexDirection="row-reverse">
- {gridRuns?.map((dr: GridRunsResponse) => (
- <Bar key={dr.run_id} max={max} onClick={handleColumnClick}
run={dr} />
+ {runsWithVersionFlags?.map((dr) => (
+ <Bar
+ key={dr.run_id}
+ max={max}
+ onClick={handleColumnClick}
+ run={dr}
+ showVersionIndicatorMode={showVersionIndicatorMode}
+ />
))}
</Flex>
{selectedIsVisible === undefined || !selectedIsVisible ?
undefined : (
@@ -202,6 +224,7 @@ export const Grid = ({ dagRunState, limit, runType,
showGantt, triggeringUser }:
nodes={flatNodes}
onCellClick={handleCellClick}
run={dr}
+ showVersionIndicatorMode={showVersionIndicatorMode}
virtualItems={virtualItems}
/>
))}
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
index dcbf7cc2120..7727748fba7 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
@@ -22,22 +22,31 @@ import { useParams } from "react-router-dom";
import type { GridRunsResponse } from "openapi/requests";
import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
import { useHover } from "src/context/hover";
import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts";
import { GridTI } from "./GridTI";
+import { DagVersionIndicator } from "./VersionIndicator";
import type { GridTask } from "./utils";
type Props = {
readonly nodes: Array<GridTask>;
readonly onCellClick?: () => void;
readonly run: GridRunsResponse;
+ readonly showVersionIndicatorMode?: VersionIndicatorOptions;
readonly virtualItems?: Array<VirtualItem>;
};
const ROW_HEIGHT = 20;
-export const TaskInstancesColumn = ({ nodes, onCellClick, run, virtualItems }:
Props) => {
+export const TaskInstancesColumn = ({
+ nodes,
+ onCellClick,
+ run,
+ showVersionIndicatorMode,
+ virtualItems,
+}: Props) => {
const { dagId = "", runId } = useParams();
const isSelected = runId === run.run_id;
const { data: gridTISummaries } = useGridTiSummaries({
@@ -52,12 +61,18 @@ export const TaskInstancesColumn = ({ nodes, onCellClick,
run, virtualItems }: P
virtualItems ?? nodes.map((_, index) => ({ index, size: ROW_HEIGHT, start:
index * ROW_HEIGHT }));
const taskInstances = gridTISummaries?.task_instances ?? [];
+
const taskInstanceMap = new Map<string, LightGridTaskInstanceSummary>();
for (const ti of taskInstances) {
taskInstanceMap.set(ti.task_id, ti);
}
+ const versionNumbers = new Set(
+ taskInstances.map((ti) => ti.dag_version_number).filter((vn) => vn !==
null && vn !== undefined),
+ );
+ const hasMixedVersions = versionNumbers.size > 1;
+
const isHovered = hoveredRunId === run.run_id;
const handleMouseEnter = () => setHoveredRunId(run.run_id);
@@ -72,7 +87,7 @@ export const TaskInstancesColumn = ({ nodes, onCellClick,
run, virtualItems }: P
transition="background-color 0.2s"
width="18px"
>
- {itemsToRender.map((virtualItem) => {
+ {itemsToRender.map((virtualItem, idx) => {
const node = nodes[virtualItem.index];
if (!node) {
@@ -95,6 +110,23 @@ export const TaskInstancesColumn = ({ nodes, onCellClick,
run, virtualItems }: P
);
}
+ let hasVersionChangeFlag = false;
+
+ if (
+ hasMixedVersions &&
+ (showVersionIndicatorMode === VersionIndicatorOptions.DAG_VERSION ||
+ showVersionIndicatorMode === VersionIndicatorOptions.ALL) &&
+ idx > 0
+ ) {
+ const prevVirtualItem = itemsToRender[idx - 1];
+ const prevNode = prevVirtualItem ? nodes[prevVirtualItem.index] :
undefined;
+ const prevTaskInstance = prevNode ? taskInstanceMap.get(prevNode.id)
: undefined;
+
+ hasVersionChangeFlag = Boolean(
+ prevTaskInstance && prevTaskInstance.dag_version_number !==
taskInstance.dag_version_number,
+ );
+ }
+
return (
<Box
key={node.id}
@@ -103,6 +135,12 @@ export const TaskInstancesColumn = ({ nodes, onCellClick,
run, virtualItems }: P
top={0}
transform={`translateY(${virtualItem.start}px)`}
>
+ {hasVersionChangeFlag && (
+ <DagVersionIndicator
+ dagVersionNumber={taskInstance.dag_version_number ?? undefined}
+ orientation="horizontal"
+ />
+ )}
<GridTI
dagId={dagId}
instance={taskInstance}
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/VersionIndicator.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/VersionIndicator.tsx
new file mode 100644
index 00000000000..908463c61ed
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/VersionIndicator.tsx
@@ -0,0 +1,140 @@
+/*!
+ * 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 { Box } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiGitCommit } from "react-icons/fi";
+
+import { Tooltip } from "src/components/ui";
+
+import {
+ BUNDLE_VERSION_ICON_SIZE,
+ BUNDLE_VERSION_INDICATOR_LEFT,
+ BUNDLE_VERSION_INDICATOR_TOP,
+ DAG_VERSION_INDICATOR_HEIGHT,
+ VERSION_INDICATOR_Z_INDEX,
+} from "./constants";
+
+type BundleVersionIndicatorProps = {
+ readonly bundleVersion: string | null | undefined;
+};
+
+export const BundleVersionIndicator = ({ bundleVersion }:
BundleVersionIndicatorProps) => {
+ const { t: translate } = useTranslation("components");
+
+ return (
+ <Tooltip content={`${translate("versionDetails.bundleVersion")}:
${bundleVersion}`}>
+ <Box
+ color="orange.focusRing"
+ left={BUNDLE_VERSION_INDICATOR_LEFT}
+ position="absolute"
+ top={BUNDLE_VERSION_INDICATOR_TOP}
+ zIndex={VERSION_INDICATOR_Z_INDEX}
+ >
+ <FiGitCommit size={BUNDLE_VERSION_ICON_SIZE} />
+ </Box>
+ </Tooltip>
+ );
+};
+
+const CONTAINER_STYLES = {
+ horizontal: {
+ height: 0.5,
+ left: "50%",
+ top: 0,
+ transform: "translate(-50%, -50%)",
+ width: 4.5,
+ },
+ vertical: {
+ height: DAG_VERSION_INDICATOR_HEIGHT,
+ left: -1.25,
+ top: -1.5,
+ width: 0.5,
+ },
+} as const;
+
+const CIRCLE_STYLES = {
+ horizontal: {
+ height: 1.5,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ width: 1.5,
+ },
+ vertical: {
+ height: 1.5,
+ left: "50%",
+ top: -1,
+ transform: "translateX(-50%)",
+ width: 1.5,
+ },
+} as const;
+
+type DagVersionIndicatorProps = {
+ readonly dagVersionNumber: number | undefined;
+ readonly orientation?: "horizontal" | "vertical";
+};
+
+export const DagVersionIndicator = ({
+ dagVersionNumber,
+ orientation = "vertical",
+}: DagVersionIndicatorProps) => {
+ const isVertical = orientation === "vertical";
+ const currentContainerStyle = CONTAINER_STYLES[orientation];
+ const currentCircleStyle = CIRCLE_STYLES[orientation];
+
+ return (
+ <Box
+ aria-label={`Version ${dagVersionNumber} indicator`}
+ as="output"
+ position="absolute"
+ zIndex={VERSION_INDICATOR_Z_INDEX}
+ {...currentContainerStyle}
+ >
+ <Box
+ bg="orange.focusRing"
+ height={isVertical ? "full" : 0.5}
+ position="absolute"
+ width={isVertical ? 0.5 : "full"}
+ />
+
+ <Tooltip
+ closeDelay={0}
+ content={`v${dagVersionNumber ?? ""}`}
+ openDelay={0}
+ portalled
+ positioning={{
+ placement: isVertical ? "top" : "right",
+ }}
+ >
+ <Box
+ _hover={{
+ cursor: "pointer",
+ transform: `${currentCircleStyle.transform} scale(1.2)`,
+ }}
+ bg="orange.focusRing"
+ borderRadius="full"
+ position="absolute"
+ transition="all 0.2s ease-in-out"
+ zIndex="tooltip"
+ {...currentCircleStyle}
+ />
+ </Tooltip>
+ </Box>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
index 7286818def7..99bd035896d 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
@@ -20,7 +20,7 @@
// Grid layout constants - shared between Grid and Gantt for alignment
export const ROW_HEIGHT = 20;
export const GRID_OUTER_PADDING_PX = 64; // pt={16} = 16 * 4 = 64px
-export const GRID_HEADER_PADDING_PX = 8; // pt={2} = 2 * 4 = 8px
+export const GRID_HEADER_PADDING_PX = 16; // pt={4} = 4 * 4 = 16px
export const GRID_HEADER_HEIGHT_PX = 100; // height="100px" for duration bars
// Gantt chart's x-axis height (time labels at top of chart)
@@ -30,3 +30,11 @@ export const GANTT_AXIS_HEIGHT_PX = 36;
// minus the Gantt axis height since the chart includes its own top axis
export const GRID_BODY_OFFSET_PX =
GRID_OUTER_PADDING_PX + GRID_HEADER_PADDING_PX + GRID_HEADER_HEIGHT_PX -
GANTT_AXIS_HEIGHT_PX;
+
+// Version indicator constants
+export const BAR_HEIGHT = GRID_HEADER_HEIGHT_PX; // Duration bar height
matches grid header
+export const BUNDLE_VERSION_INDICATOR_TOP = 93; // Position from top for
bundle version icon
+export const BUNDLE_VERSION_INDICATOR_LEFT = -2; // Position from left for
bundle version icon
+export const BUNDLE_VERSION_ICON_SIZE = 15; // Size of the git commit icon
+export const DAG_VERSION_INDICATOR_HEIGHT = 104; // Height of the vertical
line indicator
+export const VERSION_INDICATOR_Z_INDEX = 1; // Z-index for version indicators
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts
new file mode 100644
index 00000000000..e60012e58ba
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts
@@ -0,0 +1,74 @@
+/*!
+ * 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 { useMemo } from "react";
+
+import type { GridRunsResponse } from "openapi/requests";
+import { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
+
+export type GridRunWithVersionFlags = {
+ isBundleVersionChange: boolean;
+ isDagVersionChange: boolean;
+} & GridRunsResponse;
+
+type UseGridRunsWithVersionFlagsParams = {
+ gridRuns: Array<GridRunsResponse> | undefined;
+ showVersionIndicatorMode?: VersionIndicatorOptions;
+};
+
+export const getMaxVersionNumber = (run: GridRunsResponse): number | undefined
=>
+ run.dag_versions?.at(-1)?.version_number;
+
+export const getBundleVersion = (run: GridRunsResponse): string | null |
undefined =>
+ run.dag_versions?.at(-1)?.bundle_version;
+
+// Hook to calculate version change flags for grid runs.
+export const useGridRunsWithVersionFlags = ({
+ gridRuns,
+ showVersionIndicatorMode,
+}: UseGridRunsWithVersionFlagsParams): Array<GridRunWithVersionFlags> |
undefined => {
+ const isVersionIndicatorEnabled = showVersionIndicatorMode !==
VersionIndicatorOptions.NONE;
+
+ return useMemo(() => {
+ if (!gridRuns) {
+ return undefined;
+ }
+
+ if (!isVersionIndicatorEnabled) {
+ return gridRuns.map((run) => ({ ...run, isBundleVersionChange: false,
isDagVersionChange: false }));
+ }
+
+ return gridRuns.map((run, index) => {
+ const nextRun = gridRuns[index + 1];
+
+ const currentBundleVersion = getBundleVersion(run);
+ const nextBundleVersion = nextRun ? getBundleVersion(nextRun) :
undefined;
+ const isBundleVersionChange =
+ currentBundleVersion !== undefined &&
+ nextBundleVersion !== undefined &&
+ currentBundleVersion !== nextBundleVersion;
+
+ const currentVersion = getMaxVersionNumber(run);
+ const nextVersion = nextRun ? getMaxVersionNumber(nextRun) : undefined;
+ const isDagVersionChange =
+ currentVersion !== undefined && nextVersion !== undefined &&
currentVersion !== nextVersion;
+
+ return { ...run, isBundleVersionChange, isDagVersionChange };
+ });
+ }, [gridRuns, isVersionIndicatorEnabled]);
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index cdcaa90f3fc..3d6a335f9d3 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -51,12 +51,14 @@ import { Tooltip } from "src/components/ui";
import { type ButtonGroupOption, ButtonGroupToggle } from
"src/components/ui/ButtonGroupToggle";
import { Checkbox } from "src/components/ui/Checkbox";
import { dependenciesKey, directionKey } from "src/constants/localStorage";
+import type { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
import { dagRunTypeOptions, dagRunStateOptions } from
"src/constants/stateOptions";
import { useContainerWidth } from "src/utils/useContainerWidth";
import { DagRunSelect } from "./DagRunSelect";
import { TaskStreamFilter } from "./TaskStreamFilter";
import { ToggleGroups } from "./ToggleGroups";
+import { VersionIndicatorSelect } from "./VersionIndicatorSelect";
type Props = {
readonly dagRunStateFilter: DagRunState | undefined;
@@ -69,8 +71,10 @@ type Props = {
readonly setLimit: React.Dispatch<React.SetStateAction<number>>;
readonly setRunTypeFilter: React.Dispatch<React.SetStateAction<DagRunType |
undefined>>;
readonly setShowGantt: React.Dispatch<React.SetStateAction<boolean>>;
+ readonly setShowVersionIndicatorMode:
React.Dispatch<React.SetStateAction<VersionIndicatorOptions>>;
readonly setTriggeringUserFilter: React.Dispatch<React.SetStateAction<string
| undefined>>;
readonly showGantt: boolean;
+ readonly showVersionIndicatorMode: VersionIndicatorOptions;
readonly triggeringUserFilter: string | undefined;
};
@@ -118,8 +122,10 @@ export const PanelButtons = ({
setLimit,
setRunTypeFilter,
setShowGantt,
+ setShowVersionIndicatorMode,
setTriggeringUserFilter,
showGantt,
+ showVersionIndicatorMode,
triggeringUserFilter,
}: Props) => {
const { t: translate } = useTranslation(["components", "dag"]);
@@ -470,6 +476,12 @@ export const PanelButtons = ({
</Checkbox>
</VStack>
) : undefined}
+ <VStack alignItems="flex-start" px={1}>
+ <VersionIndicatorSelect
+ onChange={setShowVersionIndicatorMode}
+ value={showVersionIndicatorMode}
+ />
+ </VStack>
</>
)}
</Popover.Body>
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/VersionIndicatorSelect.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/VersionIndicatorSelect.tsx
new file mode 100644
index 00000000000..3d4cf9a51c7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/VersionIndicatorSelect.tsx
@@ -0,0 +1,94 @@
+/*!
+ * 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 { Circle, Flex, Select, type SelectValueChangeDetails } from
"@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiGitCommit } from "react-icons/fi";
+
+import {
+ isVersionIndicatorOption,
+ showVersionIndicatorOptions,
+ VersionIndicatorOptions,
+} from "src/constants/showVersionIndicatorOptions";
+
+type VersionIndicatorSelectProps = {
+ readonly onChange: (value: VersionIndicatorOptions) => void;
+ readonly value: VersionIndicatorOptions;
+};
+
+export const VersionIndicatorSelect = ({ onChange, value }:
VersionIndicatorSelectProps) => {
+ const { t: translate } = useTranslation(["components", "dag"]);
+
+ const handleChange = (event: SelectValueChangeDetails<{ label: string;
value: Array<string> }>) => {
+ const [selectedDisplayMode] = event.value;
+
+ if (isVersionIndicatorOption(selectedDisplayMode)) {
+ onChange(selectedDisplayMode);
+ }
+ };
+
+ return (
+ <Select.Root
+ // @ts-expect-error option type
+ collection={showVersionIndicatorOptions}
+ onValueChange={handleChange}
+ size="sm"
+ value={[value]}
+ >
+ <Select.Label
fontSize="xs">{translate("dag:panel.showVersionIndicator.label")}</Select.Label>
+ <Select.Control>
+ <Select.Trigger>
+ <Select.ValueText>
+ <Flex alignItems="center" gap={1}>
+ {(value === VersionIndicatorOptions.BUNDLE_VERSION ||
+ value === VersionIndicatorOptions.ALL) && (
+ <FiGitCommit color="var(--chakra-colors-orange-focus-ring)" />
+ )}
+ {(value === VersionIndicatorOptions.DAG_VERSION || value ===
VersionIndicatorOptions.ALL) && (
+ <Circle bg="orange.focusRing" size="8px" />
+ )}
+ {translate(showVersionIndicatorOptions.items.find((item) =>
item.value === value)?.label ?? "")}
+ </Flex>
+ </Select.ValueText>
+ </Select.Trigger>
+ <Select.IndicatorGroup>
+ <Select.Indicator />
+ </Select.IndicatorGroup>
+ </Select.Control>
+ <Select.Positioner>
+ <Select.Content>
+ {showVersionIndicatorOptions.items.map((option) => (
+ <Select.Item item={option} key={option.value}>
+ <Flex alignItems="center" gap={1}>
+ {(option.value === VersionIndicatorOptions.BUNDLE_VERSION ||
+ option.value === VersionIndicatorOptions.ALL) && (
+ <FiGitCommit color="var(--chakra-colors-orange-focus-ring)"
/>
+ )}
+ {(option.value === VersionIndicatorOptions.DAG_VERSION ||
+ option.value === VersionIndicatorOptions.ALL) && (
+ <Circle bg="orange.focusRing" size="8px" />
+ )}
+ {translate(option.label)}
+ </Flex>
+ </Select.Item>
+ ))}
+ </Select.Content>
+ </Select.Positioner>
+ </Select.Root>
+ );
+};
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
index ed4d6fb2e0e..c9642f7c492 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
@@ -61,6 +61,15 @@ INNER_TASK_GROUP_SUB_TASK = "inner_task_group_sub_task"
GRID_RUN_1 = {
"dag_id": "test_dag",
+ "dag_versions": [
+ {
+ "version_number": 1,
+ "dag_id": "test_dag",
+ "bundle_name": "dag_maker",
+ "created_at": "2024-12-31T00:00:00Z",
+ "dag_display_name": "test_dag",
+ }
+ ],
"duration": 283996800.0,
"end_date": "2024-12-31T00:00:00Z",
"has_missed_deadline": False,
@@ -73,6 +82,15 @@ GRID_RUN_1 = {
GRID_RUN_2 = {
"dag_id": "test_dag",
+ "dag_versions": [
+ {
+ "version_number": 1,
+ "dag_id": "test_dag",
+ "bundle_name": "dag_maker",
+ "created_at": "2024-12-31T00:00:00Z",
+ "dag_display_name": "test_dag",
+ }
+ ],
"duration": 283996800.0,
"end_date": "2024-12-31T00:00:00Z",
"has_missed_deadline": False,
@@ -83,6 +101,18 @@ GRID_RUN_2 = {
"state": "failed",
}
+
+def _strip_dag_version_ids(data):
+ """Strip dynamic `id` fields from dag_versions for deterministic
comparison."""
+ if isinstance(data, list):
+ return [_strip_dag_version_ids(item) for item in data]
+ if isinstance(data, dict) and "dag_versions" in data:
+ result = dict(data)
+ result["dag_versions"] = [{k: v for k, v in dv.items() if k != "id"}
for dv in result["dag_versions"]]
+ return result
+ return data
+
+
GRID_NODES = [
{
"children": [{"id": "mapped_task_group.subtask", "is_mapped": True,
"label": "subtask"}],
@@ -357,10 +387,10 @@ def _freeze_time_for_dagruns(time_machine):
@pytest.mark.usefixtures("_freeze_time_for_dagruns")
class TestGetGridDataEndpoint:
def test_should_response_200(self, test_client):
- with assert_queries_count(5):
+ with assert_queries_count(6):
response = test_client.get(f"/grid/runs/{DAG_ID}")
assert response.status_code == 200
- assert response.json() == [
+ assert _strip_dag_version_ids(response.json()) == [
GRID_RUN_1,
GRID_RUN_2,
]
@@ -399,10 +429,10 @@ class TestGetGridDataEndpoint:
],
)
def test_should_response_200_order_by(self, test_client, order_by,
expected):
- with assert_queries_count(5):
+ with assert_queries_count(6):
response = test_client.get(f"/grid/runs/{DAG_ID}",
params={"order_by": order_by})
assert response.status_code == 200
- assert response.json() == expected
+ assert _strip_dag_version_ids(response.json()) == expected
@pytest.mark.parametrize(
("limit", "expected"),
@@ -418,10 +448,10 @@ class TestGetGridDataEndpoint:
],
)
def test_should_response_200_limit(self, test_client, limit, expected):
- with assert_queries_count(5):
+ with assert_queries_count(6):
response = test_client.get(f"/grid/runs/{DAG_ID}",
params={"limit": limit})
assert response.status_code == 200
- assert response.json() == expected
+ assert _strip_dag_version_ids(response.json()) == expected
@pytest.mark.parametrize(
("params", "expected"),
@@ -443,13 +473,13 @@ class TestGetGridDataEndpoint:
],
)
def test_runs_should_response_200_date_filters(self, test_client, params,
expected):
- with assert_queries_count(5):
+ with assert_queries_count(6):
response = test_client.get(
f"/grid/runs/{DAG_ID}",
params=params,
)
assert response.status_code == 200
- assert response.json() == expected
+ assert _strip_dag_version_ids(response.json()) == expected
@pytest.mark.parametrize(
("params", "expected", "expected_queries_count"),
@@ -605,33 +635,10 @@ class TestGetGridDataEndpoint:
def test_get_grid_runs(self, session, test_client):
session.commit()
- with assert_queries_count(5):
+ with assert_queries_count(6):
response = test_client.get(f"/grid/runs/{DAG_ID}?limit=5")
assert response.status_code == 200
- assert response.json() == [
- {
- "dag_id": "test_dag",
- "duration": 283996800.0,
- "end_date": "2024-12-31T00:00:00Z",
- "has_missed_deadline": False,
- "run_after": "2024-11-30T00:00:00Z",
- "run_id": "run_1",
- "run_type": "scheduled",
- "start_date": "2016-01-01T00:00:00Z",
- "state": "success",
- },
- {
- "dag_id": "test_dag",
- "duration": 283996800.0,
- "end_date": "2024-12-31T00:00:00Z",
- "has_missed_deadline": False,
- "run_after": "2024-11-30T00:00:00Z",
- "run_id": "run_2",
- "run_type": "manual",
- "start_date": "2016-01-01T00:00:00Z",
- "state": "failed",
- },
- ]
+ assert _strip_dag_version_ids(response.json()) == [GRID_RUN_1,
GRID_RUN_2]
@pytest.mark.parametrize(
("endpoint", "run_type", "expected"),
@@ -646,7 +653,7 @@ class TestGetGridDataEndpoint:
session.commit()
response =
test_client.get(f"/grid/{endpoint}/{DAG_ID}?run_type={run_type}")
assert response.status_code == 200
- assert response.json() == expected
+ assert _strip_dag_version_ids(response.json()) == expected
@pytest.mark.parametrize(
("endpoint", "triggering_user", "expected"),
@@ -660,14 +667,14 @@ class TestGetGridDataEndpoint:
session.commit()
response =
test_client.get(f"/grid/{endpoint}/{DAG_ID}?triggering_user={triggering_user}")
assert response.status_code == 200
- assert response.json() == expected
+ assert _strip_dag_version_ids(response.json()) == expected
def test_get_grid_runs_filter_by_run_type_and_triggering_user(self,
session, test_client):
session.commit()
- with assert_queries_count(5):
+ with assert_queries_count(6):
response =
test_client.get(f"/grid/runs/{DAG_ID}?run_type=manual&triggering_user=user2")
assert response.status_code == 200
- assert response.json() == [GRID_RUN_2]
+ assert _strip_dag_version_ids(response.json()) == [GRID_RUN_2]
@pytest.mark.parametrize(
("endpoint", "state", "expected"),
@@ -683,7 +690,7 @@ class TestGetGridDataEndpoint:
session.commit()
response = test_client.get(f"/grid/{endpoint}/{DAG_ID}?state={state}")
assert response.status_code == 200
- assert response.json() == expected
+ assert _strip_dag_version_ids(response.json()) == expected
def test_grid_ti_summaries_group(self, session, test_client):
run_id = "run_4-1"
@@ -702,6 +709,7 @@ class TestGetGridDataEndpoint:
"task_id": "t1",
"task_display_name": "t1",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:00Z",
"min_start_date": "2025-03-01T23:59:58Z",
},
@@ -710,6 +718,7 @@ class TestGetGridDataEndpoint:
"task_id": "t2",
"task_display_name": "t2",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:02Z",
"min_start_date": "2025-03-02T00:00:00Z",
},
@@ -718,11 +727,13 @@ class TestGetGridDataEndpoint:
"task_id": "t7",
"task_display_name": "t7",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:04Z",
"min_start_date": "2025-03-02T00:00:02Z",
},
{
"child_states": {"success": 4},
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:12Z",
"min_start_date": "2025-03-02T00:00:04Z",
"state": "success",
@@ -734,11 +745,13 @@ class TestGetGridDataEndpoint:
"task_id": "task_group-1.t6",
"task_display_name": "task_group-1.t6",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:06Z",
"min_start_date": "2025-03-02T00:00:04Z",
},
{
"child_states": {"success": 3},
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:12Z",
"min_start_date": "2025-03-02T00:00:06Z",
"state": "success",
@@ -750,6 +763,7 @@ class TestGetGridDataEndpoint:
"task_id": "task_group-1.task_group-2.t3",
"task_display_name": "task_group-1.task_group-2.t3",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:08Z",
"min_start_date": "2025-03-02T00:00:06Z",
},
@@ -758,6 +772,7 @@ class TestGetGridDataEndpoint:
"task_id": "task_group-1.task_group-2.t4",
"task_display_name": "task_group-1.task_group-2.t4",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:10Z",
"min_start_date": "2025-03-02T00:00:08Z",
},
@@ -766,6 +781,7 @@ class TestGetGridDataEndpoint:
"task_id": "task_group-1.task_group-2.t5",
"task_display_name": "task_group-1.task_group-2.t5",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": "2025-03-02T00:00:12Z",
"min_start_date": "2025-03-02T00:00:10Z",
},
@@ -797,6 +813,7 @@ class TestGetGridDataEndpoint:
expected = [
{
"child_states": {"None": 1},
+ "dag_version_number": 1,
"task_id": "mapped_task_2",
"task_display_name": "mapped_task_2",
"max_end_date": None,
@@ -805,6 +822,7 @@ class TestGetGridDataEndpoint:
},
{
"child_states": {"success": 1, "running": 1, "None": 1},
+ "dag_version_number": 1,
"max_end_date": "2024-12-30T01:02:03Z",
"min_start_date": "2024-12-30T01:00:00Z",
"state": "running",
@@ -816,6 +834,7 @@ class TestGetGridDataEndpoint:
"task_id": "mapped_task_group.subtask",
"task_display_name": "mapped_task_group.subtask",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": "2024-12-30T01:02:03Z",
"min_start_date": "2024-12-30T01:00:00Z",
},
@@ -824,11 +843,13 @@ class TestGetGridDataEndpoint:
"task_id": "task",
"task_display_name": "A Beautiful Task Name \U0001f680",
"child_states": None,
+ "dag_version_number": 1,
"max_end_date": None,
"min_start_date": None,
},
{
"child_states": {"None": 6},
+ "dag_version_number": 1,
"task_id": "task_group",
"task_display_name": "task_group",
"max_end_date": None,
@@ -837,6 +858,7 @@ class TestGetGridDataEndpoint:
},
{
"child_states": {"None": 2},
+ "dag_version_number": 1,
"task_id": "task_group.inner_task_group",
"task_display_name": "task_group.inner_task_group",
"max_end_date": None,
@@ -845,6 +867,7 @@ class TestGetGridDataEndpoint:
},
{
"child_states": {"None": 2},
+ "dag_version_number": 1,
"task_id":
"task_group.inner_task_group.inner_task_group_sub_task",
"task_display_name": "Inner Task Group Sub Task Label",
"max_end_date": None,
@@ -853,6 +876,7 @@ class TestGetGridDataEndpoint:
},
{
"child_states": {"None": 4},
+ "dag_version_number": 1,
"task_id": "task_group.mapped_task",
"task_display_name": "task_group.mapped_task",
"max_end_date": None,