diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx index d53e202970343..ade558468cd2d 100644 --- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx @@ -29,6 +29,7 @@ import { BreadcrumbStats } from "src/components/BreadcrumbStats"; import { ProgressBar } from "src/components/ui"; import { GroupsProvider } from "src/context/groups"; import { NavTabs } from "src/layouts/Details/NavTabs"; +import { useDocumentTitle } from "src/utils"; import { AssetGraph } from "./AssetGraph"; import { AssetPanelButtons } from "./AssetPanelButtons"; @@ -49,6 +50,8 @@ export const AssetLayout = () => { }, ); + useDocumentTitle(asset?.name); + const links = [ { label: asset?.name, diff --git a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx index 9ddbe3d77ee0b..150b57ba9684d 100644 --- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx +++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx @@ -34,6 +34,7 @@ import { RouterLink } from "src/components/ui"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useAdvancedSearch } from "src/hooks/useAdvancedSearch"; import { CreateAssetEvent } from "src/pages/Asset/CreateAssetEvent"; +import { useDocumentTitle } from "src/utils"; import { DependencyPopover } from "./DependencyPopover"; @@ -117,6 +118,9 @@ const { NAME_PATTERN, OFFSET }: SearchParamsKeysType = SearchParamsKeys; export const AssetsList = () => { const { t: translate } = useTranslation(["assets", "common"]); + + useDocumentTitle(translate("common:nav.assets")); + const [searchParams, setSearchParams] = useSearchParams(); const namePattern = searchParams.get(NAME_PATTERN) ?? ""; diff --git a/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx b/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx index 9c12e10a49e63..4e7cc724bf728 100644 --- a/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx @@ -25,6 +25,7 @@ import { useConfigServiceGetConfig } from "openapi/queries"; import type { ConfigOption } from "openapi/requests/types.gen"; import { DataTable } from "src/components/DataTable"; import { ErrorAlert } from "src/components/ErrorAlert"; +import { useDocumentTitle } from "src/utils"; type ConfigColums = { section: string; @@ -50,6 +51,9 @@ const createColumns = (translate: TFunction): Array> => export const Configs = () => { const { t: translate } = useTranslation(["admin", "common"]); + + useDocumentTitle(translate("common:admin.Config")); + const { data, error } = useConfigServiceGetConfig(); const columns = createColumns(translate); diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx index 42a56a8fac1d7..44a86347f7083 100644 --- a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx @@ -42,6 +42,7 @@ import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searc import { useAdvancedSearch } from "src/hooks/useAdvancedSearch"; import { useConfig } from "src/queries/useConfig.tsx"; import { useConnectionTypeMeta } from "src/queries/useConnectionTypeMeta"; +import { useDocumentTitle } from "src/utils"; import AddConnectionButton from "./AddConnectionButton"; import DeleteConnectionButton from "./DeleteConnectionButton"; @@ -132,6 +133,9 @@ const getColumns = ({ export const Connections = () => { const { t: translate } = useTranslation(["admin", "common"]); + + useDocumentTitle(translate("common:admin.Connections")); + const { setTableURLState, tableURLState } = useTableURLState(); const [searchParams, setSearchParams] = useSearchParams(); const { NAME_PATTERN, OFFSET }: SearchParamsKeysType = SearchParamsKeys; diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx index 06374d10b27a1..ac4d1b9a886d2 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx @@ -47,7 +47,7 @@ import { RouterLink } from "src/components/ui"; import { ActionBar } from "src/components/ui/ActionBar"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch"; -import { renderDuration, useAutoRefresh, isStatePending } from "src/utils"; +import { renderDuration, useAutoRefresh, isStatePending, useDocumentTitle } from "src/utils"; import BulkClearDagRunsButton from "./BulkClearDagRunsButton"; import BulkDeleteDagRunsButton from "./BulkDeleteDagRunsButton"; @@ -221,6 +221,10 @@ const runColumns = ({ dagId, translate }: ColumnProps): Array { const { t: translate } = useTranslation(); const { dagId } = useParams(); + + // Only the standalone list page owns the tab title; the Dag-scoped tab inherits the Dag page's title. + useDocumentTitle(dagId === undefined ? translate("common:dagRun_other") : undefined); + const [searchParams] = useSearchParams(); const { setTableURLState, tableURLState } = useTableURLState({ 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 12e08fdb51beb..fd6afacdbef89 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx @@ -42,6 +42,7 @@ import { DagsLayout } from "src/layouts/DagsLayout"; import { useConfig } from "src/queries/useConfig"; import { useDagRunStateCounts } from "src/queries/useDagRunStateCounts"; import { useDags } from "src/queries/useDags"; +import { useDocumentTitle } from "src/utils"; import { DagImportErrors } from "../Dashboard/Stats/DagImportErrors"; import { DagCard } from "./DagCard"; @@ -218,6 +219,9 @@ const createCardDef = (runStateContext: RunStateCountsContext): CardDef { const { t: translate } = useTranslation(); + + useDocumentTitle(translate("common:nav.dags")); + const [searchParams, setSearchParams] = useSearchParams(); const [display, setDisplay] = useLocalStorage<"card" | "table">(DAGS_LIST_DISPLAY_KEY, "card"); const dagRunsLimit = display === "card" ? 14 : 1; diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx index a5867947d15d9..8af92bb6f4d85 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx @@ -29,6 +29,7 @@ import TimeRangeSelector from "src/components/TimeRangeSelector"; import { IconButton } from "src/components/ui"; import { COLLAPSED_UI_ALERTS_KEY } from "src/constants/localStorage"; import { useConfig } from "src/queries/useConfig"; +import { useDocumentTitle } from "src/utils"; import { ReactPlugin } from "../ReactPlugin"; import { AlertContent } from "./AlertContent"; @@ -44,6 +45,9 @@ const defaultHour = "24"; export const Dashboard = () => { const alerts = useConfig("dashboard_alert") as Array; const { t: translate } = useTranslation("dashboard"); + + useDocumentTitle(translate("common:nav.home")); + const instanceName = useConfig("instance_name"); const now = dayjs(); diff --git a/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx b/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx index 720648c4247b0..54a01f5cdb0ff 100644 --- a/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx @@ -31,7 +31,7 @@ import { FilterBar } from "src/components/FilterBar"; import Time from "src/components/Time"; import { TruncatedText } from "src/components/TruncatedText"; import { SearchParamsKeys } from "src/constants/searchParams"; -import { useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils"; +import { useDocumentTitle, useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils"; type DeadlineRow = { row: { original: DeadlineResponse } }; @@ -100,6 +100,9 @@ const deadlinesFilterKeys: Array = [ export const Deadlines = () => { const { t: translate } = useTranslation(["browse", "common"]); + + useDocumentTitle(translate("common:browse.deadlines")); + const { setTableURLState, tableURLState } = useTableURLState(); const [searchParams] = useSearchParams(); diff --git a/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx b/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx index dcc8adf784ea8..1b79cc771bbb4 100644 --- a/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx @@ -32,6 +32,7 @@ import RenderedJsonField from "src/components/RenderedJsonField"; import Time from "src/components/Time"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch"; +import { useDocumentTitle } from "src/utils"; import { EventsFilters } from "./EventsFilters"; @@ -161,6 +162,10 @@ const { export const Events = () => { const { t: translate } = useTranslation(["browse", "common"]); const { dagId, runId, taskId } = useParams(); + + // Only the standalone audit-log page owns the tab title; nested tabs inherit their parent page's title. + useDocumentTitle(dagId === undefined ? translate("common:browse.auditLog") : undefined); + const [searchParams] = useSearchParams(); const { setTableURLState, tableURLState } = useTableURLState(); const { pagination, sorting } = tableURLState; diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx index abb741e9498d2..3f819b64c89aa 100644 --- a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx +++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx @@ -23,12 +23,16 @@ import { useParams } from "react-router-dom"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts"; +import { useDocumentTitle } from "src/utils"; import { Header } from "./Header"; export const GroupTaskInstance = () => { const { dagId = "", groupId = "", runId = "" } = useParams(); const { t: translate } = useTranslation("dag"); + + useDocumentTitle(groupId); + const { summariesByRunId } = useGridTiSummariesStream({ dagId, runIds: runId ? [runId] : [] }); const gridTISummaries = summariesByRunId.get(runId); const taskInstance = gridTISummaries?.task_instances.find((ti) => ti.task_id === groupId); diff --git a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx index 71a0faaaf7b38..46667420dbd90 100644 --- a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx +++ b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx @@ -37,7 +37,7 @@ import { TruncatedText } from "src/components/TruncatedText"; import { IconButton, RouterLink } from "src/components/ui"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch"; -import { useAutoRefresh } from "src/utils"; +import { useAutoRefresh, useDocumentTitle } from "src/utils"; import { getHITLState, isHITLPending } from "src/utils/hitl"; import { getTaskInstanceLink } from "src/utils/links"; @@ -222,6 +222,10 @@ export const HITLTaskInstances = ({ }) => { const { t: translate } = useTranslation("hitl"); const { dagId, runId, taskId } = useParams(); + + // Only the standalone required-actions page owns the tab title; nested tabs inherit their parent's. + useDocumentTitle(enableHITLReviewDrawer ? translate("common:browse.requiredActions") : undefined); + const { closeHITLReviewDrawer, isHITLReviewDrawerOpen, openHITLReviewDrawer, selectedDetail } = useHITLReviewDrawer(); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/airflow-core/src/airflow/ui/src/pages/Jobs.tsx b/airflow-core/src/airflow/ui/src/pages/Jobs.tsx index 568c023d81889..992328c4cd0a4 100644 --- a/airflow-core/src/airflow/ui/src/pages/Jobs.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Jobs.tsx @@ -31,7 +31,7 @@ import { FilterBar } from "src/components/FilterBar"; import { StateBadge } from "src/components/StateBadge"; import Time from "src/components/Time"; import { SearchParamsKeys } from "src/constants/searchParams"; -import { useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils"; +import { useDocumentTitle, useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils"; const createColumns = (translate: TFunction): Array> => [ { @@ -95,6 +95,9 @@ const jobsFilterKeys: Array = [ export const Jobs = () => { const { t: translate } = useTranslation(["admin", "common"]); + + useDocumentTitle(translate("common:browse.jobs")); + const { setTableURLState, tableURLState } = useTableURLState(); const [searchParams] = useSearchParams(); diff --git a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx index 2bc1daa3ed60c..a7cee933b47dc 100644 --- a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx +++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx @@ -24,12 +24,16 @@ import { useParams } from "react-router-dom"; import { useDagRunServiceGetDagRun } from "openapi/queries"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts"; +import { useDocumentTitle } from "src/utils"; import { Header } from "./Header"; export const MappedTaskInstance = () => { const { dagId = "", runId = "", taskId = "" } = useParams(); const { t: translate } = useTranslation("dag"); + + useDocumentTitle(taskId); + // Pass the run state so the summaries stream keeps auto-refreshing while the run is running; // without it the Header and Details tab would freeze on the first fetch. const { data: dagRun } = useDagRunServiceGetDagRun({ dagId, dagRunId: runId }, undefined, { diff --git a/airflow-core/src/airflow/ui/src/pages/Plugins.tsx b/airflow-core/src/airflow/ui/src/pages/Plugins.tsx index 623a7176c859f..cde75c253d12f 100644 --- a/airflow-core/src/airflow/ui/src/pages/Plugins.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Plugins.tsx @@ -23,11 +23,15 @@ import { usePluginServiceGetPlugins } from "openapi/queries"; import { DataTable } from "src/components/DataTable"; import { useTableURLState } from "src/components/DataTable/useTableUrlState"; import { ErrorAlert } from "src/components/ErrorAlert"; +import { useDocumentTitle } from "src/utils"; import { PluginImportErrors } from "./Dashboard/Stats/PluginImportErrors"; export const Plugins = () => { const { t: translate } = useTranslation(["admin", "common"]); + + useDocumentTitle(translate("common:admin.Plugins")); + const { setTableURLState, tableURLState } = useTableURLState(); const { pagination } = tableURLState; const { data, error } = usePluginServiceGetPlugins({ diff --git a/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx b/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx index 89c08c22d12dd..1fab831ac492d 100644 --- a/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx @@ -33,6 +33,7 @@ import { Select } from "src/components/ui"; import type { SearchParamsKeysType } from "src/constants/searchParams"; import { SearchParamsKeys } from "src/constants/searchParams"; import { useAdvancedSearch } from "src/hooks/useAdvancedSearch"; +import { useDocumentTitle } from "src/utils"; import AddPoolButton from "./AddPoolButton"; import PoolBarCard from "./PoolBarCard"; @@ -47,6 +48,8 @@ const cardDef = (): CardDef => ({ export const Pools = () => { const { t: translate } = useTranslation(["admin", "common"]); + useDocumentTitle(translate("common:admin.Pools")); + const poolSortOptions = createListCollection({ items: [ { label: translate("pools.sort.asc"), value: "name" }, diff --git a/airflow-core/src/airflow/ui/src/pages/Providers.tsx b/airflow-core/src/airflow/ui/src/pages/Providers.tsx index e8e762fcb9bc6..96834c948b1a8 100644 --- a/airflow-core/src/airflow/ui/src/pages/Providers.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Providers.tsx @@ -27,6 +27,7 @@ import { DataTable } from "src/components/DataTable"; import { useTableURLState } from "src/components/DataTable/useTableUrlState"; import { ErrorAlert } from "src/components/ErrorAlert"; import { urlRegex } from "src/constants/urlRegex"; +import { useDocumentTitle } from "src/utils"; const createColumns = (translate: TFunction): Array> => [ { @@ -77,6 +78,9 @@ const createColumns = (translate: TFunction): Array> export const Providers = () => { const { t: translate } = useTranslation(["admin", "common"]); + + useDocumentTitle(translate("common:admin.Providers")); + const { setTableURLState, tableURLState } = useTableURLState(); const columns = createColumns(translate); diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx index ea49ef73b7c70..dcb6e4e1397d1 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx @@ -25,7 +25,7 @@ import { useParams } from "react-router-dom"; import { useDagRunServiceGetDagRun } from "openapi/queries"; import { usePluginTabs } from "src/hooks/usePluginTabs"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; -import { isStatePending, useAutoRefresh } from "src/utils"; +import { isStatePending, useAutoRefresh, useDocumentTitle } from "src/utils"; import { Header } from "./Header"; @@ -33,6 +33,8 @@ export const Run = () => { const { t: translate } = useTranslation(["dag", "hitl"]); const { dagId = "", runId = "" } = useParams(); + useDocumentTitle(runId); + // Get external views with dag_run destination const externalTabs = usePluginTabs("dag_run"); diff --git a/airflow-core/src/airflow/ui/src/pages/Security.tsx b/airflow-core/src/airflow/ui/src/pages/Security.tsx index 9a56996dcbcba..f6ccf567f6d13 100644 --- a/airflow-core/src/airflow/ui/src/pages/Security.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Security.tsx @@ -18,10 +18,12 @@ */ import { Box } from "@chakra-ui/react"; import { useRef } from "react"; +import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; import { useAuthLinksServiceGetAuthMenus } from "openapi/queries"; import { ProgressBar } from "src/components/ui"; +import { useDocumentTitle } from "src/utils"; import { ErrorPage } from "./Error"; @@ -33,6 +35,9 @@ const SANDBOX = "allow-scripts allow-same-origin allow-forms"; export const Security = () => { const { page } = useParams(); + const { t: translate } = useTranslation("common"); + + useDocumentTitle(translate("nav.security")); const { data: authLinks, isLoading } = useAuthLinksServiceGetAuthMenus(); diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx index 3d84c55c114e1..c53d18c2ee3d4 100644 --- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx @@ -28,6 +28,7 @@ import { usePluginTabs } from "src/hooks/usePluginTabs"; import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridStructure } from "src/queries/useGridStructure.ts"; +import { useDocumentTitle } from "src/utils"; import { getGroupTask } from "src/utils/groupTask"; import { GroupTaskHeader } from "./GroupTaskHeader"; @@ -37,6 +38,8 @@ export const Task = () => { const { t: translate } = useTranslation(["dag", "hitl"]); const { dagId = "", groupId, runId, taskId } = useParams(); + useDocumentTitle(groupId ?? taskId); + // Get external views with task destination const externalTabs = usePluginTabs("task"); diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx index 493270f0f789c..80fdd93268e34 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx @@ -30,13 +30,16 @@ import { usePluginTabs } from "src/hooks/usePluginTabs"; import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts"; -import { isStatePending, useAutoRefresh } from "src/utils"; +import { isStatePending, useAutoRefresh, useDocumentTitle } from "src/utils"; import { Header } from "./Header"; export const TaskInstance = () => { const { t: translate } = useTranslation(["dag", "common", "hitl"]); const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams(); + + useDocumentTitle(taskId); + // Get external views with task_instance destination const externalTabs = usePluginTabs("task_instance"); 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 3249b87662150..f09d7f07a228b 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx @@ -44,7 +44,7 @@ import { RouterLink } from "src/components/ui"; import { ActionBar } from "src/components/ui/ActionBar"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch"; -import { useAutoRefresh, isStatePending, renderDuration } from "src/utils"; +import { useAutoRefresh, isStatePending, renderDuration, useDocumentTitle } from "src/utils"; import { getTaskInstanceLink } from "src/utils/links"; import BulkClearTaskInstancesButton from "./BulkClearTaskInstancesButton"; @@ -253,6 +253,10 @@ const taskInstanceColumns = ({ export const TaskInstances = () => { const { t: translate } = useTranslation(); const { dagId, groupId, runId, taskId } = useParams(); + + // Only the standalone list page owns the tab title; nested tabs inherit their parent page's title. + useDocumentTitle(dagId === undefined ? translate("common:taskInstance_other") : undefined); + const [searchParams] = useSearchParams(); const { setTableURLState, tableURLState } = useTableURLState({ diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx index bff5770bb8186..fbb44813cf44c 100644 --- a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx @@ -42,6 +42,7 @@ import { ActionBar } from "src/components/ui/ActionBar"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useAdvancedSearch } from "src/hooks/useAdvancedSearch"; import { useConfig } from "src/queries/useConfig.tsx"; +import { useDocumentTitle } from "src/utils"; import { TrimText } from "src/utils/TrimText"; import DeleteVariablesButton from "./DeleteVariablesButton"; @@ -136,6 +137,9 @@ const getColumns = ({ export const Variables = () => { const { t: translate } = useTranslation("admin"); + + useDocumentTitle(translate("common:admin.Variables")); + const { setTableURLState, tableURLState } = useTableURLState({ pagination: { pageIndex: 0, pageSize: 30 }, sorting: [{ desc: false, id: "key" }], diff --git a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx index da6582398cfc7..851095c182d38 100644 --- a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx +++ b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx @@ -32,6 +32,7 @@ import { TruncatedText } from "src/components/TruncatedText"; import { RouterLink } from "src/components/ui"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch"; +import { useDocumentTitle } from "src/utils"; import { getTaskInstanceLink } from "src/utils/links"; import AddXComButton from "./AddXComButton"; @@ -150,6 +151,9 @@ const getColumns = ({ export const XCom = () => { const { dagId = "~", mapIndex = "-1", runId = "~", taskId = "~" } = useParams(); const { t: translate } = useTranslation(["browse", "common"]); + + // Only the standalone list page owns the tab title; the task-instance tab inherits that page's title. + useDocumentTitle(dagId === "~" ? translate("common:browse.xcoms") : undefined); const { setTableURLState, tableURLState } = useTableURLState(); const { pagination, sorting } = tableURLState; const [sort] = sorting; diff --git a/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx b/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx new file mode 100644 index 0000000000000..5312ea23e243e --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx @@ -0,0 +1,73 @@ +/*! + * 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 { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AppWrapper } from "src/utils/AppWrapper"; + +// The detail pages open a task-instance summaries stream that would otherwise stay active when the +// tiny title-only assertion resolves and the tree unmounts. Stub it so no stream is left open. +vi.mock("src/queries/useGridTISummaries.ts", () => ({ + useGridTiSummariesStream: () => ({ isLoading: false, summariesByRunId: new Map() }), +})); + +// instance_name in the mocked config is "Airflow" (src/mocks/handlers/config.ts). The test i18n +// backend is not loaded, so translations resolve to their keys — asserting on the key still proves +// each page wires the intended translation key into the browser tab title. +const INSTANCE = "Airflow"; + +describe("document title", () => { + beforeEach(() => { + document.title = ""; + }); + + it.each([ + ["/dags", "nav.dags"], + ["/assets", "nav.assets"], + ["/dag_runs", "dagRun_other"], + ["/task_instances", "taskInstance_other"], + ["/connections", "admin.Connections"], + ["/variables", "admin.Variables"], + ["/pools", "admin.Pools"], + ["/providers", "admin.Providers"], + ["/plugins", "admin.Plugins"], + ["/configs", "admin.Config"], + ["/deadlines", "browse.deadlines"], + ["/jobs", "browse.jobs"], + ["/xcoms", "browse.xcoms"], + ["/events", "browse.auditLog"], + ["/required_actions", "browse.requiredActions"], + ["/security/users", "nav.security"], + ])("shows the page name before the instance name on %s", async (route, pageTitle) => { + render(); + + await waitFor(() => expect(document.title).toBe(`${pageTitle} - ${INSTANCE}`)); + }); + + it.each([ + ["/dags/log_grouping/runs/manual__2025-02-18T12:19", "manual__2025-02-18T12:19"], + ["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source", "log_source"], + // Nested list/audit tabs are gated, so the detail page keeps ownership of the title. + ["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source/events", "log_source"], + ])("uses the entity identifier for detail page %s", async (route, pageTitle) => { + render(); + + await waitFor(() => expect(document.title).toBe(`${pageTitle} - ${INSTANCE}`)); + }); +});