UI plugins через Flask blueprints — AppBuilder views
В Airflow 2.x webserver построен на Flask + Flask-AppBuilder (FAB). Это значит UI расширяется через стандартные Flask blueprints и FAB views. Можно добавить menu items, custom HTML pages, REST endpoints, JSON APIs — все в Python. Этот урок покажет паттерны: простой Flask blueprint для REST endpoint, FAB AppBuilder view с custom HTML, регистрация в menu, доступ к Airflow metadata DB из view. И финально — почему всё это deprecated в 3.x в пользу React-based UI через react_apps.
В 2.10/2.11 LTS Flask/FAB — основной механизм. Migration на React начнётся при upgrade на 3.x.
Архитектура webserver в 2.x
airflow webserver
├── Flask app (airflow.www.app.create_app)
│ ├── Flask-AppBuilder (RBAC + UI framework)
│ │ ├── Built-in views: DAG list, graph, gantt, logs
│ │ ├── REST API (airflow/api/v1)
│ │ └── Auth manager (FAB / OIDC / custom)
│ └── Plugin blueprints (registered)
│ ├── Custom REST endpoints
│ └── Custom UI views (AppBuilder)
Plugin может зарегистрировать:
flask_blueprints— для REST endpoints, static filesappbuilder_views— для UI pages с menu integrationappbuilder_menu_items— просто menu link на внешний URLglobal_operator_extra_links— extra links на task detail page
REST API фундаментал — паттерны, которые Flask blueprint реализует
Pattern 1: Simple Flask blueprint — REST endpoint
Допустим, хочется endpoint /myorg/v1/health-check для internal monitoring.
# my_org_provider/web/blueprints.py
from flask import Blueprint, jsonify, request
from airflow.www.app import csrf
bp = Blueprint(
"my_org",
__name__,
url_prefix="/myorg",
)
@bp.route("/v1/health-check", methods=["GET"])
def health_check():
"""Simple health check returning Airflow metadata DB connectivity."""
from airflow.utils.session import create_session
from sqlalchemy import text
try:
with create_session() as session:
session.execute(text("SELECT 1"))
return jsonify({"status": "ok", "db": "connected"})
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 500
@bp.route("/v1/dag-stats/<dag_id>", methods=["GET"])
def dag_stats(dag_id: str):
"""Return last 24h stats for DAG."""
from airflow.utils.session import create_session
from airflow.models import DagRun
from datetime import datetime, timedelta
cutoff = datetime.utcnow() - timedelta(hours=24)
with create_session() as session:
runs = (
session.query(DagRun)
.filter(DagRun.dag_id == dag_id, DagRun.start_date >= cutoff)
.all()
)
success = sum(1 for r in runs if r.state == "success")
failed = sum(1 for r in runs if r.state == "failed")
running = sum(1 for r in runs if r.state == "running")
return jsonify({
"dag_id": dag_id,
"window_hours": 24,
"success": success,
"failed": failed,
"running": running,
})
@bp.route("/v1/trigger-dag/<dag_id>", methods=["POST"])
@csrf.exempt # for external triggers
def trigger_dag(dag_id: str):
"""Trigger a DAG run, simplified."""
from airflow.api.common.trigger_dag import trigger_dag as _trigger
conf = request.get_json() or {}
dag_run = _trigger(dag_id=dag_id, conf=conf)
return jsonify({"run_id": dag_run.run_id, "state": dag_run.state}), 201
Регистрация в AirflowPlugin:
# $AIRFLOW_HOME/plugins/my_org_web_plugin.py
from airflow.plugins_manager import AirflowPlugin
from my_org_provider.web.blueprints import bp
class MyOrgWebPlugin(AirflowPlugin):
name = "my_org_web"
flask_blueprints = [bp]
После restart webserver — endpoints доступны:
GET http://airflow:8080/myorg/v1/health-checkGET http://airflow:8080/myorg/v1/dag-stats/my_dag
Pattern 2: AppBuilder View — HTML page с menu integration
Custom page с собственным URL и menu item. Использует FAB framework для auth + RBAC integration — кто видит, кто может посещать.
# my_org_provider/web/views.py
from flask_appbuilder import expose, BaseView as AppBuilderBaseView
from flask import render_template
from airflow.security import permissions
from airflow.www.auth import has_access
class MyOrgDashboardView(AppBuilderBaseView):
"""Custom dashboard view."""
default_view = "index"
route_base = "/myorg/dashboard"
@expose("/")
@has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_DAG)])
def index(self):
"""Main dashboard page."""
from airflow.utils.session import create_session
from airflow.models import DagModel
with create_session() as session:
total_dags = session.query(DagModel).count()
active_dags = session.query(DagModel).filter(DagModel.is_active == True).count()
paused_dags = session.query(DagModel).filter(DagModel.is_paused == True).count()
return self.render_template(
"my_org_dashboard.html"
total_dags=total_dags,
active_dags=active_dags,
paused_dags=paused_dags,
)
@expose("/stats")
@has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_DAG)])
def stats(self):
"""Stats page."""
return self.render_template("my_org_stats.html")
# View instance — нужен для appbuilder_views registration
my_org_dashboard_view = MyOrgDashboardView()
Template my_org_provider/web/templates/my_org_dashboard.html:
{% extends "airflow/main.html" %}
{% block title %}My Org Dashboard{% endblock %}
{% block content %}
<div class="container">
<h1>My Org Dashboard</h1>
<div class="row">
<div class="col-md-4">
<div class="card">
<h3>Total DAGs</h3>
<p style="font-size: 2em;">{{ total_dags }}</p>
</div>
</div>
<div class="col-md-4">
<div class="card">
<h3>Active</h3>
<p style="font-size: 2em; color: green;">{{ active_dags }}</p>
</div>
</div>
<div class="col-md-4">
<div class="card">
<h3>Paused</h3>
<p style="font-size: 2em; color: orange;">{{ paused_dags }}</p>
</div>
</div>
</div>
</div>
{% endblock %}
Регистрация:
# $AIRFLOW_HOME/plugins/my_org_web_plugin.py
from airflow.plugins_manager import AirflowPlugin
from my_org_provider.web.views import my_org_dashboard_view
class MyOrgWebPlugin(AirflowPlugin):
name = "my_org_web"
# Регистрация view + menu
appbuilder_views = [
{
"name": "My Org Dashboard", # menu item label
"category": "My Org", # menu category (новый top-level menu)
"view": my_org_dashboard_view,
},
{
"name": "Stats",
"category": "My Org",
"view": my_org_dashboard_view,
"href": "/myorg/dashboard/stats",
}
]
После webserver restart:
- Top-level menu My Org появляется в navbar
- Под ним My Org Dashboard и Stats items
- Доступно только пользователям с permissions
CAN_READнаDAG
Pattern 3: Templates directory
Чтобы Flask находил .html template, нужно его положить в правильную папку. Default — <plugin>/templates/<your_template>.html.
my_org_provider/
├── __init__.py
├── web/
│ ├── __init__.py
│ ├── views.py
│ ├── blueprints.py
│ └── templates/
│ └── my_org_dashboard.html
Если templates не в default path — указать в Blueprint:
bp = Blueprint(
"my_org",
__name__,
template_folder="/path/to/templates"
static_folder="/path/to/static",
)
Для AppBuilder View Airflow autodetect-ит templates рядом с view module.
Pattern 4: Menu item без view (just link)
Если хочется просто link в menu на external URL:
class MyOrgPlugin(AirflowPlugin):
name = "my_org"
appbuilder_menu_items = [
{
"name": "Grafana",
"category": "Monitoring",
"href": "https://grafana.your-corp.com/d/airflow",
},
{
"name": "Internal Wiki",
"category": "Help",
"href": "https://wiki.your-corp.com/airflow",
},
]
Pattern 5: Operator extra links
Добавить link на task instance detail page (на DAG graph view → click task):
from airflow.models.baseoperator import BaseOperatorLink
class GrafanaTaskLink(BaseOperatorLink):
name = "View in Grafana"
def get_link(self, operator, *, ti_key):
# ti_key has dag_id, task_id, run_id, try_number
return (
f"https://grafana.your-corp.com/d/airflow-task"
f"?var-dag_id={ti_key.dag_id}"
f"&var-task_id={ti_key.task_id}"
f"&var-run_id={ti_key.run_id}"
)
class MyOrgPlugin(AirflowPlugin):
name = "my_org"
global_operator_extra_links = [GrafanaTaskLink()]
После restart — на каждом task instance в UI появляется кнопка “View in Grafana” с заполненными params.
Authentication и authorization
FAB views поддерживают standard Airflow permissions:
from airflow.security import permissions
from airflow.www.auth import has_access
class AdminView(AppBuilderBaseView):
@expose("/admin")
@has_access([
(permissions.ACTION_CAN_READ, permissions.RESOURCE_CONFIG),
(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_CONFIG),
])
def admin(self):
# User must have CAN_READ AND CAN_EDIT on CONFIG resource
...
Resources в airflow.security.permissions:
RESOURCE_DAG,RESOURCE_TASK_INSTANCE,RESOURCE_CONNECTION,RESOURCE_POOL,RESOURCE_VARIABLE, …
Actions:
ACTION_CAN_READ,ACTION_CAN_CREATE,ACTION_CAN_EDIT,ACTION_CAN_DELETE
Если user без needed permissions — Flask returns 403.
Limitations of plugin UI в 2.x
В 2.10/2.11:
- Flask + FAB — единственный путь UI customization
- React injection невозможна
- DAG list / graph view не customizable через plugin
Migration look-ahead: React plugins в 3.1+
В Airflow 3.0 переход на React UI. В 3.1+ planned react_apps plugin category:
# Hypothetical в 3.1+ — may differ
class MyOrgUIPlugin(AirflowPlugin):
name = "my_org_ui"
react_apps = [
{
"name": "My Dashboard",
"url_route": "/myorg/dashboard",
"bundle_url": "/static/plugins/my_org/dashboard.js",
"entry_component": "MyDashboard",
}
]
Plugin поставляет JS bundle, Airflow UI монтирует React component. Гораздо больше интерактивности, но requires React + TypeScript skills.
В 2.x не пытайтесь сделать SPA-style customization — это против архитектуры. Если нужны interactive widgets — внешний tool (Grafana, Metabase, internal app) + iframe / link из Airflow UI.
Production gotchas
1. Blueprint route conflicts. Если url_prefix совпадает с built-in Airflow routes (/api/v1, /dag) — Flask использует first registered. Префиксируйте свои routes уникально (/myorg/v1/...).
2. Templates не находятся. Если template_folder не задан — Flask ищет в <plugin>/templates. Если ставите не там — TemplateNotFound. Debug: app.jinja_env.list_templates() показывает loaded templates.
3. DB queries в view — performance overhead. Каждый request → SQLAlchemy query. Кешируйте result (Flask-Cache), или используйте API client side с pagination.
4. CSRF token для POST requests. Flask-WTF включён по default. POST endpoints либо @csrf.exempt, либо token в form. Это часто конфьюзит при добавлении REST API.
5. Webserver restart обязателен. Plugin loaded once on startup. Дев цикл: код-change → kill webserver → start → test. Hot reload в 2.x нет.
6. RBAC permissions нужны. Default — user без permissions не увидит ваш menu item. Добавьте permissions в roles через airflow roles add-perms или через UI Roles management.