diff --git a/AGENTS.md b/AGENTS.md index e801337..7082a72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,16 +87,17 @@ The state passed to a render-fn should be thought of as `{persistent db state, e - Enter the dev environment with `nix develop` if you are not already inside it - Sync Python dependencies with `uv sync --all-groups`. - Run the app with `uv run repub`. -- Run `uv run qa` often while working. It runs Tailwind CSS generation, `black`, `flake8`, `pyright`, and the full test suite in that order. -- Run `uv run qa-final` at the end before declaring a task complete and always before staging or committing. It runs `uv run qa`, then `nix fmt`, then `nix flake check`. - Generate CSS with `tailwindcss -i ./repub/static/app.tailwind.css -o ./repub/static/app.css` and add `--watch` when you need live rebuilds. - Validate a generated feed with `./scripts/validate-feed path/to/feed.rss`. This wraps the local checkout at `~/src/github.com/w3c/feedvalidator` and pages the validator output through `less` by default. ```sh uv sync --all-groups -uv run qa +uv run pytest +uv run flake8 repub/ tests/ +uv run pyright ./scripts/validate-feed out/feeds/mn-cuba/feed.rss -uv run qa-final +nix fmt +nix flake check uv run repub uv run repub crawl -c repub.toml ``` diff --git a/pyproject.toml b/pyproject.toml index b87027b..668db40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,6 @@ dependencies = [ [project.scripts] repub = "repub.entrypoint:entrypoint" -qa = "repub.qa:qa_entrypoint" -qa-final = "repub.qa:qa_final_entrypoint" [dependency-groups] dev = [ diff --git a/repub/components.py b/repub/components.py index 7bd1987..e93ee87 100644 --- a/repub/components.py +++ b/repub/components.py @@ -503,7 +503,6 @@ def status_badge(*, label: str, tone: str) -> Renderable: tones = { "running": "bg-emerald-100 text-emerald-800", "scheduled": "bg-sky-100 text-sky-800", - "queued": "bg-amber-200 text-amber-950", "idle": "bg-slate-200 text-slate-700", "failed": "bg-rose-100 text-rose-800", "done": "bg-emerald-100 text-emerald-800", diff --git a/repub/jobs.py b/repub/jobs.py index a8ee9fd..ad318ba 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -799,19 +799,8 @@ def load_dashboard_view( reference_time = now or datetime.now(UTC) runs_view = load_runs_view(log_dir=log_dir, now=reference_time) output_dir = Path(log_dir).parent - running_by_job_id = { - int(cast(int, execution["job_id"])): execution - for execution in runs_view["running"] - } - queued_by_job_id = { - int(cast(int, execution["job_id"])): execution - for execution in runs_view["queued"] - } - upcoming_by_job_id = { - int(cast(int, job["job_id"])): job for job in runs_view["upcoming"] - } with database.connection_context(): - jobs = tuple(Job.select(Job, Source).join(Source).order_by(Source.name.asc())) + sources = tuple(Source.select().order_by(Source.name.asc())) failed_last_day = ( JobExecution.select() .where( @@ -827,17 +816,9 @@ 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( - cast(Job, job), - output_dir, - reference_time, - running_execution=running_by_job_id.get(_job_id(cast(Job, job))), - queued_execution=queued_by_job_id.get(_job_id(cast(Job, job))), - upcoming_job=upcoming_by_job_id.get(_job_id(cast(Job, job))), - ) - for job in jobs + _project_source_feed(source, output_dir, reference_time) + for source in sources ), "snapshot": { "running_now": str(len(runs_view["running"])), @@ -1094,15 +1075,8 @@ def _project_completed_execution( def _project_source_feed( - job: Job, - output_dir: Path, - reference_time: datetime, - *, - running_execution: dict[str, object] | None = None, - queued_execution: dict[str, object] | None = None, - upcoming_job: dict[str, object] | None = None, + source: Source, output_dir: Path, reference_time: datetime ) -> dict[str, object]: - source = cast(Source, job.source) source_slug = str(source.slug) source_dir = feed_output_dir(out_dir=output_dir, feed_slug=source_slug) feed_path = feed_output_path(out_dir=output_dir, feed_slug=source_slug) @@ -1112,22 +1086,12 @@ def _project_source_feed( if feed_exists else None ) - if running_execution is not None: - feed_status_label = str(running_execution["status"]) - feed_status_tone = "scheduled" - elif queued_execution is not None: - feed_status_label = "Queued" - feed_status_tone = "queued" - else: - feed_status_label = "Available" if feed_exists else "Missing" - feed_status_tone = "done" if feed_exists else "failed" - return { "source": source.name, "slug": source_slug, "feed_href": f"/feeds/{source_slug}/feed.rss", - "feed_status_label": feed_status_label, - "feed_status_tone": feed_status_tone, + "feed_status_label": "Available" if feed_exists else "Missing", + "feed_status_tone": "done" if feed_exists else "failed", "feed_exists": feed_exists, "last_updated": ( _humanize_relative_time(reference_time, updated_at) @@ -1135,24 +1099,6 @@ def _project_source_feed( else "Never published" ), "last_updated_iso": updated_at.isoformat() if updated_at is not None else None, - "next_run": ( - str(upcoming_job["next_run"]) - if upcoming_job is not None - else "Not scheduled" - ), - "next_run_at": ( - cast(str | None, upcoming_job["next_run_at"]) - if upcoming_job is not None - else None - ), - "run_disabled": ( - bool(upcoming_job["run_disabled"]) if upcoming_job is not None else False - ), - "run_post_path": ( - str(upcoming_job["run_post_path"]) - if upcoming_job is not None - else f"/actions/jobs/{_job_id(job)}/run-now" - ), "artifact_footprint": _format_bytes(_directory_size(source_dir)), } diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index c9f2588..ad68076 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -1,22 +1,63 @@ from __future__ import annotations from collections.abc import Mapping -from typing import cast import htpy as h from htpy import Node, Renderable from repub.components import ( - action_button, app_shell, header_action_link, + inline_button, inline_link, muted_action_link, stat_card, status_badge, 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: @@ -80,6 +121,76 @@ 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 = ( @@ -91,19 +202,6 @@ def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]: if last_updated_iso is not None else h.p(class_="font-medium text-slate-900")[str(source_feed["last_updated"])] ) - next_run_iso = source_feed.get("next_run_at") - next_run = ( - h.time( - { - "data-next-run-at": str(next_run_iso), - "title": str(next_run_iso), - }, - datetime=str(next_run_iso), - class_="font-medium text-slate-900", - )[str(source_feed["next_run"])] - if next_run_iso is not None - else h.p(class_="font-medium text-slate-900")[str(source_feed["next_run"])] - ) return ( h.div[ h.div(class_="font-semibold text-slate-950")[str(source_feed["source"])], @@ -123,15 +221,9 @@ def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]: tone=str(source_feed["feed_status_tone"]), ), last_updated, - next_run, h.p(class_="font-medium text-slate-900")[ str(source_feed["artifact_footprint"]) ], - action_button( - label="Run now", - disabled=bool(source_feed["run_disabled"]), - post_path=cast(str | None, source_feed.get("run_post_path")), - ), ) @@ -143,15 +235,7 @@ def published_feeds_table( eyebrow="Published feeds", title="Published feeds", empty_message="No feeds have been published yet.", - headers=( - "Source", - "Feed URL", - "Status", - "Last updated", - "Next run", - "Disk usage", - "Actions", - ), + headers=("Source", "Feed URL", "Status", "Last updated", "Disk usage"), rows=rows, actions=muted_action_link(href="/sources", label="Manage sources"), ) @@ -165,11 +249,9 @@ 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="/", @@ -178,11 +260,7 @@ def dashboard_page_with_data( content=( dashboard_header(), operational_snapshot(snapshot=snapshot), - live_work_section( - running_executions=running_items, - queued_executions=queued_items, - ), + running_executions_table(running_executions=running_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 3b96e05..1906038 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -496,39 +496,81 @@ def _completed_history_section( ] -def live_work_section( +def runs_page( *, running_executions: tuple[Mapping[str, object], ...] | None = None, queued_executions: tuple[Mapping[str, object], ...] | None = None, - actions: Node | 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: 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 ) - 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, + 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 ) - -def relative_time_formatter_script() -> Renderable: - return h.script[ - """ + 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[ + """ window.repubFormatNextRuns = window.repubFormatNextRuns || (() => { const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); const absoluteFormatter = new Intl.DateTimeFormat(undefined, { @@ -570,67 +612,8 @@ 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/qa.py b/repub/qa.py deleted file mode 100644 index 955d283..0000000 --- a/repub/qa.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -import subprocess - -QA_COMMANDS: tuple[tuple[str, ...], ...] = ( - ( - "tailwindcss", - "-i", - "./repub/static/app.tailwind.css", - "-o", - "./repub/static/app.css", - ), - ("black", "repub/", "tests/"), - ("flake8", "repub/", "tests/"), - ("pyright",), - ("pytest",), -) - -QA_FINAL_COMMANDS: tuple[tuple[str, ...], ...] = QA_COMMANDS + ( - ("nix", "fmt"), - ("nix", "flake", "check"), -) - - -def _run_commands(commands: tuple[tuple[str, ...], ...]) -> int: - for command in commands: - result = subprocess.run(command, check=False) - if result.returncode != 0: - return result.returncode - return 0 - - -def qa_entrypoint() -> int: - return _run_commands(QA_COMMANDS) - - -def qa_final_entrypoint() -> int: - return _run_commands(QA_FINAL_COMMANDS) diff --git a/repub/static/app.css b/repub/static/app.css index 94b02ed..78b05de 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -394,6 +394,9 @@ .min-w-32 { min-width: calc(var(--spacing) * 32); } + .min-w-56 { + min-width: calc(var(--spacing) * 56); + } .min-w-64 { min-width: calc(var(--spacing) * 64); } @@ -403,6 +406,9 @@ .min-w-\[64rem\] { min-width: 64rem; } + .min-w-\[70rem\] { + min-width: 70rem; + } .flex-1 { flex: 1; } @@ -735,9 +741,15 @@ .pr-5 { padding-right: calc(var(--spacing) * 5); } + .pr-6 { + padding-right: calc(var(--spacing) * 6); + } .pl-3 { padding-left: calc(var(--spacing) * 3); } + .pl-4 { + padding-left: calc(var(--spacing) * 4); + } .text-center { text-align: center; } @@ -943,6 +955,11 @@ padding-left: calc(var(--spacing) * 3); } } + .first\:pl-4 { + &:first-child { + padding-left: calc(var(--spacing) * 4); + } + } .hover\:bg-amber-300 { &:hover { @media (hover: hover) { diff --git a/repub/web.py b/repub/web.py index 1c50228..f5d2339 100644 --- a/repub/web.py +++ b/repub/web.py @@ -437,7 +437,6 @@ 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_qa.py b/tests/test_qa.py deleted file mode 100644 index 906555d..0000000 --- a/tests/test_qa.py +++ /dev/null @@ -1,67 +0,0 @@ -import tomllib -from pathlib import Path -from types import SimpleNamespace - -from repub.qa import qa_entrypoint, qa_final_entrypoint - - -def test_pyproject_registers_qa_scripts() -> None: - pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" - config = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) - - scripts = config["project"]["scripts"] - - assert scripts["qa"] == "repub.qa:qa_entrypoint" - assert scripts["qa-final"] == "repub.qa:qa_final_entrypoint" - - -def test_qa_entrypoint_runs_expected_commands_in_order(monkeypatch) -> None: - recorded: list[tuple[str, ...]] = [] - - def fake_run(command: tuple[str, ...], *, check: bool) -> SimpleNamespace: - recorded.append(command) - return SimpleNamespace(returncode=0) - - monkeypatch.setattr("repub.qa.subprocess.run", fake_run) - - assert qa_entrypoint() == 0 - assert recorded == [ - ( - "tailwindcss", - "-i", - "./repub/static/app.tailwind.css", - "-o", - "./repub/static/app.css", - ), - ("black", "repub/", "tests/"), - ("flake8", "repub/", "tests/"), - ("pyright",), - ("pytest",), - ] - - -def test_qa_final_entrypoint_runs_expected_commands_in_order(monkeypatch) -> None: - recorded: list[tuple[str, ...]] = [] - - def fake_run(command: tuple[str, ...], *, check: bool) -> SimpleNamespace: - recorded.append(command) - return SimpleNamespace(returncode=0) - - monkeypatch.setattr("repub.qa.subprocess.run", fake_run) - - assert qa_final_entrypoint() == 0 - assert recorded == [ - ( - "tailwindcss", - "-i", - "./repub/static/app.tailwind.css", - "-o", - "./repub/static/app.css", - ), - ("black", "repub/", "tests/"), - ("flake8", "repub/", "tests/"), - ("pyright",), - ("pytest",), - ("nix", "fmt"), - ("nix", "flake", "check"), - ] diff --git a/tests/test_web.py b/tests/test_web.py index f9747a5..1ebae13 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -25,7 +25,6 @@ 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 ( @@ -472,7 +471,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: @@ -586,7 +583,7 @@ def test_render_dashboard_shows_dashboard_information_architecture( body = str(await render_dashboard(app)) assert "Operational snapshot" in body - assert "Running jobs" in body + assert "Running executions" in body assert "Published feeds" in body assert 'href="/sources"' in body assert 'href="/runs"' in body @@ -605,75 +602,12 @@ def test_render_dashboard_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> app = create_app() body = str(await render_dashboard(app)) - assert "No jobs are running or queued." in body + assert "No job executions are running." 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: @@ -719,7 +653,7 @@ def test_load_dashboard_view_lists_source_feed_artifacts( app.config["REPUB_LOG_DIR"] = log_dir log_dir.mkdir(parents=True) - available_source = create_source( + create_source( name="Available source", slug="available-source", source_type="feed", @@ -733,7 +667,7 @@ def test_load_dashboard_view_lists_source_feed_artifacts( cron_month="*", feed_url="https://example.com/available.xml", ) - missing_source = create_source( + create_source( name="Missing source", slug="missing-source", source_type="feed", @@ -757,8 +691,6 @@ def test_load_dashboard_view_lists_source_feed_artifacts( updated_at = reference_time - timedelta(minutes=32) updated_at_epoch = updated_at.timestamp() os.utime(feed_path, (updated_at_epoch, updated_at_epoch)) - available_job = Job.get(Job.source == available_source) - missing_job = Job.get(Job.source == missing_source) source_feeds = cast( tuple[dict[str, object], ...], @@ -775,10 +707,6 @@ def test_load_dashboard_view_lists_source_feed_artifacts( "feed_exists": True, "last_updated": "32 minutes ago", "last_updated_iso": updated_at.isoformat(), - "next_run": "Not scheduled", - "next_run_at": None, - "run_disabled": False, - "run_post_path": f"/actions/jobs/{available_job.id}/run-now", "artifact_footprint": "3.0 KB", }, { @@ -790,80 +718,11 @@ def test_load_dashboard_view_lists_source_feed_artifacts( "feed_exists": False, "last_updated": "Never published", "last_updated_iso": None, - "next_run": "Not scheduled", - "next_run_at": None, - "run_disabled": False, - "run_post_path": f"/actions/jobs/{missing_job.id}/run-now", "artifact_footprint": "0 B", }, ) -def test_load_dashboard_view_projects_feed_status_from_job_runtime( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "dashboard-feed-status.db" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - create_app() - log_dir = tmp_path / "out" / "logs" - log_dir.mkdir(parents=True) - reference_time = datetime(2026, 3, 30, 12, 30, tzinfo=UTC) - - running_source = create_source( - name="Running source", - slug="running-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="35", - cron_hour="12", - cron_day_of_month="30", - cron_day_of_week="*", - cron_month="3", - feed_url="https://example.com/running.xml", - ) - queued_source = create_source( - name="Queued source", - slug="queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="35", - cron_hour="12", - cron_day_of_month="30", - cron_day_of_week="*", - cron_month="3", - feed_url="https://example.com/queued.xml", - ) - - running_job = Job.get(Job.source == running_source) - queued_job = Job.get(Job.source == queued_source) - JobExecution.create( - job=running_job, - running_status=JobExecutionStatus.RUNNING, - started_at=reference_time - timedelta(minutes=2), - ) - JobExecution.create( - job=queued_job, - running_status=JobExecutionStatus.PENDING, - ) - - source_feeds = cast( - tuple[dict[str, object], ...], - load_dashboard_view(log_dir=log_dir, now=reference_time)["source_feeds"], - ) - - assert source_feeds[0]["feed_status_label"] == "Queued" - assert source_feeds[0]["feed_status_tone"] == "queued" - assert source_feeds[0]["run_disabled"] is True - assert source_feeds[1]["feed_status_label"] == "Running" - assert source_feeds[1]["feed_status_tone"] == "scheduled" - assert source_feeds[1]["next_run"] == "Running now" - assert source_feeds[1]["run_disabled"] is True - - def test_render_dashboard_shows_source_feed_links_and_statuses( monkeypatch, tmp_path: Path ) -> None: @@ -872,13 +731,13 @@ def test_render_dashboard_shows_source_feed_links_and_statuses( app = create_app() app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs" - published_source = create_source( + create_source( name="Published source", slug="published-source", source_type="feed", notes="", spider_arguments="", - enabled=True, + enabled=False, cron_minute="*/5", cron_hour="*", cron_day_of_month="*", @@ -886,7 +745,7 @@ def test_render_dashboard_shows_source_feed_links_and_statuses( cron_month="*", feed_url="https://example.com/published.xml", ) - missing_source = create_source( + create_source( name="Missing source", slug="missing-source", source_type="feed", @@ -905,8 +764,6 @@ def test_render_dashboard_shows_source_feed_links_and_statuses( published_feed = tmp_path / "out" / "feeds" / "published-source" / "feed.rss" published_feed.parent.mkdir(parents=True) published_feed.write_text("\n", encoding="utf-8") - published_job = Job.get(Job.source == published_source) - missing_job = Job.get(Job.source == missing_source) body = str(await render_dashboard(app)) @@ -916,11 +773,6 @@ def test_render_dashboard_shows_source_feed_links_and_statuses( assert "Available" in body assert "Missing" in body assert "Never published" in body - assert "Next run" in body - assert ">Run now<" in body - assert f"/actions/jobs/{published_job.id}/run-now" in body - assert f"/actions/jobs/{missing_job.id}/run-now" in body - assert "data-next-run-at" in body asyncio.run(run())