Skip to content

Commit e6be80a

Browse files
As tab in the DAG run page
1 parent a8baf0c commit e6be80a

4 files changed

Lines changed: 86 additions & 16 deletions

File tree

mokelumne/dags/summarise_job.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from itertools import filterfalse
55
from pathlib import Path
66
from shutil import copyfile
7-
from uuid import uuid4
87

98
import csv
109
import json
@@ -29,9 +28,17 @@ def summarise_job():
2928

3029
@task
3130
def generate_id() -> str:
32-
"""Generate a URL-safe directory for collated output."""
33-
path = public_dir() / str(uuid4())
34-
path.mkdir() # We don't exist_ok=True because it should be unique.
31+
"""Use this DAG run's ``run_id`` as the output directory name.
32+
33+
That makes the output directly addressable as
34+
``/mokelumne/public/{RUN_ID}/`` from the ``dag_run`` external-view tab
35+
that Airflow renders on each summarise_job run's detail page.
36+
``exist_ok=True`` handles task re-runs, which reuse the same run_id.
37+
"""
38+
context = get_current_context()
39+
run_id = context['dag_run'].run_id
40+
path = public_dir() / run_id
41+
path.mkdir(exist_ok=True)
3542
return str(path)
3643

3744
@task(inlets=[processed_csv])

plugins/public_browser.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,42 @@ def index() -> HTMLResponse:
128128
return HTMLResponse(_render_index(_list_jobs()))
129129

130130

131-
@app.get("/{uuid}/", response_class=HTMLResponse, dependencies=[Depends(_require_role)])
132-
def job_index(uuid: str) -> FileResponse:
133-
job_dir = _resolve_in_public(uuid)
131+
def _render_empty_run(run_id: str) -> str:
132+
return f"""<!doctype html>
133+
<html lang="en"><head>
134+
<meta charset="utf-8">
135+
<meta name="viewport" content="width=device-width, initial-scale=1">
136+
<title>Batch Image Results</title>
137+
<style>{AIRFLOW_STYLE_CSS}</style>
138+
</head><body>
139+
<h1>Batch Image Results</h1>
140+
<div class="panel"><div class="empty">
141+
No batch image summary was generated for run
142+
<code class="mono">{html.escape(run_id)}</code>.
143+
</div></div>
144+
</body></html>
145+
"""
146+
147+
148+
@app.get("/{run_id}/", response_class=HTMLResponse, dependencies=[Depends(_require_role)])
149+
def job_index(run_id: str):
150+
root = public_dir().resolve()
151+
job_dir = (root / run_id).resolve()
152+
try:
153+
job_dir.relative_to(root)
154+
except ValueError:
155+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
134156
index_html = job_dir / "index.html"
135157
if not index_html.is_file():
136-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
158+
# Return 200 with an empty-state page so the dag_run iframe tab on
159+
# non-summarise_job runs shows a clean message instead of a raw 404.
160+
return HTMLResponse(_render_empty_run(run_id))
137161
return FileResponse(index_html, media_type="text/html")
138162

139163

140-
@app.get("/{uuid}/{filename:path}", dependencies=[Depends(_require_role)])
141-
def job_file(uuid: str, filename: str) -> FileResponse:
142-
return FileResponse(_resolve_in_public(uuid, filename))
164+
@app.get("/{run_id}/{filename:path}", dependencies=[Depends(_require_role)])
165+
def job_file(run_id: str, filename: str) -> FileResponse:
166+
return FileResponse(_resolve_in_public(run_id, filename))
143167

144168

145169
class PublicBrowserPlugin(AirflowPlugin):
@@ -151,14 +175,23 @@ class PublicBrowserPlugin(AirflowPlugin):
151175
"name": "Batch Image Results",
152176
},
153177
]
154-
# No ``category`` is set so the React UI routes this into ``topNavItems``
155-
# (see ``airflow/ui/src/layouts/Nav/Nav.tsx``) instead of the "Browse"
156-
# dropdown — rendering it as a top-level sidebar button next to DAGs/Assets.
178+
# Two views:
179+
# * ``nav`` \u2192 sidebar tab, cross-run listing (``topNavItems``
180+
# branch in ``airflow/ui/src/layouts/Nav/Nav.tsx``,
181+
# rendered as a top-level sidebar button).
182+
# * ``dag_run`` \u2192 tab on each DAG run detail page, deep-linking to
183+
# the per-run directory via ``{RUN_ID}`` templating.
157184
external_views = [
158185
{
159186
"name": "Batch Image Results",
160187
"href": f"{URL_PREFIX}/",
161188
"destination": "nav",
162189
"url_route": "mokelumne_public_browser",
163190
},
191+
{
192+
"name": "Batch Image Results",
193+
"href": f"{URL_PREFIX}/{{RUN_ID}}/",
194+
"destination": "dag_run",
195+
"url_route": "mokelumne_public_browser_run",
196+
},
164197
]

test/e2e/test_public_browser.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ def test_testuser_can_download_params_json(page_as_testuser: Page) -> None:
4242
assert response.json()["tind_query"] == "farm mokelumne"
4343

4444

45-
def test_unknown_job_returns_404(page_as_testuser: Page) -> None:
45+
def test_unknown_run_renders_empty_state(page_as_testuser: Page) -> None:
46+
# Returns a friendly "no summary" page rather than 404 so the ``dag_run``
47+
# iframe tab on non-summarise_job runs doesn't show as broken.
4648
response = page_as_testuser.request.get(
4749
f"{PUBLIC_BROWSER_PATH}00000000-0000-0000-0000-000000000000/",
4850
)
49-
assert response.status == 404
51+
assert response.status == 200
52+
assert "No batch image summary was generated" in response.text()

test/e2e/test_public_browser_nav.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,30 @@ def test_breadcrumb_returns_to_listing(page_as_testadmin: Page) -> None:
4444
page.get_by_role("link", name="\u2190 Batch Image Results").click()
4545
expect(page).to_have_url(re.compile(r"/mokelumne/public/$"))
4646
expect(page.get_by_role("heading", name="Batch Image Results")).to_be_visible()
47+
48+
49+
def test_dag_run_tab_renders_with_run_id(page_as_testadmin: Page) -> None:
50+
"""The ``dag_run`` external view registers a tab on every DAG run page;
51+
its href templates ``{RUN_ID}``. This asserts the UI renders the tab,
52+
routes to ``/dags/<dag_id>/runs/<run_id>/plugin/<url_route>``, and
53+
iframes ``/mokelumne/public/<run_id>/``."""
54+
page = page_as_testadmin
55+
# A summarise_job run left in the local DB from the compose stack's prior
56+
# execution. It exists regardless of whether a per-run public directory
57+
# has been written — the endpoint renders an empty-state page in that case.
58+
run_id = "asset_triggered__2026-04-17T20:38:36.761076+00:00_ZiSQe0aG"
59+
page.goto(f"/dags/summarise_job/runs/{run_id}")
60+
61+
# The tab is an <a> (not role=tab) alongside Task Instances / Details etc.
62+
# Scope the lookup to the anchor pointing at the plugin route so we don't
63+
# match the sidebar link of the same name.
64+
tab_link = page.locator(
65+
f"a[href$='/runs/{run_id}/plugin/mokelumne_public_browser_run']"
66+
)
67+
expect(tab_link).to_be_visible(timeout=10_000)
68+
tab_link.click()
69+
70+
iframe_el = page.locator("iframe").first
71+
expect(iframe_el).to_have_attribute(
72+
"src", re.compile(r"/mokelumne/public/asset_triggered__[^/]+/$")
73+
)

0 commit comments

Comments
 (0)