Airflow の管理画面を自分好みにカスタマイズしてみた

2020.03.10

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、みかみです。

前回 初めて Airflow をさわってみました。

機能充実の Airflow ですが、まだどんな時にどの画面を使うのか、把握しきれておりません。。

実際の運用でも、全部の機能はいらないんじゃないかな? 初心者にももっと使いやすい画面にカスタマイズできないかな? と思い・・・。

やりたいこと

  • Airflow の管理画面をカスタマイズしたい
  • Airflow 管理画面表示のフレームワーク Flask の構成(アーキテクチャ)を知りたい

Flask の概要

Airflow の管理画面は、Flask で実装されているとのこと。

Python のシンプルな Web アプリケーションフレームワークとして知名度も高い Flask ですが、どんな構成になっていて、どんなふうに実装すればいいか、よく知らないので調べてみました。

Webアプリケーション構成として、MVC( Model + View + Controller ) はよく聞きますが、Flask では、MVT( Model + View + Template )と言われるようです。

Template では Jinja2 を使って HTML をレンダリングしていて、Model では SQLAlchemy を使って簡単にDBアクセスできる拡張機能もあるようです。

ざっくり図にすると、こんな感じでしょうか。

Flask のソースコードのディレクトリ構成は?

Case 1: a module:

/application.py
/templates
  /hello.html

Case 2: a package:

/application
  /__init__.py
  /templates
    /hello.html

/home/user/Projects/flask-tutorial
├── flaskr/
│ ├── __init__.py
│ ├── db.py
│ ├── schema.sql
│ ├── auth.py
│ ├── blog.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ │ ├── login.html
│ │ │ └── register.html
│ │ └── blog/
│ │ ├── create.html
│ │ ├── index.html
│ │ └── update.html
│ └── static/
│ └── style.css
├── tests/
│ ├── conftest.py
│ ├── data.sql
│ ├── test_factory.py
│ ├── test_db.py
│ ├── test_auth.py
│ └── test_blog.py
├── venv/
├── setup.py
└── MANIFEST.in

manage.py
requirements.txt
flaskr/
 |- __init__.py
 |- config.py
 |- views.py
 |- models.py
 |- static/
   |- style.css
 |- templates/
   |- layout.html
   |- show_entries.html

package 化など、プロジェクトの規模等で自由に構成できますが、template 関連のディレクトリは切る必要がありそうです。

Airflow 管理画面のソースコード

Airflow 1.10.9 で、管理画面画面表示まわりのソースコードを確認してみます。

(test_airflow) [ec2-user@ip-10-0-43-239 ~]$ airflow version
  ____________       _____________
 ____    |__( )_________  __/__  /________      __
____  /| |_  /__  ___/_  /_ __  /_  __ \_ | /| / /
___  ___ |  / _  /   _  __/ _  / / /_/ /_ |/ |/ /
 _/_/  |_/_/  /_/    /_/    /_/  \____/____/|__/  v1.10.9

Flask 実装のお作法として、まずは Flask クラスインスタンスを作成するようなので、

Airflow のコードを grep してみると、/airflow/www/app.py にありました。

app.py

(省略)
def create_app(config=None, session=None, testing=False, app_name="Airflow"):
    global app, appbuilder
    app = Flask(__name__)
(省略)

ちなみにこの create_app() メソッドの呼び元、/airflow/bin/cli.pywebserver() メソッド が、airflow webserver コマンドで管理画面を起動したときのエントリーポイントのようです。

app.py のコードをざっくり眺めてみると、管理画面上部のタブ表示あたりのコードらしき記述も。

実際の画面表示内容は /airflow/www/views.py の方で定義してるのかな?

app.py

(省略)
    with app.app_context():
        from airflow.www import views

        admin = Admin(
            app, name='Airflow',
            static_url_path='/admin',
            index_view=views.HomeView(endpoint='', url='/admin', name="DAGs"),
            template_mode='bootstrap3',
        )
        av = admin.add_view
        vs = views
        av(vs.Airflow(name='DAGs', category='DAGs'))

        if not conf.getboolean('core', 'secure_mode'):
            av(vs.QueryView(name='Ad Hoc Query', category="Data Profiling"))
            av(vs.ChartModelView(
                models.Chart, Session, name="Charts", category="Data Profiling"))
        av(vs.KnownEventView(
            models.KnownEvent,
            Session, name="Known Events", category="Data Profiling"))
        av(vs.SlaMissModelView(
            models.SlaMiss,
            Session, name="SLA Misses", category="Browse"))
        av(vs.TaskInstanceModelView(models.TaskInstance,
            Session, name="Task Instances", category="Browse"))
        av(vs.LogModelView(
            models.Log, Session, name="Logs", category="Browse"))
        av(vs.JobModelView(
            jobs.BaseJob, Session, name="Jobs", category="Browse"))
        av(vs.PoolModelView(
            models.Pool, Session, name="Pools", category="Admin"))
        av(vs.ConfigurationView(
            name='Configuration', category="Admin"))
        av(vs.UserModelView(
            models.User, Session, name="Users", category="Admin"))
        av(vs.ConnectionModelView(
            Connection, Session, name="Connections", category="Admin"))
        av(vs.VariableView(
            models.Variable, Session, name="Variables", category="Admin"))
        av(vs.XComView(
            models.XCom, Session, name="XComs", category="Admin"))
(省略)

views.py のコードもざっくり確認してみます。

views.py

(省略)
class Airflow(AirflowViewMixin, BaseView):
    def is_visible(self):
        return False

    @expose('/')
    @login_required
    def index(self):
        return self.render('airflow/dags.html')
(省略)

ここで template を指定している?

/airflow/www/templates/airflow/ ディレクトリ配下のファイルを確認してみると、dags.html がありました。

このあたりの HTML ファイル変更すれば、管理画面の表示項目などのカスタマイズができそうです。

DAGs 画面の表示項目を変更してみる

/airflow/www/templates/airflow/dags.html を編集して、表示内容が変わるか確認してみます。

画面が横長で見にくいので「Recent Tasks」の列表示を消して、「Links」列表示項目も小さくてオペミスしそうなのでアイコンを減らしてみます。

dags.html

(省略)
    <table id="dags" class="table table-striped table-bordered">
        <thead>
            <tr>
                <th></th>
                <th width="12"><span id="pause_header" class="glyphicon glyphicon-info-sign" title="Use this toggle to pause a DAG. The scheduler won't schedule new tasks instances for a paused DAG. Tasks already running at pause time won't be affected."></span></th>
                <th>DAG</th>
                <th>Schedule</th>
                <th>Owner</th>
<!-- del mikami
                <th style="padding-left: 5px;">Recent Tasks
                  <span id="statuses_info" class="glyphicon glyphicon-info-sign" aria-hidden="true" title="Status of tasks from all active DAG runs or, if not currently active, from most recent run."></span>
                  <img id="loading" width="15" src="{{ url_for("static", filename="loading.gif") }}">
                </th>
-->
                <th style="padding-left: 5px;">Last Run <span id="statuses_info" class="glyphicon glyphicon-info-sign" aria-hidden="true" title="Execution Date/Time of Highest Dag Run."></span>
                </th>
                <th style="padding-left: 5px;">DAG Runs
                  <span id="statuses_info" class="glyphicon glyphicon-info-sign" aria-hidden="true" title="Status of all previous DAG runs."></span>
                  <img id="loading" width="15" src="{{ url_for("static", filename="loading.gif") }}">
                </th>
                <th class="text-center">Links</th>
            </tr>
        </thead>
(省略)
        <tbody>
(省略)
                <!-- Column 6: Recent Tasks -->
<!-- del mikami
                <td style="padding:0px; width:200px; height:10px;">
                    <svg height="10" width="10" id='task-run-{{ dag.safe_dag_id }}' style="display: block;"></svg>
                </td>
-->
(省略)
                <!-- Column 9: Links -->
                <td class="text-center" style="display:flex; flex-direction:row; justify-content:space-around;">
                {% if dag %}

                <!-- Trigger Dag -->
                <a href="{{ url_for('airflow.trigger', dag_id=dag.dag_id) }}&origin={{ request.base_url }}"
                   onclick="return confirmTriggerDag(this, '{{ dag.safe_dag_id }}')">
                    <span class="glyphicon glyphicon-play-circle" aria-hidden="true" data-original-title="Trigger Dag"></span>
                </a>

                <!-- Tree -->
<!-- del mikami
                <a href="{{ url_for('airflow.tree', dag_id=dag.dag_id, num_runs=num_runs) }}">
                    <span class="glyphicon glyphicon-tree-deciduous" aria-hidden="true" data-original-title="Tree View"></span>
                </a>
-->

                <!-- Graph -->
                <a href="{{ url_for('airflow.graph', dag_id=dag.dag_id) }}">
                    <span class="glyphicon glyphicon-certificate" aria-hidden="true" data-original-title="Graph View"></span>
                </a>

                <!-- Duration -->
<!-- del mikami
                <a href="{{ url_for('airflow.duration', dag_id=dag.dag_id) }}">
                    <span class="glyphicon glyphicon-stats" aria-hidden="true" data-original-title="Tasks Duration"></span>
                </a>
-->

                <!-- Retries -->
<!-- del mikami
                <a href="{{ url_for('airflow.tries', dag_id=dag.dag_id) }}">
                    <span class="glyphicon glyphicon-duplicate" aria-hidden="true" data-original-title="Task Tries"></span>
                </a>
-->

                <!-- Landing Times -->
<!-- del mikami
                <a href="{{ url_for("airflow.landing_times", dag_id=dag.dag_id) }}">
                    <span class="glyphicon glyphicon-plane" aria-hidden="true" data-original-title="Landing Times"></span>
                </a>
-->

                <!-- Gantt -->
<!-- del mikami
                <a href="{{ url_for("airflow.gantt", dag_id=dag.dag_id) }}">
                    <span class="glyphicon glyphicon-align-left" aria-hidden="true" data-original-title="Gantt View"></span>
                </a>
-->

                <!-- Code -->
                <a href="{{ url_for("airflow.code", dag_id=dag.dag_id) }}">
                    <span class="glyphicon glyphicon-file" aria-hidden="true" data-original-title="Code View"></span>
                </a>

                <!-- Logs -->
                <a href="{{ url_for('log.index_view') }}?sort=1&amp;desc=1&amp;flt1_dag_id_equals={{ dag.dag_id }}">
                    <span class="glyphicon glyphicon-align-justify" aria-hidden="true" data-original-title="Logs"></span>
                </a>
                {% endif %}

                <!-- Refresh -->
                <a href="{{ url_for("airflow.refresh", dag_id=dag.dag_id) }}" onclick="postAsForm(this.href); return false">
                  <span class="glyphicon glyphicon-refresh" aria-hidden="true" data-original-title="Refresh"></span>
                </a>

                <!-- Delete -->
                <!-- Use dag_id instead of dag.dag_id, because the DAG might not exist in the webserver's DagBag -->
                <a href="{{ url_for('airflow.delete', dag_id=dag.dag_id) }}"
                  onclick="return confirmDeleteDag(this, '{{ dag.dag_id }}')">
                   <span class="glyphicon glyphicon-remove-circle" style="color:red" aria-hidden="true" data-original-title="Delete Dag"></span>
                </a>
                </td>
            </tr>
        {% endfor %}
        </tbody>
(省略)

airflow webserver を再起動して画面表示してみると

意図通り、列やアイコンが表示されなくなったことが確認できました。

タブメニュー項目を追加してみる

続いて、画面上部のタブ項目を追加してみます。

既存の「Admin」タブに新規画面を表示する項目と、新しく何かタブ項目を追加してみます。

タブ項目表示は、先ほど見た /airflow/www/app.py にあったので、他の項目を真似して追加してみます。

app.py

(省略)
        av(vs.PoolModelView(
            models.Pool, Session, name="Pools", category="Admin"))
        av(vs.ConfigurationView(
            name='Configuration', category="Admin"))
        av(vs.UserModelView(
            models.User, Session, name="Users", category="Admin"))
        av(vs.ConnectionModelView(
            Connection, Session, name="Connections", category="Admin"))
        av(vs.VariableView(
            models.Variable, Session, name="Variables", category="Admin"))
        av(vs.XComView(
            models.XCom, Session, name="XComs", category="Admin"))
# add mikami
        av(vs.TestPrintView(name="TestPrint", category="Admin"))

        admin.add_link(base.MenuLink(
            name="Classmethod",
            url='https://classmethod.jp/',
            category="CM"))
        admin.add_link(base.MenuLink(
            name="Developpers.IO",
            url='https://dev.classmethod.jp/',
            category="CM"))
(省略)

「Admin」タブに「TestPrint」項目の追加と、新しく「CM」タブを追加しました。「CM」タブ項目では、弊社ホームページと Developpers.IO へのリンクを表示します。

「TestPrint」クリック時の新規追加画面の View も、/airflow/www/views.py に追加します。

views.py

(省略)
class TestPrintView(wwwutils.SuperUserMixin, AirflowViewMixin, BaseView):
    @expose('/')
    def test_print(self):
        # TODO: ほんとはここで何か画面に表示する情報取ってきたり。
        test_text = 'It is a test of airflow customization.'
        test_text_2 = 'I add new page.'

        # Render information
        title = "For Test"
        return self.render('airflow/test.html',
                           title=title,
                           test_text=test_text,
                           test_text_2=test_text_2)

さらに、「TestPrint」画面のテンプレートとして、/airflow/www/templates/airflow/test.html も追加しました。

test.html

{% extends "airflow/master.html" %}

{% block body %}
    {{ super() }}
    <h2>{{ title }}</h2>
  {% set test_label = 'Test Mikami' %}
    {% if test_text %}
        <h4>{{ test_label }} : {{ test_text }}</h4>
    {% else %}
        <h4>{{ test_label }} : For Test Add Page!</h4>
    {% endif %}
        <h4>ページ追加のてすと :{% if test_text_2 %} {{ test_text_2 }} {% else %} Have a good day. {% endif %}</h4>
    <hr>

{% endblock %}

ちゃんと表示できるかな・・・?

できてます。

新規追加画面の表示と、新規追加タブのリンクからの遷移も、無事動作確認できました。

今回 model は使いませんでしたが、DBから取得したデータを画面表示する場合は、/airflow/models/ に新しい Class を作成して、View に渡せば良さそうです。

まとめ(所感)

けっこう簡単に、管理画面のカスタマイズができました。

システムを運用していると、いろいろ課題が出てきます。

使わない機能の表示を消したり、逆にあると便利な機能を追加したり、個別案件ごとに管理画面のカスタマイズすれば、運用課題解決の一助になるのではないかと思いました。

参考