Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ definitions in Airflow.
# It can also be put inside of an existing page, the supported views are ["dashboard", "dag_overview", "task_overview"]. You can position
# element in the existing page via the css `order` rule which will determine the flex order.
# Use "base" to mount the app in the base layout (e.g. a toolbar strip); the host uses a flex container so you can set ``order`` in your root JSX to control position.
# The server preloads the bundle of a "base" app from a plugin with no team, so it downloads alongside the UI's own scripts.
"destination": "task",
# Optional icon, url to an svg file.
"icon": "https://example.com/icon.svg",
Expand Down
24 changes: 23 additions & 1 deletion airflow-core/src/airflow/api_fastapi/core_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ def route(request: Request, namespace: str) -> Response:
)


def base_react_app_bundle_urls() -> list[str]:
"""
Bundle URLs of the React apps that render on every page, for the server to preload.

Only plugins without a team are included: the page is rendered before the user is known, and
the plugins endpoint hides a team-scoped plugin from users outside its team.
"""
from airflow import plugins_manager

return [
react_app["bundle_url"]
for plugin in plugins_manager.get_plugin_info(attrs_to_dump=["react_apps"])
if plugin["team_name"] is None
for react_app in plugin["react_apps"]
if react_app.get("destination") == "base" and isinstance(react_app.get("bundle_url"), str)
]


def init_views(app: FastAPI) -> None:
"""Init views by registering the different routers."""
from airflow.api_fastapi.core_api.routes.public import public_router
Expand All @@ -156,6 +174,7 @@ def init_views(app: FastAPI) -> None:
Path(directory).mkdir(exist_ok=True)

templates = Jinja2Templates(directory=directory)
preload_bundle_urls = base_react_app_bundle_urls()

# Ahead of the static mounts below so plugin-overridden locales take precedence.
init_ui_translation_views(app, dev_mode=dev_mode, dist_directory=directory)
Expand Down Expand Up @@ -207,7 +226,10 @@ def webapp(request: Request, rest_of_path: str):
return templates.TemplateResponse(
request,
"/index.html",
{"backend_server_base_url": request.base_url.path},
{
"backend_server_base_url": request.base_url.path,
"preload_bundle_urls": preload_bundle_urls,
},
media_type="text/html",
)

Expand Down
3 changes: 3 additions & 0 deletions airflow-core/src/airflow/ui/dev/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<head>
<meta charset="UTF-8" />
<base href="{{ backend_server_base_url }}" />
{% for bundle_url in preload_bundle_urls %}
<link rel="modulepreload" href="{{ bundle_url }}" />
{% endfor %}
<link rel="icon" type="image/png" href="http://localhost:5173/pin_32.png" />
<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module">
Expand Down
3 changes: 3 additions & 0 deletions airflow-core/src/airflow/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<head>
<meta charset="UTF-8" />
<base href="{{ backend_server_base_url }}" />
{% for bundle_url in preload_bundle_urls %}
<link rel="modulepreload" href="{{ bundle_url }}" />
{% endfor %}
<link rel="icon" type="image/png" href="/static/pin_32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Airflow</title>
Expand Down
68 changes: 66 additions & 2 deletions airflow-core/tests/unit/api_fastapi/core_api/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,20 @@
import typing

import pytest
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.params import Depends as DependsClass
from fastapi.responses import StreamingResponse
from fastapi.templating import Jinja2Templates
from starlette.routing import Mount
from starlette.testclient import TestClient

from airflow.api_fastapi.app import create_app
from airflow.api_fastapi.core_api.app import init_config, init_ui_translation_views
from airflow.api_fastapi.core_api.app import (
base_react_app_bundle_urls,
init_config,
init_ui_translation_views,
)
from airflow.api_fastapi.core_api.routes.public import authenticated_router
from airflow.api_fastapi.core_api.routes.ui import ui_router
from airflow.api_fastapi.core_api.security import get_user
Expand All @@ -39,6 +44,7 @@
from tests_common.test_utils.config import conf_vars
from tests_common.test_utils.db import clear_db_jobs
from tests_common.test_utils.mock_plugins import mock_plugin_manager
from tests_common.test_utils.paths import AIRFLOW_CORE_SOURCES_PATH

pytestmark = pytest.mark.db_test

Expand Down Expand Up @@ -319,3 +325,61 @@ class TranslationPlugin(AirflowPlugin):
response = self._client([TranslationPlugin()], tmp_path).get("/static/i18n/languages.json")

assert response.headers["cache-control"] == "no-cache"


class TestBaseReactAppPreload:
def test_lists_only_global_base_react_apps(self):
class BasePlugin(AirflowPlugin):
name = "base_plugin"
react_apps = [
{"name": "Bar", "bundle_url": "/bar/main.js", "destination": "base"},
{"name": "Tab", "bundle_url": "/bar/tab.js", "destination": "dag"},
]

class TeamPlugin(AirflowPlugin):
name = "team_plugin"
team_name = "team_a"
react_apps = [{"name": "TeamBar", "bundle_url": "/team/main.js", "destination": "base"}]

with mock_plugin_manager(plugins=[BasePlugin(), TeamPlugin()]):
assert base_react_app_bundle_urls() == ["/bar/main.js"]

@staticmethod
def _render(template: str, preload_bundle_urls: list[str]) -> str:
app = FastAPI()
templates = Jinja2Templates(directory=AIRFLOW_CORE_SOURCES_PATH / "airflow/ui")

@app.get("/")
def index(request: Request):
return templates.TemplateResponse(
request,
template,
{"backend_server_base_url": "/prefix/", "preload_bundle_urls": preload_bundle_urls},
)

return TestClient(app).get("/").text

def test_lists_nothing_without_base_react_apps(self):
class NavPlugin(AirflowPlugin):
name = "nav_plugin"
react_apps = [{"name": "Page", "bundle_url": "/page/main.js", "destination": "nav"}]

with mock_plugin_manager(plugins=[NavPlugin()]):
assert base_react_app_bundle_urls() == []

@pytest.mark.parametrize("template", ["index.html", "dev/index.html"])
def test_index_preloads_each_bundle_after_the_base_tag(self, template):
html = self._render(template, ["/bar/main.js?a=1&b=2", "https://cdn.example.com/x.js"])

assert '<link rel="modulepreload" href="/bar/main.js?a=1&amp;b=2" />' in html
assert '<link rel="modulepreload" href="https://cdn.example.com/x.js" />' in html
assert html.index("<base ") < html.index('rel="modulepreload"')

@pytest.mark.parametrize("template", ["index.html", "dev/index.html"])
def test_index_has_no_preload_without_base_bundles(self, template):
assert "modulepreload" not in self._render(template, [])

def test_index_escapes_a_bundle_url(self):
html = self._render("index.html", ['/x.js" onload="alert(1)'])

assert 'onload="alert(1)"' not in html
Loading