diff --git a/lib/iris/dashboard/favicon.ico b/lib/iris/dashboard/favicon.ico new file mode 100644 index 0000000000..2d886aaf4d Binary files /dev/null and b/lib/iris/dashboard/favicon.ico differ diff --git a/lib/iris/dashboard/rsbuild.config.ts b/lib/iris/dashboard/rsbuild.config.ts index 0c045a51d8..8a61a90bcf 100644 --- a/lib/iris/dashboard/rsbuild.config.ts +++ b/lib/iris/dashboard/rsbuild.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ templateParameters: { title: 'Iris Dashboard', }, + favicon: './favicon.ico', }, server: { proxy: { diff --git a/lib/iris/src/iris/cluster/controller/dashboard.py b/lib/iris/src/iris/cluster/controller/dashboard.py index 2d16faf0a2..a217dd66b6 100644 --- a/lib/iris/src/iris/cluster/controller/dashboard.py +++ b/lib/iris/src/iris/cluster/controller/dashboard.py @@ -25,7 +25,6 @@ import logging import os -from collections.abc import Callable from http.cookies import SimpleCookie from urllib.parse import urlparse @@ -39,7 +38,15 @@ from iris.cluster.controller.actor_proxy import PROXY_ROUTE, ActorProxy from iris.cluster.controller.service import ControllerServiceImpl -from iris.cluster.dashboard_common import html_shell, on_shutdown, static_files_mount +from iris.cluster.dashboard_common import ( + _AUTH_POLICY_ATTR, + favicon_route, + html_shell, + on_shutdown, + public, + requires_auth, + static_files_mount, +) from iris.log_server.client import LogServiceProxy from iris.log_server.server import LogServiceImpl from iris.rpc.auth import SESSION_COOKIE, NullAuthInterceptor, TokenVerifier, extract_bearer_token, resolve_auth @@ -52,24 +59,6 @@ logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Route auth policy annotations -# --------------------------------------------------------------------------- - -_AUTH_POLICY_ATTR = "_auth_policy" - - -def public(fn: Callable) -> Callable: - """Mark a route handler as publicly accessible (no auth required).""" - setattr(fn, _AUTH_POLICY_ATTR, "public") - return fn - - -def requires_auth(fn: Callable) -> Callable: - """Mark a route handler as requiring authentication via session cookie or Bearer token.""" - setattr(fn, _AUTH_POLICY_ATTR, "requires_auth") - return fn - def _extract_token_from_scope(scope: Scope) -> str | None: """Extract auth token from ASGI scope (cookie or Authorization header).""" @@ -318,6 +307,7 @@ async def _proxy_actor_rpc(request: Request) -> Response: routes = [ Route("/", self._dashboard), + favicon_route(), Route("/auth/session_bootstrap", self._session_bootstrap), Route("/auth/config", self._auth_config), Route("/auth/session", self._auth_session, methods=["POST"]), @@ -484,6 +474,7 @@ def app(self) -> Starlette: def _create_app(self) -> Starlette: routes = [ Route("/", self._dashboard), + favicon_route(), Route("/job/{job_id:path}", self._job_detail_page), Route("/worker/{worker_id:path}", self._worker_detail_page), Route("/bundles/{bundle_id:str}.zip", self._proxy_bundle), diff --git a/lib/iris/src/iris/cluster/dashboard_common.py b/lib/iris/src/iris/cluster/dashboard_common.py index df84e07d4e..487b6bee3f 100644 --- a/lib/iris/src/iris/cluster/dashboard_common.py +++ b/lib/iris/src/iris/cluster/dashboard_common.py @@ -15,13 +15,32 @@ from typing import Any from starlette.applications import Starlette -from starlette.responses import PlainTextResponse -from starlette.routing import Mount +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response +from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles from starlette.types import ASGIApp, Receive, Scope, Send logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Route auth policy annotations +# --------------------------------------------------------------------------- + +_AUTH_POLICY_ATTR = "_auth_policy" + + +def public(fn: Callable) -> Callable: + """Mark a route handler as publicly accessible (no auth required).""" + setattr(fn, _AUTH_POLICY_ATTR, "public") + return fn + + +def requires_auth(fn: Callable) -> Callable: + """Mark a route handler as requiring authentication via session cookie or Bearer token.""" + setattr(fn, _AUTH_POLICY_ATTR, "requires_auth") + return fn + def on_shutdown( *callbacks: Callable[[], Awaitable[None]], @@ -115,6 +134,26 @@ def static_files_mount() -> Mount: return Mount("/static", app=_LazyStaticFiles(), name="static") +@public +def _favicon(_request: Request) -> Response: + dist = _vue_dist_dir() + if dist is None: + return Response(status_code=404) + favicon_path = dist / "favicon.ico" + if not favicon_path.exists(): + return Response(status_code=404) + return Response( + content=favicon_path.read_bytes(), + media_type="image/x-icon", + headers={"cache-control": f"public, max-age={STATIC_MAX_AGE_SECONDS}"}, + ) + + +def favicon_route() -> Route: + """Route for serving favicon.ico from the Vue dashboard dist root.""" + return Route("/favicon.ico", _favicon) + + _NOT_BUILT_HTML = """\ diff --git a/lib/iris/src/iris/cluster/worker/dashboard.py b/lib/iris/src/iris/cluster/worker/dashboard.py index 07658911a4..9cd47f4312 100644 --- a/lib/iris/src/iris/cluster/worker/dashboard.py +++ b/lib/iris/src/iris/cluster/worker/dashboard.py @@ -10,7 +10,7 @@ from starlette.routing import Mount, Route from iris.cluster.worker.service import WorkerServiceImpl -from iris.cluster.dashboard_common import html_shell, static_files_mount +from iris.cluster.dashboard_common import favicon_route, html_shell, static_files_mount from iris.rpc.worker_connect import WorkerServiceWSGIApplication @@ -43,6 +43,7 @@ def _create_app(self) -> Starlette: routes = [ Route("/health", self._health), Route("/", self._dashboard), + favicon_route(), Route("/task/{task_id:path}", self._task_detail_page), Route("/status", self._status_page), static_files_mount(),