Add publisher dashboard routes
This commit is contained in:
parent
96551c2788
commit
e4a5246ab3
31 changed files with 1603 additions and 516 deletions
|
|
@ -17,7 +17,7 @@ The Republisher currently accepts the following source input types:
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Sync dependencies and start the admin UI:
|
Sync dependencies and start the web UI:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv sync --all-groups
|
uv sync --all-groups
|
||||||
|
|
@ -50,13 +50,17 @@ In trusted-header mode, nginx must overwrite the `X-Republisher-*` identity head
|
||||||
|
|
||||||
Once the UI is running:
|
Once the UI is running:
|
||||||
|
|
||||||
1. Open `http://127.0.0.1:8080/`.
|
1. Open `http://127.0.0.1:8080/admin` for the admin UI.
|
||||||
2. Create a source. Feed sources take a feed URL. Pangea sources take a domain plus category configuration.
|
2. Create a source. Feed sources take a feed URL. Pangea sources take a domain plus category configuration.
|
||||||
3. Open `Settings` and set `Feed URL` to the public origin that serves mirrored feeds, for example `https://mirror.example`.
|
3. Open `Settings` and set `Feed URL` to the public origin that serves mirrored feeds, for example `https://mirror.example`.
|
||||||
4. Configure the job schedule and any spider arguments.
|
4. Configure the job schedule and any spider arguments.
|
||||||
5. Use `Run now` to trigger an immediate crawl, or leave the job enabled for scheduled runs.
|
5. Use `Run now` to trigger an immediate crawl, or leave the job enabled for scheduled runs.
|
||||||
6. Watch running jobs and logs live from the Runs pages.
|
6. Watch running jobs and logs live from the Runs pages.
|
||||||
|
|
||||||
|
Publisher-facing status is available at `http://127.0.0.1:8080/publisher`.
|
||||||
|
Set `REPUBLISHER_READER_APP_URL` or pass `--reader-app-url` to add a publisher
|
||||||
|
dashboard link to the AnyNews reader application that consumes the mirrored feeds.
|
||||||
|
|
||||||
Operational notes:
|
Operational notes:
|
||||||
|
|
||||||
- The default database path is `republisher.db`. Set `REPUBLISHER_DB_PATH` to use a different SQLite file.
|
- The default database path is `republisher.db`. Set `REPUBLISHER_DB_PATH` to use a different SQLite file.
|
||||||
|
|
|
||||||
|
|
@ -98,27 +98,27 @@ def admin_sidebar(
|
||||||
h.nav(class_="mt-8 space-y-2")[
|
h.nav(class_="mt-8 space-y-2")[
|
||||||
nav_link(
|
nav_link(
|
||||||
label="Dashboard",
|
label="Dashboard",
|
||||||
href="/",
|
href="/admin",
|
||||||
active=current_path == "/",
|
active=current_path == "/admin",
|
||||||
badge="Live",
|
badge="Live",
|
||||||
),
|
),
|
||||||
nav_link(
|
nav_link(
|
||||||
label="Sources",
|
label="Sources",
|
||||||
href="/sources",
|
href="/admin/sources",
|
||||||
active=current_path.startswith("/sources"),
|
active=current_path.startswith("/admin/sources"),
|
||||||
badge=str(source_count),
|
badge=str(source_count),
|
||||||
),
|
),
|
||||||
nav_link(
|
nav_link(
|
||||||
label="Runs",
|
label="Runs",
|
||||||
href="/runs",
|
href="/admin/runs",
|
||||||
active=current_path.startswith("/runs")
|
active=current_path.startswith("/admin/runs")
|
||||||
or current_path.startswith("/job/"),
|
or current_path.startswith("/admin/job/"),
|
||||||
badge=str(running_count),
|
badge=str(running_count),
|
||||||
),
|
),
|
||||||
nav_link(
|
nav_link(
|
||||||
label="Settings",
|
label="Settings",
|
||||||
href="/settings",
|
href="/admin/settings",
|
||||||
active=current_path.startswith("/settings"),
|
active=current_path.startswith("/admin/settings"),
|
||||||
badge="App",
|
badge="App",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -148,11 +148,18 @@ def header_secondary_link(*, href: str, label: str) -> Renderable:
|
||||||
)[label]
|
)[label]
|
||||||
|
|
||||||
|
|
||||||
def muted_action_link(*, href: str, label: str) -> Renderable:
|
def muted_action_link(
|
||||||
return h.a(
|
*, href: str, label: str, target: str | None = None, rel: str | None = None
|
||||||
href=href,
|
) -> Renderable:
|
||||||
class_=_button_classes(tone="muted", emphasis="soft"),
|
attributes = {
|
||||||
)[label]
|
"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:
|
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(
|
def page_shell(
|
||||||
*,
|
*,
|
||||||
current_path: str,
|
current_path: str,
|
||||||
|
|
@ -269,7 +285,7 @@ def section_card(*, content: Node) -> Renderable:
|
||||||
def table_section(
|
def table_section(
|
||||||
*,
|
*,
|
||||||
eyebrow: str | None = None,
|
eyebrow: str | None = None,
|
||||||
title: str,
|
title: str | None,
|
||||||
subtitle: str | None = None,
|
subtitle: str | None = None,
|
||||||
empty_message: str,
|
empty_message: str,
|
||||||
headers: tuple[str, ...],
|
headers: tuple[str, ...],
|
||||||
|
|
@ -315,8 +331,16 @@ def table_section(
|
||||||
)[empty_message]
|
)[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[
|
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"
|
class_="flex flex-col gap-2.5 sm:flex-row sm:items-end sm:justify-between"
|
||||||
)[
|
)[
|
||||||
h.div[
|
h.div[
|
||||||
|
|
@ -324,13 +348,18 @@ def table_section(
|
||||||
and h.p(
|
and h.p(
|
||||||
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
|
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
|
||||||
)[eyebrow],
|
)[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],
|
subtitle and h.p(class_="mt-1 text-sm text-slate-600")[subtitle],
|
||||||
],
|
],
|
||||||
actions,
|
actions,
|
||||||
],
|
],
|
||||||
h.div(
|
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.div(class_="overflow-x-auto")[
|
||||||
h.table(
|
h.table(
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,16 @@ def parse_args(argv: list[str] | None = None) -> tuple[str, argparse.Namespace]:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Serve published feeds from /feeds for local development",
|
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 = subparsers.add_parser("crawl", help="Run the feed crawler once")
|
||||||
crawl_parser.add_argument(
|
crawl_parser.add_argument(
|
||||||
|
|
@ -144,16 +154,18 @@ def _install_signal_handlers(stop_event: asyncio.Event) -> None:
|
||||||
signal.signal(signum, request_stop)
|
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()
|
stop_event = asyncio.Event()
|
||||||
_install_signal_handlers(stop_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
|
app.extensions[SHUTDOWN_EVENT_KEY] = stop_event
|
||||||
|
|
||||||
config = HypercornConfig()
|
config = HypercornConfig()
|
||||||
config.bind = [f"{host}:{port}"]
|
config.bind = [f"{host}:{port}"]
|
||||||
config.use_reloader = False
|
config.use_reloader = reload
|
||||||
config.accesslog = "-"
|
config.accesslog = "-"
|
||||||
config.errorlog = "-"
|
config.errorlog = "-"
|
||||||
|
|
||||||
|
|
@ -203,7 +215,15 @@ def entrypoint(argv: list[str] | None = None) -> int:
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
with suppress(KeyboardInterrupt):
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -668,6 +668,7 @@ def load_runs_view(
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
completed_page: int = 1,
|
completed_page: int = 1,
|
||||||
completed_page_size: int = COMPLETED_EXECUTION_PAGE_SIZE,
|
completed_page_size: int = COMPLETED_EXECUTION_PAGE_SIZE,
|
||||||
|
path_prefix: str = "",
|
||||||
) -> RunsView:
|
) -> RunsView:
|
||||||
reference_time = now or datetime.now(UTC)
|
reference_time = now or datetime.now(UTC)
|
||||||
resolved_log_dir = Path(log_dir)
|
resolved_log_dir = Path(log_dir)
|
||||||
|
|
@ -727,6 +728,7 @@ def load_runs_view(
|
||||||
execution,
|
execution,
|
||||||
resolved_log_dir,
|
resolved_log_dir,
|
||||||
reference_time,
|
reference_time,
|
||||||
|
path_prefix=path_prefix,
|
||||||
queued_follow_up=queued_by_job.get(
|
queued_follow_up=queued_by_job.get(
|
||||||
_job_id(cast(Job, execution.job))
|
_job_id(cast(Job, execution.job))
|
||||||
),
|
),
|
||||||
|
|
@ -739,6 +741,7 @@ def load_runs_view(
|
||||||
reference_time,
|
reference_time,
|
||||||
position=position,
|
position=position,
|
||||||
total_count=len(queued_executions),
|
total_count=len(queued_executions),
|
||||||
|
path_prefix=path_prefix,
|
||||||
)
|
)
|
||||||
for position, execution in enumerate(queued_executions, start=1)
|
for position, execution in enumerate(queued_executions, start=1)
|
||||||
),
|
),
|
||||||
|
|
@ -748,12 +751,16 @@ def load_runs_view(
|
||||||
running_by_job.get(job.id),
|
running_by_job.get(job.id),
|
||||||
queued_by_job.get(job.id),
|
queued_by_job.get(job.id),
|
||||||
reference_time,
|
reference_time,
|
||||||
|
path_prefix=path_prefix,
|
||||||
)
|
)
|
||||||
for job in jobs
|
for job in jobs
|
||||||
),
|
),
|
||||||
"completed": tuple(
|
"completed": tuple(
|
||||||
_project_completed_execution(
|
_project_completed_execution(
|
||||||
execution, resolved_log_dir, reference_time
|
execution,
|
||||||
|
resolved_log_dir,
|
||||||
|
reference_time,
|
||||||
|
path_prefix=path_prefix,
|
||||||
)
|
)
|
||||||
for execution in completed_executions
|
for execution in completed_executions
|
||||||
),
|
),
|
||||||
|
|
@ -801,10 +808,14 @@ def clear_completed_executions(*, log_dir: str | Path) -> int:
|
||||||
|
|
||||||
|
|
||||||
def load_dashboard_view(
|
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]:
|
) -> dict[str, object]:
|
||||||
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,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
)
|
||||||
output_dir = Path(log_dir).parent
|
output_dir = Path(log_dir).parent
|
||||||
running_by_job_id = {
|
running_by_job_id = {
|
||||||
int(cast(int, execution["job_id"])): execution
|
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))),
|
running_execution=running_by_job_id.get(_job_id(cast(Job, job))),
|
||||||
queued_execution=queued_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))),
|
upcoming_job=upcoming_by_job_id.get(_job_id(cast(Job, job))),
|
||||||
|
path_prefix=path_prefix,
|
||||||
)
|
)
|
||||||
for job in jobs
|
for job in jobs
|
||||||
),
|
),
|
||||||
|
|
@ -855,9 +867,9 @@ def load_dashboard_view(
|
||||||
|
|
||||||
|
|
||||||
def load_execution_log_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:
|
) -> 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():
|
with database.reader():
|
||||||
execution_primary_key = getattr(JobExecution, "_meta").primary_key
|
execution_primary_key = getattr(JobExecution, "_meta").primary_key
|
||||||
execution = (
|
execution = (
|
||||||
|
|
@ -924,11 +936,16 @@ def _scheduler_job_id(job_id: int) -> str:
|
||||||
return f"{SCHEDULER_JOB_PREFIX}{job_id}"
|
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(
|
def _project_running_execution(
|
||||||
execution: JobExecution,
|
execution: JobExecution,
|
||||||
log_dir: Path,
|
log_dir: Path,
|
||||||
reference_time: datetime,
|
reference_time: datetime,
|
||||||
*,
|
*,
|
||||||
|
path_prefix: str,
|
||||||
queued_follow_up: JobExecution | None = None,
|
queued_follow_up: JobExecution | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
job = cast(Job, execution.job)
|
job = cast(Job, execution.job)
|
||||||
|
|
@ -957,13 +974,16 @@ def _project_running_execution(
|
||||||
if execution.stop_requested_at
|
if execution.stop_requested_at
|
||||||
else "streaming stats from worker"
|
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(),
|
"log_exists": artifacts.log_path.exists(),
|
||||||
"cancel_label": "Cancel" if queued_follow_up is not None else "Stop",
|
"cancel_label": "Cancel" if queued_follow_up is not None else "Stop",
|
||||||
"cancel_post_path": (
|
"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
|
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,
|
position: int,
|
||||||
total_count: int,
|
total_count: int,
|
||||||
|
path_prefix: str,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
job = cast(Job, execution.job)
|
job = cast(Job, execution.job)
|
||||||
queued_at = _coerce_datetime(cast(datetime | str, execution.created_at))
|
queued_at = _coerce_datetime(cast(datetime | str, execution.created_at))
|
||||||
|
|
@ -990,19 +1011,28 @@ def _project_queued_execution(
|
||||||
"status_tone": "idle",
|
"status_tone": "idle",
|
||||||
"run_label": "Queued",
|
"run_label": "Queued",
|
||||||
"run_disabled": True,
|
"run_disabled": True,
|
||||||
"run_post_path": f"/actions/jobs/{_job_id(job)}/run-now",
|
"run_post_path": _path(path_prefix, f"/actions/jobs/{_job_id(job)}/run-now"),
|
||||||
"cancel_post_path": (f"/actions/queued-executions/{execution_id}/cancel"),
|
"cancel_post_path": _path(
|
||||||
|
path_prefix,
|
||||||
|
f"/actions/queued-executions/{execution_id}/cancel",
|
||||||
|
),
|
||||||
"move_up_disabled": position == 1,
|
"move_up_disabled": position == 1,
|
||||||
"move_up_post_path": (
|
"move_up_post_path": (
|
||||||
None
|
None
|
||||||
if position == 1
|
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_disabled": position == total_count,
|
||||||
"move_down_post_path": (
|
"move_down_post_path": (
|
||||||
None
|
None
|
||||||
if position == total_count
|
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,
|
running_execution: JobExecution | None,
|
||||||
queued_execution: JobExecution | None,
|
queued_execution: JobExecution | None,
|
||||||
reference_time: datetime,
|
reference_time: datetime,
|
||||||
|
*,
|
||||||
|
path_prefix: str,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
job_id = _job_id(job)
|
job_id = _job_id(job)
|
||||||
trigger = _job_trigger(job)
|
trigger = _job_trigger(job)
|
||||||
|
|
@ -1051,14 +1083,21 @@ def _project_upcoming_job(
|
||||||
"run_reason": run_reason,
|
"run_reason": run_reason,
|
||||||
"toggle_label": "Disable" if job.enabled else "Enable",
|
"toggle_label": "Disable" if job.enabled else "Enable",
|
||||||
"toggle_enabled": not job.enabled,
|
"toggle_enabled": not job.enabled,
|
||||||
"run_post_path": f"/actions/jobs/{job_id}/run-now",
|
"run_post_path": _path(path_prefix, f"/actions/jobs/{job_id}/run-now"),
|
||||||
"toggle_post_path": f"/actions/jobs/{job_id}/toggle-enabled",
|
"toggle_post_path": _path(
|
||||||
"delete_post_path": f"/actions/jobs/{job_id}/delete",
|
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(
|
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]:
|
) -> dict[str, object]:
|
||||||
job = cast(Job, execution.job)
|
job = cast(Job, execution.job)
|
||||||
job_id = _job_id(job)
|
job_id = _job_id(job)
|
||||||
|
|
@ -1100,7 +1139,7 @@ def _project_completed_execution(
|
||||||
else "Worker exited with failure"
|
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(),
|
"log_exists": artifacts.log_path.exists(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1113,6 +1152,7 @@ def _project_source_feed(
|
||||||
running_execution: dict[str, object] | None = None,
|
running_execution: dict[str, object] | None = None,
|
||||||
queued_execution: dict[str, object] | None = None,
|
queued_execution: dict[str, object] | None = None,
|
||||||
upcoming_job: dict[str, object] | None = None,
|
upcoming_job: dict[str, object] | None = None,
|
||||||
|
path_prefix: str,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
source = cast(Source, job.source)
|
source = cast(Source, job.source)
|
||||||
source_slug = str(source.slug)
|
source_slug = str(source.slug)
|
||||||
|
|
@ -1163,7 +1203,7 @@ def _project_source_feed(
|
||||||
"run_post_path": (
|
"run_post_path": (
|
||||||
str(upcoming_job["run_post_path"])
|
str(upcoming_job["run_post_path"])
|
||||||
if upcoming_job is not None
|
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)),
|
"artifact_footprint": _format_bytes(_directory_size(source_dir)),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from htpy import Node, Renderable
|
||||||
from repub.components import (
|
from repub.components import (
|
||||||
action_button,
|
action_button,
|
||||||
app_shell,
|
app_shell,
|
||||||
header_action_link,
|
|
||||||
inline_link,
|
inline_link,
|
||||||
muted_action_link,
|
muted_action_link,
|
||||||
stat_card,
|
stat_card,
|
||||||
|
|
@ -19,7 +18,9 @@ from repub.components import (
|
||||||
from repub.pages.runs import live_work_section, relative_time_formatter_script
|
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[
|
return h.section[
|
||||||
h.div(
|
h.div(
|
||||||
class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
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")[
|
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,
|
last_updated,
|
||||||
next_run,
|
next_run,
|
||||||
h.p(class_="font-medium text-slate-900")[
|
|
||||||
str(source_feed["artifact_footprint"])
|
|
||||||
],
|
|
||||||
action_button(
|
action_button(
|
||||||
label="Run now",
|
label="Run now",
|
||||||
disabled=bool(source_feed["run_disabled"]),
|
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(
|
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:
|
) -> Renderable:
|
||||||
rows = tuple(_source_feed_row(source_feed) for source_feed in (source_feeds or ()))
|
rows = tuple(_source_feed_row(source_feed) for source_feed in (source_feeds or ()))
|
||||||
return table_section(
|
return table_section(
|
||||||
eyebrow="Published feeds",
|
eyebrow="Published feeds" if show_heading else None,
|
||||||
title="Published feeds",
|
title="Published feeds" if show_heading else None,
|
||||||
empty_message="No feeds have been published yet.",
|
empty_message="No feeds have been published yet.",
|
||||||
headers=(
|
headers=(
|
||||||
"Source",
|
"Source",
|
||||||
|
|
@ -149,11 +162,14 @@ def published_feeds_table(
|
||||||
"Status",
|
"Status",
|
||||||
"Last updated",
|
"Last updated",
|
||||||
"Next run",
|
"Next run",
|
||||||
"Disk usage",
|
|
||||||
"Actions",
|
"Actions",
|
||||||
),
|
),
|
||||||
rows=rows,
|
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(
|
def dashboard_page_with_data(
|
||||||
*,
|
*,
|
||||||
|
current_path: str = "/admin",
|
||||||
|
path_prefix: str = "/admin",
|
||||||
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,
|
queued_executions: tuple[Mapping[str, object], ...] | None = None,
|
||||||
source_feeds: tuple[Mapping[str, object], ...] | None = None,
|
source_feeds: tuple[Mapping[str, object], ...] | None = None,
|
||||||
|
reader_app_url: str | None = None,
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
running_items = running_executions or ()
|
running_items = running_executions or ()
|
||||||
queued_items = queued_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=current_path,
|
||||||
source_count=len(source_items),
|
source_count=len(source_items),
|
||||||
running_count=len(running_items),
|
running_count=len(running_items),
|
||||||
content=(
|
content=(
|
||||||
dashboard_header(),
|
dashboard_header(path_prefix=path_prefix, reader_app_url=reader_app_url),
|
||||||
operational_snapshot(snapshot=snapshot),
|
operational_snapshot(snapshot=snapshot),
|
||||||
live_work_section(
|
live_work_section(
|
||||||
running_executions=running_items,
|
running_executions=running_items,
|
||||||
queued_executions=queued_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(),
|
relative_time_formatter_script(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,57 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
import htpy as h
|
import htpy as h
|
||||||
from htpy import Renderable
|
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:
|
def publisher_page(
|
||||||
return app_shell(
|
*,
|
||||||
|
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,
|
current_path=current_path,
|
||||||
content=(
|
content=(
|
||||||
h.section[
|
h.section[
|
||||||
h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[
|
h.div(
|
||||||
"Hello publishers"
|
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(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 = _maybe_text(execution, "started_at_iso")
|
||||||
started_at_label: Node = h.p(class_="truncate")[_text(execution, "started_at")]
|
started_at_label: Node = h.p(class_="truncate")[_text(execution, "started_at")]
|
||||||
if started_at is not None:
|
if started_at is not None:
|
||||||
|
|
@ -203,7 +205,7 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
||||||
class_="truncate",
|
class_="truncate",
|
||||||
)[_text(execution, "started_at")]
|
)[_text(execution, "started_at")]
|
||||||
|
|
||||||
return (
|
cells = (
|
||||||
_live_status_cell(
|
_live_status_cell(
|
||||||
execution_id=_text(execution, "execution_id"),
|
execution_id=_text(execution, "execution_id"),
|
||||||
status=_text(execution, "status"),
|
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_="font-medium text-slate-900")[_text(execution, "stats")],
|
||||||
h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "worker")],
|
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")[
|
h.div(class_="flex flex-wrap items-center gap-2")[
|
||||||
inline_link(
|
inline_link(
|
||||||
href=_text(execution, "log_href"),
|
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_at = _maybe_text(execution, "queued_at_iso")
|
||||||
queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")]
|
queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")]
|
||||||
if queued_at is not None:
|
if queued_at is not None:
|
||||||
|
|
@ -250,7 +259,7 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
||||||
class_="truncate",
|
class_="truncate",
|
||||||
)[_text(execution, "queued_at")]
|
)[_text(execution, "queued_at")]
|
||||||
|
|
||||||
return (
|
cells = (
|
||||||
_live_status_cell(
|
_live_status_cell(
|
||||||
execution_id=_text(execution, "execution_id"),
|
execution_id=_text(execution, "execution_id"),
|
||||||
status="Queued",
|
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"],
|
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")[
|
h.div(class_="flex flex-wrap items-center gap-2")[
|
||||||
action_button(
|
action_button(
|
||||||
label=_queue_icon("up"),
|
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:
|
def _completed_page_action_path(page: int, *, path_prefix: str = "/admin") -> str:
|
||||||
return f"/actions/runs/completed-page/{page}"
|
return f"{path_prefix}/actions/runs/completed-page/{page}"
|
||||||
|
|
||||||
|
|
||||||
def _pagination_button(
|
def _pagination_button(
|
||||||
|
|
@ -372,9 +386,12 @@ def _pagination_button(
|
||||||
page: int,
|
page: int,
|
||||||
current: bool = False,
|
current: bool = False,
|
||||||
class_name: str,
|
class_name: str,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
attributes = {
|
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:
|
if current:
|
||||||
attributes["aria-current"] = "page"
|
attributes["aria-current"] = "page"
|
||||||
|
|
@ -391,6 +408,7 @@ def _completed_history_pagination(
|
||||||
completed_page_size: int,
|
completed_page_size: int,
|
||||||
completed_total_count: int,
|
completed_total_count: int,
|
||||||
completed_total_pages: int,
|
completed_total_pages: int,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable | None:
|
) -> Renderable | None:
|
||||||
if completed_total_count <= completed_page_size:
|
if completed_total_count <= completed_page_size:
|
||||||
return None
|
return None
|
||||||
|
|
@ -410,6 +428,7 @@ def _completed_history_pagination(
|
||||||
_pagination_button(
|
_pagination_button(
|
||||||
label="Previous",
|
label="Previous",
|
||||||
page=max(1, completed_page - 1),
|
page=max(1, completed_page - 1),
|
||||||
|
path_prefix=path_prefix,
|
||||||
class_name=(
|
class_name=(
|
||||||
"relative inline-flex items-center rounded-xl border border-slate-200 "
|
"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"
|
"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(
|
_pagination_button(
|
||||||
label="Next",
|
label="Next",
|
||||||
page=min(completed_total_pages, completed_page + 1),
|
page=min(completed_total_pages, completed_page + 1),
|
||||||
|
path_prefix=path_prefix,
|
||||||
class_name=(
|
class_name=(
|
||||||
"relative ml-3 inline-flex items-center rounded-xl border border-slate-200 "
|
"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"
|
"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),
|
label=str(page_number),
|
||||||
page=page_number,
|
page=page_number,
|
||||||
current=page_number == completed_page,
|
current=page_number == completed_page,
|
||||||
|
path_prefix=path_prefix,
|
||||||
class_name=(
|
class_name=(
|
||||||
"relative z-10 inline-flex items-center bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950"
|
"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
|
if page_number == completed_page
|
||||||
|
|
@ -463,12 +484,14 @@ def _completed_history_section(
|
||||||
completed_page_size: int,
|
completed_page_size: int,
|
||||||
completed_total_count: int,
|
completed_total_count: int,
|
||||||
completed_total_pages: int,
|
completed_total_pages: int,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
pagination = _completed_history_pagination(
|
pagination = _completed_history_pagination(
|
||||||
completed_page=completed_page,
|
completed_page=completed_page,
|
||||||
completed_page_size=completed_page_size,
|
completed_page_size=completed_page_size,
|
||||||
completed_total_count=completed_total_count,
|
completed_total_count=completed_total_count,
|
||||||
completed_total_pages=completed_total_pages,
|
completed_total_pages=completed_total_pages,
|
||||||
|
path_prefix=path_prefix,
|
||||||
)
|
)
|
||||||
return h.section[
|
return h.section[
|
||||||
table_section(
|
table_section(
|
||||||
|
|
@ -486,7 +509,7 @@ def _completed_history_section(
|
||||||
action_button(
|
action_button(
|
||||||
label="Clear history",
|
label="Clear history",
|
||||||
tone="danger",
|
tone="danger",
|
||||||
post_path="/actions/completed-executions/clear",
|
post_path=f"{path_prefix}/actions/completed-executions/clear",
|
||||||
)
|
)
|
||||||
if completed_total_count > 0
|
if completed_total_count > 0
|
||||||
else None
|
else None
|
||||||
|
|
@ -501,11 +524,18 @@ def live_work_section(
|
||||||
running_executions: tuple[Mapping[str, object], ...] | None = None,
|
running_executions: tuple[Mapping[str, object], ...] | None = None,
|
||||||
queued_executions: tuple[Mapping[str, object], ...] | None = None,
|
queued_executions: tuple[Mapping[str, object], ...] | None = None,
|
||||||
actions: Node | None = None,
|
actions: Node | None = None,
|
||||||
|
show_row_actions: bool = True,
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
running_items = running_executions or ()
|
running_items = running_executions or ()
|
||||||
queued_items = queued_executions or ()
|
queued_items = queued_executions or ()
|
||||||
running_rows = tuple(_running_row(execution) for execution in running_items)
|
running_rows = tuple(
|
||||||
queued_rows = tuple(_queued_row(execution) for execution in queued_items)
|
_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_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
|
||||||
|
|
@ -515,10 +545,9 @@ def live_work_section(
|
||||||
title="Running jobs",
|
title="Running jobs",
|
||||||
empty_message="No jobs are running or queued.",
|
empty_message="No jobs are running or queued.",
|
||||||
headers=(
|
headers=(
|
||||||
"State",
|
("State", "Source", "Details", "Actions")
|
||||||
"Source",
|
if show_row_actions
|
||||||
"Details",
|
else ("State", "Source", "Details")
|
||||||
"Actions",
|
|
||||||
),
|
),
|
||||||
rows=live_rows,
|
rows=live_rows,
|
||||||
row_attrs=live_row_attrs,
|
row_attrs=live_row_attrs,
|
||||||
|
|
@ -585,6 +614,7 @@ def runs_page(
|
||||||
completed_total_count: int | None = None,
|
completed_total_count: int | None = None,
|
||||||
completed_total_pages: int | None = None,
|
completed_total_pages: int | None = None,
|
||||||
source_count: int = 0,
|
source_count: int = 0,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
upcoming_items = upcoming_jobs or ()
|
upcoming_items = upcoming_jobs or ()
|
||||||
completed_items = completed_executions or ()
|
completed_items = completed_executions or ()
|
||||||
|
|
@ -598,10 +628,13 @@ def runs_page(
|
||||||
)
|
)
|
||||||
|
|
||||||
return page_shell(
|
return page_shell(
|
||||||
current_path="/runs",
|
current_path=f"{path_prefix}/runs",
|
||||||
eyebrow="Execution control",
|
eyebrow="Execution control",
|
||||||
title="Runs",
|
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,
|
source_count=source_count,
|
||||||
running_count=len(running_executions or ()),
|
running_count=len(running_executions or ()),
|
||||||
content=(
|
content=(
|
||||||
|
|
@ -629,6 +662,7 @@ def runs_page(
|
||||||
completed_page_size=completed_page_size,
|
completed_page_size=completed_page_size,
|
||||||
completed_total_count=resolved_completed_total_count,
|
completed_total_count=resolved_completed_total_count,
|
||||||
completed_total_pages=resolved_completed_total_pages,
|
completed_total_pages=resolved_completed_total_pages,
|
||||||
|
path_prefix=path_prefix,
|
||||||
),
|
),
|
||||||
relative_time_formatter_script(),
|
relative_time_formatter_script(),
|
||||||
),
|
),
|
||||||
|
|
@ -640,6 +674,7 @@ def execution_logs_page(
|
||||||
job_id: int,
|
job_id: int,
|
||||||
execution_id: int,
|
execution_id: int,
|
||||||
log_view: Mapping[str, object] | None = None,
|
log_view: Mapping[str, object] | None = None,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
if log_view is None:
|
if log_view is None:
|
||||||
log_view = {
|
log_view = {
|
||||||
|
|
@ -664,10 +699,10 @@ def execution_logs_page(
|
||||||
)
|
)
|
||||||
|
|
||||||
return page_shell(
|
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",
|
eyebrow="Execution log",
|
||||||
title=_text(log_view, "title"),
|
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=(
|
content=(
|
||||||
section_card(
|
section_card(
|
||||||
content=(
|
content=(
|
||||||
|
|
@ -677,7 +712,7 @@ def execution_logs_page(
|
||||||
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
|
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
|
||||||
)["Route"],
|
)["Route"],
|
||||||
h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[
|
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(
|
status_badge(
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,13 @@ def _value(settings: Mapping[str, object] | None, key: str, default: str = "") -
|
||||||
def settings_page(
|
def settings_page(
|
||||||
*,
|
*,
|
||||||
settings: Mapping[str, object] | None = None,
|
settings: Mapping[str, object] | None = None,
|
||||||
action_path: str = "/actions/settings",
|
action_path: str = "/admin/actions/settings",
|
||||||
source_count: int = 0,
|
source_count: int = 0,
|
||||||
running_count: int = 0,
|
running_count: int = 0,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
return page_shell(
|
return page_shell(
|
||||||
current_path="/settings",
|
current_path=f"{path_prefix}/settings",
|
||||||
eyebrow="Configuration",
|
eyebrow="Configuration",
|
||||||
title="Settings",
|
title="Settings",
|
||||||
description="Global runtime controls for the republisher.",
|
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")[
|
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(
|
action_button(
|
||||||
label="Save settings",
|
label="Save settings",
|
||||||
tone="dark",
|
tone="dark",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
import htpy as h
|
import htpy as h
|
||||||
from htpy import Node, Renderable
|
from htpy import Node, Renderable
|
||||||
|
|
||||||
from repub.components import app_shell
|
from repub.components import app_shell, publisher_shell
|
||||||
|
|
||||||
ON_LOAD_JS = (
|
ON_LOAD_JS = (
|
||||||
"@post(window.location.pathname + "
|
"@post(window.location.pathname + "
|
||||||
|
|
@ -17,6 +17,7 @@ TAB_ID_JS = "self.crypto.randomUUID().substring(0,8)"
|
||||||
def shim_page(
|
def shim_page(
|
||||||
*, datastar_src: str, current_path: str, head: Node | None = None
|
*, datastar_src: str, current_path: str, head: Node | None = None
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
|
shell = app_shell if current_path.startswith("/admin") else publisher_shell
|
||||||
return h.html(lang="en")[
|
return h.html(lang="en")[
|
||||||
h.head[
|
h.head[
|
||||||
h.meta(charset="UTF-8"),
|
h.meta(charset="UTF-8"),
|
||||||
|
|
@ -33,7 +34,7 @@ def shim_page(
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
h.noscript["Your browser does not support JavaScript!"],
|
h.noscript["Your browser does not support JavaScript!"],
|
||||||
app_shell(
|
shell(
|
||||||
current_path=current_path,
|
current_path=current_path,
|
||||||
content=(
|
content=(
|
||||||
h.section[
|
h.section[
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo
|
||||||
return bool(value)
|
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 (
|
return (
|
||||||
h.div[
|
h.div[
|
||||||
h.div(class_="font-semibold text-slate-950")[str(source["name"])],
|
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")[
|
h.div(class_="flex flex-nowrap items-center gap-3 whitespace-nowrap")[
|
||||||
inline_link(
|
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(
|
action_button(
|
||||||
label="Delete",
|
label="Delete",
|
||||||
tone="danger",
|
tone="danger",
|
||||||
post_path=f"/actions/sources/{source['slug']}/delete",
|
post_path=f"{path_prefix}/actions/sources/{source['slug']}/delete",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def sources_table(
|
def sources_table(
|
||||||
*, sources: tuple[Mapping[str, object], ...] | None = None
|
*,
|
||||||
|
sources: tuple[Mapping[str, object], ...] | None = None,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> 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(
|
return table_section(
|
||||||
eyebrow="Inventory",
|
eyebrow="Inventory",
|
||||||
title="Sources",
|
title="Sources",
|
||||||
empty_message="No sources yet.",
|
empty_message="No sources yet.",
|
||||||
headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"),
|
headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"),
|
||||||
rows=rows,
|
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,
|
sources: tuple[Mapping[str, object], ...] | None = None,
|
||||||
running_count: int = 0,
|
running_count: int = 0,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
source_items = sources or ()
|
source_items = sources or ()
|
||||||
return page_shell(
|
return page_shell(
|
||||||
current_path="/sources",
|
current_path=f"{path_prefix}/sources",
|
||||||
eyebrow="Source management",
|
eyebrow="Source management",
|
||||||
title="Sources",
|
title="Sources",
|
||||||
source_count=len(source_items),
|
source_count=len(source_items),
|
||||||
running_count=running_count,
|
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,
|
mode: str,
|
||||||
action_path: str,
|
action_path: str,
|
||||||
source: Mapping[str, object] | None = None,
|
source: Mapping[str, object] | None = None,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
source_type = _value(source, "source_type", "pangea")
|
source_type = _value(source, "source_type", "pangea")
|
||||||
slug = _value(source, "slug")
|
slug = _value(source, "slug")
|
||||||
|
|
@ -397,7 +410,7 @@ def source_form(
|
||||||
h.div(
|
h.div(
|
||||||
class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-6"
|
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(
|
action_button(
|
||||||
label=submit_label,
|
label=submit_label,
|
||||||
tone="dark",
|
tone="dark",
|
||||||
|
|
@ -412,22 +425,27 @@ def source_form(
|
||||||
|
|
||||||
def create_source_page(
|
def create_source_page(
|
||||||
*,
|
*,
|
||||||
action_path: str = "/actions/sources/create",
|
action_path: str = "/admin/actions/sources/create",
|
||||||
source_count: int = 0,
|
source_count: int = 0,
|
||||||
running_count: int = 0,
|
running_count: int = 0,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
actions = (
|
actions = (
|
||||||
muted_action_link(href="/sources", label="Back to sources"),
|
muted_action_link(href=f"{path_prefix}/sources", label="Back to sources"),
|
||||||
header_action_link(href="/runs", label="View runs"),
|
header_action_link(href=f"{path_prefix}/runs", label="View runs"),
|
||||||
)
|
)
|
||||||
return page_shell(
|
return page_shell(
|
||||||
current_path="/sources/create",
|
current_path=f"{path_prefix}/sources/create",
|
||||||
eyebrow="Source creation",
|
eyebrow="Source creation",
|
||||||
title="Create source",
|
title="Create source",
|
||||||
actions=actions,
|
actions=actions,
|
||||||
source_count=source_count,
|
source_count=source_count,
|
||||||
running_count=running_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,
|
action_path: str,
|
||||||
source_count: int = 0,
|
source_count: int = 0,
|
||||||
running_count: int = 0,
|
running_count: int = 0,
|
||||||
|
path_prefix: str = "/admin",
|
||||||
) -> Renderable:
|
) -> Renderable:
|
||||||
actions = (
|
actions = (
|
||||||
muted_action_link(href="/sources", label="Back to sources"),
|
muted_action_link(href=f"{path_prefix}/sources", label="Back to sources"),
|
||||||
header_action_link(href="/runs", label="View runs"),
|
header_action_link(href=f"{path_prefix}/runs", label="View runs"),
|
||||||
)
|
)
|
||||||
return page_shell(
|
return page_shell(
|
||||||
current_path=f"/sources/{slug}/edit",
|
current_path=f"{path_prefix}/sources/{slug}/edit",
|
||||||
eyebrow="Source editing",
|
eyebrow="Source editing",
|
||||||
title="Edit source",
|
title="Edit source",
|
||||||
actions=actions,
|
actions=actions,
|
||||||
source_count=source_count,
|
source_count=source_count,
|
||||||
running_count=running_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
33
repub/web/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
24
repub/web/admin/__init__.py
Normal file
24
repub/web/admin/__init__.py
Normal 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
181
repub/web/admin/actions.py
Normal 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)
|
||||||
1
repub/web/admin/pages/__init__.py
Normal file
1
repub/web/admin/pages/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from __future__ import annotations
|
||||||
26
repub/web/admin/pages/dashboard.py
Normal file
26
repub/web/admin/pages/dashboard.py
Normal 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"),
|
||||||
|
)
|
||||||
42
repub/web/admin/pages/logs.py
Normal file
42
repub/web/admin/pages/logs.py
Normal 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())
|
||||||
26
repub/web/admin/pages/runs.py
Normal file
26
repub/web/admin/pages/runs.py
Normal 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"),
|
||||||
|
)
|
||||||
26
repub/web/admin/pages/settings.py
Normal file
26
repub/web/admin/pages/settings.py
Normal 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))
|
||||||
48
repub/web/admin/pages/sources.py
Normal file
48
repub/web/admin/pages/sources.py
Normal 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),
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
|
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
@ -11,12 +12,10 @@ from typing import Any, TypedDict, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import htpy as h
|
import htpy as h
|
||||||
from datastar_py import ServerSentEventGenerator as SSE
|
|
||||||
from datastar_py.quart import DatastarResponse, read_signals
|
from datastar_py.quart import DatastarResponse, read_signals
|
||||||
from datastar_py.sse import DatastarEvent
|
from datastar_py.sse import DatastarEvent
|
||||||
from htpy import Renderable
|
from htpy import Renderable
|
||||||
from peewee import IntegrityError
|
from quart import Quart, Response, redirect, request
|
||||||
from quart import Quart, Response, request, send_from_directory, url_for
|
|
||||||
|
|
||||||
from repub.auth_headers import (
|
from repub.auth_headers import (
|
||||||
AUTH_MODE_DISABLED,
|
AUTH_MODE_DISABLED,
|
||||||
|
|
@ -29,23 +28,16 @@ from repub.datastar import RefreshBroker, TabStateStore, render_stream
|
||||||
from repub.jobs import (
|
from repub.jobs import (
|
||||||
COMPLETED_EXECUTION_PAGE_SIZE,
|
COMPLETED_EXECUTION_PAGE_SIZE,
|
||||||
JobRuntime,
|
JobRuntime,
|
||||||
clear_completed_executions,
|
|
||||||
load_dashboard_view,
|
load_dashboard_view,
|
||||||
load_execution_log_view,
|
load_execution_log_view,
|
||||||
load_runs_view,
|
load_runs_view,
|
||||||
)
|
)
|
||||||
from repub.model import (
|
from repub.model import (
|
||||||
create_source,
|
|
||||||
delete_job_source,
|
|
||||||
delete_source,
|
|
||||||
initialize_database,
|
initialize_database,
|
||||||
load_job_enabled,
|
load_job_enabled,
|
||||||
load_settings_form,
|
load_settings_form,
|
||||||
load_source_form,
|
load_source_form,
|
||||||
load_sources,
|
load_sources,
|
||||||
save_setting,
|
|
||||||
source_slug_exists,
|
|
||||||
update_source,
|
|
||||||
)
|
)
|
||||||
from repub.pages import (
|
from repub.pages import (
|
||||||
create_source_page,
|
create_source_page,
|
||||||
|
|
@ -67,6 +59,7 @@ TAB_STATE_CLEANER_TASK_KEY = "repub.tab_state_cleaner_task"
|
||||||
SHUTDOWN_EVENT_KEY = "repub.shutdown_event"
|
SHUTDOWN_EVENT_KEY = "repub.shutdown_event"
|
||||||
DEFAULT_LOG_DIR = Path("out/logs")
|
DEFAULT_LOG_DIR = Path("out/logs")
|
||||||
DEFAULT_FEEDS_DIR = Path("out/feeds")
|
DEFAULT_FEEDS_DIR = Path("out/feeds")
|
||||||
|
READER_APP_URL_ENV = "REPUBLISHER_READER_APP_URL"
|
||||||
RUNS_TAB_STATE_KEY = "runs"
|
RUNS_TAB_STATE_KEY = "runs"
|
||||||
TAB_STATE_CLEAN_INTERVAL = timedelta(seconds=10)
|
TAB_STATE_CLEAN_INTERVAL = timedelta(seconds=10)
|
||||||
|
|
||||||
|
|
@ -109,7 +102,7 @@ DEFAULT_PANGEA_CONTENT_FORMAT = "MOBILE_3"
|
||||||
DEFAULT_PANGEA_CONTENT_TYPE = "articles"
|
DEFAULT_PANGEA_CONTENT_TYPE = "articles"
|
||||||
DEFAULT_PANGEA_MAX_ARTICLES = "10"
|
DEFAULT_PANGEA_MAX_ARTICLES = "10"
|
||||||
DEFAULT_PANGEA_OLDEST_ARTICLE = "3"
|
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_STATIC_ASSETS = frozenset({"app.css"})
|
||||||
CACHE_BUSTED_HASH_LENGTH = 12
|
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}"
|
return f"{asset_path.stem}-{truncated_hash}{asset_path.suffix}"
|
||||||
|
|
||||||
|
|
||||||
def versioned_static_asset_href(filename: str) -> str:
|
def versioned_static_asset_href(filename: str, *, prefix: str = "/admin") -> str:
|
||||||
return f"/static/{versioned_static_asset_filename(filename)}"
|
return f"{prefix}/static/{versioned_static_asset_filename(filename)}"
|
||||||
|
|
||||||
|
|
||||||
def _require_cache_busted_static_asset(filename: str) -> None:
|
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}")
|
raise ValueError(f"Unsupported cache-busted static asset: {filename}")
|
||||||
|
|
||||||
|
|
||||||
def create_app(*, dev_mode: bool = False) -> Quart:
|
def create_app(*, dev_mode: bool = False, reader_app_url: str | None = None) -> Quart:
|
||||||
app = Quart(__name__)
|
app = Quart(__name__, static_folder=None)
|
||||||
app.config["REPUB_DB_PATH"] = str(initialize_database())
|
app.config["REPUB_DB_PATH"] = str(initialize_database())
|
||||||
app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR)
|
app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR)
|
||||||
app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR)
|
app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR)
|
||||||
app.config["REPUB_DEV_MODE"] = dev_mode
|
app.config["REPUB_DEV_MODE"] = dev_mode
|
||||||
app.config["REPUB_AUTH_MODE"] = load_auth_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[REFRESH_BROKER_KEY] = RefreshBroker()
|
||||||
app.extensions[JOB_RUNTIME_KEY] = None
|
app.extensions[JOB_RUNTIME_KEY] = None
|
||||||
app.extensions[TAB_STATE_STORE_KEY] = TabStateStore()
|
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")
|
admin_required = _require_role(app, "admin")
|
||||||
publisher_required = _require_role(app, "publisher")
|
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("/")
|
||||||
@app.get("/sources")
|
async def root_redirect() -> Response:
|
||||||
@app.get("/sources/create")
|
return cast(Response, redirect("/publisher"))
|
||||||
@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)
|
|
||||||
|
|
||||||
@app.post("/")
|
from repub.web.routes import register_routes
|
||||||
@admin_required
|
|
||||||
async def dashboard_patch() -> DatastarResponse:
|
|
||||||
return await _page_patch_response(app, lambda _tab_id: render_dashboard(app))
|
|
||||||
|
|
||||||
@app.post("/publisher")
|
register_routes(
|
||||||
@publisher_required
|
app,
|
||||||
async def publisher_patch() -> DatastarResponse:
|
admin_required=admin_required,
|
||||||
return await _page_patch_response(
|
publisher_required=publisher_required,
|
||||||
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())
|
|
||||||
|
|
||||||
@app.before_serving
|
@app.before_serving
|
||||||
async def start_runtime() -> None:
|
async def start_runtime() -> None:
|
||||||
|
|
@ -447,10 +192,20 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return app
|
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(
|
body, etag = _render_shim_page(
|
||||||
stylesheet_href=versioned_static_asset_href("app.css"),
|
stylesheet_href=versioned_static_asset_href("app.css", prefix=static_prefix),
|
||||||
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
|
datastar_src=f"{static_prefix}/static/datastar@1.0.0-RC.8.js",
|
||||||
current_path=current_path,
|
current_path=current_path,
|
||||||
)
|
)
|
||||||
if request.if_none_match.contains(etag):
|
if request.if_none_match.contains(etag):
|
||||||
|
|
@ -525,21 +280,56 @@ def trigger_refresh(
|
||||||
get_refresh_broker(app).publish(event, tab_id=tab_id)
|
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:
|
if app is None:
|
||||||
return dashboard_page_with_data()
|
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(
|
return dashboard_page_with_data(
|
||||||
|
current_path=path_prefix,
|
||||||
|
path_prefix=path_prefix,
|
||||||
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"]),
|
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"]),
|
||||||
|
reader_app_url=cast(str, app.config["REPUB_READER_APP_URL"]) or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def render_publisher(*, current_path: str) -> Renderable:
|
async def render_publisher(
|
||||||
return publisher_page(current_path=current_path)
|
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:
|
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)
|
source = load_source_form(slug)
|
||||||
if source is None:
|
if source is None:
|
||||||
return sources_page(sources=())
|
return sources_page(sources=())
|
||||||
|
sidebar_counts = {} if app is None else _load_sidebar_counts(app)
|
||||||
return edit_source_page(
|
return edit_source_page(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
source=source,
|
source=source,
|
||||||
action_path=f"/actions/sources/{slug}/edit",
|
action_path=f"/admin/actions/sources/{slug}/edit",
|
||||||
**({} if app is None else _load_sidebar_counts(app)),
|
source_count=sidebar_counts.get("source_count", 0),
|
||||||
|
running_count=sidebar_counts.get("running_count", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def render_runs(
|
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:
|
) -> Renderable:
|
||||||
if app is None:
|
if app is None:
|
||||||
return runs_page()
|
return runs_page()
|
||||||
|
|
@ -590,8 +385,10 @@ async def render_runs(
|
||||||
log_dir=app.config["REPUB_LOG_DIR"],
|
log_dir=app.config["REPUB_LOG_DIR"],
|
||||||
completed_page=resolved_completed_page,
|
completed_page=resolved_completed_page,
|
||||||
completed_page_size=COMPLETED_EXECUTION_PAGE_SIZE,
|
completed_page_size=COMPLETED_EXECUTION_PAGE_SIZE,
|
||||||
|
path_prefix=path_prefix,
|
||||||
)
|
)
|
||||||
return runs_page(
|
return runs_page(
|
||||||
|
path_prefix=path_prefix,
|
||||||
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"]),
|
queued_executions=cast(tuple[dict[str, object], ...], view["queued"]),
|
||||||
upcoming_jobs=cast(tuple[dict[str, object], ...], view["upcoming"]),
|
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(
|
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:
|
) -> Renderable:
|
||||||
if app is None:
|
if app is None:
|
||||||
return execution_logs_page(job_id=job_id, execution_id=execution_id)
|
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"],
|
log_dir=app.config["REPUB_LOG_DIR"],
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
execution_id=execution_id,
|
execution_id=execution_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
)
|
)
|
||||||
return execution_logs_page(
|
return execution_logs_page(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
execution_id=execution_id,
|
execution_id=execution_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
log_view={
|
log_view={
|
||||||
"title": log_view.title,
|
"title": log_view.title,
|
||||||
"description": log_view.description,
|
"description": log_view.description,
|
||||||
29
repub/web/publisher/__init__.py
Normal file
29
repub/web/publisher/__init__.py
Normal 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,
|
||||||
|
)
|
||||||
27
repub/web/publisher/actions.py
Normal file
27
repub/web/publisher/actions.py
Normal 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)
|
||||||
1
repub/web/publisher/pages/__init__.py
Normal file
1
repub/web/publisher/pages/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from __future__ import annotations
|
||||||
57
repub/web/publisher/pages/dashboard.py
Normal file
57
repub/web/publisher/pages/dashboard.py
Normal 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
27
repub/web/routes.py
Normal 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
50
repub/web/static.py
Normal 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
|
||||||
|
|
@ -39,6 +39,28 @@ def test_parse_args_supports_dev_mode_flag() -> None:
|
||||||
assert args.dev_mode is True
|
assert args.dev_mode is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_supports_reload_flag() -> None:
|
||||||
|
command, args = parse_args(["serve", "--reload"])
|
||||||
|
|
||||||
|
assert command == "serve"
|
||||||
|
assert args.reload is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_uses_reader_app_url_env_var(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"REPUBLISHER_READER_APP_URL",
|
||||||
|
"https://s3.amazonaws.com/anynews/marti-noticias/index.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
command, args = parse_args(["serve"])
|
||||||
|
|
||||||
|
assert command == "serve"
|
||||||
|
assert (
|
||||||
|
args.reader_app_url
|
||||||
|
== "https://s3.amazonaws.com/anynews/marti-noticias/index.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_args_supports_cleanup_media_defaults() -> None:
|
def test_parse_args_supports_cleanup_media_defaults() -> None:
|
||||||
command, args = parse_args(["cleanup-media"])
|
command, args = parse_args(["cleanup-media"])
|
||||||
|
|
||||||
|
|
@ -169,8 +191,9 @@ def test_entrypoint_passes_dev_mode_to_create_app(monkeypatch) -> None:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.extensions: dict[str, object] = {}
|
self.extensions: dict[str, object] = {}
|
||||||
|
|
||||||
def fake_create_app(*, dev_mode: bool) -> StubApp:
|
def fake_create_app(*, dev_mode: bool, reader_app_url: str | None) -> StubApp:
|
||||||
recorded["dev_mode"] = dev_mode
|
recorded["dev_mode"] = dev_mode
|
||||||
|
recorded["reader_app_url"] = reader_app_url
|
||||||
return StubApp()
|
return StubApp()
|
||||||
|
|
||||||
def fake_install_signal_handlers(stop_event: object) -> None:
|
def fake_install_signal_handlers(stop_event: object) -> None:
|
||||||
|
|
@ -185,6 +208,7 @@ def test_entrypoint_passes_dev_mode_to_create_app(monkeypatch) -> None:
|
||||||
recorded["app"] = app
|
recorded["app"] = app
|
||||||
recorded["host"] = config.bind[0].split(":")[0]
|
recorded["host"] = config.bind[0].split(":")[0]
|
||||||
recorded["port"] = int(config.bind[0].split(":")[1])
|
recorded["port"] = int(config.bind[0].split(":")[1])
|
||||||
|
recorded["reload"] = config.use_reloader
|
||||||
recorded["shutdown_trigger"] = shutdown_trigger
|
recorded["shutdown_trigger"] = shutdown_trigger
|
||||||
shutdown_event = cast(Any, app.extensions["repub.shutdown_event"])
|
shutdown_event = cast(Any, app.extensions["repub.shutdown_event"])
|
||||||
recorded["app_shutdown_event"] = shutdown_event
|
recorded["app_shutdown_event"] = shutdown_event
|
||||||
|
|
@ -198,12 +222,24 @@ def test_entrypoint_passes_dev_mode_to_create_app(monkeypatch) -> None:
|
||||||
monkeypatch.setattr("repub.entrypoint.hypercorn_serve", fake_hypercorn_serve)
|
monkeypatch.setattr("repub.entrypoint.hypercorn_serve", fake_hypercorn_serve)
|
||||||
|
|
||||||
exit_code = entrypoint(
|
exit_code = entrypoint(
|
||||||
["serve", "--dev-mode", "--host", "0.0.0.0", "--port", "9090"]
|
[
|
||||||
|
"serve",
|
||||||
|
"--dev-mode",
|
||||||
|
"--reload",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"9090",
|
||||||
|
"--reader-app-url",
|
||||||
|
"https://reader.example/index.html",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert exit_code == 0
|
assert exit_code == 0
|
||||||
assert recorded["dev_mode"] is True
|
assert recorded["dev_mode"] is True
|
||||||
|
assert recorded["reader_app_url"] == "https://reader.example/index.html"
|
||||||
assert recorded["host"] == "0.0.0.0"
|
assert recorded["host"] == "0.0.0.0"
|
||||||
assert recorded["port"] == 9090
|
assert recorded["port"] == 9090
|
||||||
|
assert recorded["reload"] is True
|
||||||
assert recorded["stop_event"] is recorded["app_shutdown_event"]
|
assert recorded["stop_event"] is recorded["app_shutdown_event"]
|
||||||
assert callable(recorded["shutdown_trigger"])
|
assert callable(recorded["shutdown_trigger"])
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ def _configure_trusted_auth(monkeypatch, tmp_path: Path, name: str) -> None:
|
||||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(tmp_path / f"{name}.db"))
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(tmp_path / f"{name}.db"))
|
||||||
|
|
||||||
|
|
||||||
def _assert_datastar_shell(body: str) -> None:
|
def _assert_datastar_shell(body: str, *, static_prefix: str) -> None:
|
||||||
assert body.startswith("<!doctype html>")
|
assert body.startswith("<!doctype html>")
|
||||||
assert 'id="js"' in body
|
assert 'id="js"' in body
|
||||||
assert 'src="/static/datastar@1.0.0-RC.8.js"' in body
|
assert f'src="{static_prefix}/static/datastar@1.0.0-RC.8.js"' in body
|
||||||
assert 'data-init="@post(window.location.pathname +' in body
|
assert 'data-init="@post(window.location.pathname +' in body
|
||||||
assert '<main id="morph"' in body
|
assert '<main id="morph"' in body
|
||||||
assert "Connecting" in body
|
assert "Connecting" in body
|
||||||
|
|
@ -62,7 +62,7 @@ def test_trusted_header_mode_rejects_admin_route_without_identity(
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
response = await client.get("/")
|
response = await client.get("/admin")
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ def test_trusted_header_mode_ignores_generic_forwarded_identity_headers(
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/",
|
"/admin",
|
||||||
headers={
|
headers={
|
||||||
"X-Forwarded-User": "mallory",
|
"X-Forwarded-User": "mallory",
|
||||||
"X-Forwarded-Email": "mallory@example.org",
|
"X-Forwarded-Email": "mallory@example.org",
|
||||||
|
|
@ -100,7 +100,7 @@ def test_trusted_header_mode_rejects_malformed_trusted_identity_headers(
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/",
|
"/admin",
|
||||||
headers={
|
headers={
|
||||||
"X-Republisher-Auth-Role": "admin",
|
"X-Republisher-Auth-Role": "admin",
|
||||||
"X-Republisher-Auth-Provider": "gp",
|
"X-Republisher-Auth-Provider": "gp",
|
||||||
|
|
@ -120,7 +120,7 @@ def test_trusted_header_mode_allows_admin_identity_on_admin_route(
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
response = await client.get("/", headers=_trusted_headers(role="admin"))
|
response = await client.get("/admin", headers=_trusted_headers(role="admin"))
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
@ -135,7 +135,9 @@ def test_trusted_header_mode_rejects_publisher_identity_on_admin_route(
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
response = await client.get("/", headers=_trusted_headers(role="publisher"))
|
response = await client.get(
|
||||||
|
"/admin", headers=_trusted_headers(role="publisher")
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
@ -152,7 +154,7 @@ def test_trusted_header_mode_rejects_admin_action_without_identity(
|
||||||
app.config["REPUB_LOG_DIR"] = tmp_path / "logs"
|
app.config["REPUB_LOG_DIR"] = tmp_path / "logs"
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post("/actions/completed-executions/clear")
|
response = await client.post("/admin/actions/completed-executions/clear")
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
@ -170,7 +172,7 @@ def test_trusted_header_mode_rejects_publisher_identity_on_admin_action(
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/completed-executions/clear",
|
"/admin/actions/completed-executions/clear",
|
||||||
headers=_trusted_headers(role="publisher"),
|
headers=_trusted_headers(role="publisher"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -194,12 +196,12 @@ def test_trusted_header_mode_allows_publisher_identity_on_publisher_route(
|
||||||
body = await response.get_data(as_text=True)
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
_assert_datastar_shell(body)
|
_assert_datastar_shell(body, static_prefix="/publisher")
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_trusted_header_mode_publisher_post_serves_hello_publishers_morph(
|
def test_trusted_header_mode_publisher_post_serves_publisher_dashboard_morph(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_configure_trusted_auth(monkeypatch, tmp_path, "publisher-post")
|
_configure_trusted_auth(monkeypatch, tmp_path, "publisher-post")
|
||||||
|
|
@ -220,7 +222,7 @@ def test_trusted_header_mode_publisher_post_serves_hello_publishers_morph(
|
||||||
assert raw_connection.headers["Content-Type"] == "text/event-stream"
|
assert raw_connection.headers["Content-Type"] == "text/event-stream"
|
||||||
assert b"event: datastar-patch-elements" in chunk
|
assert b"event: datastar-patch-elements" in chunk
|
||||||
assert b'<main id="morph"' in chunk
|
assert b'<main id="morph"' in chunk
|
||||||
assert b"Hello publishers" in chunk
|
assert b"Published feeds" in chunk
|
||||||
await connection.disconnect()
|
await connection.disconnect()
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
@ -258,12 +260,12 @@ def test_trusted_header_mode_allows_admin_identity_on_admin_publisher_alias(
|
||||||
body = await response.get_data(as_text=True)
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
_assert_datastar_shell(body)
|
_assert_datastar_shell(body, static_prefix="/admin")
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_trusted_header_mode_admin_publisher_post_serves_hello_publishers_morph(
|
def test_trusted_header_mode_admin_publisher_post_serves_publisher_dashboard_morph(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_configure_trusted_auth(monkeypatch, tmp_path, "admin-alias-post")
|
_configure_trusted_auth(monkeypatch, tmp_path, "admin-alias-post")
|
||||||
|
|
@ -284,7 +286,7 @@ def test_trusted_header_mode_admin_publisher_post_serves_hello_publishers_morph(
|
||||||
assert raw_connection.headers["Content-Type"] == "text/event-stream"
|
assert raw_connection.headers["Content-Type"] == "text/event-stream"
|
||||||
assert b"event: datastar-patch-elements" in chunk
|
assert b"event: datastar-patch-elements" in chunk
|
||||||
assert b'<main id="morph"' in chunk
|
assert b'<main id="morph"' in chunk
|
||||||
assert b"Hello publishers" in chunk
|
assert b"Published feeds" in chunk
|
||||||
await connection.disconnect()
|
await connection.disconnect()
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
@ -308,7 +310,79 @@ def test_trusted_header_mode_rejects_publisher_identity_on_admin_publisher_alias
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_trusted_header_mode_keeps_static_assets_public(
|
def test_trusted_header_mode_allows_publisher_identity_on_publisher_run_action(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_configure_trusted_auth(monkeypatch, tmp_path, "publisher-run-action")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/publisher/actions/jobs/999/run-now",
|
||||||
|
headers=_trusted_headers(role="publisher"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_header_mode_rejects_admin_identity_on_publisher_run_action(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_configure_trusted_auth(monkeypatch, tmp_path, "publisher-run-action-admin")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/publisher/actions/jobs/999/run-now",
|
||||||
|
headers=_trusted_headers(role="admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_header_mode_allows_admin_identity_on_admin_publisher_run_action(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_configure_trusted_auth(monkeypatch, tmp_path, "admin-publisher-run-action")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/admin/publisher/actions/jobs/999/run-now",
|
||||||
|
headers=_trusted_headers(role="admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_header_mode_rejects_publisher_identity_on_admin_publisher_run_action(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_configure_trusted_auth(monkeypatch, tmp_path, "admin-publisher-run-publisher")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/admin/publisher/actions/jobs/999/run-now",
|
||||||
|
headers=_trusted_headers(role="publisher"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_header_mode_keeps_section_static_assets_public(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_configure_trusted_auth(monkeypatch, tmp_path, "static-public")
|
_configure_trusted_auth(monkeypatch, tmp_path, "static-public")
|
||||||
|
|
@ -316,9 +390,17 @@ def test_trusted_header_mode_keeps_static_assets_public(
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
response = await client.get("/static/datastar@1.0.0-RC.8.js")
|
for path in (
|
||||||
|
"/admin/static/datastar@1.0.0-RC.8.js",
|
||||||
|
"/publisher/static/datastar@1.0.0-RC.8.js",
|
||||||
|
):
|
||||||
|
response = await client.get(path)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
root_response = await client.get("/static/datastar@1.0.0-RC.8.js")
|
||||||
|
|
||||||
|
assert root_response.status_code == 404
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from repub.jobs import load_runs_view
|
from repub.jobs import load_dashboard_view, load_runs_view
|
||||||
from repub.model import (
|
from repub.model import (
|
||||||
Job,
|
Job,
|
||||||
JobExecution,
|
JobExecution,
|
||||||
|
|
@ -232,6 +233,90 @@ def test_load_runs_view_projects_queued_executions_in_fifo_order(
|
||||||
assert view["queued"][1]["move_down_disabled"] is True
|
assert view["queued"][1]["move_down_disabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_runs_view_projects_admin_prefixed_action_and_log_paths(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
initialize_database(tmp_path / "jobs-admin-path-prefix.db")
|
||||||
|
source = create_source(
|
||||||
|
name="Admin prefixed source",
|
||||||
|
slug="admin-prefixed-source",
|
||||||
|
source_type="feed",
|
||||||
|
notes="",
|
||||||
|
spider_arguments="",
|
||||||
|
enabled=True,
|
||||||
|
cron_minute="*/5",
|
||||||
|
cron_hour="*",
|
||||||
|
cron_day_of_month="*",
|
||||||
|
cron_day_of_week="*",
|
||||||
|
cron_month="*",
|
||||||
|
feed_url="https://example.com/admin-prefixed.xml",
|
||||||
|
)
|
||||||
|
with database.writer():
|
||||||
|
job = Job.get(Job.source == source)
|
||||||
|
running = JobExecution.create(
|
||||||
|
job=job,
|
||||||
|
running_status=JobExecutionStatus.RUNNING,
|
||||||
|
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
view = load_runs_view(
|
||||||
|
log_dir=tmp_path / "out" / "logs",
|
||||||
|
now=datetime(2026, 3, 30, 12, 30, tzinfo=UTC),
|
||||||
|
path_prefix="/admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
view["running"][0]["log_href"]
|
||||||
|
== f"/admin/job/{job.id}/execution/{int(running.get_id())}/logs"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
view["running"][0]["cancel_post_path"]
|
||||||
|
== f"/admin/actions/executions/{int(running.get_id())}/cancel"
|
||||||
|
)
|
||||||
|
assert view["upcoming"][0]["run_post_path"] == (
|
||||||
|
f"/admin/actions/jobs/{job.id}/run-now"
|
||||||
|
)
|
||||||
|
assert view["upcoming"][0]["toggle_post_path"] == (
|
||||||
|
f"/admin/actions/jobs/{job.id}/toggle-enabled"
|
||||||
|
)
|
||||||
|
assert view["upcoming"][0]["delete_post_path"] == (
|
||||||
|
f"/admin/actions/jobs/{job.id}/delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_dashboard_view_projects_publisher_run_action_but_root_feed_links(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
initialize_database(tmp_path / "jobs-publisher-dashboard-prefix.db")
|
||||||
|
source = create_source(
|
||||||
|
name="Publisher prefixed source",
|
||||||
|
slug="publisher-prefixed-source",
|
||||||
|
source_type="feed",
|
||||||
|
notes="",
|
||||||
|
spider_arguments="",
|
||||||
|
enabled=True,
|
||||||
|
cron_minute="*/5",
|
||||||
|
cron_hour="*",
|
||||||
|
cron_day_of_month="*",
|
||||||
|
cron_day_of_week="*",
|
||||||
|
cron_month="*",
|
||||||
|
feed_url="https://example.com/publisher-prefixed.xml",
|
||||||
|
)
|
||||||
|
with database.reader():
|
||||||
|
job = Job.get(Job.source == source)
|
||||||
|
|
||||||
|
view = load_dashboard_view(
|
||||||
|
log_dir=tmp_path / "out" / "logs",
|
||||||
|
now=datetime(2026, 3, 30, 12, 30, tzinfo=UTC),
|
||||||
|
path_prefix="/publisher",
|
||||||
|
)
|
||||||
|
source_feeds = cast(tuple[dict[str, object], ...], view["source_feeds"])
|
||||||
|
source_feed = source_feeds[0]
|
||||||
|
|
||||||
|
assert source_feed["feed_href"] == "/feeds/publisher-prefixed-source/feed.rss"
|
||||||
|
assert source_feed["run_post_path"] == (f"/publisher/actions/jobs/{job.id}/run-now")
|
||||||
|
|
||||||
|
|
||||||
def test_load_runs_view_keeps_queued_jobs_in_scheduled_jobs(
|
def test_load_runs_view_keeps_queued_jobs_in_scheduled_jobs(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1048,7 +1048,7 @@ def test_render_runs_uses_database_backed_jobs_and_executions(
|
||||||
assert "Running jobs" in body
|
assert "Running jobs" in body
|
||||||
assert "Scheduled jobs" in body
|
assert "Scheduled jobs" in body
|
||||||
assert "Completed job executions" in body
|
assert "Completed job executions" in body
|
||||||
assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
assert f"/admin/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
||||||
assert "Succeeded" in body
|
assert "Succeeded" in body
|
||||||
assert "Run now" in body
|
assert "Run now" in body
|
||||||
|
|
||||||
|
|
@ -1138,7 +1138,7 @@ def test_delete_job_action_removes_source_job_and_execution_history(
|
||||||
running_status=JobExecutionStatus.SUCCEEDED,
|
running_status=JobExecutionStatus.SUCCEEDED,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.post(f"/actions/jobs/{job.id}/delete")
|
response = await client.post(f"/admin/actions/jobs/{job.id}/delete")
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -1185,7 +1185,7 @@ def test_delete_source_action_removes_source_job_and_execution_history(
|
||||||
running_status=JobExecutionStatus.SUCCEEDED,
|
running_status=JobExecutionStatus.SUCCEEDED,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.post("/actions/sources/delete-source-row/delete")
|
response = await client.post("/admin/actions/sources/delete-source-row/delete")
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert (
|
assert (
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ from repub.web import (
|
||||||
render_dashboard,
|
render_dashboard,
|
||||||
render_edit_source,
|
render_edit_source,
|
||||||
render_execution_logs,
|
render_execution_logs,
|
||||||
|
render_publisher,
|
||||||
render_runs,
|
render_runs,
|
||||||
render_settings,
|
render_settings,
|
||||||
render_sources,
|
render_sources,
|
||||||
|
|
@ -55,7 +56,12 @@ def _db_writer(fn):
|
||||||
|
|
||||||
|
|
||||||
def test_web_routes_do_not_access_peewee_models_directly() -> None:
|
def test_web_routes_do_not_access_peewee_models_directly() -> None:
|
||||||
web_source = Path("repub/web.py").read_text(encoding="utf-8")
|
web_paths = (
|
||||||
|
tuple(sorted(Path("repub/web").rglob("*.py")))
|
||||||
|
if Path("repub/web").is_dir()
|
||||||
|
else (Path("repub/web.py"),)
|
||||||
|
)
|
||||||
|
web_source = "\n".join(path.read_text(encoding="utf-8") for path in web_paths)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
re.search(
|
re.search(
|
||||||
|
|
@ -111,7 +117,7 @@ def test_action_button_omits_post_handler_when_disabled() -> None:
|
||||||
action_button(
|
action_button(
|
||||||
label="Queued",
|
label="Queued",
|
||||||
disabled=True,
|
disabled=True,
|
||||||
post_path="/actions/jobs/7/run-now",
|
post_path="/admin/actions/jobs/7/run-now",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -138,11 +144,13 @@ def test_action_button_supports_datastar_pointerdown_post() -> None:
|
||||||
action_button(
|
action_button(
|
||||||
label="Delete",
|
label="Delete",
|
||||||
tone="danger",
|
tone="danger",
|
||||||
post_path="/actions/jobs/7/delete",
|
post_path="/admin/actions/jobs/7/delete",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert 'data-on:pointerdown="@post('/actions/jobs/7/delete')"' in markup
|
assert (
|
||||||
|
'data-on:pointerdown="@post('/admin/actions/jobs/7/delete')"' in markup
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> (
|
def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> (
|
||||||
|
|
@ -163,7 +171,7 @@ def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_ti
|
||||||
"status_tone": "done",
|
"status_tone": "done",
|
||||||
"stats": "1 requests • 1 items • 1 bytes",
|
"stats": "1 requests • 1 items • 1 bytes",
|
||||||
"summary": "Worker exited successfully",
|
"summary": "Worker exited successfully",
|
||||||
"log_href": "/job/7/execution/42/logs",
|
"log_href": "/admin/job/7/execution/42/logs",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -195,7 +203,7 @@ def test_runs_page_renders_completed_execution_state_cell_with_duration_and_end_
|
||||||
"status_tone": "done",
|
"status_tone": "done",
|
||||||
"stats": "1 requests • 1 items • 1 bytes",
|
"stats": "1 requests • 1 items • 1 bytes",
|
||||||
"summary": "Worker exited successfully",
|
"summary": "Worker exited successfully",
|
||||||
"log_href": "/job/7/execution/42/logs",
|
"log_href": "/admin/job/7/execution/42/logs",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -234,8 +242,8 @@ def test_runs_page_renders_combined_running_jobs_table() -> None:
|
||||||
"status_tone": "idle",
|
"status_tone": "idle",
|
||||||
"run_label": "Queued",
|
"run_label": "Queued",
|
||||||
"run_disabled": True,
|
"run_disabled": True,
|
||||||
"run_post_path": "/actions/jobs/7/run-now",
|
"run_post_path": "/admin/actions/jobs/7/run-now",
|
||||||
"cancel_post_path": "/actions/queued-executions/42/cancel",
|
"cancel_post_path": "/admin/actions/queued-executions/42/cancel",
|
||||||
"move_up_disabled": True,
|
"move_up_disabled": True,
|
||||||
"move_up_post_path": None,
|
"move_up_post_path": None,
|
||||||
"move_down_disabled": True,
|
"move_down_disabled": True,
|
||||||
|
|
@ -249,7 +257,7 @@ def test_runs_page_renders_combined_running_jobs_table() -> None:
|
||||||
assert "queued-source" in body
|
assert "queued-source" in body
|
||||||
assert ">Queued<" in body
|
assert ">Queued<" in body
|
||||||
assert "bg-amber-200 text-amber-950" in body
|
assert "bg-amber-200 text-amber-950" in body
|
||||||
assert "/actions/queued-executions/42/cancel" in body
|
assert "/admin/actions/queued-executions/42/cancel" in body
|
||||||
|
|
||||||
|
|
||||||
def test_runs_page_renders_running_state_cell_with_duration_and_started_at() -> None:
|
def test_runs_page_renders_running_state_cell_with_duration_and_started_at() -> None:
|
||||||
|
|
@ -269,9 +277,9 @@ def test_runs_page_renders_running_state_cell_with_duration_and_started_at() ->
|
||||||
"status": "Running",
|
"status": "Running",
|
||||||
"stats": "1 requests • 1 items • 1 byte",
|
"stats": "1 requests • 1 items • 1 byte",
|
||||||
"worker": "streaming stats from worker",
|
"worker": "streaming stats from worker",
|
||||||
"log_href": "/job/1/execution/11/logs",
|
"log_href": "/admin/job/1/execution/11/logs",
|
||||||
"cancel_label": "Stop",
|
"cancel_label": "Stop",
|
||||||
"cancel_post_path": "/actions/executions/11/cancel",
|
"cancel_post_path": "/admin/actions/executions/11/cancel",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -316,9 +324,9 @@ def test_runs_page_moves_scheduled_jobs_state_column_to_second_position() -> Non
|
||||||
"run_disabled": False,
|
"run_disabled": False,
|
||||||
"run_reason": "Ready",
|
"run_reason": "Ready",
|
||||||
"toggle_label": "Disable",
|
"toggle_label": "Disable",
|
||||||
"toggle_post_path": "/actions/jobs/7/toggle-enabled",
|
"toggle_post_path": "/admin/actions/jobs/7/toggle-enabled",
|
||||||
"run_post_path": "/actions/jobs/7/run-now",
|
"run_post_path": "/admin/actions/jobs/7/run-now",
|
||||||
"delete_post_path": "/actions/jobs/7/delete",
|
"delete_post_path": "/admin/actions/jobs/7/delete",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -371,7 +379,7 @@ def test_runs_page_renders_clear_completed_button_and_pagination() -> None:
|
||||||
"status_tone": "done",
|
"status_tone": "done",
|
||||||
"stats": "1 requests • 1 items • 1 bytes",
|
"stats": "1 requests • 1 items • 1 bytes",
|
||||||
"summary": "Worker exited successfully",
|
"summary": "Worker exited successfully",
|
||||||
"log_href": f"/job/7/execution/{index}/logs",
|
"log_href": f"/admin/job/7/execution/{index}/logs",
|
||||||
}
|
}
|
||||||
for index in range(1, 21)
|
for index in range(1, 21)
|
||||||
)
|
)
|
||||||
|
|
@ -385,29 +393,41 @@ def test_runs_page_renders_clear_completed_button_and_pagination() -> None:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "/actions/completed-executions/clear" in body
|
assert "/admin/actions/completed-executions/clear" in body
|
||||||
assert ">Clear history<" in body
|
assert ">Clear history<" in body
|
||||||
assert "Showing" in body
|
assert "Showing" in body
|
||||||
assert "21" in body
|
assert "21" in body
|
||||||
assert "@post('/actions/runs/completed-page/1')" in body
|
assert "@post('/admin/actions/runs/completed-page/1')" in body
|
||||||
assert "@post('/actions/runs/completed-page/2')" in body
|
assert "@post('/admin/actions/runs/completed-page/2')" in body
|
||||||
assert 'aria-current="page"' in body
|
assert 'aria-current="page"' in body
|
||||||
|
|
||||||
|
|
||||||
def test_root_get_serves_datastar_shim() -> None:
|
def test_root_get_redirects_to_publisher() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
response = await client.get("/")
|
response = await client.get("/")
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers["Location"] == "/publisher"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_get_serves_datastar_shim_with_admin_static_assets() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
response = await client.get("/admin")
|
||||||
body = await response.get_data(as_text=True)
|
body = await response.get_data(as_text=True)
|
||||||
stylesheet_href = versioned_static_asset_href("app.css")
|
stylesheet_href = versioned_static_asset_href("app.css", prefix="/admin")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["ETag"]
|
assert response.headers["ETag"]
|
||||||
assert body.startswith("<!doctype html>")
|
assert body.startswith("<!doctype html>")
|
||||||
assert f'<link rel="stylesheet" href="{stylesheet_href}">' in body
|
assert f'<link rel="stylesheet" href="{stylesheet_href}">' in body
|
||||||
assert (
|
assert (
|
||||||
'<script id="js" defer type="module" src="/static/datastar@1.0.0-RC.8.js"></script>'
|
'<script id="js" defer type="module" src="/admin/static/datastar@1.0.0-RC.8.js"></script>'
|
||||||
in body
|
in body
|
||||||
)
|
)
|
||||||
assert 'data-signals:tabid="self.crypto.randomUUID().substring(0,8)"' in body
|
assert 'data-signals:tabid="self.crypto.randomUUID().substring(0,8)"' in body
|
||||||
|
|
@ -417,47 +437,110 @@ def test_root_get_serves_datastar_shim() -> None:
|
||||||
assert '<main id="morph"' in body
|
assert '<main id="morph"' in body
|
||||||
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
||||||
assert "lg:px-5 lg:py-4" in body
|
assert "lg:px-5 lg:py-4" in body
|
||||||
assert 'href="/sources"' in body
|
assert 'href="/admin/sources"' in body
|
||||||
assert 'href="/runs"' in body
|
assert 'href="/admin/runs"' in body
|
||||||
assert 'href="/settings"' in body
|
assert 'href="/admin/settings"' in body
|
||||||
assert "Connecting" in body
|
assert "Connecting" in body
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_publisher_get_serves_datastar_shim_with_publisher_static_assets() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
response = await client.get("/publisher")
|
||||||
|
body = await response.get_data(as_text=True)
|
||||||
|
stylesheet_href = versioned_static_asset_href("app.css", prefix="/publisher")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert f'<link rel="stylesheet" href="{stylesheet_href}">' in body
|
||||||
|
assert (
|
||||||
|
'<script id="js" defer type="module" src="/publisher/static/datastar@1.0.0-RC.8.js"></script>'
|
||||||
|
in body
|
||||||
|
)
|
||||||
|
assert '<main id="morph"' in body
|
||||||
|
assert "Connecting" in body
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_root_level_admin_routes_do_not_serve_admin_pages() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
for path in (
|
||||||
|
"/sources",
|
||||||
|
"/sources/create",
|
||||||
|
"/runs",
|
||||||
|
"/settings",
|
||||||
|
"/job/1/execution/1/logs",
|
||||||
|
):
|
||||||
|
response = await client.get(path)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_versioned_static_asset_href_uses_truncated_file_hash() -> None:
|
def test_versioned_static_asset_href_uses_truncated_file_hash() -> None:
|
||||||
href = versioned_static_asset_href("app.css")
|
href = versioned_static_asset_href("app.css")
|
||||||
|
|
||||||
assert re.fullmatch(r"/static/app-[0-9a-f]{12}\.css", href)
|
assert re.fullmatch(r"/admin/static/app-[0-9a-f]{12}\.css", href)
|
||||||
|
|
||||||
|
|
||||||
def test_versioned_static_asset_route_serves_registered_css_file() -> None:
|
def test_versioned_static_asset_href_supports_publisher_prefix() -> None:
|
||||||
|
href = versioned_static_asset_href("app.css", prefix="/publisher")
|
||||||
|
|
||||||
|
assert re.fullmatch(r"/publisher/static/app-[0-9a-f]{12}\.css", href)
|
||||||
|
|
||||||
|
|
||||||
|
def test_section_static_asset_routes_serve_registered_css_file() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
expected = (
|
expected = (
|
||||||
Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css"
|
Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
response = await client.get("/static/app-deadbeefcafe.css")
|
for path in (
|
||||||
body = await response.get_data(as_text=True)
|
"/admin/static/app-deadbeefcafe.css",
|
||||||
|
"/publisher/static/app-deadbeefcafe.css",
|
||||||
|
):
|
||||||
|
response = await client.get(path)
|
||||||
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.mimetype == "text/css"
|
assert response.mimetype == "text/css"
|
||||||
assert body == expected
|
assert body == expected
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_versioned_static_asset_route_preserves_existing_hyphenated_files() -> None:
|
def test_section_static_asset_routes_preserve_existing_hyphenated_files() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
for path in (
|
||||||
|
"/admin/static/datastar@1.0.0-RC.8.js",
|
||||||
|
"/publisher/static/datastar@1.0.0-RC.8.js",
|
||||||
|
):
|
||||||
|
response = await client.get(path)
|
||||||
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == "text/javascript"
|
||||||
|
assert body.startswith("// Datastar v1.0.0-RC.8")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_static_asset_route_no_longer_serves_app_assets() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
response = await client.get("/static/datastar@1.0.0-RC.8.js")
|
response = await client.get("/static/datastar@1.0.0-RC.8.js")
|
||||||
body = await response.get_data(as_text=True)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 404
|
||||||
assert response.mimetype == "text/javascript"
|
|
||||||
assert body.startswith("// Datastar v1.0.0-RC.8")
|
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
@ -473,14 +556,14 @@ def test_create_app_bootstraps_default_database_path(
|
||||||
assert (tmp_path / "republisher.db").exists()
|
assert (tmp_path / "republisher.db").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_root_get_honors_if_none_match() -> None:
|
def test_admin_get_honors_if_none_match() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
|
|
||||||
initial = await client.get("/")
|
initial = await client.get("/admin")
|
||||||
etag = initial.headers["ETag"]
|
etag = initial.headers["ETag"]
|
||||||
|
|
||||||
response = await client.get("/", headers={"If-None-Match": etag})
|
response = await client.get("/admin", headers={"If-None-Match": etag})
|
||||||
|
|
||||||
assert response.status_code == 304
|
assert response.status_code == 304
|
||||||
assert response.headers["ETag"] == etag
|
assert response.headers["ETag"] == etag
|
||||||
|
|
@ -491,7 +574,7 @@ def test_root_get_honors_if_none_match() -> None:
|
||||||
def test_dashboard_post_serves_morph_component() -> None:
|
def test_dashboard_post_serves_morph_component() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
client = create_app().test_client()
|
client = create_app().test_client()
|
||||||
async with client.request("/?u=shim", method="POST") as connection:
|
async with client.request("/admin?u=shim", method="POST") as connection:
|
||||||
await connection.send_complete()
|
await connection.send_complete()
|
||||||
chunk = await asyncio.wait_for(connection.receive(), timeout=1)
|
chunk = await asyncio.wait_for(connection.receive(), timeout=1)
|
||||||
raw_connection = cast(Any, connection)
|
raw_connection = cast(Any, connection)
|
||||||
|
|
@ -651,15 +734,46 @@ def test_render_dashboard_shows_dashboard_information_architecture(
|
||||||
assert "Operational snapshot" in body
|
assert "Operational snapshot" in body
|
||||||
assert "Running jobs" in body
|
assert "Running jobs" in body
|
||||||
assert "Published feeds" in body
|
assert "Published feeds" in body
|
||||||
assert 'href="/sources"' in body
|
assert 'href="/admin/sources"' in body
|
||||||
assert 'href="/runs"' in body
|
assert 'href="/admin/runs"' in body
|
||||||
assert "Create source" in body
|
assert 'href="/admin/publisher"' in body
|
||||||
|
assert "Publisher View" in body
|
||||||
|
assert "Create source" not in body
|
||||||
|
assert "View sources" not in body
|
||||||
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
||||||
assert "lg:px-5 lg:py-4" in body
|
assert "lg:px-5 lg:py-4" in body
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_dashboard_shows_configured_reader_app_link(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
db_path = tmp_path / "dashboard-reader-link.db"
|
||||||
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"REPUBLISHER_READER_APP_URL",
|
||||||
|
"https://s3.amazonaws.com/anynews/marti-noticias/index.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
app = create_app()
|
||||||
|
body = str(await render_dashboard(app))
|
||||||
|
link_match = re.search(
|
||||||
|
r'<a href="https://s3\.amazonaws\.com/anynews/marti-noticias/index\.html"[^>]*>Open AnyNews</a>',
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert link_match is not None
|
||||||
|
link = link_match.group(0)
|
||||||
|
assert 'target="_blank"' in link
|
||||||
|
assert 'rel="noopener noreferrer"' in link
|
||||||
|
assert "bg-white" in link
|
||||||
|
assert "bg-amber-400" not in link
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_render_dashboard_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None:
|
def test_render_dashboard_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None:
|
||||||
db_path = tmp_path / "dashboard-empty.db"
|
db_path = tmp_path / "dashboard-empty.db"
|
||||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||||
|
|
@ -688,9 +802,9 @@ def test_dashboard_running_table_matches_runs_page_live_table_markup() -> None:
|
||||||
"status": "Running",
|
"status": "Running",
|
||||||
"stats": "1 requests • 1 items • 1 byte",
|
"stats": "1 requests • 1 items • 1 byte",
|
||||||
"worker": "streaming stats from worker",
|
"worker": "streaming stats from worker",
|
||||||
"log_href": "/job/1/execution/11/logs",
|
"log_href": "/admin/job/1/execution/11/logs",
|
||||||
"cancel_label": "Stop",
|
"cancel_label": "Stop",
|
||||||
"cancel_post_path": "/actions/executions/11/cancel",
|
"cancel_post_path": "/admin/actions/executions/11/cancel",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
queued_executions = (
|
queued_executions = (
|
||||||
|
|
@ -706,8 +820,8 @@ def test_dashboard_running_table_matches_runs_page_live_table_markup() -> None:
|
||||||
"status_tone": "idle",
|
"status_tone": "idle",
|
||||||
"run_label": "Queued",
|
"run_label": "Queued",
|
||||||
"run_disabled": True,
|
"run_disabled": True,
|
||||||
"run_post_path": "/actions/jobs/2/run-now",
|
"run_post_path": "/admin/actions/jobs/2/run-now",
|
||||||
"cancel_post_path": "/actions/queued-executions/22/cancel",
|
"cancel_post_path": "/admin/actions/queued-executions/22/cancel",
|
||||||
"move_up_disabled": True,
|
"move_up_disabled": True,
|
||||||
"move_up_post_path": None,
|
"move_up_post_path": None,
|
||||||
"move_down_disabled": True,
|
"move_down_disabled": True,
|
||||||
|
|
@ -733,7 +847,7 @@ def test_dashboard_running_table_matches_runs_page_live_table_markup() -> None:
|
||||||
assert "running-source" in dashboard_body
|
assert "running-source" in dashboard_body
|
||||||
assert "queued-source" in dashboard_body
|
assert "queued-source" in dashboard_body
|
||||||
assert "bg-sky-100 text-sky-800" in dashboard_body
|
assert "bg-sky-100 text-sky-800" in dashboard_body
|
||||||
assert "/job/1/execution/11/logs" in dashboard_body
|
assert "/admin/job/1/execution/11/logs" in dashboard_body
|
||||||
assert runs_body.count(">State<") >= 1
|
assert runs_body.count(">State<") >= 1
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -829,7 +943,11 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
|
||||||
|
|
||||||
source_feeds = cast(
|
source_feeds = cast(
|
||||||
tuple[dict[str, object], ...],
|
tuple[dict[str, object], ...],
|
||||||
load_dashboard_view(log_dir=log_dir, now=reference_time)["source_feeds"],
|
load_dashboard_view(
|
||||||
|
log_dir=log_dir,
|
||||||
|
now=reference_time,
|
||||||
|
path_prefix="/admin",
|
||||||
|
)["source_feeds"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert source_feeds == (
|
assert source_feeds == (
|
||||||
|
|
@ -845,7 +963,7 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
|
||||||
"next_run": "Not scheduled",
|
"next_run": "Not scheduled",
|
||||||
"next_run_at": None,
|
"next_run_at": None,
|
||||||
"run_disabled": False,
|
"run_disabled": False,
|
||||||
"run_post_path": f"/actions/jobs/{available_job.id}/run-now",
|
"run_post_path": f"/admin/actions/jobs/{available_job.id}/run-now",
|
||||||
"artifact_footprint": "3.0 KB",
|
"artifact_footprint": "3.0 KB",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -860,7 +978,7 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
|
||||||
"next_run": "Not scheduled",
|
"next_run": "Not scheduled",
|
||||||
"next_run_at": None,
|
"next_run_at": None,
|
||||||
"run_disabled": False,
|
"run_disabled": False,
|
||||||
"run_post_path": f"/actions/jobs/{missing_job.id}/run-now",
|
"run_post_path": f"/admin/actions/jobs/{missing_job.id}/run-now",
|
||||||
"artifact_footprint": "0 B",
|
"artifact_footprint": "0 B",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -991,19 +1109,164 @@ def test_render_dashboard_shows_source_feed_links_and_statuses(
|
||||||
assert "Never published" in body
|
assert "Never published" in body
|
||||||
assert "Next run" in body
|
assert "Next run" in body
|
||||||
assert ">Run now<" in body
|
assert ">Run now<" in body
|
||||||
assert f"/actions/jobs/{published_job.id}/run-now" in body
|
assert f"/admin/actions/jobs/{published_job.id}/run-now" in body
|
||||||
assert f"/actions/jobs/{missing_job.id}/run-now" in body
|
assert f"/admin/actions/jobs/{missing_job.id}/run-now" in body
|
||||||
assert "data-next-run-at" in body
|
assert "data-next-run-at" in body
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_publisher_shows_published_feeds_with_publisher_actions(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
db_path = tmp_path / "publisher-render.db"
|
||||||
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||||
|
app = create_app()
|
||||||
|
app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs"
|
||||||
|
|
||||||
|
source = create_source(
|
||||||
|
name="Publisher source",
|
||||||
|
slug="publisher-source",
|
||||||
|
source_type="feed",
|
||||||
|
notes="",
|
||||||
|
spider_arguments="",
|
||||||
|
enabled=True,
|
||||||
|
cron_minute="*/5",
|
||||||
|
cron_hour="*",
|
||||||
|
cron_day_of_month="*",
|
||||||
|
cron_day_of_week="*",
|
||||||
|
cron_month="*",
|
||||||
|
feed_url="https://example.com/publisher.xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
feed_path = tmp_path / "out" / "feeds" / "publisher-source" / "feed.rss"
|
||||||
|
feed_path.parent.mkdir(parents=True)
|
||||||
|
feed_path.write_text("<rss/>\n", encoding="utf-8")
|
||||||
|
job = _db_reader(lambda: Job.get(Job.source == source))
|
||||||
|
|
||||||
|
body = str(await render_publisher(app, current_path="/publisher"))
|
||||||
|
|
||||||
|
assert body.count("Published feeds") == 1
|
||||||
|
assert "Publisher source" in body
|
||||||
|
assert 'href="/feeds/publisher-source/feed.rss"' in body
|
||||||
|
assert "Available" in body
|
||||||
|
assert "Next run" in body
|
||||||
|
assert "Disk usage" not in body
|
||||||
|
assert f"/publisher/actions/jobs/{job.id}/run-now" in body
|
||||||
|
assert f"/admin/actions/jobs/{job.id}/run-now" not in body
|
||||||
|
assert 'href="/admin/sources"' not in body
|
||||||
|
assert "Create source" not in body
|
||||||
|
assert "Manage sources" not in body
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_publisher_shows_live_work_without_admin_controls(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
db_path = tmp_path / "publisher-live-work.db"
|
||||||
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||||
|
app = create_app()
|
||||||
|
app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs"
|
||||||
|
|
||||||
|
running_source = create_source(
|
||||||
|
name="Publisher running source",
|
||||||
|
slug="publisher-running-source",
|
||||||
|
source_type="feed",
|
||||||
|
notes="",
|
||||||
|
spider_arguments="",
|
||||||
|
enabled=True,
|
||||||
|
cron_minute="*/5",
|
||||||
|
cron_hour="*",
|
||||||
|
cron_day_of_month="*",
|
||||||
|
cron_day_of_week="*",
|
||||||
|
cron_month="*",
|
||||||
|
feed_url="https://example.com/running.xml",
|
||||||
|
)
|
||||||
|
queued_source = create_source(
|
||||||
|
name="Publisher queued source",
|
||||||
|
slug="publisher-queued-source",
|
||||||
|
source_type="feed",
|
||||||
|
notes="",
|
||||||
|
spider_arguments="",
|
||||||
|
enabled=True,
|
||||||
|
cron_minute="*/5",
|
||||||
|
cron_hour="*",
|
||||||
|
cron_day_of_month="*",
|
||||||
|
cron_day_of_week="*",
|
||||||
|
cron_month="*",
|
||||||
|
feed_url="https://example.com/queued.xml",
|
||||||
|
)
|
||||||
|
running_job, running_execution, queued_execution = _db_writer(
|
||||||
|
lambda: (
|
||||||
|
Job.get(Job.source == running_source),
|
||||||
|
JobExecution.create(
|
||||||
|
job=Job.get(Job.source == running_source),
|
||||||
|
running_status=JobExecutionStatus.RUNNING,
|
||||||
|
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
JobExecution.create(
|
||||||
|
job=Job.get(Job.source == queued_source),
|
||||||
|
running_status=JobExecutionStatus.PENDING,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
body = str(await render_publisher(app, current_path="/publisher"))
|
||||||
|
|
||||||
|
assert body.index("Published feeds") < body.index("Live work")
|
||||||
|
assert "Running jobs" in body
|
||||||
|
assert "Publisher running source" in body
|
||||||
|
assert "Publisher queued source" in body
|
||||||
|
assert "Queue position #1" in body
|
||||||
|
assert f"/publisher/job/{running_job.id}/execution/" not in body
|
||||||
|
assert (
|
||||||
|
f"/publisher/actions/executions/{int(running_execution.get_id())}/cancel"
|
||||||
|
not in body
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
f"/publisher/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
||||||
|
not in body
|
||||||
|
)
|
||||||
|
assert "View log" not in body
|
||||||
|
assert ">Stop<" not in body
|
||||||
|
assert ">Cancel<" not in body
|
||||||
|
assert body.count(">Actions<") == 1
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_publisher_shows_configured_reader_app_link(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
db_path = tmp_path / "publisher-reader-link.db"
|
||||||
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"REPUBLISHER_READER_APP_URL",
|
||||||
|
"https://s3.amazonaws.com/anynews/marti-noticias/index.html",
|
||||||
|
)
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
body = str(await render_publisher(app, current_path="/publisher"))
|
||||||
|
|
||||||
|
assert (
|
||||||
|
'href="https://s3.amazonaws.com/anynews/marti-noticias/index.html"' in body
|
||||||
|
)
|
||||||
|
assert 'target="_blank"' in body
|
||||||
|
assert "Open AnyNews" in body
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_render_sources_shows_table_and_create_link() -> None:
|
def test_render_sources_shows_table_and_create_link() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
body = str(await render_sources())
|
body = str(await render_sources())
|
||||||
|
|
||||||
assert ">Sources<" in body
|
assert ">Sources<" in body
|
||||||
assert 'href="/sources/create"' in body
|
assert 'href="/admin/sources/create"' in body
|
||||||
assert "No sources yet." in body
|
assert "No sources yet." in body
|
||||||
assert "guardian-feed" not in body
|
assert "guardian-feed" not in body
|
||||||
assert "podcast-audio" not in body
|
assert "podcast-audio" not in body
|
||||||
|
|
@ -1048,12 +1311,12 @@ def test_render_sources_shows_live_sidebar_badges(monkeypatch, tmp_path: Path) -
|
||||||
body = str(await render_sources(app))
|
body = str(await render_sources(app))
|
||||||
|
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r'href="/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>2</span>',
|
r'href="/admin/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>2</span>',
|
||||||
body,
|
body,
|
||||||
re.S,
|
re.S,
|
||||||
)
|
)
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r'href="/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
r'href="/admin/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
||||||
body,
|
body,
|
||||||
re.S,
|
re.S,
|
||||||
)
|
)
|
||||||
|
|
@ -1086,12 +1349,12 @@ def test_render_dashboard_shows_live_sidebar_badges(
|
||||||
body = str(await render_dashboard(app))
|
body = str(await render_dashboard(app))
|
||||||
|
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r'href="/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>1</span>',
|
r'href="/admin/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>1</span>',
|
||||||
body,
|
body,
|
||||||
re.S,
|
re.S,
|
||||||
)
|
)
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r'href="/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
r'href="/admin/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
||||||
body,
|
body,
|
||||||
re.S,
|
re.S,
|
||||||
)
|
)
|
||||||
|
|
@ -1125,7 +1388,7 @@ def test_render_sources_shows_delete_action_for_each_source(
|
||||||
|
|
||||||
assert "Delete" in body
|
assert "Delete" in body
|
||||||
assert "data-on:pointerdown" in body
|
assert "data-on:pointerdown" in body
|
||||||
assert "/actions/sources/delete-me/delete" in body
|
assert "/admin/actions/sources/delete-me/delete" in body
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
@ -1137,7 +1400,7 @@ def test_render_create_source_shows_dedicated_form_page() -> None:
|
||||||
assert ">Create source<" in body
|
assert ">Create source<" in body
|
||||||
assert "Source and job setup" in body
|
assert "Source and job setup" in body
|
||||||
assert "data-signals__ifmissing" in body
|
assert "data-signals__ifmissing" in body
|
||||||
assert "/actions/sources/create" in body
|
assert "/admin/actions/sources/create" in body
|
||||||
assert 'data-show="$sourceType === 'feed'"' in body
|
assert 'data-show="$sourceType === 'feed'"' in body
|
||||||
assert 'data-show="$sourceType === 'pangea'"' in body
|
assert 'data-show="$sourceType === 'pangea'"' in body
|
||||||
assert "jobEnabled" in body
|
assert "jobEnabled" in body
|
||||||
|
|
@ -1206,7 +1469,7 @@ def test_render_edit_source_shows_existing_values(monkeypatch, tmp_path: Path) -
|
||||||
body = str(await render_edit_source("kenya-health"))
|
body = str(await render_edit_source("kenya-health"))
|
||||||
|
|
||||||
assert "Edit source" in body
|
assert "Edit source" in body
|
||||||
assert "/actions/sources/kenya-health/edit" in body
|
assert "/admin/actions/sources/kenya-health/edit" in body
|
||||||
assert "Kenya health desk" in body
|
assert "Kenya health desk" in body
|
||||||
assert "kenya-health" in body
|
assert "kenya-health" in body
|
||||||
assert 'id="source-slug"' in body
|
assert 'id="source-slug"' in body
|
||||||
|
|
@ -1239,7 +1502,7 @@ def test_render_settings_shows_current_max_concurrent_jobs(
|
||||||
body = str(await render_settings(app))
|
body = str(await render_settings(app))
|
||||||
|
|
||||||
assert ">Settings<" in body
|
assert ">Settings<" in body
|
||||||
assert "/actions/settings" in body
|
assert "/admin/actions/settings" in body
|
||||||
assert 'value="3"' in body
|
assert 'value="3"' in body
|
||||||
assert 'value="https://mirror.example"' in body
|
assert 'value="https://mirror.example"' in body
|
||||||
assert "Max concurrent jobs" in body
|
assert "Max concurrent jobs" in body
|
||||||
|
|
@ -1263,7 +1526,7 @@ def test_create_source_action_creates_pangea_source_and_job_in_database(
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/sources/create",
|
"/admin/actions/sources/create",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={
|
json={
|
||||||
"sourceName": "Kenya health desk",
|
"sourceName": "Kenya health desk",
|
||||||
|
|
@ -1291,7 +1554,7 @@ def test_create_source_action_creates_pangea_source_and_job_in_database(
|
||||||
body = await response.get_data(as_text=True)
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "window.location = '/sources'" in body
|
assert "window.location = '/admin/sources'" in body
|
||||||
|
|
||||||
source, pangea, job = _db_reader(
|
source, pangea, job = _db_reader(
|
||||||
lambda: (
|
lambda: (
|
||||||
|
|
@ -1331,7 +1594,7 @@ def test_create_source_action_creates_feed_source_and_job_in_database(
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/sources/create",
|
"/admin/actions/sources/create",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={
|
json={
|
||||||
"sourceName": "NASA feed",
|
"sourceName": "NASA feed",
|
||||||
|
|
@ -1351,7 +1614,7 @@ def test_create_source_action_creates_feed_source_and_job_in_database(
|
||||||
body = await response.get_data(as_text=True)
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "window.location = '/sources'" in body
|
assert "window.location = '/admin/sources'" in body
|
||||||
|
|
||||||
source, feed, job = _db_reader(
|
source, feed, job = _db_reader(
|
||||||
lambda: (
|
lambda: (
|
||||||
|
|
@ -1409,7 +1672,7 @@ def test_edit_source_action_updates_existing_source_and_job_in_database(
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/sources/kenya-health/edit",
|
"/admin/actions/sources/kenya-health/edit",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={
|
json={
|
||||||
"sourceName": "Kenya health desk nightly",
|
"sourceName": "Kenya health desk nightly",
|
||||||
|
|
@ -1440,7 +1703,7 @@ def test_edit_source_action_updates_existing_source_and_job_in_database(
|
||||||
body = await response.get_data(as_text=True)
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "window.location = '/sources'" in body
|
assert "window.location = '/admin/sources'" in body
|
||||||
|
|
||||||
source, pangea, job = _db_reader(
|
source, pangea, job = _db_reader(
|
||||||
lambda: (
|
lambda: (
|
||||||
|
|
@ -1505,7 +1768,7 @@ def test_edit_source_action_rejects_slug_changes(monkeypatch, tmp_path: Path) ->
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/sources/kenya-health/edit",
|
"/admin/actions/sources/kenya-health/edit",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={
|
json={
|
||||||
"sourceName": "Kenya health desk",
|
"sourceName": "Kenya health desk",
|
||||||
|
|
@ -1569,7 +1832,7 @@ def test_create_source_action_validates_duplicate_slug_and_pangea_type(
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/sources/create",
|
"/admin/actions/sources/create",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={
|
json={
|
||||||
"sourceName": "Duplicate guardian",
|
"sourceName": "Duplicate guardian",
|
||||||
|
|
@ -1619,7 +1882,7 @@ def test_settings_action_updates_max_concurrent_jobs(
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/settings",
|
"/admin/actions/settings",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={
|
json={
|
||||||
"maxConcurrentJobs": "3",
|
"maxConcurrentJobs": "3",
|
||||||
|
|
@ -1629,7 +1892,7 @@ def test_settings_action_updates_max_concurrent_jobs(
|
||||||
body = await response.get_data(as_text=True)
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "window.location = '/settings'" in body
|
assert "window.location = '/admin/settings'" in body
|
||||||
assert load_max_concurrent_jobs() == 3
|
assert load_max_concurrent_jobs() == 3
|
||||||
assert load_settings_form()["feed_url"] == "https://mirror.example"
|
assert load_settings_form()["feed_url"] == "https://mirror.example"
|
||||||
assert 'value="3"' in str(await render_settings(app))
|
assert 'value="3"' in str(await render_settings(app))
|
||||||
|
|
@ -1648,7 +1911,7 @@ def test_settings_action_rejects_non_positive_max_concurrent_jobs(
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/settings",
|
"/admin/actions/settings",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={"maxConcurrentJobs": "0", "feedUrl": "https://mirror.example"},
|
json={"maxConcurrentJobs": "0", "feedUrl": "https://mirror.example"},
|
||||||
)
|
)
|
||||||
|
|
@ -1670,7 +1933,7 @@ def test_settings_action_rejects_invalid_feed_url(monkeypatch, tmp_path: Path) -
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/settings",
|
"/admin/actions/settings",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={"maxConcurrentJobs": "2", "feedUrl": "mirror.example"},
|
json={"maxConcurrentJobs": "2", "feedUrl": "mirror.example"},
|
||||||
)
|
)
|
||||||
|
|
@ -1722,7 +1985,7 @@ def test_render_runs_shows_running_scheduled_and_completed_tables(
|
||||||
assert "Scheduled jobs" in body
|
assert "Scheduled jobs" in body
|
||||||
assert "Completed job executions" in body
|
assert "Completed job executions" in body
|
||||||
assert "runs-render-source" in body
|
assert "runs-render-source" in body
|
||||||
assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
assert f"/admin/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
||||||
assert "data-next-run-at" in body
|
assert "data-next-run-at" in body
|
||||||
assert "in " in body
|
assert "in " in body
|
||||||
|
|
||||||
|
|
@ -1797,7 +2060,7 @@ def test_runs_pagination_action_updates_only_the_current_tab(
|
||||||
)
|
)
|
||||||
|
|
||||||
async with client.request(
|
async with client.request(
|
||||||
"/runs?u=shim",
|
"/admin/runs?u=shim",
|
||||||
method="POST",
|
method="POST",
|
||||||
headers={
|
headers={
|
||||||
"Datastar-Request": "true",
|
"Datastar-Request": "true",
|
||||||
|
|
@ -1805,7 +2068,7 @@ def test_runs_pagination_action_updates_only_the_current_tab(
|
||||||
},
|
},
|
||||||
) as first_connection:
|
) as first_connection:
|
||||||
async with client.request(
|
async with client.request(
|
||||||
"/runs?u=shim",
|
"/admin/runs?u=shim",
|
||||||
method="POST",
|
method="POST",
|
||||||
headers={
|
headers={
|
||||||
"Datastar-Request": "true",
|
"Datastar-Request": "true",
|
||||||
|
|
@ -1825,7 +2088,7 @@ def test_runs_pagination_action_updates_only_the_current_tab(
|
||||||
).decode()
|
).decode()
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
'href="/runs?completed_page=1" aria-current="page"'
|
'href="/admin/runs?completed_page=1" aria-current="page"'
|
||||||
not in first_body
|
not in first_body
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -1840,7 +2103,7 @@ def test_runs_pagination_action_updates_only_the_current_tab(
|
||||||
) in second_body
|
) in second_body
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/actions/runs/completed-page/2",
|
"/admin/actions/runs/completed-page/2",
|
||||||
headers={"Datastar-Request": "true"},
|
headers={"Datastar-Request": "true"},
|
||||||
json={"tabid": "tab-1"},
|
json={"tabid": "tab-1"},
|
||||||
)
|
)
|
||||||
|
|
@ -1878,7 +2141,7 @@ def test_runs_patch_creates_and_cleans_up_tab_state(
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
async with client.request(
|
async with client.request(
|
||||||
"/runs?u=shim",
|
"/admin/runs?u=shim",
|
||||||
method="POST",
|
method="POST",
|
||||||
headers={
|
headers={
|
||||||
"Datastar-Request": "true",
|
"Datastar-Request": "true",
|
||||||
|
|
@ -1955,7 +2218,7 @@ def test_render_runs_keeps_queued_execution_in_scheduled_jobs_table(
|
||||||
assert "scheduled-source" in body
|
assert "scheduled-source" in body
|
||||||
assert ">Queued<" in body
|
assert ">Queued<" in body
|
||||||
assert (
|
assert (
|
||||||
f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
f"/admin/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
||||||
in body
|
in body
|
||||||
)
|
)
|
||||||
assert "Ready" in body
|
assert "Ready" in body
|
||||||
|
|
@ -2004,9 +2267,12 @@ def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up(
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
body = str(await render_runs(app))
|
body = str(await render_runs(app))
|
||||||
|
|
||||||
assert f"/job/{job.id}/execution/{int(running_execution.get_id())}/logs" in body
|
|
||||||
assert (
|
assert (
|
||||||
f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
f"/admin/job/{job.id}/execution/{int(running_execution.get_id())}/logs"
|
||||||
|
in body
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
f"/admin/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
||||||
in body
|
in body
|
||||||
)
|
)
|
||||||
assert ">Cancel<" in body
|
assert ">Cancel<" in body
|
||||||
|
|
@ -2031,9 +2297,9 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
||||||
"status": "Running",
|
"status": "Running",
|
||||||
"stats": "1 requests • 1 items • 1 byte",
|
"stats": "1 requests • 1 items • 1 byte",
|
||||||
"worker": "streaming stats from worker",
|
"worker": "streaming stats from worker",
|
||||||
"log_href": "/job/1/execution/11/logs",
|
"log_href": "/admin/job/1/execution/11/logs",
|
||||||
"cancel_label": "Stop",
|
"cancel_label": "Stop",
|
||||||
"cancel_post_path": "/actions/executions/11/cancel",
|
"cancel_post_path": "/admin/actions/executions/11/cancel",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
queued_executions=(
|
queued_executions=(
|
||||||
|
|
@ -2049,8 +2315,8 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
||||||
"status_tone": "idle",
|
"status_tone": "idle",
|
||||||
"run_label": "Queued",
|
"run_label": "Queued",
|
||||||
"run_disabled": True,
|
"run_disabled": True,
|
||||||
"run_post_path": "/actions/jobs/2/run-now",
|
"run_post_path": "/admin/actions/jobs/2/run-now",
|
||||||
"cancel_post_path": "/actions/queued-executions/22/cancel",
|
"cancel_post_path": "/admin/actions/queued-executions/22/cancel",
|
||||||
"move_up_disabled": True,
|
"move_up_disabled": True,
|
||||||
"move_up_post_path": None,
|
"move_up_post_path": None,
|
||||||
"move_down_disabled": True,
|
"move_down_disabled": True,
|
||||||
|
|
@ -2070,9 +2336,9 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
||||||
"run_disabled": False,
|
"run_disabled": False,
|
||||||
"run_reason": "Ready",
|
"run_reason": "Ready",
|
||||||
"toggle_label": "Disable",
|
"toggle_label": "Disable",
|
||||||
"toggle_post_path": "/actions/jobs/3/toggle-enabled",
|
"toggle_post_path": "/admin/actions/jobs/3/toggle-enabled",
|
||||||
"run_post_path": "/actions/jobs/3/run-now",
|
"run_post_path": "/admin/actions/jobs/3/run-now",
|
||||||
"delete_post_path": "/actions/jobs/3/delete",
|
"delete_post_path": "/admin/actions/jobs/3/delete",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
completed_executions=(
|
completed_executions=(
|
||||||
|
|
@ -2087,7 +2353,7 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
||||||
"status_tone": "done",
|
"status_tone": "done",
|
||||||
"stats": "1 requests • 1 items • 1 byte",
|
"stats": "1 requests • 1 items • 1 byte",
|
||||||
"summary": "Worker exited successfully",
|
"summary": "Worker exited successfully",
|
||||||
"log_href": "/job/4/execution/44/logs",
|
"log_href": "/admin/job/4/execution/44/logs",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -2098,7 +2364,7 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
||||||
assert ">Cancel<" in body
|
assert ">Cancel<" in body
|
||||||
assert ">Run now<" in body
|
assert ">Run now<" in body
|
||||||
assert ">Disable<" in body
|
assert ">Disable<" in body
|
||||||
assert "/job/4/execution/44/logs" in body
|
assert "/admin/job/4/execution/44/logs" in body
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_queued_execution_action_deletes_pending_row_without_touching_running_execution(
|
def test_cancel_queued_execution_action_deletes_pending_row_without_touching_running_execution(
|
||||||
|
|
@ -2143,7 +2409,7 @@ def test_cancel_queued_execution_action_deletes_pending_row_without_touching_run
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
f"/admin/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
@ -2217,7 +2483,7 @@ def test_clear_completed_executions_action_removes_history_and_log_artifacts(
|
||||||
completed_prefix.with_suffix(suffix).write_text("history", encoding="utf-8")
|
completed_prefix.with_suffix(suffix).write_text("history", encoding="utf-8")
|
||||||
running_log_path.write_text("running", encoding="utf-8")
|
running_log_path.write_text("running", encoding="utf-8")
|
||||||
|
|
||||||
response = await client.post("/actions/completed-executions/clear")
|
response = await client.post("/admin/actions/completed-executions/clear")
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -2297,18 +2563,18 @@ def test_move_queued_execution_action_reorders_queue(
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/actions/queued-executions/{int(second_execution.get_id())}/move-up"
|
f"/admin/actions/queued-executions/{int(second_execution.get_id())}/move-up"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
body = str(await render_runs(app))
|
body = str(await render_runs(app))
|
||||||
assert body.index("second-queued-source") < body.index("first-queued-source")
|
assert body.index("second-queued-source") < body.index("first-queued-source")
|
||||||
assert (
|
assert (
|
||||||
f"/actions/queued-executions/{int(second_execution.get_id())}/move-down"
|
f"/admin/actions/queued-executions/{int(second_execution.get_id())}/move-down"
|
||||||
in body
|
in body
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
f"/actions/queued-executions/{int(first_execution.get_id())}/move-up"
|
f"/admin/actions/queued-executions/{int(first_execution.get_id())}/move-up"
|
||||||
in body
|
in body
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2349,7 +2615,7 @@ def test_toggle_job_enabled_action_removes_queued_execution(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.post(f"/actions/jobs/{job.id}/toggle-enabled")
|
response = await client.post(f"/admin/actions/jobs/{job.id}/toggle-enabled")
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert _db_reader(lambda: Job.get_by_id(job.id).enabled) is False
|
assert _db_reader(lambda: Job.get_by_id(job.id).enabled) is False
|
||||||
|
|
@ -2361,7 +2627,7 @@ def test_toggle_job_enabled_action_removes_queued_execution(
|
||||||
)
|
)
|
||||||
body = str(await render_runs(app))
|
body = str(await render_runs(app))
|
||||||
assert (
|
assert (
|
||||||
f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
f"/admin/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
||||||
not in body
|
not in body
|
||||||
)
|
)
|
||||||
assert "Disabled" in body
|
assert "Disabled" in body
|
||||||
|
|
@ -2439,7 +2705,7 @@ def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> No
|
||||||
)
|
)
|
||||||
|
|
||||||
assert f"Job {job.id} / execution {execution.get_id()}" in body
|
assert f"Job {job.id} / execution {execution.get_id()}" in body
|
||||||
assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
assert f"/admin/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
||||||
assert "waiting for more log lines" in body
|
assert "waiting for more log lines" in body
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue