From f3f4badaa20b4b3e50e280031596947567f9c502 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 12:48:21 +0200 Subject: [PATCH] Share live runs table between dashboard and runs --- repub/jobs.py | 1 + repub/pages/dashboard.py | 123 +++------------------------------ repub/pages/runs.py | 143 ++++++++++++++++++++++----------------- repub/web.py | 1 + tests/test_web.py | 72 +++++++++++++++++++- 5 files changed, 159 insertions(+), 181 deletions(-) diff --git a/repub/jobs.py b/repub/jobs.py index ad318ba..826b1fd 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -816,6 +816,7 @@ def load_dashboard_view( footprint_bytes = _directory_size(output_dir) return { "running": runs_view["running"], + "queued": runs_view["queued"], "source_feeds": tuple( _project_source_feed(source, output_dir, reference_time) for source in sources diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index ad68076..2b07d50 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -8,56 +8,13 @@ from htpy import Node, Renderable from repub.components import ( app_shell, header_action_link, - inline_button, inline_link, muted_action_link, stat_card, status_badge, table_section, ) - - -def _text(values: Mapping[str, object], key: str) -> str: - return str(values[key]) - - -def _running_execution_row(execution: Mapping[str, object]) -> tuple[Node, ...]: - status_tone = "running" if _text(execution, "status") != "Succeeded" else "done" - return ( - h.div[ - h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], - h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[ - _text(execution, "slug") - ], - ], - h.div[ - h.p(class_="font-medium text-slate-900")[ - f"#{_text(execution, 'execution_id')}" - ], - h.p(class_="mt-0.5 text-[11px] text-slate-500")[ - f"job {_text(execution, 'job_id')}" - ], - ], - h.div[ - h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")], - h.p(class_="mt-0.5 text-[11px] text-slate-500")[ - _text(execution, "runtime") - ], - ], - status_badge(label=_text(execution, "status"), tone=status_tone), - h.div(class_="min-w-56 whitespace-normal")[ - h.p(class_="font-medium text-slate-900")[_text(execution, "stats")], - h.p(class_="mt-0.5 text-[11px] text-slate-500")[_text(execution, "worker")], - ], - h.div(class_="flex flex-nowrap items-center gap-3")[ - inline_link( - href=_text(execution, "log_href"), - label="View log", - tone="amber", - ), - inline_button(label="Stop", tone="danger"), - ], - ) +from repub.pages.runs import live_work_section, relative_time_formatter_script def dashboard_header() -> Renderable: @@ -121,76 +78,6 @@ def operational_snapshot(*, snapshot: Mapping[str, str] | None = None) -> Render ] -def running_executions_table( - *, running_executions: tuple[Mapping[str, object], ...] | None = None -) -> Renderable: - rows = tuple( - _running_execution_row(execution) for execution in (running_executions or ()) - ) - headers = ("Source", "Execution", "Started", "Status", "Stats", "Actions") - - def render_row(row: tuple[Node, ...]) -> Renderable: - first_cell, *other_cells = row - return h.tr(class_="align-top")[ - h.td(class_="py-3 pr-6 pl-4 text-sm font-medium text-slate-950 sm:pl-4")[ - first_cell - ], - ( - h.td( - class_="px-3 py-3 align-top text-sm whitespace-nowrap text-slate-600" - )[cell] - for cell in other_cells - ), - ] - - body_rows: Node - if rows: - body_rows = (render_row(row) for row in rows) - else: - body_rows = h.tr[ - h.td( - colspan=str(len(headers)), - class_="px-4 py-8 text-center text-sm text-slate-500", - )["No job executions are running."] - ] - - return h.section[ - h.div(class_="mb-3 flex items-end justify-between gap-4")[ - h.div[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" - )["Live work"], - h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[ - "Running executions" - ], - ], - muted_action_link(href="/runs", label="Open runs"), - ], - h.div( - class_="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200" - )[ - h.div(class_="overflow-x-auto")[ - h.table( - class_="w-full min-w-[70rem] divide-y divide-slate-200 table-auto" - )[ - h.thead(class_="bg-stone-50")[ - h.tr[ - ( - h.th( - scope="col", - class_="px-3 py-2.5 text-left text-[11px] font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-4", - )[header] - for header in headers - ) - ] - ], - h.tbody(class_="divide-y divide-slate-200 bg-white")[body_rows], - ] - ] - ], - ] - - def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]: last_updated_iso = source_feed.get("last_updated_iso") last_updated = ( @@ -249,9 +136,11 @@ def dashboard_page_with_data( *, snapshot: Mapping[str, str] | None = None, running_executions: tuple[Mapping[str, object], ...] | None = None, + queued_executions: tuple[Mapping[str, object], ...] | None = None, source_feeds: tuple[Mapping[str, object], ...] | None = None, ) -> Renderable: running_items = running_executions or () + queued_items = queued_executions or () source_items = source_feeds or () return app_shell( current_path="/", @@ -260,7 +149,11 @@ def dashboard_page_with_data( content=( dashboard_header(), operational_snapshot(snapshot=snapshot), - running_executions_table(running_executions=running_items), + live_work_section( + running_executions=running_items, + queued_executions=queued_items, + ), published_feeds_table(source_feeds=source_items), + relative_time_formatter_script(), ), ) diff --git a/repub/pages/runs.py b/repub/pages/runs.py index 1906038..3b96e05 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -496,81 +496,39 @@ def _completed_history_section( ] -def runs_page( +def live_work_section( *, running_executions: tuple[Mapping[str, object], ...] | None = None, queued_executions: tuple[Mapping[str, object], ...] | None = None, - upcoming_jobs: tuple[Mapping[str, object], ...] | None = None, - completed_executions: tuple[Mapping[str, object], ...] | None = None, - completed_page: int = 1, - completed_page_size: int = 20, - completed_total_count: int | None = None, - completed_total_pages: int | None = None, - source_count: int = 0, + actions: Node | None = None, ) -> Renderable: running_items = running_executions or () queued_items = queued_executions or () - upcoming_items = upcoming_jobs or () - completed_items = completed_executions or () running_rows = tuple(_running_row(execution) for execution in running_items) queued_rows = tuple(_queued_row(execution) for execution in queued_items) live_rows = running_rows + queued_rows live_row_attrs = tuple( _queue_row_attrs(execution) for execution in running_items + queued_items ) - upcoming_rows = tuple(_upcoming_row(job) for job in upcoming_items) - completed_rows = tuple(_completed_row(execution) for execution in completed_items) - resolved_completed_total_count = ( - len(completed_items) if completed_total_count is None else completed_total_count - ) - resolved_completed_total_pages = ( - 1 if completed_total_pages is None else completed_total_pages + return table_section( + eyebrow="Live work", + title="Running jobs", + empty_message="No jobs are running or queued.", + headers=( + "State", + "Source", + "Details", + "Actions", + ), + rows=live_rows, + row_attrs=live_row_attrs, + actions=actions, ) - return page_shell( - current_path="/runs", - eyebrow="Execution control", - title="Runs", - actions=muted_action_link(href="/sources", label="Back to sources"), - source_count=source_count, - running_count=len(running_items), - content=( - table_section( - eyebrow="Live work", - title="Running jobs", - empty_message="No jobs are running or queued.", - headers=( - "State", - "Source", - "Details", - "Actions", - ), - rows=live_rows, - row_attrs=live_row_attrs, - ), - table_section( - eyebrow="Schedule", - title="Scheduled jobs", - empty_message="No jobs are scheduled.", - headers=( - "Source", - "State", - "Next run", - "Cron", - "Run now", - "Actions", - ), - rows=upcoming_rows, - ), - _completed_history_section( - completed_rows=completed_rows, - completed_page=completed_page, - completed_page_size=completed_page_size, - completed_total_count=resolved_completed_total_count, - completed_total_pages=resolved_completed_total_pages, - ), - h.script[ - """ + +def relative_time_formatter_script() -> Renderable: + return h.script[ + """ window.repubFormatNextRuns = window.repubFormatNextRuns || (() => { const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); const absoluteFormatter = new Intl.DateTimeFormat(undefined, { @@ -612,8 +570,67 @@ window.repubFormatNextRuns = window.repubFormatNextRuns || (() => { } }); window.repubFormatNextRuns(); - """ - ], + """ + ] + + +def runs_page( + *, + running_executions: tuple[Mapping[str, object], ...] | None = None, + queued_executions: tuple[Mapping[str, object], ...] | None = None, + upcoming_jobs: tuple[Mapping[str, object], ...] | None = None, + completed_executions: tuple[Mapping[str, object], ...] | None = None, + completed_page: int = 1, + completed_page_size: int = 20, + completed_total_count: int | None = None, + completed_total_pages: int | None = None, + source_count: int = 0, +) -> Renderable: + upcoming_items = upcoming_jobs or () + completed_items = completed_executions or () + upcoming_rows = tuple(_upcoming_row(job) for job in upcoming_items) + completed_rows = tuple(_completed_row(execution) for execution in completed_items) + resolved_completed_total_count = ( + len(completed_items) if completed_total_count is None else completed_total_count + ) + resolved_completed_total_pages = ( + 1 if completed_total_pages is None else completed_total_pages + ) + + return page_shell( + current_path="/runs", + eyebrow="Execution control", + title="Runs", + actions=muted_action_link(href="/sources", label="Back to sources"), + source_count=source_count, + running_count=len(running_executions or ()), + content=( + live_work_section( + running_executions=running_executions, + queued_executions=queued_executions, + ), + table_section( + eyebrow="Schedule", + title="Scheduled jobs", + empty_message="No jobs are scheduled.", + headers=( + "Source", + "State", + "Next run", + "Cron", + "Run now", + "Actions", + ), + rows=upcoming_rows, + ), + _completed_history_section( + completed_rows=completed_rows, + completed_page=completed_page, + completed_page_size=completed_page_size, + completed_total_count=resolved_completed_total_count, + completed_total_pages=resolved_completed_total_pages, + ), + relative_time_formatter_script(), ), ) diff --git a/repub/web.py b/repub/web.py index f5d2339..1c50228 100644 --- a/repub/web.py +++ b/repub/web.py @@ -437,6 +437,7 @@ async def render_dashboard(app: Quart | None = None) -> Renderable: return dashboard_page_with_data( snapshot=cast(dict[str, str], view["snapshot"]), running_executions=cast(tuple[dict[str, object], ...], view["running"]), + queued_executions=cast(tuple[dict[str, object], ...], view["queued"]), source_feeds=cast(tuple[dict[str, object], ...], view["source_feeds"]), ) diff --git a/tests/test_web.py b/tests/test_web.py index 1ebae13..4bf416a 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -25,6 +25,7 @@ from repub.model import ( load_settings_form, save_setting, ) +from repub.pages import dashboard_page_with_data from repub.pages.runs import runs_page from repub.pages.sources import sources_page from repub.web import ( @@ -471,7 +472,7 @@ def test_dashboard_post_serves_morph_component() -> None: assert b"id: " in chunk assert b'
None: await stream.aclose() asyncio.run(run()) + + def test_render_dashboard_shows_dashboard_information_architecture( monkeypatch, tmp_path: Path ) -> None: @@ -583,7 +586,7 @@ def test_render_dashboard_shows_dashboard_information_architecture( body = str(await render_dashboard(app)) assert "Operational snapshot" in body - assert "Running executions" in body + assert "Running jobs" in body assert "Published feeds" in body assert 'href="/sources"' in body assert 'href="/runs"' in body @@ -602,12 +605,75 @@ def test_render_dashboard_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> app = create_app() body = str(await render_dashboard(app)) - assert "No job executions are running." in body + assert "No jobs are running or queued." in body assert "No feeds have been published yet." in body asyncio.run(run()) +def test_dashboard_running_table_matches_runs_page_live_table_markup() -> None: + running_executions = ( + { + "source": "Running source", + "slug": "running-source", + "job_id": 1, + "execution_id": 11, + "started_at": "2 minutes ago", + "started_at_iso": "2026-03-30T12:00:00+00:00", + "duration": "00:00:10", + "runtime": "running for 10s", + "status": "Running", + "stats": "1 requests • 1 items • 1 byte", + "worker": "streaming stats from worker", + "log_href": "/job/1/execution/11/logs", + "cancel_label": "Stop", + "cancel_post_path": "/actions/executions/11/cancel", + }, + ) + queued_executions = ( + { + "source": "Queued source", + "slug": "queued-source", + "job_id": 2, + "execution_id": 22, + "queued_at": "2 minutes ago", + "queued_at_iso": "2026-03-30T12:28:00+00:00", + "queue_position": 1, + "status": "Queued", + "status_tone": "idle", + "run_label": "Queued", + "run_disabled": True, + "run_post_path": "/actions/jobs/2/run-now", + "cancel_post_path": "/actions/queued-executions/22/cancel", + "move_up_disabled": True, + "move_up_post_path": None, + "move_down_disabled": True, + "move_down_post_path": None, + }, + ) + runs_body = str( + runs_page( + running_executions=running_executions, + queued_executions=queued_executions, + ) + ) + dashboard_body = str( + dashboard_page_with_data( + running_executions=running_executions, + queued_executions=queued_executions, + ) + ) + + assert "Running jobs" in dashboard_body + assert "Running executions" not in dashboard_body + assert "Running source" in dashboard_body + assert "running-source" in dashboard_body + assert "queued-source" in dashboard_body + assert "bg-sky-100 text-sky-800" in dashboard_body + assert "/job/1/execution/11/logs" in dashboard_body + assert runs_body.count(">State<") >= 1 + + def test_load_dashboard_view_measures_log_artifact_path( monkeypatch, tmp_path: Path ) -> None: