This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 eee2d56daf6 Add icon support for theme customization (#62172)
eee2d56daf6 is described below
commit eee2d56daf615258ecbc0fd062d96a35623aba86
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Mon Mar 9 14:40:28 2026 +0100
Add icon support for theme customization (#62172)
* Add icon support for theme customization
* Clean code
* Handle failures to load
* Adjust tests
* Custom logo for error pages
* Handle favicon
---
airflow-core/docs/howto/customize-ui.rst | 41 +++++++++++++++-
.../src/airflow/api_fastapi/common/types.py | 24 ++++++++++
.../api_fastapi/core_api/openapi/_private_ui.yaml | 10 ++++
.../src/airflow/config_templates/config.yml | 4 +-
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 22 +++++++++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +
.../src/airflow/ui/src/components/Logo.tsx | 54 ++++++++++++++++++++++
.../src/airflow/ui/src/layouts/BaseLayout.tsx | 42 +++++++++++++++++
.../src/airflow/ui/src/layouts/Nav/Nav.tsx | 4 +-
.../src/airflow/ui/src/pages/Dag/DagNotFound.tsx | 4 +-
airflow-core/src/airflow/ui/src/pages/Error.tsx | 4 +-
.../api_fastapi/core_api/routes/ui/test_config.py | 2 +
12 files changed, 205 insertions(+), 8 deletions(-)
diff --git a/airflow-core/docs/howto/customize-ui.rst
b/airflow-core/docs/howto/customize-ui.rst
index ef330a80517..24e4b035890 100644
--- a/airflow-core/docs/howto/customize-ui.rst
+++ b/airflow-core/docs/howto/customize-ui.rst
@@ -70,7 +70,7 @@ We can provide a JSON configuration to customize the UI.
.. important::
- - Currently only the ``brand`` color palette and ``globalCss`` can be
customized.
+ - You can customize the ``brand`` color palette, ``globalCss`` and the
navigation icon via ``icon`` (and ``icon_dark_mode``).
- You must supply ``50``-``950`` OKLCH color values for ``brand`` color.
- OKLCH colors must have next format ``oklch(l c h)`` For more info see
:ref:`config:api__theme`
- There is also the ability to provide custom global CSS for a fine grained
theme control.
@@ -180,6 +180,45 @@ Dark Mode
}
}'
+Icon (SVG-only)
+^^^^^^^^^^^^^^^
+
+You can replace the default Airflow icon in the navigation bar by providing an
``icon`` key (and optionally
+``icon_dark_mode`` for dark color mode) in the ``theme`` configuration. The
value must be either an absolute
+``http(s)`` URL or an app-relative path starting with ``/``, and must point to
an ``.svg`` file.
+
+.. code-block::
+
+ [api]
+
+ theme = {
+ "tokens": {
+ "colors": {
+ "brand": {
+ "50": { "value": "oklch(0.971 0.013 17.38)" },
+ "100": { "value": "oklch(0.936 0.032 17.717)" },
+ "200": { "value": "oklch(0.885 0.062 18.334)" },
+ "300": { "value": "oklch(0.808 0.114 19.571)" },
+ "400": { "value": "oklch(0.704 0.191 22.216)" },
+ "500": { "value": "oklch(0.637 0.237 25.331)" },
+ "600": { "value": "oklch(0.577 0.245 27.325)" },
+ "700": { "value": "oklch(0.505 0.213 27.518)" },
+ "800": { "value": "oklch(0.444 0.177 26.899)" },
+ "900": { "value": "oklch(0.396 0.141 25.723)" },
+ "950": { "value": "oklch(0.258 0.092 26.042)" }
+ }
+ }
+ },
+ "icon": "/static/company-icon.svg",
+ "icon_dark_mode": "/static/company-icon-dark.svg"
+ }
+
+.. note::
+
+ - Only SVG icons are supported.
+ - If the icon fails to load, Airflow falls back to its default icon.
+ - Icon sizing is controlled by the UI and cannot be configured via the theme.
+
|
Adding Dashboard Alert Messages
diff --git a/airflow-core/src/airflow/api_fastapi/common/types.py
b/airflow-core/src/airflow/api_fastapi/common/types.py
index f31809f4a4f..269669c8ee5 100644
--- a/airflow-core/src/airflow/api_fastapi/common/types.py
+++ b/airflow-core/src/airflow/api_fastapi/common/types.py
@@ -71,6 +71,28 @@ class TimeDelta(BaseModel):
TimeDeltaWithValidation = Annotated[TimeDelta,
BeforeValidator(_validate_timedelta_field)]
+# Common validator for theme icon fields (SVG-only, http(s) or app-relative
path).
+def _validate_theme_icon(value: str | None) -> str | None:
+ if value is None:
+ return value
+ from urllib.parse import urlparse
+
+ parsed = urlparse(value)
+ if parsed.scheme in ("http", "https"):
+ path = parsed.path or ""
+ elif parsed.scheme == "" and value.startswith("/"):
+ path = value
+ else:
+ raise ValueError("theme.icon must be http(s) URL or app-relative path
starting with '/'")
+ if not path.lower().endswith(".svg"):
+ raise ValueError("theme.icon must point to an SVG file (*.svg)")
+ return value
+
+
+# Alias type for theme icon fields with shared validation
+ThemeIconType = Annotated[str | None, BeforeValidator(_validate_theme_icon)]
+
+
class Mimetype(str, Enum):
"""Mimetype for the `Content-Type` header."""
@@ -180,3 +202,5 @@ class Theme(BaseModel):
],
]
globalCss: dict[str, dict] | None = None
+ icon: ThemeIconType = None
+ icon_dark_mode: ThemeIconType = None
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index f60a7781130..96550601a0d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -3242,6 +3242,16 @@ components:
type: object
- type: 'null'
title: Globalcss
+ icon:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Icon
+ icon_dark_mode:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Icon Dark Mode
type: object
required:
- tokens
diff --git a/airflow-core/src/airflow/config_templates/config.yml
b/airflow-core/src/airflow/config_templates/config.yml
index 7c66eb3938f..8558a71236e 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1480,7 +1480,9 @@ api:
theme:
description: |
JSON config to customize the Chakra UI theme.
- Currently only supports ``brand`` color customization and
``globalCss``.
+ Supports ``brand`` color customization, ``globalCss``, and optional
navigation icons ``icon``
+ (for light mode) and ``icon_dark_mode`` (for dark mode). Icons must be
SVG files and can be
+ either absolute http(s) URLs or app-relative paths starting with ``/``.
Must supply ``50``-``950`` OKLCH color values for ``brand`` color.
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 0b41a478c25..61dfb3c3406 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
@@ -8921,6 +8921,28 @@ export const $Theme = {
}
],
title: 'Globalcss'
+ },
+ icon: {
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Icon'
+ },
+ icon_dark_mode: {
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Icon Dark Mode'
}
},
type: 'object',
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index db34d1f2548..aaa5674dca1 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
@@ -2207,6 +2207,8 @@ export type Theme = {
[key: string]: unknown;
};
} | null;
+ icon?: string | null;
+ icon_dark_mode?: string | null;
};
/**
diff --git a/airflow-core/src/airflow/ui/src/components/Logo.tsx
b/airflow-core/src/airflow/ui/src/components/Logo.tsx
new file mode 100644
index 00000000000..3047abbc698
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Logo.tsx
@@ -0,0 +1,54 @@
+/*!
+ * 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 type { ComponentProps } from "react";
+import { useState } from "react";
+
+import { AirflowPin } from "src/assets/AirflowPin";
+import { useColorMode } from "src/context/colorMode";
+import { useConfig } from "src/queries/useConfig";
+
+type LogoProps = ComponentProps<typeof AirflowPin>;
+
+export const Logo = ({ height = "1.5em", width = "1.5em", ...rest }:
LogoProps) => {
+ const theme = useConfig("theme") as unknown as { icon?: string;
icon_dark_mode?: string } | undefined;
+ const { colorMode } = useColorMode();
+ const darkIcon = theme?.icon_dark_mode ?? undefined;
+ const lightIcon = theme?.icon ?? undefined;
+ const iconSrc = colorMode === "dark" && darkIcon !== undefined ? darkIcon :
lightIcon;
+ const hasIconSrc = Boolean(iconSrc);
+ const [failedLoadingCustomIcon, setFailedLoadingCustomIcon] = useState({
dark: false, light: false });
+
+ if (hasIconSrc && colorMode && !failedLoadingCustomIcon[colorMode]) {
+ return (
+ // eslint-disable-next-line
jsx-a11y/no-noninteractive-element-interactions
+ <img
+ alt="Logo"
+ onError={() => setFailedLoadingCustomIcon((prev) => ({ ...prev,
[colorMode]: true }))}
+ src={iconSrc}
+ // Chakra allows object as 'height' and 'width' but 'img' tag only
allows string or number, so we need to check the type before passing it to 'img'
+ style={{
+ height: typeof height === "string" || typeof height === "number" ?
height : "1.5em",
+ width: typeof width === "string" || typeof width === "number" ?
width : "1.5em",
+ }}
+ />
+ );
+ }
+
+ return <AirflowPin height={height} width={width} {...rest} />;
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
index cb645babc82..30023505e62 100644
--- a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
@@ -32,6 +32,7 @@ export const BaseLayout = ({ children }: PropsWithChildren)
=> {
const instanceName = useConfig("instance_name");
const { i18n } = useTranslation();
const { data: pluginData } = usePluginServiceGetPlugins();
+ const theme = useConfig("theme") as unknown as { icon?: string;
icon_dark_mode?: string } | undefined;
const baseReactPlugins =
pluginData?.plugins
@@ -59,6 +60,47 @@ export const BaseLayout = ({ children }: PropsWithChildren)
=> {
};
}, [i18n]);
+ useEffect(() => {
+ const link = document.querySelector<HTMLLinkElement>("link[rel='icon']");
+
+ if (!link) {
+ return undefined;
+ }
+
+ const defaultFavicon = link.href;
+ const darkIcon = theme?.icon_dark_mode;
+ const lightIcon = theme?.icon;
+ // favicon color theme should follow system color scheme, not the one set
in Airflow UI.
+ // (tab colors in browsers are based on system color scheme, not the one
set in the website)
+ const darkModeQuery = globalThis.matchMedia("(prefers-color-scheme:
dark)");
+
+ const updateFavicon = () => {
+ const customIcon =
+ darkModeQuery.matches && typeof darkIcon === "string" &&
darkIcon.length > 0 ? darkIcon : lightIcon;
+
+ if (typeof customIcon === "string" && customIcon.length > 0) {
+ link.href = customIcon;
+
+ const img = new Image();
+
+ img.addEventListener("error", () => {
+ link.href = defaultFavicon;
+ });
+ img.src = customIcon;
+ } else {
+ link.href = defaultFavicon;
+ }
+ };
+
+ updateFavicon();
+ darkModeQuery.addEventListener("change", updateFavicon);
+
+ return () => {
+ darkModeQuery.removeEventListener("change", updateFavicon);
+ link.href = defaultFavicon;
+ };
+ }, [theme?.icon, theme?.icon_dark_mode]);
+
return (
<LocaleProvider locale={i18n.language || "en"}>
<Box display="flex" flexDirection="column" h="100vh">
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 7053e6fc5cb..a408f8589c9 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
@@ -27,8 +27,8 @@ import {
usePluginServiceGetPlugins,
} from "openapi/queries";
import type { ExternalViewResponse } from "openapi/requests/types.gen";
-import { AirflowPin } from "src/assets/AirflowPin";
import { DagIcon } from "src/assets/DagIcon";
+import { Logo } from "src/components/Logo";
import { useTimezone } from "src/context/timezone";
import { getTimezoneOffsetString, getTimezoneTooltipLabel } from
"src/utils/datetimeUtils";
import type { NavItemResponse } from "src/utils/types";
@@ -157,7 +157,7 @@ export const Nav = () => {
<Flex alignItems="center" flexDir="column" gap={1} width="100%">
<Box alignItems="center" asChild boxSize={14} display="flex"
justifyContent="center">
<Link title={translate("nav.home")} to="/">
- <AirflowPin
+ <Logo
_motionSafe={{
_hover: {
transform: "rotate(360deg)",
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/DagNotFound.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/DagNotFound.tsx
index 0a667e8ef63..55ff3310ca1 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/DagNotFound.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/DagNotFound.tsx
@@ -20,7 +20,7 @@ import { Box, Button, Container, Heading, HStack, Text,
VStack } from "@chakra-u
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
-import { AirflowPin } from "src/assets/AirflowPin";
+import { Logo } from "src/components/Logo";
type DagNotFoundProps = {
readonly dagId: string;
@@ -34,7 +34,7 @@ export const DagNotFound = ({ dagId }: DagNotFoundProps) => {
<Box alignItems="center" display="flex" justifyContent="center" pt={36}
px={4}>
<Container maxW="lg">
<VStack gap={8} textAlign="center">
- <AirflowPin height="50px" width="50px" />
+ <Logo height="50px" width="50px" />
<VStack gap={4}>
<Heading>404</Heading>
diff --git a/airflow-core/src/airflow/ui/src/pages/Error.tsx
b/airflow-core/src/airflow/ui/src/pages/Error.tsx
index fb10bdb212a..a6a1fdbe1d6 100644
--- a/airflow-core/src/airflow/ui/src/pages/Error.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Error.tsx
@@ -20,7 +20,7 @@ import { Box, VStack, Heading, Text, Button, Container,
HStack, Code } from "@ch
import { useTranslation } from "react-i18next";
import { useNavigate, useRouteError, isRouteErrorResponse } from
"react-router-dom";
-import { AirflowPin } from "src/assets/AirflowPin";
+import { Logo } from "src/components/Logo";
export const ErrorPage = () => {
const navigate = useNavigate();
@@ -51,7 +51,7 @@ export const ErrorPage = () => {
<Box alignItems="center" display="flex" justifyContent="center" pt={36}
px={4}>
<Container maxW="lg">
<VStack gap={8} textAlign="center">
- <AirflowPin height="50px" width="50px" />
+ <Logo height="50px" width="50px" />
<VStack gap={4}>
<Heading>{statusCode || translate("error.title")}</Heading>
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
index 6ffe5932fbe..ca5f44b1fc5 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
@@ -51,6 +51,8 @@ THEME = {
"text-transform": "uppercase",
},
},
+ "icon": "https://somehost.com/static/custom-logo.svg",
+ "icon_dark_mode": "/static/custom-logo-dark.svg",
}
expected_config_response = {