Share live runs table between dashboard and runs

This commit is contained in:
Abel Luck 2026-03-31 12:48:21 +02:00
parent c04efeb189
commit f3f4badaa2
5 changed files with 159 additions and 181 deletions

View file

@ -816,6 +816,7 @@ def load_dashboard_view(
footprint_bytes = _directory_size(output_dir) footprint_bytes = _directory_size(output_dir)
return { return {
"running": runs_view["running"], "running": runs_view["running"],
"queued": runs_view["queued"],
"source_feeds": tuple( "source_feeds": tuple(
_project_source_feed(source, output_dir, reference_time) _project_source_feed(source, output_dir, reference_time)
for source in sources for source in sources

View file

@ -8,56 +8,13 @@ from htpy import Node, Renderable
from repub.components import ( from repub.components import (
app_shell, app_shell,
header_action_link, header_action_link,
inline_button,
inline_link, inline_link,
muted_action_link, muted_action_link,
stat_card, stat_card,
status_badge, status_badge,
table_section, table_section,
) )
from repub.pages.runs import live_work_section, relative_time_formatter_script
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"),
],
)
def dashboard_header() -> Renderable: 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, ...]: def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]:
last_updated_iso = source_feed.get("last_updated_iso") last_updated_iso = source_feed.get("last_updated_iso")
last_updated = ( last_updated = (
@ -249,9 +136,11 @@ def dashboard_page_with_data(
*, *,
snapshot: Mapping[str, str] | None = None, snapshot: Mapping[str, str] | None = None,
running_executions: tuple[Mapping[str, object], ...] | 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, source_feeds: tuple[Mapping[str, object], ...] | None = None,
) -> Renderable: ) -> Renderable:
running_items = running_executions or () running_items = running_executions or ()
queued_items = queued_executions or ()
source_items = source_feeds or () source_items = source_feeds or ()
return app_shell( return app_shell(
current_path="/", current_path="/",
@ -260,7 +149,11 @@ def dashboard_page_with_data(
content=( content=(
dashboard_header(), dashboard_header(),
operational_snapshot(snapshot=snapshot), 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), published_feeds_table(source_feeds=source_items),
relative_time_formatter_script(),
), ),
) )

View file

@ -496,81 +496,39 @@ def _completed_history_section(
] ]
def runs_page( def live_work_section(
*, *,
running_executions: tuple[Mapping[str, object], ...] | None = None, running_executions: tuple[Mapping[str, object], ...] | None = None,
queued_executions: tuple[Mapping[str, object], ...] | None = None, queued_executions: tuple[Mapping[str, object], ...] | None = None,
upcoming_jobs: tuple[Mapping[str, object], ...] | None = None, actions: Node | 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: ) -> Renderable:
running_items = running_executions or () running_items = running_executions or ()
queued_items = queued_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) running_rows = tuple(_running_row(execution) for execution in running_items)
queued_rows = tuple(_queued_row(execution) for execution in queued_items) queued_rows = tuple(_queued_row(execution) for execution in queued_items)
live_rows = running_rows + queued_rows live_rows = running_rows + queued_rows
live_row_attrs = tuple( live_row_attrs = tuple(
_queue_row_attrs(execution) for execution in running_items + queued_items _queue_row_attrs(execution) for execution in running_items + queued_items
) )
upcoming_rows = tuple(_upcoming_row(job) for job in upcoming_items) return table_section(
completed_rows = tuple(_completed_row(execution) for execution in completed_items) eyebrow="Live work",
resolved_completed_total_count = ( title="Running jobs",
len(completed_items) if completed_total_count is None else completed_total_count empty_message="No jobs are running or queued.",
) headers=(
resolved_completed_total_pages = ( "State",
1 if completed_total_pages is None else completed_total_pages "Source",
"Details",
"Actions",
),
rows=live_rows,
row_attrs=live_row_attrs,
actions=actions,
) )
return page_shell(
current_path="/runs", def relative_time_formatter_script() -> Renderable:
eyebrow="Execution control", return h.script[
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[
"""
window.repubFormatNextRuns = window.repubFormatNextRuns || (() => { window.repubFormatNextRuns = window.repubFormatNextRuns || (() => {
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const absoluteFormatter = new Intl.DateTimeFormat(undefined, { const absoluteFormatter = new Intl.DateTimeFormat(undefined, {
@ -612,8 +570,67 @@ window.repubFormatNextRuns = 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(),
), ),
) )

View file

@ -437,6 +437,7 @@ async def render_dashboard(app: Quart | None = None) -> Renderable:
return dashboard_page_with_data( return dashboard_page_with_data(
snapshot=cast(dict[str, str], view["snapshot"]), snapshot=cast(dict[str, str], view["snapshot"]),
running_executions=cast(tuple[dict[str, object], ...], view["running"]), 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"]), source_feeds=cast(tuple[dict[str, object], ...], view["source_feeds"]),
) )

View file

@ -25,6 +25,7 @@ from repub.model import (
load_settings_form, load_settings_form,
save_setting, save_setting,
) )
from repub.pages import dashboard_page_with_data
from repub.pages.runs import runs_page from repub.pages.runs import runs_page
from repub.pages.sources import sources_page from repub.pages.sources import sources_page
from repub.web import ( from repub.web import (
@ -471,7 +472,7 @@ def test_dashboard_post_serves_morph_component() -> None:
assert b"id: " in chunk assert b"id: " in chunk
assert b'<main id="morph"' in chunk assert b'<main id="morph"' in chunk
assert b"Operational snapshot" in chunk assert b"Operational snapshot" in chunk
assert b"Running executions" in chunk assert b"Running jobs" in chunk
await connection.disconnect() await connection.disconnect()
asyncio.run(run()) asyncio.run(run())
@ -572,6 +573,8 @@ def test_render_stream_stops_when_shutdown_is_requested() -> None:
await stream.aclose() await stream.aclose()
asyncio.run(run()) asyncio.run(run())
def test_render_dashboard_shows_dashboard_information_architecture( def test_render_dashboard_shows_dashboard_information_architecture(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
@ -583,7 +586,7 @@ def test_render_dashboard_shows_dashboard_information_architecture(
body = str(await render_dashboard(app)) body = str(await render_dashboard(app))
assert "Operational snapshot" in body assert "Operational snapshot" in body
assert "Running executions" in body assert "Running jobs" in body
assert "Published feeds" in body assert "Published feeds" in body
assert 'href="/sources"' in body assert 'href="/sources"' in body
assert 'href="/runs"' 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() app = create_app()
body = str(await render_dashboard(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 assert "No feeds have been published yet." in body
asyncio.run(run()) 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( def test_load_dashboard_view_measures_log_artifact_path(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None: