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 = {

Reply via email to