Compare commits
No commits in common. "ca3d34053fd93884c77c0f33492fd5e805ebdd11" and "c04efeb1894aef3529c90f98ac1576483bb199af" have entirely different histories.
ca3d34053f
...
c04efeb189
11 changed files with 215 additions and 447 deletions
|
|
@ -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
|
- 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 qa
|
uv run pytest
|
||||||
|
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
|
||||||
uv run qa-final
|
nix fmt
|
||||||
|
nix flake check
|
||||||
uv run repub
|
uv run repub
|
||||||
uv run repub crawl -c repub.toml
|
uv run repub crawl -c repub.toml
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,6 @@ 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 = [
|
||||||
|
|
|
||||||
|
|
@ -503,7 +503,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -799,19 +799,8 @@ 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():
|
||||||
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 = (
|
failed_last_day = (
|
||||||
JobExecution.select()
|
JobExecution.select()
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -827,17 +816,9 @@ 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(
|
_project_source_feed(source, output_dir, reference_time)
|
||||||
cast(Job, job),
|
for source in sources
|
||||||
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"])),
|
||||||
|
|
@ -1094,15 +1075,8 @@ def _project_completed_execution(
|
||||||
|
|
||||||
|
|
||||||
def _project_source_feed(
|
def _project_source_feed(
|
||||||
job: Job,
|
source: Source, output_dir: Path, reference_time: datetime
|
||||||
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)
|
||||||
|
|
@ -1112,22 +1086,12 @@ 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": feed_status_label,
|
"feed_status_label": "Available" if feed_exists else "Missing",
|
||||||
"feed_status_tone": feed_status_tone,
|
"feed_status_tone": "done" if feed_exists else "failed",
|
||||||
"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)
|
||||||
|
|
@ -1135,24 +1099,6 @@ 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)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,63 @@
|
||||||
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:
|
||||||
|
|
@ -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, ...]:
|
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 = (
|
||||||
|
|
@ -91,19 +202,6 @@ 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"])],
|
||||||
|
|
@ -123,15 +221,9 @@ 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")),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -143,15 +235,7 @@ 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=(
|
headers=("Source", "Feed URL", "Status", "Last updated", "Disk usage"),
|
||||||
"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"),
|
||||||
)
|
)
|
||||||
|
|
@ -165,11 +249,9 @@ 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="/",
|
||||||
|
|
@ -178,11 +260,7 @@ def dashboard_page_with_data(
|
||||||
content=(
|
content=(
|
||||||
dashboard_header(),
|
dashboard_header(),
|
||||||
operational_snapshot(snapshot=snapshot),
|
operational_snapshot(snapshot=snapshot),
|
||||||
live_work_section(
|
running_executions_table(running_executions=running_items),
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -496,21 +496,46 @@ def _completed_history_section(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def live_work_section(
|
def runs_page(
|
||||||
*,
|
*,
|
||||||
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,
|
||||||
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:
|
) -> 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
|
||||||
)
|
)
|
||||||
return table_section(
|
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_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.",
|
||||||
|
|
@ -522,12 +547,29 @@ def live_work_section(
|
||||||
),
|
),
|
||||||
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",
|
||||||
def relative_time_formatter_script() -> Renderable:
|
empty_message="No jobs are scheduled.",
|
||||||
return h.script[
|
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' });
|
||||||
|
|
@ -571,66 +613,7 @@ 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
38
repub/qa.py
|
|
@ -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)
|
|
||||||
|
|
@ -394,6 +394,9 @@
|
||||||
.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);
|
||||||
}
|
}
|
||||||
|
|
@ -403,6 +406,9 @@
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
@ -735,9 +741,15 @@
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
@ -943,6 +955,11 @@
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -437,7 +437,6 @@ 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"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
|
||||||
]
|
|
||||||
|
|
@ -25,7 +25,6 @@ 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 (
|
||||||
|
|
@ -472,7 +471,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 jobs" in chunk
|
assert b"Running executions" in chunk
|
||||||
await connection.disconnect()
|
await connection.disconnect()
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
@ -573,8 +572,6 @@ 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:
|
||||||
|
|
@ -586,7 +583,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 jobs" in body
|
assert "Running executions" 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
|
||||||
|
|
@ -605,75 +602,12 @@ 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 jobs are running or queued." in body
|
assert "No job executions are running." 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:
|
||||||
|
|
@ -719,7 +653,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)
|
||||||
|
|
||||||
available_source = create_source(
|
create_source(
|
||||||
name="Available source",
|
name="Available source",
|
||||||
slug="available-source",
|
slug="available-source",
|
||||||
source_type="feed",
|
source_type="feed",
|
||||||
|
|
@ -733,7 +667,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",
|
||||||
)
|
)
|
||||||
missing_source = create_source(
|
create_source(
|
||||||
name="Missing source",
|
name="Missing source",
|
||||||
slug="missing-source",
|
slug="missing-source",
|
||||||
source_type="feed",
|
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 = 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], ...],
|
||||||
|
|
@ -775,10 +707,6 @@ 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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -790,80 +718,11 @@ 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:
|
||||||
|
|
@ -872,13 +731,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"
|
||||||
|
|
||||||
published_source = create_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=True,
|
enabled=False,
|
||||||
cron_minute="*/5",
|
cron_minute="*/5",
|
||||||
cron_hour="*",
|
cron_hour="*",
|
||||||
cron_day_of_month="*",
|
cron_day_of_month="*",
|
||||||
|
|
@ -886,7 +745,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",
|
||||||
)
|
)
|
||||||
missing_source = create_source(
|
create_source(
|
||||||
name="Missing source",
|
name="Missing source",
|
||||||
slug="missing-source",
|
slug="missing-source",
|
||||||
source_type="feed",
|
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 = 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))
|
||||||
|
|
||||||
|
|
@ -916,11 +773,6 @@ 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())
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue