This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new b9b471177c6 Extend user settings with additional configuration (#71699)
b9b471177c6 is described below
commit b9b471177c6a3663a0762a4eefdcf730de0d6adb
Author: Software Developer <[email protected]>
AuthorDate: Mon Aug 31 17:26:45 2026 +0200
Extend user settings with additional configuration (#71699)
* add a new configuration to the user settings:
- make it possible to configure default landing page.
- make it possible to configure default instance tab.
* UI: Limit settings translations to the new default landing page and task
tab keys
The default-settings change bundled full settings-block translations into
the
non-English locales, including keys unrelated to this feature. Those belong
to
a separate translation PR, so keep only the general (landing page) and task
instance (default tab) keys this change actually introduces.
* revert translations for other languages then eng.
* added Russian translation
* remove Russian translation
* addressing PR comments.
* consolidate TaskInstanceTab with tab.ts. add more related tests.
---
.../airflow/ui/public/i18n/locales/en/common.json | 19 +++++
.../HITLReview/HITLReviewDetailSummary.tsx | 2 +-
.../src/airflow/ui/src/constants/localStorage.ts | 2 +
airflow-core/src/airflow/ui/src/constants/tab.ts | 33 ++++++++
.../airflow/ui/src/hooks/useUserSettings.test.tsx | 58 +++++++++++++
.../src/airflow/ui/src/hooks/useUserSettings.ts | 14 ++++
.../src/airflow/ui/src/layouts/Nav/Nav.test.tsx | 77 ++++++++++++++++++
.../src/airflow/ui/src/layouts/Nav/Nav.tsx | 9 ++-
.../ui/src/pages/Dag/Overview/TaskLogPreview.tsx | 2 +-
.../pages/HITLTaskInstances/HITLTaskInstances.tsx | 4 +-
.../src/airflow/ui/src/pages/LandingPage.test.tsx | 66 +++++++++++++++
.../{constants/tab.ts => pages/LandingPage.tsx} | 27 +++----
.../ui/src/pages/Settings/Settings.test.tsx | 42 +++++++++-
.../src/airflow/ui/src/pages/Settings/Settings.tsx | 43 +++++++++-
.../ui/src/pages/TaskInstance/DefaultTab.test.tsx | 94 ++++++++++++++++++++++
.../tab.ts => pages/TaskInstance/DefaultTab.tsx} | 27 +++----
.../ui/src/pages/TaskInstance/TaskInstance.tsx | 9 ++-
.../src/airflow/ui/src/queries/useLogs.tsx | 2 +-
airflow-core/src/airflow/ui/src/router.test.tsx | 17 +++-
airflow-core/src/airflow/ui/src/router.tsx | 31 ++++---
.../src/airflow/ui/src/utils/links.test.ts | 15 ++++
airflow-core/src/airflow/ui/src/utils/links.ts | 21 ++++-
22 files changed, 557 insertions(+), 57 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index dcb41805cb1..2bed02c1a22 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -180,6 +180,7 @@
"assets": "Assets",
"browse": "Browse",
"dags": "Dags",
+ "dashboard": "Dashboard",
"docs": "Docs",
"home": "Home",
"legacyFabViews": "Legacy Views",
@@ -273,6 +274,17 @@
"title": "Clearing"
},
"description": "These preferences are saved in this browser only and apply
across the whole app.",
+ "general": {
+ "landingPage": {
+ "helper": "Page shown when you open Airflow.",
+ "label": "Landing page",
+ "options": {
+ "dags": "Dags",
+ "dashboard": "Dashboard"
+ }
+ },
+ "title": "General"
+ },
"graph": {
"defaultDirection": {
"helper": "Layout direction for Dag and asset graphs you haven't set
individually.",
@@ -287,6 +299,13 @@
},
"title": "Marking"
},
+ "taskInstance": {
+ "defaultTab": {
+ "helper": "Tab shown first when you open a task instance. Links to a
specific tab still open that tab.",
+ "label": "Default task instance tab"
+ },
+ "title": "Task Instance"
+ },
"title": "Settings"
},
"shortcuts": {
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
index f6d02117bc5..6d34d47bf57 100644
---
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
@@ -53,7 +53,7 @@ export const HITLReviewDetailSummary = ({
<HITLReviewRow
label={translate("common:taskId")}
value={
- <RouterLink onClick={onOpenTask}
to={`${getTaskInstanceLink(ti)}/required_actions`}>
+ <RouterLink onClick={onOpenTask} to={getTaskInstanceLink(ti,
"required_actions")}>
{ti.task_id}
</RouterLink>
}
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 29a60434f3d..5dde959e3fd 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -34,6 +34,8 @@ export const CLEAR_RUN_DEFAULT_OPTIONS_KEY =
"clear_run_default_options";
export const CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY =
"clear_task_instance_default_options";
export const CLEAR_PREVENT_RUNNING_TASK_KEY = "clear_prevent_running_task";
export const MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY =
"mark_task_instance_default_options";
+export const DEFAULT_TASK_INSTANCE_TAB_KEY = "default_task_instance_tab";
+export const DEFAULT_LANDING_PAGE_KEY = "default_landing_page";
// Dag-scoped keys
export const dagRunsLimitKey = (dagId: string) => `dag_runs_limit-${dagId}`;
diff --git a/airflow-core/src/airflow/ui/src/constants/tab.ts
b/airflow-core/src/airflow/ui/src/constants/tab.ts
index 051148442ac..43ff17cdb01 100644
--- a/airflow-core/src/airflow/ui/src/constants/tab.ts
+++ b/airflow-core/src/airflow/ui/src/constants/tab.ts
@@ -33,3 +33,36 @@ export enum TabName {
Runs = "runs",
Tasks = "tasks",
}
+
+/** Route path segments for the task-instance detail tabs. Single source of
truth powering the router. */
+export enum TaskInstanceTab {
+ AssetEvents = "asset_events",
+ Code = "code",
+ Details = "details",
+ Events = "events",
+ Logs = "logs",
+ RenderedTemplates = "rendered_templates",
+ RequiredActions = "required_actions",
+ TaskInstances = "task_instances",
+ TaskStateStore = "task-state-store",
+ XCom = "xcom",
+}
+
+/** Plain-string form of the tab paths, for APIs that accept a path segment. */
+export type TaskInstanceTabValue = `${TaskInstanceTab}`;
+
+/**
+ * Tabs offered as the user's default landing tab on the Settings page, each
mapped to the
+ * route path it redirects to. Logs is the index route, so it maps to ""
(renders in place).
+ */
+export const DEFAULT_TASK_INSTANCE_TAB_PATHS = {
+ asset_events: "asset_events",
+ code: "code",
+ details: "details",
+ events: "events",
+ logs: "",
+ rendered_templates: "rendered_templates",
+ xcom: "xcom",
+} as const satisfies Partial<Record<TaskInstanceTabValue, TaskInstanceTabValue
| "">>;
+
+export type DefaultTaskInstanceTab = keyof typeof
DEFAULT_TASK_INSTANCE_TAB_PATHS;
diff --git a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
index 4117595b0de..ab9143f1bf4 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
+++ b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
@@ -24,6 +24,8 @@ import {
CLEAR_RUN_DEFAULT_OPTIONS_KEY,
CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
DEFAULT_GRAPH_DIRECTION_KEY,
+ DEFAULT_LANDING_PAGE_KEY,
+ DEFAULT_TASK_INSTANCE_TAB_KEY,
MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
} from "src/constants/localStorage";
@@ -32,6 +34,8 @@ import {
useClearRunDefaultOptions,
useClearTaskInstanceDefaultOptions,
useDefaultGraphDirection,
+ useDefaultLandingPage,
+ useDefaultTaskInstanceTab,
useMarkTaskInstanceDefaultOptions,
} from "./useUserSettings";
@@ -127,6 +131,60 @@ describe("useClearPreventRunningTaskDefault", () => {
});
});
+describe("useDefaultTaskInstanceTab", () => {
+ it("defaults to logs when nothing is stored", () => {
+ const { result } = renderHook(() => useDefaultTaskInstanceTab());
+
+ expect(result.current[0]).toBe("logs");
+ });
+
+ it("reads an existing stored tab", () => {
+ localStorage.setItem(DEFAULT_TASK_INSTANCE_TAB_KEY,
JSON.stringify("details"));
+
+ const { result } = renderHook(() => useDefaultTaskInstanceTab());
+
+ expect(result.current[0]).toBe("details");
+ });
+
+ it("persists a new tab to localStorage", () => {
+ const { result } = renderHook(() => useDefaultTaskInstanceTab());
+
+ act(() => {
+ result.current[1]("xcom");
+ });
+
+ expect(result.current[0]).toBe("xcom");
+ expect(JSON.parse(localStorage.getItem(DEFAULT_TASK_INSTANCE_TAB_KEY) ??
'""')).toBe("xcom");
+ });
+});
+
+describe("useDefaultLandingPage", () => {
+ it("defaults to dashboard when nothing is stored", () => {
+ const { result } = renderHook(() => useDefaultLandingPage());
+
+ expect(result.current[0]).toBe("dashboard");
+ });
+
+ it("reads an existing stored landing page", () => {
+ localStorage.setItem(DEFAULT_LANDING_PAGE_KEY, JSON.stringify("dags"));
+
+ const { result } = renderHook(() => useDefaultLandingPage());
+
+ expect(result.current[0]).toBe("dags");
+ });
+
+ it("persists a new landing page to localStorage", () => {
+ const { result } = renderHook(() => useDefaultLandingPage());
+
+ act(() => {
+ result.current[1]("dags");
+ });
+
+ expect(result.current[0]).toBe("dags");
+ expect(JSON.parse(localStorage.getItem(DEFAULT_LANDING_PAGE_KEY) ??
'""')).toBe("dags");
+ });
+});
+
describe("useMarkTaskInstanceDefaultOptions", () => {
it("defaults to an empty selection", () => {
const { result } = renderHook(() => useMarkTaskInstanceDefaultOptions());
diff --git a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
index bec8c853b5b..182f9af07da 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
@@ -24,8 +24,14 @@ import {
CLEAR_RUN_DEFAULT_OPTIONS_KEY,
CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
DEFAULT_GRAPH_DIRECTION_KEY,
+ DEFAULT_LANDING_PAGE_KEY,
+ DEFAULT_TASK_INSTANCE_TAB_KEY,
MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
} from "src/constants/localStorage";
+import type { DefaultTaskInstanceTab } from "src/constants/tab";
+
+/** Page shown at the app root. */
+export type LandingPageOption = "dags" | "dashboard";
/**
* User-configurable defaults surfaced in the Settings page and consumed as
@@ -52,3 +58,11 @@ export const useClearPreventRunningTaskDefault = () =>
/** Default selection for the "Mark as" task-instance dialog toggle (past /
future / … ). */
export const useMarkTaskInstanceDefaultOptions = () =>
useLocalStorage<Array<string>>(MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY, []);
+
+/** Tab shown first when a task instance is opened without an explicit tab in
the URL. */
+export const useDefaultTaskInstanceTab = () =>
+ useLocalStorage<DefaultTaskInstanceTab>(DEFAULT_TASK_INSTANCE_TAB_KEY,
"logs");
+
+/** Page the app root ("/") lands on: the dashboard or the Dags list. */
+export const useDefaultLandingPage = () =>
+ useLocalStorage<LandingPageOption>(DEFAULT_LANDING_PAGE_KEY, "dashboard");
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.test.tsx
new file mode 100644
index 00000000000..96e1464333b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.test.tsx
@@ -0,0 +1,77 @@
+/*!
+ * 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 type { PropsWithChildren } from "react";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it, vi } from "vitest";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { Nav } from "./Nav";
+
+vi.mock("openapi/queries", () => ({
+ useAuthLinksServiceGetAuthMenus: () => ({ data: undefined }),
+ usePluginServiceGetPlugins: () => ({ data: undefined }),
+ useVersionServiceGetVersion: () => ({ data: undefined }),
+}));
+vi.mock("src/context/timezone", () => ({ useTimezone: () => ({
selectedTimezone: "UTC" }) }));
+vi.mock("src/utils/datetimeUtils", () => ({
+ getTimezoneOffsetString: () => "+00:00",
+ getTimezoneTooltipLabel: () => "UTC",
+}));
+vi.mock("src/queries/useConfig", () => ({ useConfig: () => undefined }));
+vi.mock("src/components/Logo", () => ({ Logo: () => <div /> }));
+vi.mock("./AdminButton", () => ({ AdminButton: () => <div /> }));
+vi.mock("./BrowseButton", () => ({ BrowseButton: () => <div /> }));
+vi.mock("./DocsButton", () => ({ DocsButton: () => <div /> }));
+vi.mock("./SecurityButton", () => ({ SecurityButton: () => <div /> }));
+vi.mock("./UserSettingsButton", () => ({ UserSettingsButton: () => <div /> }));
+vi.mock("./PluginMenus", () => ({ PluginMenus: () => <div /> }));
+vi.mock("./TimezoneModal", () => ({ default: () => <div /> }));
+
+const wrapperAt = (path: string) => {
+ const wrapper = ({ children }: PropsWithChildren) => (
+ <BaseWrapper>
+ <MemoryRouter initialEntries={[path]}>{children}</MemoryRouter>
+ </BaseWrapper>
+ );
+
+ return wrapper;
+};
+
+describe("Nav dashboard button", () => {
+ it("links to the dashboard at /home", () => {
+ render(<Nav />, { wrapper: wrapperAt("/") });
+
+ expect(screen.getByTestId("nav-dashboard-link")).toHaveAttribute("href",
"/home");
+ });
+
+ it("is active on the dashboard route", () => {
+ render(<Nav />, { wrapper: wrapperAt("/home") });
+
+
expect(screen.getByTestId("nav-dashboard-link")).toHaveAttribute("aria-current",
"page");
+ });
+
+ it("is not active on the landing page", () => {
+ render(<Nav />, { wrapper: wrapperAt("/") });
+
+
expect(screen.getByTestId("nav-dashboard-link")).not.toHaveAttribute("aria-current");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
index 82bd724af00..a56a0c1335f 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
@@ -18,7 +18,7 @@
*/
import { Box, Flex, Text, VStack, useDisclosure } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
-import { FiDatabase, FiHome, FiClock } from "react-icons/fi";
+import { FiDatabase, FiGrid, FiClock } from "react-icons/fi";
import { Link } from "react-router-dom";
import {
@@ -171,7 +171,12 @@ export const Nav = () => {
/>
</Link>
</Box>
- <NavButton data-testid="nav-home-link" icon={FiHome}
title={translate("nav.home")} to="/" />
+ <NavButton
+ data-testid="nav-dashboard-link"
+ icon={FiGrid}
+ title={translate("nav.dashboard")}
+ to="/home"
+ />
<NavButton
data-testid="nav-dags-link"
disabled={!authLinks?.authorized_menu_items.includes("Dags")}
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
index 476d9a41dda..7becae19d1a 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
@@ -73,7 +73,7 @@ export const TaskLogPreview = ({
: translate("overview.failedLogs.showLogs")}
</Button>
<ClearTaskInstanceButton taskInstance={taskInstance} />
- <RouterLink fontSize="sm" to={getTaskInstanceLink(taskInstance)}>
+ <RouterLink fontSize="sm" to={getTaskInstanceLink(taskInstance,
"logs")}>
{translate("overview.failedLogs.viewFullLogs")}
</RouterLink>
</Flex>
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 20f0874b958..a5e750d27ae 100644
---
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
@@ -122,7 +122,7 @@ const taskInstanceColumns = ({
<RouterLink
fontWeight="bold"
onClick={(event) => event.stopPropagation()}
- to={`${getTaskInstanceLink(original.task_instance)}/required_actions`}
+ to={getTaskInstanceLink(original.task_instance, "required_actions")}
>
<TruncatedText text={original.subject} />
</RouterLink>
@@ -180,7 +180,7 @@ const taskInstanceColumns = ({
<RouterLink
fontWeight="bold"
onClick={(event) => event.stopPropagation()}
-
to={`${getTaskInstanceLink(original.task_instance)}/required_actions`}
+ to={getTaskInstanceLink(original.task_instance,
"required_actions")}
>
<TruncatedText text={original.task_instance.task_display_name} />
</RouterLink>
diff --git a/airflow-core/src/airflow/ui/src/pages/LandingPage.test.tsx
b/airflow-core/src/airflow/ui/src/pages/LandingPage.test.tsx
new file mode 100644
index 00000000000..babfcd183ad
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/LandingPage.test.tsx
@@ -0,0 +1,66 @@
+/*!
+ * 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 { MemoryRouter, Route, Routes } from "react-router-dom";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { useDefaultLandingPage } from "src/hooks/useUserSettings";
+
+import { LandingPage } from "./LandingPage";
+
+vi.mock("src/hooks/useUserSettings", () => ({ useDefaultLandingPage: vi.fn()
}));
+vi.mock("src/pages/Dashboard", () => ({ Dashboard: () =>
<div>dashboard-page</div> }));
+
+const setLandingPage = (value: unknown) =>
+ vi
+ .mocked(useDefaultLandingPage)
+ .mockReturnValue([value as never, vi.fn(), vi.fn()] as unknown as
ReturnType<
+ typeof useDefaultLandingPage
+ >);
+
+const renderAt = () =>
+ render(
+ <MemoryRouter initialEntries={["/"]}>
+ <Routes>
+ <Route element={<LandingPage />} index />
+ <Route element={<div>dags-page</div>} path="dags" />
+ </Routes>
+ </MemoryRouter>,
+ );
+
+describe("LandingPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the dashboard by default", () => {
+ setLandingPage("dashboard");
+ renderAt();
+
+ expect(screen.getByText("dashboard-page")).toBeInTheDocument();
+ });
+
+ it("redirects to the Dags list when configured", () => {
+ setLandingPage("dags");
+ renderAt();
+
+ expect(screen.getByText("dags-page")).toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/constants/tab.ts
b/airflow-core/src/airflow/ui/src/pages/LandingPage.tsx
similarity index 68%
copy from airflow-core/src/airflow/ui/src/constants/tab.ts
copy to airflow-core/src/airflow/ui/src/pages/LandingPage.tsx
index 051148442ac..5de94952b61 100644
--- a/airflow-core/src/airflow/ui/src/constants/tab.ts
+++ b/airflow-core/src/airflow/ui/src/pages/LandingPage.tsx
@@ -16,20 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Navigate } from "react-router-dom";
-export enum TabEntity {
- Dag = "dag",
- Task = "task",
- TaskInstance = "task-instance",
-}
+import { useDefaultLandingPage } from "src/hooks/useUserSettings";
+import { Dashboard } from "src/pages/Dashboard";
-export enum TabName {
- Backfills = "backfills",
- Calendar = "calendar",
- Code = "code",
- Details = "details",
- Events = "events",
- Overview = "",
- Runs = "runs",
- Tasks = "tasks",
-}
+export const LandingPage = () => {
+ const [defaultLandingPage] = useDefaultLandingPage();
+
+ if (defaultLandingPage === "dags") {
+ return <Navigate replace to="/dags" />;
+ }
+
+ return <Dashboard />;
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.test.tsx
index 51ef9cd8b8d..f1030f5e585 100644
--- a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.test.tsx
@@ -22,7 +22,12 @@ import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
-import { CLEAR_PREVENT_RUNNING_TASK_KEY, DEFAULT_GRAPH_DIRECTION_KEY } from
"src/constants/localStorage";
+import {
+ CLEAR_PREVENT_RUNNING_TASK_KEY,
+ DEFAULT_GRAPH_DIRECTION_KEY,
+ DEFAULT_LANDING_PAGE_KEY,
+ DEFAULT_TASK_INSTANCE_TAB_KEY,
+} from "src/constants/localStorage";
import { BaseWrapper } from "src/utils/Wrapper";
import { Settings } from "./Settings";
@@ -45,6 +50,14 @@ beforeAll(async () => {
title: "Clearing",
},
description: "browser only",
+ general: {
+ landingPage: {
+ helper: "helper",
+ label: "Landing page",
+ options: { dags: "Dags", dashboard: "DASHBOARD-OPT" },
+ },
+ title: "General",
+ },
graph: {
defaultDirection: { helper: "helper", label: "Default graph
direction" },
title: "Graph",
@@ -53,6 +66,10 @@ beforeAll(async () => {
taskSelection: { helper: "helper", label: "Default mark
selection" },
title: "Marking",
},
+ taskInstance: {
+ defaultTab: { helper: "helper", label: "Default task instance
tab" },
+ title: "Task Instance",
+ },
title: "Settings",
},
},
@@ -64,6 +81,17 @@ beforeAll(async () => {
directionUp: "UP-LABEL",
},
},
+ dag: {
+ tabs: {
+ assetEvents: "Asset Events",
+ auditLog: "Audit Log",
+ code: "Code",
+ details: "DETAILS-TAB",
+ logs: "Logs",
+ renderedTemplates: "Rendered Templates",
+ xcom: "XCom",
+ },
+ },
dags: {
runAndTaskActions: {
options: {
@@ -93,7 +121,12 @@ describe("Settings page", () => {
expect(screen.getByText("Settings")).toBeInTheDocument();
// Selects and the switch expose test ids.
- for (const testId of ["default-graph-direction",
"clear-prevent-running-task"]) {
+ for (const testId of [
+ "default-landing-page",
+ "default-graph-direction",
+ "default-task-instance-tab",
+ "clear-prevent-running-task",
+ ]) {
expect(screen.getByTestId(testId)).toBeInTheDocument();
}
@@ -110,9 +143,14 @@ describe("Settings page", () => {
localStorage.setItem(DEFAULT_GRAPH_DIRECTION_KEY, JSON.stringify("DOWN"));
localStorage.setItem(CLEAR_PREVENT_RUNNING_TASK_KEY,
JSON.stringify(false));
+ localStorage.setItem(DEFAULT_TASK_INSTANCE_TAB_KEY,
JSON.stringify("details"));
+ localStorage.setItem(DEFAULT_LANDING_PAGE_KEY,
JSON.stringify("dashboard"));
+
render(<Settings />, { wrapper: BaseWrapper });
+
expect(screen.getByTestId("default-landing-page")).toHaveTextContent("DASHBOARD-OPT");
expect(screen.getByTestId("default-graph-direction")).toHaveTextContent("DOWN-LABEL");
+
expect(screen.getByTestId("default-task-instance-tab")).toHaveTextContent("DETAILS-TAB");
expect(screen.getByTestId("clear-prevent-running-task")).toHaveAttribute("data-state",
"unchecked");
});
});
diff --git a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
index 0400dde881b..310e699aabf 100644
--- a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
@@ -24,12 +24,16 @@ import type { Direction } from
"src/components/Graph/DirectionDropdown";
import { Select } from "src/components/ui";
import SegmentedControl from "src/components/ui/SegmentedControl";
import { Switch } from "src/components/ui/Switch";
+import type { DefaultTaskInstanceTab } from "src/constants/tab";
import {
useClearPreventRunningTaskDefault,
useClearRunDefaultOptions,
useClearTaskInstanceDefaultOptions,
useDefaultGraphDirection,
+ useDefaultLandingPage,
+ useDefaultTaskInstanceTab,
useMarkTaskInstanceDefaultOptions,
+ type LandingPageOption,
} from "src/hooks/useUserSettings";
import { useDocumentTitle } from "src/utils";
import type { Option } from "src/utils/option";
@@ -160,7 +164,7 @@ const Section = ({ children, title }: { readonly children:
ReactNode; readonly t
);
export const Settings = () => {
- const { t: translate } = useTranslation(["common", "components", "dags"]);
+ const { t: translate } = useTranslation(["common", "components", "dags",
"dag"]);
useDocumentTitle(translate("settings.title"));
@@ -169,6 +173,23 @@ export const Settings = () => {
const [clearTaskOptions, setClearTaskOptions] =
useClearTaskInstanceDefaultOptions();
const [preventRunningTask, setPreventRunningTask] =
useClearPreventRunningTaskDefault();
const [markTaskOptions, setMarkTaskOptions] =
useMarkTaskInstanceDefaultOptions();
+ const [defaultTaskInstanceTab, setDefaultTaskInstanceTab] =
useDefaultTaskInstanceTab();
+ const [defaultLandingPage, setDefaultLandingPage] = useDefaultLandingPage();
+
+ const taskInstanceTabOptions: Array<SelectOption<DefaultTaskInstanceTab>> = [
+ { label: translate("dag:tabs.logs"), value: "logs" },
+ { label: translate("dag:tabs.details"), value: "details" },
+ { label: translate("dag:tabs.renderedTemplates"), value:
"rendered_templates" },
+ { label: translate("dag:tabs.code"), value: "code" },
+ { label: translate("dag:tabs.auditLog"), value: "events" },
+ { label: translate("dag:tabs.assetEvents"), value: "asset_events" },
+ { label: translate("dag:tabs.xcom"), value: "xcom" },
+ ];
+
+ const landingPageOptions: Array<SelectOption<LandingPageOption>> = [
+ { label: translate("settings.general.landingPage.options.dashboard"),
value: "dashboard" },
+ { label: translate("settings.general.landingPage.options.dags"), value:
"dags" },
+ ];
const directionOptions: Array<SelectOption<Direction>> = [
{ label: translate("components:graph.directionRight"), value: "RIGHT" },
@@ -199,6 +220,16 @@ export const Settings = () => {
{translate("settings.description")}
</Text>
<Stack gap={8}>
+ <Section title={translate("settings.general.title")}>
+ <SelectSetting
+ helper={translate("settings.general.landingPage.helper")}
+ label={translate("settings.general.landingPage.label")}
+ onChange={setDefaultLandingPage}
+ options={landingPageOptions}
+ testId="default-landing-page"
+ value={defaultLandingPage}
+ />
+ </Section>
<Section title={translate("settings.graph.title")}>
<SelectSetting
helper={translate("settings.graph.defaultDirection.helper")}
@@ -250,6 +281,16 @@ export const Settings = () => {
options={directionalToggleOptions}
/>
</Section>
+ <Section title={translate("settings.taskInstance.title")}>
+ <SelectSetting
+ helper={translate("settings.taskInstance.defaultTab.helper")}
+ label={translate("settings.taskInstance.defaultTab.label")}
+ onChange={setDefaultTaskInstanceTab}
+ options={taskInstanceTabOptions}
+ testId="default-task-instance-tab"
+ value={defaultTaskInstanceTab}
+ />
+ </Section>
</Stack>
</Box>
);
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/DefaultTab.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/DefaultTab.test.tsx
new file mode 100644
index 00000000000..d0a5660a300
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/DefaultTab.test.tsx
@@ -0,0 +1,94 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { useDefaultTaskInstanceTab } from "src/hooks/useUserSettings";
+
+import { DefaultTab } from "./DefaultTab";
+
+vi.mock("src/hooks/useUserSettings", () => ({ useDefaultTaskInstanceTab:
vi.fn() }));
+vi.mock("./Logs", () => ({ Logs: () => <div>logs-page</div> }));
+
+const setDefaultTab = (value: unknown) =>
+ vi
+ .mocked(useDefaultTaskInstanceTab)
+ .mockReturnValue([value as never, vi.fn(), vi.fn()] as unknown as
ReturnType<
+ typeof useDefaultTaskInstanceTab
+ >);
+
+const LocationEcho = ({ label }: { readonly label: string }) => {
+ const { search } = useLocation();
+
+ return (
+ <div>
+ {label}
+ {search}
+ </div>
+ );
+};
+
+const renderAt = (url: string) =>
+ render(
+ <MemoryRouter initialEntries={[url]}>
+ <Routes>
+ <Route path="dags/:dagId/runs/:runId/tasks/:taskId">
+ <Route element={<DefaultTab />} index />
+ <Route element={<div>logs-page</div>} path="logs" />
+ <Route element={<LocationEcho label="details-page" />}
path="details" />
+ </Route>
+ </Routes>
+ </MemoryRouter>,
+ );
+
+describe("DefaultTab", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders logs on the index route by default", () => {
+ setDefaultTab("logs");
+ renderAt("/dags/d1/runs/r1/tasks/t1");
+
+ expect(screen.getByText("logs-page")).toBeInTheDocument();
+ });
+
+ it("redirects to the configured tab", () => {
+ setDefaultTab("details");
+ renderAt("/dags/d1/runs/r1/tasks/t1");
+
+ expect(screen.getByText("details-page")).toBeInTheDocument();
+ });
+
+ it("preserves the query string when redirecting", () => {
+ setDefaultTab("details");
+ renderAt("/dags/d1/runs/r1/tasks/t1?foo=bar");
+
+ expect(screen.getByText("details-page?foo=bar")).toBeInTheDocument();
+ });
+
+ it("falls back to logs for an unknown tab value", () => {
+ setDefaultTab("not-a-tab");
+ renderAt("/dags/d1/runs/r1/tasks/t1");
+
+ expect(screen.getByText("logs-page")).toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/constants/tab.ts
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/DefaultTab.tsx
similarity index 60%
copy from airflow-core/src/airflow/ui/src/constants/tab.ts
copy to airflow-core/src/airflow/ui/src/pages/TaskInstance/DefaultTab.tsx
index 051148442ac..ea5eb980431 100644
--- a/airflow-core/src/airflow/ui/src/constants/tab.ts
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/DefaultTab.tsx
@@ -16,20 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Navigate, useLocation } from "react-router-dom";
-export enum TabEntity {
- Dag = "dag",
- Task = "task",
- TaskInstance = "task-instance",
-}
+import { useDefaultTaskInstanceTab } from "src/hooks/useUserSettings";
+import { getDefaultTaskInstanceTabPath } from "src/utils/links";
-export enum TabName {
- Backfills = "backfills",
- Calendar = "calendar",
- Code = "code",
- Details = "details",
- Events = "events",
- Overview = "",
- Runs = "runs",
- Tasks = "tasks",
-}
+import { Logs } from "./Logs";
+
+export const DefaultTab = () => {
+ const [defaultTab] = useDefaultTaskInstanceTab();
+ const tabPath = getDefaultTaskInstanceTabPath(defaultTab);
+ const { hash, search } = useLocation();
+
+ return tabPath === "" ? <Logs /> : <Navigate replace to={{ hash, pathname:
tabPath, search }} />;
+};
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 75371ce6270..bc4fb8721f4 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
@@ -28,9 +28,11 @@ import { useTaskInstanceServiceGetMappedTaskInstance } from
"openapi/queries";
import { useHITLReviewTabs } from "src/hooks/useHITLReviewTabs";
import { usePluginTabs } from "src/hooks/usePluginTabs";
import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs";
+import { useDefaultTaskInstanceTab } from "src/hooks/useUserSettings";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
import { isStatePending, useAutoRefresh, useDocumentTitle } from "src/utils";
+import { getDefaultTaskInstanceTabPath } from "src/utils/links";
import { Header } from "./Header";
@@ -43,8 +45,13 @@ export const TaskInstance = () => {
// Get external views with task_instance destination
const externalTabs = usePluginTabs("task_instance");
+ // When another tab is the default, the index route redirects to it, so the
Logs
+ // tab must point at the explicit /logs path to stay reachable.
+ const [defaultTab] = useDefaultTaskInstanceTab();
+ const logsTabValue = getDefaultTaskInstanceTabPath(defaultTab) === "" ? "" :
"logs";
+
const tabs = [
- { icon: <MdReorder />, label: translate("tabs.logs"), value: "" },
+ { icon: <MdReorder />, label: translate("tabs.logs"), matchPaths:
["logs"], value: logsTabValue },
{ icon: <FiUser />, label: translate("tabs.requiredActions"), value:
"required_actions" },
{
icon: <PiBracketsCurlyBold />,
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
index d9bf323cf84..ab6f0df1e6b 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -120,7 +120,7 @@ const parseLogs = ({
let parsedLines;
const sources: Array<string> = [];
- const logLink = taskInstance ?
`${getTaskInstanceLink(taskInstance)}?try_number=${tryNumber}` : "";
+ const logLink = taskInstance ? `${getTaskInstanceLink(taskInstance,
"logs")}?try_number=${tryNumber}` : "";
try {
let lineNumber = 0;
diff --git a/airflow-core/src/airflow/ui/src/router.test.tsx
b/airflow-core/src/airflow/ui/src/router.test.tsx
index e34b5fa31a5..2598dff5d14 100644
--- a/airflow-core/src/airflow/ui/src/router.test.tsx
+++ b/airflow-core/src/airflow/ui/src/router.test.tsx
@@ -19,10 +19,10 @@
import { matchRoutes } from "react-router-dom";
import { describe, expect, it } from "vitest";
-import { TabEntity } from "src/constants/tab";
+import { TabEntity, TaskInstanceTab } from "src/constants/tab";
import { getTabPath } from "src/utils/tab";
-import { routerConfig } from "./router";
+import { routerConfig, taskInstanceRoutes } from "./router";
const getAdditionalPath = (pathname: string) => {
const matches = matchRoutes(routerConfig, pathname) ?? [];
@@ -60,3 +60,16 @@ describe("Dag route handles", () => {
expect(getAdditionalPath(pathname)).toBe("");
});
});
+
+type RouteLike = { readonly children?: ReadonlyArray<RouteLike>; readonly
path?: string };
+
+describe("taskInstanceRoutes stay in sync with the TaskInstanceTab enum", ()
=> {
+ it("uses exactly the enum values as its named tab paths", () => {
+ const namedPaths = (taskInstanceRoutes as ReadonlyArray<RouteLike>)
+ .flatMap((route) => route.children ?? [route])
+ .map((route) => route.path)
+ .filter((path): path is string => path !== undefined &&
!path.startsWith("plugin/"));
+
+
expect(namedPaths.sort()).toStrictEqual(Object.values(TaskInstanceTab).sort());
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index d342fbf5878..f94e2ecb08d 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -21,7 +21,7 @@ import { createBrowserRouter } from "react-router-dom";
import { UseConfigServiceGetConfigsKeyFn } from "openapi/queries";
import { ConfigService } from "openapi/requests/services.gen";
-import { TabEntity, TabName } from "src/constants/tab";
+import { TabEntity, TabName, TaskInstanceTab } from "src/constants/tab";
import { BaseLayout } from "src/layouts/BaseLayout";
import { DagsLayout } from "src/layouts/DagsLayout";
import { Asset } from "src/pages/Asset";
@@ -47,6 +47,7 @@ import { ExternalView } from "src/pages/ExternalView";
import { GroupTaskInstance } from "src/pages/GroupTaskInstance";
import { HITLTaskInstances } from "src/pages/HITLTaskInstances";
import { Jobs } from "src/pages/Jobs";
+import { LandingPage } from "src/pages/LandingPage";
import { MappedTaskInstance } from "src/pages/MappedTaskInstance";
import { Details as MappedTaskInstanceDetails } from
"src/pages/MappedTaskInstance/Details";
import { Plugins } from "src/pages/Plugins";
@@ -61,6 +62,7 @@ import { Task } from "src/pages/Task";
import { Overview as TaskOverview } from "src/pages/Task/Overview";
import { TaskInstance, Logs } from "src/pages/TaskInstance";
import { AssetEvents as TaskInstanceAssetEvents } from
"src/pages/TaskInstance/AssetEvents";
+import { DefaultTab as TaskInstanceDefaultTab } from
"src/pages/TaskInstance/DefaultTab";
import { Details as TaskInstanceDetails } from
"src/pages/TaskInstance/Details";
import { HITLResponse } from "src/pages/TaskInstance/HITLResponse";
import { RenderedTemplates } from "src/pages/TaskInstance/RenderedTemplates";
@@ -78,21 +80,22 @@ const pluginRoute = {
};
export const taskInstanceRoutes = [
- { element: <Logs />, index: true, path: undefined },
- { element: <Events />, path: "events" },
+ { element: <TaskInstanceDefaultTab />, index: true, path: undefined },
+ { element: <Logs />, path: TaskInstanceTab.Logs },
+ { element: <Events />, path: TaskInstanceTab.Events },
{
children: [
- { element: <TaskStateStore />, path: "task-state-store" },
- { element: <XCom />, path: "xcom" },
+ { element: <TaskStateStore />, path: TaskInstanceTab.TaskStateStore },
+ { element: <XCom />, path: TaskInstanceTab.XCom },
],
element: <StorageLayout />,
},
- { element: <Code />, path: "code" },
- { element: <TaskInstanceDetails />, path: "details" },
- { element: <RenderedTemplates />, path: "rendered_templates" },
- { element: <TaskInstances />, path: "task_instances" },
- { element: <TaskInstanceAssetEvents />, path: "asset_events" },
- { element: <HITLResponse />, path: "required_actions" },
+ { element: <Code />, path: TaskInstanceTab.Code },
+ { element: <TaskInstanceDetails />, path: TaskInstanceTab.Details },
+ { element: <RenderedTemplates />, path: TaskInstanceTab.RenderedTemplates },
+ { element: <TaskInstances />, path: TaskInstanceTab.TaskInstances },
+ { element: <TaskInstanceAssetEvents />, path: TaskInstanceTab.AssetEvents },
+ { element: <HITLResponse />, path: TaskInstanceTab.RequiredActions },
pluginRoute,
];
@@ -100,9 +103,13 @@ export const routerConfig = [
{
children: [
{
- element: <Dashboard />,
+ element: <LandingPage />,
index: true,
},
+ {
+ element: <Dashboard />,
+ path: "home",
+ },
{
element: <HITLTaskInstances enableHITLReviewDrawer />,
path: "required_actions",
diff --git a/airflow-core/src/airflow/ui/src/utils/links.test.ts
b/airflow-core/src/airflow/ui/src/utils/links.test.ts
index 5afe9f0d4cc..e22e0dc9581 100644
--- a/airflow-core/src/airflow/ui/src/utils/links.test.ts
+++ b/airflow-core/src/airflow/ui/src/utils/links.test.ts
@@ -22,6 +22,7 @@ import type { TaskInstanceResponse } from
"openapi/requests/types.gen";
import {
buildTaskInstanceUrl,
+ getDefaultTaskInstanceTabPath,
getNextHref,
getSafeExternalUrl,
getTaskInstanceAdditionalPath,
@@ -118,6 +119,7 @@ describe("getTaskInstanceAdditionalPath", () => {
it("should handle all known task instance routes", () => {
const knownRoutes = [
+ "logs",
"events",
"xcom",
"code",
@@ -156,6 +158,19 @@ describe("getTaskInstanceAdditionalPath", () => {
});
});
+describe("getDefaultTaskInstanceTabPath", () => {
+ it.each(["details", "rendered_templates", "code", "events", "asset_events",
"xcom"])(
+ "returns the tab path for %s",
+ (tab) => {
+ expect(getDefaultTaskInstanceTabPath(tab)).toBe(tab);
+ },
+ );
+
+ it.each(["logs", "", "unknown_tab", undefined, 42])("falls back to the index
route for %s", (value) => {
+ expect(getDefaultTaskInstanceTabPath(value)).toBe("");
+ });
+});
+
describe("buildTaskInstanceUrl", () => {
it("should build basic URL types", () => {
// Basic task instance URL
diff --git a/airflow-core/src/airflow/ui/src/utils/links.ts
b/airflow-core/src/airflow/ui/src/utils/links.ts
index 132a9e3f471..dffd32c3580 100644
--- a/airflow-core/src/airflow/ui/src/utils/links.ts
+++ b/airflow-core/src/airflow/ui/src/utils/links.ts
@@ -17,6 +17,11 @@
* under the License.
*/
import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+import {
+ DEFAULT_TASK_INSTANCE_TAB_PATHS,
+ type DefaultTaskInstanceTab,
+ type TaskInstanceTabValue,
+} from "src/constants/tab";
import { taskInstanceRoutes } from "src/router";
export const getTaskInstanceLink = (
@@ -28,16 +33,21 @@ export const getTaskInstanceLink = (
mapIndex?: number;
taskId: string;
},
+ tab?: TaskInstanceTabValue,
): string => {
+ const tabPath = tab === undefined ? "" : `/${tab}`;
+
if ("dag_id" in tiOrParams) {
return
`/dags/${tiOrParams.dag_id}/runs/${tiOrParams.dag_run_id}/tasks/${tiOrParams.task_id}${
tiOrParams.map_index >= 0 ? `/mapped/${tiOrParams.map_index}` : ""
- }`;
+ }${tabPath}`;
}
const { dagId, dagRunId, mapIndex = -1, taskId } = tiOrParams;
- return `/dags/${dagId}/runs/${dagRunId}/tasks/${taskId}${mapIndex >= 0 ?
`/mapped/${mapIndex}` : ""}`;
+ return `/dags/${dagId}/runs/${dagRunId}/tasks/${taskId}${
+ mapIndex >= 0 ? `/mapped/${mapIndex}` : ""
+ }${tabPath}`;
};
export const getRedirectPath = (targetPath: string): string => {
@@ -93,6 +103,13 @@ export const getTaskInstanceAdditionalPath = (pathname:
string): string => {
return "";
};
+// Resolve a stored default-tab preference to a route path. Logs maps to ""
(the index
+// route), and any unknown value falls back to "" so the index keeps rendering
in place.
+export const getDefaultTaskInstanceTabPath = (value: unknown): string =>
+ typeof value === "string" && value in DEFAULT_TASK_INSTANCE_TAB_PATHS
+ ? DEFAULT_TASK_INSTANCE_TAB_PATHS[value as DefaultTaskInstanceTab]
+ : "";
+
const SAFE_EXTERNAL_URL_SCHEMES = new Set(["http:", "https:", "mailto:"]);
/**