Compare commits

..

3 commits

11 changed files with 447 additions and 215 deletions

View file

@ -87,17 +87,16 @@ 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 - Enter the dev environment with `nix develop` if you are not already inside it
- Sync Python dependencies with `uv sync --all-groups`. - Sync Python dependencies with `uv sync --all-groups`.
- Run the app with `uv run repub`. - 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. - 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. - 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 ```sh
uv sync --all-groups uv sync --all-groups
uv run pytest uv run qa
uv run flake8 repub/ tests/
uv run pyright
./scripts/validate-feed out/feeds/mn-cuba/feed.rss ./scripts/validate-feed out/feeds/mn-cuba/feed.rss
nix fmt uv run qa-final
nix flake check
uv run repub uv run repub
uv run repub crawl -c repub.toml uv run repub crawl -c repub.toml
``` ```

View file

@ -27,6 +27,8 @@ dependencies = [
[project.scripts] [project.scripts]
repub = "repub.entrypoint:entrypoint" repub = "repub.entrypoint:entrypoint"
qa = "repub.qa:qa_entrypoint"
qa-final = "repub.qa:qa_final_entrypoint"
[dependency-groups] [dependency-groups]
dev = [ dev = [

View file

@ -503,6 +503,7 @@ def status_badge(*, label: str, tone: str) -> Renderable:
tones = { tones = {
"running": "bg-emerald-100 text-emerald-800", "running": "bg-emerald-100 text-emerald-800",
"scheduled": "bg-sky-100 text-sky-800", "scheduled": "bg-sky-100 text-sky-800",
"queued": "bg-amber-200 text-amber-950",
"idle": "bg-slate-200 text-slate-700", "idle": "bg-slate-200 text-slate-700",
"failed": "bg-rose-100 text-rose-800", "failed": "bg-rose-100 text-rose-800",
"done": "bg-emerald-100 text-emerald-800", "done": "bg-emerald-100 text-emerald-800",

View file

@ -799,8 +799,19 @@ def load_dashboard_view(
reference_time = now or datetime.now(UTC) reference_time = now or datetime.now(UTC)
runs_view = load_runs_view(log_dir=log_dir, now=reference_time) runs_view = load_runs_view(log_dir=log_dir, now=reference_time)
output_dir = Path(log_dir).parent 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(): with database.connection_context():
sources = tuple(Source.select().order_by(Source.name.asc())) jobs = tuple(Job.select(Job, Source).join(Source).order_by(Source.name.asc()))
failed_last_day = ( failed_last_day = (
JobExecution.select() JobExecution.select()
.where( .where(
@ -816,9 +827,17 @@ 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(
for source in sources 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
), ),
"snapshot": { "snapshot": {
"running_now": str(len(runs_view["running"])), "running_now": str(len(runs_view["running"])),
@ -1075,8 +1094,15 @@ def _project_completed_execution(
def _project_source_feed( def _project_source_feed(
source: Source, output_dir: Path, reference_time: datetime 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,
) -> dict[str, object]: ) -> dict[str, object]:
source = cast(Source, job.source)
source_slug = str(source.slug) source_slug = str(source.slug)
source_dir = feed_output_dir(out_dir=output_dir, feed_slug=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) feed_path = feed_output_path(out_dir=output_dir, feed_slug=source_slug)
@ -1086,12 +1112,22 @@ def _project_source_feed(
if feed_exists if feed_exists
else None 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 { return {
"source": source.name, "source": source.name,
"slug": source_slug, "slug": source_slug,
"feed_href": f"/feeds/{source_slug}/feed.rss", "feed_href": f"/feeds/{source_slug}/feed.rss",
"feed_status_label": "Available" if feed_exists else "Missing", "feed_status_label": feed_status_label,
"feed_status_tone": "done" if feed_exists else "failed", "feed_status_tone": feed_status_tone,
"feed_exists": feed_exists, "feed_exists": feed_exists,
"last_updated": ( "last_updated": (
_humanize_relative_time(reference_time, updated_at) _humanize_relative_time(reference_time, updated_at)
@ -1099,6 +1135,24 @@ def _project_source_feed(
else "Never published" else "Never published"
), ),
"last_updated_iso": updated_at.isoformat() if updated_at is not None else None, "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)), "artifact_footprint": _format_bytes(_directory_size(source_dir)),
} }

View file

@ -1,63 +1,22 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from typing import cast
import htpy as h import htpy as h
from htpy import Node, Renderable from htpy import Node, Renderable
from repub.components import ( from repub.components import (
action_button,
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 +80,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 = (
@ -202,6 +91,19 @@ def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]:
if last_updated_iso is not None if last_updated_iso is not None
else h.p(class_="font-medium text-slate-900")[str(source_feed["last_updated"])] 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 ( return (
h.div[ h.div[
h.div(class_="font-semibold text-slate-950")[str(source_feed["source"])], h.div(class_="font-semibold text-slate-950")[str(source_feed["source"])],
@ -221,9 +123,15 @@ def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]:
tone=str(source_feed["feed_status_tone"]), tone=str(source_feed["feed_status_tone"]),
), ),
last_updated, last_updated,
next_run,
h.p(class_="font-medium text-slate-900")[ h.p(class_="font-medium text-slate-900")[
str(source_feed["artifact_footprint"]) 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")),
),
) )
@ -235,7 +143,15 @@ def published_feeds_table(
eyebrow="Published feeds", eyebrow="Published feeds",
title="Published feeds", title="Published feeds",
empty_message="No feeds have been published yet.", empty_message="No feeds have been published yet.",
headers=("Source", "Feed URL", "Status", "Last updated", "Disk usage"), headers=(
"Source",
"Feed URL",
"Status",
"Last updated",
"Next run",
"Disk usage",
"Actions",
),
rows=rows, rows=rows,
actions=muted_action_link(href="/sources", label="Manage sources"), actions=muted_action_link(href="/sources", label="Manage sources"),
) )
@ -249,9 +165,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 +178,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,46 +496,21 @@ 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)
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_items),
content=(
table_section(
eyebrow="Live work", eyebrow="Live work",
title="Running jobs", title="Running jobs",
empty_message="No jobs are running or queued.", empty_message="No jobs are running or queued.",
@ -547,29 +522,12 @@ def runs_page(
), ),
rows=live_rows, rows=live_rows,
row_attrs=live_row_attrs, row_attrs=live_row_attrs,
), actions=actions,
table_section( )
eyebrow="Schedule",
title="Scheduled jobs",
empty_message="No jobs are scheduled.", def relative_time_formatter_script() -> Renderable:
headers=( return h.script[
"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' });
@ -613,7 +571,66 @@ 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(),
), ),
) )

38
repub/qa.py Normal file
View file

@ -0,0 +1,38 @@
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)

View file

@ -394,9 +394,6 @@
.min-w-32 { .min-w-32 {
min-width: calc(var(--spacing) * 32); min-width: calc(var(--spacing) * 32);
} }
.min-w-56 {
min-width: calc(var(--spacing) * 56);
}
.min-w-64 { .min-w-64 {
min-width: calc(var(--spacing) * 64); min-width: calc(var(--spacing) * 64);
} }
@ -406,9 +403,6 @@
.min-w-\[64rem\] { .min-w-\[64rem\] {
min-width: 64rem; min-width: 64rem;
} }
.min-w-\[70rem\] {
min-width: 70rem;
}
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
@ -741,15 +735,9 @@
.pr-5 { .pr-5 {
padding-right: calc(var(--spacing) * 5); padding-right: calc(var(--spacing) * 5);
} }
.pr-6 {
padding-right: calc(var(--spacing) * 6);
}
.pl-3 { .pl-3 {
padding-left: calc(var(--spacing) * 3); padding-left: calc(var(--spacing) * 3);
} }
.pl-4 {
padding-left: calc(var(--spacing) * 4);
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@ -955,11 +943,6 @@
padding-left: calc(var(--spacing) * 3); padding-left: calc(var(--spacing) * 3);
} }
} }
.first\:pl-4 {
&:first-child {
padding-left: calc(var(--spacing) * 4);
}
}
.hover\:bg-amber-300 { .hover\:bg-amber-300 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {

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"]),
) )

67
tests/test_qa.py Normal file
View file

@ -0,0 +1,67 @@
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"),
]

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:
@ -653,7 +719,7 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
app.config["REPUB_LOG_DIR"] = log_dir app.config["REPUB_LOG_DIR"] = log_dir
log_dir.mkdir(parents=True) log_dir.mkdir(parents=True)
create_source( available_source = create_source(
name="Available source", name="Available source",
slug="available-source", slug="available-source",
source_type="feed", source_type="feed",
@ -667,7 +733,7 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
cron_month="*", cron_month="*",
feed_url="https://example.com/available.xml", feed_url="https://example.com/available.xml",
) )
create_source( missing_source = create_source(
name="Missing source", name="Missing source",
slug="missing-source", slug="missing-source",
source_type="feed", source_type="feed",
@ -691,6 +757,8 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
updated_at = reference_time - timedelta(minutes=32) updated_at = reference_time - timedelta(minutes=32)
updated_at_epoch = updated_at.timestamp() updated_at_epoch = updated_at.timestamp()
os.utime(feed_path, (updated_at_epoch, updated_at_epoch)) 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( source_feeds = cast(
tuple[dict[str, object], ...], tuple[dict[str, object], ...],
@ -707,6 +775,10 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
"feed_exists": True, "feed_exists": True,
"last_updated": "32 minutes ago", "last_updated": "32 minutes ago",
"last_updated_iso": updated_at.isoformat(), "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", "artifact_footprint": "3.0 KB",
}, },
{ {
@ -718,11 +790,80 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
"feed_exists": False, "feed_exists": False,
"last_updated": "Never published", "last_updated": "Never published",
"last_updated_iso": None, "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", "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( def test_render_dashboard_shows_source_feed_links_and_statuses(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
@ -731,13 +872,13 @@ def test_render_dashboard_shows_source_feed_links_and_statuses(
app = create_app() app = create_app()
app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs" app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs"
create_source( published_source = create_source(
name="Published source", name="Published source",
slug="published-source", slug="published-source",
source_type="feed", source_type="feed",
notes="", notes="",
spider_arguments="", spider_arguments="",
enabled=False, enabled=True,
cron_minute="*/5", cron_minute="*/5",
cron_hour="*", cron_hour="*",
cron_day_of_month="*", cron_day_of_month="*",
@ -745,7 +886,7 @@ def test_render_dashboard_shows_source_feed_links_and_statuses(
cron_month="*", cron_month="*",
feed_url="https://example.com/published.xml", feed_url="https://example.com/published.xml",
) )
create_source( missing_source = create_source(
name="Missing source", name="Missing source",
slug="missing-source", slug="missing-source",
source_type="feed", source_type="feed",
@ -764,6 +905,8 @@ def test_render_dashboard_shows_source_feed_links_and_statuses(
published_feed = tmp_path / "out" / "feeds" / "published-source" / "feed.rss" published_feed = tmp_path / "out" / "feeds" / "published-source" / "feed.rss"
published_feed.parent.mkdir(parents=True) published_feed.parent.mkdir(parents=True)
published_feed.write_text("<rss/>\n", encoding="utf-8") published_feed.write_text("<rss/>\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)) body = str(await render_dashboard(app))
@ -773,6 +916,11 @@ def test_render_dashboard_shows_source_feed_links_and_statuses(
assert "Available" in body assert "Available" in body
assert "Missing" in body assert "Missing" in body
assert "Never published" 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()) asyncio.run(run())