Add publisher dashboard routes
All checks were successful
buildbot/nix-eval Build done.
buildbot/nix-build Build done.
buildbot/nix-effects Build done.

This commit is contained in:
Abel Luck 2026-06-02 10:18:59 +02:00
parent 96551c2788
commit e4a5246ab3
31 changed files with 1603 additions and 516 deletions

View file

@ -98,27 +98,27 @@ def admin_sidebar(
h.nav(class_="mt-8 space-y-2")[
nav_link(
label="Dashboard",
href="/",
active=current_path == "/",
href="/admin",
active=current_path == "/admin",
badge="Live",
),
nav_link(
label="Sources",
href="/sources",
active=current_path.startswith("/sources"),
href="/admin/sources",
active=current_path.startswith("/admin/sources"),
badge=str(source_count),
),
nav_link(
label="Runs",
href="/runs",
active=current_path.startswith("/runs")
or current_path.startswith("/job/"),
href="/admin/runs",
active=current_path.startswith("/admin/runs")
or current_path.startswith("/admin/job/"),
badge=str(running_count),
),
nav_link(
label="Settings",
href="/settings",
active=current_path.startswith("/settings"),
href="/admin/settings",
active=current_path.startswith("/admin/settings"),
badge="App",
),
],
@ -148,11 +148,18 @@ def header_secondary_link(*, href: str, label: str) -> Renderable:
)[label]
def muted_action_link(*, href: str, label: str) -> Renderable:
return h.a(
href=href,
class_=_button_classes(tone="muted", emphasis="soft"),
)[label]
def muted_action_link(
*, href: str, label: str, target: str | None = None, rel: str | None = None
) -> Renderable:
attributes = {
"href": href,
"class": _button_classes(tone="muted", emphasis="soft"),
}
if target is not None:
attributes["target"] = target
if rel is not None:
attributes["rel"] = rel
return h.a(attributes)[label]
def inline_link(*, href: str, label: str, tone: str = "default") -> Renderable:
@ -225,6 +232,15 @@ def app_shell(
]
def publisher_shell(*, current_path: str, content: Node) -> Renderable:
del current_path
return h.main(id="morph", class_="min-h-screen")[
h.div(class_="px-4 py-4 sm:px-4 lg:px-5 lg:py-4")[
h.div(class_="mx-auto max-w-7xl space-y-4")[content]
],
]
def page_shell(
*,
current_path: str,
@ -269,7 +285,7 @@ def section_card(*, content: Node) -> Renderable:
def table_section(
*,
eyebrow: str | None = None,
title: str,
title: str | None,
subtitle: str | None = None,
empty_message: str,
headers: tuple[str, ...],
@ -315,8 +331,16 @@ def table_section(
)[empty_message]
]
has_heading = (
eyebrow is not None
or title is not None
or subtitle is not None
or actions is not None
)
return h.section[
h.div(
has_heading
and h.div(
class_="flex flex-col gap-2.5 sm:flex-row sm:items-end sm:justify-between"
)[
h.div[
@ -324,13 +348,18 @@ def table_section(
and h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)[eyebrow],
h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[title],
title
and h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[title],
subtitle and h.p(class_="mt-1 text-sm text-slate-600")[subtitle],
],
actions,
],
h.div(
class_="mt-3 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200"
class_=(
"overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200"
if not has_heading
else "mt-3 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200"
)
)[
h.div(class_="overflow-x-auto")[
h.table(

View file

@ -63,6 +63,16 @@ def parse_args(argv: list[str] | None = None) -> tuple[str, argparse.Namespace]:
action="store_true",
help="Serve published feeds from /feeds for local development",
)
serve_parser.add_argument(
"--reload",
action="store_true",
help="Reload the web UI when source files change",
)
serve_parser.add_argument(
"--reader-app-url",
default=os.environ.get("REPUBLISHER_READER_APP_URL", ""),
help="URL of the AnyNews reader application linked from the publisher UI",
)
crawl_parser = subparsers.add_parser("crawl", help="Run the feed crawler once")
crawl_parser.add_argument(
@ -144,16 +154,18 @@ def _install_signal_handlers(stop_event: asyncio.Event) -> None:
signal.signal(signum, request_stop)
async def _serve_app(*, host: str, port: int, dev_mode: bool) -> None:
async def _serve_app(
*, host: str, port: int, dev_mode: bool, reload: bool, reader_app_url: str | None
) -> None:
stop_event = asyncio.Event()
_install_signal_handlers(stop_event)
app = create_app(dev_mode=dev_mode)
app = create_app(dev_mode=dev_mode, reader_app_url=reader_app_url)
app.extensions[SHUTDOWN_EVENT_KEY] = stop_event
config = HypercornConfig()
config.bind = [f"{host}:{port}"]
config.use_reloader = False
config.use_reloader = reload
config.accesslog = "-"
config.errorlog = "-"
@ -203,7 +215,15 @@ def entrypoint(argv: list[str] | None = None) -> int:
return 2
with suppress(KeyboardInterrupt):
asyncio.run(_serve_app(host=args.host, port=port, dev_mode=bool(args.dev_mode)))
asyncio.run(
_serve_app(
host=args.host,
port=port,
dev_mode=bool(args.dev_mode),
reload=bool(args.reload),
reader_app_url=args.reader_app_url,
)
)
return 0

View file

@ -668,6 +668,7 @@ def load_runs_view(
now: datetime | None = None,
completed_page: int = 1,
completed_page_size: int = COMPLETED_EXECUTION_PAGE_SIZE,
path_prefix: str = "",
) -> RunsView:
reference_time = now or datetime.now(UTC)
resolved_log_dir = Path(log_dir)
@ -727,6 +728,7 @@ def load_runs_view(
execution,
resolved_log_dir,
reference_time,
path_prefix=path_prefix,
queued_follow_up=queued_by_job.get(
_job_id(cast(Job, execution.job))
),
@ -739,6 +741,7 @@ def load_runs_view(
reference_time,
position=position,
total_count=len(queued_executions),
path_prefix=path_prefix,
)
for position, execution in enumerate(queued_executions, start=1)
),
@ -748,12 +751,16 @@ def load_runs_view(
running_by_job.get(job.id),
queued_by_job.get(job.id),
reference_time,
path_prefix=path_prefix,
)
for job in jobs
),
"completed": tuple(
_project_completed_execution(
execution, resolved_log_dir, reference_time
execution,
resolved_log_dir,
reference_time,
path_prefix=path_prefix,
)
for execution in completed_executions
),
@ -801,10 +808,14 @@ def clear_completed_executions(*, log_dir: str | Path) -> int:
def load_dashboard_view(
*, log_dir: str | Path, now: datetime | None = None
*, log_dir: str | Path, now: datetime | None = None, path_prefix: str = ""
) -> dict[str, object]:
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,
path_prefix=path_prefix,
)
output_dir = Path(log_dir).parent
running_by_job_id = {
int(cast(int, execution["job_id"])): execution
@ -842,6 +853,7 @@ def load_dashboard_view(
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))),
path_prefix=path_prefix,
)
for job in jobs
),
@ -855,9 +867,9 @@ def load_dashboard_view(
def load_execution_log_view(
*, log_dir: str | Path, job_id: int, execution_id: int
*, log_dir: str | Path, job_id: int, execution_id: int, path_prefix: str = ""
) -> ExecutionLogView:
route = f"/job/{job_id}/execution/{execution_id}/logs"
route = _path(path_prefix, f"/job/{job_id}/execution/{execution_id}/logs")
with database.reader():
execution_primary_key = getattr(JobExecution, "_meta").primary_key
execution = (
@ -924,11 +936,16 @@ def _scheduler_job_id(job_id: int) -> str:
return f"{SCHEDULER_JOB_PREFIX}{job_id}"
def _path(prefix: str, path: str) -> str:
return f"{prefix.rstrip('/')}{path}" if prefix else path
def _project_running_execution(
execution: JobExecution,
log_dir: Path,
reference_time: datetime,
*,
path_prefix: str,
queued_follow_up: JobExecution | None = None,
) -> dict[str, object]:
job = cast(Job, execution.job)
@ -957,13 +974,16 @@ def _project_running_execution(
if execution.stop_requested_at
else "streaming stats from worker"
),
"log_href": f"/job/{job_id}/execution/{execution_id}/logs",
"log_href": _path(path_prefix, f"/job/{job_id}/execution/{execution_id}/logs"),
"log_exists": artifacts.log_path.exists(),
"cancel_label": "Cancel" if queued_follow_up is not None else "Stop",
"cancel_post_path": (
f"/actions/queued-executions/{_execution_id(queued_follow_up)}/cancel"
_path(
path_prefix,
f"/actions/queued-executions/{_execution_id(queued_follow_up)}/cancel",
)
if queued_follow_up is not None
else f"/actions/executions/{execution_id}/cancel"
else _path(path_prefix, f"/actions/executions/{execution_id}/cancel")
),
}
@ -974,6 +994,7 @@ def _project_queued_execution(
*,
position: int,
total_count: int,
path_prefix: str,
) -> dict[str, object]:
job = cast(Job, execution.job)
queued_at = _coerce_datetime(cast(datetime | str, execution.created_at))
@ -990,19 +1011,28 @@ def _project_queued_execution(
"status_tone": "idle",
"run_label": "Queued",
"run_disabled": True,
"run_post_path": f"/actions/jobs/{_job_id(job)}/run-now",
"cancel_post_path": (f"/actions/queued-executions/{execution_id}/cancel"),
"run_post_path": _path(path_prefix, f"/actions/jobs/{_job_id(job)}/run-now"),
"cancel_post_path": _path(
path_prefix,
f"/actions/queued-executions/{execution_id}/cancel",
),
"move_up_disabled": position == 1,
"move_up_post_path": (
None
if position == 1
else f"/actions/queued-executions/{execution_id}/move-up"
else _path(
path_prefix,
f"/actions/queued-executions/{execution_id}/move-up",
)
),
"move_down_disabled": position == total_count,
"move_down_post_path": (
None
if position == total_count
else f"/actions/queued-executions/{execution_id}/move-down"
else _path(
path_prefix,
f"/actions/queued-executions/{execution_id}/move-down",
)
),
}
@ -1012,6 +1042,8 @@ def _project_upcoming_job(
running_execution: JobExecution | None,
queued_execution: JobExecution | None,
reference_time: datetime,
*,
path_prefix: str,
) -> dict[str, object]:
job_id = _job_id(job)
trigger = _job_trigger(job)
@ -1051,14 +1083,21 @@ def _project_upcoming_job(
"run_reason": run_reason,
"toggle_label": "Disable" if job.enabled else "Enable",
"toggle_enabled": not job.enabled,
"run_post_path": f"/actions/jobs/{job_id}/run-now",
"toggle_post_path": f"/actions/jobs/{job_id}/toggle-enabled",
"delete_post_path": f"/actions/jobs/{job_id}/delete",
"run_post_path": _path(path_prefix, f"/actions/jobs/{job_id}/run-now"),
"toggle_post_path": _path(
path_prefix,
f"/actions/jobs/{job_id}/toggle-enabled",
),
"delete_post_path": _path(path_prefix, f"/actions/jobs/{job_id}/delete"),
}
def _project_completed_execution(
execution: JobExecution, log_dir: Path, reference_time: datetime
execution: JobExecution,
log_dir: Path,
reference_time: datetime,
*,
path_prefix: str,
) -> dict[str, object]:
job = cast(Job, execution.job)
job_id = _job_id(job)
@ -1100,7 +1139,7 @@ def _project_completed_execution(
else "Worker exited with failure"
)
),
"log_href": f"/job/{job_id}/execution/{execution_id}/logs",
"log_href": _path(path_prefix, f"/job/{job_id}/execution/{execution_id}/logs"),
"log_exists": artifacts.log_path.exists(),
}
@ -1113,6 +1152,7 @@ def _project_source_feed(
running_execution: dict[str, object] | None = None,
queued_execution: dict[str, object] | None = None,
upcoming_job: dict[str, object] | None = None,
path_prefix: str,
) -> dict[str, object]:
source = cast(Source, job.source)
source_slug = str(source.slug)
@ -1163,7 +1203,7 @@ def _project_source_feed(
"run_post_path": (
str(upcoming_job["run_post_path"])
if upcoming_job is not None
else f"/actions/jobs/{_job_id(job)}/run-now"
else _path(path_prefix, f"/actions/jobs/{_job_id(job)}/run-now")
),
"artifact_footprint": _format_bytes(_directory_size(source_dir)),
}

View file

@ -9,7 +9,6 @@ from htpy import Node, Renderable
from repub.components import (
action_button,
app_shell,
header_action_link,
inline_link,
muted_action_link,
stat_card,
@ -19,7 +18,9 @@ from repub.components import (
from repub.pages.runs import live_work_section, relative_time_formatter_script
def dashboard_header() -> Renderable:
def dashboard_header(
*, path_prefix: str = "/admin", reader_app_url: str | None = None
) -> Renderable:
return h.section[
h.div(
class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
@ -30,8 +31,20 @@ def dashboard_header() -> Renderable:
],
],
h.div(class_="flex flex-wrap gap-2")[
header_action_link(href="/sources/create", label="Create source"),
muted_action_link(href="/sources", label="View sources"),
(
muted_action_link(
href=reader_app_url,
label="Open AnyNews",
target="_blank",
rel="noopener noreferrer",
)
if reader_app_url is not None
else None
),
muted_action_link(
href=f"{path_prefix}/publisher",
label="Publisher View",
),
],
]
]
@ -124,9 +137,6 @@ def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]:
),
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"]),
@ -136,12 +146,15 @@ def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]:
def published_feeds_table(
*, source_feeds: tuple[Mapping[str, object], ...] | None = None
*,
source_feeds: tuple[Mapping[str, object], ...] | None = None,
manage_sources_href: str | None = "/admin/sources",
show_heading: bool = True,
) -> Renderable:
rows = tuple(_source_feed_row(source_feed) for source_feed in (source_feeds or ()))
return table_section(
eyebrow="Published feeds",
title="Published feeds",
eyebrow="Published feeds" if show_heading else None,
title="Published feeds" if show_heading else None,
empty_message="No feeds have been published yet.",
headers=(
"Source",
@ -149,11 +162,14 @@ def published_feeds_table(
"Status",
"Last updated",
"Next run",
"Disk usage",
"Actions",
),
rows=rows,
actions=muted_action_link(href="/sources", label="Manage sources"),
actions=(
muted_action_link(href=manage_sources_href, label="Manage sources")
if manage_sources_href is not None
else None
),
)
@ -163,26 +179,32 @@ def dashboard_page() -> Renderable:
def dashboard_page_with_data(
*,
current_path: str = "/admin",
path_prefix: str = "/admin",
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,
reader_app_url: str | None = None,
) -> Renderable:
running_items = running_executions or ()
queued_items = queued_executions or ()
source_items = source_feeds or ()
return app_shell(
current_path="/",
current_path=current_path,
source_count=len(source_items),
running_count=len(running_items),
content=(
dashboard_header(),
dashboard_header(path_prefix=path_prefix, reader_app_url=reader_app_url),
operational_snapshot(snapshot=snapshot),
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,
manage_sources_href=f"{path_prefix}/sources",
),
relative_time_formatter_script(),
),
)

View file

@ -1,19 +1,57 @@
from __future__ import annotations
from collections.abc import Mapping
import htpy as h
from htpy import Renderable
from repub.components import app_shell
from repub.components import publisher_shell
from repub.pages.dashboard import published_feeds_table
from repub.pages.runs import live_work_section, relative_time_formatter_script
def publisher_page(*, current_path: str) -> Renderable:
return app_shell(
def publisher_page(
*,
current_path: str,
source_feeds: tuple[Mapping[str, object], ...] | None = None,
running_executions: tuple[Mapping[str, object], ...] | None = None,
queued_executions: tuple[Mapping[str, object], ...] | None = None,
reader_app_url: str | None = None,
) -> Renderable:
return publisher_shell(
current_path=current_path,
content=(
h.section[
h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[
"Hello publishers"
]
h.div(
class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
)[
h.div[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)["Republisher"],
h.h1(
class_="mt-1 text-3xl font-semibold tracking-tight text-slate-950"
)["Published feeds"],
],
reader_app_url
and h.a(
href=reader_app_url,
target="_blank",
rel="noopener noreferrer",
class_="inline-flex shrink-0 items-center justify-center rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-amber-300",
)["Open AnyNews"],
],
],
published_feeds_table(
source_feeds=source_feeds,
manage_sources_href=None,
show_heading=False,
),
live_work_section(
running_executions=running_executions,
queued_executions=queued_executions,
show_row_actions=False,
),
relative_time_formatter_script(),
),
)

View file

@ -190,7 +190,9 @@ def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]:
}
def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
def _running_row(
execution: Mapping[str, object], *, show_row_actions: bool = True
) -> tuple[Node, ...]:
started_at = _maybe_text(execution, "started_at_iso")
started_at_label: Node = h.p(class_="truncate")[_text(execution, "started_at")]
if started_at is not None:
@ -203,7 +205,7 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
class_="truncate",
)[_text(execution, "started_at")]
return (
cells = (
_live_status_cell(
execution_id=_text(execution, "execution_id"),
status=_text(execution, "status"),
@ -222,6 +224,11 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")],
h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "worker")],
],
)
if not show_row_actions:
return cells
return (
*cells,
h.div(class_="flex flex-wrap items-center gap-2")[
inline_link(
href=_text(execution, "log_href"),
@ -237,7 +244,9 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
)
def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
def _queued_row(
execution: Mapping[str, object], *, show_row_actions: bool = True
) -> tuple[Node, ...]:
queued_at = _maybe_text(execution, "queued_at_iso")
queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")]
if queued_at is not None:
@ -250,7 +259,7 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
class_="truncate",
)[_text(execution, "queued_at")]
return (
cells = (
_live_status_cell(
execution_id=_text(execution, "execution_id"),
status="Queued",
@ -270,6 +279,11 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
],
h.p(class_="mt-0.5 text-xs text-slate-500")["waiting for capacity"],
],
)
if not show_row_actions:
return cells
return (
*cells,
h.div(class_="flex flex-wrap items-center gap-2")[
action_button(
label=_queue_icon("up"),
@ -362,8 +376,8 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
)
def _completed_page_action_path(page: int) -> str:
return f"/actions/runs/completed-page/{page}"
def _completed_page_action_path(page: int, *, path_prefix: str = "/admin") -> str:
return f"{path_prefix}/actions/runs/completed-page/{page}"
def _pagination_button(
@ -372,9 +386,12 @@ def _pagination_button(
page: int,
current: bool = False,
class_name: str,
path_prefix: str = "/admin",
) -> Renderable:
attributes = {
"data-on:pointerdown": f"@post('{_completed_page_action_path(page)}')",
"data-on:pointerdown": (
f"@post('{_completed_page_action_path(page, path_prefix=path_prefix)}')"
),
}
if current:
attributes["aria-current"] = "page"
@ -391,6 +408,7 @@ def _completed_history_pagination(
completed_page_size: int,
completed_total_count: int,
completed_total_pages: int,
path_prefix: str = "/admin",
) -> Renderable | None:
if completed_total_count <= completed_page_size:
return None
@ -410,6 +428,7 @@ def _completed_history_pagination(
_pagination_button(
label="Previous",
page=max(1, completed_page - 1),
path_prefix=path_prefix,
class_name=(
"relative inline-flex items-center rounded-xl border border-slate-200 "
"bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-stone-50"
@ -418,6 +437,7 @@ def _completed_history_pagination(
_pagination_button(
label="Next",
page=min(completed_total_pages, completed_page + 1),
path_prefix=path_prefix,
class_name=(
"relative ml-3 inline-flex items-center rounded-xl border border-slate-200 "
"bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-stone-50"
@ -443,6 +463,7 @@ def _completed_history_pagination(
label=str(page_number),
page=page_number,
current=page_number == completed_page,
path_prefix=path_prefix,
class_name=(
"relative z-10 inline-flex items-center bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950"
if page_number == completed_page
@ -463,12 +484,14 @@ def _completed_history_section(
completed_page_size: int,
completed_total_count: int,
completed_total_pages: int,
path_prefix: str = "/admin",
) -> Renderable:
pagination = _completed_history_pagination(
completed_page=completed_page,
completed_page_size=completed_page_size,
completed_total_count=completed_total_count,
completed_total_pages=completed_total_pages,
path_prefix=path_prefix,
)
return h.section[
table_section(
@ -486,7 +509,7 @@ def _completed_history_section(
action_button(
label="Clear history",
tone="danger",
post_path="/actions/completed-executions/clear",
post_path=f"{path_prefix}/actions/completed-executions/clear",
)
if completed_total_count > 0
else None
@ -501,11 +524,18 @@ def live_work_section(
running_executions: tuple[Mapping[str, object], ...] | None = None,
queued_executions: tuple[Mapping[str, object], ...] | None = None,
actions: Node | None = None,
show_row_actions: bool = True,
) -> Renderable:
running_items = running_executions or ()
queued_items = queued_executions or ()
running_rows = tuple(_running_row(execution) for execution in running_items)
queued_rows = tuple(_queued_row(execution) for execution in queued_items)
running_rows = tuple(
_running_row(execution, show_row_actions=show_row_actions)
for execution in running_items
)
queued_rows = tuple(
_queued_row(execution, show_row_actions=show_row_actions)
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
@ -515,10 +545,9 @@ def live_work_section(
title="Running jobs",
empty_message="No jobs are running or queued.",
headers=(
"State",
"Source",
"Details",
"Actions",
("State", "Source", "Details", "Actions")
if show_row_actions
else ("State", "Source", "Details")
),
rows=live_rows,
row_attrs=live_row_attrs,
@ -585,6 +614,7 @@ def runs_page(
completed_total_count: int | None = None,
completed_total_pages: int | None = None,
source_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
upcoming_items = upcoming_jobs or ()
completed_items = completed_executions or ()
@ -598,10 +628,13 @@ def runs_page(
)
return page_shell(
current_path="/runs",
current_path=f"{path_prefix}/runs",
eyebrow="Execution control",
title="Runs",
actions=muted_action_link(href="/sources", label="Back to sources"),
actions=muted_action_link(
href=f"{path_prefix}/sources",
label="Back to sources",
),
source_count=source_count,
running_count=len(running_executions or ()),
content=(
@ -629,6 +662,7 @@ def runs_page(
completed_page_size=completed_page_size,
completed_total_count=resolved_completed_total_count,
completed_total_pages=resolved_completed_total_pages,
path_prefix=path_prefix,
),
relative_time_formatter_script(),
),
@ -640,6 +674,7 @@ def execution_logs_page(
job_id: int,
execution_id: int,
log_view: Mapping[str, object] | None = None,
path_prefix: str = "/admin",
) -> Renderable:
if log_view is None:
log_view = {
@ -664,10 +699,10 @@ def execution_logs_page(
)
return page_shell(
current_path=f"/job/{job_id}/execution/{execution_id}/logs",
current_path=f"{path_prefix}/job/{job_id}/execution/{execution_id}/logs",
eyebrow="Execution log",
title=_text(log_view, "title"),
actions=muted_action_link(href="/runs", label="Back to runs"),
actions=muted_action_link(href=f"{path_prefix}/runs", label="Back to runs"),
content=(
section_card(
content=(
@ -677,7 +712,7 @@ def execution_logs_page(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)["Route"],
h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[
f"/job/{job_id}/execution/{execution_id}/logs"
f"{path_prefix}/job/{job_id}/execution/{execution_id}/logs"
],
],
status_badge(

View file

@ -23,12 +23,13 @@ def _value(settings: Mapping[str, object] | None, key: str, default: str = "") -
def settings_page(
*,
settings: Mapping[str, object] | None = None,
action_path: str = "/actions/settings",
action_path: str = "/admin/actions/settings",
source_count: int = 0,
running_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
return page_shell(
current_path="/settings",
current_path=f"{path_prefix}/settings",
eyebrow="Configuration",
title="Settings",
description="Global runtime controls for the republisher.",
@ -85,7 +86,10 @@ def settings_page(
),
],
h.div(class_="flex flex-wrap justify-end gap-3 pt-2")[
muted_action_link(href="/", label="Back to dashboard"),
muted_action_link(
href=path_prefix,
label="Back to dashboard",
),
action_button(
label="Save settings",
tone="dark",

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import htpy as h
from htpy import Node, Renderable
from repub.components import app_shell
from repub.components import app_shell, publisher_shell
ON_LOAD_JS = (
"@post(window.location.pathname + "
@ -17,6 +17,7 @@ TAB_ID_JS = "self.crypto.randomUUID().substring(0,8)"
def shim_page(
*, datastar_src: str, current_path: str, head: Node | None = None
) -> Renderable:
shell = app_shell if current_path.startswith("/admin") else publisher_shell
return h.html(lang="en")[
h.head[
h.meta(charset="UTF-8"),
@ -33,7 +34,7 @@ def shim_page(
}
),
h.noscript["Your browser does not support JavaScript!"],
app_shell(
shell(
current_path=current_path,
content=(
h.section[

View file

@ -55,7 +55,9 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo
return bool(value)
def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]:
def _source_row(
source: Mapping[str, object], *, path_prefix: str = "/admin"
) -> tuple[Node, ...]:
return (
h.div[
h.div(class_="font-semibold text-slate-950")[str(source["name"])],
@ -78,28 +80,37 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]:
],
h.div(class_="flex flex-nowrap items-center gap-3 whitespace-nowrap")[
inline_link(
href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber"
href=f"{path_prefix}/sources/{source['slug']}/edit",
label="Edit",
tone="amber",
),
action_button(
label="Delete",
tone="danger",
post_path=f"/actions/sources/{source['slug']}/delete",
post_path=f"{path_prefix}/actions/sources/{source['slug']}/delete",
),
],
)
def sources_table(
*, sources: tuple[Mapping[str, object], ...] | None = None
*,
sources: tuple[Mapping[str, object], ...] | None = None,
path_prefix: str = "/admin",
) -> Renderable:
rows = tuple(_source_row(source) for source in (sources or ()))
rows = tuple(
_source_row(source, path_prefix=path_prefix) for source in (sources or ())
)
return table_section(
eyebrow="Inventory",
title="Sources",
empty_message="No sources yet.",
headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"),
rows=rows,
actions=header_action_link(href="/sources/create", label="Create source"),
actions=header_action_link(
href=f"{path_prefix}/sources/create",
label="Create source",
),
)
@ -107,15 +118,16 @@ def sources_page(
*,
sources: tuple[Mapping[str, object], ...] | None = None,
running_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
source_items = sources or ()
return page_shell(
current_path="/sources",
current_path=f"{path_prefix}/sources",
eyebrow="Source management",
title="Sources",
source_count=len(source_items),
running_count=running_count,
content=sources_table(sources=source_items),
content=sources_table(sources=source_items, path_prefix=path_prefix),
)
@ -124,6 +136,7 @@ def source_form(
mode: str,
action_path: str,
source: Mapping[str, object] | None = None,
path_prefix: str = "/admin",
) -> Renderable:
source_type = _value(source, "source_type", "pangea")
slug = _value(source, "slug")
@ -397,7 +410,7 @@ def source_form(
h.div(
class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-6"
)[
muted_action_link(href="/sources", label="Cancel"),
muted_action_link(href=f"{path_prefix}/sources", label="Cancel"),
action_button(
label=submit_label,
tone="dark",
@ -412,22 +425,27 @@ def source_form(
def create_source_page(
*,
action_path: str = "/actions/sources/create",
action_path: str = "/admin/actions/sources/create",
source_count: int = 0,
running_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
actions = (
muted_action_link(href="/sources", label="Back to sources"),
header_action_link(href="/runs", label="View runs"),
muted_action_link(href=f"{path_prefix}/sources", label="Back to sources"),
header_action_link(href=f"{path_prefix}/runs", label="View runs"),
)
return page_shell(
current_path="/sources/create",
current_path=f"{path_prefix}/sources/create",
eyebrow="Source creation",
title="Create source",
actions=actions,
source_count=source_count,
running_count=running_count,
content=source_form(mode="create", action_path=action_path),
content=source_form(
mode="create",
action_path=action_path,
path_prefix=path_prefix,
),
)
@ -438,17 +456,23 @@ def edit_source_page(
action_path: str,
source_count: int = 0,
running_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
actions = (
muted_action_link(href="/sources", label="Back to sources"),
header_action_link(href="/runs", label="View runs"),
muted_action_link(href=f"{path_prefix}/sources", label="Back to sources"),
header_action_link(href=f"{path_prefix}/runs", label="View runs"),
)
return page_shell(
current_path=f"/sources/{slug}/edit",
current_path=f"{path_prefix}/sources/{slug}/edit",
eyebrow="Source editing",
title="Edit source",
actions=actions,
source_count=source_count,
running_count=running_count,
content=source_form(mode="edit", action_path=action_path, source=source),
content=source_form(
mode="edit",
action_path=action_path,
source=source,
path_prefix=path_prefix,
),
)

33
repub/web/__init__.py Normal file
View file

@ -0,0 +1,33 @@
from repub.web.app import (
SHUTDOWN_EVENT_KEY,
create_app,
get_job_runtime,
get_refresh_broker,
get_tab_state_store,
render_create_source,
render_dashboard,
render_edit_source,
render_execution_logs,
render_publisher,
render_runs,
render_settings,
render_sources,
versioned_static_asset_href,
)
__all__ = [
"SHUTDOWN_EVENT_KEY",
"create_app",
"get_job_runtime",
"get_refresh_broker",
"get_tab_state_store",
"render_create_source",
"render_dashboard",
"render_edit_source",
"render_execution_logs",
"render_publisher",
"render_runs",
"render_settings",
"render_sources",
"versioned_static_asset_href",
]

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from quart import Quart
from repub.web.admin.actions import register_admin_actions
from repub.web.admin.pages.dashboard import register_dashboard_routes
from repub.web.admin.pages.logs import register_log_routes
from repub.web.admin.pages.runs import register_runs_routes
from repub.web.admin.pages.settings import register_settings_routes
from repub.web.admin.pages.sources import register_source_routes
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_admin_routes(app: Quart, *, admin_required: RouteGuard) -> None:
register_dashboard_routes(app, admin_required=admin_required)
register_source_routes(app, admin_required=admin_required)
register_runs_routes(app, admin_required=admin_required)
register_settings_routes(app, admin_required=admin_required)
register_log_routes(app, admin_required=admin_required)
register_admin_actions(app, admin_required=admin_required)

181
repub/web/admin/actions.py Normal file
View file

@ -0,0 +1,181 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any, cast
from datastar_py import ServerSentEventGenerator as SSE
from datastar_py.quart import DatastarResponse, read_signals
from peewee import IntegrityError
from quart import Quart, Response
from repub.jobs import clear_completed_executions
from repub.model import (
create_source,
delete_job_source,
delete_source,
load_job_enabled,
save_setting,
source_slug_exists,
update_source,
)
from repub.web.app import (
RUNS_TAB_STATE_KEY,
_read_optional_signals,
_read_tab_id,
get_job_runtime,
get_tab_state_store,
run_job_now_response,
trigger_refresh,
validate_settings_form,
validate_source_form,
)
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_admin_actions(app: Quart, *, admin_required: RouteGuard) -> None:
@app.post("/admin/actions/sources/create")
@admin_required
async def admin_create_source_action() -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
source, error = validate_source_form(
signals,
slug_exists=source_slug_exists,
)
if error is not None:
return DatastarResponse(
SSE.patch_signals({"_formError": error, "_formSuccess": ""})
)
assert source is not None
try:
create_source(**source)
except IntegrityError:
return DatastarResponse(
SSE.patch_signals(
{"_formError": "Slug must be unique.", "_formSuccess": ""}
)
)
get_job_runtime(app).sync_jobs()
trigger_refresh(app)
return DatastarResponse(SSE.redirect("/admin/sources"))
@app.post("/admin/actions/sources/<string:slug>/edit")
@admin_required
async def admin_edit_source_action(slug: str) -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
source, error = validate_source_form(
signals,
slug_exists=lambda candidate: candidate != slug
and source_slug_exists(candidate),
immutable_slug=slug,
)
if error is not None:
return DatastarResponse(
SSE.patch_signals({"_formError": error, "_formSuccess": ""})
)
assert source is not None
if update_source(slug, **source) is None:
return DatastarResponse(
SSE.patch_signals(
{"_formError": "Source does not exist.", "_formSuccess": ""}
)
)
get_job_runtime(app).sync_jobs()
trigger_refresh(app)
return DatastarResponse(SSE.redirect("/admin/sources"))
@app.post("/admin/actions/sources/<string:slug>/delete")
@admin_required
async def admin_delete_source_action(slug: str) -> Response:
delete_source(slug)
get_job_runtime(app).sync_jobs()
trigger_refresh(app)
return Response(status=204)
@app.post("/admin/actions/settings")
@admin_required
async def admin_update_settings_action() -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
settings, error = validate_settings_form(signals)
if error is not None:
return DatastarResponse(
SSE.patch_signals({"_formError": error, "_formSuccess": ""})
)
assert settings is not None
save_setting("max_concurrent_jobs", settings["max_concurrent_jobs"])
save_setting("feed_url", settings["feed_url"])
trigger_refresh(app)
return DatastarResponse(SSE.redirect("/admin/settings"))
@app.post("/admin/actions/runs/completed-page/<int:page>")
@admin_required
async def admin_set_completed_runs_page_action(page: int) -> Response:
signals = await _read_optional_signals()
tab_id = _read_tab_id(signals)
if tab_id is None:
return Response(status=400)
get_tab_state_store(app).update_page_state(
tab_id,
RUNS_TAB_STATE_KEY,
lambda state: {**state, "completed_page": max(1, page)},
)
trigger_refresh(app, tab_id=tab_id)
return Response(status=204)
@app.post("/admin/actions/jobs/<int:job_id>/run-now")
@admin_required
async def admin_run_job_now_action(job_id: int) -> Response:
return run_job_now_response(app, job_id)
@app.post("/admin/actions/jobs/<int:job_id>/toggle-enabled")
@admin_required
async def admin_toggle_job_enabled_action(job_id: int) -> Response:
enabled = load_job_enabled(job_id)
if enabled is not None:
get_job_runtime(app).set_job_enabled(job_id, enabled=not enabled)
trigger_refresh(app)
return Response(status=204)
@app.post("/admin/actions/jobs/<int:job_id>/delete")
@admin_required
async def admin_delete_job_action(job_id: int) -> Response:
delete_job_source(job_id)
get_job_runtime(app).sync_jobs()
trigger_refresh(app)
return Response(status=204)
@app.post("/admin/actions/executions/<int:execution_id>/cancel")
@admin_required
async def admin_cancel_execution_action(execution_id: int) -> Response:
get_job_runtime(app).request_execution_cancel(execution_id)
trigger_refresh(app)
return Response(status=204)
@app.post("/admin/actions/queued-executions/<int:execution_id>/cancel")
@admin_required
async def admin_cancel_queued_execution_action(execution_id: int) -> Response:
get_job_runtime(app).cancel_queued_execution(execution_id)
trigger_refresh(app)
return Response(status=204)
@app.post("/admin/actions/queued-executions/<int:execution_id>/move-up")
@admin_required
async def admin_move_queued_execution_up_action(execution_id: int) -> Response:
get_job_runtime(app).move_queued_execution(execution_id, direction="up")
return Response(status=204)
@app.post("/admin/actions/queued-executions/<int:execution_id>/move-down")
@admin_required
async def admin_move_queued_execution_down_action(execution_id: int) -> Response:
get_job_runtime(app).move_queued_execution(execution_id, direction="down")
return Response(status=204)
@app.post("/admin/actions/completed-executions/clear")
@admin_required
async def admin_clear_completed_executions_action() -> Response:
clear_completed_executions(log_dir=app.config["REPUB_LOG_DIR"])
trigger_refresh(app)
return Response(status=204)

View file

@ -0,0 +1 @@
from __future__ import annotations

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from datastar_py.quart import DatastarResponse
from quart import Quart, Response
from repub.web.app import _page_patch_response, _shim_page_response, render_dashboard
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_dashboard_routes(app: Quart, *, admin_required: RouteGuard) -> None:
@app.get("/admin")
@admin_required
async def admin_dashboard_home() -> Response:
return _shim_page_response(current_path="/admin", static_prefix="/admin")
@app.post("/admin")
@admin_required
async def admin_dashboard_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_dashboard(app, path_prefix="/admin"),
)

View file

@ -0,0 +1,42 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from datastar_py.quart import DatastarResponse
from htpy import Renderable
from quart import Quart, Response
from repub.web.app import (
_page_patch_response,
_shim_page_response,
render_execution_logs,
)
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_log_routes(app: Quart, *, admin_required: RouteGuard) -> None:
@app.get("/admin/job/<int:job_id>/execution/<int:execution_id>/logs")
@admin_required
async def admin_logs_home(job_id: int, execution_id: int) -> Response:
return _shim_page_response(
current_path=f"/admin/job/{job_id}/execution/{execution_id}/logs",
static_prefix="/admin",
)
@app.post("/admin/job/<int:job_id>/execution/<int:execution_id>/logs")
@admin_required
async def admin_logs_patch(
job_id: int,
execution_id: int,
) -> DatastarResponse:
async def render() -> Renderable:
return await render_execution_logs(
app,
job_id=job_id,
execution_id=execution_id,
path_prefix="/admin",
)
return await _page_patch_response(app, lambda _tab_id: render())

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from datastar_py.quart import DatastarResponse
from quart import Quart, Response
from repub.web.app import _page_patch_response, _shim_page_response, render_runs
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_runs_routes(app: Quart, *, admin_required: RouteGuard) -> None:
@app.get("/admin/runs")
@admin_required
async def admin_runs_home() -> Response:
return _shim_page_response(current_path="/admin/runs", static_prefix="/admin")
@app.post("/admin/runs")
@admin_required
async def admin_runs_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda tab_id: render_runs(app, tab_id=tab_id, path_prefix="/admin"),
)

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from datastar_py.quart import DatastarResponse
from quart import Quart, Response
from repub.web.app import _page_patch_response, _shim_page_response, render_settings
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_settings_routes(app: Quart, *, admin_required: RouteGuard) -> None:
@app.get("/admin/settings")
@admin_required
async def admin_settings_home() -> Response:
return _shim_page_response(
current_path="/admin/settings",
static_prefix="/admin",
)
@app.post("/admin/settings")
@admin_required
async def admin_settings_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_settings(app))

View file

@ -0,0 +1,48 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from datastar_py.quart import DatastarResponse
from quart import Quart, Response, request
from repub.web.app import (
_page_patch_response,
_shim_page_response,
render_create_source,
render_edit_source,
render_sources,
)
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_source_routes(app: Quart, *, admin_required: RouteGuard) -> None:
@app.get("/admin/sources")
@app.get("/admin/sources/create")
@app.get("/admin/sources/<string:slug>/edit")
@admin_required
async def admin_sources_shim(slug: str | None = None) -> Response:
del slug
return _shim_page_response(current_path=request.path, static_prefix="/admin")
@app.post("/admin/sources")
@admin_required
async def admin_sources_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_sources(app))
@app.post("/admin/sources/create")
@admin_required
async def admin_create_source_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_create_source(app),
)
@app.post("/admin/sources/<string:slug>/edit")
@admin_required
async def admin_edit_source_patch(slug: str) -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_edit_source(slug, app),
)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import hashlib
import os
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
from contextlib import suppress
from datetime import timedelta
@ -11,12 +12,10 @@ from typing import Any, TypedDict, cast
from urllib.parse import urlparse
import htpy as h
from datastar_py import ServerSentEventGenerator as SSE
from datastar_py.quart import DatastarResponse, read_signals
from datastar_py.sse import DatastarEvent
from htpy import Renderable
from peewee import IntegrityError
from quart import Quart, Response, request, send_from_directory, url_for
from quart import Quart, Response, redirect, request
from repub.auth_headers import (
AUTH_MODE_DISABLED,
@ -29,23 +28,16 @@ from repub.datastar import RefreshBroker, TabStateStore, render_stream
from repub.jobs import (
COMPLETED_EXECUTION_PAGE_SIZE,
JobRuntime,
clear_completed_executions,
load_dashboard_view,
load_execution_log_view,
load_runs_view,
)
from repub.model import (
create_source,
delete_job_source,
delete_source,
initialize_database,
load_job_enabled,
load_settings_form,
load_source_form,
load_sources,
save_setting,
source_slug_exists,
update_source,
)
from repub.pages import (
create_source_page,
@ -67,6 +59,7 @@ TAB_STATE_CLEANER_TASK_KEY = "repub.tab_state_cleaner_task"
SHUTDOWN_EVENT_KEY = "repub.shutdown_event"
DEFAULT_LOG_DIR = Path("out/logs")
DEFAULT_FEEDS_DIR = Path("out/feeds")
READER_APP_URL_ENV = "REPUBLISHER_READER_APP_URL"
RUNS_TAB_STATE_KEY = "runs"
TAB_STATE_CLEAN_INTERVAL = timedelta(seconds=10)
@ -109,7 +102,7 @@ DEFAULT_PANGEA_CONTENT_FORMAT = "MOBILE_3"
DEFAULT_PANGEA_CONTENT_TYPE = "articles"
DEFAULT_PANGEA_MAX_ARTICLES = "10"
DEFAULT_PANGEA_OLDEST_ARTICLE = "3"
STATIC_DIR = Path(__file__).resolve().parent / "static"
STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
CACHE_BUSTED_STATIC_ASSETS = frozenset({"app.css"})
CACHE_BUSTED_HASH_LENGTH = 12
@ -137,8 +130,8 @@ def versioned_static_asset_filename(filename: str) -> str:
return f"{asset_path.stem}-{truncated_hash}{asset_path.suffix}"
def versioned_static_asset_href(filename: str) -> str:
return f"/static/{versioned_static_asset_filename(filename)}"
def versioned_static_asset_href(filename: str, *, prefix: str = "/admin") -> str:
return f"{prefix}/static/{versioned_static_asset_filename(filename)}"
def _require_cache_busted_static_asset(filename: str) -> None:
@ -146,13 +139,18 @@ def _require_cache_busted_static_asset(filename: str) -> None:
raise ValueError(f"Unsupported cache-busted static asset: {filename}")
def create_app(*, dev_mode: bool = False) -> Quart:
app = Quart(__name__)
def create_app(*, dev_mode: bool = False, reader_app_url: str | None = None) -> Quart:
app = Quart(__name__, static_folder=None)
app.config["REPUB_DB_PATH"] = str(initialize_database())
app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR)
app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR)
app.config["REPUB_DEV_MODE"] = dev_mode
app.config["REPUB_AUTH_MODE"] = load_auth_mode()
app.config["REPUB_READER_APP_URL"] = _normalize_external_url(
os.environ.get(READER_APP_URL_ENV, "")
if reader_app_url is None
else reader_app_url
)
app.extensions[REFRESH_BROKER_KEY] = RefreshBroker()
app.extensions[JOB_RUNTIME_KEY] = None
app.extensions[TAB_STATE_STORE_KEY] = TabStateStore()
@ -161,270 +159,17 @@ def create_app(*, dev_mode: bool = False) -> Quart:
admin_required = _require_role(app, "admin")
publisher_required = _require_role(app, "publisher")
@app.get("/feeds/<path:feed_path>")
async def published_feed(feed_path: str) -> Response:
if not bool(app.config["REPUB_DEV_MODE"]):
return Response(status=404)
response = await send_from_directory(
str(Path(app.config["REPUB_FEEDS_DIR"])),
feed_path,
)
if Path(feed_path).suffix == ".rss":
response.mimetype = "application/rss+xml"
return response
@app.get("/static/<string:asset_name>-<string:asset_hash>.<string:extension>")
async def versioned_static_asset(
asset_name: str, asset_hash: str, extension: str
) -> Response:
logical_filename = f"{asset_name}.{extension}"
requested_filename = f"{asset_name}-{asset_hash}.{extension}"
if logical_filename in CACHE_BUSTED_STATIC_ASSETS:
response = await send_from_directory(str(STATIC_DIR), logical_filename)
response.cache_control.public = True
response.cache_control.max_age = 31536000
response.cache_control.immutable = True
return response
response = await send_from_directory(str(STATIC_DIR), requested_filename)
return response
@app.get("/publisher")
@publisher_required
async def publisher_home() -> Response:
return _shim_page_response(current_path="/publisher")
@app.get("/admin/publisher")
@admin_required
async def admin_publisher_home() -> Response:
return _shim_page_response(current_path="/admin/publisher")
@app.get("/")
@app.get("/sources")
@app.get("/sources/create")
@app.get("/sources/<string:slug>/edit")
@app.get("/runs")
@app.get("/settings")
@app.get("/job/<int:job_id>/execution/<int:execution_id>/logs")
@admin_required
async def page_shim(
slug: str | None = None,
job_id: int | None = None,
execution_id: int | None = None,
) -> Response:
del slug, job_id, execution_id
return _shim_page_response(current_path=request.path)
async def root_redirect() -> Response:
return cast(Response, redirect("/publisher"))
@app.post("/")
@admin_required
async def dashboard_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_dashboard(app))
from repub.web.routes import register_routes
@app.post("/publisher")
@publisher_required
async def publisher_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_publisher(current_path="/publisher"),
)
@app.post("/admin/publisher")
@admin_required
async def admin_publisher_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_publisher(current_path="/admin/publisher"),
)
@app.post("/sources")
@admin_required
async def sources_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_sources(app))
@app.post("/sources/create")
@admin_required
async def create_source_patch() -> DatastarResponse:
return await _page_patch_response(
app, lambda _tab_id: render_create_source(app)
)
@app.post("/sources/<string:slug>/edit")
@admin_required
async def edit_source_patch(slug: str) -> DatastarResponse:
return await _page_patch_response(
app, lambda _tab_id: render_edit_source(slug, app)
)
@app.post("/settings")
@admin_required
async def settings_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_settings(app))
@app.post("/actions/sources/create")
@admin_required
async def create_source_action() -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
source, error = validate_source_form(
signals,
slug_exists=source_slug_exists,
)
if error is not None:
return DatastarResponse(
SSE.patch_signals({"_formError": error, "_formSuccess": ""})
)
assert source is not None
try:
create_source(**source)
except IntegrityError:
return DatastarResponse(
SSE.patch_signals(
{"_formError": "Slug must be unique.", "_formSuccess": ""}
)
)
get_job_runtime(app).sync_jobs()
trigger_refresh(app)
return DatastarResponse(SSE.redirect("/sources"))
@app.post("/actions/sources/<string:slug>/edit")
@admin_required
async def edit_source_action(slug: str) -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
source, error = validate_source_form(
signals,
slug_exists=lambda candidate: candidate != slug
and source_slug_exists(candidate),
immutable_slug=slug,
)
if error is not None:
return DatastarResponse(
SSE.patch_signals({"_formError": error, "_formSuccess": ""})
)
assert source is not None
if update_source(slug, **source) is None:
return DatastarResponse(
SSE.patch_signals(
{"_formError": "Source does not exist.", "_formSuccess": ""}
)
)
get_job_runtime(app).sync_jobs()
trigger_refresh(app)
return DatastarResponse(SSE.redirect("/sources"))
@app.post("/actions/sources/<string:slug>/delete")
@admin_required
async def delete_source_action(slug: str) -> Response:
delete_source(slug)
get_job_runtime(app).sync_jobs()
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/settings")
@admin_required
async def update_settings_action() -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
settings, error = validate_settings_form(signals)
if error is not None:
return DatastarResponse(
SSE.patch_signals({"_formError": error, "_formSuccess": ""})
)
assert settings is not None
save_setting("max_concurrent_jobs", settings["max_concurrent_jobs"])
save_setting("feed_url", settings["feed_url"])
trigger_refresh(app)
return DatastarResponse(SSE.redirect("/settings"))
@app.post("/runs")
@admin_required
async def runs_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda tab_id: render_runs(app, tab_id=tab_id),
)
@app.post("/actions/runs/completed-page/<int:page>")
@admin_required
async def set_completed_runs_page_action(page: int) -> Response:
signals = await _read_optional_signals()
tab_id = _read_tab_id(signals)
if tab_id is None:
return Response(status=400)
get_tab_state_store(app).update_page_state(
tab_id,
RUNS_TAB_STATE_KEY,
lambda state: {**state, "completed_page": max(1, page)},
)
trigger_refresh(app, tab_id=tab_id)
return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/run-now")
@admin_required
async def run_job_now_action(job_id: int) -> Response:
get_job_runtime(app).run_job_now(job_id, reason="manual")
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/toggle-enabled")
@admin_required
async def toggle_job_enabled_action(job_id: int) -> Response:
enabled = load_job_enabled(job_id)
if enabled is not None:
get_job_runtime(app).set_job_enabled(job_id, enabled=not enabled)
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/delete")
@admin_required
async def delete_job_action(job_id: int) -> Response:
delete_job_source(job_id)
get_job_runtime(app).sync_jobs()
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/executions/<int:execution_id>/cancel")
@admin_required
async def cancel_execution_action(execution_id: int) -> Response:
get_job_runtime(app).request_execution_cancel(execution_id)
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/cancel")
@admin_required
async def cancel_queued_execution_action(execution_id: int) -> Response:
get_job_runtime(app).cancel_queued_execution(execution_id)
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/move-up")
@admin_required
async def move_queued_execution_up_action(execution_id: int) -> Response:
get_job_runtime(app).move_queued_execution(execution_id, direction="up")
return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/move-down")
@admin_required
async def move_queued_execution_down_action(execution_id: int) -> Response:
get_job_runtime(app).move_queued_execution(execution_id, direction="down")
return Response(status=204)
@app.post("/actions/completed-executions/clear")
@admin_required
async def clear_completed_executions_action() -> Response:
clear_completed_executions(log_dir=app.config["REPUB_LOG_DIR"])
trigger_refresh(app)
return Response(status=204)
@app.post("/job/<int:job_id>/execution/<int:execution_id>/logs")
@admin_required
async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse:
async def render() -> Renderable:
return await render_execution_logs(
app, job_id=job_id, execution_id=execution_id
)
return await _page_patch_response(app, lambda _tab_id: render())
register_routes(
app,
admin_required=admin_required,
publisher_required=publisher_required,
)
@app.before_serving
async def start_runtime() -> None:
@ -447,10 +192,20 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return app
def _shim_page_response(*, current_path: str) -> Response:
def _normalize_external_url(value: str | None) -> str:
url = (value or "").strip()
if url == "":
return ""
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or parsed.netloc == "":
return ""
return url
def _shim_page_response(*, current_path: str, static_prefix: str) -> Response:
body, etag = _render_shim_page(
stylesheet_href=versioned_static_asset_href("app.css"),
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
stylesheet_href=versioned_static_asset_href("app.css", prefix=static_prefix),
datastar_src=f"{static_prefix}/static/datastar@1.0.0-RC.8.js",
current_path=current_path,
)
if request.if_none_match.contains(etag):
@ -525,21 +280,56 @@ def trigger_refresh(
get_refresh_broker(app).publish(event, tab_id=tab_id)
async def render_dashboard(app: Quart | None = None) -> Renderable:
def run_job_now_response(app: Quart, job_id: int) -> Response:
if load_job_enabled(job_id) is None:
return Response(status=204)
get_job_runtime(app).run_job_now(job_id, reason="manual")
trigger_refresh(app)
return Response(status=204)
async def render_dashboard(
app: Quart | None = None, *, path_prefix: str = "/admin"
) -> Renderable:
if app is None:
return dashboard_page_with_data()
view = load_dashboard_view(log_dir=app.config["REPUB_LOG_DIR"])
view = load_dashboard_view(
log_dir=app.config["REPUB_LOG_DIR"],
path_prefix=path_prefix,
)
return dashboard_page_with_data(
current_path=path_prefix,
path_prefix=path_prefix,
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"]),
reader_app_url=cast(str, app.config["REPUB_READER_APP_URL"]) or None,
)
async def render_publisher(*, current_path: str) -> Renderable:
return publisher_page(current_path=current_path)
async def render_publisher(
app: Quart | None = None,
*,
current_path: str,
path_prefix: str | None = None,
) -> Renderable:
if app is None:
return publisher_page(current_path=current_path)
resolved_path_prefix = path_prefix or current_path
view = load_dashboard_view(
log_dir=app.config["REPUB_LOG_DIR"],
path_prefix=resolved_path_prefix,
)
return publisher_page(
current_path=current_path,
source_feeds=cast(tuple[dict[str, object], ...], view["source_feeds"]),
running_executions=cast(tuple[dict[str, object], ...], view["running"]),
queued_executions=cast(tuple[dict[str, object], ...], view["queued"]),
reader_app_url=cast(str, app.config["REPUB_READER_APP_URL"]) or None,
)
async def render_sources(app: Quart | None = None) -> Renderable:
@ -570,16 +360,21 @@ async def render_edit_source(slug: str, app: Quart | None = None) -> Renderable:
source = load_source_form(slug)
if source is None:
return sources_page(sources=())
sidebar_counts = {} if app is None else _load_sidebar_counts(app)
return edit_source_page(
slug=slug,
source=source,
action_path=f"/actions/sources/{slug}/edit",
**({} if app is None else _load_sidebar_counts(app)),
action_path=f"/admin/actions/sources/{slug}/edit",
source_count=sidebar_counts.get("source_count", 0),
running_count=sidebar_counts.get("running_count", 0),
)
async def render_runs(
app: Quart | None = None, *, tab_id: str | None = None
app: Quart | None = None,
*,
tab_id: str | None = None,
path_prefix: str = "/admin",
) -> Renderable:
if app is None:
return runs_page()
@ -590,8 +385,10 @@ async def render_runs(
log_dir=app.config["REPUB_LOG_DIR"],
completed_page=resolved_completed_page,
completed_page_size=COMPLETED_EXECUTION_PAGE_SIZE,
path_prefix=path_prefix,
)
return runs_page(
path_prefix=path_prefix,
running_executions=cast(tuple[dict[str, object], ...], view["running"]),
queued_executions=cast(tuple[dict[str, object], ...], view["queued"]),
upcoming_jobs=cast(tuple[dict[str, object], ...], view["upcoming"]),
@ -616,7 +413,11 @@ async def render_settings(app: Quart | None = None) -> Renderable:
async def render_execution_logs(
app: Quart | None = None, *, job_id: int, execution_id: int
app: Quart | None = None,
*,
job_id: int,
execution_id: int,
path_prefix: str = "/admin",
) -> Renderable:
if app is None:
return execution_logs_page(job_id=job_id, execution_id=execution_id)
@ -625,10 +426,12 @@ async def render_execution_logs(
log_dir=app.config["REPUB_LOG_DIR"],
job_id=job_id,
execution_id=execution_id,
path_prefix=path_prefix,
)
return execution_logs_page(
job_id=job_id,
execution_id=execution_id,
path_prefix=path_prefix,
log_view={
"title": log_view.title,
"description": log_view.description,

View file

@ -0,0 +1,29 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from quart import Quart
from repub.web.publisher.actions import register_publisher_actions
from repub.web.publisher.pages.dashboard import register_publisher_dashboard_routes
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_publisher_routes(
app: Quart,
*,
publisher_required: RouteGuard,
admin_required: RouteGuard,
) -> None:
register_publisher_dashboard_routes(
app,
publisher_required=publisher_required,
admin_required=admin_required,
)
register_publisher_actions(
app,
publisher_required=publisher_required,
admin_required=admin_required,
)

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from quart import Quart, Response
from repub.web.app import run_job_now_response
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_publisher_actions(
app: Quart,
*,
publisher_required: RouteGuard,
admin_required: RouteGuard,
) -> None:
@app.post("/publisher/actions/jobs/<int:job_id>/run-now")
@publisher_required
async def publisher_run_job_now_action(job_id: int) -> Response:
return run_job_now_response(app, job_id)
@app.post("/admin/publisher/actions/jobs/<int:job_id>/run-now")
@admin_required
async def admin_publisher_run_job_now_action(job_id: int) -> Response:
return run_job_now_response(app, job_id)

View file

@ -0,0 +1 @@
from __future__ import annotations

View file

@ -0,0 +1,57 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from datastar_py.quart import DatastarResponse
from quart import Quart, Response
from repub.web.app import _page_patch_response, _shim_page_response, render_publisher
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_publisher_dashboard_routes(
app: Quart,
*,
publisher_required: RouteGuard,
admin_required: RouteGuard,
) -> None:
@app.get("/publisher")
@publisher_required
async def publisher_home() -> Response:
return _shim_page_response(
current_path="/publisher", static_prefix="/publisher"
)
@app.post("/publisher")
@publisher_required
async def publisher_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_publisher(
app,
current_path="/publisher",
path_prefix="/publisher",
),
)
@app.get("/admin/publisher")
@admin_required
async def admin_publisher_home() -> Response:
return _shim_page_response(
current_path="/admin/publisher",
static_prefix="/admin",
)
@app.post("/admin/publisher")
@admin_required
async def admin_publisher_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_publisher(
app,
current_path="/admin/publisher",
path_prefix="/admin/publisher",
),
)

27
repub/web/routes.py Normal file
View file

@ -0,0 +1,27 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from quart import Quart
from repub.web.admin import register_admin_routes
from repub.web.publisher import register_publisher_routes
from repub.web.static import register_static_routes
RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]
def register_routes(
app: Quart,
*,
admin_required: RouteGuard,
publisher_required: RouteGuard,
) -> None:
register_static_routes(app)
register_admin_routes(app, admin_required=admin_required)
register_publisher_routes(
app,
publisher_required=publisher_required,
admin_required=admin_required,
)

50
repub/web/static.py Normal file
View file

@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
from quart import Quart, Response, send_from_directory
from repub.web.app import CACHE_BUSTED_STATIC_ASSETS, STATIC_DIR
def register_static_routes(app: Quart) -> None:
@app.get("/feeds/<path:feed_path>")
async def published_feed(feed_path: str) -> Response:
if not bool(app.config["REPUB_DEV_MODE"]):
return Response(status=404)
response = await send_from_directory(
str(Path(app.config["REPUB_FEEDS_DIR"])),
feed_path,
)
if Path(feed_path).suffix == ".rss":
response.mimetype = "application/rss+xml"
return response
@app.get("/admin/static/<path:filename>")
async def admin_static_asset(filename: str) -> Response:
return await _static_asset_response(filename)
@app.get("/publisher/static/<path:filename>")
async def publisher_static_asset(filename: str) -> Response:
return await _static_asset_response(filename)
async def _static_asset_response(filename: str) -> Response:
logical_filename = _cache_busted_logical_filename(filename)
if logical_filename is not None:
response = await send_from_directory(str(STATIC_DIR), logical_filename)
response.cache_control.public = True
response.cache_control.max_age = 31536000
response.cache_control.immutable = True
return response
return await send_from_directory(str(STATIC_DIR), filename)
def _cache_busted_logical_filename(filename: str) -> str | None:
for logical_filename in CACHE_BUSTED_STATIC_ASSETS:
logical_path = Path(logical_filename)
prefix = f"{logical_path.stem}-"
if filename.startswith(prefix) and filename.endswith(logical_path.suffix):
return logical_filename
return None