This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 84a211b5d22 UI: Show owning team on Dag, Dag Run, and Task Instance
detail pages (#70312)
84a211b5d22 is described below
commit 84a211b5d22a280fa37a03cd93a9234e4c22dd43
Author: Vincent <[email protected]>
AuthorDate: Tue Aug 11 08:47:01 2026 -0400
UI: Show owning team on Dag, Dag Run, and Task Instance detail pages
(#70312)
Multi-team deployments need to see which team owns a Dag directly from the
detail pages, not just from the list views. This surfaces the owning team on
the Dag, Dag Run, and Task Instance detail pages as a linked row that
filters
the Dag list by that team, mirroring how the Owner field already behaves.
The team row is only shown when multi-team support is enabled and a team is
resolved for the object, so single-team deployments see no change.
---
.../src/airflow/api_fastapi/common/db/dag_runs.py | 6 +-
.../src/airflow/api_fastapi/common/db/dags.py | 37 +++---
.../api_fastapi/common/db/task_instances.py | 5 +-
.../api_fastapi/core_api/datamodels/dags.py | 1 +
.../core_api/openapi/v2-rest-api-generated.yaml | 5 +
.../api_fastapi/core_api/routes/public/dag_run.py | 8 +-
.../api_fastapi/core_api/routes/public/dags.py | 4 +-
.../core_api/routes/public/task_instances.py | 7 +-
.../execution_api/datamodels/taskinstance.py | 4 +
.../execution_api/routes/task_instances.py | 5 +-
airflow-core/src/airflow/models/dag.py | 24 ++++
airflow-core/src/airflow/models/dagbundle.py | 6 +
airflow-core/src/airflow/models/dagrun.py | 8 ++
airflow-core/src/airflow/models/taskinstance.py | 1 +
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 11 ++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 1 +
.../airflow/ui/src/components/TeamName.test.tsx | 60 ++++++++++
.../src/airflow/ui/src/components/TeamName.tsx | 40 +++++++
.../src/airflow/ui/src/hooks/useShowTeam.ts | 26 +++++
.../src/airflow/ui/src/pages/Dag/Details.tsx | 12 ++
.../src/airflow/ui/src/pages/Dag/Header.test.tsx | 37 +++++-
.../src/airflow/ui/src/pages/Dag/Header.tsx | 11 ++
.../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx | 8 +-
.../src/airflow/ui/src/pages/DagsList/DagCard.tsx | 7 +-
.../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 8 +-
.../src/airflow/ui/src/pages/Run/Details.tsx | 12 ++
.../src/airflow/ui/src/pages/Run/Header.test.tsx | 21 ++++
.../src/airflow/ui/src/pages/Run/Header.tsx | 11 ++
.../airflow/ui/src/pages/TaskInstance/Details.tsx | 12 ++
.../ui/src/pages/TaskInstance/Header.test.tsx | 78 +++++++++++++
.../airflow/ui/src/pages/TaskInstance/Header.tsx | 11 ++
.../ui/src/pages/TaskInstances/TaskInstances.tsx | 8 +-
.../core_api/routes/public/test_dag_run.py | 19 +++
.../core_api/routes/public/test_dags.py | 36 +++++-
.../core_api/routes/public/test_task_instances.py | 45 +++++++
.../versions/head/test_task_instances.py | 59 ++++++++++
airflow-core/tests/unit/models/test_team.py | 130 +++++++++++++++++++++
.../src/airflowctl/api/datamodels/generated.py | 1 +
.../airflow/providers/openlineage/utils/utils.py | 7 +-
scripts/ci/prek/check_ti_vs_tis_attributes.py | 2 +
40 files changed, 736 insertions(+), 58 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/common/db/dag_runs.py
b/airflow-core/src/airflow/api_fastapi/common/db/dag_runs.py
index 1508ea8528e..09779b17dd0 100644
--- a/airflow-core/src/airflow/api_fastapi/common/db/dag_runs.py
+++ b/airflow-core/src/airflow/api_fastapi/common/db/dag_runs.py
@@ -26,6 +26,7 @@ from sqlalchemy import func, select, tuple_, union_all
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption
+from airflow.api_fastapi.common.db.dags import eager_load_teams
from airflow.models.dag import DagModel
from airflow.models.dag_version import DagVersion
from airflow.models.dagrun import DagRun
@@ -56,14 +57,15 @@ def eager_load_dag_run_for_list() -> tuple[LoaderOption,
...]:
Lightweight eager loading for the DagRun list endpoint.
Only loads the direct relationships needed for serialization (dag_model,
- dag_run_note, created_dag_version). The dag_versions property — which
- requires iterating every TI and TIH — is populated separately by
+ dag_run_note, created_dag_version, and the owning team). The dag_versions
+ property — which requires iterating every TI and TIH — is populated
separately by
:func:`attach_dag_versions_to_runs` using a single DISTINCT query.
"""
return (
joinedload(DagRun.dag_model),
joinedload(DagRun.dag_run_note),
joinedload(DagRun.created_dag_version).joinedload(DagVersion.bundle),
+ *eager_load_teams(DagRun.dag_model),
)
diff --git a/airflow-core/src/airflow/api_fastapi/common/db/dags.py
b/airflow-core/src/airflow/api_fastapi/common/db/dags.py
index 7113e104666..c0a7c7cbcdf 100644
--- a/airflow-core/src/airflow/api_fastapi/common/db/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/common/db/dags.py
@@ -17,11 +17,10 @@
from __future__ import annotations
-from collections.abc import Sequence
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from sqlalchemy import func, select
-from sqlalchemy.orm import selectinload
+from sqlalchemy.orm import joinedload, selectinload
from airflow.api_fastapi.common.db.common import (
apply_filters_to_select,
@@ -29,10 +28,11 @@ from airflow.api_fastapi.common.db.common import (
from airflow.api_fastapi.common.parameters import BaseParam, RangeFilter,
SortParam
from airflow.configuration import conf
from airflow.models import DagModel
+from airflow.models.dagbundle import DagBundleModel
from airflow.models.dagrun import DagRun
if TYPE_CHECKING:
- from sqlalchemy.orm import Session
+ from sqlalchemy.orm.strategy_options import _AbstractLoad
from sqlalchemy.sql import Select
@@ -98,18 +98,23 @@ def generate_dag_with_latest_run_query(
return query
-def attach_team_names(objects: Sequence, *, session: Session) -> None:
+def eager_load_teams(*path: Any) -> tuple[_AbstractLoad, ...]:
"""
- Attach the owning team name to each object exposing a ``dag_id``.
+ Eager loading options for the team owning a Dag, for serializing
``team_name``.
- Only performs a database lookup when multi-team mode is enabled; otherwise
every
- object keeps its default ``team_name`` of ``None``. The resolved name is
set as the
- ``team_name`` attribute on each object so the response serializer can read
it.
- """
- if not objects or not conf.getboolean("core", "multi_team"):
- return
+ ``DagModel.bundle`` is ``lazy="raise"``, so any endpoint whose response
exposes
+ ``team_name`` must apply these options. Nothing is loaded when multi-team
mode is
+ off: :attr:`DagModel.team_name` short-circuits to ``None`` without
touching the
+ relationship, so single-team deployments pay for no extra join.
- dag_ids = list({obj.dag_id for obj in objects})
- team_names_by_dag_id = DagModel.get_dag_id_to_team_name_mapping(dag_ids,
session=session)
- for obj in objects:
- obj.team_name = team_names_by_dag_id.get(obj.dag_id)
+ :param path: relationship attributes leading to ``DagModel``, empty when
selecting it
+ directly. For example, ``eager_load_teams(DagRun.dag_model)`` for a
Dag run query.
+ """
+ if not conf.getboolean("core", "multi_team"):
+ return ()
+
+ loader: Any = None
+ for attribute in path:
+ loader = joinedload(attribute) if loader is None else
loader.joinedload(attribute)
+ bundle_loader = joinedload(DagModel.bundle) if loader is None else
loader.joinedload(DagModel.bundle)
+ return (bundle_loader.selectinload(DagBundleModel.teams),)
diff --git a/airflow-core/src/airflow/api_fastapi/common/db/task_instances.py
b/airflow-core/src/airflow/api_fastapi/common/db/task_instances.py
index 37ff83d406c..9d93baf2530 100644
--- a/airflow-core/src/airflow/api_fastapi/common/db/task_instances.py
+++ b/airflow-core/src/airflow/api_fastapi/common/db/task_instances.py
@@ -20,6 +20,7 @@ from __future__ import annotations
from sqlalchemy import Select
from sqlalchemy.orm import contains_eager, joinedload
+from airflow.api_fastapi.common.db.dags import eager_load_teams
from airflow.models import Base
from airflow.models.dag_version import DagVersion
from airflow.models.dagrun import DagRun
@@ -48,7 +49,9 @@ def eager_load_TI_and_TIH_for_validation(
query = query.join(orm_model.dag_run).outerjoin(orm_model.dag_version)
query = query.options(
-
contains_eager(orm_model.dag_run).options(joinedload(DagRun.dag_model)),
+ contains_eager(orm_model.dag_run).options(
+ joinedload(DagRun.dag_model).options(*eager_load_teams()),
+ ),
contains_eager(orm_model.dag_version).options(joinedload(DagVersion.bundle)),
)
if orm_model is TaskInstance:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
index 2151a8599cc..e67777d6ffa 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
@@ -207,6 +207,7 @@ class DAGDetailsResponse(DAGResponse):
owner_links: dict[str, str] | None = None
is_favorite: bool = False
active_runs_count: int = 0
+ team_name: str | None = None
@field_validator("timezone", mode="before")
@classmethod
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 92edb82d7f9..81b0a710c9e 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -13105,6 +13105,11 @@ components:
type: integer
title: Active Runs Count
default: 0
+ team_name:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Team Name
is_backfillable:
type: boolean
title: Is Backfillable
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
index e5d06587cb5..f2310f092d5 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
@@ -42,7 +42,7 @@ from airflow.api_fastapi.common.db.dag_runs import (
attach_dag_versions_to_runs,
eager_load_dag_run_for_list,
)
-from airflow.api_fastapi.common.db.dags import attach_team_names
+from airflow.api_fastapi.common.db.dags import eager_load_teams
from airflow.api_fastapi.common.parameters import (
FilterOptionEnum,
FilterParam,
@@ -134,7 +134,9 @@ dag_run_at_dag_router = AirflowRouter(tags=["DagRun"],
prefix="/dags/{dag_id}")
)
def get_dag_run(dag_id: str, dag_run_id: str, session: SessionDep) ->
DAGRunResponse:
dag_run = session.scalar(
- select(DagRun).filter_by(dag_id=dag_id,
run_id=dag_run_id).options(joinedload(DagRun.dag_model))
+ select(DagRun)
+ .filter_by(dag_id=dag_id, run_id=dag_run_id)
+ .options(joinedload(DagRun.dag_model),
*eager_load_teams(DagRun.dag_model))
)
if dag_run is None:
raise HTTPException(
@@ -693,7 +695,6 @@ def get_dag_runs(
has_next = has_more
attach_dag_versions_to_runs(dag_runs, session=session)
- attach_team_names(dag_runs, session=session)
return DAGRunCollectionResponse(
dag_runs=dag_runs,
@@ -713,7 +714,6 @@ def get_dag_runs(
)
dag_runs = list(session.scalars(dag_run_select))
attach_dag_versions_to_runs(dag_runs, session=session)
- attach_team_names(dag_runs, session=session)
return DAGRunCollectionResponse(
dag_runs=dag_runs,
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
index 69b6b2932f0..112e6c98391 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
@@ -27,7 +27,7 @@ from sqlalchemy import delete, func, insert, select, update
from airflow.api.common import delete_dag as delete_dag_module
from airflow.api_fastapi.common.dagbag import DagBagDep,
get_latest_version_of_dag
from airflow.api_fastapi.common.db.common import SessionDep,
apply_filters_to_select, paginated_select
-from airflow.api_fastapi.common.db.dags import
generate_dag_with_latest_run_query
+from airflow.api_fastapi.common.db.dags import eager_load_teams,
generate_dag_with_latest_run_query
from airflow.api_fastapi.common.parameters import (
FilterOptionEnum,
FilterParam,
@@ -228,7 +228,7 @@ def get_dag_details(
"""Get details of Dag."""
dag = get_latest_version_of_dag(dag_bag, dag_id, session)
- dag_model = session.get(DagModel, dag_id)
+ dag_model = session.scalar(select(DagModel).where(DagModel.dag_id ==
dag_id).options(*eager_load_teams()))
if not dag_model:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Unable to obtain dag
with id {dag_id} from session")
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
index 214179cdca9..b5558746ff0 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
@@ -41,7 +41,7 @@ from airflow.api_fastapi.common.dagbag import (
resolve_run_on_latest_version,
)
from airflow.api_fastapi.common.db.common import SessionDep,
apply_filters_to_select, paginated_select
-from airflow.api_fastapi.common.db.dags import attach_team_names
+from airflow.api_fastapi.common.db.dags import eager_load_teams
from airflow.api_fastapi.common.db.task_instances import
eager_load_TI_and_TIH_for_validation
from airflow.api_fastapi.common.parameters import (
FilterOptionEnum,
@@ -144,6 +144,7 @@ def get_task_instance(
.options(joinedload(TI.rendered_task_instance_fields))
.options(joinedload(TI.dag_version))
.options(joinedload(TI.dag_run).options(joinedload(DagRun.dag_model)))
+ .options(*eager_load_teams(TI.dag_run, DagRun.dag_model))
)
task_instance = session.scalar(query)
@@ -434,6 +435,7 @@ def get_mapped_task_instance(
.options(joinedload(TI.rendered_task_instance_fields))
.options(joinedload(TI.dag_version))
.options(joinedload(TI.dag_run).options(joinedload(DagRun.dag_model)))
+ .options(*eager_load_teams(TI.dag_run, DagRun.dag_model))
)
task_instance = session.scalar(query)
@@ -640,8 +642,6 @@ def get_task_instances(
has_prev = bool(cursor)
has_next = has_more
- attach_team_names(task_instances, session=session)
-
return TaskInstanceCollectionResponse(
task_instances=task_instances,
next_cursor=(
@@ -663,7 +663,6 @@ def get_task_instances(
session=session,
)
task_instances = list(session.scalars(task_instance_select))
- attach_team_names(task_instances, session=session)
return TaskInstanceCollectionResponse(
task_instances=task_instances,
total_entries=total_entries,
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
index ad051b3e6d3..0064bdf1e73 100644
---
a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
@@ -390,6 +390,10 @@ class DagRun(StrictBaseModel):
else:
values["note"] = None
+ # A property rather than a column, so the loop above never picks it up.
+ if not insp.detached:
+ values["team_name"] = data.team_name
+
return values
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index 41ecf49b053..8f79c89808b 100644
---
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -46,6 +46,7 @@ from airflow._shared.timezones import timezone
from airflow.api_fastapi.auth.tokens import JWTGenerator
from airflow.api_fastapi.common.dagbag import DagBagDep,
get_latest_version_of_dag
from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.common.db.dags import eager_load_teams
from airflow.api_fastapi.common.types import UtcDateTime
from airflow.api_fastapi.compat import HTTP_422_UNPROCESSABLE_CONTENT
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
@@ -262,7 +263,7 @@ def ti_run(
session.scalars(
select(DR)
.filter_by(dag_id=ti.dag_id, run_id=ti.run_id)
- .options(joinedload(DR.consumed_asset_events))
+ .options(joinedload(DR.consumed_asset_events),
*eager_load_teams(DR.dag_model))
)
.unique()
.one_or_none()
@@ -297,8 +298,6 @@ def ti_run(
or 0
)
- dr.team_name = get_team_name_for_ti(task_instance_id, session)
-
context = TIRunContext(
dag_run=dr,
task_reschedule_count=task_reschedule_count,
diff --git a/airflow-core/src/airflow/models/dag.py
b/airflow-core/src/airflow/models/dag.py
index d6dfab06a27..4c8d4d3e680 100644
--- a/airflow-core/src/airflow/models/dag.py
+++ b/airflow-core/src/airflow/models/dag.py
@@ -38,6 +38,7 @@ from sqlalchemy import (
Text,
case,
func,
+ inspect as sa_inspect,
or_,
select,
)
@@ -79,6 +80,7 @@ if TYPE_CHECKING:
from typing import TypeAlias
from dateutil.relativedelta import relativedelta
+ from sqlalchemy.orm.state import InstanceState
from airflow.sdk import Context
from airflow.serialization.definitions.assets import (
@@ -440,6 +442,28 @@ class DagModel(Base):
dag_versions = relationship(
"DagVersion", back_populates="dag_model", cascade="all, delete,
delete-orphan"
)
+ # Path from a Dag to its owning team, used by ``team_name`` below.
``lazy="raise"`` keeps the
+ # traversal opt-in so a caller that forgets eager_load_teams() cannot emit
a silent N+1.
+ bundle = relationship("DagBundleModel", viewonly=True, lazy="raise")
+
+ @property
+ def team_name(self) -> str | None:
+ """Name of the team owning this Dag, or ``None`` when it is not
team-owned."""
+ if not airflow_conf.getboolean("core", "multi_team"):
+ return None
+
+ state: InstanceState = sa_inspect(self)
+ if "bundle" in state.unloaded:
+ # Serialization paths that fetch a Dag by primary key cannot apply
loader options
+ # (e.g. Deadline.handle_miss, asset materialization), so fall back
to the cached
+ # resolver rather than tripping ``lazy="raise"``. Reuse this
instance's own session:
+ # ``get_team_name`` is ``@provide_session``, and the session it
would otherwise open
+ # is the *same* scoped session the caller holds, so closing it on
exit would detach
+ # every object still in use.
+ if state.session is not None:
+ return DagModel.get_team_name(self.dag_id,
session=state.session)
+ return DagModel.get_team_name(self.dag_id)
+ return self.bundle.team_name if self.bundle else None
def __init__(self, **kwargs):
super().__init__(**kwargs)
diff --git a/airflow-core/src/airflow/models/dagbundle.py
b/airflow-core/src/airflow/models/dagbundle.py
index f18c548a019..a881d3f9a4a 100644
--- a/airflow-core/src/airflow/models/dagbundle.py
+++ b/airflow-core/src/airflow/models/dagbundle.py
@@ -59,6 +59,12 @@ class DagBundleModel(Base, LoggingMixin):
template_params: Mapped[dict | None] = mapped_column(sa.JSON(),
nullable=True)
teams = relationship("Team", secondary=dag_bundle_team_association_table,
back_populates="dag_bundles")
+ @property
+ def team_name(self) -> str | None:
+ """Name of the team owning this bundle, if any."""
+ # unique index on dag_bundle_team.dag_bundle_name -> at most one team
+ return self.teams[0].name if self.teams else None
+
def __init__(self, *, name: str, version: str | None = None):
super().__init__()
self.name = name
diff --git a/airflow-core/src/airflow/models/dagrun.py
b/airflow-core/src/airflow/models/dagrun.py
index 3da601ef69c..46fbebb0800 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -394,6 +394,14 @@ class DagRun(Base, LoggingMixin):
backfill_max_active_runs = association_proxy("backfill", "max_active_runs")
max_active_runs = association_proxy("dag_model", "max_active_runs")
+ @property
+ def team_name(self) -> str | None:
+ """Name of the team owning this run's Dag, or ``None`` when it is not
team-owned."""
+ # Gate before touching ``dag_model``: single-team deployments must not
pay for the load.
+ if not airflow_conf.getboolean("core", "multi_team"):
+ return None
+ return self.dag_model.team_name if self.dag_model else None
+
note = association_proxy("dag_run_note", "content", creator=_creator_note)
DEFAULT_DAGRUNS_TO_EXAMINE = airflow_conf.getint(
diff --git a/airflow-core/src/airflow/models/taskinstance.py
b/airflow-core/src/airflow/models/taskinstance.py
index dbcb311afe4..6a91d18223e 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -695,6 +695,7 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
run_after = association_proxy("dag_run", "run_after")
logical_date = association_proxy("dag_run", "logical_date")
+ team_name = association_proxy("dag_run", "team_name")
task_instance_note = relationship(
"TaskInstanceNote",
back_populates="task_instance",
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 e1f10b5e91e..1a1f29b0951 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
@@ -3247,6 +3247,17 @@ export const $DAGDetailsResponse = {
title: 'Active Runs Count',
default: 0
},
+ team_name: {
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Team Name'
+ },
is_backfillable: {
type: 'boolean',
title: 'Is Backfillable',
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 6d6256f88db..13252e1126f 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
@@ -901,6 +901,7 @@ export type DAGDetailsResponse = {
} | null;
is_favorite?: boolean;
active_runs_count?: number;
+ team_name?: string | null;
/**
* Whether this Dag's schedule supports backfilling.
*/
diff --git a/airflow-core/src/airflow/ui/src/components/TeamName.test.tsx
b/airflow-core/src/airflow/ui/src/components/TeamName.test.tsx
new file mode 100644
index 00000000000..de684894ad3
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/TeamName.test.tsx
@@ -0,0 +1,60 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { TeamName } from "./TeamName";
+
+const mockConfig: Record<string, unknown> = { multi_team: false };
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: (key: string) => mockConfig[key],
+}));
+
+describe("TeamName", () => {
+ beforeEach(() => {
+ mockConfig.multi_team = false;
+ });
+
+ it("links to the Dags list filtered on the team when multi-team is enabled",
() => {
+ mockConfig.multi_team = true;
+ render(<TeamName teamName="team a" />, { wrapper: Wrapper });
+
+ expect(screen.getByRole("link", { name: "team a"
})).toHaveAttribute("href", "/dags?teams=team%20a");
+ });
+
+ it.each([{ teamName: null }, { teamName: undefined }])(
+ "renders nothing when the team is $teamName",
+ ({ teamName }) => {
+ mockConfig.multi_team = true;
+ render(<TeamName teamName={teamName} />, { wrapper: Wrapper });
+
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ },
+ );
+
+ it("renders nothing when multi-team is disabled", () => {
+ render(<TeamName teamName="team-a" />, { wrapper: Wrapper });
+
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/TeamName.tsx
b/airflow-core/src/airflow/ui/src/components/TeamName.tsx
new file mode 100644
index 00000000000..a60be035ea9
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/TeamName.tsx
@@ -0,0 +1,40 @@
+/*!
+ * 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 { SearchParamsKeys } from "src/constants/searchParams";
+import { useShowTeam } from "src/hooks/useShowTeam";
+
+import { RouterLink } from "./ui";
+
+type Props = {
+ readonly teamName?: string | null;
+};
+
+export const TeamName = ({ teamName }: Props) => {
+ const showTeam = useShowTeam(teamName);
+
+ if (!showTeam) {
+ return undefined;
+ }
+
+ return (
+ <RouterLink
to={`/dags?${SearchParamsKeys.TEAMS}=${encodeURIComponent(teamName as
string)}`}>
+ {teamName}
+ </RouterLink>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/hooks/useShowTeam.ts
b/airflow-core/src/airflow/ui/src/hooks/useShowTeam.ts
new file mode 100644
index 00000000000..3970d4f4012
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useShowTeam.ts
@@ -0,0 +1,26 @@
+/*!
+ * 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 { useConfig } from "src/queries/useConfig";
+
+/**
+ * Whether a team should be surfaced at all: teams only exist in multi-team
+ * deployments, and an entity may not be owned by any team.
+ */
+export const useShowTeam = (teamName?: string | null) =>
+ Boolean(useConfig("multi_team")) && teamName !== undefined && teamName !==
null;
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Details.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Details.tsx
index 9aed9814726..3d3905e03a7 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Details.tsx
@@ -23,8 +23,10 @@ import { useParams } from "react-router-dom";
import { useDagServiceGetDagDetails } from "openapi/queries";
import { DagVersionDetails } from "src/components/DagVersionDetails";
import RenderedJsonField from "src/components/RenderedJsonField";
+import { TeamName } from "src/components/TeamName";
import Time from "src/components/Time";
import { ClipboardRoot, ClipboardIconButton } from "src/components/ui";
+import { useShowTeam } from "src/hooks/useShowTeam";
import { renderDuration } from "src/utils";
export const Details = () => {
@@ -35,6 +37,8 @@ export const Details = () => {
dagId,
});
+ const showTeam = useShowTeam(dag?.team_name);
+
return (
<Box p={2}>
{dag === undefined ? (
@@ -53,6 +57,14 @@ export const Details = () => {
</HStack>
</Table.Cell>
</Table.Row>
+ {showTeam ? (
+ <Table.Row data-testid="team-row">
+ <Table.Cell>{translate("dagDetails.team")}</Table.Cell>
+ <Table.Cell>
+ <TeamName teamName={dag.team_name} />
+ </Table.Cell>
+ </Table.Row>
+ ) : undefined}
<Table.Row data-testid="description-row">
<Table.Cell>{translate("dagDetails.description")}</Table.Cell>
<Table.Cell>{dag.description}</Table.Cell>
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
index 4be93388cca..dc79e170ba2 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
@@ -19,7 +19,7 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import type { DAGDetailsResponse } from "openapi-gen/requests/types.gen";
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "src/i18n/config";
import { MOCK_DAG } from "src/mocks/handlers/dag";
@@ -27,6 +27,12 @@ import { Wrapper } from "src/utils/Wrapper";
import { Header } from "./Header";
+const mockConfig: Record<string, unknown> = { multi_team: false };
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: (key: string) => mockConfig[key],
+}));
+
const mockDag = {
...MOCK_DAG,
active_runs_count: 0,
@@ -50,6 +56,10 @@ const mockDag = {
} as unknown as DAGDetailsResponse;
describe("Header", () => {
+ afterEach(() => {
+ mockConfig.multi_team = false;
+ });
+
it("shows a deactivated badge and hides stale-only next actions for stale
dags", () => {
render(
<Wrapper>
@@ -71,4 +81,29 @@ describe("Header", () => {
expect(screen.getByText(i18n.t("dag:dagDetails.nextRun"))).toBeInTheDocument();
expect(screen.queryByText("2024-08-22 19:00:00")).not.toBeInTheDocument();
});
+
+ it("shows the team alongside the owner when multi-team is enabled", () => {
+ mockConfig.multi_team = true;
+ render(
+ <Wrapper>
+ <Header dag={{ ...mockDag, team_name: "team-a" }} />
+ </Wrapper>,
+ );
+
+
expect(screen.getByText(i18n.t("common:dagDetails.team"))).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "team-a"
})).toHaveAttribute("href", "/dags?teams=team-a");
+
expect(screen.getByText(i18n.t("common:dagDetails.owner"))).toBeInTheDocument();
+ });
+
+ it("shows the owner stat when multi-team is enabled but no team is
resolved", () => {
+ mockConfig.multi_team = true;
+ render(
+ <Wrapper>
+ <Header dag={{ ...mockDag, team_name: null }} />
+ </Wrapper>,
+ );
+
+
expect(screen.getByText(i18n.t("common:dagDetails.owner"))).toBeInTheDocument();
+
expect(screen.queryByText(i18n.t("common:dagDetails.team"))).not.toBeInTheDocument();
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
index 419db668c81..a5b39a8effb 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
@@ -30,8 +30,10 @@ import { DagVersion } from "src/components/DagVersion";
import DisplayMarkdownButton from "src/components/DisplayMarkdownButton";
import { HeaderCard } from "src/components/HeaderCard";
import { NeedsReviewButtonWithModal } from "src/components/NeedsReviewButton";
+import { TeamName } from "src/components/TeamName";
import { TogglePause } from "src/components/TogglePause";
import { RouterLink } from "src/components/ui";
+import { useShowTeam } from "src/hooks/useShowTeam";
import { DagOwners } from "../DagsList/DagOwners";
import { DagTags } from "../DagsList/DagTags";
@@ -58,6 +60,7 @@ export const Header = ({
const { t: translate } = useTranslation(["common", "dag"]);
// We would still like to show the dagId even if the dag object hasn't
loaded yet
const { dagId } = useParams();
+ const showTeam = useShowTeam(dag?.team_name);
const isStale = dag?.is_stale;
const nextRunStat = isStale
@@ -116,6 +119,14 @@ export const Header = ({
label: translate("dagDetails.owner"),
value: <DagOwners ownerLinks={dag?.owner_links ?? undefined}
owners={dag?.owners} />,
},
+ ...(showTeam
+ ? [
+ {
+ label: translate("dagDetails.team"),
+ value: <TeamName teamName={dag?.team_name} />,
+ },
+ ]
+ : []),
{
label: translate("dagDetails.tags"),
value: <DagTags tags={dag?.tags ?? []} />,
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 183306a396c..26a5fd0ae93 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -42,6 +42,7 @@ 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 { TeamName } from "src/components/TeamName";
import Time from "src/components/Time";
import { TruncatedText } from "src/components/TruncatedText";
import { RouterLink } from "src/components/ui";
@@ -160,12 +161,7 @@ const runColumns = ({ dagId, multiTeam, open, translate }:
ColumnProps): Array<C
? [
{
accessorKey: "team_name",
- cell: ({ row: { original } }: DagRunRow) =>
- original.team_name !== undefined && original.team_name !== null ? (
- <RouterLink
to={`/dags?teams=${encodeURIComponent(original.team_name)}`}>
- {original.team_name}
- </RouterLink>
- ) : undefined,
+ cell: ({ row: { original } }: DagRunRow) => <TeamName
teamName={original.team_name} />,
enableSorting: false,
header: translate("dagDetails.team"),
},
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
index 188b484fe8c..6f4f9e93220 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
@@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next";
import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
import DagRunInfo from "src/components/DagRunInfo";
import { Stat } from "src/components/Stat";
+import { TeamName } from "src/components/TeamName";
import { RouterLink, Tooltip } from "src/components/ui";
import { useNearViewport } from "src/hooks/useNearViewport";
import { useConfig } from "src/queries/useConfig";
@@ -125,11 +126,7 @@ export const DagCard = ({ dag, runStateCounts,
runStateCountsLoading, stateCount
{multiTeamEnabled ? (
<GridItem gridColumn={4} gridRow={1}>
<Stat label={translate("dagDetails.team")}>
- {dag.team_name === undefined || dag.team_name === null ?
undefined : (
- <RouterLink
to={`/dags?teams=${encodeURIComponent(dag.team_name)}`}>
- {dag.team_name}
- </RouterLink>
- )}
+ <TeamName teamName={dag.team_name} />
</Stat>
</GridItem>
) : undefined}
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index 5faaa5321ae..a3a621f41ff 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -32,6 +32,7 @@ import { useTableURLState } from
"src/components/DataTable/useTableUrlState";
import { ErrorAlert } from "src/components/ErrorAlert";
import { NeedsReviewBadge } from "src/components/NeedsReviewBadge";
import { SearchBar } from "src/components/SearchBar";
+import { TeamName } from "src/components/TeamName";
import { TogglePause } from "src/components/TogglePause";
import { TriggerDAGButton } from "src/components/TriggerDag/TriggerDAGButton";
import { RouterLink } from "src/components/ui";
@@ -164,10 +165,9 @@ const createColumns = (
? [
{
accessorKey: "team_name",
- cell: ({ row: { original } }: { row: { original:
DAGWithLatestDagRunsResponse } }) =>
- original.team_name !== undefined && original.team_name !== null ? (
- <RouterLink
to={`/dags?teams=${original.team_name}`}>{original.team_name}</RouterLink>
- ) : undefined,
+ cell: ({ row: { original } }: { row: { original:
DAGWithLatestDagRunsResponse } }) => (
+ <TeamName teamName={original.team_name} />
+ ),
enableSorting: false,
header: () => translate("dagDetails.team"),
},
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
index e7ec9fa8364..8a793147bbd 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
@@ -25,8 +25,10 @@ import { DagVersionDetails } from
"src/components/DagVersionDetails";
import RenderedJsonField from "src/components/RenderedJsonField";
import { RunTypeIcon } from "src/components/RunTypeIcon";
import { StateBadge } from "src/components/StateBadge";
+import { TeamName } from "src/components/TeamName";
import Time from "src/components/Time";
import { ClipboardRoot, ClipboardIconButton } from "src/components/ui";
+import { useShowTeam } from "src/hooks/useShowTeam";
import { getDuration, isStatePending, renderDuration, useAutoRefresh } from
"src/utils";
export const Details = () => {
@@ -46,6 +48,8 @@ export const Details = () => {
const { data: dagRunStats } = useDagRunServiceGetDagRunStats({ dagId,
dagRunId: runId });
+ const showTeam = useShowTeam(dagRun?.team_name);
+
if (!dagRun) {
return undefined;
}
@@ -82,6 +86,14 @@ export const Details = () => {
</HStack>
</Table.Cell>
</Table.Row>
+ {showTeam ? (
+ <Table.Row>
+ <Table.Cell>{translate("dagDetails.team")}</Table.Cell>
+ <Table.Cell>
+ <TeamName teamName={dagRun.team_name} />
+ </Table.Cell>
+ </Table.Row>
+ ) : undefined}
<Table.Row>
<Table.Cell>{translate("duration")}</Table.Cell>
<Table.Cell>{getDuration(dagRun.start_date,
dagRun.end_date)}</Table.Cell>
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Header.test.tsx
index a9ba55247b7..881758834f8 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.test.tsx
@@ -45,6 +45,12 @@ vi.mock("openapi/queries", async (importOriginal) => {
};
});
+const mockConfig: Record<string, unknown> = { multi_team: false };
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: (key: string) => mockConfig[key],
+}));
+
const { useDeadlinesServiceGetDagDeadlineAlerts } = await
import("openapi/queries");
const baseDagRun = {
@@ -73,6 +79,7 @@ const baseDagRun = {
describe("Header", () => {
beforeEach(() => {
+ mockConfig.multi_team = false;
vi.mocked(useDeadlinesServiceGetDagDeadlineAlerts).mockReturnValue({
data: undefined,
} as ReturnType<typeof useDeadlinesServiceGetDagDeadlineAlerts>);
@@ -91,4 +98,18 @@ describe("Header", () => {
expect(screen.queryByText(i18n.t("dagRun.partitionKey"))).not.toBeInTheDocument();
});
+
+ it("shows the team stat when multi-team is enabled and a team is resolved",
() => {
+ mockConfig.multi_team = true;
+ render(<Header dagRun={{ ...baseDagRun, team_name: "team-a" }} />, {
wrapper: Wrapper });
+
+
expect(screen.getByText(i18n.t("common:dagDetails.team"))).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "team-a"
})).toHaveAttribute("href", "/dags?teams=team-a");
+ });
+
+ it("hides the team stat when multi-team is disabled", () => {
+ render(<Header dagRun={{ ...baseDagRun, team_name: "team-a" }} />, {
wrapper: Wrapper });
+
+
expect(screen.queryByText(i18n.t("common:dagDetails.team"))).not.toBeInTheDocument();
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
index c31db0e9ac0..a4505195548 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
@@ -30,9 +30,11 @@ import { MarkRunAsButton } from "src/components/MarkAs";
import { NeedsReviewButtonWithModal } from "src/components/NeedsReviewButton";
import NotePreview from "src/components/NotePreview";
import { RunTypeIcon } from "src/components/RunTypeIcon";
+import { TeamName } from "src/components/TeamName";
import Time from "src/components/Time";
import { RouterLink } from "src/components/ui";
import { SearchParamsKeys } from "src/constants/searchParams";
+import { useShowTeam } from "src/hooks/useShowTeam";
import DeleteRunButton from "src/pages/DagRuns/DeleteRunButton";
import { useDagRunNote } from "src/queries/useDagRunNote";
import { getDuration } from "src/utils";
@@ -42,6 +44,7 @@ import { DeadlineStatus } from "./DeadlineStatus";
export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => {
const { t: translate } = useTranslation();
const { isPending, note, onOpen, onSave, setNote } = useDagRunNote(dagRun);
+ const showTeam = useShowTeam(dagRun.team_name);
const dagId = dagRun.dag_id;
const dagRunId = dagRun.dag_run_id;
@@ -105,6 +108,14 @@ export const Header = ({ dagRun }: { readonly dagRun:
DAGRunResponse }) => {
),
},
]),
+ ...(showTeam
+ ? [
+ {
+ label: translate("dagDetails.team"),
+ value: <TeamName teamName={dagRun.team_name} />,
+ },
+ ]
+ : []),
{
label: translate("dagRun.dagVersions"),
value: (
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
index 6b5eccf7f5d..279a4ac860a 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
@@ -29,9 +29,11 @@ import { DagVersionDetails } from
"src/components/DagVersionDetails";
import RenderedJsonField from "src/components/RenderedJsonField";
import { StateBadge } from "src/components/StateBadge";
import { TaskTrySelect } from "src/components/TaskTrySelect";
+import { TeamName } from "src/components/TeamName";
import Time from "src/components/Time";
import { ClipboardRoot, ClipboardIconButton } from "src/components/ui";
import { SearchParamsKeys } from "src/constants/searchParams";
+import { useShowTeam } from "src/hooks/useShowTeam";
import { useAutoRefresh, isStatePending, renderDuration } from "src/utils";
import { BlockingDeps } from "./BlockingDeps";
@@ -59,6 +61,8 @@ export const Details = () => {
},
);
+ const showTeam = useShowTeam(taskInstance?.team_name);
+
const onSelectTryNumber = (newTryNumber: number) => {
if (newTryNumber === taskInstance?.try_number) {
searchParams.delete(SearchParamsKeys.TRY_NUMBER);
@@ -179,6 +183,14 @@ export const Details = () => {
<Table.Cell>{translate("mapIndex")}</Table.Cell>
<Table.Cell>{tryInstance?.map_index}</Table.Cell>
</Table.Row>
+ {showTeam ? (
+ <Table.Row>
+ <Table.Cell>{translate("dagDetails.team")}</Table.Cell>
+ <Table.Cell>
+ <TeamName teamName={taskInstance?.team_name} />
+ </Table.Cell>
+ </Table.Row>
+ ) : undefined}
<Table.Row>
<Table.Cell>{translate("task.operator")}</Table.Cell>
<Table.Cell>{tryInstance?.operator_name}</Table.Cell>
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx
new file mode 100644
index 00000000000..b9b263e7dcd
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.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 "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+import i18n from "src/i18n/config";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { Header } from "./Header";
+
+// Action buttons and note preview pull in mutation/permission wiring that is
+// unrelated to the team stat under test; stub them out so the test only
+// depends on the stats rendered by Header itself.
+vi.mock("src/components/Clear", () => ({ ClearTaskInstanceButton: () =>
undefined }));
+vi.mock("src/components/Clear/TaskInstance/ClearTaskInstanceDialog", () => ({
default: () => undefined }));
+vi.mock("src/components/MarkAs", () => ({ MarkTaskInstanceAsButton: () =>
undefined }));
+vi.mock("src/components/NotePreview", () => ({ default: () => undefined }));
+
+const mockConfig: Record<string, unknown> = { multi_team: false };
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: (key: string) => mockConfig[key],
+}));
+
+const baseTaskInstance = {
+ dag_id: "test_dag",
+ dag_run_id: "run_1",
+ dag_version: null,
+ duration: null,
+ end_date: null,
+ map_index: -1,
+ note: null,
+ operator_name: "PythonOperator",
+ rendered_map_index: null,
+ start_date: null,
+ state: "success",
+ task_display_name: "test_task",
+ task_id: "test_task",
+ try_number: 1,
+} satisfies Partial<TaskInstanceResponse> as unknown as TaskInstanceResponse;
+
+describe("Header", () => {
+ beforeEach(() => {
+ mockConfig.multi_team = false;
+ });
+
+ it("shows the team stat when multi-team is enabled and a team is resolved",
() => {
+ mockConfig.multi_team = true;
+ render(<Header taskInstance={{ ...baseTaskInstance, team_name: "team-a" }}
/>, { wrapper: Wrapper });
+
+
expect(screen.getByText(i18n.t("common:dagDetails.team"))).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "team-a"
})).toHaveAttribute("href", "/dags?teams=team-a");
+ });
+
+ it("hides the team stat when multi-team is disabled", () => {
+ render(<Header taskInstance={{ ...baseTaskInstance, team_name: "team-a" }}
/>, { wrapper: Wrapper });
+
+
expect(screen.queryByText(i18n.t("common:dagDetails.team"))).not.toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
index 48364a9e9a6..624751e6dd0 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
@@ -28,13 +28,16 @@ import { DagVersion } from "src/components/DagVersion";
import { HeaderCard } from "src/components/HeaderCard";
import { MarkTaskInstanceAsButton } from "src/components/MarkAs";
import NotePreview from "src/components/NotePreview";
+import { TeamName } from "src/components/TeamName";
import Time from "src/components/Time";
+import { useShowTeam } from "src/hooks/useShowTeam";
import { useTaskInstanceNote } from "src/queries/useTaskInstanceNote";
import { getDuration, renderDuration } from "src/utils";
export const Header = ({ taskInstance }: { readonly taskInstance:
TaskInstanceResponse }) => {
const { t: translate } = useTranslation();
const { isPending, note, onOpen, onSave, setNote } =
useTaskInstanceNote(taskInstance);
+ const showTeam = useShowTeam(taskInstance.team_name);
const stats = [
{ label: translate("task.operator"), value: taskInstance.operator_name },
@@ -56,6 +59,14 @@ export const Header = ({ taskInstance }: { readonly
taskInstance: TaskInstanceRe
},
]
: []),
+ ...(showTeam
+ ? [
+ {
+ label: translate("dagDetails.team"),
+ value: <TeamName teamName={taskInstance.team_name} />,
+ },
+ ]
+ : []),
{
label: translate("taskInstance.dagVersion"),
value: <DagVersion version={taskInstance.dag_version} />,
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index c1b5f583467..7f6a899adf6 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -38,6 +38,7 @@ import { useTableURLState } from
"src/components/DataTable/useTableUrlState";
import { ErrorAlert } from "src/components/ErrorAlert";
import { MarkTaskInstanceAsButton } from "src/components/MarkAs";
import { StateBadge } from "src/components/StateBadge";
+import { TeamName } from "src/components/TeamName";
import Time from "src/components/Time";
import { TruncatedText } from "src/components/TruncatedText";
import { RouterLink } from "src/components/ui";
@@ -181,12 +182,7 @@ const taskInstanceColumns = ({
? [
{
accessorKey: "team_name",
- cell: ({ row: { original } }: TaskInstanceRow) =>
- original.team_name !== undefined && original.team_name !== null ? (
- <RouterLink
to={`/dags?teams=${encodeURIComponent(original.team_name)}`}>
- {original.team_name}
- </RouterLink>
- ) : undefined,
+ cell: ({ row: { original } }: TaskInstanceRow) => <TeamName
teamName={original.team_name} />,
enableSorting: false,
header: translate("dagDetails.team"),
},
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index 0a14f69bcbe..974ebf82d3e 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -413,6 +413,25 @@ class TestGetDagRun:
assert body["triggered_by"] == triggered_by.value
assert body["note"] == dag_run_note
+ @conf_vars({("core", "multi_team"): "True"})
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ def test_get_dag_run_includes_team_name(self, test_client, session):
+ original_bundle_name = _attach_dag_to_team(
+ session, DAG1_ID, bundle_name="team-bundle-run",
team_name="team-run"
+ )
+ try:
+ response =
test_client.get(f"/dags/{DAG1_ID}/dagRuns/{DAG1_RUN1_ID}")
+ assert response.status_code == 200
+ assert response.json()["team_name"] == "team-run"
+ finally:
+ _detach_dag_from_team(
+ session,
+ DAG1_ID,
+ bundle_name="team-bundle-run",
+ team_name="team-run",
+ original_bundle_name=original_bundle_name,
+ )
+
def test_get_dag_run_not_found(self, test_client):
response = test_client.get(f"/dags/{DAG1_ID}/dagRuns/invalid")
assert response.status_code == 404
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
index f47d6f54c2a..8873c82e273 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
@@ -21,17 +21,20 @@ from unittest import mock
import pendulum
import pytest
-from sqlalchemy import insert, select
+from sqlalchemy import delete, insert, select, update
from airflow.models.asset import AssetModel, DagScheduleAssetReference
from airflow.models.dag import DagModel, DagTag
from airflow.models.dag_favorite import DagFavorite
+from airflow.models.dagbundle import DagBundleModel
from airflow.models.dagrun import DagRun
+from airflow.models.team import Team
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.utils.state import DagRunState, TaskInstanceState
from airflow.utils.types import DagRunTriggeredByType, DagRunType
from tests_common.test_utils.asserts import assert_queries_count, count_queries
+from tests_common.test_utils.config import conf_vars
from tests_common.test_utils.db import (
clear_db_assets,
clear_db_connections,
@@ -1082,6 +1085,7 @@ class TestDagDetails(TestDagEndpoint):
"timetable_periodic": False,
"timetable_summary": None,
"timezone": UTC_JSON_REPR,
+ "team_name": None,
}
assert res_json == expected
@@ -1184,6 +1188,7 @@ class TestDagDetails(TestDagEndpoint):
"timetable_partitioned": False,
"timetable_periodic": False,
"timezone": UTC_JSON_REPR,
+ "team_name": None,
}
assert res_json == expected
@@ -1289,6 +1294,35 @@ class TestDagDetails(TestDagEndpoint):
assert isinstance(body["active_runs_count"], int)
assert body["active_runs_count"] == 0
+ def test_dag_details_team_name_none_without_multi_team(self, test_client):
+ """Without multi-team enabled, ``team_name`` stays ``None`` and no
lookup happens."""
+ response = test_client.get(f"/dags/{DAG1_ID}/details")
+ assert response.status_code == 200
+ assert response.json()["team_name"] is None
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_dag_details_includes_team_name(self, session, test_client):
+ original_bundle_name =
session.scalar(select(DagModel.bundle_name).where(DagModel.dag_id == DAG1_ID))
+ bundle = DagBundleModel(name="team-bundle-details")
+ bundle.teams.append(Team(name="team-details"))
+ session.add(bundle)
+ session.flush()
+ session.execute(
+ update(DagModel).where(DagModel.dag_id ==
DAG1_ID).values(bundle_name="team-bundle-details")
+ )
+ session.commit()
+ try:
+ response = test_client.get(f"/dags/{DAG1_ID}/details")
+ assert response.status_code == 200
+ assert response.json()["team_name"] == "team-details"
+ finally:
+ session.execute(
+ update(DagModel).where(DagModel.dag_id ==
DAG1_ID).values(bundle_name=original_bundle_name)
+ )
+ session.execute(delete(DagBundleModel).where(DagBundleModel.name
== "team-bundle-details"))
+ session.execute(delete(Team).where(Team.name == "team-details"))
+ session.commit()
+
class TestGetDag(TestDagEndpoint):
"""Unit tests for Get DAG."""
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
index f68acf75d60..6dc6ecac582 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
@@ -274,6 +274,27 @@ class TestGetTaskInstance(TestTaskInstanceEndpoint):
"team_name": None,
}
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_should_include_team_name(self, test_client, session):
+ self.create_task_instances(session)
+ original_bundle_name = _attach_dag_to_team(
+ session, "example_python_operator", bundle_name="team-bundle-ti",
team_name="team-ti"
+ )
+ try:
+ response = test_client.get(
+
"/dags/example_python_operator/dagRuns/TEST_DAG_RUN_ID/taskInstances/print_the_context"
+ )
+ assert response.status_code == 200
+ assert response.json()["team_name"] == "team-ti"
+ finally:
+ _detach_dag_from_team(
+ session,
+ "example_python_operator",
+ bundle_name="team-bundle-ti",
+ team_name="team-ti",
+ original_bundle_name=original_bundle_name,
+ )
+
def test_should_respond_200_with_decorator(self, test_client, session):
self.create_task_instances(session, "example_python_decorator")
response = test_client.get(
@@ -701,6 +722,30 @@ class TestGetMappedTaskInstance(TestTaskInstanceEndpoint):
"detail": "The Mapped Task Instance with dag_id:
`example_python_operator`, run_id: `TEST_DAG_RUN_ID`, task_id:
`print_the_context`, and map_index: `10` was not found"
}
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_should_include_team_name(self, test_client, session):
+ self.create_task_instances(session)
+ original_bundle_name = _attach_dag_to_team(
+ session,
+ "example_python_operator",
+ bundle_name="team-bundle-mapped-ti",
+ team_name="team-mapped-ti",
+ )
+ try:
+ response = test_client.get(
+
"/dags/example_python_operator/dagRuns/TEST_DAG_RUN_ID/taskInstances/print_the_context/-1",
+ )
+ assert response.status_code == 200
+ assert response.json()["team_name"] == "team-mapped-ti"
+ finally:
+ _detach_dag_from_team(
+ session,
+ "example_python_operator",
+ bundle_name="team-bundle-mapped-ti",
+ team_name="team-mapped-ti",
+ original_bundle_name=original_bundle_name,
+ )
+
class TestGetMappedTaskInstances:
@pytest.fixture(autouse=True)
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index bb3c0f7e5a7..36ed804e647 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -1032,6 +1032,65 @@ class TestTIRunState:
assert response.status_code == 200
assert response.json()["dag_run"]["team_name"] == (team_name if
expect_team else None)
+ def test_ti_run_team_name_is_not_served_from_a_stale_cache(
+ self, client, session, dag_maker, time_machine
+ ):
+ """
+ ``ti_run`` must eager load the team rather than fall back to the
cached resolver.
+
+ The fallback caches per dag_id for ``team_name_cache_ttl`` seconds, so
a Dag whose team
+ was looked up before it moved bundles would keep reporting the old
team to the worker.
+ """
+ from airflow.models.dag import clear_team_name_cache
+ from airflow.models.dagbundle import DagBundleModel
+ from airflow.models.team import Team
+
+ instant = timezone.parse("2024-09-30T12:00:00Z")
+ time_machine.move_to(instant, tick=False)
+
+ dag_id = str(uuid4())
+ with dag_maker(dag_id=dag_id, session=session):
+ EmptyOperator(task_id="task")
+ dr = dag_maker.create_dagrun(
+ run_id="test", logical_date=instant, state=DagRunState.RUNNING,
start_date=instant
+ )
+ ti = dr.get_task_instance(task_id="task")
+ ti.set_state(State.QUEUED)
+
+ for suffix in ("old", "new"):
+ bundle = DagBundleModel(name=f"bundle-{suffix}-{dag_id}")
+ bundle.teams.append(Team(name=f"team-{suffix}-{dag_id[:8]}"))
+ session.add(bundle)
+ session.flush()
+ session.execute(
+ update(DagModel).where(DagModel.dag_id ==
dag_id).values(bundle_name=f"bundle-old-{dag_id}")
+ )
+ session.commit()
+
+ with conf_vars({("core", "multi_team"): "True"}):
+ clear_team_name_cache()
+ # Warm the cache with the old team, then move the Dag to the other
bundle.
+ assert DagModel.get_team_name(dag_id, session=session) ==
f"team-old-{dag_id[:8]}"
+ session.execute(
+ update(DagModel).where(DagModel.dag_id ==
dag_id).values(bundle_name=f"bundle-new-{dag_id}")
+ )
+ session.commit()
+
+ response = client.patch(
+ f"/execution/task-instances/{ti.id}/run",
+ json={
+ "state": "running",
+ "hostname": "h",
+ "unixname": "u",
+ "pid": 1,
+ "start_date": "2024-09-30T12:00:00Z",
+ },
+ )
+ clear_team_name_cache()
+
+ assert response.status_code == 200
+ assert response.json()["dag_run"]["team_name"] ==
f"team-new-{dag_id[:8]}"
+
def test_ti_run_creates_audit_log(self, client, session,
create_task_instance, time_machine):
"""Test that transitioning to RUNNING creates an audit log record."""
instant_str = "2024-09-30T12:00:00Z"
diff --git a/airflow-core/tests/unit/models/test_team.py
b/airflow-core/tests/unit/models/test_team.py
index dcc4c85cc2c..c98f0d0e3f6 100644
--- a/airflow-core/tests/unit/models/test_team.py
+++ b/airflow-core/tests/unit/models/test_team.py
@@ -17,9 +17,24 @@
from __future__ import annotations
import pytest
+from sqlalchemy import delete, inspect as sa_inspect, select, update
+from airflow.api_fastapi.common.db.dags import eager_load_teams
+from airflow.models.dag import DagModel, clear_team_name_cache
+from airflow.models.dagbundle import DagBundleModel
+from airflow.models.dagrun import DagRun
+from airflow.models.taskinstance import TaskInstance
from airflow.models.team import Team
+from tests_common.test_utils.asserts import assert_queries_count
+from tests_common.test_utils.config import conf_vars
+
+pytestmark = pytest.mark.db_test
+
+DAG_ID = "team_owned_dag"
+BUNDLE_NAME = "team-owned-bundle"
+TEAM_NAME = "owning-team"
+
class TestTeam:
"""Unit tests for Team model class methods."""
@@ -38,3 +53,118 @@ class TestTeam:
assert result == {"testing"}
assert isinstance(result, set)
+
+
+class TestTeamName:
+ """``team_name`` resolution on the models that reach a team through a
relationship."""
+
+ @pytest.fixture
+ def team_owned_run(self, dag_maker, session):
+ """A Dag run and task instance whose Dag is owned by ``TEAM_NAME`` via
its bundle."""
+ with dag_maker(DAG_ID, session=session):
+ from airflow.providers.standard.operators.empty import
EmptyOperator
+
+ EmptyOperator(task_id="task")
+ dag_run = dag_maker.create_dagrun()
+ original_bundle_name =
session.scalar(select(DagModel.bundle_name).where(DagModel.dag_id == DAG_ID))
+
+ session.execute(delete(DagBundleModel).where(DagBundleModel.name ==
BUNDLE_NAME))
+ session.execute(delete(Team).where(Team.name == TEAM_NAME))
+ session.flush()
+ bundle = DagBundleModel(name=BUNDLE_NAME)
+ bundle.teams.append(Team(name=TEAM_NAME))
+ session.add(bundle)
+ session.flush()
+ session.execute(update(DagModel).where(DagModel.dag_id ==
DAG_ID).values(bundle_name=BUNDLE_NAME))
+ session.commit()
+ clear_team_name_cache()
+
+ yield dag_run
+
+ # bundle_name is a foreign key with no ON DELETE action, so restore it
before
+ # dropping the bundle this fixture introduced.
+ session.execute(
+ update(DagModel).where(DagModel.dag_id ==
DAG_ID).values(bundle_name=original_bundle_name)
+ )
+ session.execute(delete(DagBundleModel).where(DagBundleModel.name ==
BUNDLE_NAME))
+ session.execute(delete(Team).where(Team.name == TEAM_NAME))
+ session.commit()
+ clear_team_name_cache()
+
+ def get_run(self, session, *, eager_load: bool) -> DagRun:
+ """Re-fetch the run, with or without the team eager loading options."""
+ session.expunge_all()
+ options = eager_load_teams(DagRun.dag_model) if eager_load else ()
+ return session.scalar(select(DagRun).where(DagRun.dag_id ==
DAG_ID).options(*options))
+
+ def get_dag_model(self, session, *, eager_load: bool) -> DagModel:
+ """Re-fetch the Dag, with or without the team eager loading options."""
+ session.expunge_all()
+ options = eager_load_teams() if eager_load else ()
+ return session.scalar(select(DagModel).where(DagModel.dag_id ==
DAG_ID).options(*options))
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_team_name_uses_eager_loaded_relationships(self, team_owned_run,
session):
+ dag_run = self.get_run(session, eager_load=True)
+
+ with assert_queries_count(0, session=session):
+ assert dag_run.team_name == TEAM_NAME
+
+ @conf_vars({("core", "multi_team"): "True"})
+ @pytest.mark.parametrize("entity", ["dag", "dag_run"])
+ def test_team_name_falls_back_when_not_eager_loaded(self, team_owned_run,
session, entity):
+ """Paths that cannot eager load resolve via the cached resolver, not
``lazy="raise"``."""
+ if entity == "dag":
+ assert self.get_dag_model(session, eager_load=False).team_name ==
TEAM_NAME
+ else:
+ assert self.get_run(session, eager_load=False).team_name ==
TEAM_NAME
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_team_name_fallback_keeps_caller_objects_attached(self,
team_owned_run, session):
+ """The fallback must not close the caller's scoped session out from
under it."""
+ dag_model = self.get_dag_model(session, eager_load=False)
+
+ assert dag_model.team_name == TEAM_NAME
+ assert not sa_inspect(dag_model).detached
+ # Would raise DetachedInstanceError if the resolver had closed the
shared session.
+ assert dag_model.dag_versions is not None
+
+ @conf_vars({("core", "multi_team"): "False"})
+ def test_team_name_is_none_without_multi_team(self, team_owned_run,
session):
+ """Single-team deployments answer ``None`` without loading anything."""
+ dag_run = self.get_run(session, eager_load=False)
+
+ with assert_queries_count(0, session=session):
+ assert dag_run.team_name is None
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_team_name_on_task_instance(self, team_owned_run, session):
+ task_instance =
session.scalar(select(TaskInstance).where(TaskInstance.dag_id == DAG_ID))
+
+ assert task_instance.team_name == TEAM_NAME
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_team_name_is_none_for_bundle_without_team(self, dag_maker,
session):
+ with dag_maker("unteamed_dag", session=session):
+ from airflow.providers.standard.operators.empty import
EmptyOperator
+
+ EmptyOperator(task_id="task")
+ dag_maker.create_dagrun()
+ session.commit()
+ clear_team_name_cache()
+
+ dag_run = session.scalar(
+ select(DagRun).where(DagRun.dag_id ==
"unteamed_dag").options(*eager_load_teams(DagRun.dag_model))
+ )
+
+ assert dag_run.team_name is None
+ clear_team_name_cache()
+
+ def test_eager_load_teams_is_a_no_op_without_multi_team(self):
+ with conf_vars({("core", "multi_team"): "False"}):
+ assert eager_load_teams() == ()
+ assert eager_load_teams(DagRun.dag_model) == ()
+
+ with conf_vars({("core", "multi_team"): "True"}):
+ assert eager_load_teams() != ()
+ assert eager_load_teams(DagRun.dag_model) != ()
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index a4b7f60fdaf..ca3c63d6168 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -2640,6 +2640,7 @@ class DAGDetailsResponse(BaseModel):
owner_links: Annotated[dict[str, str] | None, Field(title="Owner Links")]
= None
is_favorite: Annotated[bool | None, Field(title="Is Favorite")] = False
active_runs_count: Annotated[int | None, Field(title="Active Runs Count")]
= 0
+ team_name: Annotated[str | None, Field(title="Team Name")] = None
is_backfillable: Annotated[
bool, Field(description="Whether this Dag's schedule supports
backfilling.", title="Is Backfillable")
]
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/utils/utils.py
b/providers/openlineage/src/airflow/providers/openlineage/utils/utils.py
index 0afb3b347df..dace509b880 100644
--- a/providers/openlineage/src/airflow/providers/openlineage/utils/utils.py
+++ b/providers/openlineage/src/airflow/providers/openlineage/utils/utils.py
@@ -1075,9 +1075,10 @@ class DagRunInfo(InfoJsonEncodable):
# The Execution API delivers the team name on the DagRun payload it
sends to the task
# runner, so task events resolve it from there rather than through the
bundle lookup below,
# which needs a metadata DB session the task runner does not have.
- # `hasattr` rather than a None check -- a team-less run legitimately
carries None, while
- # the scheduler's ORM DagRun has no such attribute at all.
- if hasattr(dagrun, "team_name"):
+ # `hasattr` rather than a None check -- a team-less run legitimately
carries None. The ORM
+ # DagRun exposes `team_name` too, but reading it lazy-loads the
bundle, so scheduler-side
+ # runs stay on the guarded lookup below instead of risking a load on a
detached instance.
+ if not isinstance(dagrun, DagRun) and hasattr(dagrun, "team_name"):
return dagrun.team_name
# Best-effort: the scheduler stamps `_team_name` on ORM DagRun objects
before
diff --git a/scripts/ci/prek/check_ti_vs_tis_attributes.py
b/scripts/ci/prek/check_ti_vs_tis_attributes.py
index 3ae595c7398..49de566fcba 100755
--- a/scripts/ci/prek/check_ti_vs_tis_attributes.py
+++ b/scripts/ci/prek/check_ti_vs_tis_attributes.py
@@ -63,6 +63,8 @@ def compare_attributes(path1, path2):
# Storing last heartbeat for historic TIs is not interesting/useful
"last_heartbeat_at",
"id",
+ # Resolved through the dag_run relationship, not stored on the TI row
+ "team_name",
} # exclude attrs not necessary to be in TaskInstanceHistory
if not diff:
return