From 9ce576e7e89d4640b78587039df3657505f32296 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 12:13:04 +0200 Subject: [PATCH 01/23] with htpy and css --- flake.nix | 5 +- pyproject.toml | 9 + repub/components.py | 154 ++++ repub/pages/__init__.py | 1 + repub/pages/dashboard.py | 598 ++++++++++++++ repub/static/app.css | 1430 +++++++++++++++++++++++++++++++++ repub/static/app.tailwind.css | 3 + repub/web.py | 20 +- uv.lock | 14 + 9 files changed, 2217 insertions(+), 17 deletions(-) create mode 100644 repub/components.py create mode 100644 repub/pages/__init__.py create mode 100644 repub/pages/dashboard.py create mode 100644 repub/static/app.css create mode 100644 repub/static/app.tailwind.css diff --git a/flake.nix b/flake.nix index 2618716..2d4cda9 100644 --- a/flake.nix +++ b/flake.nix @@ -239,7 +239,10 @@ inherit src; dontConfigure = true; dontBuild = true; - nativeBuildInputs = [ testVenv ]; + nativeBuildInputs = [ + pkgs.pyright + testVenv + ]; checkPhase = '' runHook preCheck pyright diff --git a/pyproject.toml b/pyproject.toml index 474e97b..425ad43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "aiosqlite>=0.21.0,<0.22.0", "datastar-py>=0.8.0,<0.9.0", "greenlet>=3.2.4,<4.0.0", + "htpy>=25.12.0,<26.0.0", "peewee>=3.19.0,<4.0.0", "pygea @ git+https://guardianproject.dev/anynews/pygea.git", ] @@ -65,6 +66,14 @@ max-line-length = "88" [tool.pyright] include = ["repub", "tests"] +exclude = [ + "repub/crawl.py", + "repub/exporters.py", + "repub/media.py", + "repub/rss.py", + "repub/spiders", + "repub/srcset.py", +] pythonVersion = "3.13" typeCheckingMode = "basic" reportMissingImports = false diff --git a/repub/components.py b/repub/components.py new file mode 100644 index 0000000..4a6630c --- /dev/null +++ b/repub/components.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import htpy as h +from htpy import Node, Renderable + + +def base_layout(*, page_title: str, stylesheet_href: str, content: Node) -> Renderable: + return h.html(lang="en", class_="h-full bg-slate-100")[ + h.head[ + h.meta(charset="utf-8"), + h.meta(name="viewport", content="width=device-width, initial-scale=1"), + h.title[page_title], + h.link(rel="stylesheet", href=stylesheet_href), + ], + h.body( + class_="h-full bg-linear-to-br from-stone-100 via-amber-50 to-orange-100 text-slate-900" + )[content], + ] + + +def nav_link( + *, label: str, active: bool = False, badge: str | None = None +) -> Renderable: + link_class = ( + "group flex items-center justify-between rounded-xl px-3 py-2 text-sm font-medium transition " + + ( + "bg-white text-slate-950 shadow-sm ring-1 ring-white/10" + if active + else "text-slate-300 hover:bg-white/5 hover:text-white" + ) + ) + badge_class = "rounded-full px-2 py-0.5 text-[11px] font-semibold " + ( + "bg-amber-200 text-amber-950" if active else "bg-slate-800 text-slate-300" + ) + + return h.a(href="#", class_=link_class)[ + h.span[label], + badge and h.span(class_=badge_class)[badge], + ] + + +def stat_card(*, label: str, value: str, detail: str) -> Renderable: + return h.div( + class_="rounded-3xl bg-white/85 p-5 shadow-sm ring-1 ring-slate-200 backdrop-blur" + )[ + h.dt(class_="text-sm font-medium text-slate-500")[label], + h.dd(class_="mt-3 text-3xl font-semibold tracking-tight text-slate-950")[value], + h.p(class_="mt-2 text-sm text-slate-600")[detail], + ] + + +def input_field( + *, + label: str, + field_id: str, + value: str = "", + placeholder: str = "", + help_text: str | None = None, +) -> Renderable: + return h.div[ + h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ + label + ], + h.input( + id=field_id, + name=field_id, + type="text", + value=value, + placeholder=placeholder, + class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500", + ), + help_text and h.p(class_="mt-2 text-xs text-slate-500")[help_text], + ] + + +def select_field( + *, + label: str, + field_id: str, + options: tuple[str, ...], + selected: str, + help_text: str | None = None, +) -> Renderable: + return h.div[ + h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ + label + ], + h.select( + id=field_id, + name=field_id, + class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 focus:outline-hidden focus:ring-2 focus:ring-amber-500", + )[ + ( + h.option(value=option, selected=option == selected)[option] + for option in options + ) + ], + help_text and h.p(class_="mt-2 text-xs text-slate-500")[help_text], + ] + + +def textarea_field( + *, label: str, field_id: str, value: str, rows: str = "4" +) -> Renderable: + return h.div[ + h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ + label + ], + h.textarea( + id=field_id, + name=field_id, + rows=rows, + class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500", + )[value], + ] + + +def toggle_field(*, label: str, description: str, checked: bool = False) -> Renderable: + wrapper_class = ( + "group relative inline-flex w-11 shrink-0 rounded-full p-0.5 outline-offset-2 outline-amber-500 transition " + + ("bg-amber-500" if checked else "bg-slate-200") + ) + knob_class = ( + "size-5 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform " + + ("translate-x-5" if checked else "translate-x-0") + ) + + return h.div(class_="rounded-3xl bg-stone-50 p-4 ring-1 ring-slate-200")[ + h.div(class_="flex items-start justify-between gap-4")[ + h.div[ + h.h3(class_="text-sm font-semibold text-slate-900")[label], + h.p(class_="mt-1 text-sm text-slate-600")[description], + ], + h.label(class_="mt-0.5 cursor-pointer")[ + h.div(class_=wrapper_class)[ + h.span(class_=knob_class), + h.input(type="checkbox", checked=checked, class_="sr-only"), + ], + ], + ] + ] + + +def status_badge(*, label: str, tone: str) -> Renderable: + tones = { + "running": "bg-emerald-100 text-emerald-800", + "scheduled": "bg-sky-100 text-sky-800", + "idle": "bg-slate-200 text-slate-700", + "failed": "bg-rose-100 text-rose-800", + "done": "bg-amber-100 text-amber-800", + } + return h.span( + class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}" + )[label] diff --git a/repub/pages/__init__.py b/repub/pages/__init__.py new file mode 100644 index 0000000..5490d15 --- /dev/null +++ b/repub/pages/__init__.py @@ -0,0 +1 @@ +from repub.pages.dashboard import admin_page diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py new file mode 100644 index 0000000..a17bef7 --- /dev/null +++ b/repub/pages/dashboard.py @@ -0,0 +1,598 @@ +from __future__ import annotations + +import htpy as h +from htpy import Node, Renderable + +from repub.components import ( + base_layout, + input_field, + nav_link, + select_field, + stat_card, + status_badge, + textarea_field, + toggle_field, +) + + +def sidebar() -> Renderable: + return h.aside( + class_="relative overflow-hidden bg-slate-950 px-6 py-8 text-white lg:min-h-screen" + )[ + h.div( + class_="absolute inset-x-0 top-0 h-40 bg-radial from-amber-400/25 via-amber-400/10 to-transparent" + ), + h.div(class_="relative flex h-full flex-col")[ + h.div(class_="flex items-center gap-3")[ + h.div( + class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" + )["AR"], + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300" + )["Republisher"], + h.p(class_="text-sm text-slate-300")["Admin spike"], + ], + ], + h.nav(class_="mt-10 space-y-2")[ + nav_link(label="Dashboard", active=True, badge="Live"), + nav_link(label="Sources", badge="12"), + nav_link(label="Runs", badge="3"), + nav_link(label="Schedule"), + nav_link(label="Settings"), + ], + h.div(class_="mt-10 rounded-3xl border border-white/10 bg-white/5 p-5")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" + )["Operator notes"], + h.p(class_="mt-3 text-sm leading-6 text-slate-300")[ + "Single-operator control plane over Tailscale. Everything here is static HTML for the v1 UI spike." + ], + ], + h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[ + h.p(class_="text-sm font-semibold text-white")["Output root"], + h.p(class_="mt-2 font-mono text-sm text-slate-300")["/srv/anynews/out"], + h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[ + "Trusted network only" + ], + ], + ], + ] + + +def page_header() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-slate-950 px-6 py-8 text-white shadow-xl sm:px-8" + )[ + h.div(class_="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between")[ + h.div(class_="max-w-3xl")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.3em] text-amber-300" + )["Republisher Redux"], + h.h1(class_="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl")[ + "Admin UI" + ], + h.p( + class_="mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base" + )[ + "One page for source management, job scheduling, live execution visibility, and operator settings. This pass is HTML and CSS only." + ], + ], + h.div(class_="flex flex-wrap gap-3")[ + h.button( + type="button", + class_="rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm hover:bg-amber-300", + )["Add source"], + h.button( + type="button", + class_="rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white hover:bg-white/10", + )["Run scheduler health check"], + ], + ] + ] + + +def overview_section() -> Renderable: + return h.section[ + h.div(class_="mb-4 flex items-end justify-between")[ + h.div[ + h.p( + class_="text-sm font-semibold uppercase tracking-[0.22em] text-slate-500" + )["Overview"], + h.h2( + class_="mt-1 text-2xl font-semibold tracking-tight text-slate-950" + )["Operational snapshot"], + ], + h.p(class_="text-sm text-slate-500")["Updated from static fixture data"], + ], + h.dl(class_="grid gap-4 md:grid-cols-2 xl:grid-cols-4")[ + stat_card(label="Active jobs", value="12", detail="9 scheduled, 3 paused"), + stat_card(label="Running now", value="2", detail="RSS and Pangea workers"), + stat_card( + label="Completed today", value="34", detail="31 succeeded, 3 failed" + ), + stat_card( + label="Output size", value="18.4 GB", detail="Media and rewritten feeds" + ), + ], + ] + + +def source_form_section() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200" + )[ + h.div(class_="border-b border-slate-200 px-6 py-5 sm:px-8")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Create or edit"], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ + "Source and job setup" + ], + h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[ + "The form shows the intended v1 structure: source fields, subtype fields, cron controls, and job toggles. No persistence is wired yet." + ], + ], + h.div(class_="space-y-8 px-6 py-6 sm:px-8")[ + h.div(class_="grid gap-4 md:grid-cols-2")[ + input_field( + label="Source name", + field_id="source-name", + value="Pangea mobile articles", + ), + input_field( + label="Slug", + field_id="source-slug", + value="pangea-mobile", + help_text="Immutable after creation.", + ), + select_field( + label="Source type", + field_id="source-type", + options=("feed", "pangea"), + selected="pangea", + ), + input_field( + label="Feed URL", + field_id="feed-url", + placeholder="https://example.com/feed.xml", + ), + ], + h.div(class_="grid gap-4 lg:grid-cols-3")[ + input_field( + label="Pangea domain", + field_id="pangea-domain", + value="guardianproject.info", + ), + input_field( + label="Category name", field_id="pangea-category", value="News" + ), + select_field( + label="Content format", + field_id="content-format", + options=("MOBILE_3", "MOBILE_2", "WEB"), + selected="MOBILE_3", + ), + input_field( + label="Content type", field_id="content-type", value="articles" + ), + input_field(label="Max articles", field_id="max-articles", value="10"), + input_field( + label="Oldest article (days)", field_id="oldest-article", value="3" + ), + ], + h.div(class_="grid gap-4 lg:grid-cols-2")[ + textarea_field( + label="Notes", + field_id="source-notes", + value="Primary Pangea mobile article mirror for the operator landing page.", + ), + textarea_field( + label="Spider arguments", + field_id="spider-arguments", + value="language=en,download_media=true", + ), + ], + h.div[ + h.div(class_="mb-4 flex items-end justify-between")[ + h.div[ + h.h3(class_="text-lg font-semibold text-slate-950")[ + "Cron schedule" + ], + h.p(class_="mt-1 text-sm text-slate-600")[ + "Stored in UTC and displayed in the browser timezone." + ], + ] + ], + h.div(class_="grid gap-4 sm:grid-cols-2 xl:grid-cols-5")[ + input_field(label="Minute", field_id="cron-minute", value="15"), + input_field(label="Hour", field_id="cron-hour", value="*/4"), + input_field( + label="Day of month", field_id="cron-day-of-month", value="*" + ), + input_field( + label="Day of week", field_id="cron-day-of-week", value="1-6" + ), + input_field(label="Month", field_id="cron-month", value="*"), + ], + ], + h.div(class_="grid gap-4 xl:grid-cols-2")[ + toggle_field( + label="Job enabled", + description="If disabled, the scheduler keeps the source definition but skips automatic runs.", + checked=True, + ), + toggle_field( + label="Only newest", + description="Restrict Pangea fetches to the newest material available in the selected category.", + checked=True, + ), + toggle_field( + label="Include authors", + description="Carry author bylines into rendered output where the upstream provides them.", + checked=True, + ), + toggle_field( + label="Exclude media", + description="Use article text only and skip image/media attachment mirroring for this source.", + checked=False, + ), + ], + ], + h.div( + class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 px-6 py-4 sm:px-8" + )[ + h.button( + type="button", + class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50", + )["Cancel"], + h.button( + type="button", + class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800", + )["Save source"], + ], + ] + + +def source_card( + *, name: str, slug: str, source_type: str, schedule: str, state: Node +) -> Renderable: + return h.div(class_="rounded-3xl bg-stone-50 p-5 ring-1 ring-slate-200")[ + h.div(class_="flex items-start justify-between gap-4")[ + h.div[ + h.h3(class_="text-base font-semibold text-slate-950")[name], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[slug], + ], + state, + ], + h.dl(class_="mt-4 grid gap-3 sm:grid-cols-2")[ + h.div[ + h.dt( + class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" + )["Type"], + h.dd(class_="mt-1 text-sm text-slate-900")[source_type], + ], + h.div[ + h.dt( + class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" + )["Schedule"], + h.dd(class_="mt-1 text-sm text-slate-900")[schedule], + ], + ], + h.div(class_="mt-5 flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50", + )["Edit"], + h.button( + type="button", + class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50", + )["Run now"], + h.button( + type="button", + class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-rose-700 ring-1 ring-rose-200 hover:bg-rose-50", + )["Delete"], + ], + ] + + +def configured_sources_section() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" + )[ + h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Configured"], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")["Sources"], + ], + h.p(class_="text-sm text-slate-500")[ + "Static cards for the CRUD list state" + ], + ], + h.div(class_="mt-6 grid gap-4 xl:grid-cols-3")[ + source_card( + name="Guardian feed mirror", + slug="guardian-feed", + source_type="RSS feed", + schedule="Every 30 minutes", + state=status_badge(label="Scheduled", tone="scheduled"), + ), + source_card( + name="Pangea mobile articles", + slug="pangea-mobile", + source_type="Pangea", + schedule="Every 4 hours", + state=status_badge(label="Running", tone="running"), + ), + source_card( + name="Podcast enclosure mirror", + slug="podcast-audio", + source_type="RSS feed", + schedule="Paused", + state=status_badge(label="Idle", tone="idle"), + ), + ], + ] + + +def class_cells(row: tuple[Node, ...]) -> tuple[Renderable, ...]: + return tuple( + h.td(class_="px-4 py-4 align-top text-slate-700")[cell] for cell in row + ) + + +def job_table_section( + *, title: str, subtitle: str, rows: tuple[tuple[Node, ...], ...] +) -> Renderable: + headers = ("Source", "Window", "Status", "Stats", "Actions") + return h.section( + class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" + )[ + h.div(class_="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between")[ + h.div[ + h.h2(class_="text-xl font-semibold text-slate-950")[title], + h.p(class_="mt-1 text-sm text-slate-600")[subtitle], + ] + ], + h.div(class_="mt-6 overflow-hidden rounded-3xl ring-1 ring-slate-200")[ + h.table(class_="min-w-full divide-y divide-slate-200 text-left text-sm")[ + h.thead(class_="bg-stone-50")[ + h.tr[ + ( + h.th( + class_="px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" + )[header] + for header in headers + ) + ] + ], + h.tbody(class_="divide-y divide-slate-200 bg-white")[ + (h.tr[class_cells(row)] for row in rows) + ], + ] + ], + ] + + +def log_panel() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-slate-950 p-6 text-white shadow-xl sm:p-8" + )[ + h.div(class_="flex items-end justify-between gap-4")[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" + )["Live view"], + h.h2(class_="mt-2 text-xl font-semibold")["Execution log"], + ], + status_badge(label="Streaming", tone="running"), + ], + h.pre( + class_="mt-5 overflow-x-auto rounded-3xl bg-black/30 p-4 text-xs leading-6 text-emerald-200 ring-1 ring-white/10" + )[ + "\n".join( + [ + "11:42:01 scheduler: run_now requested for job 7", + "11:42:02 worker[7]: starting pangea-mobile", + "11:42:08 stats: requests=18 items=4 bytes=1.8MB", + "11:42:11 stats: requests=26 items=7 bytes=2.6MB", + "11:42:17 worker[7]: writing out/logs/exec-0007.log", + "11:42:24 worker[7]: finished successfully", + ] + ) + ], + h.div(class_="mt-5 flex flex-wrap gap-3")[ + h.button( + type="button", + class_="rounded-full bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/10 hover:bg-white/15", + )["Open full log"], + h.button( + type="button", + class_="rounded-full bg-rose-500/15 px-4 py-2.5 text-sm font-semibold text-rose-200 ring-1 ring-rose-400/20 hover:bg-rose-500/20", + )["Stop job"], + ], + ] + + +def settings_panel() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" + )[ + h.p(class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600")[ + "Global" + ], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ + "Application settings" + ], + h.div(class_="mt-6 grid gap-4")[ + input_field(label="Bind host", field_id="bind-host", value="127.0.0.1"), + input_field(label="Bind port", field_id="bind-port", value="8080"), + input_field( + label="Output root", field_id="output-root", value="/srv/anynews/out" + ), + input_field( + label="Log directory", + field_id="log-directory", + value="/srv/anynews/out/logs", + ), + ], + h.div(class_="mt-6 flex flex-wrap gap-3")[ + h.button( + type="button", + class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800", + )["Save settings"], + h.button( + type="button", + class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50", + )["Reload schedule preview"], + ], + ] + + +def admin_page(*, stylesheet_href: str) -> Renderable: + running_rows = ( + ( + h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"], + h.span["Started 11:42"], + status_badge(label="Running", tone="running"), + h.div["26 requests • 7 items • 2.6 MB"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["View log"], + h.button( + type="button", + class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", + )["Stop"], + ], + ), + ( + h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"], + h.span["Started 11:33"], + status_badge(label="Running", tone="running"), + h.div["91 requests • 13 items • 5.1 MB"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["View log"], + h.button( + type="button", + class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", + )["Stop"], + ], + ), + ) + upcoming_rows = ( + ( + h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"], + h.span["Today, 12:15"], + status_badge(label="Scheduled", tone="scheduled"), + h.div["cron: 15 */4 * * 1-6"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Run now"], + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Disable"], + h.button( + type="button", + class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", + )["Delete"], + ], + ), + ( + h.div(class_="font-semibold text-slate-950")["Weekly digest feed"], + h.span["Tomorrow, 08:00"], + status_badge(label="Idle", tone="idle"), + h.div["cron: 0 8 * * 1"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Run now"], + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Enable"], + h.button( + type="button", + class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", + )["Delete"], + ], + ), + ) + completed_rows = ( + ( + h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"], + h.span["Ended 10:57"], + status_badge(label="Succeeded", tone="done"), + h.div["204 requests • 28 items • 9.4 MB"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["View log"], + ], + ), + ( + h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"], + h.span["Ended 09:12"], + status_badge(label="Failed", tone="failed"), + h.div["timeout after 3 retries"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["View log"], + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Retry"], + ], + ), + ) + + return base_layout( + page_title="Republisher Admin UI", + stylesheet_href=stylesheet_href, + content=h.div(class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]")[ + sidebar(), + h.main(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[ + h.div(class_="mx-auto max-w-7xl space-y-6")[ + page_header(), + overview_section(), + h.div( + class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]" + )[ + h.div(class_="space-y-6")[ + source_form_section(), + configured_sources_section(), + job_table_section( + title="Running executions", + subtitle="Operators can inspect active crawls and stop them if needed.", + rows=running_rows, + ), + job_table_section( + title="Upcoming jobs", + subtitle="Schedule preview with enable, disable, run now, and delete affordances.", + rows=upcoming_rows, + ), + job_table_section( + title="Completed executions", + subtitle="Recent history with direct access to text logs.", + rows=completed_rows, + ), + ], + h.div(class_="space-y-6")[log_panel(), settings_panel()], + ], + ] + ], + ], + ) diff --git a/repub/static/app.css b/repub/static/app.css new file mode 100644 index 0000000..672a945 --- /dev/null +++ b/repub/static/app.css @@ -0,0 +1,1430 @@ +/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-amber-50: oklch(98.7% 0.022 95.277); + --color-amber-100: oklch(96.2% 0.059 95.617); + --color-amber-200: oklch(92.4% 0.12 95.746); + --color-amber-300: oklch(87.9% 0.169 91.605); + --color-amber-400: oklch(82.8% 0.189 84.429); + --color-amber-500: oklch(76.9% 0.188 70.08); + --color-amber-600: oklch(66.6% 0.179 58.318); + --color-amber-800: oklch(47.3% 0.137 46.201); + --color-amber-950: oklch(27.9% 0.077 45.635); + --color-emerald-100: oklch(95% 0.052 163.051); + --color-emerald-200: oklch(90.5% 0.093 164.15); + --color-emerald-800: oklch(43.2% 0.095 166.913); + --color-sky-100: oklch(95.1% 0.026 236.824); + --color-sky-800: oklch(44.3% 0.11 240.79); + --color-rose-50: oklch(96.9% 0.015 12.422); + --color-rose-100: oklch(94.1% 0.03 12.58); + --color-rose-200: oklch(89.2% 0.058 10.001); + --color-rose-400: oklch(71.2% 0.194 13.428); + --color-rose-500: oklch(64.5% 0.246 16.439); + --color-rose-700: oklch(51.4% 0.222 16.935); + --color-rose-800: oklch(45.5% 0.188 13.697); + --color-slate-50: oklch(98.4% 0.003 247.858); + --color-slate-100: oklch(96.8% 0.007 247.896); + --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-300: oklch(86.9% 0.022 252.894); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-slate-950: oklch(12.9% 0.042 264.695); + --color-stone-50: oklch(98.5% 0.001 106.423); + --color-stone-100: oklch(97% 0.001 106.424); + --color-stone-200: oklch(92.3% 0.003 48.717); + --color-black: #000; + --color-white: #fff; + --spacing: 0.25rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-7xl: 80rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-black: 900; + --tracking-tight: -0.025em; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-3xl: 1.5rem; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden='until-found'])) { + display: none !important; + } +} +@layer utilities { + .visible { + visibility: visible; + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + .absolute { + position: absolute; + } + .fixed { + position: fixed; + } + .relative { + position: relative; + } + .static { + position: static; + } + .inset-x-0 { + inset-inline: calc(var(--spacing) * 0); + } + .start { + inset-inline-start: var(--spacing); + } + .top-0 { + top: calc(var(--spacing) * 0); + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .mx-auto { + margin-inline: auto; + } + .mt-0\.5 { + margin-top: calc(var(--spacing) * 0.5); + } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mt-5 { + margin-top: calc(var(--spacing) * 5); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mt-10 { + margin-top: calc(var(--spacing) * 10); + } + .mt-auto { + margin-top: auto; + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .block { + display: block; + } + .contents { + display: contents; + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline-flex { + display: inline-flex; + } + .table { + display: table; + } + .size-5 { + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + } + .size-11 { + width: calc(var(--spacing) * 11); + height: calc(var(--spacing) * 11); + } + .h-40 { + height: calc(var(--spacing) * 40); + } + .h-full { + height: 100%; + } + .min-h-screen { + min-height: 100vh; + } + .w-11 { + width: calc(var(--spacing) * 11); + } + .w-full { + width: 100%; + } + .max-w-2xl { + max-width: var(--container-2xl); + } + .max-w-3xl { + max-width: var(--container-3xl); + } + .max-w-7xl { + max-width: var(--container-7xl); + } + .min-w-full { + min-width: 100%; + } + .shrink-0 { + flex-shrink: 0; + } + .translate-x-0 { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .translate-x-5 { + --tw-translate-x: calc(var(--spacing) * 5); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .cursor-pointer { + cursor: pointer; + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .items-end { + align-items: flex-end; + } + .items-start { + align-items: flex-start; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .gap-6 { + gap: calc(var(--spacing) * 6); + } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-6 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-8 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-slate-200 { + :where(& > :not(:last-child)) { + border-color: var(--color-slate-200); + } + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-auto { + overflow-x: auto; + } + .rounded-2xl { + border-radius: var(--radius-2xl); + } + .rounded-3xl { + border-radius: var(--radius-3xl); + } + .rounded-\[2rem\] { + border-radius: 2rem; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-xl { + border-radius: var(--radius-xl); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-0 { + border-style: var(--tw-border-style); + border-width: 0px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-slate-200 { + border-color: var(--color-slate-200); + } + .border-white\/10 { + border-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + .border-white\/15 { + border-color: color-mix(in srgb, #fff 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-white) 15%, transparent); + } + } + .bg-amber-100 { + background-color: var(--color-amber-100); + } + .bg-amber-200 { + background-color: var(--color-amber-200); + } + .bg-amber-400 { + background-color: var(--color-amber-400); + } + .bg-amber-500 { + background-color: var(--color-amber-500); + } + .bg-black\/30 { + background-color: color-mix(in srgb, #000 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 30%, transparent); + } + } + .bg-emerald-100 { + background-color: var(--color-emerald-100); + } + .bg-rose-50 { + background-color: var(--color-rose-50); + } + .bg-rose-100 { + background-color: var(--color-rose-100); + } + .bg-rose-500\/15 { + background-color: color-mix(in srgb, oklch(64.5% 0.246 16.439) 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-rose-500) 15%, transparent); + } + } + .bg-sky-100 { + background-color: var(--color-sky-100); + } + .bg-slate-100 { + background-color: var(--color-slate-100); + } + .bg-slate-200 { + background-color: var(--color-slate-200); + } + .bg-slate-800 { + background-color: var(--color-slate-800); + } + .bg-slate-950 { + background-color: var(--color-slate-950); + } + .bg-stone-50 { + background-color: var(--color-stone-50); + } + .bg-stone-100 { + background-color: var(--color-stone-100); + } + .bg-white { + background-color: var(--color-white); + } + .bg-white\/5 { + background-color: color-mix(in srgb, #fff 5%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 5%, transparent); + } + } + .bg-white\/10 { + background-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + .bg-white\/85 { + background-color: color-mix(in srgb, #fff 85%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 85%, transparent); + } + } + .bg-white\/90 { + background-color: color-mix(in srgb, #fff 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 90%, transparent); + } + } + .bg-linear-to-br { + --tw-gradient-position: to bottom right; + @supports (background-image: linear-gradient(in lab, red, red)) { + --tw-gradient-position: to bottom right in oklab; + } + background-image: linear-gradient(var(--tw-gradient-stops)); + } + .bg-radial { + --tw-gradient-position: in oklab; + background-image: radial-gradient(var(--tw-gradient-stops)); + } + .from-amber-400\/25 { + --tw-gradient-from: color-mix(in srgb, oklch(82.8% 0.189 84.429) 25%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-gradient-from: color-mix(in oklab, var(--color-amber-400) 25%, transparent); + } + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .from-stone-100 { + --tw-gradient-from: var(--color-stone-100); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .via-amber-50 { + --tw-gradient-via: var(--color-amber-50); + --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-via-stops); + } + .via-amber-400\/10 { + --tw-gradient-via: color-mix(in srgb, oklch(82.8% 0.189 84.429) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-gradient-via: color-mix(in oklab, var(--color-amber-400) 10%, transparent); + } + --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-via-stops); + } + .to-orange-100 { + --tw-gradient-to: var(--color-orange-100); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .to-transparent { + --tw-gradient-to: transparent; + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .p-0\.5 { + padding: calc(var(--spacing) * 0.5); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-5 { + padding: calc(var(--spacing) * 5); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-2\.5 { + padding-inline: calc(var(--spacing) * 2.5); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-3\.5 { + padding-inline: calc(var(--spacing) * 3.5); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-2\.5 { + padding-block: calc(var(--spacing) * 2.5); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-5 { + padding-block: calc(var(--spacing) * 5); + } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } + .py-8 { + padding-block: calc(var(--spacing) * 8); + } + .text-left { + text-align: left; + } + .align-top { + vertical-align: top; + } + .font-mono { + font-family: var(--font-mono); + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .text-\[11px\] { + font-size: 11px; + } + .leading-6 { + --tw-leading: calc(var(--spacing) * 6); + line-height: calc(var(--spacing) * 6); + } + .font-black { + --tw-font-weight: var(--font-weight-black); + font-weight: var(--font-weight-black); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-\[0\.3em\] { + --tw-tracking: 0.3em; + letter-spacing: 0.3em; + } + .tracking-\[0\.18em\] { + --tw-tracking: 0.18em; + letter-spacing: 0.18em; + } + .tracking-\[0\.22em\] { + --tw-tracking: 0.22em; + letter-spacing: 0.22em; + } + .tracking-\[0\.24em\] { + --tw-tracking: 0.24em; + letter-spacing: 0.24em; + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .text-amber-300 { + color: var(--color-amber-300); + } + .text-amber-600 { + color: var(--color-amber-600); + } + .text-amber-800 { + color: var(--color-amber-800); + } + .text-amber-950 { + color: var(--color-amber-950); + } + .text-emerald-200 { + color: var(--color-emerald-200); + } + .text-emerald-800 { + color: var(--color-emerald-800); + } + .text-rose-200 { + color: var(--color-rose-200); + } + .text-rose-700 { + color: var(--color-rose-700); + } + .text-rose-800 { + color: var(--color-rose-800); + } + .text-sky-800 { + color: var(--color-sky-800); + } + .text-slate-300 { + color: var(--color-slate-300); + } + .text-slate-400 { + color: var(--color-slate-400); + } + .text-slate-500 { + color: var(--color-slate-500); + } + .text-slate-600 { + color: var(--color-slate-600); + } + .text-slate-700 { + color: var(--color-slate-700); + } + .text-slate-900 { + color: var(--color-slate-900); + } + .text-slate-950 { + color: var(--color-slate-950); + } + .text-white { + color: var(--color-white); + } + .uppercase { + text-transform: uppercase; + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-xl { + --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-xs { + --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-1 { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-rose-200 { + --tw-ring-color: var(--color-rose-200); + } + .ring-rose-400\/20 { + --tw-ring-color: color-mix(in srgb, oklch(71.2% 0.194 13.428) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-rose-400) 20%, transparent); + } + } + .ring-slate-200 { + --tw-ring-color: var(--color-slate-200); + } + .ring-slate-900\/5 { + --tw-ring-color: color-mix(in srgb, oklch(20.8% 0.042 265.755) 5%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-slate-900) 5%, transparent); + } + } + .ring-white\/10 { + --tw-ring-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + .outline-offset-2 { + outline-offset: 2px; + } + .outline-amber-500 { + outline-color: var(--color-amber-500); + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .backdrop-blur { + --tw-backdrop-blur: blur(8px); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .placeholder\:text-slate-400 { + &::placeholder { + color: var(--color-slate-400); + } + } + .hover\:bg-amber-300 { + &:hover { + @media (hover: hover) { + background-color: var(--color-amber-300); + } + } + } + .hover\:bg-rose-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-rose-50); + } + } + } + .hover\:bg-rose-100 { + &:hover { + @media (hover: hover) { + background-color: var(--color-rose-100); + } + } + } + .hover\:bg-rose-500\/20 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(64.5% 0.246 16.439) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-rose-500) 20%, transparent); + } + } + } + } + .hover\:bg-slate-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-slate-50); + } + } + } + .hover\:bg-slate-800 { + &:hover { + @media (hover: hover) { + background-color: var(--color-slate-800); + } + } + } + .hover\:bg-stone-200 { + &:hover { + @media (hover: hover) { + background-color: var(--color-stone-200); + } + } + } + .hover\:bg-white\/5 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 5%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 5%, transparent); + } + } + } + } + .hover\:bg-white\/10 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + } + } + .hover\:bg-white\/15 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 15%, transparent); + } + } + } + } + .hover\:text-white { + &:hover { + @media (hover: hover) { + color: var(--color-white); + } + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-amber-500 { + &:focus { + --tw-ring-color: var(--color-amber-500); + } + } + .focus\:outline-hidden { + &:focus { + --tw-outline-style: none; + outline-style: none; + @media (forced-colors: active) { + outline: 2px solid transparent; + outline-offset: 2px; + } + } + } + .sm\:grid-cols-2 { + @media (width >= 40rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .sm\:flex-row { + @media (width >= 40rem) { + flex-direction: row; + } + } + .sm\:items-end { + @media (width >= 40rem) { + align-items: flex-end; + } + } + .sm\:justify-between { + @media (width >= 40rem) { + justify-content: space-between; + } + } + .sm\:p-8 { + @media (width >= 40rem) { + padding: calc(var(--spacing) * 8); + } + } + .sm\:px-6 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 6); + } + } + .sm\:px-8 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 8); + } + } + .sm\:text-4xl { + @media (width >= 40rem) { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + } + .sm\:text-base { + @media (width >= 40rem) { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .lg\:grid { + @media (width >= 64rem) { + display: grid; + } + } + .lg\:min-h-screen { + @media (width >= 64rem) { + min-height: 100vh; + } + } + .lg\:grid-cols-2 { + @media (width >= 64rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .lg\:grid-cols-\[18rem_minmax\(0\,1fr\)\] { + @media (width >= 64rem) { + grid-template-columns: 18rem minmax(0,1fr); + } + } + .lg\:flex-row { + @media (width >= 64rem) { + flex-direction: row; + } + } + .lg\:items-end { + @media (width >= 64rem) { + align-items: flex-end; + } + } + .lg\:justify-between { + @media (width >= 64rem) { + justify-content: space-between; + } + } + .lg\:px-8 { + @media (width >= 64rem) { + padding-inline: calc(var(--spacing) * 8); + } + } + .lg\:py-8 { + @media (width >= 64rem) { + padding-block: calc(var(--spacing) * 8); + } + } + .xl\:grid-cols-2 { + @media (width >= 80rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .xl\:grid-cols-3 { + @media (width >= 80rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .xl\:grid-cols-4 { + @media (width >= 80rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + .xl\:grid-cols-5 { + @media (width >= 80rem) { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + } + .xl\:grid-cols-\[minmax\(0\,1\.35fr\)_minmax\(22rem\,0\.95fr\)\] { + @media (width >= 80rem) { + grid-template-columns: minmax(0,1.35fr) minmax(22rem,0.95fr); + } + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-gradient-position { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-via { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-to { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-via-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from-position { + syntax: ""; + inherits: false; + initial-value: 0%; +} +@property --tw-gradient-via-position { + syntax: ""; + inherits: false; + initial-value: 50%; +} +@property --tw-gradient-to-position { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-space-y-reverse: 0; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-gradient-position: initial; + --tw-gradient-from: #0000; + --tw-gradient-via: #0000; + --tw-gradient-to: #0000; + --tw-gradient-stops: initial; + --tw-gradient-via-stops: initial; + --tw-gradient-from-position: 0%; + --tw-gradient-via-position: 50%; + --tw-gradient-to-position: 100%; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + } + } +} diff --git a/repub/static/app.tailwind.css b/repub/static/app.tailwind.css new file mode 100644 index 0000000..13ce457 --- /dev/null +++ b/repub/static/app.tailwind.css @@ -0,0 +1,3 @@ +@import "tailwindcss"; +@source "../components.py"; +@source "../web.py"; diff --git a/repub/web.py b/repub/web.py index c6fe715..8c3e1c2 100644 --- a/repub/web.py +++ b/repub/web.py @@ -1,6 +1,8 @@ from __future__ import annotations -from quart import Quart +from quart import Quart, url_for + +from repub.pages import admin_page def create_app() -> Quart: @@ -8,20 +10,6 @@ def create_app() -> Quart: @app.get("/") async def index() -> str: - return """ - - - - - Republisher - - -
-

Hello, world!

-

Republisher web UI is starting here.

-
- - -""" + return str(admin_page(stylesheet_href=url_for("static", filename="app.css"))) return app diff --git a/uv.lock b/uv.lock index a106ad6..bfa042b 100644 --- a/uv.lock +++ b/uv.lock @@ -504,6 +504,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] +[[package]] +name = "htpy" +version = "25.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/23/e00bbc355e70444d16c90a0f1fdce108c67379fe65e9312cd026c13db976/htpy-25.12.0.tar.gz", hash = "sha256:7d3f4aaa10b35c5e46dfa804df1f3f18772caf8efee6e6a035b5dee89a5d6af8", size = 291259, upload-time = "2025-12-01T20:35:01.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/f1/a2f2caf14b03e7fab4801ac6018a4ac996de3e82a573e7aa21f3cb11a7cc/htpy-25.12.0-py3-none-any.whl", hash = "sha256:642e69278d6f8f4643acc2d2d13c21682ceb5fb4860ecbbce042f171577fff54", size = 21141, upload-time = "2025-12-01T20:35:00.13Z" }, +] + [[package]] name = "hypercorn" version = "0.18.0" @@ -1077,6 +1089,7 @@ dependencies = [ { name = "feedparser" }, { name = "ffmpeg-python" }, { name = "greenlet" }, + { name = "htpy" }, { name = "lxml" }, { name = "peewee" }, { name = "pillow" }, @@ -1108,6 +1121,7 @@ requires-dist = [ { name = "feedparser", specifier = ">=6.0.11,<7.0.0" }, { name = "ffmpeg-python", specifier = ">=0.2.0,<0.3.0" }, { name = "greenlet", specifier = ">=3.2.4,<4.0.0" }, + { name = "htpy", specifier = ">=25.12.0,<26.0.0" }, { name = "lxml", specifier = ">=5.2.1,<6.0.0" }, { name = "peewee", specifier = ">=3.19.0,<4.0.0" }, { name = "pillow", specifier = ">=10.3.0,<11.0.0" }, From 2accb265467e793f293b259a37adb9b8dca5345d Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 12:27:45 +0200 Subject: [PATCH 02/23] add datastar and render shim --- repub/pages/__init__.py | 5 +- repub/pages/dashboard.py | 72 ++++++++++++++--------------- repub/pages/shim.py | 34 ++++++++++++++ repub/static/datastar@1.0.0-RC.8.js | 9 ++++ repub/web.py | 36 +++++++++++++-- tests/test_web.py | 59 +++++++++++++++++++++++ 6 files changed, 173 insertions(+), 42 deletions(-) create mode 100644 repub/pages/shim.py create mode 100644 repub/static/datastar@1.0.0-RC.8.js create mode 100644 tests/test_web.py diff --git a/repub/pages/__init__.py b/repub/pages/__init__.py index 5490d15..95e84fb 100644 --- a/repub/pages/__init__.py +++ b/repub/pages/__init__.py @@ -1 +1,4 @@ -from repub.pages.dashboard import admin_page +from repub.pages.dashboard import admin_component +from repub.pages.shim import shim_page + +__all__ = ["admin_component", "shim_page"] diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index a17bef7..852e1fa 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -4,7 +4,6 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( - base_layout, input_field, nav_link, select_field, @@ -451,7 +450,7 @@ def settings_panel() -> Renderable: ] -def admin_page(*, stylesheet_href: str) -> Renderable: +def admin_component() -> Renderable: running_rows = ( ( h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"], @@ -559,40 +558,39 @@ def admin_page(*, stylesheet_href: str) -> Renderable: ), ) - return base_layout( - page_title="Republisher Admin UI", - stylesheet_href=stylesheet_href, - content=h.div(class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]")[ - sidebar(), - h.main(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[ - h.div(class_="mx-auto max-w-7xl space-y-6")[ - page_header(), - overview_section(), - h.div( - class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]" - )[ - h.div(class_="space-y-6")[ - source_form_section(), - configured_sources_section(), - job_table_section( - title="Running executions", - subtitle="Operators can inspect active crawls and stop them if needed.", - rows=running_rows, - ), - job_table_section( - title="Upcoming jobs", - subtitle="Schedule preview with enable, disable, run now, and delete affordances.", - rows=upcoming_rows, - ), - job_table_section( - title="Completed executions", - subtitle="Recent history with direct access to text logs.", - rows=completed_rows, - ), - ], - h.div(class_="space-y-6")[log_panel(), settings_panel()], + return h.main( + id="morph", + class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", + )[ + sidebar(), + h.div(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[ + h.div(class_="mx-auto max-w-7xl space-y-6")[ + page_header(), + overview_section(), + h.div( + class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]" + )[ + h.div(class_="space-y-6")[ + source_form_section(), + configured_sources_section(), + job_table_section( + title="Running executions", + subtitle="Operators can inspect active crawls and stop them if needed.", + rows=running_rows, + ), + job_table_section( + title="Upcoming jobs", + subtitle="Schedule preview with enable, disable, run now, and delete affordances.", + rows=upcoming_rows, + ), + job_table_section( + title="Completed executions", + subtitle="Recent history with direct access to text logs.", + rows=completed_rows, + ), ], - ] - ], + h.div(class_="space-y-6")[log_panel(), settings_panel()], + ], + ] ], - ) + ] diff --git a/repub/pages/shim.py b/repub/pages/shim.py new file mode 100644 index 0000000..40859d1 --- /dev/null +++ b/repub/pages/shim.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import htpy as h +from htpy import Node, Renderable + +ON_LOAD_JS = ( + "@post(window.location.pathname + " + "(window.location.search + '&u=').replace(/^&/,'?'), " + "{retryMaxCount: Infinity})" +) + +TAB_ID_JS = "self.crypto.randomUUID().substring(0,8)" + + +def shim_page(*, datastar_src: str, head: Node | None = None) -> Renderable: + return h.html(lang="en")[ + h.head[ + h.meta(charset="UTF-8"), + head, + h.script(id="js", defer=True, type="module", src=datastar_src), + h.meta(name="viewport", content="width=device-width, initial-scale=1.0"), + ], + h.body[ + h.div({"data-signals:tabid": TAB_ID_JS}), + h.div( + { + "data-init": ON_LOAD_JS, + "data-on:online__window": ON_LOAD_JS, + } + ), + h.noscript["Your browser does not support JavaScript!"], + h.main(id="morph"), + ], + ] diff --git a/repub/static/datastar@1.0.0-RC.8.js b/repub/static/datastar@1.0.0-RC.8.js new file mode 100644 index 0000000..2106636 --- /dev/null +++ b/repub/static/datastar@1.0.0-RC.8.js @@ -0,0 +1,9 @@ +// Datastar v1.0.0-RC.8 +var ut=/🖕JS_DS🚀/.source,Ge=ut.slice(0,5),je=ut.slice(4),j="datastar-fetch",ee="datastar-signal-patch";var P=Object.hasOwn??Object.prototype.hasOwnProperty.call;var J=e=>e!==null&&typeof e=="object"&&(Object.getPrototypeOf(e)===Object.prototype||Object.getPrototypeOf(e)===null),ft=e=>{for(let t in e)if(P(e,t))return!1;return!0},te=(e,t)=>{for(let n in e){let r=e[n];J(r)||Array.isArray(r)?te(r,t):e[n]=t(r)}},Re=e=>{let t={};for(let[n,r]of e){let s=n.split("."),i=s.pop(),o=s.reduce((a,c)=>a[c]??={},t);o[i]=r}return t};var Me=[],We=[],Oe=0,xe=0,Be=0,Ue,W,Le=0,M=()=>{Oe++},x=()=>{--Oe||(pt(),K())},k=e=>{Ue=W,W=e},H=()=>{W=Ue,Ue=void 0},pe=e=>Zt.bind(0,{previousValue:e,t:e,e:1}),Je=Symbol("computed"),Pe=e=>{let t=Yt.bind(0,{e:17,getter:e});return t[Je]=1,t},T=e=>{let t={d:e,e:2};W&&ze(t,W),k(t),M();try{t.d()}finally{x(),H()}return bt.bind(0,t)},pt=()=>{for(;xe"getter"in e?gt(e):ht(e,e.t),gt=e=>{k(e),vt(e);try{let t=e.t;return t!==(e.t=e.getter(t))}finally{H(),Et(e)}},ht=(e,t)=>(e.e=1,e.previousValue!==(e.previousValue=t)),Ke=e=>{let t=e.e;if(!(t&64)){e.e=t|64;let n=e.r;n?Ke(n.o):We[Be++]=e}},yt=(e,t)=>{if(t&16||t&32&&St(e.s,e)){k(e),vt(e),M();try{e.d()}finally{x(),H(),Et(e)}return}t&32&&(e.e=t&-33);let n=e.s;for(;n;){let r=n.c,s=r.e;s&64&&yt(r,r.e=s&-65),n=n.i}},Zt=(e,...t)=>{if(t.length){if(e.t!==(e.t=t[0])){e.e=17;let r=e.r;return r&&(Xt(r),Oe||pt()),!0}return!1}let n=e.t;if(e.e&16&&ht(e,n)){let r=e.r;r&&Ce(r)}return W&&ze(e,W),n},Yt=e=>{let t=e.e;if(t&16||t&32&&St(e.s,e)){if(gt(e)){let n=e.r;n&&Ce(n)}}else t&32&&(e.e=t&-33);return W&&ze(e,W),e.t},bt=e=>{let t=e.s;for(;t;)t=Fe(t,e);let n=e.r;n&&Fe(n),e.e=0},ze=(e,t)=>{let n=t.a;if(n&&n.c===e)return;let r=n?n.i:t.s;if(r&&r.c===e){r.m=Le,t.a=r;return}let s=e.p;if(s&&s.m===Le&&s.o===t)return;let i=t.a=e.p={m:Le,c:e,o:t,l:n,i:r,u:s};r&&(r.l=i),n?n.i=i:t.s=i,s?s.n=i:e.r=i},Fe=(e,t=e.o)=>{let n=e.c,r=e.l,s=e.i,i=e.n,o=e.u;if(s?s.l=r:t.a=r,r?r.i=s:t.s=s,i?i.u=o:n.p=o,o)o.n=i;else if(!(n.r=i))if("getter"in n){let a=n.s;if(a){n.e=17;do a=Fe(a,n);while(a)}}else"previousValue"in n||bt(n);return s},Xt=e=>{let t=e.n,n;e:for(;;){let r=e.o,s=r.e;if(s&60?s&12?s&4?!(s&48)&&en(e,r)?(r.e=s|40,s&=1):s=0:r.e=s&-9|32:s=0:r.e=s|32,s&2&&Ke(r),s&1){let i=r.r;if(i){let o=(e=i).n;o&&(n={t,f:n},t=o);continue}}if(e=t){t=e.n;continue}for(;n;)if(e=n.t,n=n.f,e){t=e.n;continue e}break}},vt=e=>{Le++,e.a=void 0,e.e=e.e&-57|4},Et=e=>{let t=e.a,n=t?t.i:e.s;for(;n;)n=Fe(n,e);e.e&=-5},St=(e,t)=>{let n,r=0,s=!1;e:for(;;){let i=e.c,o=i.e;if(t.e&16)s=!0;else if((o&17)===17){if(dt(i)){let a=i.r;a.n&&Ce(a),s=!0}}else if((o&33)===33){(e.n||e.u)&&(n={t:e,f:n}),e=i.s,t=i,++r;continue}if(!s){let a=e.i;if(a){e=a;continue}}for(;r--;){let a=t.r,c=a.n;if(c?(e=n.t,n=n.f):e=a,s){if(dt(t)){c&&Ce(a),t=e.o;continue}s=!1}else t.e&=-33;if(t=e.o,e.i){e=e.i;continue e}}return s}},Ce=e=>{do{let t=e.o,n=t.e;(n&48)===32&&(t.e=n|16,n&2&&Ke(t))}while(e=e.n)},en=(e,t)=>{let n=t.a;for(;n;){if(n===e)return!0;n=n.l}return!1},ie=e=>{let t=ne,n=e.split(".");for(let r of n){if(t==null||!P(t,r))return;t=t[r]}return t},Ne=(e,t="")=>{let n=Array.isArray(e);if(n||J(e)){let r=n?[]:{};for(let i in e)r[i]=pe(Ne(e[i],`${t+i}.`));let s=pe(0);return new Proxy(r,{get(i,o){if(!(o==="toJSON"&&!P(r,o)))return n&&o in Array.prototype?(s(),r[o]):typeof o=="symbol"?r[o]:((!P(r,o)||r[o]()==null)&&(r[o]=pe(""),K(t+o,""),s(s()+1)),r[o]())},set(i,o,a){let c=t+o;if(n&&o==="length"){let l=r[o]-a;if(r[o]=a,l>0){let u={};for(let d=a;d{if(e!==void 0&&t!==void 0&&Me.push([e,t]),!Oe&&Me.length){let n=Re(Me);Me.length=0,document.dispatchEvent(new CustomEvent(ee,{detail:n}))}},_=(e,{ifMissing:t}={})=>{M();for(let n in e)e[n]==null?t||delete ne[n]:Tt(e[n],n,ne,"",t);x()},A=(e,t)=>_(Re(e),t),Tt=(e,t,n,r,s)=>{if(J(e)){P(n,t)&&(J(n[t])||Array.isArray(n[t]))||(n[t]={});for(let i in e)e[i]==null?s||delete n[t][i]:Tt(e[i],i,n[t],`${r+t}.`,s)}else s&&P(n,t)||(n[t]=e)},mt=e=>typeof e=="string"?RegExp(e.replace(/^\/|\/$/g,"")):e,$=({include:e=/.*/,exclude:t=/(?!)/}={},n=ne)=>{let r=mt(e),s=mt(t),i=[],o=[[n,""]];for(;o.length;){let[a,c]=o.pop();for(let l in a){let u=c+l;J(a[l])?o.push([a[l],`${u}.`]):r.test(u)&&!s.test(u)&&i.push([u,ie(u)])}}return Re(i)},ne=Ne({});var z=e=>e instanceof HTMLElement||e instanceof SVGElement||e instanceof MathMLElement;var ge=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/([a-z])([0-9]+)/gi,"$1-$2").replace(/([0-9]+)([a-z])/gi,"$1-$2").replace(/[\s_]+/g,"-").toLowerCase();var At=e=>ge(e).replace(/-/g,"_");var tn=/^(?:(?:async\s+)?function\b|(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/,oe=(e,t={})=>{let{reviveFunctionStrings:n=!1}=t;try{return n?JSON.parse(e,(r,s)=>{if(typeof s!="string")return s;let i=s.trim();if(!tn.test(i))return s;try{let o=Function(`return (${i})`)();return typeof o=="function"?o:s}catch{return s}}):JSON.parse(e)}catch{return Function(`return (${e})`)()}},wt={camel:e=>e.replace(/-[a-z]/g,t=>t[1].toUpperCase()),snake:e=>e.replace(/-/g,"_"),pascal:e=>e[0].toUpperCase()+wt.camel(e.slice(1))},L=(e,t,n="camel")=>{for(let r of t.get("case")||[n])e=wt[r]?.(e)||e;return e},B=e=>`data-${e}`,Qe=e=>e;var nn="https://data-star.dev/errors",he=(e,t,n={})=>{Object.assign(n,e);let r=new Error,s=At(t),i=new URLSearchParams({metadata:JSON.stringify(n)}).toString(),o=JSON.stringify(n,null,2);return r.message=`${t} +More info: ${nn}/${s}?${i} +Context: ${o}`,r},ye=new Map,Ze=new Map,Mt=new Map,xt=new Proxy({},{get:(e,t)=>ye.get(t)?.apply,has:(e,t)=>ye.has(t),ownKeys:()=>Reflect.ownKeys(ye),set:()=>!1,deleteProperty:()=>!1}),be=new Map,ke=[],Ye=new Set,rn=new WeakSet,g=e=>{ke.push(e),ke.length===1&&setTimeout(()=>{for(let t of ke)Ye.add(t.name),Ze.set(t.name,t);ke.length=0,ln(),Ye.clear()})},V=e=>{ye.set(e.name,e)};document.addEventListener(j,e=>{let t=Mt.get(e.detail.type);t&&t.apply({error:he.bind(0,{plugin:{type:"watcher",name:t.name},element:{id:e.target.id,tag:e.target.tagName}})},e.detail.argsRaw)});var ve=e=>{Mt.set(e.name,e)},Rt=e=>{for(let t of e){let n=be.get(t);if(n&&be.delete(t))for(let r of n.values())for(let s of r.values())s()}},Lt=B("ignore"),sn=`[${Lt}]`,Nt=e=>e.hasAttribute(`${Lt}__self`)||!!e.closest(sn),He=(e,t)=>{for(let n of e)if(!Nt(n)){let r=new Set;for(let s in n.dataset){let i=s.replace(/[A-Z]/g,"-$&").toLowerCase();r.add(i),Xe(n,i,n.dataset[s],t)}for(let s of Array.from(n.attributes)){if(!s.name.startsWith("data-"))continue;let i=s.name.slice(5);r.has(i)||Xe(n,i,s.value,t)}}},on=e=>{for(let{target:t,type:n,attributeName:r,addedNodes:s,removedNodes:i}of e)if(n==="childList"){for(let o of i)z(o)&&(Rt([o]),Rt(o.querySelectorAll("*")));for(let o of s)z(o)&&(He([o]),He(o.querySelectorAll("*")))}else if(n==="attributes"&&r.startsWith("data-")&&z(t)&&!Nt(t)){let o=r.slice(5),a=Qe(o);if(!a)continue;let c=t.getAttribute(r);if(c===null){let l=be.get(t);if(l){let u=l.get(a);if(u){for(let d of u.values())d();l.delete(a)}}}else Xe(t,o,c)}},an=new MutationObserver(on),cn=e=>{let[t,...n]=e.split("__"),[r,s]=t.split(/:(.+)/),i=new Map;for(let o of n){let[a,...c]=o.split(".");i.set(a,new Set(c))}return{pluginName:r,key:s,mods:i}};var ln=(e=document.documentElement,t=!0)=>{z(e)&&He([e],!0),He(e.querySelectorAll("*"),!0),t&&(an.observe(e,{subtree:!0,childList:!0,attributes:!0}),rn.add(e))};var Xe=(e,t,n,r)=>{let s=Qe(t);if(!s)return;let{pluginName:i,key:o,mods:a}=cn(s),c=Ze.get(i);if((!r||Ye.has(i))&&!!c){let u={el:e,rawKey:s,mods:a,error:he.bind(0,{plugin:{type:"attribute",name:c.name},element:{id:e.id,tag:e.tagName},expression:{rawKey:s,key:o,value:n}}),key:o,value:n,loadedPluginNames:{actions:new Set(ye.keys()),attributes:new Set(Ze.keys())},rx:void 0},d=c.requirement&&(typeof c.requirement=="string"?c.requirement:c.requirement.key)||"allowed",h=c.requirement&&(typeof c.requirement=="string"?c.requirement:c.requirement.value)||"allowed",f=o!=null&&o!=="",p=n!=null&&n!=="";if(f){if(d==="denied")throw u.error("KeyNotAllowed")}else if(d==="must")throw u.error("KeyRequired");if(p){if(h==="denied")throw u.error("ValueNotAllowed")}else if(h==="must")throw u.error("ValueRequired");if(d==="exclusive"||h==="exclusive"){if(f&&p)throw u.error("KeyAndValueProvided");if(!f&&!p)throw u.error("KeyOrValueRequired")}let m=new Map;if(p){let v;u.rx=(...C)=>(v||(v=un(n,{returnsValue:c.returnsValue,argNames:c.argNames,cleanups:m})),v(e,...C))}let b=c.apply(u);b&&m.set("attribute",b);let R=be.get(e);if(R){let v=R.get(s);if(v)for(let C of v.values())C()}else R=new Map,be.set(e,R);R.set(s,m)}},un=(e,{returnsValue:t=!1,argNames:n=[],cleanups:r=new Map}={})=>{let s="";if(t){let c=/(\/(\\\/|[^/])*\/|"(\\"|[^"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|\(\s*((function)\s*\(\s*\)|(\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/gm,l=e.trim().match(c);if(l){let u=l.length-1,d=l[u].trim();d.startsWith("return")||(l[u]=`return (${d});`),s=l.join(`; +`)}}else s=e.trim();let i=new Map,o=RegExp(`(?:${Ge})(.*?)(?:${je})`,"gm"),a=0;for(let c of s.matchAll(o)){let l=c[1],u=`__escaped${a++}`;i.set(u,l),s=s.replace(Ge+l+je,u)}s=s.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\$]|\$(?!\{))*`)|\$\{([^{}]*)\}|\$([a-zA-Z_\d]\w*(?:[.-]\w+)*)/g,(c,l,u,d)=>l?c:u!==void 0?`\${${u.replace(/\$([a-zA-Z_\d]\w*(?:[.-]\w+)*)/g,(h,f)=>f.split(".").reduce((p,m)=>`${p}['${m}']`,"$"))}}`:d.split(".").reduce((h,f)=>`${h}['${f}']`,"$")),s=s.replaceAll(/@([A-Za-z_$][\w$]*)\(/g,'__action("$1",evt,');for(let[c,l]of i)s=s.replace(c,l);try{let c=Function("el","$","__action","evt",...n,s);return(l,...u)=>{let d=(h,f,...p)=>{let m=he.bind(0,{plugin:{type:"action",name:h},element:{id:l.id,tag:l.tagName},expression:{fnContent:s,value:e}}),b=xt[h];if(b)return b({el:l,evt:f,error:m,cleanups:r},...p);throw m("UndefinedAction")};try{return c(l,ne,d,void 0,...u)}catch(h){throw console.error(h),he({element:{id:l.id,tag:l.tagName},expression:{fnContent:s,value:e},error:h.message},"ExecuteExpression")}}}catch(c){throw console.error(c),he({expression:{fnContent:s,value:e},error:c.message},"GenerateExpression")}};V({name:"peek",apply(e,t){k();try{return t()}finally{H()}}});V({name:"setAll",apply(e,t,n){k();let r=$(n);te(r,()=>t),_(r),H()}});V({name:"toggleAll",apply(e,t){k();let n=$(t);te(n,r=>!r),_(n),H()}});var Ft=new WeakMap,Ee=(e,t,n=!0)=>V({name:e,apply:async({el:r,evt:s,error:i,cleanups:o},a,{selector:c,headers:l,contentType:u="json",filterSignals:{include:d=/.*/,exclude:h=/(^|\.)_/}={},openWhenHidden:f=n,payload:p,requestCancellation:m="auto",retry:b="auto",retryInterval:R=1e3,retryScaler:v=2,retryMaxWaitMs:C=3e4,retryMaxCount:_e=10}={})=>{let Y=m instanceof AbortController?m:new AbortController;(m==="auto"||m==="cleanup")&&(Ft.get(r)?.abort(),Ft.set(r,Y)),m==="cleanup"&&(o.get(`@${e}`)?.(),o.set(`@${e}`,async()=>{Y.abort(),await Promise.resolve()}));let X=()=>{};try{if(!a?.length)throw i("FetchNoUrlProvided",{action:V});let fe={Accept:"text/event-stream, text/html, application/json","Datastar-Request":!0};u==="json"&&(fe["Content-Type"]="application/json");let q=Object.assign({},fe,l),N={input:"",method:t,headers:q,openWhenHidden:f,retry:b,retryInterval:R,retryScaler:v,retryMaxWaitMs:C,retryMaxCount:_e,signal:Y.signal,onopen:async y=>{y.status>=400&&re(fn,r,{status:y.status.toString()})},onmessage:y=>{if(!y.event.startsWith("datastar"))return;let U=y.event,E={};for(let F of y.data.split(` +`)){let S=F.indexOf(" "),O=F.slice(0,S),w=F.slice(S+1);(E[O]||=[]).push(w)}let D=Object.fromEntries(Object.entries(E).map(([F,S])=>[F,S.join(` +`)]));re(U,r,D)},onerror:y=>{if(Ct(y))throw y("FetchExpectedTextEventStream",{url:a});y&&(console.error(y.message),re(dn,r,{message:y.message}))}},Ve=()=>{let y=new URL(a,document.baseURI),U=new URLSearchParams(y.search);if(u==="json"){k(),p=p!==void 0?p:$({include:d,exclude:h}),H();let E=JSON.stringify(p);t==="GET"?U.set("datastar",E):N.body=E}else if(u==="form"){let E=c?document.querySelector(c):r.closest("form");if(!E)throw i("FetchFormNotFound",{action:V,selector:c});if(!E.noValidate&&!E.checkValidity()){E.reportValidity();return}let D=new FormData(E),F=r;if(r===E&&s instanceof SubmitEvent)F=s.submitter;else{let w=de=>de.preventDefault();E.addEventListener("submit",w),X=()=>{E.removeEventListener("submit",w)}}if(F instanceof HTMLButtonElement){let w=F.getAttribute("name");w&&D.append(w,F.value)}let S=E.getAttribute("enctype")==="multipart/form-data";S||(q["Content-Type"]="application/x-www-form-urlencoded");let O=new URLSearchParams(D);if(t==="GET")for(let[w,de]of O)U.append(w,de);else S?N.body=D:N.body=O}else throw i("FetchInvalidContentType",{action:V,contentType:u});return y.search=U.toString(),N.input=y.toString(),N};re(et,r,{});try{await bn(r,Ve)}catch(y){if(!Ct(y))throw i("FetchFailed",{method:t,url:a,error:y.message})}}finally{re(tt,r,{}),X(),o.delete(`@${e}`)}}});Ee("get","GET",!1);Ee("patch","PATCH");Ee("post","POST");Ee("put","PUT");Ee("delete","DELETE");var et="started",tt="finished",fn="error",dn="retrying",mn="retries-failed",re=(e,t,n)=>document.dispatchEvent(new CustomEvent(j,{detail:{type:e,el:t,argsRaw:n}})),Ct=e=>`${e}`.includes("text/event-stream"),pn=async(e,t)=>{let n=e.getReader(),r=await n.read();for(;!r.done;)t(r.value),r=await n.read()},gn=e=>{let t,n,r,s=!1;return i=>{t?t=yn(t,i):(t=i,n=0,r=-1);let o=t.length,a=0;for(;n{let r=Ot(),s=new TextDecoder;return(i,o)=>{if(!i.length)n?.(r),r=Ot();else if(o>0){let a=s.decode(i.subarray(0,o)),c=o+(i[o+1]===32?2:1),l=s.decode(i.subarray(c));switch(a){case"data":r.data=r.data?`${r.data} +${l}`:l;break;case"event":r.event=l;break;case"id":e(r.id=l);break;case"retry":{let u=+l;Number.isNaN(u)||t(r.retry=u);break}}}}},yn=(e,t)=>{let n=new Uint8Array(e.length+t.length);return n.set(e),n.set(t,e.length),n},Ot=()=>({data:"",event:"",id:"",retry:void 0}),bn=(e,t)=>new Promise((n,r)=>{let s=t();if(!s)return;let{input:i,signal:o,headers:a,onopen:c,onmessage:l,onclose:u,onerror:d,openWhenHidden:h,fetch:f,retry:p="auto",retryInterval:m=1e3,retryScaler:b=2,retryMaxWaitMs:R=3e4,retryMaxCount:v=10,responseOverrides:C,..._e}=s,Y={...a},X,fe=()=>{X.abort(),document.hidden||D()};h||document.addEventListener("visibilitychange",fe);let q,N=()=>{document.removeEventListener("visibilitychange",fe),clearTimeout(q),X.abort()};o?.addEventListener("abort",()=>{N(),n()});let Ve=f||window.fetch,y=c||(()=>{}),U=0,E=m,D=async()=>{X=new AbortController;let F=X.signal;try{let S=await Ve(i,{..._e,headers:Y,signal:F});await y(S);let O=async(G,me,De,Ae,...Qt)=>{let lt={[De]:await me.text()};for(let $e of Qt){let qe=me.headers.get(`datastar-${ge($e)}`);if(Ae){let we=Ae[$e];we&&(qe=typeof we=="string"?we:JSON.stringify(we))}qe&&(lt[$e]=qe)}re(G,e,lt),N(),n()},w=S.status,de=w===204,ct=w>=300&&w<400,zt=w>=400&&w<600;if(w!==200){if(u?.(),p!=="never"&&!de&&!ct&&(p==="always"||p==="error"&&zt)){clearTimeout(q),q=setTimeout(D,m);return}N(),n();return}U=0,m=E;let Ie=S.headers.get("Content-Type");if(Ie?.includes("text/html"))return await O("datastar-patch-elements",S,"elements",C,"selector","mode","namespace","useViewTransition");if(Ie?.includes("application/json"))return await O("datastar-patch-signals",S,"signals",C,"onlyIfMissing");if(Ie?.includes("text/javascript")){let G=document.createElement("script"),me=S.headers.get("datastar-script-attributes");if(me)for(let[De,Ae]of Object.entries(JSON.parse(me)))G.setAttribute(De,Ae);G.textContent=await S.text(),document.head.appendChild(G),N();return}if(await pn(S.body,gn(hn(G=>{G?Y["last-event-id"]=G:delete Y["last-event-id"]},G=>{E=m=G},l))),u?.(),p==="always"&&!ct){clearTimeout(q),q=setTimeout(D,m);return}N(),n()}catch(S){if(!F.aborted)try{let O=d?.(S)||m;clearTimeout(q),q=setTimeout(D,O),m=Math.min(m*b,R),++U>=v?(re(mn,e,{}),N(),r("Max retries reached.")):console.error(`Datastar failed to reach ${i.toString()} retrying in ${O}ms.`)}catch(O){N(),r(O)}}};D()});g({name:"attr",requirement:{value:"must"},returnsValue:!0,apply({el:e,key:t,rx:n}){let r=(a,c)=>{c===""||c===!0?e.setAttribute(a,""):c===!1||c==null?e.removeAttribute(a):typeof c=="string"?e.setAttribute(a,c):typeof c=="function"?e.setAttribute(a,c.toString()):e.setAttribute(a,JSON.stringify(c,(l,u)=>typeof u=="function"?u.toString():u))},s=t?()=>{i.disconnect();let a=n();r(t,a),i.observe(e,{attributeFilter:[t]})}:()=>{i.disconnect();let a=n(),c=Object.keys(a);for(let l of c)r(l,a[l]);i.observe(e,{attributeFilter:c})},i=new MutationObserver(s),o=T(s);return()=>{i.disconnect(),o()}}});var vn=/^data:(?[^;]+);base64,(?.*)$/,Pt=Symbol("empty"),kt=B("bind");g({name:"bind",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r,error:s}){let i=t!=null?L(t,n):r,o=(f,p)=>p==="number"?+f.value:f.value,a=f=>{e.value=`${f}`};if(e instanceof HTMLInputElement)switch(e.type){case"range":case"number":o=(f,p)=>p==="string"?f.value:+f.value;break;case"checkbox":o=(f,p)=>f.value!=="on"?p==="boolean"?f.checked:f.checked?f.value:"":p==="string"?f.checked?f.value:"":f.checked,a=f=>{e.checked=typeof f=="string"?f===e.value:f};break;case"radio":e.getAttribute("name")?.length||e.setAttribute("name",i),o=(f,p)=>f.checked?p==="number"?+f.value:f.value:Pt,a=f=>{e.checked=f===(typeof f=="number"?+e.value:e.value)};break;case"file":{let f=()=>{let p=[...e.files||[]],m=[];Promise.all(p.map(b=>new Promise(R=>{let v=new FileReader;v.onload=()=>{if(typeof v.result!="string")throw s("InvalidFileResultType",{resultType:typeof v.result});let C=v.result.match(vn);if(!C?.groups)throw s("InvalidDataUri",{result:v.result});m.push({name:b.name,contents:C.groups.contents,mime:C.groups.mime})},v.onloadend=()=>R(),v.readAsDataURL(b)}))).then(()=>{A([[i,m]])})};return e.addEventListener("change",f),e.addEventListener("input",f),()=>{e.removeEventListener("change",f),e.removeEventListener("input",f)}}}else if(e instanceof HTMLSelectElement){if(e.multiple){let f=new Map;o=p=>[...p.selectedOptions].map(m=>{let b=f.get(m.value);return b==="string"||b==null?m.value:+m.value}),a=p=>{for(let m of e.options)p.includes(m.value)?(f.set(m.value,"string"),m.selected=!0):p.includes(+m.value)?(f.set(m.value,"number"),m.selected=!0):m.selected=!1}}}else e instanceof HTMLTextAreaElement||(o=f=>"value"in f?f.value:f.getAttribute("value"),a=f=>{"value"in e?e.value=f:e.setAttribute("value",f)});let c=ie(i),l=typeof c,u=i;if(Array.isArray(c)&&!(e instanceof HTMLSelectElement&&e.multiple)){let f=t||r,p=document.querySelectorAll(`[${kt}\\:${CSS.escape(f)}],[${kt}="${CSS.escape(f)}"]`),m=[],b=0;for(let R of p){if(m.push([`${u}.${b}`,o(R,"none")]),e===R)break;b++}A(m,{ifMissing:!0}),u=`${u}.${b}`}else A([[u,o(e,l)]],{ifMissing:!0});let d=()=>{let f=ie(u);if(f!=null){let p=o(e,typeof f);p!==Pt&&A([[u,p]])}};e.addEventListener("input",d),e.addEventListener("change",d);let h=T(()=>{a(ie(u))});return()=>{h(),e.removeEventListener("input",d),e.removeEventListener("change",d)}}});g({name:"class",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,mods:n,rx:r}){e&&=L(e,n,"kebab");let s,i=()=>{o.disconnect(),s=e?{[e]:r()}:r();for(let c in s){let l=c.split(/\s+/).filter(u=>u.length>0);if(s[c])for(let u of l)t.classList.contains(u)||t.classList.add(u);else for(let u of l)t.classList.contains(u)&&t.classList.remove(u)}o.observe(t,{attributeFilter:["class"]})},o=new MutationObserver(i),a=T(i);return()=>{o.disconnect(),a();for(let c in s){let l=c.split(/\s+/).filter(u=>u.length>0);for(let u of l)t.classList.remove(u)}}}});g({name:"computed",requirement:{value:"must"},returnsValue:!0,apply({key:e,mods:t,rx:n,error:r}){if(e)A([[L(e,t),Pe(n)]]);else{let s=Object.assign({},n());te(s,i=>{if(typeof i=="function")return Pe(i);throw r("ComputedExpectedFunction")}),_(s)}}});g({name:"effect",requirement:{key:"denied",value:"must"},apply:({rx:e})=>T(e)});g({name:"indicator",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?L(t,n):r;A([[s,!1]]);let i=o=>{let{type:a,el:c}=o.detail;if(c===e)switch(a){case et:A([[s,!0]]);break;case tt:A([[s,!1]]);break}};return document.addEventListener(j,i),()=>{A([[s,!1]]),document.removeEventListener(j,i)}}});var Q=e=>{if(!e||e.size<=0)return 0;for(let t of e){if(t.endsWith("ms"))return+t.replace("ms","");if(t.endsWith("s"))return+t.replace("s","")*1e3;try{return Number.parseFloat(t)}catch{}}return 0},se=(e,t,n=!1)=>e?e.has(t.toLowerCase()):n,Ht=(e,t="")=>{if(e&&e.size>0)for(let n of e)return n;return t};var nt=(e,t)=>(...n)=>{setTimeout(()=>{e(...n)},t)},_t=(e,t,n=!0,r=!1,s=!1)=>{let i=null,o=0;return(...a)=>{n&&!o?(e(...a),i=null):i=a,(!o||s)&&(o&&clearTimeout(o),o=setTimeout(()=>{r&&i!==null&&e(...i),i=null,o=0},t))}},ae=(e,t)=>{let n=t.get("delay");if(n){let i=Q(n);e=nt(e,i)}let r=t.get("debounce");if(r){let i=Q(r),o=se(r,"leading",!1),a=!se(r,"notrailing",!1);e=_t(e,i,o,a,!0)}let s=t.get("throttle");if(s){let i=Q(s),o=!se(s,"noleading",!1),a=se(s,"trailing",!1);e=_t(e,i,o,a)}return e};var rt=!!document.startViewTransition,Z=(e,t)=>{if(t.has("viewtransition")&&rt){let n=e;e=(...r)=>document.startViewTransition(()=>n(...r))}return e};g({name:"init",requirement:{key:"denied",value:"must"},apply({rx:e,mods:t}){let n=()=>{M(),e(),x()};n=Z(n,t);let r=0,s=t.get("delay");s&&(r=Q(s),r>0&&(n=nt(n,r))),n()}});g({name:"json-signals",requirement:{key:"denied"},apply({el:e,value:t,mods:n}){let r=n.has("terse")?0:2,s={};t&&(s=oe(t));let i=()=>{o.disconnect(),e.textContent=JSON.stringify($(s),null,r),o.observe(e,{childList:!0,characterData:!0,subtree:!0})},o=new MutationObserver(i),a=T(i);return()=>{o.disconnect(),a()}}});g({name:"on",requirement:"must",argNames:["evt"],apply({el:e,key:t,mods:n,rx:r}){let s=e;n.has("window")&&(s=window);let i=c=>{c&&(n.has("prevent")&&c.preventDefault(),n.has("stop")&&c.stopPropagation()),M(),r(c),x()};i=Z(i,n),i=ae(i,n);let o={capture:n.has("capture"),passive:n.has("passive"),once:n.has("once")};if(n.has("outside")){s=document;let c=i;i=l=>{e.contains(l?.target)||c(l)}}let a=L(t,n,"kebab");if((a===j||a===ee)&&(s=document),e instanceof HTMLFormElement&&a==="submit"){let c=i;i=l=>{l?.preventDefault(),c(l)}}return s.addEventListener(a,i,o),()=>{s.removeEventListener(a,i)}}});var Vt=(e,t,n)=>Math.max(t,Math.min(n,e));var st=new WeakSet;g({name:"on-intersect",requirement:{key:"denied",value:"must"},apply({el:e,mods:t,rx:n}){let r=()=>{M(),n(),x()};r=Z(r,t),r=ae(r,t);let s={threshold:0};if(t.has("full"))s.threshold=1;else if(t.has("half"))s.threshold=.5;else{let a=t.get("threshold");a&&(s.threshold=Vt(Number(Ht(a)),0,100)/100)}let i=t.has("exit"),o=new IntersectionObserver(a=>{for(let c of a)c.isIntersecting!==i&&(r(),o&&st.has(e)&&o.disconnect())},s);return o.observe(e),t.has("once")&&st.add(e),()=>{t.has("once")||st.delete(e),o&&(o.disconnect(),o=null)}}});g({name:"on-interval",requirement:{key:"denied",value:"must"},apply({mods:e,rx:t}){let n=()=>{M(),t(),x()};n=Z(n,e);let r=1e3,s=e.get("duration");s&&(r=Q(s),se(s,"leading",!1)&&n());let i=setInterval(n,r);return()=>{clearInterval(i)}}});g({name:"on-signal-patch",requirement:{value:"must"},argNames:["patch"],returnsValue:!0,apply({el:e,key:t,mods:n,rx:r,error:s}){if(t&&t!=="filter")throw s("KeyNotAllowed");let i=B(`${this.name}-filter`),o=e.getAttribute(i),a={};o&&(a=oe(o));let c=!1,l=ae(u=>{if(c)return;let d=$(a,u.detail);if(!ft(d)){c=!0,M();try{r(d)}finally{x(),c=!1}}},n);return document.addEventListener(ee,l),()=>{document.removeEventListener(ee,l)}}});g({name:"ref",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?L(t,n):r;A([[s,e]])}});var It="none",Dt="display";g({name:"show",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),t()?e.style.display===It&&e.style.removeProperty(Dt):e.style.setProperty(Dt,It),r.observe(e,{attributeFilter:["style"]})},r=new MutationObserver(n),s=T(n);return()=>{r.disconnect(),s()}}});g({name:"signals",returnsValue:!0,apply({key:e,mods:t,rx:n}){let r=t.has("ifmissing");if(e){e=L(e,t);let s=n?.();A([[e,s]],{ifMissing:r})}else{let s=Object.assign({},n?.());_(s,{ifMissing:r})}}});g({name:"style",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,rx:n}){let{style:r}=t,s=new Map,i=(l,u)=>{let d=s.get(l);!u&&u!==0?d!==void 0&&(d?r.setProperty(l,d):r.removeProperty(l)):(d===void 0&&s.set(l,r.getPropertyValue(l)),r.setProperty(l,String(u)))},o=()=>{if(a.disconnect(),e)i(e,n());else{let l=n();for(let[u,d]of s)u in l||(d?r.setProperty(u,d):r.removeProperty(u));for(let u in l)i(ge(u),l[u])}a.observe(t,{attributeFilter:["style"]})},a=new MutationObserver(o),c=T(o);return()=>{a.disconnect(),c();for(let[l,u]of s)u?r.setProperty(l,u):r.removeProperty(l)}}});g({name:"text",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),e.textContent=`${t()}`,r.observe(e,{childList:!0,characterData:!0,subtree:!0})},r=new MutationObserver(n),s=T(n);return()=>{r.disconnect(),s()}}});var $t=(e,t)=>e.includes(t),En=["remove","outer","inner","replace","prepend","append","before","after"],Sn=["html","svg","mathml"];ve({name:"datastar-patch-elements",apply(e,t){let n=typeof t.selector=="string"?t.selector:"",r=typeof t.mode=="string"?t.mode:"outer",s=typeof t.namespace=="string"?t.namespace:"html",i=typeof t.useViewTransition=="string"?t.useViewTransition:"",o=t.elements;if(!$t(En,r))throw e.error("PatchElementsInvalidMode",{mode:r});if(!n&&r!=="outer"&&r!=="replace")throw e.error("PatchElementsExpectedSelector");if(!$t(Sn,s))throw e.error("PatchElementsInvalidNamespace",{namespace:s});let a={selector:n,mode:r,namespace:s,useViewTransition:i.trim()==="true",elements:o};rt&&a.useViewTransition?document.startViewTransition(()=>qt(e,a)):qt(e,a)}});var qt=({error:e},{selector:t,mode:n,namespace:r,elements:s})=>{let i=document.createDocumentFragment(),o=typeof s!="string"&&!!s;if(typeof s=="string"){let a=s.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,""),c=/<\/html>/.test(a),l=/<\/head>/.test(a),u=/<\/body>/.test(a),d=r==="svg"?"svg":r==="mathml"?"math":"",h=d?`<${d}>${s}`:s,f=new DOMParser().parseFromString(c||l||u?s:``,"text/html");if(c)i.appendChild(f.documentElement);else if(l&&u)i.appendChild(f.head),i.appendChild(f.body);else if(l)i.appendChild(f.head);else if(u)i.appendChild(f.body);else if(d){let p=f.querySelector("template").content.querySelector(d);for(let m of p.childNodes)i.appendChild(m)}else i=f.querySelector("template").content}else s&&(s instanceof DocumentFragment?i=s:s instanceof Element&&i.appendChild(s));if(!t&&(n==="outer"||n==="replace")){let a=Array.from(i.children);for(let c of a){let l;if(c instanceof HTMLHtmlElement)l=document.documentElement;else if(c instanceof HTMLBodyElement)l=document.body;else if(c instanceof HTMLHeadElement)l=document.head;else if(l=document.getElementById(c.id),!l){console.warn(e("PatchElementsNoTargetsFound"),{element:{id:c.id}});continue}jt(n,c,[l],o)}}else{let a=document.querySelectorAll(t);if(!a.length){console.warn(e("PatchElementsNoTargetsFound"),{selector:t});return}let c=o&&n!=="remove"?[a[0]]:a;jt(n,i,c,o)}},ot=new WeakSet;for(let e of document.querySelectorAll("script"))ot.add(e);var Ut=e=>{let t=e instanceof HTMLScriptElement?[e]:e.querySelectorAll("script");for(let n of t)if(!ot.has(n)){let r=document.createElement("script");for(let{name:s,value:i}of n.attributes)r.setAttribute(s,i);r.text=n.text,n.replaceWith(r),ot.add(r)}},Gt=(e,t,n,r)=>{let s=!1;for(let i of e){if(r&&s)break;let o=r?t:t.cloneNode(!0);Ut(o),i[n](o),s=!0}},jt=(e,t,n,r)=>{switch(e){case"remove":for(let s of n)s.remove();break;case"outer":case"inner":{let s=!1;for(let i of n){if(r&&s)break;let o=r?t:t.cloneNode(!0);An(i,o,e),Ut(i);let a=i.closest("[data-scope-children]");a&&a.dispatchEvent(new CustomEvent("datastar:scope-children",{bubbles:!1})),s=!0}}break;case"replace":Gt(n,t,"replaceWith",r);break;case"prepend":case"append":case"before":case"after":Gt(n,t,e,r)}},I=new Map,le=new Set,ce=new Map,Se=new Set,ue=document.createElement("div");ue.hidden=!0;var Te=B("ignore-morph"),Tn=`[${Te}]`,An=(e,t,n="outer")=>{if(z(e)&&z(t)&&e.hasAttribute(Te)&&t.hasAttribute(Te)||e.parentElement?.closest(Tn))return;let r=document.createElement("div");r.append(t),document.body.insertAdjacentElement("afterend",ue);let s=e.querySelectorAll("[id]");for(let{id:a,tagName:c}of s)ce.has(a)?Se.add(a):ce.set(a,c);e instanceof Element&&e.id&&(ce.has(e.id)?Se.add(e.id):ce.set(e.id,e.tagName)),le.clear();let i=r.querySelectorAll("[id]");for(let{id:a,tagName:c}of i)le.has(a)?Se.add(a):ce.get(a)===c&&le.add(a);for(let a of Se)le.delete(a);ce.clear(),Se.clear(),I.clear();let o=n==="outer"?e.parentElement:e;Bt(o,s),Bt(r,i),Jt(o,r,n==="outer"?e:null,e.nextSibling),ue.remove()},Jt=(e,t,n=null,r=null)=>{e instanceof HTMLTemplateElement&&t instanceof HTMLTemplateElement&&(e=e.content,t=t.content),n??=e.firstChild;for(let s of t.childNodes){if(n&&n!==r){let i=wn(s,n,r);if(i){if(i!==n){let o=n;for(;o&&o!==i;){let a=o;o=o.nextSibling,at(a)}}it(i,s),n=i.nextSibling;continue}}if(s instanceof Element&&le.has(s.id)){let i=document.getElementById(s.id),o=i;for(;o=o.parentNode;){let a=I.get(o);a&&(a.delete(s.id),a.size||I.delete(o))}Kt(e,i,n),it(i,s),n=i.nextSibling;continue}if(I.has(s)){let i=s.namespaceURI,o=s.tagName,a=i&&i!=="http://www.w3.org/1999/xhtml"?document.createElementNS(i,o):document.createElement(o);e.insertBefore(a,n),it(a,s),n=a.nextSibling}else{let i=document.importNode(s,!0);e.insertBefore(i,n),n=i.nextSibling}}for(;n&&n!==r;){let s=n;n=n.nextSibling,at(s)}},wn=(e,t,n)=>{let r=null,s=e.nextSibling,i=0,o=0,a=I.get(e)?.size||0,c=t;for(;c&&c!==n;){if(Wt(c,e)){let l=!1,u=I.get(c),d=I.get(e);if(d&&u){for(let h of u)if(d.has(h)){l=!0;break}}if(l)return c;if(!r&&!I.has(c)){if(!a)return c;r=c}}if(o+=I.get(c)?.size||0,o>a)break;r===null&&s&&Wt(c,s)&&(i++,s=s.nextSibling,i>=2&&(r=void 0)),c=c.nextSibling}return r||null},Wt=(e,t)=>e.nodeType===t.nodeType&&e.tagName===t.tagName&&(!e.id||e.id===t.id),at=e=>{I.has(e)?Kt(ue,e,null):e.parentNode?.removeChild(e)},Kt=at.call.bind(ue.moveBefore??ue.insertBefore),Rn=B("preserve-attr"),it=(e,t)=>{let n=t.nodeType;if(n===1){let r=e,s=t,i=r.hasAttribute("data-scope-children");if(r.hasAttribute(Te)&&s.hasAttribute(Te))return e;r instanceof HTMLInputElement&&s instanceof HTMLInputElement&&s.type!=="file"?s.getAttribute("value")!==r.getAttribute("value")&&(r.value=s.getAttribute("value")??""):r instanceof HTMLTextAreaElement&&s instanceof HTMLTextAreaElement&&(s.value!==r.value&&(r.value=s.value),r.firstChild&&r.firstChild.nodeValue!==s.value&&(r.firstChild.nodeValue=s.value));let o=(t.getAttribute(Rn)??"").split(" ");for(let{name:a,value:c}of s.attributes)r.getAttribute(a)!==c&&!o.includes(a)&&r.setAttribute(a,c);for(let a=r.attributes.length-1;a>=0;a--){let{name:c}=r.attributes[a];!s.hasAttribute(c)&&!o.includes(c)&&r.removeAttribute(c)}i&&!r.hasAttribute("data-scope-children")&&r.setAttribute("data-scope-children",""),r instanceof HTMLTemplateElement&&s instanceof HTMLTemplateElement?r.innerHTML=s.innerHTML:r.isEqualNode(s)||Jt(r,s),i&&r.dispatchEvent(new CustomEvent("datastar:scope-children",{bubbles:!1}))}return(n===8||n===3)&&e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue),e},Bt=(e,t)=>{for(let n of t)if(le.has(n.id)){let r=n;for(;r&&r!==e;){let s=I.get(r);s||(s=new Set,I.set(r,s)),s.add(n.id),r=r.parentElement}}};ve({name:"datastar-patch-signals",apply({error:e},{signals:t,onlyIfMissing:n}){if(typeof t!="string")throw e("PatchSignalsExpectedSignals");let r=typeof n=="string"&&n.trim()==="true";_(oe(t),{ifMissing:r})}});export{V as action,xt as actions,g as attribute,M as beginBatch,Pe as computed,T as effect,x as endBatch,$ as filtered,ie as getPath,_ as mergePatch,A as mergePaths,ne as root,pe as signal,k as startPeeking,H as stopPeeking,ve as watcher}; +//# sourceMappingURL=datastar.js.map diff --git a/repub/web.py b/repub/web.py index 8c3e1c2..c7db483 100644 --- a/repub/web.py +++ b/repub/web.py @@ -1,15 +1,43 @@ from __future__ import annotations -from quart import Quart, url_for +import hashlib -from repub.pages import admin_page +import htpy as h +from quart import Quart, Response, request, url_for + +from repub.pages import admin_component, shim_page + + +def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]: + head = ( + h.title["Republisher Admin UI"], + h.link(rel="stylesheet", href=stylesheet_href), + ) + body = str(shim_page(datastar_src=datastar_src, head=head)) + etag = hashlib.sha256(body.encode("utf-8")).hexdigest() + return body, etag def create_app() -> Quart: app = Quart(__name__) @app.get("/") - async def index() -> str: - return str(admin_page(stylesheet_href=url_for("static", filename="app.css"))) + async def index() -> Response: + body, etag = _render_shim_page( + stylesheet_href=url_for("static", filename="app.css"), + datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), + ) + if request.if_none_match.contains(etag): + response = Response(status=304) + response.set_etag(etag) + return response + + response = Response(body, mimetype="text/html") + response.set_etag(etag) + return response + + @app.post("/") + async def index_patch() -> Response: + return Response(str(admin_component()), mimetype="text/html") return app diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..533a4f5 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import asyncio + +from repub.web import create_app + + +def test_root_get_serves_datastar_shim() -> None: + async def run() -> None: + client = create_app().test_client() + + response = await client.get("/") + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert response.headers["ETag"] + assert body.startswith("") + assert ( + '' + in body + ) + assert 'data-signals:tabid="self.crypto.randomUUID().substring(0,8)"' in body + assert 'data-init="@post(window.location.pathname +' in body + assert "retryMaxCount: Infinity" in body + assert "data-on:online__window=" in body + assert '
' in body + + asyncio.run(run()) + + +def test_root_get_honors_if_none_match() -> None: + async def run() -> None: + client = create_app().test_client() + + initial = await client.get("/") + etag = initial.headers["ETag"] + + response = await client.get("/", headers={"If-None-Match": etag}) + + assert response.status_code == 304 + assert response.headers["ETag"] == etag + + asyncio.run(run()) + + +def test_root_post_serves_morph_component() -> None: + async def run() -> None: + client = create_app().test_client() + + response = await client.post("/?u=shim") + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert response.content_type == "text/html; charset=utf-8" + assert body.startswith('
Date: Mon, 30 Mar 2026 12:34:38 +0200 Subject: [PATCH 03/23] add datastar SSE rendering --- AGENTS.md | 68 ++++++++++++++++++++++++++ repub/datastar.py | 85 ++++++++++++++++++++++++++++++++ repub/pages/dashboard.py | 12 +++-- repub/web.py | 80 ++++++++++++++++++++++++++++-- tests/test_web.py | 103 ++++++++++++++++++++++++++++++++++----- 5 files changed, 329 insertions(+), 19 deletions(-) create mode 100644 repub/datastar.py diff --git a/AGENTS.md b/AGENTS.md index bdbb433..1ccde1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # republisher-redux +See @README.md + ## Overview - `republisher-redux` is a Scrapy-based tool that mirrors RSS and Atom feeds for offline use. @@ -8,6 +10,71 @@ - Nix development and packaging use `flake.nix`. - Formatting is managed through `treefmt-nix`, exposed via `nix fmt`. +- Prefer immutable style functional programming style + - functions that operate on data over classes that encapsulate state +- No backwards-compatibility guarantees; prefer breaking changes over backwards compat and complexity. +- Think carefully and implement the most concise solution that changes as little code as possible. + + +## HTML/Datastar Rules + +Very important rules for datastar usage. + +The views are pure functions data in -> html out. + +- we only use full page morph mode. no diffing + Why large/fat/main morphs (aka immediate mode)? + + By only using data: mode morph and always targeting the main element of the document the API can be massively simplified. This avoids having the explosion of endpoints you get with HTMX and makes reasoning about your app much simpler. + +- we only have a single render function per page + By having a single render function per page you can simplify the reasoning about your app to view = f(state). You can then reason about your pushed updates as a continuous signal rather than discrete event stream. The benefit of this is you don't have to handle missed events, disconnects and reconnects. When the state changes on the server you push down the latest view, not the delta between views. On the client idiomorph can translate that into fine grained dom updates. + + +- any database change -> re render all connected users with 200ms throttle + When your events are not homogeneous, you can't miss events, so you cannot throttle your events without losing data. + + But, wait! Won't that mean every change will cause all users to re-render? Yes, but at a maximum rate determined by the throttle. This, might sound scary at first but in practice: + + The more shared views the users have the more likely most of the connected users will have to re-render when a change happen. + + The more events that are happening the more likely most users will have to re-render. + + This means you actually end up doing more work with a non homogeneous event system under heavy load than with this simple homogeneous event system that's throttled (especially it there's any sort of common/shared view between users). + +- Signals are only for ephemeral client side state + Signals should only be used for ephemeral client side state. Things like: the current value of a text input, whether a popover is visible, current csrf token, input validation errors. Signals can be controlled on the client via expressions, or from the backend via patch-signals. +- Signals in elements should be declared __ifmissing + Because signals are only being used to represent ephemeral client state that means they can only be initialised by elements and they can only be changed via expressions on the client or from the server via patch-signals in an action. Signals in elements should be declared __ifmissing unless they are "view only". + +- View only signals, are signals that can only be changed by the server. These should not be declared __ifmissing instead they should be made "local" by starting their key with an _ this prevents the client from sending them up to the server. + +- Actions should not update the view themselves directly + Actions should not update the view via patch elements. This is because the changes they make would get overwritten on the next render-fn that pushes a new view down the updates SSE connection. However, they can still be used to update signals as those won't be changed by elements patch. This allows you to do things like validation on the server. + +- Stateless views +The only way for actions to affect the view returned by the render-fn running in a connection is via the database. The ensures CQRS. This means there is no connection state that needs to be persisted or maintained (so missed events and shutdowns/deploys will not lead to lost state). Even when you are running in a single process there is no way for an action (command) to communicate with/affect a view render (query) without going through the database. + +- CQRS + Actions modify the database and return a 204 or a 200 if they patch-signals. + Render functions re-render when the database changes and send an update down the updates SSE connection. + +- Work sharing (caching) + Work sharing is the term I'm using for sharing renders between connected users. This can be useful when a lot of connected users share the same view. For example a leader board, game board, presence indicator etc. It ensures the work (eg: query and html generation) for that view is only done once regardless of the number of connected users. The simplest way to do this is to recalculate and cache values after after a batch has been run. + +- Use data-on:pointerdown/mousedown over data-on:click + This is a small one but can make even the slowest of networks feel much snappier. + +- No CORS By hosting all assets on the same origin we avoid the need for CORS. This avoids additional server round trips and helps reduce latency. + +- Rendering an initial shim -Rather than returning the whole page on initial render and having two render paths, one for initial render and one for subsequent rendering a shell is rendered and then populated when the page connects to the updates endpoint for that page. This has a few advantages: + + The page will only render dynamic content if the user has javascript and first party cookies enabled. + + The initial shell page can generated and compressed once. + + The server only does more work for actual users and less work for link preview crawlers and other bots (that don't support javascript or cookies). + ## Workflow - Use Python 3.13. @@ -44,3 +111,4 @@ uv run repub crawl -c repub.toml - The console entrypoint is `repub`. - Runtime ffmpeg availability is provided by the flake package and devshell. - Tests live under `tests/`. +- `prompts/` is git ignored intentionally diff --git a/repub/datastar.py b/repub/datastar.py new file mode 100644 index 0000000..2ae30a8 --- /dev/null +++ b/repub/datastar.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import asyncio +import hashlib +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Protocol + +from datastar_py import ServerSentEventGenerator as SSE +from datastar_py.sse import DatastarEvent + + +class HtmlRenderable(Protocol): + def __html__(self) -> str: ... + + +RenderResult = str | HtmlRenderable +RenderFunction = Callable[[], Awaitable[RenderResult]] + + +class RefreshBroker: + def __init__(self) -> None: + self._subscribers: set[asyncio.Queue[object]] = set() + + def subscribe(self) -> asyncio.Queue[object]: + queue: asyncio.Queue[object] = asyncio.Queue(maxsize=1) + self._subscribers.add(queue) + return queue + + def unsubscribe(self, queue: asyncio.Queue[object]) -> None: + self._subscribers.discard(queue) + + def publish(self, event: object = "refresh-event") -> None: + for queue in tuple(self._subscribers): + if queue.full(): + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + queue.put_nowait(event) + except asyncio.QueueFull: + continue + + +async def render_sse_event( + render: RenderFunction, *, last_event_id: str | None = None +) -> tuple[str | None, DatastarEvent | None]: + html = _coerce_html(await render()) + event_id = _render_hash(html) + if event_id == last_event_id: + return last_event_id, None + return event_id, SSE.patch_elements(html, event_id=event_id) + + +async def render_stream( + queue: asyncio.Queue[object], + render: RenderFunction, + *, + last_event_id: str | None = None, + render_on_connect: bool = True, +) -> AsyncGenerator[DatastarEvent, None]: + if render_on_connect: + last_event_id, event = await render_sse_event( + render, last_event_id=last_event_id + ) + if event is not None: + yield event + + while True: + await queue.get() + last_event_id, event = await render_sse_event( + render, last_event_id=last_event_id + ) + if event is not None: + yield event + + +def _coerce_html(view: RenderResult) -> str: + if isinstance(view, str): + return view + return view.__html__() + + +def _render_hash(html: str) -> str: + return hashlib.blake2s(html.encode("utf-8"), digest_size=16).hexdigest() diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index 852e1fa..3748438 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -91,7 +91,7 @@ def page_header() -> Renderable: ] -def overview_section() -> Renderable: +def overview_section(*, active_jobs: str) -> Renderable: return h.section[ h.div(class_="mb-4 flex items-end justify-between")[ h.div[ @@ -105,7 +105,11 @@ def overview_section() -> Renderable: h.p(class_="text-sm text-slate-500")["Updated from static fixture data"], ], h.dl(class_="grid gap-4 md:grid-cols-2 xl:grid-cols-4")[ - stat_card(label="Active jobs", value="12", detail="9 scheduled, 3 paused"), + stat_card( + label="Active jobs", + value=active_jobs, + detail="Temporary live demo counter for Datastar refresh testing", + ), stat_card(label="Running now", value="2", detail="RSS and Pangea workers"), stat_card( label="Completed today", value="34", detail="31 succeeded, 3 failed" @@ -450,7 +454,7 @@ def settings_panel() -> Renderable: ] -def admin_component() -> Renderable: +def admin_component(*, active_jobs: str = "12") -> Renderable: running_rows = ( ( h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"], @@ -566,7 +570,7 @@ def admin_component() -> Renderable: h.div(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[ h.div(class_="mx-auto max-w-7xl space-y-6")[ page_header(), - overview_section(), + overview_section(active_jobs=active_jobs), h.div( class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]" )[ diff --git a/repub/web.py b/repub/web.py index c7db483..6291759 100644 --- a/repub/web.py +++ b/repub/web.py @@ -1,12 +1,24 @@ from __future__ import annotations +import asyncio import hashlib +from collections.abc import AsyncGenerator +from contextlib import suppress +from typing import cast import htpy as h +from datastar_py.quart import DatastarResponse +from datastar_py.sse import DatastarEvent +from htpy import Renderable from quart import Quart, Response, request, url_for +from repub.datastar import RefreshBroker, render_stream from repub.pages import admin_component, shim_page +REFRESH_BROKER_KEY = "repub.refresh_broker" +ACTIVE_JOBS_KEY = "repub.demo_active_jobs" +REFRESH_TASK_KEY = "repub.demo_refresh_task" + def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]: head = ( @@ -18,8 +30,27 @@ def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, return body, etag -def create_app() -> Quart: +def create_app(*, enable_demo_refresh: bool = True) -> Quart: app = Quart(__name__) + app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() + app.extensions[ACTIVE_JOBS_KEY] = 12 + + if enable_demo_refresh: + + @app.before_serving + async def start_demo_refresh() -> None: + app.extensions[REFRESH_TASK_KEY] = asyncio.create_task( + _demo_refresh_loop(app) + ) + + @app.after_serving + async def stop_demo_refresh() -> None: + task = cast(asyncio.Task[None] | None, app.extensions.get(REFRESH_TASK_KEY)) + if task is None: + return + task.cancel() + with suppress(asyncio.CancelledError): + await task @app.get("/") async def index() -> Response: @@ -37,7 +68,50 @@ def create_app() -> Quart: return response @app.post("/") - async def index_patch() -> Response: - return Response(str(admin_component()), mimetype="text/html") + async def index_patch() -> DatastarResponse: + queue = get_refresh_broker(app).subscribe() + stream = render_stream( + queue, + render=lambda: render_dashboard(app), + last_event_id=request.headers.get("last-event-id"), + ) + return DatastarResponse(_unsubscribe_on_close(queue, stream, app)) return app + + +def get_refresh_broker(app: Quart) -> RefreshBroker: + return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY]) + + +def trigger_refresh(app: Quart, event: object = "refresh-event") -> None: + get_refresh_broker(app).publish(event) + + +async def render_dashboard(app: Quart) -> Renderable: + return admin_component(active_jobs=str(get_active_jobs(app))) + + +async def _unsubscribe_on_close( + queue: object, stream: AsyncGenerator[DatastarEvent, None], app: Quart +) -> AsyncGenerator[DatastarEvent, None]: + try: + async for event in stream: + yield event + finally: + get_refresh_broker(app).unsubscribe(cast(asyncio.Queue[object], queue)) + + +def get_active_jobs(app: Quart) -> int: + return cast(int, app.extensions[ACTIVE_JOBS_KEY]) + + +def set_active_jobs(app: Quart, value: int) -> None: + app.extensions[ACTIVE_JOBS_KEY] = value + + +async def _demo_refresh_loop(app: Quart) -> None: + while True: + await asyncio.sleep(1) + set_active_jobs(app, get_active_jobs(app) + 1) + trigger_refresh(app) diff --git a/tests/test_web.py b/tests/test_web.py index 533a4f5..225bb8c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,13 +1,21 @@ from __future__ import annotations import asyncio +from typing import Any, cast -from repub.web import create_app +from repub.datastar import RefreshBroker, render_sse_event, render_stream +from repub.web import ( + create_app, + get_active_jobs, + get_refresh_broker, + render_dashboard, + set_active_jobs, +) def test_root_get_serves_datastar_shim() -> None: async def run() -> None: - client = create_app().test_client() + client = create_app(enable_demo_refresh=False).test_client() response = await client.get("/") body = await response.get_data(as_text=True) @@ -30,7 +38,7 @@ def test_root_get_serves_datastar_shim() -> None: def test_root_get_honors_if_none_match() -> None: async def run() -> None: - client = create_app().test_client() + client = create_app(enable_demo_refresh=False).test_client() initial = await client.get("/") etag = initial.headers["ETag"] @@ -45,15 +53,86 @@ def test_root_get_honors_if_none_match() -> None: def test_root_post_serves_morph_component() -> None: async def run() -> None: - client = create_app().test_client() + client = create_app(enable_demo_refresh=False).test_client() + async with client.request("/?u=shim", method="POST") as connection: + await connection.send_complete() + chunk = await asyncio.wait_for(connection.receive(), timeout=1) + raw_connection = cast(Any, connection) - response = await client.post("/?u=shim") - body = await response.get_data(as_text=True) - - assert response.status_code == 200 - assert response.content_type == "text/html; charset=utf-8" - assert body.startswith('
None: + async def run() -> None: + async def render() -> str: + return '
same
' + + event_id, event = await render_sse_event(render) + repeated_id, repeated_event = await render_sse_event( + render, last_event_id=event_id + ) + + assert repeated_id == event_id + assert event is not None + assert repeated_event is None + + asyncio.run(run()) + + +def test_app_refresh_broker_publishes_events() -> None: + async def run() -> None: + app = create_app(enable_demo_refresh=False) + broker = get_refresh_broker(app) + queue = broker.subscribe() + + broker.publish() + event = await asyncio.wait_for(queue.get(), timeout=1) + + assert event == "refresh-event" + broker.unsubscribe(queue) + + asyncio.run(run()) + + +def test_render_stream_yields_on_connect_and_refresh() -> None: + async def run() -> None: + queue = RefreshBroker().subscribe() + renders = 0 + + async def render() -> str: + nonlocal renders + renders += 1 + return f'
{renders}
' + + stream = render_stream(queue, render) + first = await anext(stream) + await queue.put("refresh-event") + second = await anext(stream) + await stream.aclose() + + assert "1
" in first + assert "2
" in second + + asyncio.run(run()) + + +def test_render_dashboard_uses_active_jobs_from_app_state() -> None: + async def run() -> None: + app = create_app(enable_demo_refresh=False) + assert get_active_jobs(app) == 12 + set_active_jobs(app, 27) + + async with app.app_context(): + body = str(await render_dashboard(app)) + + assert "27" in body + assert "Temporary live demo counter for Datastar refresh testing" in body asyncio.run(run()) From 3fc999a69b0b473fbaded191a4543d0e303c7bb8 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 12:48:32 +0200 Subject: [PATCH 04/23] add a datastar action --- repub/pages/dashboard.py | 58 +++++++++++++++++++++++++++++++++++++++- repub/web.py | 30 ++++++++++++++++++++- tests/test_web.py | 56 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index 3748438..88d162e 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -87,7 +87,8 @@ def page_header() -> Renderable: class_="rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white hover:bg-white/10", )["Run scheduler health check"], ], - ] + ], + demo_action_panel(), ] @@ -121,6 +122,61 @@ def overview_section(*, active_jobs: str) -> Renderable: ] +def demo_action_panel() -> Renderable: + return h.div( + {"data-signals": "{decrementAmount: '1', decrementError: ''}"}, + id="demo-decrement-panel", + class_="mt-6 rounded-[1.75rem] border border-white/10 bg-white/5 p-5 ring-1 ring-white/10", + )[ + h.div(class_="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between")[ + h.div(class_="max-w-2xl")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" + )["Demo action"], + h.h2(class_="mt-2 text-lg font-semibold text-white")[ + "Decrement active jobs" + ], + h.p(class_="mt-2 text-sm text-slate-300")[ + "Uses Datastar signals plus a server action. Enter an odd number and the server will validate it before mutating state." + ], + ], + h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end")[ + h.div(class_="min-w-40")[ + h.label( + for_="decrement-amount", + class_="block text-xs font-semibold uppercase tracking-[0.18em] text-slate-300", + )["Odd decrement"], + h.input( + { + "data-bind:decrement-amount": True, + "data-preserve-attr:value": True, + }, + id="decrement-amount", + name="decrement-amount", + type="number", + min="1", + step="1", + inputmode="numeric", + class_="mt-2 block w-full rounded-2xl border border-white/10 bg-slate-950/70 px-3.5 py-2.5 text-sm text-white shadow-sm placeholder:text-slate-500 focus:outline-hidden focus:ring-2 focus:ring-amber-400", + ), + ], + h.button( + {"data-on:click": "@post('/demo/decrement')"}, + type="button", + class_="cursor-pointer rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm hover:bg-amber-300", + )["Decrement"], + ], + ], + h.p( + { + "data-show": "$decrementError !== ''", + "data-text": "$decrementError", + }, + class_="mt-3 text-sm font-medium text-rose-300", + ), + ] + + def source_form_section() -> Renderable: return h.section( class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200" diff --git a/repub/web.py b/repub/web.py index 6291759..bc85141 100644 --- a/repub/web.py +++ b/repub/web.py @@ -7,7 +7,8 @@ from contextlib import suppress from typing import cast import htpy as h -from datastar_py.quart import DatastarResponse +from datastar_py import ServerSentEventGenerator as SSE +from datastar_py.quart import DatastarResponse, read_signals from datastar_py.sse import DatastarEvent from htpy import Renderable from quart import Quart, Response, request, url_for @@ -77,6 +78,16 @@ def create_app(*, enable_demo_refresh: bool = True) -> Quart: ) return DatastarResponse(_unsubscribe_on_close(queue, stream, app)) + @app.post("/demo/decrement") + async def demo_decrement() -> DatastarResponse: + amount, error = _validated_decrement_amount(await read_signals()) + if error is not None: + return DatastarResponse(SSE.patch_signals({"decrementError": error})) + + set_active_jobs(app, max(0, get_active_jobs(app) - amount)) + trigger_refresh(app) + return DatastarResponse(SSE.patch_signals({"decrementError": ""})) + return app @@ -115,3 +126,20 @@ async def _demo_refresh_loop(app: Quart) -> None: await asyncio.sleep(1) set_active_jobs(app, get_active_jobs(app) + 1) trigger_refresh(app) + + +def _validated_decrement_amount( + signals: dict[str, object] | None, +) -> tuple[int, str | None]: + raw_amount = ( + "" if signals is None else str(signals.get("decrementAmount", "")).strip() + ) + try: + amount = int(raw_amount) + except ValueError: + return 0, "Decrement amount must be an odd integer." + + if amount < 1 or amount % 2 == 0: + return 0, "Decrement amount must be an odd integer." + + return amount, None diff --git a/tests/test_web.py b/tests/test_web.py index 225bb8c..8e8ff8d 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -134,5 +134,61 @@ def test_render_dashboard_uses_active_jobs_from_app_state() -> None: assert "27" in body assert "Temporary live demo counter for Datastar refresh testing" in body + assert "/demo/decrement" in body + assert "data-bind:decrement-amount" in body + + asyncio.run(run()) + + +def test_demo_decrement_action_decrements_active_jobs() -> None: + async def run() -> None: + app = create_app(enable_demo_refresh=False) + broker = get_refresh_broker(app) + queue = broker.subscribe() + client = app.test_client() + + response = await client.post( + "/demo/decrement", + headers={"Datastar-Request": "true"}, + json={"decrementAmount": "3"}, + ) + body = await response.get_data(as_text=True) + event = await asyncio.wait_for(queue.get(), timeout=1) + + assert response.status_code == 200 + assert get_active_jobs(app) == 9 + assert event == "refresh-event" + assert 'data: signals {"decrementError":""}' in body + broker.unsubscribe(queue) + + asyncio.run(run()) + + +def test_demo_decrement_action_validates_odd_amount() -> None: + async def run() -> None: + app = create_app(enable_demo_refresh=False) + broker = get_refresh_broker(app) + queue = broker.subscribe() + client = app.test_client() + + response = await client.post( + "/demo/decrement", + headers={"Datastar-Request": "true"}, + json={"decrementAmount": "2"}, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert get_active_jobs(app) == 12 + assert "odd integer" in body + + try: + await asyncio.wait_for(queue.get(), timeout=0.1) + except TimeoutError: + pass + else: + raise AssertionError("invalid decrement should not publish a refresh") + finally: + broker.unsubscribe(queue) asyncio.run(run()) From 9e826fcee886e6abf2ca08850048631e7ebcd1a9 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 13:11:37 +0200 Subject: [PATCH 05/23] separeate pages --- repub/components.py | 266 +++++++++++- repub/pages/__init__.py | 13 +- repub/pages/dashboard.py | 751 +++++++--------------------------- repub/pages/runs.py | 360 ++++++++++++++++ repub/pages/sources.py | 337 +++++++++++++++ repub/static/app.css | 297 +++++++------- repub/static/app.tailwind.css | 4 +- repub/web.py | 145 +++---- tests/test_web.py | 127 +++--- 9 files changed, 1376 insertions(+), 924 deletions(-) create mode 100644 repub/pages/runs.py create mode 100644 repub/pages/sources.py diff --git a/repub/components.py b/repub/components.py index 4a6630c..5c82639 100644 --- a/repub/components.py +++ b/repub/components.py @@ -19,7 +19,7 @@ def base_layout(*, page_title: str, stylesheet_href: str, content: Node) -> Rend def nav_link( - *, label: str, active: bool = False, badge: str | None = None + *, label: str, href: str, active: bool = False, badge: str | None = None ) -> Renderable: link_class = ( "group flex items-center justify-between rounded-xl px-3 py-2 text-sm font-medium transition " @@ -33,12 +33,228 @@ def nav_link( "bg-amber-200 text-amber-950" if active else "bg-slate-800 text-slate-300" ) - return h.a(href="#", class_=link_class)[ + return h.a(href=href, class_=link_class)[ h.span[label], badge and h.span(class_=badge_class)[badge], ] +def admin_sidebar(*, current_path: str) -> Renderable: + return h.aside( + class_="relative overflow-hidden bg-slate-950 px-6 py-8 text-white lg:min-h-screen" + )[ + h.div( + class_="absolute inset-x-0 top-0 h-40 bg-radial from-amber-400/25 via-amber-400/10 to-transparent" + ), + h.div(class_="relative flex h-full flex-col")[ + h.div(class_="flex items-center gap-3")[ + h.div( + class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" + )["AR"], + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300" + )["Republisher"], + h.p(class_="text-sm text-slate-300")["Admin spike"], + ], + ], + h.nav(class_="mt-10 space-y-2")[ + nav_link( + label="Dashboard", + href="/", + active=current_path == "/", + badge="Live", + ), + nav_link( + label="Sources", + href="/sources", + active=current_path.startswith("/sources"), + badge="12", + ), + nav_link( + label="Runs", + href="/runs", + active=current_path.startswith("/runs") + or current_path.startswith("/job/"), + badge="3", + ), + ], + h.div(class_="mt-10 rounded-3xl border border-white/10 bg-white/5 p-5")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" + )["Operator notes"], + h.p(class_="mt-3 text-sm leading-6 text-slate-300")[ + "Single-operator control plane over Tailscale. The current pass is layout only, but it follows the new full-page Datastar render pattern." + ], + ], + h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[ + h.p(class_="text-sm font-semibold text-white")["Output root"], + h.p(class_="mt-2 font-mono text-sm text-slate-300")["/srv/anynews/out"], + h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[ + "Trusted network only" + ], + ], + ], + ] + + +def header_action_link(*, href: str, label: str) -> Renderable: + return h.a( + href=href, + class_="inline-flex items-center rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm transition hover:bg-amber-300", + )[label] + + +def header_secondary_link(*, href: str, label: str) -> Renderable: + return h.a( + href=href, + class_="inline-flex items-center rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/10", + )[label] + + +def muted_action_link(*, href: str, label: str) -> Renderable: + return h.a( + href=href, + class_="inline-flex items-center rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50", + )[label] + + +def inline_link(*, href: str, label: str, tone: str = "default") -> Renderable: + classes = { + "default": "text-slate-700 hover:text-slate-950", + "amber": "text-amber-700 hover:text-amber-800", + "rose": "text-rose-700 hover:text-rose-800", + } + return h.a( + href=href, + class_=f"inline-flex items-center whitespace-nowrap text-sm font-semibold {classes[tone]}", + )[label] + + +def inline_button( + *, label: str, tone: str = "default", disabled: bool = False +) -> Renderable: + classes = { + "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", + "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", + "success": "bg-emerald-100 text-emerald-800 hover:bg-emerald-200", + } + class_name = ( + "cursor-not-allowed bg-slate-100 text-slate-400" if disabled else classes[tone] + ) + return h.button( + type="button", + disabled=disabled, + class_=f"inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-semibold transition {class_name}", + )[label] + + +def page_shell( + *, + current_path: str, + eyebrow: str, + title: str, + description: str, + actions: Node | None = None, + content: Node, +) -> Renderable: + return h.main( + id="morph", + class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", + )[ + admin_sidebar(current_path=current_path), + h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ + h.div(class_="mx-auto max-w-7xl space-y-5")[ + h.section[ + h.div( + class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" + )[ + h.div(class_="max-w-3xl")[ + h.h1( + class_="text-3xl font-semibold tracking-tight text-slate-950" + )[title], + ( + description + and h.p(class_="mt-1 text-sm text-slate-600")[ + description + ] + ), + ], + actions and h.div(class_="flex flex-wrap gap-2")[actions], + ] + ], + content, + ] + ], + ] + + +def section_card(*, content: Node) -> Renderable: + return h.section(class_="space-y-4")[content] + + +def table_section( + *, + eyebrow: str | None = None, + title: str, + subtitle: str, + headers: tuple[str, ...], + rows: tuple[tuple[Node, ...], ...], + actions: Node | None = None, +) -> Renderable: + def render_row(row: tuple[Node, ...]) -> Renderable: + first_cell, *other_cells = row + return h.tr(class_="align-top")[ + h.td(class_="py-4 pr-6 pl-4 text-sm font-medium text-slate-950 sm:pl-6")[ + first_cell + ], + ( + h.td( + class_="px-3 py-4 align-top text-sm whitespace-nowrap text-slate-600" + )[cell] + for cell in other_cells + ), + ] + + return h.section[ + h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[ + h.div[ + eyebrow + and h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )[eyebrow], + h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[title], + h.p(class_="mt-1 text-sm text-slate-600")[subtitle], + ], + actions, + ], + h.div( + class_="mt-3 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200" + )[ + h.div(class_="overflow-x-auto")[ + h.table( + class_="relative w-full min-w-[72rem] divide-y divide-slate-200 table-auto" + )[ + h.thead(class_="bg-stone-50")[ + h.tr[ + ( + h.th( + scope="col", + class_="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-4 sm:first:pl-6", + )[header] + for header in headers + ) + ] + ], + h.tbody(class_="divide-y divide-slate-200 bg-white")[ + (render_row(row) for row in rows) + ], + ] + ] + ], + ] + + def stat_card(*, label: str, value: str, detail: str) -> Renderable: return h.div( class_="rounded-3xl bg-white/85 p-5 shadow-sm ring-1 ring-slate-200 backdrop-blur" @@ -115,26 +331,46 @@ def textarea_field( ] -def toggle_field(*, label: str, description: str, checked: bool = False) -> Renderable: - wrapper_class = ( - "group relative inline-flex w-11 shrink-0 rounded-full p-0.5 outline-offset-2 outline-amber-500 transition " - + ("bg-amber-500" if checked else "bg-slate-200") - ) - knob_class = ( - "size-5 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform " - + ("translate-x-5" if checked else "translate-x-0") - ) +def toggle_field( + *, + label: str, + description: str, + signal_name: str, + checked: bool = False, +) -> Renderable: + signal_value = str(checked).lower() - return h.div(class_="rounded-3xl bg-stone-50 p-4 ring-1 ring-slate-200")[ + return h.div( + {"data-signals__ifmissing": f"{{{signal_name}: {signal_value}}}"}, + class_="rounded-3xl bg-white p-4 shadow-sm", + )[ h.div(class_="flex items-start justify-between gap-4")[ h.div[ h.h3(class_="text-sm font-semibold text-slate-900")[label], h.p(class_="mt-1 text-sm text-slate-600")[description], ], h.label(class_="mt-0.5 cursor-pointer")[ - h.div(class_=wrapper_class)[ - h.span(class_=knob_class), - h.input(type="checkbox", checked=checked, class_="sr-only"), + h.div( + { + "data-class:bg-amber-500": f"${signal_name}", + "data-class:bg-slate-200": f"!${signal_name}", + }, + class_="group relative inline-flex w-11 shrink-0 rounded-full bg-slate-200 p-0.5 outline-offset-2 outline-amber-500 transition", + )[ + h.span( + { + "data-class:translate-x-5": f"${signal_name}", + "data-class:translate-x-0": f"!${signal_name}", + }, + class_="size-5 translate-x-0 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform", + ), + h.input( + {"data-bind": signal_name}, + type="checkbox", + name=signal_name, + checked=checked, + class_="sr-only", + ), ], ], ] diff --git a/repub/pages/__init__.py b/repub/pages/__init__.py index 95e84fb..5428e35 100644 --- a/repub/pages/__init__.py +++ b/repub/pages/__init__.py @@ -1,4 +1,13 @@ -from repub.pages.dashboard import admin_component +from repub.pages.dashboard import dashboard_page +from repub.pages.runs import execution_logs_page, runs_page from repub.pages.shim import shim_page +from repub.pages.sources import create_source_page, sources_page -__all__ = ["admin_component", "shim_page"] +__all__ = [ + "create_source_page", + "dashboard_page", + "execution_logs_page", + "runs_page", + "shim_page", + "sources_page", +] diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index 88d162e..1267fba 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -4,653 +4,184 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( - input_field, - nav_link, - select_field, + admin_sidebar, + header_action_link, + inline_button, + inline_link, + muted_action_link, stat_card, status_badge, - textarea_field, - toggle_field, ) +from repub.pages.runs import RUNNING_EXECUTIONS -def sidebar() -> Renderable: - return h.aside( - class_="relative overflow-hidden bg-slate-950 px-6 py-8 text-white lg:min-h-screen" - )[ - h.div( - class_="absolute inset-x-0 top-0 h-40 bg-radial from-amber-400/25 via-amber-400/10 to-transparent" - ), - h.div(class_="relative flex h-full flex-col")[ - h.div(class_="flex items-center gap-3")[ - h.div( - class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" - )["AR"], - h.div[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300" - )["Republisher"], - h.p(class_="text-sm text-slate-300")["Admin spike"], - ], - ], - h.nav(class_="mt-10 space-y-2")[ - nav_link(label="Dashboard", active=True, badge="Live"), - nav_link(label="Sources", badge="12"), - nav_link(label="Runs", badge="3"), - nav_link(label="Schedule"), - nav_link(label="Settings"), - ], - h.div(class_="mt-10 rounded-3xl border border-white/10 bg-white/5 p-5")[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" - )["Operator notes"], - h.p(class_="mt-3 text-sm leading-6 text-slate-300")[ - "Single-operator control plane over Tailscale. Everything here is static HTML for the v1 UI spike." - ], - ], - h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[ - h.p(class_="text-sm font-semibold text-white")["Output root"], - h.p(class_="mt-2 font-mono text-sm text-slate-300")["/srv/anynews/out"], - h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[ - "Trusted network only" - ], +def _running_execution_row(execution: dict[str, str | bool]) -> tuple[Node, ...]: + status_tone = "running" if execution["is_running"] else "done" + return ( + h.div[ + h.div(class_="font-semibold text-slate-950")[execution["source"]], + h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[ + execution["slug"] ], ], - ] - - -def page_header() -> Renderable: - return h.section( - class_="rounded-[2rem] bg-slate-950 px-6 py-8 text-white shadow-xl sm:px-8" - )[ - h.div(class_="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between")[ - h.div(class_="max-w-3xl")[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.3em] text-amber-300" - )["Republisher Redux"], - h.h1(class_="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl")[ - "Admin UI" - ], - h.p( - class_="mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base" - )[ - "One page for source management, job scheduling, live execution visibility, and operator settings. This pass is HTML and CSS only." - ], - ], - h.div(class_="flex flex-wrap gap-3")[ - h.button( - type="button", - class_="rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm hover:bg-amber-300", - )["Add source"], - h.button( - type="button", - class_="rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white hover:bg-white/10", - )["Run scheduler health check"], + h.div[ + h.p(class_="font-medium text-slate-900")[f"#{execution['execution_id']}"], + h.p(class_="mt-0.5 text-[11px] text-slate-500")[ + f"job {execution['job_id']}" ], ], - demo_action_panel(), - ] + h.div[ + h.p(class_="font-medium text-slate-900")[execution["started_at"]], + h.p(class_="mt-0.5 text-[11px] text-slate-500")[execution["runtime"]], + ], + status_badge(label=str(execution["status"]), tone=status_tone), + h.div(class_="min-w-56 whitespace-normal")[ + h.p(class_="font-medium text-slate-900")[execution["stats"]], + h.p(class_="mt-0.5 text-[11px] text-slate-500")[execution["worker"]], + ], + h.div(class_="flex flex-nowrap items-center gap-3")[ + inline_link( + href=str(execution["log_href"]), + label="View log", + tone="amber", + ), + inline_button(label="Stop", tone="danger"), + ], + ) -def overview_section(*, active_jobs: str) -> Renderable: +def dashboard_header() -> Renderable: return h.section[ - h.div(class_="mb-4 flex items-end justify-between")[ - h.div[ - h.p( - class_="text-sm font-semibold uppercase tracking-[0.22em] text-slate-500" - )["Overview"], - h.h2( - class_="mt-1 text-2xl font-semibold tracking-tight text-slate-950" - )["Operational snapshot"], - ], - h.p(class_="text-sm text-slate-500")["Updated from static fixture data"], - ], - h.dl(class_="grid gap-4 md:grid-cols-2 xl:grid-cols-4")[ - stat_card( - label="Active jobs", - value=active_jobs, - detail="Temporary live demo counter for Datastar refresh testing", - ), - stat_card(label="Running now", value="2", detail="RSS and Pangea workers"), - stat_card( - label="Completed today", value="34", detail="31 succeeded, 3 failed" - ), - stat_card( - label="Output size", value="18.4 GB", detail="Media and rewritten feeds" - ), - ], - ] - - -def demo_action_panel() -> Renderable: - return h.div( - {"data-signals": "{decrementAmount: '1', decrementError: ''}"}, - id="demo-decrement-panel", - class_="mt-6 rounded-[1.75rem] border border-white/10 bg-white/5 p-5 ring-1 ring-white/10", - )[ - h.div(class_="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between")[ - h.div(class_="max-w-2xl")[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" - )["Demo action"], - h.h2(class_="mt-2 text-lg font-semibold text-white")[ - "Decrement active jobs" - ], - h.p(class_="mt-2 text-sm text-slate-300")[ - "Uses Datastar signals plus a server action. Enter an odd number and the server will validate it before mutating state." - ], - ], - h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end")[ - h.div(class_="min-w-40")[ - h.label( - for_="decrement-amount", - class_="block text-xs font-semibold uppercase tracking-[0.18em] text-slate-300", - )["Odd decrement"], - h.input( - { - "data-bind:decrement-amount": True, - "data-preserve-attr:value": True, - }, - id="decrement-amount", - name="decrement-amount", - type="number", - min="1", - step="1", - inputmode="numeric", - class_="mt-2 block w-full rounded-2xl border border-white/10 bg-slate-950/70 px-3.5 py-2.5 text-sm text-white shadow-sm placeholder:text-slate-500 focus:outline-hidden focus:ring-2 focus:ring-amber-400", - ), - ], - h.button( - {"data-on:click": "@post('/demo/decrement')"}, - type="button", - class_="cursor-pointer rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm hover:bg-amber-300", - )["Decrement"], - ], - ], - h.p( - { - "data-show": "$decrementError !== ''", - "data-text": "$decrementError", - }, - class_="mt-3 text-sm font-medium text-rose-300", - ), - ] - - -def source_form_section() -> Renderable: - return h.section( - class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200" - )[ - h.div(class_="border-b border-slate-200 px-6 py-5 sm:px-8")[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" - )["Create or edit"], - h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ - "Source and job setup" - ], - h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[ - "The form shows the intended v1 structure: source fields, subtype fields, cron controls, and job toggles. No persistence is wired yet." - ], - ], - h.div(class_="space-y-8 px-6 py-6 sm:px-8")[ - h.div(class_="grid gap-4 md:grid-cols-2")[ - input_field( - label="Source name", - field_id="source-name", - value="Pangea mobile articles", - ), - input_field( - label="Slug", - field_id="source-slug", - value="pangea-mobile", - help_text="Immutable after creation.", - ), - select_field( - label="Source type", - field_id="source-type", - options=("feed", "pangea"), - selected="pangea", - ), - input_field( - label="Feed URL", - field_id="feed-url", - placeholder="https://example.com/feed.xml", - ), - ], - h.div(class_="grid gap-4 lg:grid-cols-3")[ - input_field( - label="Pangea domain", - field_id="pangea-domain", - value="guardianproject.info", - ), - input_field( - label="Category name", field_id="pangea-category", value="News" - ), - select_field( - label="Content format", - field_id="content-format", - options=("MOBILE_3", "MOBILE_2", "WEB"), - selected="MOBILE_3", - ), - input_field( - label="Content type", field_id="content-type", value="articles" - ), - input_field(label="Max articles", field_id="max-articles", value="10"), - input_field( - label="Oldest article (days)", field_id="oldest-article", value="3" - ), - ], - h.div(class_="grid gap-4 lg:grid-cols-2")[ - textarea_field( - label="Notes", - field_id="source-notes", - value="Primary Pangea mobile article mirror for the operator landing page.", - ), - textarea_field( - label="Spider arguments", - field_id="spider-arguments", - value="language=en,download_media=true", - ), - ], - h.div[ - h.div(class_="mb-4 flex items-end justify-between")[ - h.div[ - h.h3(class_="text-lg font-semibold text-slate-950")[ - "Cron schedule" - ], - h.p(class_="mt-1 text-sm text-slate-600")[ - "Stored in UTC and displayed in the browser timezone." - ], - ] - ], - h.div(class_="grid gap-4 sm:grid-cols-2 xl:grid-cols-5")[ - input_field(label="Minute", field_id="cron-minute", value="15"), - input_field(label="Hour", field_id="cron-hour", value="*/4"), - input_field( - label="Day of month", field_id="cron-day-of-month", value="*" - ), - input_field( - label="Day of week", field_id="cron-day-of-week", value="1-6" - ), - input_field(label="Month", field_id="cron-month", value="*"), - ], - ], - h.div(class_="grid gap-4 xl:grid-cols-2")[ - toggle_field( - label="Job enabled", - description="If disabled, the scheduler keeps the source definition but skips automatic runs.", - checked=True, - ), - toggle_field( - label="Only newest", - description="Restrict Pangea fetches to the newest material available in the selected category.", - checked=True, - ), - toggle_field( - label="Include authors", - description="Carry author bylines into rendered output where the upstream provides them.", - checked=True, - ), - toggle_field( - label="Exclude media", - description="Use article text only and skip image/media attachment mirroring for this source.", - checked=False, - ), - ], - ], h.div( - class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 px-6 py-4 sm:px-8" + class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" )[ - h.button( - type="button", - class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50", - )["Cancel"], - h.button( - type="button", - class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800", - )["Save source"], + h.div[ + h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[ + "Republisher" + ], + h.p(class_="mt-1 text-sm text-slate-600")[ + "Operational status and live executions." + ], + ], + 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"), + ], + ] + ] + + +def operational_snapshot() -> Renderable: + return h.section[ + h.div(class_="mb-3 flex items-end justify-between gap-4")[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-slate-500" + )["Overview"], + h.h2(class_="mt-1 text-xl font-semibold tracking-tight text-slate-950")[ + "Operational snapshot" + ], + ], + h.p(class_="text-xs text-slate-500")[ + "Static fixture data shaped around the intended operator dashboard" + ], + ], + h.dl(class_="grid gap-3 md:grid-cols-2 xl:grid-cols-4")[ + stat_card( + label="Running now", + value="3", + detail="Two feed workers and one Pangea worker are active.", + ), + stat_card( + label="Upcoming today", + value="11", + detail="Next scheduled job fires in 13 minutes.", + ), + stat_card( + label="Failures in 24h", + value="2", + detail="One network timeout and one source parsing error.", + ), + stat_card( + label="Output footprint", + value="18.4 GB", + detail="Mirrored feeds, media, logs, and execution stats.", + ), ], ] -def source_card( - *, name: str, slug: str, source_type: str, schedule: str, state: Node -) -> Renderable: - return h.div(class_="rounded-3xl bg-stone-50 p-5 ring-1 ring-slate-200")[ - h.div(class_="flex items-start justify-between gap-4")[ - h.div[ - h.h3(class_="text-base font-semibold text-slate-950")[name], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[slug], - ], - state, - ], - h.dl(class_="mt-4 grid gap-3 sm:grid-cols-2")[ - h.div[ - h.dt( - class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" - )["Type"], - h.dd(class_="mt-1 text-sm text-slate-900")[source_type], - ], - h.div[ - h.dt( - class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" - )["Schedule"], - h.dd(class_="mt-1 text-sm text-slate-900")[schedule], - ], - ], - h.div(class_="mt-5 flex flex-wrap gap-2")[ - h.button( - type="button", - class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50", - )["Edit"], - h.button( - type="button", - class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50", - )["Run now"], - h.button( - type="button", - class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-rose-700 ring-1 ring-rose-200 hover:bg-rose-50", - )["Delete"], - ], - ] +def running_executions_table() -> Renderable: + rows = tuple(_running_execution_row(execution) for execution in RUNNING_EXECUTIONS) + headers = ("Source", "Execution", "Started", "Status", "Stats", "Actions") + def render_row(row: tuple[Node, ...]) -> Renderable: + first_cell, *other_cells = row + return h.tr(class_="align-top")[ + h.td(class_="py-3 pr-6 pl-4 text-sm font-medium text-slate-950 sm:pl-4")[ + first_cell + ], + ( + h.td( + class_="px-3 py-3 align-top text-sm whitespace-nowrap text-slate-600" + )[cell] + for cell in other_cells + ), + ] -def configured_sources_section() -> Renderable: - return h.section( - class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" - )[ - h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[ + return h.section[ + h.div(class_="mb-3 flex items-end justify-between gap-4")[ h.div[ h.p( class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" - )["Configured"], - h.h2(class_="mt-2 text-xl font-semibold text-slate-950")["Sources"], - ], - h.p(class_="text-sm text-slate-500")[ - "Static cards for the CRUD list state" - ], - ], - h.div(class_="mt-6 grid gap-4 xl:grid-cols-3")[ - source_card( - name="Guardian feed mirror", - slug="guardian-feed", - source_type="RSS feed", - schedule="Every 30 minutes", - state=status_badge(label="Scheduled", tone="scheduled"), - ), - source_card( - name="Pangea mobile articles", - slug="pangea-mobile", - source_type="Pangea", - schedule="Every 4 hours", - state=status_badge(label="Running", tone="running"), - ), - source_card( - name="Podcast enclosure mirror", - slug="podcast-audio", - source_type="RSS feed", - schedule="Paused", - state=status_badge(label="Idle", tone="idle"), - ), - ], - ] - - -def class_cells(row: tuple[Node, ...]) -> tuple[Renderable, ...]: - return tuple( - h.td(class_="px-4 py-4 align-top text-slate-700")[cell] for cell in row - ) - - -def job_table_section( - *, title: str, subtitle: str, rows: tuple[tuple[Node, ...], ...] -) -> Renderable: - headers = ("Source", "Window", "Status", "Stats", "Actions") - return h.section( - class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" - )[ - h.div(class_="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between")[ - h.div[ - h.h2(class_="text-xl font-semibold text-slate-950")[title], - h.p(class_="mt-1 text-sm text-slate-600")[subtitle], - ] - ], - h.div(class_="mt-6 overflow-hidden rounded-3xl ring-1 ring-slate-200")[ - h.table(class_="min-w-full divide-y divide-slate-200 text-left text-sm")[ - h.thead(class_="bg-stone-50")[ - h.tr[ - ( - h.th( - class_="px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" - )[header] - for header in headers - ) - ] + )["Live work"], + h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[ + "Running executions" ], - h.tbody(class_="divide-y divide-slate-200 bg-white")[ - (h.tr[class_cells(row)] for row in rows) + h.p(class_="mt-1 text-sm text-slate-600")[ + "Dashboard keeps only the in-flight executions visible here. The full run history lives on the Runs page." ], - ] - ], - ] - - -def log_panel() -> Renderable: - return h.section( - class_="rounded-[2rem] bg-slate-950 p-6 text-white shadow-xl sm:p-8" - )[ - h.div(class_="flex items-end justify-between gap-4")[ - h.div[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" - )["Live view"], - h.h2(class_="mt-2 text-xl font-semibold")["Execution log"], ], - status_badge(label="Streaming", tone="running"), + muted_action_link(href="/runs", label="Open runs"), ], - h.pre( - class_="mt-5 overflow-x-auto rounded-3xl bg-black/30 p-4 text-xs leading-6 text-emerald-200 ring-1 ring-white/10" + h.div( + class_="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200" )[ - "\n".join( - [ - "11:42:01 scheduler: run_now requested for job 7", - "11:42:02 worker[7]: starting pangea-mobile", - "11:42:08 stats: requests=18 items=4 bytes=1.8MB", - "11:42:11 stats: requests=26 items=7 bytes=2.6MB", - "11:42:17 worker[7]: writing out/logs/exec-0007.log", - "11:42:24 worker[7]: finished successfully", + h.div(class_="overflow-x-auto")[ + h.table( + class_="w-full min-w-[70rem] divide-y divide-slate-200 table-auto" + )[ + h.thead(class_="bg-stone-50")[ + h.tr[ + ( + h.th( + scope="col", + class_="px-3 py-2.5 text-left text-[11px] font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-4", + )[header] + for header in headers + ) + ] + ], + h.tbody(class_="divide-y divide-slate-200 bg-white")[ + (render_row(row) for row in rows) + ], ] - ) - ], - h.div(class_="mt-5 flex flex-wrap gap-3")[ - h.button( - type="button", - class_="rounded-full bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/10 hover:bg-white/15", - )["Open full log"], - h.button( - type="button", - class_="rounded-full bg-rose-500/15 px-4 py-2.5 text-sm font-semibold text-rose-200 ring-1 ring-rose-400/20 hover:bg-rose-500/20", - )["Stop job"], + ] ], ] -def settings_panel() -> Renderable: - return h.section( - class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" - )[ - h.p(class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600")[ - "Global" - ], - h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ - "Application settings" - ], - h.div(class_="mt-6 grid gap-4")[ - input_field(label="Bind host", field_id="bind-host", value="127.0.0.1"), - input_field(label="Bind port", field_id="bind-port", value="8080"), - input_field( - label="Output root", field_id="output-root", value="/srv/anynews/out" - ), - input_field( - label="Log directory", - field_id="log-directory", - value="/srv/anynews/out/logs", - ), - ], - h.div(class_="mt-6 flex flex-wrap gap-3")[ - h.button( - type="button", - class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800", - )["Save settings"], - h.button( - type="button", - class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50", - )["Reload schedule preview"], - ], - ] - - -def admin_component(*, active_jobs: str = "12") -> Renderable: - running_rows = ( - ( - h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"], - h.span["Started 11:42"], - status_badge(label="Running", tone="running"), - h.div["26 requests • 7 items • 2.6 MB"], - h.div(class_="flex flex-wrap gap-2")[ - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["View log"], - h.button( - type="button", - class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", - )["Stop"], - ], - ), - ( - h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"], - h.span["Started 11:33"], - status_badge(label="Running", tone="running"), - h.div["91 requests • 13 items • 5.1 MB"], - h.div(class_="flex flex-wrap gap-2")[ - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["View log"], - h.button( - type="button", - class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", - )["Stop"], - ], - ), - ) - upcoming_rows = ( - ( - h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"], - h.span["Today, 12:15"], - status_badge(label="Scheduled", tone="scheduled"), - h.div["cron: 15 */4 * * 1-6"], - h.div(class_="flex flex-wrap gap-2")[ - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["Run now"], - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["Disable"], - h.button( - type="button", - class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", - )["Delete"], - ], - ), - ( - h.div(class_="font-semibold text-slate-950")["Weekly digest feed"], - h.span["Tomorrow, 08:00"], - status_badge(label="Idle", tone="idle"), - h.div["cron: 0 8 * * 1"], - h.div(class_="flex flex-wrap gap-2")[ - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["Run now"], - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["Enable"], - h.button( - type="button", - class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", - )["Delete"], - ], - ), - ) - completed_rows = ( - ( - h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"], - h.span["Ended 10:57"], - status_badge(label="Succeeded", tone="done"), - h.div["204 requests • 28 items • 9.4 MB"], - h.div(class_="flex flex-wrap gap-2")[ - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["View log"], - ], - ), - ( - h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"], - h.span["Ended 09:12"], - status_badge(label="Failed", tone="failed"), - h.div["timeout after 3 retries"], - h.div(class_="flex flex-wrap gap-2")[ - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["View log"], - h.button( - type="button", - class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", - )["Retry"], - ], - ), - ) - +def dashboard_page() -> Renderable: return h.main( id="morph", class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", )[ - sidebar(), - h.div(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[ - h.div(class_="mx-auto max-w-7xl space-y-6")[ - page_header(), - overview_section(active_jobs=active_jobs), - h.div( - class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]" - )[ - h.div(class_="space-y-6")[ - source_form_section(), - configured_sources_section(), - job_table_section( - title="Running executions", - subtitle="Operators can inspect active crawls and stop them if needed.", - rows=running_rows, - ), - job_table_section( - title="Upcoming jobs", - subtitle="Schedule preview with enable, disable, run now, and delete affordances.", - rows=upcoming_rows, - ), - job_table_section( - title="Completed executions", - subtitle="Recent history with direct access to text logs.", - rows=completed_rows, - ), - ], - h.div(class_="space-y-6")[log_panel(), settings_panel()], - ], + admin_sidebar(current_path="/"), + h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ + h.div(class_="mx-auto max-w-7xl space-y-5")[ + dashboard_header(), + operational_snapshot(), + running_executions_table(), ] ], ] diff --git a/repub/pages/runs.py b/repub/pages/runs.py new file mode 100644 index 0000000..f2a70c0 --- /dev/null +++ b/repub/pages/runs.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import htpy as h +from htpy import Node, Renderable + +from repub.components import ( + inline_button, + inline_link, + muted_action_link, + page_shell, + section_card, + status_badge, + table_section, +) + +RUNNING_EXECUTIONS: tuple[dict[str, str | bool], ...] = ( + { + "source": "Pangea mobile articles", + "slug": "pangea-mobile", + "job_id": "7", + "execution_id": "104", + "started_at": "Today, 11:42 UTC", + "runtime": "running for 8m", + "status": "Running", + "stats": "26 requests • 7 items • 2.6 MB", + "worker": "graceful stop after current item", + "log_href": "/job/7/execution/104/logs", + "is_running": True, + }, + { + "source": "Guardian feed mirror", + "slug": "guardian-feed", + "job_id": "3", + "execution_id": "103", + "started_at": "Today, 11:33 UTC", + "runtime": "running for 17m", + "status": "Running", + "stats": "91 requests • 13 items • 5.1 MB", + "worker": "streaming stats from worker jsonl", + "log_href": "/job/3/execution/103/logs", + "is_running": True, + }, + { + "source": "Podcast enclosure mirror", + "slug": "podcast-audio", + "job_id": "11", + "execution_id": "105", + "started_at": "Today, 11:48 UTC", + "runtime": "running for 2m", + "status": "Stopping", + "stats": "4 requests • 0 items • 0.8 MB", + "worker": "waiting for 15s graceful shutdown window", + "log_href": "/job/11/execution/105/logs", + "is_running": True, + }, +) + +UPCOMING_JOBS: tuple[dict[str, str | bool], ...] = ( + { + "source": "Podcast enclosure mirror", + "slug": "podcast-audio", + "job_id": "11", + "next_run": "Today, 12:15 local", + "schedule": "15 */4 * * 1-6", + "enabled_label": "Enabled", + "enabled_tone": "scheduled", + "run_disabled": True, + "run_reason": "Already running", + "toggle_label": "Disable", + }, + { + "source": "Weekly digest feed", + "slug": "weekly-digest", + "job_id": "18", + "next_run": "Tomorrow, 08:00 local", + "schedule": "0 8 * * 1", + "enabled_label": "Disabled", + "enabled_tone": "idle", + "run_disabled": False, + "run_reason": "Ready", + "toggle_label": "Enable", + }, + { + "source": "Kenya health desk", + "slug": "kenya-health", + "job_id": "22", + "next_run": "Today, 13:00 local", + "schedule": "0 */6 * * *", + "enabled_label": "Enabled", + "enabled_tone": "scheduled", + "run_disabled": False, + "run_reason": "Ready", + "toggle_label": "Disable", + }, +) + +COMPLETED_EXECUTIONS: tuple[dict[str, str], ...] = ( + { + "source": "Guardian feed mirror", + "slug": "guardian-feed", + "job_id": "3", + "execution_id": "102", + "ended_at": "Today, 10:57 UTC", + "status": "Succeeded", + "status_tone": "done", + "stats": "204 requests • 28 items • 9.4 MB", + "summary": "Finished on schedule", + "log_href": "/job/3/execution/102/logs", + }, + { + "source": "Podcast enclosure mirror", + "slug": "podcast-audio", + "job_id": "11", + "execution_id": "101", + "ended_at": "Today, 09:12 UTC", + "status": "Failed", + "status_tone": "failed", + "stats": "timeout after 3 retries", + "summary": "Worker exited with failure", + "log_href": "/job/11/execution/101/logs", + }, + { + "source": "Pangea mobile articles", + "slug": "pangea-mobile", + "job_id": "7", + "execution_id": "100", + "ended_at": "Today, 05:48 UTC", + "status": "Canceled", + "status_tone": "idle", + "stats": "stopped by operator after 11m", + "summary": "Graceful stop completed", + "log_href": "/job/7/execution/100/logs", + }, +) + + +def _running_row(execution: dict[str, str | bool]) -> tuple[Node, ...]: + return ( + h.div[ + h.div(class_="font-semibold text-slate-950")[execution["source"]], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[execution["slug"]], + ], + h.div[ + h.p(class_="font-medium text-slate-900")[f"#{execution['execution_id']}"], + h.p(class_="mt-1 text-xs text-slate-500")[f"job {execution['job_id']}"], + ], + h.div[ + h.p(class_="font-medium text-slate-900")[execution["started_at"]], + h.p(class_="mt-1 text-xs text-slate-500")[execution["runtime"]], + ], + status_badge(label=str(execution["status"]), tone="running"), + h.div(class_="min-w-56 whitespace-normal")[ + h.p(class_="font-medium text-slate-900")[execution["stats"]], + h.p(class_="mt-1 text-xs text-slate-500")[execution["worker"]], + ], + h.div(class_="flex flex-nowrap items-center gap-3")[ + inline_link( + href=str(execution["log_href"]), + label="View log", + tone="amber", + ), + inline_button(label="Stop", tone="danger"), + ], + ) + + +def _upcoming_row(job: dict[str, str | bool]) -> tuple[Node, ...]: + return ( + h.div[ + h.div(class_="font-semibold text-slate-950")[job["source"]], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[job["slug"]], + ], + h.div[ + h.p(class_="font-medium text-slate-900")[job["next_run"]], + h.p(class_="mt-1 text-xs text-slate-500")[f"job {job['job_id']}"], + ], + h.p(class_="font-mono text-xs text-slate-600")[job["schedule"]], + status_badge( + label=str(job["enabled_label"]), + tone=str(job["enabled_tone"]), + ), + h.p(class_="max-w-40 whitespace-normal text-sm text-slate-500")[ + job["run_reason"] + ], + h.div(class_="flex flex-nowrap items-center gap-2")[ + inline_button(label="Run now", disabled=bool(job["run_disabled"])), + inline_button(label=str(job["toggle_label"])), + inline_button(label="Delete", tone="danger"), + ], + ) + + +def _completed_row(execution: dict[str, str]) -> tuple[Node, ...]: + return ( + h.div[ + h.div(class_="font-semibold text-slate-950")[execution["source"]], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[execution["slug"]], + ], + h.div[ + h.p(class_="font-medium text-slate-900")[f"#{execution['execution_id']}"], + h.p(class_="mt-1 text-xs text-slate-500")[f"job {execution['job_id']}"], + ], + h.div[ + h.p(class_="font-medium text-slate-900")[execution["ended_at"]], + h.p(class_="mt-1 text-xs text-slate-500")[execution["summary"]], + ], + status_badge( + label=execution["status"], + tone=execution["status_tone"], + ), + h.div(class_="min-w-48 whitespace-normal")[ + h.p(class_="font-medium text-slate-900")[execution["stats"]] + ], + inline_link( + href=execution["log_href"], + label="View log", + tone="amber", + ), + ) + + +def delete_confirmation_preview() -> Renderable: + return section_card( + content=( + h.div(class_="flex items-center justify-between gap-4")[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Modal preview"], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ + "Delete confirmation" + ], + h.p(class_="mt-2 max-w-2xl text-sm text-slate-600")[ + "Upcoming jobs use a confirmation modal before deleting a job. This is the intended open state, placed inline for the static UI pass." + ], + ], + status_badge(label="Preview", tone="scheduled"), + ], + h.div(class_="mt-3 rounded-[1.5rem] bg-stone-50 p-5")[ + h.p( + class_="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500" + )["Delete job"], + h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[ + "Delete Weekly digest feed?" + ], + h.p(class_="mt-3 max-w-2xl text-sm leading-6 text-slate-600")[ + "This removes the source-linked job record and its schedule. Existing execution history and log files remain available for inspection." + ], + h.div(class_="mt-6 flex flex-wrap gap-3")[ + inline_button(label="Cancel"), + inline_button(label="Delete job", tone="danger"), + ], + ], + ) + ) + + +def runs_page() -> Renderable: + running_rows = tuple(_running_row(execution) for execution in RUNNING_EXECUTIONS) + upcoming_rows = tuple(_upcoming_row(job) for job in UPCOMING_JOBS) + completed_rows = tuple( + _completed_row(execution) for execution in COMPLETED_EXECUTIONS + ) + + return page_shell( + current_path="/runs", + eyebrow="Execution control", + title="Runs", + description="Running executions first, then the schedule queue, then completed history. Logs are routed through app URLs instead of direct file serving.", + actions=muted_action_link(href="/sources", label="Back to sources"), + content=( + table_section( + eyebrow="Live work", + title="Running job executions", + subtitle="Operators can inspect the live log stream, request a graceful stop, and escalate to a hard kill after the 15 second deadline if needed.", + headers=( + "Source", + "Execution", + "Started", + "Status", + "Stats", + "Actions", + ), + rows=running_rows, + ), + table_section( + eyebrow="Queue", + title="Upcoming jobs", + subtitle="Scheduled work shows enable or disable state, run-now affordances, and delete controls. Run now is disabled while the job is already running.", + headers=( + "Source", + "Next run", + "Cron", + "State", + "Run now", + "Actions", + ), + rows=upcoming_rows, + ), + table_section( + eyebrow="History", + title="Completed job executions", + subtitle="Recent execution history keeps the summary counters visible and links back to the plain text log view.", + headers=( + "Source", + "Execution", + "Ended", + "Status", + "Summary", + "Log", + ), + rows=completed_rows, + ), + delete_confirmation_preview(), + ), + ) + + +def execution_logs_page(*, job_id: int, execution_id: int) -> Renderable: + return page_shell( + current_path=f"/job/{job_id}/execution/{execution_id}/logs", + eyebrow="Execution log", + title=f"Job {job_id} / execution {execution_id}", + description="Plain text log view routed through the app. The final version will stream appended lines while the worker is still active.", + actions=muted_action_link(href="/runs", label="Back to runs"), + content=( + section_card( + content=( + h.div(class_="flex items-end justify-between gap-4")[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Route"], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ + f"/job/{job_id}/execution/{execution_id}/logs" + ], + h.p(class_="mt-2 text-sm text-slate-600")[ + "Streaming text log view. No arbitrary file paths are exposed in the UI." + ], + ], + status_badge(label="Streaming", tone="running"), + ], + h.pre( + class_="mt-3 overflow-x-auto rounded-[1.5rem] bg-slate-950 p-5 text-xs leading-6 text-emerald-200" + )[ + "\n".join( + ( + "11:42:01 scheduler: run_now requested for job 7", + "11:42:02 worker[7]: starting pangea-mobile", + "11:42:08 stats: requests=18 items=4 bytes=1.8MB", + "11:42:11 stats: requests=26 items=7 bytes=2.6MB", + "11:42:17 stats: requests=31 items=9 bytes=3.0MB", + "11:42:24 worker[7]: waiting for more log lines ...", + ) + ) + ], + ) + ), + ), + ) diff --git a/repub/pages/sources.py b/repub/pages/sources.py new file mode 100644 index 0000000..e73ddaf --- /dev/null +++ b/repub/pages/sources.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import htpy as h +from htpy import Node, Renderable + +from repub.components import ( + header_action_link, + inline_link, + input_field, + muted_action_link, + page_shell, + section_card, + select_field, + status_badge, + table_section, + textarea_field, + toggle_field, +) + +SOURCES: tuple[dict[str, str], ...] = ( + { + "name": "Guardian feed mirror", + "slug": "guardian-feed", + "source_type": "Feed", + "upstream": "https://guardianproject.info/feed.xml", + "schedule": "Every 30 minutes", + "last_run": "Succeeded 53m ago", + "state": "Enabled", + "state_tone": "scheduled", + }, + { + "name": "Pangea mobile articles", + "slug": "pangea-mobile", + "source_type": "Pangea", + "upstream": "guardianproject.info / News", + "schedule": "Every 4 hours", + "last_run": "Running now", + "state": "Enabled", + "state_tone": "running", + }, + { + "name": "Podcast enclosure mirror", + "slug": "podcast-audio", + "source_type": "Feed", + "upstream": "https://guardianproject.info/podcast/podcast.xml", + "schedule": "Paused", + "last_run": "Failed 2h ago", + "state": "Disabled", + "state_tone": "idle", + }, +) + + +def _source_row(source: dict[str, str]) -> tuple[Node, ...]: + return ( + h.div[ + h.div(class_="font-semibold text-slate-950")[source["name"]], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[source["slug"]], + ], + h.p(class_="font-medium whitespace-nowrap text-slate-900")[ + source["source_type"] + ], + h.p(class_="max-w-sm truncate font-mono text-xs text-slate-600")[ + source["upstream"] + ], + h.p(class_="font-medium whitespace-nowrap text-slate-900")[source["schedule"]], + h.div(class_="min-w-32 whitespace-normal")[ + status_badge(label=source["state"], tone=source["state_tone"]), + h.p(class_="mt-2 text-xs text-slate-500")[source["last_run"]], + ], + h.div(class_="flex flex-nowrap items-center gap-3")[ + inline_link(href="/sources/create", label="Edit", tone="amber"), + inline_link(href="/runs", label="View runs"), + ], + ) + + +def sources_table() -> Renderable: + rows = tuple(_source_row(source) for source in SOURCES) + return table_section( + eyebrow="Inventory", + title="Sources", + subtitle="Configured feed and Pangea sources live here as tables, with clear schedule and job state visibility instead of card-based CRUD.", + headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"), + rows=rows, + actions=header_action_link(href="/sources/create", label="Create source"), + ) + + +def sources_page() -> Renderable: + return page_shell( + current_path="/sources", + eyebrow="Source management", + title="Sources", + description="Configured feed and Pangea sources live here as tables, with clear schedule and job state visibility instead of card-based CRUD.", + actions=header_action_link(href="/sources/create", label="Create source"), + content=sources_table(), + ) + + +def create_source_form() -> Renderable: + return section_card( + content=( + h.div( + class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between" + )[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Create"], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ + "Source and job setup" + ], + h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[ + "The create flow lives on its own page and creates the source plus its paired job record. This pass is visual only, but the fields already reflect the intended shape." + ], + ], + status_badge(label="New source", tone="scheduled"), + ], + h.form( + {"data-signals__ifmissing": "{sourceType: 'pangea'}"}, + class_="mt-5 space-y-6", + )[ + h.div(class_="grid gap-4 md:grid-cols-2")[ + input_field( + label="Source name", + field_id="source-name", + value="Pangea mobile articles", + ), + input_field( + label="Slug", + field_id="source-slug", + value="pangea-mobile", + help_text="Immutable after creation.", + ), + h.div[ + h.label( + for_="source-type", + class_="block text-sm font-medium text-slate-900", + )["Source type"], + h.select( + {"data-bind": "sourceType"}, + id="source-type", + name="source-type", + class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 focus:outline-hidden focus:ring-2 focus:ring-amber-500", + )[ + h.option(value="feed")["feed"], + h.option(value="pangea", selected=True)["pangea"], + ], + ], + ], + h.div( + {"data-show": "$sourceType === 'feed'"}, + class_="space-y-4 rounded-[1.5rem] bg-stone-50 p-5", + )[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Feed source options"], + h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[ + "Feed settings" + ], + h.p(class_="mt-2 text-sm text-slate-600")[ + "Shown only when the source type is set to feed." + ], + ], + h.div(class_="grid gap-4 md:grid-cols-2")[ + input_field( + label="Feed URL", + field_id="feed-url", + placeholder="https://example.com/feed.xml", + ), + ], + ], + h.div( + {"data-show": "$sourceType === 'pangea'"}, + class_="space-y-4 rounded-[1.5rem] bg-stone-50 p-5", + )[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Pangea source options"], + h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[ + "Pangea settings" + ], + h.p(class_="mt-2 text-sm text-slate-600")[ + "Shown only when the source type is set to pangea." + ], + ], + h.div(class_="grid gap-4 lg:grid-cols-3")[ + input_field( + label="Pangea domain", + field_id="pangea-domain", + value="guardianproject.info", + ), + input_field( + label="Category name", + field_id="pangea-category", + value="News", + ), + select_field( + label="Content format", + field_id="content-format", + options=("MOBILE_3", "MOBILE_2", "WEB"), + selected="MOBILE_3", + ), + input_field( + label="Content type", + field_id="content-type", + value="articles", + ), + input_field( + label="Max articles", + field_id="max-articles", + value="10", + ), + input_field( + label="Oldest article (days)", + field_id="oldest-article", + value="3", + ), + ], + ], + h.div(class_="grid gap-4 lg:grid-cols-2")[ + textarea_field( + label="Notes", + field_id="source-notes", + value="Primary Pangea mobile article mirror for the operator landing page.", + ), + textarea_field( + label="Spider arguments", + field_id="spider-arguments", + value="language=en,download_media=true", + ), + ], + h.div( + class_="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(20rem,0.9fr)]" + )[ + h.div(class_="rounded-[1.5rem] bg-stone-50 p-5")[ + h.div[ + h.h3(class_="text-lg font-semibold text-slate-950")[ + "Cron schedule" + ], + h.p(class_="mt-1 text-sm text-slate-600")[ + "Stored in UTC and displayed in the browser timezone." + ], + ], + h.div(class_="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-5")[ + input_field( + label="Minute", + field_id="cron-minute", + value="15", + ), + input_field( + label="Hour", + field_id="cron-hour", + value="*/4", + ), + input_field( + label="Day of month", + field_id="cron-day-of-month", + value="*", + ), + input_field( + label="Day of week", + field_id="cron-day-of-week", + value="1-6", + ), + input_field( + label="Month", + field_id="cron-month", + value="*", + ), + ], + ], + h.div(class_="rounded-[1.5rem] bg-stone-50 p-5")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Job defaults"], + h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[ + "Initial job state" + ], + h.div(class_="mt-5 grid gap-4")[ + toggle_field( + label="Job enabled", + description="Scheduler will consider the new job immediately after creation.", + signal_name="jobEnabled", + checked=True, + ), + toggle_field( + label="Only newest", + description="Limit Pangea syncs to the newest material available in the selected category.", + signal_name="onlyNewest", + checked=True, + ), + toggle_field( + label="Include authors", + description="Carry author bylines into mirrored output where upstream data exists.", + signal_name="includeAuthors", + checked=True, + ), + toggle_field( + label="Exclude media", + description="Skip image and media attachment mirroring for this source.", + signal_name="excludeMedia", + checked=False, + ), + ], + ], + ], + h.div( + class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-6" + )[ + muted_action_link(href="/sources", label="Cancel"), + h.button( + type="button", + class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800", + )["Create source"], + ], + ], + ) + ) + + +def create_source_page() -> Renderable: + actions = ( + muted_action_link(href="/sources", label="Back to sources"), + header_action_link(href="/runs", label="View runs"), + ) + return page_shell( + current_path="/sources/create", + eyebrow="Source creation", + title="Create source", + description="Dedicated create page for the source form. The list page stays focused on scanning existing sources, while this page handles the new source and job configuration flow.", + actions=actions, + content=create_source_form(), + ) diff --git a/repub/static/app.css b/repub/static/app.css index 672a945..c01e60b 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -15,6 +15,7 @@ --color-amber-400: oklch(82.8% 0.189 84.429); --color-amber-500: oklch(76.9% 0.188 70.08); --color-amber-600: oklch(66.6% 0.179 58.318); + --color-amber-700: oklch(55.5% 0.163 48.998); --color-amber-800: oklch(47.3% 0.137 46.201); --color-amber-950: oklch(27.9% 0.077 45.635); --color-emerald-100: oklch(95% 0.052 163.051); @@ -24,9 +25,6 @@ --color-sky-800: oklch(44.3% 0.11 240.79); --color-rose-50: oklch(96.9% 0.015 12.422); --color-rose-100: oklch(94.1% 0.03 12.58); - --color-rose-200: oklch(89.2% 0.058 10.001); - --color-rose-400: oklch(71.2% 0.194 13.428); - --color-rose-500: oklch(64.5% 0.246 16.439); --color-rose-700: oklch(51.4% 0.222 16.935); --color-rose-800: oklch(45.5% 0.188 13.697); --color-slate-50: oklch(98.4% 0.003 247.858); @@ -43,9 +41,9 @@ --color-stone-50: oklch(98.5% 0.001 106.423); --color-stone-100: oklch(97% 0.001 106.424); --color-stone-200: oklch(92.3% 0.003 48.717); - --color-black: #000; --color-white: #fff; --spacing: 0.25rem; + --container-sm: 24rem; --container-2xl: 42rem; --container-3xl: 48rem; --container-7xl: 80rem; @@ -59,12 +57,8 @@ --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); - --text-2xl: 1.5rem; - --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); - --text-4xl: 2.25rem; - --text-4xl--line-height: calc(2.5 / 2.25); --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-black: 900; @@ -244,9 +238,6 @@ .absolute { position: absolute; } - .fixed { - position: fixed; - } .relative { position: relative; } @@ -310,15 +301,12 @@ .mt-auto { margin-top: auto; } - .mb-4 { - margin-bottom: calc(var(--spacing) * 4); + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); } .block { display: block; } - .contents { - display: contents; - } .flex { display: flex; } @@ -328,6 +316,9 @@ .hidden { display: none; } + .inline { + display: inline; + } .inline-flex { display: inline-flex; } @@ -366,19 +357,39 @@ .max-w-7xl { max-width: var(--container-7xl); } - .min-w-full { - min-width: 100%; + .max-w-40 { + max-width: calc(var(--spacing) * 40); + } + .max-w-sm { + max-width: var(--container-sm); + } + .min-w-32 { + min-width: calc(var(--spacing) * 32); + } + .min-w-48 { + min-width: calc(var(--spacing) * 48); + } + .min-w-56 { + min-width: calc(var(--spacing) * 56); + } + .min-w-\[70rem\] { + min-width: 70rem; + } + .min-w-\[72rem\] { + min-width: 72rem; } .shrink-0 { flex-shrink: 0; } + .table-auto { + table-layout: auto; + } .translate-x-0 { --tw-translate-x: calc(var(--spacing) * 0); translate: var(--tw-translate-x) var(--tw-translate-y); } - .translate-x-5 { - --tw-translate-x: calc(var(--spacing) * 5); - translate: var(--tw-translate-x) var(--tw-translate-y); + .cursor-not-allowed { + cursor: not-allowed; } .cursor-pointer { cursor: pointer; @@ -386,6 +397,9 @@ .flex-col { flex-direction: column; } + .flex-nowrap { + flex-wrap: nowrap; + } .flex-wrap { flex-wrap: wrap; } @@ -426,6 +440,20 @@ margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-5 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-6 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -433,13 +461,6 @@ margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); } } - .space-y-8 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); - } - } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; @@ -454,6 +475,11 @@ border-color: var(--color-slate-200); } } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .overflow-hidden { overflow: hidden; } @@ -466,8 +492,8 @@ .rounded-3xl { border-radius: var(--radius-3xl); } - .rounded-\[2rem\] { - border-radius: 2rem; + .rounded-\[1\.5rem\] { + border-radius: 1.5rem; } .rounded-full { border-radius: calc(infinity * 1px); @@ -487,10 +513,6 @@ border-top-style: var(--tw-border-style); border-top-width: 1px; } - .border-b { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 1px; - } .border-slate-200 { border-color: var(--color-slate-200); } @@ -515,15 +537,6 @@ .bg-amber-400 { background-color: var(--color-amber-400); } - .bg-amber-500 { - background-color: var(--color-amber-500); - } - .bg-black\/30 { - background-color: color-mix(in srgb, #000 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-black) 30%, transparent); - } - } .bg-emerald-100 { background-color: var(--color-emerald-100); } @@ -533,12 +546,6 @@ .bg-rose-100 { background-color: var(--color-rose-100); } - .bg-rose-500\/15 { - background-color: color-mix(in srgb, oklch(64.5% 0.246 16.439) 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-rose-500) 15%, transparent); - } - } .bg-sky-100 { background-color: var(--color-sky-100); } @@ -569,24 +576,12 @@ background-color: color-mix(in oklab, var(--color-white) 5%, transparent); } } - .bg-white\/10 { - background-color: color-mix(in srgb, #fff 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 10%, transparent); - } - } .bg-white\/85 { background-color: color-mix(in srgb, #fff 85%, transparent); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--color-white) 85%, transparent); } } - .bg-white\/90 { - background-color: color-mix(in srgb, #fff 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 90%, transparent); - } - } .bg-linear-to-br { --tw-gradient-position: to bottom right; @supports (background-image: linear-gradient(in lab, red, red)) { @@ -639,9 +634,6 @@ .p-5 { padding: calc(var(--spacing) * 5); } - .p-6 { - padding: calc(var(--spacing) * 6); - } .px-2 { padding-inline: calc(var(--spacing) * 2); } @@ -681,15 +673,18 @@ .py-4 { padding-block: calc(var(--spacing) * 4); } - .py-5 { - padding-block: calc(var(--spacing) * 5); - } - .py-6 { - padding-block: calc(var(--spacing) * 6); - } .py-8 { padding-block: calc(var(--spacing) * 8); } + .pt-6 { + padding-top: calc(var(--spacing) * 6); + } + .pr-6 { + padding-right: calc(var(--spacing) * 6); + } + .pl-4 { + padding-left: calc(var(--spacing) * 4); + } .text-left { text-align: left; } @@ -699,10 +694,6 @@ .font-mono { font-family: var(--font-mono); } - .text-2xl { - font-size: var(--text-2xl); - line-height: var(--tw-leading, var(--text-2xl--line-height)); - } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); @@ -746,10 +737,6 @@ --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } - .tracking-\[0\.3em\] { - --tw-tracking: 0.3em; - letter-spacing: 0.3em; - } .tracking-\[0\.18em\] { --tw-tracking: 0.18em; letter-spacing: 0.18em; @@ -766,12 +753,21 @@ --tw-tracking: var(--tracking-tight); letter-spacing: var(--tracking-tight); } + .whitespace-normal { + white-space: normal; + } + .whitespace-nowrap { + white-space: nowrap; + } .text-amber-300 { color: var(--color-amber-300); } .text-amber-600 { color: var(--color-amber-600); } + .text-amber-700 { + color: var(--color-amber-700); + } .text-amber-800 { color: var(--color-amber-800); } @@ -784,9 +780,6 @@ .text-emerald-800 { color: var(--color-emerald-800); } - .text-rose-200 { - color: var(--color-rose-200); - } .text-rose-700 { color: var(--color-rose-700); } @@ -827,10 +820,6 @@ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .shadow-xl { - --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } .shadow-xs { --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -839,15 +828,6 @@ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .ring-rose-200 { - --tw-ring-color: var(--color-rose-200); - } - .ring-rose-400\/20 { - --tw-ring-color: color-mix(in srgb, oklch(71.2% 0.194 13.428) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-ring-color: color-mix(in oklab, var(--color-rose-400) 20%, transparent); - } - } .ring-slate-200 { --tw-ring-color: var(--color-slate-200); } @@ -892,6 +872,11 @@ color: var(--color-slate-400); } } + .first\:pl-4 { + &:first-child { + padding-left: calc(var(--spacing) * 4); + } + } .hover\:bg-amber-300 { &:hover { @media (hover: hover) { @@ -899,10 +884,10 @@ } } } - .hover\:bg-rose-50 { + .hover\:bg-emerald-200 { &:hover { @media (hover: hover) { - background-color: var(--color-rose-50); + background-color: var(--color-emerald-200); } } } @@ -913,16 +898,6 @@ } } } - .hover\:bg-rose-500\/20 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(64.5% 0.246 16.439) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-rose-500) 20%, transparent); - } - } - } - } .hover\:bg-slate-50 { &:hover { @media (hover: hover) { @@ -964,13 +939,24 @@ } } } - .hover\:bg-white\/15 { + .hover\:text-amber-800 { &:hover { @media (hover: hover) { - background-color: color-mix(in srgb, #fff 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 15%, transparent); - } + color: var(--color-amber-800); + } + } + } + .hover\:text-rose-800 { + &:hover { + @media (hover: hover) { + color: var(--color-rose-800); + } + } + } + .hover\:text-slate-950 { + &:hover { + @media (hover: hover) { + color: var(--color-slate-950); } } } @@ -1002,6 +988,28 @@ } } } + .data-class\:translate-x-0 { + &[data-class] { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .data-class\:translate-x-5 { + &[data-class] { + --tw-translate-x: calc(var(--spacing) * 5); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .data-class\:bg-amber-500 { + &[data-class] { + background-color: var(--color-amber-500); + } + } + .data-class\:bg-slate-200 { + &[data-class] { + background-color: var(--color-slate-200); + } + } .sm\:grid-cols-2 { @media (width >= 40rem) { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1017,36 +1025,36 @@ align-items: flex-end; } } + .sm\:items-start { + @media (width >= 40rem) { + align-items: flex-start; + } + } .sm\:justify-between { @media (width >= 40rem) { justify-content: space-between; } } - .sm\:p-8 { + .sm\:px-5 { @media (width >= 40rem) { - padding: calc(var(--spacing) * 8); + padding-inline: calc(var(--spacing) * 5); } } - .sm\:px-6 { + .sm\:pl-4 { @media (width >= 40rem) { - padding-inline: calc(var(--spacing) * 6); + padding-left: calc(var(--spacing) * 4); } } - .sm\:px-8 { + .sm\:pl-6 { @media (width >= 40rem) { - padding-inline: calc(var(--spacing) * 8); + padding-left: calc(var(--spacing) * 6); } } - .sm\:text-4xl { + .sm\:first\:pl-6 { @media (width >= 40rem) { - font-size: var(--text-4xl); - line-height: var(--tw-leading, var(--text-4xl--line-height)); - } - } - .sm\:text-base { - @media (width >= 40rem) { - font-size: var(--text-base); - line-height: var(--tw-leading, var(--text-base--line-height)); + &:first-child { + padding-left: calc(var(--spacing) * 6); + } } } .md\:grid-cols-2 { @@ -1079,39 +1087,14 @@ grid-template-columns: 18rem minmax(0,1fr); } } - .lg\:flex-row { + .lg\:px-6 { @media (width >= 64rem) { - flex-direction: row; + padding-inline: calc(var(--spacing) * 6); } } - .lg\:items-end { + .lg\:py-5 { @media (width >= 64rem) { - align-items: flex-end; - } - } - .lg\:justify-between { - @media (width >= 64rem) { - justify-content: space-between; - } - } - .lg\:px-8 { - @media (width >= 64rem) { - padding-inline: calc(var(--spacing) * 8); - } - } - .lg\:py-8 { - @media (width >= 64rem) { - padding-block: calc(var(--spacing) * 8); - } - } - .xl\:grid-cols-2 { - @media (width >= 80rem) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - .xl\:grid-cols-3 { - @media (width >= 80rem) { - grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-block: calc(var(--spacing) * 5); } } .xl\:grid-cols-4 { @@ -1124,9 +1107,9 @@ grid-template-columns: repeat(5, minmax(0, 1fr)); } } - .xl\:grid-cols-\[minmax\(0\,1\.35fr\)_minmax\(22rem\,0\.95fr\)\] { + .xl\:grid-cols-\[minmax\(0\,1\.3fr\)_minmax\(20rem\,0\.9fr\)\] { @media (width >= 80rem) { - grid-template-columns: minmax(0,1.35fr) minmax(22rem,0.95fr); + grid-template-columns: minmax(0,1.3fr) minmax(20rem,0.9fr); } } } diff --git a/repub/static/app.tailwind.css b/repub/static/app.tailwind.css index 13ce457..97c0bcf 100644 --- a/repub/static/app.tailwind.css +++ b/repub/static/app.tailwind.css @@ -1,3 +1 @@ -@import "tailwindcss"; -@source "../components.py"; -@source "../web.py"; +@import "tailwindcss" source("../"); diff --git a/repub/web.py b/repub/web.py index bc85141..bbfcb6a 100644 --- a/repub/web.py +++ b/repub/web.py @@ -2,23 +2,28 @@ from __future__ import annotations import asyncio import hashlib -from collections.abc import AsyncGenerator -from contextlib import suppress +from collections.abc import AsyncGenerator, Awaitable, Callable from typing import cast 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 from datastar_py.sse import DatastarEvent from htpy import Renderable from quart import Quart, Response, request, url_for from repub.datastar import RefreshBroker, render_stream -from repub.pages import admin_component, shim_page +from repub.pages import ( + create_source_page, + dashboard_page, + execution_logs_page, + runs_page, + shim_page, + sources_page, +) REFRESH_BROKER_KEY = "repub.refresh_broker" -ACTIVE_JOBS_KEY = "repub.demo_active_jobs" -REFRESH_TASK_KEY = "repub.demo_refresh_task" + +RenderFunction = Callable[[], Awaitable[Renderable]] def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]: @@ -31,30 +36,19 @@ def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, return body, etag -def create_app(*, enable_demo_refresh: bool = True) -> Quart: +def create_app() -> Quart: app = Quart(__name__) app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() - app.extensions[ACTIVE_JOBS_KEY] = 12 - - if enable_demo_refresh: - - @app.before_serving - async def start_demo_refresh() -> None: - app.extensions[REFRESH_TASK_KEY] = asyncio.create_task( - _demo_refresh_loop(app) - ) - - @app.after_serving - async def stop_demo_refresh() -> None: - task = cast(asyncio.Task[None] | None, app.extensions.get(REFRESH_TASK_KEY)) - if task is None: - return - task.cancel() - with suppress(asyncio.CancelledError): - await task @app.get("/") - async def index() -> Response: + @app.get("/sources") + @app.get("/sources/create") + @app.get("/runs") + @app.get("/job//execution//logs") + async def page_shim( + job_id: int | None = None, execution_id: int | None = None + ) -> Response: + del job_id, execution_id body, etag = _render_shim_page( stylesheet_href=url_for("static", filename="app.css"), datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), @@ -69,24 +63,27 @@ def create_app(*, enable_demo_refresh: bool = True) -> Quart: return response @app.post("/") - async def index_patch() -> DatastarResponse: - queue = get_refresh_broker(app).subscribe() - stream = render_stream( - queue, - render=lambda: render_dashboard(app), - last_event_id=request.headers.get("last-event-id"), - ) - return DatastarResponse(_unsubscribe_on_close(queue, stream, app)) + async def dashboard_patch() -> DatastarResponse: + return _page_patch_response(app, render_dashboard) - @app.post("/demo/decrement") - async def demo_decrement() -> DatastarResponse: - amount, error = _validated_decrement_amount(await read_signals()) - if error is not None: - return DatastarResponse(SSE.patch_signals({"decrementError": error})) + @app.post("/sources") + async def sources_patch() -> DatastarResponse: + return _page_patch_response(app, render_sources) - set_active_jobs(app, max(0, get_active_jobs(app) - amount)) - trigger_refresh(app) - return DatastarResponse(SSE.patch_signals({"decrementError": ""})) + @app.post("/sources/create") + async def create_source_patch() -> DatastarResponse: + return _page_patch_response(app, render_create_source) + + @app.post("/runs") + async def runs_patch() -> DatastarResponse: + return _page_patch_response(app, render_runs) + + @app.post("/job//execution//logs") + async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse: + async def render() -> Renderable: + return await render_execution_logs(job_id=job_id, execution_id=execution_id) + + return _page_patch_response(app, render) return app @@ -99,8 +96,34 @@ def trigger_refresh(app: Quart, event: object = "refresh-event") -> None: get_refresh_broker(app).publish(event) -async def render_dashboard(app: Quart) -> Renderable: - return admin_component(active_jobs=str(get_active_jobs(app))) +async def render_dashboard() -> Renderable: + return dashboard_page() + + +async def render_sources() -> Renderable: + return sources_page() + + +async def render_create_source() -> Renderable: + return create_source_page() + + +async def render_runs() -> Renderable: + return runs_page() + + +async def render_execution_logs(*, job_id: int, execution_id: int) -> Renderable: + return execution_logs_page(job_id=job_id, execution_id=execution_id) + + +def _page_patch_response(app: Quart, render: RenderFunction) -> DatastarResponse: + queue = get_refresh_broker(app).subscribe() + stream = render_stream( + queue, + render=render, + last_event_id=request.headers.get("last-event-id"), + ) + return DatastarResponse(_unsubscribe_on_close(queue, stream, app)) async def _unsubscribe_on_close( @@ -111,35 +134,3 @@ async def _unsubscribe_on_close( yield event finally: get_refresh_broker(app).unsubscribe(cast(asyncio.Queue[object], queue)) - - -def get_active_jobs(app: Quart) -> int: - return cast(int, app.extensions[ACTIVE_JOBS_KEY]) - - -def set_active_jobs(app: Quart, value: int) -> None: - app.extensions[ACTIVE_JOBS_KEY] = value - - -async def _demo_refresh_loop(app: Quart) -> None: - while True: - await asyncio.sleep(1) - set_active_jobs(app, get_active_jobs(app) + 1) - trigger_refresh(app) - - -def _validated_decrement_amount( - signals: dict[str, object] | None, -) -> tuple[int, str | None]: - raw_amount = ( - "" if signals is None else str(signals.get("decrementAmount", "")).strip() - ) - try: - amount = int(raw_amount) - except ValueError: - return 0, "Decrement amount must be an odd integer." - - if amount < 1 or amount % 2 == 0: - return 0, "Decrement amount must be an odd integer." - - return amount, None diff --git a/tests/test_web.py b/tests/test_web.py index 8e8ff8d..6ddff2c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -6,16 +6,18 @@ from typing import Any, cast from repub.datastar import RefreshBroker, render_sse_event, render_stream from repub.web import ( create_app, - get_active_jobs, get_refresh_broker, + render_create_source, render_dashboard, - set_active_jobs, + render_execution_logs, + render_runs, + render_sources, ) def test_root_get_serves_datastar_shim() -> None: async def run() -> None: - client = create_app(enable_demo_refresh=False).test_client() + client = create_app().test_client() response = await client.get("/") body = await response.get_data(as_text=True) @@ -38,7 +40,7 @@ def test_root_get_serves_datastar_shim() -> None: def test_root_get_honors_if_none_match() -> None: async def run() -> None: - client = create_app(enable_demo_refresh=False).test_client() + client = create_app().test_client() initial = await client.get("/") etag = initial.headers["ETag"] @@ -51,9 +53,9 @@ def test_root_get_honors_if_none_match() -> None: asyncio.run(run()) -def test_root_post_serves_morph_component() -> None: +def test_dashboard_post_serves_morph_component() -> None: async def run() -> None: - client = create_app(enable_demo_refresh=False).test_client() + client = create_app().test_client() async with client.request("/?u=shim", method="POST") as connection: await connection.send_complete() chunk = await asyncio.wait_for(connection.receive(), timeout=1) @@ -64,6 +66,8 @@ def test_root_post_serves_morph_component() -> None: assert b"event: datastar-patch-elements" in chunk assert b"id: " in chunk assert b'
None: def test_app_refresh_broker_publishes_events() -> None: async def run() -> None: - app = create_app(enable_demo_refresh=False) + app = create_app() broker = get_refresh_broker(app) queue = broker.subscribe() @@ -123,72 +127,75 @@ def test_render_stream_yields_on_connect_and_refresh() -> None: asyncio.run(run()) -def test_render_dashboard_uses_active_jobs_from_app_state() -> None: +def test_render_dashboard_shows_dashboard_information_architecture() -> None: async def run() -> None: - app = create_app(enable_demo_refresh=False) - assert get_active_jobs(app) == 12 - set_active_jobs(app, 27) + body = str(await render_dashboard()) - async with app.app_context(): - body = str(await render_dashboard(app)) - - assert "27" in body - assert "Temporary live demo counter for Datastar refresh testing" in body - assert "/demo/decrement" in body - assert "data-bind:decrement-amount" in body + assert "Operational snapshot" in body + assert "Running executions" in body + assert 'href="/sources"' in body + assert 'href="/runs"' in body + assert "/job/7/execution/104/logs" in body + assert "Create source" in body asyncio.run(run()) -def test_demo_decrement_action_decrements_active_jobs() -> None: +def test_render_sources_shows_table_and_create_link() -> None: async def run() -> None: - app = create_app(enable_demo_refresh=False) - broker = get_refresh_broker(app) - queue = broker.subscribe() - client = app.test_client() + body = str(await render_sources()) - response = await client.post( - "/demo/decrement", - headers={"Datastar-Request": "true"}, - json={"decrementAmount": "3"}, - ) - body = await response.get_data(as_text=True) - event = await asyncio.wait_for(queue.get(), timeout=1) - - assert response.status_code == 200 - assert get_active_jobs(app) == 9 - assert event == "refresh-event" - assert 'data: signals {"decrementError":""}' in body - broker.unsubscribe(queue) + assert "Configured feed and Pangea sources live here as tables" in body + assert ">Sources<" in body + assert 'href="/sources/create"' in body + assert "guardian-feed" in body + assert "podcast-audio" in body asyncio.run(run()) -def test_demo_decrement_action_validates_odd_amount() -> None: +def test_render_create_source_shows_dedicated_form_page() -> None: async def run() -> None: - app = create_app(enable_demo_refresh=False) - broker = get_refresh_broker(app) - queue = broker.subscribe() - client = app.test_client() + body = str(await render_create_source()) - response = await client.post( - "/demo/decrement", - headers={"Datastar-Request": "true"}, - json={"decrementAmount": "2"}, - ) - body = await response.get_data(as_text=True) - - assert response.status_code == 200 - assert get_active_jobs(app) == 12 - assert "odd integer" in body - - try: - await asyncio.wait_for(queue.get(), timeout=0.1) - except TimeoutError: - pass - else: - raise AssertionError("invalid decrement should not publish a refresh") - finally: - broker.unsubscribe(queue) + assert "Dedicated create page for the source form" in body + assert "Source and job setup" in body + assert "data-signals__ifmissing" in body + assert 'data-show="$sourceType === 'feed'"' in body + assert 'data-show="$sourceType === 'pangea'"' in body + assert "jobEnabled" in body + assert "onlyNewest" in body + assert "includeAuthors" in body + assert "excludeMedia" in body + assert "Pangea domain" in body + assert "Feed URL" in body + assert "Cron schedule" in body + assert "Initial job state" in body + + asyncio.run(run()) + + +def test_render_runs_shows_running_upcoming_and_completed_tables() -> None: + async def run() -> None: + body = str(await render_runs()) + + assert "Running job executions" in body + assert "Upcoming jobs" in body + assert "Completed job executions" in body + assert "Delete confirmation" in body + assert "/job/11/execution/101/logs" in body + assert "Already running" in body + + asyncio.run(run()) + + +def test_render_execution_logs_uses_app_route() -> None: + async def run() -> None: + body = str(await render_execution_logs(job_id=7, execution_id=104)) + + assert "Job 7 / execution 104" in body + assert "/job/7/execution/104/logs" in body + assert "Streaming text log view" in body + assert "waiting for more log lines" in body asyncio.run(run()) From 06066c2394d77cc266d14b1c6c5e7a6c88c7c4f5 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 13:23:36 +0200 Subject: [PATCH 06/23] create sources in memory --- repub/components.py | 12 ++- repub/pages/sources.py | 147 ++++++++++++++++++++++++--------- repub/web.py | 182 +++++++++++++++++++++++++++++++++++++++-- tests/test_web.py | 97 ++++++++++++++++++++++ 4 files changed, 392 insertions(+), 46 deletions(-) diff --git a/repub/components.py b/repub/components.py index 5c82639..ae60aad 100644 --- a/repub/components.py +++ b/repub/components.py @@ -272,12 +272,14 @@ def input_field( value: str = "", placeholder: str = "", help_text: str | None = None, + signal_name: str | None = None, ) -> Renderable: return h.div[ h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ label ], h.input( + {"data-bind": signal_name} if signal_name is not None else {}, id=field_id, name=field_id, type="text", @@ -296,12 +298,14 @@ def select_field( options: tuple[str, ...], selected: str, help_text: str | None = None, + signal_name: str | None = None, ) -> Renderable: return h.div[ h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ label ], h.select( + {"data-bind": signal_name} if signal_name is not None else {}, id=field_id, name=field_id, class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 focus:outline-hidden focus:ring-2 focus:ring-amber-500", @@ -316,13 +320,19 @@ def select_field( def textarea_field( - *, label: str, field_id: str, value: str, rows: str = "4" + *, + label: str, + field_id: str, + value: str, + rows: str = "4", + signal_name: str | None = None, ) -> Renderable: return h.div[ h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ label ], h.textarea( + {"data-bind": signal_name} if signal_name is not None else {}, id=field_id, name=field_id, rows=rows, diff --git a/repub/pages/sources.py b/repub/pages/sources.py index e73ddaf..ea2af13 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping + import htpy as h from htpy import Node, Renderable @@ -17,7 +19,28 @@ from repub.components import ( toggle_field, ) -SOURCES: tuple[dict[str, str], ...] = ( +PANGEA_CONTENT_FORMATS = ( + "WTF_0", + "TEXT_ONLY", + "WTF_1", + "MOBILE_1", + "MOBILE_2", + "MOBILE_3", + "WTF_2", + "XML_TX", + "JSON", +) + +PANGEA_CONTENT_TYPES = ( + "articles", + "audioclips", + "videoclips", + "breakingnews", + "mostpopular", + "topstories", +) + +DEFAULT_SOURCES: tuple[dict[str, str], ...] = ( { "name": "Guardian feed mirror", "slug": "guardian-feed", @@ -51,22 +74,27 @@ SOURCES: tuple[dict[str, str], ...] = ( ) -def _source_row(source: dict[str, str]) -> tuple[Node, ...]: +def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ - h.div(class_="font-semibold text-slate-950")[source["name"]], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[source["slug"]], + h.div(class_="font-semibold text-slate-950")[str(source["name"])], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[str(source["slug"])], ], h.p(class_="font-medium whitespace-nowrap text-slate-900")[ - source["source_type"] + str(source["source_type"]) ], h.p(class_="max-w-sm truncate font-mono text-xs text-slate-600")[ - source["upstream"] + str(source["upstream"]) + ], + h.p(class_="font-medium whitespace-nowrap text-slate-900")[ + str(source["schedule"]) ], - h.p(class_="font-medium whitespace-nowrap text-slate-900")[source["schedule"]], h.div(class_="min-w-32 whitespace-normal")[ - status_badge(label=source["state"], tone=source["state_tone"]), - h.p(class_="mt-2 text-xs text-slate-500")[source["last_run"]], + status_badge( + label=str(source["state"]), + tone=str(source["state_tone"]), + ), + h.p(class_="mt-2 text-xs text-slate-500")[str(source["last_run"])], ], h.div(class_="flex flex-nowrap items-center gap-3")[ inline_link(href="/sources/create", label="Edit", tone="amber"), @@ -75,8 +103,10 @@ def _source_row(source: dict[str, str]) -> tuple[Node, ...]: ) -def sources_table() -> Renderable: - rows = tuple(_source_row(source) for source in SOURCES) +def sources_table( + *, sources: tuple[Mapping[str, object], ...] | None = None +) -> Renderable: + rows = tuple(_source_row(source) for source in (sources or DEFAULT_SOURCES)) return table_section( eyebrow="Inventory", title="Sources", @@ -87,18 +117,20 @@ def sources_table() -> Renderable: ) -def sources_page() -> Renderable: +def sources_page( + *, sources: tuple[Mapping[str, object], ...] | None = None +) -> Renderable: return page_shell( current_path="/sources", eyebrow="Source management", title="Sources", description="Configured feed and Pangea sources live here as tables, with clear schedule and job state visibility instead of card-based CRUD.", actions=header_action_link(href="/sources/create", label="Create source"), - content=sources_table(), + content=sources_table(sources=sources), ) -def create_source_form() -> Renderable: +def create_source_form(*, action_path: str = "/actions/sources/create") -> Renderable: return section_card( content=( h.div( @@ -118,20 +150,40 @@ def create_source_form() -> Renderable: status_badge(label="New source", tone="scheduled"), ], h.form( - {"data-signals__ifmissing": "{sourceType: 'pangea'}"}, + { + "data-signals": "{_formError: '', _formSuccess: ''}", + "data-signals__ifmissing": "{sourceType: 'pangea'}", + "data-on:submit": f"@post('{action_path}')", + }, class_="mt-5 space-y-6", )[ + h.div( + { + "data-show": "$_formError !== ''", + "data-text": "$_formError", + }, + class_="rounded-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-800", + ), + h.div( + { + "data-show": "$_formSuccess !== ''", + "data-text": "$_formSuccess", + }, + class_="rounded-2xl bg-emerald-100 px-4 py-3 text-sm font-medium text-emerald-800", + ), h.div(class_="grid gap-4 md:grid-cols-2")[ input_field( label="Source name", field_id="source-name", value="Pangea mobile articles", + signal_name="sourceName", ), input_field( label="Slug", field_id="source-slug", value="pangea-mobile", help_text="Immutable after creation.", + signal_name="sourceSlug", ), h.div[ h.label( @@ -169,6 +221,7 @@ def create_source_form() -> Renderable: label="Feed URL", field_id="feed-url", placeholder="https://example.com/feed.xml", + signal_name="feedUrl", ), ], ], @@ -192,32 +245,59 @@ def create_source_form() -> Renderable: label="Pangea domain", field_id="pangea-domain", value="guardianproject.info", + signal_name="pangeaDomain", ), input_field( label="Category name", field_id="pangea-category", value="News", + signal_name="pangeaCategory", ), select_field( label="Content format", field_id="content-format", - options=("MOBILE_3", "MOBILE_2", "WEB"), + options=PANGEA_CONTENT_FORMATS, selected="MOBILE_3", + signal_name="contentFormat", ), - input_field( + select_field( label="Content type", field_id="content-type", - value="articles", + options=PANGEA_CONTENT_TYPES, + selected="articles", + signal_name="contentType", ), input_field( label="Max articles", field_id="max-articles", value="10", + signal_name="maxArticles", ), input_field( label="Oldest article (days)", field_id="oldest-article", value="3", + signal_name="oldestArticle", + ), + ], + h.div(class_="grid gap-4 lg:grid-cols-3")[ + toggle_field( + label="Only newest", + description="Limit Pangea syncs to the newest material available in the selected category.", + signal_name="onlyNewest", + checked=True, + ), + toggle_field( + label="Include authors", + description="Carry author bylines into mirrored output where upstream data exists.", + signal_name="includeAuthors", + checked=True, + ), + toggle_field( + label="Exclude media", + description="Skip image and media attachment mirroring for this source.", + signal_name="excludeMedia", + checked=False, ), ], ], @@ -226,11 +306,13 @@ def create_source_form() -> Renderable: label="Notes", field_id="source-notes", value="Primary Pangea mobile article mirror for the operator landing page.", + signal_name="sourceNotes", ), textarea_field( label="Spider arguments", field_id="spider-arguments", value="language=en,download_media=true", + signal_name="spiderArguments", ), ], h.div( @@ -250,26 +332,31 @@ def create_source_form() -> Renderable: label="Minute", field_id="cron-minute", value="15", + signal_name="cronMinute", ), input_field( label="Hour", field_id="cron-hour", value="*/4", + signal_name="cronHour", ), input_field( label="Day of month", field_id="cron-day-of-month", value="*", + signal_name="cronDayOfMonth", ), input_field( label="Day of week", field_id="cron-day-of-week", value="1-6", + signal_name="cronDayOfWeek", ), input_field( label="Month", field_id="cron-month", value="*", + signal_name="cronMonth", ), ], ], @@ -287,24 +374,6 @@ def create_source_form() -> Renderable: signal_name="jobEnabled", checked=True, ), - toggle_field( - label="Only newest", - description="Limit Pangea syncs to the newest material available in the selected category.", - signal_name="onlyNewest", - checked=True, - ), - toggle_field( - label="Include authors", - description="Carry author bylines into mirrored output where upstream data exists.", - signal_name="includeAuthors", - checked=True, - ), - toggle_field( - label="Exclude media", - description="Skip image and media attachment mirroring for this source.", - signal_name="excludeMedia", - checked=False, - ), ], ], ], @@ -313,7 +382,7 @@ def create_source_form() -> Renderable: )[ muted_action_link(href="/sources", label="Cancel"), h.button( - type="button", + type="submit", class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800", )["Create source"], ], @@ -322,7 +391,7 @@ def create_source_form() -> Renderable: ) -def create_source_page() -> Renderable: +def create_source_page(*, action_path: str = "/actions/sources/create") -> Renderable: actions = ( muted_action_link(href="/sources", label="Back to sources"), header_action_link(href="/runs", label="View runs"), @@ -333,5 +402,5 @@ def create_source_page() -> Renderable: title="Create source", description="Dedicated create page for the source form. The list page stays focused on scanning existing sources, while this page handles the new source and job configuration flow.", actions=actions, - content=create_source_form(), + content=create_source_form(action_path=action_path), ) diff --git a/repub/web.py b/repub/web.py index bbfcb6a..86c9a71 100644 --- a/repub/web.py +++ b/repub/web.py @@ -4,14 +4,17 @@ import asyncio import hashlib from collections.abc import AsyncGenerator, Awaitable, Callable from typing import cast +from urllib.parse import urlparse import htpy as h -from datastar_py.quart import DatastarResponse +from datastar_py import ServerSentEventGenerator as SSE +from datastar_py.quart import DatastarResponse, read_signals from datastar_py.sse import DatastarEvent from htpy import Renderable from quart import Quart, Response, request, url_for from repub.datastar import RefreshBroker, render_stream +from repub.model import initialize_database from repub.pages import ( create_source_page, dashboard_page, @@ -20,8 +23,14 @@ from repub.pages import ( shim_page, sources_page, ) +from repub.pages.sources import ( + DEFAULT_SOURCES, + PANGEA_CONTENT_FORMATS, + PANGEA_CONTENT_TYPES, +) REFRESH_BROKER_KEY = "repub.refresh_broker" +SOURCES_KEY = "repub.sources" RenderFunction = Callable[[], Awaitable[Renderable]] @@ -38,7 +47,9 @@ def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, def create_app() -> Quart: app = Quart(__name__) + app.config["REPUB_DB_PATH"] = str(initialize_database()) app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() + app.extensions[SOURCES_KEY] = _default_sources_dict() @app.get("/") @app.get("/sources") @@ -68,11 +79,28 @@ def create_app() -> Quart: @app.post("/sources") async def sources_patch() -> DatastarResponse: - return _page_patch_response(app, render_sources) + return _page_patch_response(app, lambda: render_sources(app)) @app.post("/sources/create") async def create_source_patch() -> DatastarResponse: - return _page_patch_response(app, render_create_source) + return _page_patch_response(app, lambda: render_create_source(app)) + + @app.post("/actions/sources/create") + async def create_source_action() -> DatastarResponse: + signals = cast(dict[str, object], await read_signals()) + source, error = validate_source_form( + signals, + existing_sources=get_sources_dict(app), + ) + if error is not None: + return DatastarResponse( + SSE.patch_signals({"_formError": error, "_formSuccess": ""}) + ) + + assert source is not None + get_sources_dict(app)[str(source["slug"])] = source + trigger_refresh(app) + return DatastarResponse(SSE.redirect("/sources")) @app.post("/runs") async def runs_patch() -> DatastarResponse: @@ -100,11 +128,17 @@ async def render_dashboard() -> Renderable: return dashboard_page() -async def render_sources() -> Renderable: - return sources_page() +def get_sources_dict(app: Quart) -> dict[str, dict[str, object]]: + return cast(dict[str, dict[str, object]], app.extensions[SOURCES_KEY]) -async def render_create_source() -> Renderable: +async def render_sources(app: Quart | None = None) -> Renderable: + sources = None if app is None else tuple(get_sources_dict(app).values()) + return sources_page(sources=sources) + + +async def render_create_source(app: Quart | None = None) -> Renderable: + del app return create_source_page() @@ -134,3 +168,139 @@ async def _unsubscribe_on_close( yield event finally: get_refresh_broker(app).unsubscribe(cast(asyncio.Queue[object], queue)) + + +def _default_sources_dict() -> dict[str, dict[str, object]]: + return {source["slug"]: dict(source) for source in DEFAULT_SOURCES} + + +def validate_source_form( + signals: dict[str, object] | None, + *, + existing_sources: dict[str, dict[str, object]], +) -> tuple[dict[str, object] | None, str | None]: + if signals is None: + return None, "Missing form data." + + source_name = _read_string(signals, "sourceName") + source_slug = _read_string(signals, "sourceSlug") + source_type = _read_string(signals, "sourceType") + feed_url = _read_string(signals, "feedUrl") + pangea_domain = _read_string(signals, "pangeaDomain") + pangea_category = _read_string(signals, "pangeaCategory") + content_format = _read_string(signals, "contentFormat") + content_type = _read_string(signals, "contentType") + max_articles = _read_string(signals, "maxArticles") + oldest_article = _read_string(signals, "oldestArticle") + source_notes = _read_string(signals, "sourceNotes") + spider_arguments = _read_string(signals, "spiderArguments") + cron_minute = _read_string(signals, "cronMinute") + cron_hour = _read_string(signals, "cronHour") + cron_day_of_month = _read_string(signals, "cronDayOfMonth") + cron_day_of_week = _read_string(signals, "cronDayOfWeek") + cron_month = _read_string(signals, "cronMonth") + + errors: list[str] = [] + if source_name == "": + errors.append("Source name is required.") + if source_slug == "": + errors.append("Slug is required.") + elif source_slug in existing_sources: + errors.append("Slug must be unique.") + + if source_type not in {"feed", "pangea"}: + errors.append("Source type must be feed or pangea.") + + if source_type == "feed": + if feed_url == "": + errors.append("Feed URL is required for feed sources.") + elif not _is_valid_url(feed_url): + errors.append("Feed URL must be a valid URL.") + + if source_type == "pangea": + if pangea_domain == "": + errors.append("Pangea domain is required.") + if pangea_category == "": + errors.append("Category name is required.") + if content_format not in PANGEA_CONTENT_FORMATS: + errors.append("Content format is invalid.") + if content_type not in PANGEA_CONTENT_TYPES: + errors.append("Content type is invalid.") + if _parse_int(max_articles) is None: + errors.append("Max articles must be an integer.") + if _parse_int(oldest_article) is None: + errors.append("Oldest article must be an integer.") + + cron_values = ( + cron_minute, + cron_hour, + cron_day_of_month, + cron_day_of_week, + cron_month, + ) + if any(value == "" for value in cron_values): + errors.append("All cron fields are required.") + + if errors: + return None, " ".join(errors) + + enabled = _read_bool(signals, "jobEnabled") + source = { + "name": source_name, + "slug": source_slug, + "source_type": "Feed" if source_type == "feed" else "Pangea", + "upstream": ( + feed_url + if source_type == "feed" + else f"{pangea_domain} / {pangea_category}" + ), + "schedule": f"cron: {cron_minute} {cron_hour} {cron_day_of_month} {cron_month} {cron_day_of_week}", + "last_run": "Never run", + "state": "Enabled" if enabled else "Disabled", + "state_tone": "scheduled" if enabled else "idle", + "notes": source_notes, + "spider_arguments": spider_arguments, + "source_kind": source_type, + "feed_url": feed_url, + "pangea_domain": pangea_domain, + "pangea_category": pangea_category, + "content_format": content_format, + "content_type": content_type, + "max_articles": max_articles, + "oldest_article": oldest_article, + "job_enabled": enabled, + "only_newest": _read_bool(signals, "onlyNewest"), + "include_authors": _read_bool(signals, "includeAuthors"), + "exclude_media": _read_bool(signals, "excludeMedia"), + "cron_minute": cron_minute, + "cron_hour": cron_hour, + "cron_day_of_month": cron_day_of_month, + "cron_day_of_week": cron_day_of_week, + "cron_month": cron_month, + } + return source, None + + +def _read_string(signals: dict[str, object], key: str) -> str: + return str(signals.get(key, "")).strip() + + +def _read_bool(signals: dict[str, object], key: str) -> bool: + value = signals.get(key, False) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"true", "1", "on", "yes"} + return bool(value) + + +def _parse_int(value: str) -> int | None: + try: + return int(value) + except ValueError: + return None + + +def _is_valid_url(value: str) -> bool: + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and parsed.netloc != "" diff --git a/tests/test_web.py b/tests/test_web.py index 6ddff2c..9f0475f 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,12 +1,14 @@ from __future__ import annotations import asyncio +from pathlib import Path from typing import Any, cast from repub.datastar import RefreshBroker, render_sse_event, render_stream from repub.web import ( create_app, get_refresh_broker, + get_sources_dict, render_create_source, render_dashboard, render_execution_logs, @@ -38,6 +40,17 @@ def test_root_get_serves_datastar_shim() -> None: asyncio.run(run()) +def test_create_app_bootstraps_default_database_path( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + + app = create_app() + + assert Path(app.config["REPUB_DB_PATH"]) == tmp_path / "republisher.db" + assert (tmp_path / "republisher.db").exists() + + def test_root_get_honors_if_none_match() -> None: async def run() -> None: client = create_app().test_client() @@ -161,12 +174,15 @@ def test_render_create_source_shows_dedicated_form_page() -> None: assert "Dedicated create page for the source form" in body assert "Source and job setup" in body assert "data-signals__ifmissing" in body + assert "/actions/sources/create" in body assert 'data-show="$sourceType === 'feed'"' in body assert 'data-show="$sourceType === 'pangea'"' in body assert "jobEnabled" in body assert "onlyNewest" in body assert "includeAuthors" in body assert "excludeMedia" in body + assert "TEXT_ONLY" in body + assert "breakingnews" in body assert "Pangea domain" in body assert "Feed URL" in body assert "Cron schedule" in body @@ -175,6 +191,87 @@ def test_render_create_source_shows_dedicated_form_page() -> None: asyncio.run(run()) +def test_create_source_action_adds_new_source_to_in_memory_store() -> None: + async def run() -> None: + app = create_app() + client = app.test_client() + + response = await client.post( + "/actions/sources/create", + headers={"Datastar-Request": "true"}, + json={ + "sourceName": "Kenya health desk", + "sourceSlug": "kenya-health", + "sourceType": "pangea", + "pangeaDomain": "example.org", + "pangeaCategory": "Health", + "contentFormat": "MOBILE_3", + "contentType": "breakingnews", + "maxArticles": "12", + "oldestArticle": "5", + "sourceNotes": "Regional health alerts.", + "spiderArguments": "language=en", + "cronMinute": "0", + "cronHour": "*/6", + "cronDayOfMonth": "*", + "cronDayOfWeek": "*", + "cronMonth": "*", + "jobEnabled": True, + "onlyNewest": True, + "includeAuthors": True, + "excludeMedia": False, + }, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert "window.location = '/sources'" in body + assert "kenya-health" in get_sources_dict(app) + assert get_sources_dict(app)["kenya-health"]["content_type"] == "breakingnews" + + asyncio.run(run()) + + +def test_create_source_action_validates_duplicate_slug_and_pangea_type() -> None: + async def run() -> None: + app = create_app() + client = app.test_client() + + response = await client.post( + "/actions/sources/create", + headers={"Datastar-Request": "true"}, + json={ + "sourceName": "Duplicate guardian", + "sourceSlug": "guardian-feed", + "sourceType": "pangea", + "pangeaDomain": "example.org", + "pangeaCategory": "News", + "contentFormat": "WEB", + "contentType": "not-a-real-type", + "maxArticles": "ten", + "oldestArticle": "3", + "cronMinute": "0", + "cronHour": "*", + "cronDayOfMonth": "*", + "cronDayOfWeek": "*", + "cronMonth": "*", + "jobEnabled": True, + }, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Slug must be unique." in body + assert "Content format is invalid." in body + assert "Content type is invalid." in body + assert "Max articles must be an integer." in body + assert "Duplicate guardian" not in { + str(source["name"]) for source in get_sources_dict(app).values() + } + + asyncio.run(run()) + + def test_render_runs_shows_running_upcoming_and_completed_tables() -> None: async def run() -> None: body = str(await render_runs()) From b9e288a22df18cff2fc88756e9c76ea6e1ac3e13 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 13:26:59 +0200 Subject: [PATCH 07/23] add sqlite database --- .gitignore | 1 + pyproject.toml | 3 + repub/model.py | 168 +++++++++++++++++++++++++++++++++++++ repub/sql/001_initial.sql | 97 +++++++++++++++++++++ tests/test_model.py | 171 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 440 insertions(+) create mode 100644 repub/model.py create mode 100644 repub/sql/001_initial.sql create mode 100644 tests/test_model.py diff --git a/.gitignore b/.gitignore index 6a9e93b..bf0de74 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ data logs archive *egg-info +*.db diff --git a/pyproject.toml b/pyproject.toml index 425ad43..3361a67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,9 @@ include-package-data = true where = ["."] include = ["repub*"] +[tool.setuptools.package-data] +repub = ["sql/*.sql"] + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/repub/model.py b/repub/model.py new file mode 100644 index 0000000..8b26934 --- /dev/null +++ b/repub/model.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import os +from datetime import UTC, datetime +from enum import IntEnum +from importlib import resources +from importlib.resources.abc import Traversable +from pathlib import Path + +from peewee import ( + BooleanField, + Check, + DateTimeField, + ForeignKeyField, + IntegerField, + Model, + SqliteDatabase, + TextField, +) + +DEFAULT_DB_PATH = Path("republisher.db") +DATABASE_PRAGMAS = { + "busy_timeout": 5000, + "cache_size": 15625, + "foreign_keys": 1, + "journal_mode": "wal", + "page_size": 4096, + "synchronous": "normal", + "temp_store": "memory", +} +SCHEMA_GLOB = "*.sql" + +database = SqliteDatabase(None, pragmas=DATABASE_PRAGMAS) + + +class JobExecutionStatus(IntEnum): + PENDING = 0 + RUNNING = 1 + SUCCEEDED = 2 + FAILED = 3 + CANCELED = 4 + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def resolve_database_path(db_path: str | Path | None = None) -> Path: + raw_value = ( + os.environ.get("REPUBLISHER_DB_PATH", DEFAULT_DB_PATH) + if db_path is None + else db_path + ) + raw_path = Path(raw_value) + return raw_path.expanduser().resolve() + + +def schema_paths() -> tuple[Traversable, ...]: + schema_dir = resources.files("repub").joinpath("sql") + return tuple( + sorted( + (path for path in schema_dir.iterdir() if path.name.endswith(".sql")), + key=lambda path: path.name, + ) + ) + + +def initialize_database(db_path: str | Path | None = None) -> Path: + resolved_path = resolve_database_path(db_path) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + if not database.is_closed(): + database.close() + + database.init(str(resolved_path), pragmas=DATABASE_PRAGMAS) + database.connect(reuse_if_open=True) + try: + connection = database.connection() + for path in schema_paths(): + connection.executescript(path.read_text(encoding="utf-8")) + finally: + database.close() + + return resolved_path + + +class BaseModel(Model): + class Meta: + database = database + + +class Source(BaseModel): + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) + name = TextField() + slug = TextField(unique=True) + source_type = TextField(constraints=[Check("source_type IN ('feed', 'pangea')")]) + notes = TextField(default="") + + class Meta: + table_name = "source" + + +class SourceFeed(BaseModel): + source = ForeignKeyField(Source, primary_key=True, backref="feed_config") + feed_url = TextField() + etag = TextField(null=True) + last_modified = TextField(null=True) + + class Meta: + table_name = "source_feed" + + +class SourcePangea(BaseModel): + source = ForeignKeyField(Source, primary_key=True, backref="pangea_config") + domain = TextField() + category_name = TextField() + content_type = TextField() + only_newest = BooleanField() + max_articles = IntegerField() + oldest_article = IntegerField() + include_authors = BooleanField() + exclude_media = BooleanField() + include_content = BooleanField() + content_format = TextField() + + class Meta: + table_name = "source_pangea" + + +class Job(BaseModel): + source = ForeignKeyField(Source, unique=True, backref="job") + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) + enabled = BooleanField() + spider_arguments = TextField(default="") + cron_minute = TextField() + cron_hour = TextField() + cron_day_of_month = TextField() + cron_day_of_week = TextField() + cron_month = TextField() + + class Meta: + table_name = "job" + + +class JobExecution(BaseModel): + job = ForeignKeyField(Job, backref="executions") + created_at = DateTimeField(default=utc_now) + started_at = DateTimeField(null=True) + ended_at = DateTimeField(null=True) + running_status = IntegerField( + default=JobExecutionStatus.PENDING, + constraints=[Check("running_status BETWEEN 0 AND 4")], + ) + requests_count = IntegerField(default=0) + items_count = IntegerField(default=0) + warnings_count = IntegerField(default=0) + errors_count = IntegerField(default=0) + bytes_count = IntegerField(default=0) + retries_count = IntegerField(default=0) + exceptions_count = IntegerField(default=0) + cache_size_count = IntegerField(default=0) + cache_object_count = IntegerField(default=0) + raw_stats = TextField(default="{}") + + class Meta: + table_name = "job_execution" diff --git a/repub/sql/001_initial.sql b/repub/sql/001_initial.sql new file mode 100644 index 0000000..12f3d41 --- /dev/null +++ b/repub/sql/001_initial.sql @@ -0,0 +1,97 @@ +CREATE TABLE IF NOT EXISTS source ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + source_type TEXT NOT NULL CHECK (source_type IN ('feed', 'pangea')), + notes TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS source_feed ( + source_id INTEGER PRIMARY KEY, + feed_url TEXT NOT NULL, + etag TEXT, + last_modified TEXT, + FOREIGN KEY (source_id) REFERENCES source(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS source_pangea ( + source_id INTEGER PRIMARY KEY, + domain TEXT NOT NULL, + category_name TEXT NOT NULL, + content_type TEXT NOT NULL, + only_newest INTEGER NOT NULL CHECK (only_newest IN (0, 1)), + max_articles INTEGER NOT NULL, + oldest_article INTEGER NOT NULL, + include_authors INTEGER NOT NULL CHECK (include_authors IN (0, 1)), + exclude_media INTEGER NOT NULL CHECK (exclude_media IN (0, 1)), + include_content INTEGER NOT NULL CHECK (include_content IN (0, 1)), + content_format TEXT NOT NULL, + FOREIGN KEY (source_id) REFERENCES source(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS job ( + id INTEGER PRIMARY KEY, + source_id INTEGER NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + spider_arguments TEXT NOT NULL DEFAULT '', + cron_minute TEXT NOT NULL, + cron_hour TEXT NOT NULL, + cron_day_of_month TEXT NOT NULL, + cron_day_of_week TEXT NOT NULL, + cron_month TEXT NOT NULL, + FOREIGN KEY (source_id) REFERENCES source(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS job_execution ( + id INTEGER PRIMARY KEY, + job_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TEXT, + ended_at TEXT, + running_status INTEGER NOT NULL DEFAULT 0 CHECK (running_status BETWEEN 0 AND 4), + requests_count INTEGER NOT NULL DEFAULT 0, + items_count INTEGER NOT NULL DEFAULT 0, + warnings_count INTEGER NOT NULL DEFAULT 0, + errors_count INTEGER NOT NULL DEFAULT 0, + bytes_count INTEGER NOT NULL DEFAULT 0, + retries_count INTEGER NOT NULL DEFAULT 0, + exceptions_count INTEGER NOT NULL DEFAULT 0, + cache_size_count INTEGER NOT NULL DEFAULT 0, + cache_object_count INTEGER NOT NULL DEFAULT 0, + raw_stats TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY (job_id) REFERENCES job(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS job_enabled_idx +ON job (enabled); + +CREATE INDEX IF NOT EXISTS job_execution_job_created_at_idx +ON job_execution (job_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS job_execution_status_started_at_idx +ON job_execution (running_status, started_at DESC); + +CREATE INDEX IF NOT EXISTS job_execution_status_ended_at_idx +ON job_execution (running_status, ended_at DESC); + +CREATE TRIGGER IF NOT EXISTS source_set_updated_at +AFTER UPDATE ON source +FOR EACH ROW +BEGIN + UPDATE source + SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS job_set_updated_at +AFTER UPDATE ON job +FOR EACH ROW +BEGIN + UPDATE job + SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; +END; diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..b27bf8d --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from peewee import IntegrityError + +from repub.model import ( + Job, + Source, + database, + initialize_database, + resolve_database_path, +) + + +def test_resolve_database_path_defaults_to_republisher_db( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("REPUBLISHER_DB_PATH", raising=False) + + assert resolve_database_path() == tmp_path / "republisher.db" + + +def test_resolve_database_path_prefers_environment_variable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + db_path = tmp_path / "env-configured.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + assert resolve_database_path() == db_path + + +def test_initialize_database_bootstraps_schema_from_sql_files(tmp_path: Path) -> None: + db_path = tmp_path / "bootstrap.db" + + initialize_database(db_path) + + connection = sqlite3.connect(db_path) + try: + table_names = { + row[0] + for row in connection.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + """ + ) + } + assert table_names == { + "job", + "job_execution", + "settings", + "source", + "source_feed", + "source_pangea", + } + + defaults = { + row[1]: row[4] + for row in connection.execute("PRAGMA table_info('source_pangea')") + } + assert defaults["content_type"] is None + assert defaults["only_newest"] is None + assert defaults["max_articles"] is None + assert defaults["oldest_article"] is None + assert defaults["include_authors"] is None + assert defaults["exclude_media"] is None + assert defaults["include_content"] is None + assert defaults["content_format"] is None + finally: + connection.close() + + +def test_initialize_database_configures_sqlite_pragmas(tmp_path: Path) -> None: + db_path = tmp_path / "pragmas.db" + + initialize_database(db_path) + + database.connect(reuse_if_open=True) + try: + pragma_values = { + "cache_size": database.execute_sql("PRAGMA cache_size").fetchone()[0], + "page_size": database.execute_sql("PRAGMA page_size").fetchone()[0], + "journal_mode": database.execute_sql("PRAGMA journal_mode").fetchone()[0], + "synchronous": database.execute_sql("PRAGMA synchronous").fetchone()[0], + "temp_store": database.execute_sql("PRAGMA temp_store").fetchone()[0], + "foreign_keys": database.execute_sql("PRAGMA foreign_keys").fetchone()[0], + "busy_timeout": database.execute_sql("PRAGMA busy_timeout").fetchone()[0], + } + assert pragma_values == { + "cache_size": 15625, + "page_size": 4096, + "journal_mode": "wal", + "synchronous": 1, + "temp_store": 2, + "foreign_keys": 1, + "busy_timeout": 5000, + } + finally: + database.close() + + +def test_initialize_database_creates_scheduler_and_execution_indexes( + tmp_path: Path, +) -> None: + db_path = tmp_path / "indexes.db" + + initialize_database(db_path) + + connection = sqlite3.connect(db_path) + try: + index_names = { + row[0] + for row in connection.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'index' + AND name IN ( + 'job_enabled_idx', + 'job_execution_job_created_at_idx', + 'job_execution_status_started_at_idx', + 'job_execution_status_ended_at_idx' + ) + """ + ) + } + assert index_names == { + "job_enabled_idx", + "job_execution_job_created_at_idx", + "job_execution_status_started_at_idx", + "job_execution_status_ended_at_idx", + } + finally: + connection.close() + + +def test_job_table_allows_exactly_one_job_per_source(tmp_path: Path) -> None: + initialize_database(tmp_path / "jobs.db") + + source = Source.create( + name="Guardian feed mirror", + slug="guardian-feed", + source_type="feed", + ) + Job.create( + source=source, + enabled=True, + spider_arguments="", + cron_minute="15", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + ) + + with pytest.raises(IntegrityError): + Job.create( + source=source, + enabled=True, + spider_arguments="language=en", + cron_minute="30", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + ) From 847aeae7721a5de1dd23685eaf29130f7e0b8e8e Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 13:37:25 +0200 Subject: [PATCH 08/23] db backed source creation --- repub/model.py | 126 +++++++++++++++++++++++++++++++++++++++++ repub/pages/sources.py | 55 ++++-------------- repub/web.py | 117 ++++++++++++++++++++++++-------------- tests/test_model.py | 1 - tests/test_web.py | 112 ++++++++++++++++++++++++++++++++---- 5 files changed, 312 insertions(+), 99 deletions(-) diff --git a/repub/model.py b/repub/model.py index 8b26934..60c4bb4 100644 --- a/repub/model.py +++ b/repub/model.py @@ -84,6 +84,132 @@ def initialize_database(db_path: str | Path | None = None) -> Path: return resolved_path +def source_slug_exists(slug: str) -> bool: + with database.connection_context(): + return Source.select().where(Source.slug == slug).exists() + + +def create_source( + *, + name: str, + slug: str, + source_type: str, + notes: str, + spider_arguments: str, + enabled: bool, + cron_minute: str, + cron_hour: str, + cron_day_of_month: str, + cron_day_of_week: str, + cron_month: str, + feed_url: str = "", + pangea_domain: str = "", + pangea_category: str = "", + content_type: str = "", + only_newest: bool = True, + max_articles: int | None = None, + oldest_article: int | None = None, + include_authors: bool = True, + exclude_media: bool = False, + include_content: bool = True, + content_format: str = "", +) -> Source: + with database.connection_context(): + with database.atomic(): + source = Source.create( + name=name, + slug=slug, + source_type=source_type, + notes=notes, + ) + if source_type == "feed": + SourceFeed.create( + source=source, + feed_url=feed_url, + ) + else: + SourcePangea.create( + source=source, + domain=pangea_domain, + category_name=pangea_category, + content_type=content_type, + only_newest=only_newest, + max_articles=max_articles, + oldest_article=oldest_article, + include_authors=include_authors, + exclude_media=exclude_media, + include_content=include_content, + content_format=content_format, + ) + Job.create( + source=source, + enabled=enabled, + spider_arguments=spider_arguments, + cron_minute=cron_minute, + cron_hour=cron_hour, + cron_day_of_month=cron_day_of_month, + cron_day_of_week=cron_day_of_week, + cron_month=cron_month, + ) + return source + + +def load_sources() -> tuple[dict[str, object], ...]: + with database.connection_context(): + sources = tuple(Source.select().order_by(Source.created_at.desc())) + source_ids = tuple(int(source.get_id()) for source in sources) + if not source_ids: + return () + jobs = { + job.source_id: job for job in Job.select().where(Job.source.in_(source_ids)) + } + feed_configs = { + config.source_id: config + for config in SourceFeed.select().where(SourceFeed.source.in_(source_ids)) + } + pangea_configs = { + config.source_id: config + for config in SourcePangea.select().where( + SourcePangea.source.in_(source_ids) + ) + } + return tuple( + _project_source(source, jobs, feed_configs, pangea_configs) + for source in sources + ) + + +def _project_source( + source: "Source", + jobs: dict[int, "Job"], + feed_configs: dict[int, "SourceFeed"], + pangea_configs: dict[int, "SourcePangea"], +) -> dict[str, object]: + source_id = int(source.get_id()) + job = jobs[source_id] + if source.source_type == "feed": + upstream = feed_configs[source_id].feed_url + source_type = "Feed" + else: + pangea = pangea_configs[source_id] + upstream = f"{pangea.domain} / {pangea.category_name}" + source_type = "Pangea" + + return { + "name": source.name, + "slug": source.slug, + "source_type": source_type, + "upstream": upstream, + "schedule": ( + f"cron: {job.cron_minute} {job.cron_hour} {job.cron_day_of_month} " + f"{job.cron_month} {job.cron_day_of_week}" + ), + "last_run": "Never run", + "state": "Enabled" if job.enabled else "Disabled", + "state_tone": "scheduled" if job.enabled else "idle", + } + + class BaseModel(Model): class Meta: database = database diff --git a/repub/pages/sources.py b/repub/pages/sources.py index ea2af13..2da44f9 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -40,39 +40,6 @@ PANGEA_CONTENT_TYPES = ( "topstories", ) -DEFAULT_SOURCES: tuple[dict[str, str], ...] = ( - { - "name": "Guardian feed mirror", - "slug": "guardian-feed", - "source_type": "Feed", - "upstream": "https://guardianproject.info/feed.xml", - "schedule": "Every 30 minutes", - "last_run": "Succeeded 53m ago", - "state": "Enabled", - "state_tone": "scheduled", - }, - { - "name": "Pangea mobile articles", - "slug": "pangea-mobile", - "source_type": "Pangea", - "upstream": "guardianproject.info / News", - "schedule": "Every 4 hours", - "last_run": "Running now", - "state": "Enabled", - "state_tone": "running", - }, - { - "name": "Podcast enclosure mirror", - "slug": "podcast-audio", - "source_type": "Feed", - "upstream": "https://guardianproject.info/podcast/podcast.xml", - "schedule": "Paused", - "last_run": "Failed 2h ago", - "state": "Disabled", - "state_tone": "idle", - }, -) - def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( @@ -106,7 +73,7 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: def sources_table( *, sources: tuple[Mapping[str, object], ...] | None = None ) -> Renderable: - rows = tuple(_source_row(source) for source in (sources or DEFAULT_SOURCES)) + rows = tuple(_source_row(source) for source in (sources or ())) return table_section( eyebrow="Inventory", title="Sources", @@ -175,13 +142,11 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Source name", field_id="source-name", - value="Pangea mobile articles", signal_name="sourceName", ), input_field( label="Slug", field_id="source-slug", - value="pangea-mobile", help_text="Immutable after creation.", signal_name="sourceSlug", ), @@ -244,13 +209,11 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Pangea domain", field_id="pangea-domain", - value="guardianproject.info", signal_name="pangeaDomain", ), input_field( label="Category name", field_id="pangea-category", - value="News", signal_name="pangeaCategory", ), select_field( @@ -299,19 +262,25 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende signal_name="excludeMedia", checked=False, ), + toggle_field( + label="Include content", + description="Store article body content in mirrored output when the upstream provides it.", + signal_name="includeContent", + checked=True, + ), ], ], h.div(class_="grid gap-4 lg:grid-cols-2")[ textarea_field( label="Notes", field_id="source-notes", - value="Primary Pangea mobile article mirror for the operator landing page.", + value="", signal_name="sourceNotes", ), textarea_field( label="Spider arguments", field_id="spider-arguments", - value="language=en,download_media=true", + value="language=en\ndownload_media=true", signal_name="spiderArguments", ), ], @@ -331,13 +300,13 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Minute", field_id="cron-minute", - value="15", + value="*/30", signal_name="cronMinute", ), input_field( label="Hour", field_id="cron-hour", - value="*/4", + value="*", signal_name="cronHour", ), input_field( @@ -349,7 +318,7 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Day of week", field_id="cron-day-of-week", - value="1-6", + value="*", signal_name="cronDayOfWeek", ), input_field( diff --git a/repub/web.py b/repub/web.py index 86c9a71..350de6a 100644 --- a/repub/web.py +++ b/repub/web.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import hashlib from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import cast +from typing import TypedDict, cast from urllib.parse import urlparse import htpy as h @@ -11,10 +11,16 @@ from datastar_py import ServerSentEventGenerator as SSE from datastar_py.quart import DatastarResponse, read_signals from datastar_py.sse import DatastarEvent from htpy import Renderable +from peewee import IntegrityError from quart import Quart, Response, request, url_for from repub.datastar import RefreshBroker, render_stream -from repub.model import initialize_database +from repub.model import ( + create_source, + initialize_database, + load_sources, + source_slug_exists, +) from repub.pages import ( create_source_page, dashboard_page, @@ -23,18 +29,44 @@ from repub.pages import ( shim_page, sources_page, ) -from repub.pages.sources import ( - DEFAULT_SOURCES, - PANGEA_CONTENT_FORMATS, - PANGEA_CONTENT_TYPES, -) +from repub.pages.sources import PANGEA_CONTENT_FORMATS, PANGEA_CONTENT_TYPES REFRESH_BROKER_KEY = "repub.refresh_broker" -SOURCES_KEY = "repub.sources" RenderFunction = Callable[[], Awaitable[Renderable]] +class SourceFormData(TypedDict): + name: str + slug: str + source_type: str + notes: str + spider_arguments: str + enabled: bool + cron_minute: str + cron_hour: str + cron_day_of_month: str + cron_day_of_week: str + cron_month: str + feed_url: str + pangea_domain: str + pangea_category: str + content_format: str + content_type: str + max_articles: int | None + oldest_article: int | None + only_newest: bool + include_authors: bool + exclude_media: bool + include_content: bool + + +DEFAULT_PANGEA_CONTENT_FORMAT = "MOBILE_3" +DEFAULT_PANGEA_CONTENT_TYPE = "articles" +DEFAULT_PANGEA_MAX_ARTICLES = "10" +DEFAULT_PANGEA_OLDEST_ARTICLE = "3" + + def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]: head = ( h.title["Republisher Admin UI"], @@ -49,7 +81,6 @@ def create_app() -> Quart: app = Quart(__name__) app.config["REPUB_DB_PATH"] = str(initialize_database()) app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() - app.extensions[SOURCES_KEY] = _default_sources_dict() @app.get("/") @app.get("/sources") @@ -90,7 +121,7 @@ def create_app() -> Quart: signals = cast(dict[str, object], await read_signals()) source, error = validate_source_form( signals, - existing_sources=get_sources_dict(app), + slug_exists=source_slug_exists, ) if error is not None: return DatastarResponse( @@ -98,7 +129,14 @@ def create_app() -> Quart: ) assert source is not None - get_sources_dict(app)[str(source["slug"])] = source + try: + create_source(**source) + except IntegrityError: + return DatastarResponse( + SSE.patch_signals( + {"_formError": "Slug must be unique.", "_formSuccess": ""} + ) + ) trigger_refresh(app) return DatastarResponse(SSE.redirect("/sources")) @@ -128,12 +166,8 @@ async def render_dashboard() -> Renderable: return dashboard_page() -def get_sources_dict(app: Quart) -> dict[str, dict[str, object]]: - return cast(dict[str, dict[str, object]], app.extensions[SOURCES_KEY]) - - async def render_sources(app: Quart | None = None) -> Renderable: - sources = None if app is None else tuple(get_sources_dict(app).values()) + sources = None if app is None else load_sources() return sources_page(sources=sources) @@ -170,15 +204,11 @@ async def _unsubscribe_on_close( get_refresh_broker(app).unsubscribe(cast(asyncio.Queue[object], queue)) -def _default_sources_dict() -> dict[str, dict[str, object]]: - return {source["slug"]: dict(source) for source in DEFAULT_SOURCES} - - def validate_source_form( signals: dict[str, object] | None, *, - existing_sources: dict[str, dict[str, object]], -) -> tuple[dict[str, object] | None, str | None]: + slug_exists: Callable[[str], bool], +) -> tuple[SourceFormData | None, str | None]: if signals is None: return None, "Missing form data." @@ -193,7 +223,7 @@ def validate_source_form( max_articles = _read_string(signals, "maxArticles") oldest_article = _read_string(signals, "oldestArticle") source_notes = _read_string(signals, "sourceNotes") - spider_arguments = _read_string(signals, "spiderArguments") + spider_arguments = _normalize_multiline(_read_string(signals, "spiderArguments")) cron_minute = _read_string(signals, "cronMinute") cron_hour = _read_string(signals, "cronHour") cron_day_of_month = _read_string(signals, "cronDayOfMonth") @@ -205,7 +235,7 @@ def validate_source_form( errors.append("Source name is required.") if source_slug == "": errors.append("Slug is required.") - elif source_slug in existing_sources: + elif slug_exists(source_slug): errors.append("Slug must be unique.") if source_type not in {"feed", "pangea"}: @@ -218,6 +248,10 @@ def validate_source_form( errors.append("Feed URL must be a valid URL.") if source_type == "pangea": + content_format = content_format or DEFAULT_PANGEA_CONTENT_FORMAT + content_type = content_type or DEFAULT_PANGEA_CONTENT_TYPE + max_articles = max_articles or DEFAULT_PANGEA_MAX_ARTICLES + oldest_article = oldest_article or DEFAULT_PANGEA_OLDEST_ARTICLE if pangea_domain == "": errors.append("Pangea domain is required.") if pangea_category == "": @@ -245,33 +279,24 @@ def validate_source_form( return None, " ".join(errors) enabled = _read_bool(signals, "jobEnabled") - source = { + source: SourceFormData = { "name": source_name, "slug": source_slug, - "source_type": "Feed" if source_type == "feed" else "Pangea", - "upstream": ( - feed_url - if source_type == "feed" - else f"{pangea_domain} / {pangea_category}" - ), - "schedule": f"cron: {cron_minute} {cron_hour} {cron_day_of_month} {cron_month} {cron_day_of_week}", - "last_run": "Never run", - "state": "Enabled" if enabled else "Disabled", - "state_tone": "scheduled" if enabled else "idle", + "source_type": source_type, "notes": source_notes, "spider_arguments": spider_arguments, - "source_kind": source_type, "feed_url": feed_url, "pangea_domain": pangea_domain, "pangea_category": pangea_category, "content_format": content_format, "content_type": content_type, - "max_articles": max_articles, - "oldest_article": oldest_article, - "job_enabled": enabled, - "only_newest": _read_bool(signals, "onlyNewest"), - "include_authors": _read_bool(signals, "includeAuthors"), - "exclude_media": _read_bool(signals, "excludeMedia"), + "max_articles": _parse_int(max_articles), + "oldest_article": _parse_int(oldest_article), + "enabled": enabled, + "only_newest": _read_bool(signals, "onlyNewest", default=True), + "include_authors": _read_bool(signals, "includeAuthors", default=True), + "exclude_media": _read_bool(signals, "excludeMedia", default=False), + "include_content": _read_bool(signals, "includeContent", default=True), "cron_minute": cron_minute, "cron_hour": cron_hour, "cron_day_of_month": cron_day_of_month, @@ -285,8 +310,8 @@ def _read_string(signals: dict[str, object], key: str) -> str: return str(signals.get(key, "")).strip() -def _read_bool(signals: dict[str, object], key: str) -> bool: - value = signals.get(key, False) +def _read_bool(signals: dict[str, object], key: str, *, default: bool = False) -> bool: + value = signals.get(key, default) if isinstance(value, bool): return value if isinstance(value, str): @@ -294,6 +319,10 @@ def _read_bool(signals: dict[str, object], key: str) -> bool: return bool(value) +def _normalize_multiline(value: str) -> str: + return value.replace("\r\n", "\n").replace("\r", "\n") + + def _parse_int(value: str) -> int | None: try: return int(value) diff --git a/tests/test_model.py b/tests/test_model.py index b27bf8d..2df0b8f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -53,7 +53,6 @@ def test_initialize_database_bootstraps_schema_from_sql_files(tmp_path: Path) -> assert table_names == { "job", "job_execution", - "settings", "source", "source_feed", "source_pangea", diff --git a/tests/test_web.py b/tests/test_web.py index 9f0475f..b866934 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -5,10 +5,10 @@ from pathlib import Path from typing import Any, cast from repub.datastar import RefreshBroker, render_sse_event, render_stream +from repub.model import Job, Source, SourceFeed, SourcePangea from repub.web import ( create_app, get_refresh_broker, - get_sources_dict, render_create_source, render_dashboard, render_execution_logs, @@ -161,8 +161,8 @@ def test_render_sources_shows_table_and_create_link() -> None: assert "Configured feed and Pangea sources live here as tables" in body assert ">Sources<" in body assert 'href="/sources/create"' in body - assert "guardian-feed" in body - assert "podcast-audio" in body + assert "guardian-feed" not in body + assert "podcast-audio" not in body asyncio.run(run()) @@ -181,17 +181,37 @@ def test_render_create_source_shows_dedicated_form_page() -> None: assert "onlyNewest" in body assert "includeAuthors" in body assert "excludeMedia" in body + assert "includeContent" in body assert "TEXT_ONLY" in body assert "breakingnews" in body assert "Pangea domain" in body assert "Feed URL" in body assert "Cron schedule" in body assert "Initial job state" in body + assert "Pangea mobile articles" not in body + assert "pangea-mobile" not in body + assert "guardianproject.info" not in body + assert ( + "Primary Pangea mobile article mirror for the operator landing page." + not in body + ) + assert "language=en,download_media=true" not in body + assert "language=en\ndownload_media=true" in body + assert 'value="articles"' in body + assert 'value="10"' in body + assert 'value="3"' in body + assert 'value="*/30"' in body + assert 'value="*"' in body asyncio.run(run()) -def test_create_source_action_adds_new_source_to_in_memory_store() -> None: +def test_create_source_action_creates_pangea_source_and_job_in_database( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "sources.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + async def run() -> None: app = create_app() client = app.test_client() @@ -210,7 +230,7 @@ def test_create_source_action_adds_new_source_to_in_memory_store() -> None: "maxArticles": "12", "oldestArticle": "5", "sourceNotes": "Regional health alerts.", - "spiderArguments": "language=en", + "spiderArguments": "language=en\ndownload_media=true", "cronMinute": "0", "cronHour": "*/6", "cronDayOfMonth": "*", @@ -226,17 +246,89 @@ def test_create_source_action_adds_new_source_to_in_memory_store() -> None: assert response.status_code == 200 assert "window.location = '/sources'" in body - assert "kenya-health" in get_sources_dict(app) - assert get_sources_dict(app)["kenya-health"]["content_type"] == "breakingnews" + + source = Source.get(Source.slug == "kenya-health") + pangea = SourcePangea.get(SourcePangea.source == source) + job = Job.get(Job.source == source) + rendered_sources = str(await render_sources(app)) + + assert source.name == "Kenya health desk" + assert source.source_type == "pangea" + assert pangea.content_type == "breakingnews" + assert pangea.include_content is True + assert job.enabled is True + assert job.spider_arguments == "language=en\ndownload_media=true" + assert job.cron_hour == "*/6" + assert "kenya-health" in rendered_sources + assert "example.org / Health" in rendered_sources + assert "Enabled" in rendered_sources asyncio.run(run()) -def test_create_source_action_validates_duplicate_slug_and_pangea_type() -> None: +def test_create_source_action_creates_feed_source_and_job_in_database( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "feed-sources.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + async def run() -> None: app = create_app() client = app.test_client() + response = await client.post( + "/actions/sources/create", + headers={"Datastar-Request": "true"}, + json={ + "sourceName": "NASA feed", + "sourceSlug": "nasa-feed", + "sourceType": "feed", + "feedUrl": "https://www.nasa.gov/rss/dyn/breaking_news.rss", + "sourceNotes": "Primary NASA mirror.", + "spiderArguments": "", + "cronMinute": "30", + "cronHour": "*", + "cronDayOfMonth": "*", + "cronDayOfWeek": "*", + "cronMonth": "*", + "jobEnabled": False, + }, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert "window.location = '/sources'" in body + + source = Source.get(Source.slug == "nasa-feed") + feed = SourceFeed.get(SourceFeed.source == source) + job = Job.get(Job.source == source) + rendered_sources = str(await render_sources(app)) + + assert source.source_type == "feed" + assert feed.feed_url == "https://www.nasa.gov/rss/dyn/breaking_news.rss" + assert job.enabled is False + assert "nasa-feed" in rendered_sources + assert "https://www.nasa.gov/rss/dyn/breaking_news.rss" in rendered_sources + assert "Disabled" in rendered_sources + + asyncio.run(run()) + + +def test_create_source_action_validates_duplicate_slug_and_pangea_type( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "duplicate.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + Source.create( + name="Guardian feed mirror", + slug="guardian-feed", + source_type="feed", + ) + client = app.test_client() + response = await client.post( "/actions/sources/create", headers={"Datastar-Request": "true"}, @@ -265,9 +357,7 @@ def test_create_source_action_validates_duplicate_slug_and_pangea_type() -> None assert "Content format is invalid." in body assert "Content type is invalid." in body assert "Max articles must be an integer." in body - assert "Duplicate guardian" not in { - str(source["name"]) for source in get_sources_dict(app).values() - } + assert Source.select().where(Source.name == "Duplicate guardian").count() == 0 asyncio.run(run()) From 328a70ff9be675951a2e9478bd7c34a9884da380 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 13:49:00 +0200 Subject: [PATCH 09/23] edit sources --- AGENTS.md | 2 + repub/components.py | 12 ++- repub/model.py | 141 +++++++++++++++++++++++++++ repub/pages/__init__.py | 3 +- repub/pages/sources.py | 129 ++++++++++++++++++------ repub/web.py | 52 +++++++++- tests/test_web.py | 211 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 512 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ccde1e..39c43e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,3 +112,5 @@ uv run repub crawl -c repub.toml - Runtime ffmpeg availability is provided by the flake package and devshell. - Tests live under `tests/`. - `prompts/` is git ignored intentionally +- Treat the repo-root `republisher.db` as user-owned local state. Do not delete or reset it as part of routine testing or verification. +- For automated tests or isolated verification, use a separate database path via `REPUBLISHER_DB_PATH` instead of mutating or removing the repo-root database. diff --git a/repub/components.py b/repub/components.py index ae60aad..48af6d6 100644 --- a/repub/components.py +++ b/repub/components.py @@ -273,7 +273,16 @@ def input_field( placeholder: str = "", help_text: str | None = None, signal_name: str | None = None, + disabled: bool = False, ) -> Renderable: + class_name = ( + "mt-2 block w-full rounded-2xl border-0 px-3.5 py-2.5 text-sm shadow-sm ring-1 " + + ( + "cursor-not-allowed bg-slate-100 text-slate-500 ring-slate-200" + if disabled + else "bg-white text-slate-900 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500" + ) + ) return h.div[ h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ label @@ -285,7 +294,8 @@ def input_field( type="text", value=value, placeholder=placeholder, - class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500", + disabled=disabled, + class_=class_name, ), help_text and h.p(class_="mt-2 text-xs text-slate-500")[help_text], ] diff --git a/repub/model.py b/repub/model.py index 60c4bb4..de62329 100644 --- a/repub/model.py +++ b/repub/model.py @@ -89,6 +89,59 @@ def source_slug_exists(slug: str) -> bool: return Source.select().where(Source.slug == slug).exists() +def load_source_form(slug: str) -> dict[str, object] | None: + with database.connection_context(): + source = Source.get_or_none(Source.slug == slug) + if source is None: + return None + + job = Job.get(Job.source == source) + form_data: dict[str, object] = { + "name": source.name, + "slug": source.slug, + "source_type": source.source_type, + "notes": source.notes, + "spider_arguments": job.spider_arguments, + "enabled": job.enabled, + "cron_minute": job.cron_minute, + "cron_hour": job.cron_hour, + "cron_day_of_month": job.cron_day_of_month, + "cron_day_of_week": job.cron_day_of_week, + "cron_month": job.cron_month, + "feed_url": "", + "pangea_domain": "", + "pangea_category": "", + "content_format": "MOBILE_3", + "content_type": "articles", + "max_articles": "10", + "oldest_article": "3", + "only_newest": True, + "include_authors": True, + "exclude_media": False, + "include_content": True, + } + if source.source_type == "feed": + feed = SourceFeed.get(SourceFeed.source == source) + form_data["feed_url"] = feed.feed_url + else: + pangea = SourcePangea.get(SourcePangea.source == source) + form_data.update( + { + "pangea_domain": pangea.domain, + "pangea_category": pangea.category_name, + "content_format": pangea.content_format, + "content_type": pangea.content_type, + "max_articles": str(pangea.max_articles), + "oldest_article": str(pangea.oldest_article), + "only_newest": pangea.only_newest, + "include_authors": pangea.include_authors, + "exclude_media": pangea.exclude_media, + "include_content": pangea.include_content, + } + ) + return form_data + + def create_source( *, name: str, @@ -154,6 +207,94 @@ def create_source( return source +def update_source( + source_slug: str, + *, + name: str, + slug: str, + source_type: str, + notes: str, + spider_arguments: str, + enabled: bool, + cron_minute: str, + cron_hour: str, + cron_day_of_month: str, + cron_day_of_week: str, + cron_month: str, + feed_url: str = "", + pangea_domain: str = "", + pangea_category: str = "", + content_type: str = "", + only_newest: bool = True, + max_articles: int | None = None, + oldest_article: int | None = None, + include_authors: bool = True, + exclude_media: bool = False, + include_content: bool = True, + content_format: str = "", +) -> Source | None: + with database.connection_context(): + with database.atomic(): + source = Source.get_or_none(Source.slug == source_slug) + if source is None: + return None + + source.name = name + source.notes = notes + source.source_type = source_type + source.save() + + job = Job.get(Job.source == source) + job.enabled = enabled + job.spider_arguments = spider_arguments + job.cron_minute = cron_minute + job.cron_hour = cron_hour + job.cron_day_of_month = cron_day_of_month + job.cron_day_of_week = cron_day_of_week + job.cron_month = cron_month + job.save() + + if source_type == "feed": + SourcePangea.delete().where(SourcePangea.source == source).execute() + feed = SourceFeed.get_or_none(SourceFeed.source == source) + if feed is None: + SourceFeed.create(source=source, feed_url=feed_url) + else: + feed.feed_url = feed_url + feed.save() + else: + SourceFeed.delete().where(SourceFeed.source == source).execute() + pangea = SourcePangea.get_or_none(SourcePangea.source == source) + if pangea is None: + SourcePangea.create( + source=source, + domain=pangea_domain, + category_name=pangea_category, + content_type=content_type, + only_newest=only_newest, + max_articles=max_articles, + oldest_article=oldest_article, + include_authors=include_authors, + exclude_media=exclude_media, + include_content=include_content, + content_format=content_format, + ) + else: + pangea.domain = pangea_domain + pangea.category_name = pangea_category + pangea.content_type = content_type + pangea.only_newest = only_newest + pangea.max_articles = max_articles + pangea.oldest_article = oldest_article + pangea.include_authors = include_authors + pangea.exclude_media = exclude_media + pangea.include_content = include_content + pangea.content_format = content_format + pangea.save() + + return source + + def load_sources() -> tuple[dict[str, object], ...]: with database.connection_context(): sources = tuple(Source.select().order_by(Source.created_at.desc())) diff --git a/repub/pages/__init__.py b/repub/pages/__init__.py index 5428e35..55612a1 100644 --- a/repub/pages/__init__.py +++ b/repub/pages/__init__.py @@ -1,11 +1,12 @@ from repub.pages.dashboard import dashboard_page from repub.pages.runs import execution_logs_page, runs_page from repub.pages.shim import shim_page -from repub.pages.sources import create_source_page, sources_page +from repub.pages.sources import create_source_page, edit_source_page, sources_page __all__ = [ "create_source_page", "dashboard_page", + "edit_source_page", "execution_logs_page", "runs_page", "shim_page", diff --git a/repub/pages/sources.py b/repub/pages/sources.py index 2da44f9..f8ba517 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -41,6 +41,19 @@ PANGEA_CONTENT_TYPES = ( ) +def _value(source: Mapping[str, object] | None, key: str, default: str = "") -> str: + if source is None: + return default + return str(source.get(key, default)) + + +def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bool: + if source is None: + return default + value = source.get(key, default) + return bool(value) + + def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ @@ -64,7 +77,9 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: h.p(class_="mt-2 text-xs text-slate-500")[str(source["last_run"])], ], h.div(class_="flex flex-nowrap items-center gap-3")[ - inline_link(href="/sources/create", label="Edit", tone="amber"), + inline_link( + href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber" + ), inline_link(href="/runs", label="View runs"), ], ) @@ -97,7 +112,27 @@ def sources_page( ) -def create_source_form(*, action_path: str = "/actions/sources/create") -> Renderable: +def source_form( + *, + mode: str, + action_path: str, + source: Mapping[str, object] | None = None, +) -> Renderable: + source_type = _value(source, "source_type", "pangea") + slug = _value(source, "slug") + title = "Source and job setup" if mode == "create" else "Edit source" + eyebrow = "Create" if mode == "create" else "Edit" + description = ( + "Create the source and its paired job record." + if mode == "create" + else "Update the existing source and its paired job record." + ) + status_label = "New source" if mode == "create" else "Existing source" + submit_label = "Create source" if mode == "create" else "Save changes" + initial_signals = "{sourceType: 'pangea'}" + if mode == "edit": + initial_signals = f"{{sourceType: '{source_type}', sourceSlug: '{slug}'}}" + return section_card( content=( h.div( @@ -106,20 +141,16 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende h.div[ h.p( class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" - )["Create"], - h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ - "Source and job setup" - ], - h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[ - "The create flow lives on its own page and creates the source plus its paired job record. This pass is visual only, but the fields already reflect the intended shape." - ], + )[eyebrow], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[title], + h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[description], ], - status_badge(label="New source", tone="scheduled"), + status_badge(label=status_label, tone="scheduled"), ], h.form( { "data-signals": "{_formError: '', _formSuccess: ''}", - "data-signals__ifmissing": "{sourceType: 'pangea'}", + "data-signals__ifmissing": initial_signals, "data-on:submit": f"@post('{action_path}')", }, class_="mt-5 space-y-6", @@ -142,13 +173,16 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Source name", field_id="source-name", + value=_value(source, "name"), signal_name="sourceName", ), input_field( label="Slug", field_id="source-slug", + value=slug, help_text="Immutable after creation.", signal_name="sourceSlug", + disabled=mode == "edit", ), h.div[ h.label( @@ -161,8 +195,12 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende name="source-type", class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 focus:outline-hidden focus:ring-2 focus:ring-amber-500", )[ - h.option(value="feed")["feed"], - h.option(value="pangea", selected=True)["pangea"], + h.option(value="feed", selected=source_type == "feed")[ + "feed" + ], + h.option(value="pangea", selected=source_type == "pangea")[ + "pangea" + ], ], ], ], @@ -185,6 +223,7 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Feed URL", field_id="feed-url", + value=_value(source, "feed_url"), placeholder="https://example.com/feed.xml", signal_name="feedUrl", ), @@ -209,37 +248,39 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Pangea domain", field_id="pangea-domain", + value=_value(source, "pangea_domain"), signal_name="pangeaDomain", ), input_field( label="Category name", field_id="pangea-category", + value=_value(source, "pangea_category"), signal_name="pangeaCategory", ), select_field( label="Content format", field_id="content-format", options=PANGEA_CONTENT_FORMATS, - selected="MOBILE_3", + selected=_value(source, "content_format", "MOBILE_3"), signal_name="contentFormat", ), select_field( label="Content type", field_id="content-type", options=PANGEA_CONTENT_TYPES, - selected="articles", + selected=_value(source, "content_type", "articles"), signal_name="contentType", ), input_field( label="Max articles", field_id="max-articles", - value="10", + value=_value(source, "max_articles", "10"), signal_name="maxArticles", ), input_field( label="Oldest article (days)", field_id="oldest-article", - value="3", + value=_value(source, "oldest_article", "3"), signal_name="oldestArticle", ), ], @@ -248,25 +289,25 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende label="Only newest", description="Limit Pangea syncs to the newest material available in the selected category.", signal_name="onlyNewest", - checked=True, + checked=_checked(source, "only_newest", True), ), toggle_field( label="Include authors", description="Carry author bylines into mirrored output where upstream data exists.", signal_name="includeAuthors", - checked=True, + checked=_checked(source, "include_authors", True), ), toggle_field( label="Exclude media", description="Skip image and media attachment mirroring for this source.", signal_name="excludeMedia", - checked=False, + checked=_checked(source, "exclude_media", False), ), toggle_field( label="Include content", description="Store article body content in mirrored output when the upstream provides it.", signal_name="includeContent", - checked=True, + checked=_checked(source, "include_content", True), ), ], ], @@ -274,13 +315,17 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende textarea_field( label="Notes", field_id="source-notes", - value="", + value=_value(source, "notes"), signal_name="sourceNotes", ), textarea_field( label="Spider arguments", field_id="spider-arguments", - value="language=en\ndownload_media=true", + value=_value( + source, + "spider_arguments", + "language=en\ndownload_media=true", + ), signal_name="spiderArguments", ), ], @@ -300,31 +345,31 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Minute", field_id="cron-minute", - value="*/30", + value=_value(source, "cron_minute", "*/30"), signal_name="cronMinute", ), input_field( label="Hour", field_id="cron-hour", - value="*", + value=_value(source, "cron_hour", "*"), signal_name="cronHour", ), input_field( label="Day of month", field_id="cron-day-of-month", - value="*", + value=_value(source, "cron_day_of_month", "*"), signal_name="cronDayOfMonth", ), input_field( label="Day of week", field_id="cron-day-of-week", - value="*", + value=_value(source, "cron_day_of_week", "*"), signal_name="cronDayOfWeek", ), input_field( label="Month", field_id="cron-month", - value="*", + value=_value(source, "cron_month", "*"), signal_name="cronMonth", ), ], @@ -341,7 +386,7 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende label="Job enabled", description="Scheduler will consider the new job immediately after creation.", signal_name="jobEnabled", - checked=True, + checked=_checked(source, "enabled", True), ), ], ], @@ -353,7 +398,7 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende h.button( type="submit", class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800", - )["Create source"], + )[submit_label], ], ], ) @@ -369,7 +414,27 @@ def create_source_page(*, action_path: str = "/actions/sources/create") -> Rende current_path="/sources/create", eyebrow="Source creation", title="Create source", - description="Dedicated create page for the source form. The list page stays focused on scanning existing sources, while this page handles the new source and job configuration flow.", + description="Create a new source and its paired job configuration.", actions=actions, - content=create_source_form(action_path=action_path), + content=source_form(mode="create", action_path=action_path), + ) + + +def edit_source_page( + *, + slug: str, + source: Mapping[str, object], + action_path: str, +) -> Renderable: + actions = ( + muted_action_link(href="/sources", label="Back to sources"), + header_action_link(href="/runs", label="View runs"), + ) + return page_shell( + current_path=f"/sources/{slug}/edit", + eyebrow="Source editing", + title="Edit source", + description="Update an existing source and its paired job configuration.", + actions=actions, + content=source_form(mode="edit", action_path=action_path, source=source), ) diff --git a/repub/web.py b/repub/web.py index 350de6a..fdb24fa 100644 --- a/repub/web.py +++ b/repub/web.py @@ -18,12 +18,15 @@ from repub.datastar import RefreshBroker, render_stream from repub.model import ( create_source, initialize_database, + load_source_form, load_sources, source_slug_exists, + update_source, ) from repub.pages import ( create_source_page, dashboard_page, + edit_source_page, execution_logs_page, runs_page, shim_page, @@ -85,12 +88,15 @@ def create_app() -> Quart: @app.get("/") @app.get("/sources") @app.get("/sources/create") + @app.get("/sources//edit") @app.get("/runs") @app.get("/job//execution//logs") async def page_shim( - job_id: int | None = None, execution_id: int | None = None + slug: str | None = None, + job_id: int | None = None, + execution_id: int | None = None, ) -> Response: - del job_id, execution_id + del slug, job_id, execution_id body, etag = _render_shim_page( stylesheet_href=url_for("static", filename="app.css"), datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), @@ -116,6 +122,10 @@ def create_app() -> Quart: async def create_source_patch() -> DatastarResponse: return _page_patch_response(app, lambda: render_create_source(app)) + @app.post("/sources//edit") + async def edit_source_patch(slug: str) -> DatastarResponse: + return _page_patch_response(app, lambda: render_edit_source(slug)) + @app.post("/actions/sources/create") async def create_source_action() -> DatastarResponse: signals = cast(dict[str, object], await read_signals()) @@ -140,6 +150,30 @@ def create_app() -> Quart: trigger_refresh(app) return DatastarResponse(SSE.redirect("/sources")) + @app.post("/actions/sources//edit") + 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": ""} + ) + ) + trigger_refresh(app) + return DatastarResponse(SSE.redirect("/sources")) + @app.post("/runs") async def runs_patch() -> DatastarResponse: return _page_patch_response(app, render_runs) @@ -176,6 +210,17 @@ async def render_create_source(app: Quart | None = None) -> Renderable: return create_source_page() +async def render_edit_source(slug: str) -> Renderable: + source = load_source_form(slug) + if source is None: + return sources_page(sources=()) + return edit_source_page( + slug=slug, + source=source, + action_path=f"/actions/sources/{slug}/edit", + ) + + async def render_runs() -> Renderable: return runs_page() @@ -208,6 +253,7 @@ def validate_source_form( signals: dict[str, object] | None, *, slug_exists: Callable[[str], bool], + immutable_slug: str | None = None, ) -> tuple[SourceFormData | None, str | None]: if signals is None: return None, "Missing form data." @@ -235,6 +281,8 @@ def validate_source_form( errors.append("Source name is required.") if source_slug == "": errors.append("Slug is required.") + elif immutable_slug is not None and source_slug != immutable_slug: + errors.append("Slug is immutable.") elif slug_exists(source_slug): errors.append("Slug must be unique.") diff --git a/tests/test_web.py b/tests/test_web.py index b866934..3952930 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -5,12 +5,13 @@ from pathlib import Path from typing import Any, cast from repub.datastar import RefreshBroker, render_sse_event, render_stream -from repub.model import Job, Source, SourceFeed, SourcePangea +from repub.model import Job, Source, SourceFeed, SourcePangea, create_source from repub.web import ( create_app, get_refresh_broker, render_create_source, render_dashboard, + render_edit_source, render_execution_logs, render_runs, render_sources, @@ -171,7 +172,7 @@ def test_render_create_source_shows_dedicated_form_page() -> None: async def run() -> None: body = str(await render_create_source()) - assert "Dedicated create page for the source form" in body + assert "Create a new source and its paired job configuration." in body assert "Source and job setup" in body assert "data-signals__ifmissing" in body assert "/actions/sources/create" in body @@ -206,6 +207,55 @@ def test_render_create_source_shows_dedicated_form_page() -> None: asyncio.run(run()) +def test_render_edit_source_shows_existing_values(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "edit-page.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + create_app() + create_source( + name="Kenya health desk", + slug="kenya-health", + source_type="pangea", + notes="Regional health alerts.", + spider_arguments="language=en\ndownload_media=true", + enabled=True, + cron_minute="0", + cron_hour="*/6", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + pangea_domain="example.org", + pangea_category="Health", + content_type="breakingnews", + only_newest=True, + max_articles=12, + oldest_article=5, + include_authors=True, + exclude_media=False, + include_content=True, + content_format="MOBILE_3", + ) + + async def run() -> None: + body = str(await render_edit_source("kenya-health")) + + assert "Edit source" in body + assert "/actions/sources/kenya-health/edit" in body + assert "Kenya health desk" in body + assert "kenya-health" in body + assert 'id="source-slug"' in body + assert ( + 'id="source-slug" name="source-slug" type="text" value="kenya-health"' + in body + ) + assert " disabled " in body + assert "cursor-not-allowed bg-slate-100 text-slate-500" in body + assert "example.org" in body + assert "Health" in body + assert "language=en\ndownload_media=true" in body + + asyncio.run(run()) + + def test_create_source_action_creates_pangea_source_and_job_in_database( monkeypatch, tmp_path: Path ) -> None: @@ -314,6 +364,163 @@ def test_create_source_action_creates_feed_source_and_job_in_database( asyncio.run(run()) +def test_edit_source_action_updates_existing_source_and_job_in_database( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "edit-source.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + create_app() + create_source( + name="Kenya health desk", + slug="kenya-health", + source_type="pangea", + notes="Regional health alerts.", + spider_arguments="language=en\ndownload_media=true", + enabled=True, + cron_minute="0", + cron_hour="*/6", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + pangea_domain="example.org", + pangea_category="Health", + content_type="breakingnews", + only_newest=True, + max_articles=12, + oldest_article=5, + include_authors=True, + exclude_media=False, + include_content=True, + content_format="MOBILE_3", + ) + + async def run() -> None: + app = create_app() + client = app.test_client() + + response = await client.post( + "/actions/sources/kenya-health/edit", + headers={"Datastar-Request": "true"}, + json={ + "sourceName": "Kenya health desk nightly", + "sourceSlug": "kenya-health", + "sourceType": "pangea", + "pangeaDomain": "example.org", + "pangeaCategory": "Nightly", + "contentFormat": "TEXT_ONLY", + "contentType": "articles", + "maxArticles": "25", + "oldestArticle": "7", + "sourceNotes": "Updated nightly run.", + "spiderArguments": "language=sw\ninclude_audio=false", + "cronMinute": "15", + "cronHour": "2", + "cronDayOfMonth": "*", + "cronDayOfWeek": "*", + "cronMonth": "*", + "jobEnabled": False, + "onlyNewest": False, + "includeAuthors": False, + "excludeMedia": True, + "includeContent": True, + }, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert "window.location = '/sources'" in body + + source = Source.get(Source.slug == "kenya-health") + pangea = SourcePangea.get(SourcePangea.source == source) + job = Job.get(Job.source == source) + rendered_sources = str(await render_sources(app)) + + assert source.name == "Kenya health desk nightly" + assert source.notes == "Updated nightly run." + assert pangea.category_name == "Nightly" + assert pangea.content_format == "TEXT_ONLY" + assert pangea.max_articles == 25 + assert pangea.include_authors is False + assert pangea.exclude_media is True + assert job.enabled is False + assert job.spider_arguments == "language=sw\ninclude_audio=false" + assert job.cron_hour == "2" + assert "Kenya health desk nightly" in rendered_sources + assert "example.org / Nightly" in rendered_sources + assert "Disabled" in rendered_sources + + asyncio.run(run()) + + +def test_edit_source_action_rejects_slug_changes(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "edit-invalid.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + create_app() + create_source( + name="Kenya health desk", + slug="kenya-health", + source_type="pangea", + notes="Regional health alerts.", + spider_arguments="language=en\ndownload_media=true", + enabled=True, + cron_minute="0", + cron_hour="*/6", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + pangea_domain="example.org", + pangea_category="Health", + content_type="breakingnews", + only_newest=True, + max_articles=12, + oldest_article=5, + include_authors=True, + exclude_media=False, + include_content=True, + content_format="MOBILE_3", + ) + + async def run() -> None: + app = create_app() + client = app.test_client() + + response = await client.post( + "/actions/sources/kenya-health/edit", + headers={"Datastar-Request": "true"}, + json={ + "sourceName": "Kenya health desk", + "sourceSlug": "kenya-health-renamed", + "sourceType": "pangea", + "pangeaDomain": "example.org", + "pangeaCategory": "Health", + "contentFormat": "MOBILE_3", + "contentType": "breakingnews", + "maxArticles": "12", + "oldestArticle": "5", + "sourceNotes": "Regional health alerts.", + "spiderArguments": "language=en\ndownload_media=true", + "cronMinute": "0", + "cronHour": "*/6", + "cronDayOfMonth": "*", + "cronDayOfWeek": "*", + "cronMonth": "*", + "jobEnabled": True, + "onlyNewest": True, + "includeAuthors": True, + "excludeMedia": False, + "includeContent": True, + }, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Slug is immutable." in body + assert Source.get(Source.slug == "kenya-health").name == "Kenya health desk" + assert Source.select().where(Source.slug == "kenya-health-renamed").count() == 0 + + asyncio.run(run()) + + def test_create_source_action_validates_duplicate_slug_and_pangea_type( monkeypatch, tmp_path: Path ) -> None: From 2b2a3f1cc07f08b1970f96ee8fafd9b91d5b93e2 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 14:02:39 +0200 Subject: [PATCH 10/23] implement job runner and scheduler --- repub/datastar.py | 30 +- repub/job_runner.py | 123 ++++++ repub/jobs.py | 643 ++++++++++++++++++++++++++++++++ repub/model.py | 11 + repub/pages/__init__.py | 3 +- repub/pages/dashboard.py | 85 +++-- repub/pages/runs.py | 370 ++++++++---------- repub/sql/001_initial.sql | 1 + repub/web.py | 131 ++++++- tests/test_scheduler_runtime.py | 346 +++++++++++++++++ tests/test_web.py | 113 +++++- 11 files changed, 1572 insertions(+), 284 deletions(-) create mode 100644 repub/job_runner.py create mode 100644 repub/jobs.py create mode 100644 tests/test_scheduler_runtime.py diff --git a/repub/datastar.py b/repub/datastar.py index 2ae30a8..d11efe5 100644 --- a/repub/datastar.py +++ b/repub/datastar.py @@ -19,27 +19,31 @@ RenderFunction = Callable[[], Awaitable[RenderResult]] class RefreshBroker: def __init__(self) -> None: - self._subscribers: set[asyncio.Queue[object]] = set() + self._subscribers: dict[asyncio.Queue[object], asyncio.AbstractEventLoop] = {} def subscribe(self) -> asyncio.Queue[object]: queue: asyncio.Queue[object] = asyncio.Queue(maxsize=1) - self._subscribers.add(queue) + self._subscribers[queue] = asyncio.get_running_loop() return queue def unsubscribe(self, queue: asyncio.Queue[object]) -> None: - self._subscribers.discard(queue) + self._subscribers.pop(queue, None) def publish(self, event: object = "refresh-event") -> None: - for queue in tuple(self._subscribers): - if queue.full(): - try: - queue.get_nowait() - except asyncio.QueueEmpty: - pass - try: - queue.put_nowait(event) - except asyncio.QueueFull: - continue + for queue, loop in tuple(self._subscribers.items()): + loop.call_soon_threadsafe(_publish_event, queue, event) + + +def _publish_event(queue: asyncio.Queue[object], event: object) -> None: + if queue.full(): + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + queue.put_nowait(event) + except asyncio.QueueFull: + return async def render_sse_event( diff --git a/repub/job_runner.py b/repub/job_runner.py new file mode 100644 index 0000000..61abacb --- /dev/null +++ b/repub/job_runner.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import argparse +import json +import random +import signal +import sys +import time +from datetime import UTC, datetime +from pathlib import Path + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Simulated republisher worker") + parser.add_argument("--job-id", type=int, required=True) + parser.add_argument("--execution-id", type=int, required=True) + parser.add_argument("--stats-path", required=True) + parser.add_argument("--duration-seconds", type=float, required=True) + parser.add_argument("--interval-seconds", type=float, required=True) + parser.add_argument("--failure-probability", type=float, required=True) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + rng = random.Random(f"{args.job_id}:{args.execution_id}") + stats_path = Path(args.stats_path) + stats_path.parent.mkdir(parents=True, exist_ok=True) + stop_requested = False + + def request_stop(signum: int, frame: object | None) -> None: + del signum, frame + nonlocal stop_requested + if stop_requested: + return + stop_requested = True + print( + f"worker[{args.job_id}:{args.execution_id}]: graceful stop requested", + flush=True, + ) + + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + + counters = { + "requests_count": 0, + "items_count": 0, + "warnings_count": 0, + "errors_count": 0, + "bytes_count": 0, + "retries_count": 0, + "exceptions_count": 0, + "cache_size_count": 0, + "cache_object_count": 0, + } + + print( + f"worker[{args.job_id}:{args.execution_id}]: starting simulated crawl", + flush=True, + ) + started = time.monotonic() + iteration = 0 + with stats_path.open("a", encoding="utf-8") as stats_file: + while time.monotonic() - started < args.duration_seconds: + time.sleep(args.interval_seconds) + iteration += 1 + counters["requests_count"] += rng.randint(1, 5) + counters["items_count"] += rng.randint(0, 2) + counters["bytes_count"] += rng.randint(500, 3000) + counters["cache_size_count"] += rng.randint(0, 1) + counters["cache_object_count"] += rng.randint(0, 2) + if rng.random() < 0.1: + counters["warnings_count"] += 1 + if rng.random() < 0.05: + counters["retries_count"] += 1 + + snapshot = { + "timestamp": datetime.now(UTC).isoformat(), + "iteration": iteration, + **counters, + } + stats_file.write(json.dumps(snapshot, sort_keys=True) + "\n") + stats_file.flush() + print( + "stats: " + f"requests={counters['requests_count']} " + f"items={counters['items_count']} " + f"bytes={counters['bytes_count']}", + flush=True, + ) + if stop_requested: + print( + f"worker[{args.job_id}:{args.execution_id}]: stopping after graceful request", + flush=True, + ) + return 130 + + if rng.random() < args.failure_probability: + counters["errors_count"] += 1 + counters["exceptions_count"] += 1 + stats_file.write( + json.dumps( + {"timestamp": datetime.now(UTC).isoformat(), **counters}, + sort_keys=True, + ) + + "\n" + ) + stats_file.flush() + print( + f"worker[{args.job_id}:{args.execution_id}]: simulated failure", + flush=True, + ) + return 1 + + print( + f"worker[{args.job_id}:{args.execution_id}]: completed successfully", + flush=True, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/repub/jobs.py b/repub/jobs.py new file mode 100644 index 0000000..6e306af --- /dev/null +++ b/repub/jobs.py @@ -0,0 +1,643 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Callable, TextIO, cast + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from repub.model import Job, JobExecution, JobExecutionStatus, Source, database, utc_now + +SCHEDULER_JOB_PREFIX = "job-" +POLL_JOB_ID = "runtime-poll-workers" +SYNC_JOB_ID = "runtime-sync-jobs" + + +@dataclass(frozen=True) +class JobArtifacts: + log_path: Path + stats_path: Path + + @classmethod + def for_execution( + cls, *, log_dir: Path, job_id: int, execution_id: int + ) -> "JobArtifacts": + prefix = f"job-{job_id}-execution-{execution_id}" + return cls( + log_path=log_dir / f"{prefix}.log", + stats_path=log_dir / f"{prefix}.jsonl", + ) + + +@dataclass +class RunningWorker: + execution_id: int + process: subprocess.Popen[str] + log_handle: TextIO + artifacts: JobArtifacts + stats_offset: int = 0 + + +@dataclass(frozen=True) +class ExecutionLogView: + job_id: int + execution_id: int + title: str + description: str + status_label: str + status_tone: str + log_text: str + error_message: str | None = None + + +class JobRuntime: + def __init__( + self, + *, + log_dir: str | Path, + worker_duration_seconds: float = 20.0, + worker_stats_interval_seconds: float = 1.0, + worker_failure_probability: float = 0.3, + refresh_callback: Callable[[], None] | None = None, + graceful_stop_seconds: float = 15.0, + ) -> None: + self.log_dir = Path(log_dir) + self.worker_duration_seconds = worker_duration_seconds + self.worker_stats_interval_seconds = worker_stats_interval_seconds + self.worker_failure_probability = worker_failure_probability + self.refresh_callback = refresh_callback + self.graceful_stop_seconds = graceful_stop_seconds + self.scheduler = BackgroundScheduler(timezone=UTC) + self._workers: dict[int, RunningWorker] = {} + self._started = False + + def start(self) -> None: + if self._started: + return + + self.scheduler.start() + self.scheduler.add_job( + self.poll_workers, + "interval", + id=POLL_JOB_ID, + seconds=0.25, + replace_existing=True, + max_instances=1, + coalesce=True, + ) + self.scheduler.add_job( + self.sync_jobs, + "interval", + id=SYNC_JOB_ID, + seconds=1, + replace_existing=True, + max_instances=1, + coalesce=True, + ) + self.sync_jobs() + self._started = True + + def shutdown(self) -> None: + for execution_id in tuple(self._workers): + worker = self._workers.pop(execution_id) + if worker.process.poll() is None: + worker.process.kill() + worker.process.wait(timeout=2) + worker.log_handle.close() + + if self._started: + self.scheduler.shutdown(wait=False) + self._started = False + + def sync_jobs(self) -> None: + with database.connection_context(): + jobs = tuple(Job.select().where(Job.enabled == True)) # noqa: E712 + + desired_ids = set() + for job in jobs: + scheduler_job_id = _scheduler_job_id(_job_id(job)) + desired_ids.add(scheduler_job_id) + self.scheduler.add_job( + self.run_scheduled_job, + trigger=_job_trigger(job), + args=(_job_id(job),), + id=scheduler_job_id, + replace_existing=True, + max_instances=1, + coalesce=True, + misfire_grace_time=1, + ) + + for scheduled_job in tuple(self.scheduler.get_jobs()): + if ( + scheduled_job.id.startswith(SCHEDULER_JOB_PREFIX) + and scheduled_job.id not in desired_ids + ): + self.scheduler.remove_job(scheduled_job.id) + + def run_scheduled_job(self, job_id: int) -> None: + self.run_job_now(job_id, reason="scheduled") + + def run_job_now(self, job_id: int, *, reason: str) -> int | None: + del reason + self.start() + with database.connection_context(): + job = Job.get_or_none(id=job_id) + if job is None: + return None + + already_running = ( + JobExecution.select() + .where( + (JobExecution.job == job) + & (JobExecution.running_status == JobExecutionStatus.RUNNING) + ) + .exists() + ) + if already_running: + return None + + execution = JobExecution.create( + job=job, + started_at=utc_now(), + running_status=JobExecutionStatus.RUNNING, + ) + execution_id = _execution_id(execution) + + artifacts = JobArtifacts.for_execution( + log_dir=self.log_dir, job_id=job_id, execution_id=execution_id + ) + artifacts.log_path.parent.mkdir(parents=True, exist_ok=True) + log_handle = artifacts.log_path.open("a", encoding="utf-8", buffering=1) + log_handle.write( + f"scheduler: starting execution {execution_id} for job {job_id}\n" + ) + process = subprocess.Popen( + [ + sys.executable, + "-u", + "-m", + "repub.job_runner", + "--job-id", + str(job_id), + "--execution-id", + str(execution_id), + "--stats-path", + str(artifacts.stats_path), + "--duration-seconds", + str(self.worker_duration_seconds), + "--interval-seconds", + str(self.worker_stats_interval_seconds), + "--failure-probability", + str(self.worker_failure_probability), + ], + stdout=log_handle, + stderr=subprocess.STDOUT, + text=True, + ) + self._workers[execution_id] = RunningWorker( + execution_id=execution_id, + process=process, + log_handle=log_handle, + artifacts=artifacts, + ) + self._trigger_refresh() + return execution_id + + def request_execution_cancel(self, execution_id: int) -> bool: + with database.connection_context(): + execution = JobExecution.get_or_none(id=execution_id) + if execution is None: + return False + if execution.running_status != JobExecutionStatus.RUNNING: + return False + if execution.stop_requested_at is None: + execution.stop_requested_at = utc_now() + execution.save() + + worker = self._workers.get(execution_id) + if worker is not None and worker.process.poll() is None: + worker.log_handle.write( + f"scheduler: graceful stop requested for execution {execution_id}\n" + ) + worker.process.terminate() + + self._trigger_refresh() + return True + + def set_job_enabled(self, job_id: int, *, enabled: bool) -> bool: + with database.connection_context(): + job = Job.get_or_none(id=job_id) + if job is None: + return False + job.enabled = enabled + job.save() + self.sync_jobs() + self._trigger_refresh() + return True + + def poll_workers(self) -> None: + for execution_id in tuple(self._workers): + worker = self._workers[execution_id] + self._apply_stats(worker) + self._enforce_graceful_stop(worker) + returncode = worker.process.poll() + if returncode is None: + continue + + self._apply_stats(worker) + with database.connection_context(): + execution = JobExecution.get_by_id(execution_id) + execution.ended_at = utc_now() + execution.running_status = _final_status( + execution=execution, + returncode=returncode, + ) + execution.save() + + worker.log_handle.close() + del self._workers[execution_id] + self._trigger_refresh() + + def _apply_stats(self, worker: RunningWorker) -> None: + if not worker.artifacts.stats_path.exists(): + return + + with worker.artifacts.stats_path.open("r", encoding="utf-8") as handle: + handle.seek(worker.stats_offset) + payload = handle.read() + worker.stats_offset = handle.tell() + + lines = [line for line in payload.splitlines() if line.strip()] + if not lines: + return + + stats = json.loads(lines[-1]) + with database.connection_context(): + execution = JobExecution.get_by_id(worker.execution_id) + execution.requests_count = int(stats.get("requests_count", 0)) + execution.items_count = int(stats.get("items_count", 0)) + execution.warnings_count = int(stats.get("warnings_count", 0)) + execution.errors_count = int(stats.get("errors_count", 0)) + execution.bytes_count = int(stats.get("bytes_count", 0)) + execution.retries_count = int(stats.get("retries_count", 0)) + execution.exceptions_count = int(stats.get("exceptions_count", 0)) + execution.cache_size_count = int(stats.get("cache_size_count", 0)) + execution.cache_object_count = int(stats.get("cache_object_count", 0)) + execution.raw_stats = json.dumps(stats, sort_keys=True) + execution.save() + + self._trigger_refresh() + + def _enforce_graceful_stop(self, worker: RunningWorker) -> None: + with database.connection_context(): + execution = JobExecution.get_by_id(worker.execution_id) + if execution.stop_requested_at is None: + return + elapsed = utc_now() - _coerce_datetime(execution.stop_requested_at) + + if ( + elapsed >= timedelta(seconds=self.graceful_stop_seconds) + and worker.process.poll() is None + ): + worker.process.kill() + + def _trigger_refresh(self) -> None: + if self.refresh_callback is not None: + self.refresh_callback() + + +def load_runs_view( + *, log_dir: str | Path, now: datetime | None = None +) -> dict[str, tuple[dict[str, object], ...]]: + reference_time = now or datetime.now(UTC) + resolved_log_dir = Path(log_dir) + with database.connection_context(): + jobs = tuple(Job.select(Job, Source).join(Source).order_by(Source.name.asc())) + running_executions = tuple( + JobExecution.select(JobExecution, Job, Source) + .join(Job) + .join(Source) + .where(JobExecution.running_status == JobExecutionStatus.RUNNING) + .order_by(JobExecution.started_at.desc()) + ) + completed_executions = tuple( + JobExecution.select(JobExecution, Job, Source) + .join(Job) + .join(Source) + .where( + JobExecution.running_status.in_( + ( + JobExecutionStatus.SUCCEEDED, + JobExecutionStatus.FAILED, + JobExecutionStatus.CANCELED, + ) + ) + ) + .order_by(JobExecution.ended_at.desc()) + .limit(20) + ) + + running_by_job = { + _job_id(execution.job): execution for execution in running_executions + } + return { + "running": tuple( + _project_running_execution(execution, resolved_log_dir, reference_time) + for execution in running_executions + ), + "upcoming": tuple( + _project_upcoming_job(job, running_by_job.get(job.id), reference_time) + for job in jobs + ), + "completed": tuple( + _project_completed_execution(execution, resolved_log_dir) + for execution in completed_executions + ), + } + + +def load_dashboard_view( + *, log_dir: str | Path, now: datetime | None = None +) -> dict[str, object]: + reference_time = now or datetime.now(UTC) + runs_view = load_runs_view(log_dir=log_dir, now=reference_time) + with database.connection_context(): + failed_last_day = ( + JobExecution.select() + .where( + (JobExecution.running_status == JobExecutionStatus.FAILED) + & (JobExecution.ended_at.is_null(False)) + ) + .count() + ) + + upcoming_ready = sum( + 1 for job in runs_view["upcoming"] if str(job["run_reason"]) == "Ready" + ) + footprint_bytes = _directory_size(Path(log_dir)) + return { + "running": runs_view["running"], + "snapshot": { + "running_now": str(len(runs_view["running"])), + "upcoming_today": str(upcoming_ready), + "failures_24h": str(failed_last_day), + "artifact_footprint": _format_bytes(footprint_bytes), + }, + } + + +def load_execution_log_view( + *, log_dir: str | Path, job_id: int, execution_id: int +) -> ExecutionLogView: + with database.connection_context(): + execution = JobExecution.get_or_none(id=execution_id) + + route = f"/job/{job_id}/execution/{execution_id}/logs" + if execution is None or _job_id(cast(Job, execution.job)) != job_id: + return ExecutionLogView( + job_id=job_id, + execution_id=execution_id, + title=f"Job {job_id} / execution {execution_id}", + description="Plain text log view routed through the app.", + status_label="Unavailable", + status_tone="failed", + log_text="", + error_message="Execution does not exist.", + ) + + artifacts = JobArtifacts.for_execution( + log_dir=Path(log_dir), + job_id=job_id, + execution_id=execution_id, + ) + if not artifacts.log_path.exists(): + return ExecutionLogView( + job_id=job_id, + execution_id=execution_id, + title=f"Job {job_id} / execution {execution_id}", + description="Plain text log view routed through the app.", + status_label=_execution_status_label(execution), + status_tone=_execution_status_tone(execution), + log_text="", + error_message="Log file has not been created yet.", + ) + + return ExecutionLogView( + job_id=job_id, + execution_id=execution_id, + title=f"Job {job_id} / execution {execution_id}", + description=f"Route: {route}", + status_label=_execution_status_label(execution), + status_tone=_execution_status_tone(execution), + log_text=artifacts.log_path.read_text(encoding="utf-8"), + ) + + +def _job_trigger(job: Job) -> CronTrigger: + expression = " ".join( + ( + str(job.cron_minute), + str(job.cron_hour), + str(job.cron_day_of_month), + str(job.cron_month), + str(job.cron_day_of_week), + ) + ) + return CronTrigger.from_crontab(expression, timezone=UTC) + + +def _scheduler_job_id(job_id: int) -> str: + return f"{SCHEDULER_JOB_PREFIX}{job_id}" + + +def _project_running_execution( + execution: JobExecution, log_dir: Path, reference_time: datetime +) -> dict[str, object]: + job = cast(Job, execution.job) + job_id = _job_id(job) + execution_id = _execution_id(execution) + artifacts = JobArtifacts.for_execution( + log_dir=log_dir, job_id=job_id, execution_id=execution_id + ) + started_at = _coerce_datetime( + cast(datetime | str, execution.started_at or execution.created_at) + ) + runtime = reference_time - started_at + return { + "source": job.source.name, + "slug": job.source.slug, + "job_id": job_id, + "execution_id": execution_id, + "started_at": started_at.strftime("%Y-%m-%d %H:%M UTC"), + "runtime": f"running for {int(runtime.total_seconds())}s", + "status": "Stopping" if execution.stop_requested_at else "Running", + "stats": _stats_summary(execution), + "worker": ( + "graceful stop requested" + if execution.stop_requested_at + else "streaming stats from worker jsonl" + ), + "log_href": f"/job/{job_id}/execution/{execution_id}/logs", + "log_exists": artifacts.log_path.exists(), + "cancel_post_path": f"/actions/executions/{execution_id}/cancel", + } + + +def _project_upcoming_job( + job: Job, running_execution: JobExecution | None, reference_time: datetime +) -> dict[str, object]: + job_id = _job_id(job) + trigger = _job_trigger(job) + next_run = ( + trigger.get_next_fire_time(None, reference_time) + if job.enabled and running_execution is None + else None + ) + return { + "source": job.source.name, + "slug": job.source.slug, + "job_id": job_id, + "next_run": ( + next_run.strftime("%Y-%m-%d %H:%M UTC") + if next_run is not None + else ("Running now" if running_execution is not None else "Not scheduled") + ), + "schedule": " ".join( + ( + str(job.cron_minute), + str(job.cron_hour), + str(job.cron_day_of_month), + str(job.cron_month), + str(job.cron_day_of_week), + ) + ), + "enabled_label": "Enabled" if job.enabled else "Disabled", + "enabled_tone": "scheduled" if job.enabled else "idle", + "run_disabled": running_execution is not None, + "run_reason": "Already running" if running_execution is not None else "Ready", + "toggle_label": "Disable" if job.enabled else "Enable", + "toggle_enabled": not job.enabled, + "run_post_path": f"/actions/jobs/{job_id}/run-now", + "toggle_post_path": f"/actions/jobs/{job_id}/toggle-enabled", + "delete_post_path": f"/actions/jobs/{job_id}/delete", + } + + +def _project_completed_execution( + execution: JobExecution, log_dir: Path +) -> dict[str, object]: + job = cast(Job, execution.job) + job_id = _job_id(job) + execution_id = _execution_id(execution) + artifacts = JobArtifacts.for_execution( + log_dir=log_dir, job_id=job_id, execution_id=execution_id + ) + return { + "source": job.source.name, + "slug": job.source.slug, + "job_id": job_id, + "execution_id": execution_id, + "ended_at": ( + _coerce_datetime(cast(datetime | str, execution.ended_at)).strftime( + "%Y-%m-%d %H:%M UTC" + ) + if execution.ended_at is not None + else "Pending" + ), + "status": _execution_status_label(execution), + "status_tone": _execution_status_tone(execution), + "stats": _stats_summary(execution), + "summary": ( + "Canceled by operator" + if execution.running_status == JobExecutionStatus.CANCELED + else ( + "Worker exited successfully" + if execution.running_status == JobExecutionStatus.SUCCEEDED + else "Worker exited with failure" + ) + ), + "log_href": f"/job/{job_id}/execution/{execution_id}/logs", + "log_exists": artifacts.log_path.exists(), + } + + +def _execution_status_label(execution: JobExecution) -> str: + status = JobExecutionStatus(execution.running_status) + return { + JobExecutionStatus.PENDING: "Pending", + JobExecutionStatus.RUNNING: ( + "Stopping" if execution.stop_requested_at else "Running" + ), + JobExecutionStatus.SUCCEEDED: "Succeeded", + JobExecutionStatus.FAILED: "Failed", + JobExecutionStatus.CANCELED: "Canceled", + }[status] + + +def _execution_status_tone(execution: JobExecution) -> str: + status = JobExecutionStatus(execution.running_status) + return { + JobExecutionStatus.PENDING: "idle", + JobExecutionStatus.RUNNING: "running", + JobExecutionStatus.SUCCEEDED: "done", + JobExecutionStatus.FAILED: "failed", + JobExecutionStatus.CANCELED: "idle", + }[status] + + +def _stats_summary(execution: JobExecution) -> str: + return ( + f"{execution.requests_count} requests" + f" • {execution.items_count} items" + f" • {execution.bytes_count} bytes" + ) + + +def _final_status(*, execution: JobExecution, returncode: int) -> JobExecutionStatus: + if execution.stop_requested_at is not None: + return JobExecutionStatus.CANCELED + if returncode == 0: + return JobExecutionStatus.SUCCEEDED + return JobExecutionStatus.FAILED + + +def _coerce_datetime(value: datetime | str) -> datetime: + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _job_id(job: Job) -> int: + return int(job.get_id()) + + +def _execution_id(execution: JobExecution) -> int: + return int(execution.get_id()) + + +def _directory_size(path: Path) -> int: + if not path.exists(): + return 0 + return sum(entry.stat().st_size for entry in path.rglob("*") if entry.is_file()) + + +def _format_bytes(value: int) -> str: + if value < 1024: + return f"{value} B" + if value < 1024 * 1024: + return f"{value / 1024:.1f} KB" + if value < 1024 * 1024 * 1024: + return f"{value / (1024 * 1024):.1f} MB" + return f"{value / (1024 * 1024 * 1024):.1f} GB" diff --git a/repub/model.py b/repub/model.py index de62329..41d31d6 100644 --- a/repub/model.py +++ b/repub/model.py @@ -295,6 +295,16 @@ def update_source( return source +def delete_job_source(job_id: int) -> bool: + with database.connection_context(): + with database.atomic(): + job = Job.get_or_none(id=job_id) + if job is None: + return False + source = Source.get_by_id(job.source_id) + return source.delete_instance() > 0 + + def load_sources() -> tuple[dict[str, object], ...]: with database.connection_context(): sources = tuple(Source.select().order_by(Source.created_at.desc())) @@ -416,6 +426,7 @@ class JobExecution(BaseModel): created_at = DateTimeField(default=utc_now) started_at = DateTimeField(null=True) ended_at = DateTimeField(null=True) + stop_requested_at = DateTimeField(null=True) running_status = IntegerField( default=JobExecutionStatus.PENDING, constraints=[Check("running_status BETWEEN 0 AND 4")], diff --git a/repub/pages/__init__.py b/repub/pages/__init__.py index 55612a1..bc914b7 100644 --- a/repub/pages/__init__.py +++ b/repub/pages/__init__.py @@ -1,4 +1,4 @@ -from repub.pages.dashboard import dashboard_page +from repub.pages.dashboard import dashboard_page, dashboard_page_with_data from repub.pages.runs import execution_logs_page, runs_page from repub.pages.shim import shim_page from repub.pages.sources import create_source_page, edit_source_page, sources_page @@ -6,6 +6,7 @@ from repub.pages.sources import create_source_page, edit_source_page, sources_pa __all__ = [ "create_source_page", "dashboard_page", + "dashboard_page_with_data", "edit_source_page", "execution_logs_page", "runs_page", diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index 1267fba..67b93a8 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping + import htpy as h from htpy import Node, Renderable @@ -12,36 +14,43 @@ from repub.components import ( stat_card, status_badge, ) -from repub.pages.runs import RUNNING_EXECUTIONS -def _running_execution_row(execution: dict[str, str | bool]) -> tuple[Node, ...]: - status_tone = "running" if execution["is_running"] else "done" +def _text(values: Mapping[str, object], key: str) -> str: + return str(values[key]) + + +def _running_execution_row(execution: Mapping[str, object]) -> tuple[Node, ...]: + status_tone = "running" if _text(execution, "status") != "Succeeded" else "done" return ( h.div[ - h.div(class_="font-semibold text-slate-950")[execution["source"]], + h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[ - execution["slug"] + _text(execution, "slug") ], ], h.div[ - h.p(class_="font-medium text-slate-900")[f"#{execution['execution_id']}"], + h.p(class_="font-medium text-slate-900")[ + f"#{_text(execution, 'execution_id')}" + ], h.p(class_="mt-0.5 text-[11px] text-slate-500")[ - f"job {execution['job_id']}" + f"job {_text(execution, 'job_id')}" ], ], h.div[ - h.p(class_="font-medium text-slate-900")[execution["started_at"]], - h.p(class_="mt-0.5 text-[11px] text-slate-500")[execution["runtime"]], + h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")], + h.p(class_="mt-0.5 text-[11px] text-slate-500")[ + _text(execution, "runtime") + ], ], - status_badge(label=str(execution["status"]), tone=status_tone), + status_badge(label=_text(execution, "status"), tone=status_tone), h.div(class_="min-w-56 whitespace-normal")[ - h.p(class_="font-medium text-slate-900")[execution["stats"]], - h.p(class_="mt-0.5 text-[11px] text-slate-500")[execution["worker"]], + h.p(class_="font-medium text-slate-900")[_text(execution, "stats")], + h.p(class_="mt-0.5 text-[11px] text-slate-500")[_text(execution, "worker")], ], h.div(class_="flex flex-nowrap items-center gap-3")[ inline_link( - href=str(execution["log_href"]), + href=_text(execution, "log_href"), label="View log", tone="amber", ), @@ -71,7 +80,13 @@ def dashboard_header() -> Renderable: ] -def operational_snapshot() -> Renderable: +def operational_snapshot(*, snapshot: Mapping[str, str] | None = None) -> Renderable: + values = snapshot or { + "running_now": "0", + "upcoming_today": "0", + "failures_24h": "0", + "artifact_footprint": "0 B", + } return h.section[ h.div(class_="mb-3 flex items-end justify-between gap-4")[ h.div[ @@ -82,37 +97,39 @@ def operational_snapshot() -> Renderable: "Operational snapshot" ], ], - h.p(class_="text-xs text-slate-500")[ - "Static fixture data shaped around the intended operator dashboard" - ], + h.p(class_="text-xs text-slate-500")["Live values from the database"], ], h.dl(class_="grid gap-3 md:grid-cols-2 xl:grid-cols-4")[ stat_card( label="Running now", - value="3", - detail="Two feed workers and one Pangea worker are active.", + value=values["running_now"], + detail="Currently active job executions.", ), stat_card( label="Upcoming today", - value="11", - detail="Next scheduled job fires in 13 minutes.", + value=values["upcoming_today"], + detail="Enabled jobs that are ready for their next run.", ), stat_card( label="Failures in 24h", - value="2", - detail="One network timeout and one source parsing error.", + value=values["failures_24h"], + detail="Recent failed executions recorded by the scheduler.", ), stat_card( - label="Output footprint", - value="18.4 GB", - detail="Mirrored feeds, media, logs, and execution stats.", + label="Artifact footprint", + value=values["artifact_footprint"], + detail="Current log and stats artifact size under out/logs.", ), ], ] -def running_executions_table() -> Renderable: - rows = tuple(_running_execution_row(execution) for execution in RUNNING_EXECUTIONS) +def running_executions_table( + *, running_executions: tuple[Mapping[str, object], ...] | None = None +) -> Renderable: + rows = tuple( + _running_execution_row(execution) for execution in (running_executions or ()) + ) headers = ("Source", "Execution", "Started", "Status", "Stats", "Actions") def render_row(row: tuple[Node, ...]) -> Renderable: @@ -172,6 +189,14 @@ def running_executions_table() -> Renderable: def dashboard_page() -> Renderable: + return dashboard_page_with_data() + + +def dashboard_page_with_data( + *, + snapshot: Mapping[str, str] | None = None, + running_executions: tuple[Mapping[str, object], ...] | None = None, +) -> Renderable: return h.main( id="morph", class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", @@ -180,8 +205,8 @@ def dashboard_page() -> Renderable: h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ h.div(class_="mx-auto max-w-7xl space-y-5")[ dashboard_header(), - operational_snapshot(), - running_executions_table(), + operational_snapshot(snapshot=snapshot), + running_executions_table(running_executions=running_executions), ] ], ] diff --git a/repub/pages/runs.py b/repub/pages/runs.py index f2a70c0..c76f5c0 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -1,10 +1,11 @@ from __future__ import annotations +from collections.abc import Mapping + import htpy as h from htpy import Node, Renderable from repub.components import ( - inline_button, inline_link, muted_action_link, page_shell, @@ -13,254 +14,174 @@ from repub.components import ( table_section, ) -RUNNING_EXECUTIONS: tuple[dict[str, str | bool], ...] = ( - { - "source": "Pangea mobile articles", - "slug": "pangea-mobile", - "job_id": "7", - "execution_id": "104", - "started_at": "Today, 11:42 UTC", - "runtime": "running for 8m", - "status": "Running", - "stats": "26 requests • 7 items • 2.6 MB", - "worker": "graceful stop after current item", - "log_href": "/job/7/execution/104/logs", - "is_running": True, - }, - { - "source": "Guardian feed mirror", - "slug": "guardian-feed", - "job_id": "3", - "execution_id": "103", - "started_at": "Today, 11:33 UTC", - "runtime": "running for 17m", - "status": "Running", - "stats": "91 requests • 13 items • 5.1 MB", - "worker": "streaming stats from worker jsonl", - "log_href": "/job/3/execution/103/logs", - "is_running": True, - }, - { - "source": "Podcast enclosure mirror", - "slug": "podcast-audio", - "job_id": "11", - "execution_id": "105", - "started_at": "Today, 11:48 UTC", - "runtime": "running for 2m", - "status": "Stopping", - "stats": "4 requests • 0 items • 0.8 MB", - "worker": "waiting for 15s graceful shutdown window", - "log_href": "/job/11/execution/105/logs", - "is_running": True, - }, -) -UPCOMING_JOBS: tuple[dict[str, str | bool], ...] = ( - { - "source": "Podcast enclosure mirror", - "slug": "podcast-audio", - "job_id": "11", - "next_run": "Today, 12:15 local", - "schedule": "15 */4 * * 1-6", - "enabled_label": "Enabled", - "enabled_tone": "scheduled", - "run_disabled": True, - "run_reason": "Already running", - "toggle_label": "Disable", - }, - { - "source": "Weekly digest feed", - "slug": "weekly-digest", - "job_id": "18", - "next_run": "Tomorrow, 08:00 local", - "schedule": "0 8 * * 1", - "enabled_label": "Disabled", - "enabled_tone": "idle", - "run_disabled": False, - "run_reason": "Ready", - "toggle_label": "Enable", - }, - { - "source": "Kenya health desk", - "slug": "kenya-health", - "job_id": "22", - "next_run": "Today, 13:00 local", - "schedule": "0 */6 * * *", - "enabled_label": "Enabled", - "enabled_tone": "scheduled", - "run_disabled": False, - "run_reason": "Ready", - "toggle_label": "Disable", - }, -) - -COMPLETED_EXECUTIONS: tuple[dict[str, str], ...] = ( - { - "source": "Guardian feed mirror", - "slug": "guardian-feed", - "job_id": "3", - "execution_id": "102", - "ended_at": "Today, 10:57 UTC", - "status": "Succeeded", - "status_tone": "done", - "stats": "204 requests • 28 items • 9.4 MB", - "summary": "Finished on schedule", - "log_href": "/job/3/execution/102/logs", - }, - { - "source": "Podcast enclosure mirror", - "slug": "podcast-audio", - "job_id": "11", - "execution_id": "101", - "ended_at": "Today, 09:12 UTC", - "status": "Failed", - "status_tone": "failed", - "stats": "timeout after 3 retries", - "summary": "Worker exited with failure", - "log_href": "/job/11/execution/101/logs", - }, - { - "source": "Pangea mobile articles", - "slug": "pangea-mobile", - "job_id": "7", - "execution_id": "100", - "ended_at": "Today, 05:48 UTC", - "status": "Canceled", - "status_tone": "idle", - "stats": "stopped by operator after 11m", - "summary": "Graceful stop completed", - "log_href": "/job/7/execution/100/logs", - }, -) +def _action_button( + *, + label: str, + tone: str = "default", + disabled: bool = False, + post_path: str | None = None, +) -> Renderable: + classes = { + "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", + "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", + } + class_name = ( + "cursor-not-allowed bg-slate-100 text-slate-400" if disabled else classes[tone] + ) + attributes: dict[str, str] = {} + if post_path is not None and not disabled: + attributes["data-on:pointerdown"] = f"@post('{post_path}')" + return h.button( + attributes, + type="button", + disabled=disabled, + class_=( + "inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 " + f"text-sm font-semibold transition {class_name}" + ), + )[label] -def _running_row(execution: dict[str, str | bool]) -> tuple[Node, ...]: +def _text(values: Mapping[str, object], key: str) -> str: + return str(values[key]) + + +def _maybe_text(values: Mapping[str, object], key: str) -> str | None: + value = values.get(key) + if value in {None, ""}: + return None + return str(value) + + +def _flag(values: Mapping[str, object], key: str) -> bool: + return bool(values[key]) + + +def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ - h.div(class_="font-semibold text-slate-950")[execution["source"]], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[execution["slug"]], + h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[ + _text(execution, "slug") + ], ], h.div[ - h.p(class_="font-medium text-slate-900")[f"#{execution['execution_id']}"], - h.p(class_="mt-1 text-xs text-slate-500")[f"job {execution['job_id']}"], + h.p(class_="font-medium text-slate-900")[ + f"#{_text(execution, 'execution_id')}" + ], + h.p(class_="mt-1 text-xs text-slate-500")[ + f"job {_text(execution, 'job_id')}" + ], ], h.div[ - h.p(class_="font-medium text-slate-900")[execution["started_at"]], - h.p(class_="mt-1 text-xs text-slate-500")[execution["runtime"]], + h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")], + h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "runtime")], ], - status_badge(label=str(execution["status"]), tone="running"), + status_badge(label=_text(execution, "status"), tone="running"), h.div(class_="min-w-56 whitespace-normal")[ - h.p(class_="font-medium text-slate-900")[execution["stats"]], - h.p(class_="mt-1 text-xs text-slate-500")[execution["worker"]], + h.p(class_="font-medium text-slate-900")[_text(execution, "stats")], + h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "worker")], ], h.div(class_="flex flex-nowrap items-center gap-3")[ inline_link( - href=str(execution["log_href"]), + href=_text(execution, "log_href"), label="View log", tone="amber", ), - inline_button(label="Stop", tone="danger"), + _action_button( + label="Stop", + tone="danger", + post_path=_maybe_text(execution, "cancel_post_path"), + ), ], ) -def _upcoming_row(job: dict[str, str | bool]) -> tuple[Node, ...]: +def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ - h.div(class_="font-semibold text-slate-950")[job["source"]], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[job["slug"]], + h.div(class_="font-semibold text-slate-950")[_text(job, "source")], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[_text(job, "slug")], ], h.div[ - h.p(class_="font-medium text-slate-900")[job["next_run"]], - h.p(class_="mt-1 text-xs text-slate-500")[f"job {job['job_id']}"], + h.p(class_="font-medium text-slate-900")[_text(job, "next_run")], + h.p(class_="mt-1 text-xs text-slate-500")[f"job {_text(job, 'job_id')}"], ], - h.p(class_="font-mono text-xs text-slate-600")[job["schedule"]], + h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")], status_badge( - label=str(job["enabled_label"]), - tone=str(job["enabled_tone"]), + label=_text(job, "enabled_label"), + tone=_text(job, "enabled_tone"), ), h.p(class_="max-w-40 whitespace-normal text-sm text-slate-500")[ - job["run_reason"] + _text(job, "run_reason") ], h.div(class_="flex flex-nowrap items-center gap-2")[ - inline_button(label="Run now", disabled=bool(job["run_disabled"])), - inline_button(label=str(job["toggle_label"])), - inline_button(label="Delete", tone="danger"), + _action_button( + label="Run now", + disabled=_flag(job, "run_disabled"), + post_path=_maybe_text(job, "run_post_path"), + ), + _action_button( + label=_text(job, "toggle_label"), + post_path=_maybe_text(job, "toggle_post_path"), + ), + _action_button( + label="Delete", + tone="danger", + post_path=_maybe_text(job, "delete_post_path"), + ), ], ) -def _completed_row(execution: dict[str, str]) -> tuple[Node, ...]: +def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ - h.div(class_="font-semibold text-slate-950")[execution["source"]], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[execution["slug"]], + h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[ + _text(execution, "slug") + ], ], h.div[ - h.p(class_="font-medium text-slate-900")[f"#{execution['execution_id']}"], - h.p(class_="mt-1 text-xs text-slate-500")[f"job {execution['job_id']}"], + h.p(class_="font-medium text-slate-900")[ + f"#{_text(execution, 'execution_id')}" + ], + h.p(class_="mt-1 text-xs text-slate-500")[ + f"job {_text(execution, 'job_id')}" + ], ], h.div[ - h.p(class_="font-medium text-slate-900")[execution["ended_at"]], - h.p(class_="mt-1 text-xs text-slate-500")[execution["summary"]], + h.p(class_="font-medium text-slate-900")[_text(execution, "ended_at")], + h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "summary")], ], status_badge( - label=execution["status"], - tone=execution["status_tone"], + label=_text(execution, "status"), + tone=_text(execution, "status_tone"), ), h.div(class_="min-w-48 whitespace-normal")[ - h.p(class_="font-medium text-slate-900")[execution["stats"]] + h.p(class_="font-medium text-slate-900")[_text(execution, "stats")] ], inline_link( - href=execution["log_href"], + href=_text(execution, "log_href"), label="View log", tone="amber", ), ) -def delete_confirmation_preview() -> Renderable: - return section_card( - content=( - h.div(class_="flex items-center justify-between gap-4")[ - h.div[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" - )["Modal preview"], - h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ - "Delete confirmation" - ], - h.p(class_="mt-2 max-w-2xl text-sm text-slate-600")[ - "Upcoming jobs use a confirmation modal before deleting a job. This is the intended open state, placed inline for the static UI pass." - ], - ], - status_badge(label="Preview", tone="scheduled"), - ], - h.div(class_="mt-3 rounded-[1.5rem] bg-stone-50 p-5")[ - h.p( - class_="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500" - )["Delete job"], - h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[ - "Delete Weekly digest feed?" - ], - h.p(class_="mt-3 max-w-2xl text-sm leading-6 text-slate-600")[ - "This removes the source-linked job record and its schedule. Existing execution history and log files remain available for inspection." - ], - h.div(class_="mt-6 flex flex-wrap gap-3")[ - inline_button(label="Cancel"), - inline_button(label="Delete job", tone="danger"), - ], - ], - ) - ) - - -def runs_page() -> Renderable: - running_rows = tuple(_running_row(execution) for execution in RUNNING_EXECUTIONS) - upcoming_rows = tuple(_upcoming_row(job) for job in UPCOMING_JOBS) - completed_rows = tuple( - _completed_row(execution) for execution in COMPLETED_EXECUTIONS - ) +def runs_page( + *, + running_executions: tuple[Mapping[str, object], ...] | None = None, + upcoming_jobs: tuple[Mapping[str, object], ...] | None = None, + completed_executions: tuple[Mapping[str, object], ...] | None = None, +) -> Renderable: + running_items = running_executions or () + upcoming_items = upcoming_jobs or () + completed_items = completed_executions or () + running_rows = tuple(_running_row(execution) for execution in running_items) + upcoming_rows = tuple(_upcoming_row(job) for job in upcoming_items) + completed_rows = tuple(_completed_row(execution) for execution in completed_items) return page_shell( current_path="/runs", @@ -286,7 +207,7 @@ def runs_page() -> Renderable: table_section( eyebrow="Queue", title="Upcoming jobs", - subtitle="Scheduled work shows enable or disable state, run-now affordances, and delete controls. Run now is disabled while the job is already running.", + subtitle="Scheduled work shows enable or disable state, run-now affordances, and destructive delete controls. Deleting removes the source-linked job and its execution history.", headers=( "Source", "Next run", @@ -311,17 +232,43 @@ def runs_page() -> Renderable: ), rows=completed_rows, ), - delete_confirmation_preview(), ), ) -def execution_logs_page(*, job_id: int, execution_id: int) -> Renderable: +def execution_logs_page( + *, + job_id: int, + execution_id: int, + log_view: Mapping[str, object] | None = None, +) -> Renderable: + if log_view is None: + log_view = { + "title": f"Job {job_id} / execution {execution_id}", + "description": "Plain text log view routed through the app.", + "status_label": "Unavailable", + "status_tone": "failed", + "log_text": "", + "error_message": "Execution log is only available from persisted job runs.", + } + + error_message = _maybe_text(log_view, "error_message") + error_notice = ( + h.div( + class_="mt-3 rounded-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-800" + )[ + h.p["Execution log unavailable"], + h.p(class_="mt-1 font-normal")[error_message], + ] + if error_message is not None + else None + ) + return page_shell( current_path=f"/job/{job_id}/execution/{execution_id}/logs", eyebrow="Execution log", - title=f"Job {job_id} / execution {execution_id}", - description="Plain text log view routed through the app. The final version will stream appended lines while the worker is still active.", + title=_text(log_view, "title"), + description=_text(log_view, "description"), actions=muted_action_link(href="/runs", label="Back to runs"), content=( section_card( @@ -335,25 +282,18 @@ def execution_logs_page(*, job_id: int, execution_id: int) -> Renderable: f"/job/{job_id}/execution/{execution_id}/logs" ], h.p(class_="mt-2 text-sm text-slate-600")[ - "Streaming text log view. No arbitrary file paths are exposed in the UI." + _text(log_view, "description") ], ], - status_badge(label="Streaming", tone="running"), + status_badge( + label=_text(log_view, "status_label"), + tone=_text(log_view, "status_tone"), + ), ], + error_notice, h.pre( class_="mt-3 overflow-x-auto rounded-[1.5rem] bg-slate-950 p-5 text-xs leading-6 text-emerald-200" - )[ - "\n".join( - ( - "11:42:01 scheduler: run_now requested for job 7", - "11:42:02 worker[7]: starting pangea-mobile", - "11:42:08 stats: requests=18 items=4 bytes=1.8MB", - "11:42:11 stats: requests=26 items=7 bytes=2.6MB", - "11:42:17 stats: requests=31 items=9 bytes=3.0MB", - "11:42:24 worker[7]: waiting for more log lines ...", - ) - ) - ], + )[_text(log_view, "log_text")], ) ), ), diff --git a/repub/sql/001_initial.sql b/repub/sql/001_initial.sql index 12f3d41..43ad445 100644 --- a/repub/sql/001_initial.sql +++ b/repub/sql/001_initial.sql @@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS job_execution ( created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at TEXT, ended_at TEXT, + stop_requested_at TEXT, running_status INTEGER NOT NULL DEFAULT 0 CHECK (running_status BETWEEN 0 AND 4), requests_count INTEGER NOT NULL DEFAULT 0, items_count INTEGER NOT NULL DEFAULT 0, diff --git a/repub/web.py b/repub/web.py index fdb24fa..a216448 100644 --- a/repub/web.py +++ b/repub/web.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import hashlib from collections.abc import AsyncGenerator, Awaitable, Callable +from pathlib import Path from typing import TypedDict, cast from urllib.parse import urlparse @@ -15,8 +16,16 @@ from peewee import IntegrityError from quart import Quart, Response, request, url_for from repub.datastar import RefreshBroker, render_stream +from repub.jobs import ( + JobRuntime, + load_dashboard_view, + load_execution_log_view, + load_runs_view, +) from repub.model import ( + Job, create_source, + delete_job_source, initialize_database, load_source_form, load_sources, @@ -25,7 +34,7 @@ from repub.model import ( ) from repub.pages import ( create_source_page, - dashboard_page, + dashboard_page_with_data, edit_source_page, execution_logs_page, runs_page, @@ -35,6 +44,8 @@ from repub.pages import ( from repub.pages.sources import PANGEA_CONTENT_FORMATS, PANGEA_CONTENT_TYPES REFRESH_BROKER_KEY = "repub.refresh_broker" +JOB_RUNTIME_KEY = "repub.job_runtime" +DEFAULT_LOG_DIR = Path("out/logs") RenderFunction = Callable[[], Awaitable[Renderable]] @@ -83,7 +94,12 @@ def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, def create_app() -> Quart: app = Quart(__name__) app.config["REPUB_DB_PATH"] = str(initialize_database()) + app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR) + app.config.setdefault("REPUB_JOB_WORKER_DURATION_SECONDS", 20.0) + app.config.setdefault("REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS", 1.0) + app.config.setdefault("REPUB_JOB_WORKER_FAILURE_PROBABILITY", 0.3) app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() + app.extensions[JOB_RUNTIME_KEY] = None @app.get("/") @app.get("/sources") @@ -112,7 +128,7 @@ def create_app() -> Quart: @app.post("/") async def dashboard_patch() -> DatastarResponse: - return _page_patch_response(app, render_dashboard) + return _page_patch_response(app, lambda: render_dashboard(app)) @app.post("/sources") async def sources_patch() -> DatastarResponse: @@ -147,6 +163,7 @@ def create_app() -> Quart: {"_formError": "Slug must be unique.", "_formSuccess": ""} ) ) + get_job_runtime(app).sync_jobs() trigger_refresh(app) return DatastarResponse(SSE.redirect("/sources")) @@ -171,20 +188,58 @@ def create_app() -> Quart: {"_formError": "Source does not exist.", "_formSuccess": ""} ) ) + get_job_runtime(app).sync_jobs() trigger_refresh(app) return DatastarResponse(SSE.redirect("/sources")) @app.post("/runs") async def runs_patch() -> DatastarResponse: - return _page_patch_response(app, render_runs) + return _page_patch_response(app, lambda: render_runs(app)) + + @app.post("/actions/jobs//run-now") + 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//toggle-enabled") + async def toggle_job_enabled_action(job_id: int) -> Response: + job = Job.get_or_none(id=job_id) + if job is not None: + get_job_runtime(app).set_job_enabled(job_id, enabled=not job.enabled) + trigger_refresh(app) + return Response(status=204) + + @app.post("/actions/jobs//delete") + 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//cancel") + 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("/job//execution//logs") async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse: async def render() -> Renderable: - return await render_execution_logs(job_id=job_id, execution_id=execution_id) + return await render_execution_logs( + app, job_id=job_id, execution_id=execution_id + ) return _page_patch_response(app, render) + @app.before_serving + async def start_runtime() -> None: + get_job_runtime(app).start() + + @app.after_serving + async def stop_runtime() -> None: + get_job_runtime(app).shutdown() + return app @@ -192,12 +247,39 @@ def get_refresh_broker(app: Quart) -> RefreshBroker: return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY]) +def get_job_runtime(app: Quart) -> JobRuntime: + runtime = cast(JobRuntime | None, app.extensions.get(JOB_RUNTIME_KEY)) + if runtime is None: + runtime = JobRuntime( + log_dir=app.config["REPUB_LOG_DIR"], + worker_duration_seconds=float( + app.config["REPUB_JOB_WORKER_DURATION_SECONDS"] + ), + worker_stats_interval_seconds=float( + app.config["REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS"] + ), + worker_failure_probability=float( + app.config["REPUB_JOB_WORKER_FAILURE_PROBABILITY"] + ), + refresh_callback=lambda: trigger_refresh(app), + ) + app.extensions[JOB_RUNTIME_KEY] = runtime + return runtime + + def trigger_refresh(app: Quart, event: object = "refresh-event") -> None: get_refresh_broker(app).publish(event) -async def render_dashboard() -> Renderable: - return dashboard_page() +async def render_dashboard(app: Quart | None = None) -> Renderable: + if app is None: + return dashboard_page_with_data() + + view = load_dashboard_view(log_dir=app.config["REPUB_LOG_DIR"]) + return dashboard_page_with_data( + snapshot=cast(dict[str, str], view["snapshot"]), + running_executions=cast(tuple[dict[str, object], ...], view["running"]), + ) async def render_sources(app: Quart | None = None) -> Renderable: @@ -221,12 +303,41 @@ async def render_edit_source(slug: str) -> Renderable: ) -async def render_runs() -> Renderable: - return runs_page() +async def render_runs(app: Quart | None = None) -> Renderable: + if app is None: + return runs_page() + + view = load_runs_view(log_dir=app.config["REPUB_LOG_DIR"]) + return runs_page( + running_executions=cast(tuple[dict[str, object], ...], view["running"]), + upcoming_jobs=cast(tuple[dict[str, object], ...], view["upcoming"]), + completed_executions=cast(tuple[dict[str, object], ...], view["completed"]), + ) -async def render_execution_logs(*, job_id: int, execution_id: int) -> Renderable: - return execution_logs_page(job_id=job_id, execution_id=execution_id) +async def render_execution_logs( + app: Quart | None = None, *, job_id: int, execution_id: int +) -> Renderable: + if app is None: + return execution_logs_page(job_id=job_id, execution_id=execution_id) + + log_view = load_execution_log_view( + log_dir=app.config["REPUB_LOG_DIR"], + job_id=job_id, + execution_id=execution_id, + ) + return execution_logs_page( + job_id=job_id, + execution_id=execution_id, + log_view={ + "title": log_view.title, + "description": log_view.description, + "status_label": log_view.status_label, + "status_tone": log_view.status_tone, + "log_text": log_view.log_text, + "error_message": log_view.error_message, + }, + ) def _page_patch_response(app: Quart, render: RenderFunction) -> DatastarResponse: diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py new file mode 100644 index 0000000..df226dc --- /dev/null +++ b/tests/test_scheduler_runtime.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import asyncio +import json +import time +from pathlib import Path + +from repub.jobs import JobArtifacts, JobRuntime +from repub.model import ( + Job, + JobExecution, + JobExecutionStatus, + Source, + create_source, + initialize_database, +) +from repub.web import create_app, get_job_runtime, render_execution_logs, render_runs + + +def test_job_runtime_syncs_enabled_jobs_into_apscheduler(tmp_path: Path) -> None: + initialize_database(tmp_path / "scheduler.db") + enabled_source = create_source( + name="Enabled source", + slug="enabled-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/enabled.xml", + ) + disabled_source = create_source( + name="Disabled source", + slug="disabled-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="15", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/disabled.xml", + ) + enabled_job = Job.get(Job.source == enabled_source) + disabled_job = Job.get(Job.source == disabled_source) + + runtime = JobRuntime( + log_dir=tmp_path / "out" / "logs", + worker_duration_seconds=0.4, + worker_stats_interval_seconds=0.05, + worker_failure_probability=0.0, + ) + try: + runtime.start() + runtime.sync_jobs() + + scheduled_ids = {job.id for job in runtime.scheduler.get_jobs()} + + assert f"job-{enabled_job.id}" in scheduled_ids + assert f"job-{disabled_job.id}" not in scheduled_ids + + enabled_job.enabled = False + enabled_job.save() + runtime.sync_jobs() + + scheduled_ids = {job.id for job in runtime.scheduler.get_jobs()} + assert f"job-{enabled_job.id}" not in scheduled_ids + finally: + runtime.shutdown() + + +def test_job_runtime_run_now_writes_log_and_stats_and_marks_success( + tmp_path: Path, +) -> None: + initialize_database(tmp_path / "run-now.db") + source = create_source( + name="Manual source", + slug="manual-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/manual.xml", + ) + job = Job.get(Job.source == source) + + runtime = JobRuntime( + log_dir=tmp_path / "out" / "logs", + worker_duration_seconds=0.35, + worker_stats_interval_seconds=0.05, + worker_failure_probability=0.0, + ) + try: + runtime.start() + execution_id = runtime.run_job_now(job.id, reason="manual") + assert execution_id is not None + execution = _wait_for_terminal_execution(execution_id) + artifacts = JobArtifacts.for_execution( + log_dir=tmp_path / "out" / "logs", + job_id=job.id, + execution_id=execution_id, + ) + + assert execution.running_status == JobExecutionStatus.SUCCEEDED + assert execution.started_at is not None + assert execution.ended_at is not None + assert execution.requests_count > 0 + assert execution.items_count > 0 + assert execution.bytes_count > 0 + assert artifacts.log_path.exists() + assert artifacts.stats_path.exists() + assert "starting simulated crawl" in artifacts.log_path.read_text( + encoding="utf-8" + ) + + stats_lines = [ + json.loads(line) + for line in artifacts.stats_path.read_text(encoding="utf-8").splitlines() + ] + assert len(stats_lines) >= 2 + assert stats_lines[-1]["requests_count"] == execution.requests_count + finally: + runtime.shutdown() + + +def test_job_runtime_cancel_marks_execution_canceled(tmp_path: Path) -> None: + initialize_database(tmp_path / "cancel.db") + source = create_source( + name="Cancelable source", + slug="cancelable-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/cancelable.xml", + ) + job = Job.get(Job.source == source) + + runtime = JobRuntime( + log_dir=tmp_path / "out" / "logs", + worker_duration_seconds=2.0, + worker_stats_interval_seconds=0.1, + worker_failure_probability=0.0, + ) + try: + runtime.start() + execution_id = runtime.run_job_now(job.id, reason="manual") + assert execution_id is not None + _wait_for_running_execution(execution_id) + + runtime.request_execution_cancel(execution_id) + execution = _wait_for_terminal_execution(execution_id) + artifacts = JobArtifacts.for_execution( + log_dir=tmp_path / "out" / "logs", + job_id=job.id, + execution_id=execution_id, + ) + + assert execution.running_status == JobExecutionStatus.CANCELED + assert execution.ended_at is not None + assert execution.stop_requested_at is not None + assert "graceful stop requested" in artifacts.log_path.read_text( + encoding="utf-8" + ) + finally: + runtime.shutdown() + + +def test_render_runs_uses_database_backed_jobs_and_executions( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "runs-page.db" + log_dir = tmp_path / "out" / "logs" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + app = create_app() + app.config["REPUB_LOG_DIR"] = log_dir + app.config["REPUB_JOB_WORKER_DURATION_SECONDS"] = 0.35 + app.config["REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS"] = 0.05 + app.config["REPUB_JOB_WORKER_FAILURE_PROBABILITY"] = 0.0 + + source = create_source( + name="Runs page source", + slug="runs-page-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/runs-page.xml", + ) + job = Job.get(Job.source == source) + runtime = get_job_runtime(app) + runtime.start() + try: + execution_id = runtime.run_job_now(job.id, reason="manual") + assert execution_id is not None + execution = _wait_for_terminal_execution(execution_id) + + async def run() -> None: + body = str(await render_runs(app)) + + assert "runs-page-source" in body + assert "Running job executions" in body + assert "Upcoming jobs" in body + assert "Completed job executions" in body + assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body + assert "Succeeded" in body + assert "Run now" in body + + asyncio.run(run()) + finally: + runtime.shutdown() + + +def test_render_execution_logs_handles_missing_execution_and_missing_log_file( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "log-errors.db" + log_dir = tmp_path / "out" / "logs" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + app = create_app() + app.config["REPUB_LOG_DIR"] = log_dir + source = create_source( + name="Log source", + slug="log-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/log-source.xml", + ) + job = Job.get(Job.source == source) + execution = JobExecution.create( + job=job, + running_status=JobExecutionStatus.FAILED, + ) + + async def run() -> None: + missing_execution = str( + await render_execution_logs(app, job_id=job.id, execution_id=9999) + ) + missing_log = str( + await render_execution_logs(app, job_id=job.id, execution_id=execution.id) + ) + + assert "Execution log unavailable" in missing_execution + assert "Execution does not exist." in missing_execution + assert "Execution log unavailable" in missing_log + assert "Log file has not been created yet." in missing_log + + asyncio.run(run()) + + +def test_delete_job_action_removes_source_job_and_execution_history( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "delete-job.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + client = app.test_client() + + source = create_source( + name="Delete source", + slug="delete-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=True, + cron_minute="*/30", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/delete.xml", + ) + job = Job.get(Job.source == source) + execution = JobExecution.create( + job=job, + running_status=JobExecutionStatus.SUCCEEDED, + ) + + response = await client.post(f"/actions/jobs/{job.id}/delete") + + assert response.status_code == 204 + assert Source.get_or_none(Source.slug == "delete-source") is None + assert Job.get_or_none(id=job.id) is None + assert JobExecution.get_or_none(id=int(execution.get_id())) is None + + asyncio.run(run()) + + +def _wait_for_running_execution( + execution_id: int, *, timeout_seconds: float = 2.0 +) -> JobExecution: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + execution = JobExecution.get_by_id(execution_id) + if execution.running_status == JobExecutionStatus.RUNNING: + return execution + time.sleep(0.02) + raise AssertionError(f"execution {execution_id} never entered RUNNING state") + + +def _wait_for_terminal_execution( + execution_id: int, *, timeout_seconds: float = 4.0 +) -> JobExecution: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + execution = JobExecution.get_by_id(execution_id) + if execution.running_status in { + JobExecutionStatus.SUCCEEDED, + JobExecutionStatus.FAILED, + JobExecutionStatus.CANCELED, + }: + return execution + time.sleep(0.02) + raise AssertionError(f"execution {execution_id} did not finish in time") diff --git a/tests/test_web.py b/tests/test_web.py index 3952930..51d469d 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -5,7 +5,15 @@ from pathlib import Path from typing import Any, cast from repub.datastar import RefreshBroker, render_sse_event, render_stream -from repub.model import Job, Source, SourceFeed, SourcePangea, create_source +from repub.model import ( + Job, + JobExecution, + JobExecutionStatus, + Source, + SourceFeed, + SourcePangea, + create_source, +) from repub.web import ( create_app, get_refresh_broker, @@ -141,15 +149,20 @@ def test_render_stream_yields_on_connect_and_refresh() -> None: asyncio.run(run()) -def test_render_dashboard_shows_dashboard_information_architecture() -> None: +def test_render_dashboard_shows_dashboard_information_architecture( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "dashboard-render.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + async def run() -> None: - body = str(await render_dashboard()) + app = create_app() + body = str(await render_dashboard(app)) assert "Operational snapshot" in body assert "Running executions" in body assert 'href="/sources"' in body assert 'href="/runs"' in body - assert "/job/7/execution/104/logs" in body assert "Create source" in body asyncio.run(run()) @@ -569,27 +582,97 @@ def test_create_source_action_validates_duplicate_slug_and_pangea_type( asyncio.run(run()) -def test_render_runs_shows_running_upcoming_and_completed_tables() -> None: +def test_render_runs_shows_running_upcoming_and_completed_tables( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "runs-render.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + async def run() -> None: - body = str(await render_runs()) + app = create_app() + + source = create_source( + name="Runs render source", + slug="runs-render-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=True, + cron_minute="*/30", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/runs.xml", + ) + job = Job.get(Job.source == source) + execution = JobExecution.create( + job=job, + running_status=JobExecutionStatus.SUCCEEDED, + ) + + body = str(await render_runs(app)) assert "Running job executions" in body assert "Upcoming jobs" in body assert "Completed job executions" in body - assert "Delete confirmation" in body - assert "/job/11/execution/101/logs" in body - assert "Already running" in body + assert "runs-render-source" in body + assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body + assert "Already running" not in body asyncio.run(run()) -def test_render_execution_logs_uses_app_route() -> None: - async def run() -> None: - body = str(await render_execution_logs(job_id=7, execution_id=104)) +def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "logs-render.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - assert "Job 7 / execution 104" in body - assert "/job/7/execution/104/logs" in body - assert "Streaming text log view" in body + async def run() -> None: + log_dir = tmp_path / "out" / "logs" + app = create_app() + app.config["REPUB_LOG_DIR"] = log_dir + + source = create_source( + name="Log render source", + slug="log-render-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/30", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/logs.xml", + ) + job = Job.get(Job.source == source) + execution = JobExecution.create( + job=job, + running_status=JobExecutionStatus.RUNNING, + ) + log_path = log_dir / f"job-{job.id}-execution-{execution.get_id()}.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text( + "\n".join( + ( + "scheduler: run_now requested", + "worker: starting simulated crawl", + "worker: waiting for more log lines ...", + ) + ), + encoding="utf-8", + ) + + body = str( + await render_execution_logs( + app, job_id=job.id, execution_id=int(execution.get_id()) + ) + ) + + assert f"Job {job.id} / execution {execution.get_id()}" in body + assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body + assert "Route: /job/" in body assert "waiting for more log lines" in body asyncio.run(run()) From c210168d65ae2d31991aa0a9843d23ea4675cd37 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 14:14:59 +0200 Subject: [PATCH 11/23] tweak job runs --- AGENTS.md | 1 + repub/components.py | 2 +- repub/jobs.py | 22 ++++++++++++++- repub/pages/runs.py | 67 +++++++++++++++++++++++++++++++++++++------- repub/static/app.css | 4 +++ tests/test_web.py | 10 +++++++ 6 files changed, 94 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39c43e0..e77c250 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,5 +112,6 @@ uv run repub crawl -c repub.toml - Runtime ffmpeg availability is provided by the flake package and devshell. - Tests live under `tests/`. - `prompts/` is git ignored intentionally +- Never search the web for this repo. If an external resource, document, or reference is needed, stop and ask the user to provide it. - Treat the repo-root `republisher.db` as user-owned local state. Do not delete or reset it as part of routine testing or verification. - For automated tests or isolated verification, use a separate database path via `REPUBLISHER_DB_PATH` instead of mutating or removing the repo-root database. diff --git a/repub/components.py b/repub/components.py index 48af6d6..bcf79f7 100644 --- a/repub/components.py +++ b/repub/components.py @@ -403,7 +403,7 @@ def status_badge(*, label: str, tone: str) -> Renderable: "scheduled": "bg-sky-100 text-sky-800", "idle": "bg-slate-200 text-slate-700", "failed": "bg-rose-100 text-rose-800", - "done": "bg-amber-100 text-amber-800", + "done": "bg-emerald-100 text-emerald-800", } return h.span( class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}" diff --git a/repub/jobs.py b/repub/jobs.py index 6e306af..8d7de38 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -504,10 +504,11 @@ def _project_upcoming_job( "slug": job.source.slug, "job_id": job_id, "next_run": ( - next_run.strftime("%Y-%m-%d %H:%M UTC") + _humanize_future_time(reference_time, next_run) if next_run is not None else ("Running now" if running_execution is not None else "Not scheduled") ), + "next_run_at": next_run.isoformat() if next_run is not None else None, "schedule": " ".join( ( str(job.cron_minute), @@ -641,3 +642,22 @@ def _format_bytes(value: int) -> str: if value < 1024 * 1024 * 1024: return f"{value / (1024 * 1024):.1f} MB" return f"{value / (1024 * 1024 * 1024):.1f} GB" + + +def _humanize_future_time(reference_time: datetime, target_time: datetime) -> str: + delta_seconds = int(round((target_time - reference_time).total_seconds())) + if delta_seconds <= 0: + return "now" + + units = ( + ("day", 24 * 60 * 60), + ("hour", 60 * 60), + ("minute", 60), + ) + for label, size in units: + if delta_seconds >= size: + count = max(1, round(delta_seconds / size)) + suffix = "" if count == 1 else "s" + return f"in {count} {label}{suffix}" + + return f"in {delta_seconds} seconds" diff --git a/repub/pages/runs.py b/repub/pages/runs.py index c76f5c0..94acee7 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -70,9 +70,6 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: h.p(class_="font-medium text-slate-900")[ f"#{_text(execution, 'execution_id')}" ], - h.p(class_="mt-1 text-xs text-slate-500")[ - f"job {_text(execution, 'job_id')}" - ], ], h.div[ h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")], @@ -99,15 +96,26 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: + next_run_at = _maybe_text(job, "next_run_at") + next_run_label: Node = h.p(class_="font-medium text-slate-900")[ + _text(job, "next_run") + ] + if next_run_at is not None: + next_run_label = h.time( + { + "data-next-run-at": next_run_at, + "title": next_run_at, + }, + datetime=next_run_at, + class_="font-medium text-slate-900", + )[_text(job, "next_run")] + return ( h.div[ h.div(class_="font-semibold text-slate-950")[_text(job, "source")], h.p(class_="mt-1 font-mono text-xs text-slate-500")[_text(job, "slug")], ], - h.div[ - h.p(class_="font-medium text-slate-900")[_text(job, "next_run")], - h.p(class_="mt-1 text-xs text-slate-500")[f"job {_text(job, 'job_id')}"], - ], + h.div[next_run_label,], h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")], status_badge( label=_text(job, "enabled_label"), @@ -147,9 +155,6 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: h.p(class_="font-medium text-slate-900")[ f"#{_text(execution, 'execution_id')}" ], - h.p(class_="mt-1 text-xs text-slate-500")[ - f"job {_text(execution, 'job_id')}" - ], ], h.div[ h.p(class_="font-medium text-slate-900")[_text(execution, "ended_at")], @@ -232,6 +237,48 @@ def runs_page( ), rows=completed_rows, ), + h.script[ + """ +window.repubFormatNextRuns = window.repubFormatNextRuns || (() => { + const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + const absoluteFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + timeZoneName: 'short', + }); + const formatRelative = (targetDate) => { + const diffSeconds = Math.round((targetDate.getTime() - Date.now()) / 1000); + const units = [ + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ['second', 1], + ]; + for (const [unit, size] of units) { + if (Math.abs(diffSeconds) >= size || unit === 'second') { + return relativeFormatter.format(Math.round(diffSeconds / size), unit); + } + } + return relativeFormatter.format(0, 'second'); + }; + const format = () => { + document.querySelectorAll('time[data-next-run-at]').forEach((element) => { + const nextRunAt = element.getAttribute('data-next-run-at'); + if (!nextRunAt) return; + const targetDate = new Date(nextRunAt); + if (Number.isNaN(targetDate.getTime())) return; + element.textContent = formatRelative(targetDate); + element.title = absoluteFormatter.format(targetDate); + }); + }; + format(); + if (!window.repubNextRunTimer) { + window.repubNextRunTimer = window.setInterval(format, 30000); + } +}); +window.repubFormatNextRuns(); + """ + ], ), ) diff --git a/repub/static/app.css b/repub/static/app.css index c01e60b..dae085d 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -1,4 +1,8 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@view-transition { + navigation: auto; +} + @layer properties; @layer theme, base, components, utilities; @layer theme { diff --git a/tests/test_web.py b/tests/test_web.py index 51d469d..257f88b 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -4,6 +4,7 @@ import asyncio from pathlib import Path from typing import Any, cast +from repub.components import status_badge from repub.datastar import RefreshBroker, render_sse_event, render_stream from repub.model import ( Job, @@ -26,6 +27,13 @@ from repub.web import ( ) +def test_status_badge_uses_green_done_tone() -> None: + badge = str(status_badge(label="Succeeded", tone="done")) + + assert "bg-emerald-100 text-emerald-800" in badge + assert "Succeeded" in badge + + def test_root_get_serves_datastar_shim() -> None: async def run() -> None: client = create_app().test_client() @@ -618,6 +626,8 @@ def test_render_runs_shows_running_upcoming_and_completed_tables( assert "Completed job executions" in body assert "runs-render-source" in body assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body + assert "data-next-run-at" in body + assert "in " in body assert "Already running" not in body asyncio.run(run()) From 51728a5401d69896f966098180a9e38e212facb7 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 14:16:15 +0200 Subject: [PATCH 12/23] shim renders app shell --- repub/pages/shim.py | 44 ++++++++++++++++++++++++++++++++++++++++++-- repub/web.py | 9 +++++++-- tests/test_web.py | 5 ++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/repub/pages/shim.py b/repub/pages/shim.py index 40859d1..e66d255 100644 --- a/repub/pages/shim.py +++ b/repub/pages/shim.py @@ -3,6 +3,8 @@ from __future__ import annotations import htpy as h from htpy import Node, Renderable +from repub.components import admin_sidebar + ON_LOAD_JS = ( "@post(window.location.pathname + " "(window.location.search + '&u=').replace(/^&/,'?'), " @@ -12,7 +14,9 @@ ON_LOAD_JS = ( TAB_ID_JS = "self.crypto.randomUUID().substring(0,8)" -def shim_page(*, datastar_src: str, head: Node | None = None) -> Renderable: +def shim_page( + *, datastar_src: str, current_path: str, head: Node | None = None +) -> Renderable: return h.html(lang="en")[ h.head[ h.meta(charset="UTF-8"), @@ -29,6 +33,42 @@ def shim_page(*, datastar_src: str, head: Node | None = None) -> Renderable: } ), h.noscript["Your browser does not support JavaScript!"], - h.main(id="morph"), + h.main( + id="morph", + class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", + )[ + admin_sidebar(current_path=current_path), + h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ + h.div(class_="mx-auto max-w-7xl space-y-5")[ + h.section[ + h.div( + class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" + )[ + h.div(class_="max-w-3xl")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Connecting"], + h.h1( + class_="mt-1 text-3xl font-semibold tracking-tight text-slate-950" + )["Loading page"], + h.p(class_="mt-1 text-sm text-slate-600")[ + "Rendering the latest server view for this route." + ], + ], + ] + ], + h.section( + class_="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200" + )[ + h.div(class_="animate-pulse space-y-4 p-6")[ + h.div(class_="h-5 w-40 rounded-full bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + ] + ], + ] + ], + ], ], ] diff --git a/repub/web.py b/repub/web.py index a216448..f380bb4 100644 --- a/repub/web.py +++ b/repub/web.py @@ -81,12 +81,16 @@ DEFAULT_PANGEA_MAX_ARTICLES = "10" DEFAULT_PANGEA_OLDEST_ARTICLE = "3" -def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]: +def _render_shim_page( + *, stylesheet_href: str, datastar_src: str, current_path: str +) -> tuple[str, str]: head = ( h.title["Republisher Admin UI"], h.link(rel="stylesheet", href=stylesheet_href), ) - body = str(shim_page(datastar_src=datastar_src, head=head)) + body = str( + shim_page(datastar_src=datastar_src, current_path=current_path, head=head) + ) etag = hashlib.sha256(body.encode("utf-8")).hexdigest() return body, etag @@ -116,6 +120,7 @@ def create_app() -> Quart: body, etag = _render_shim_page( stylesheet_href=url_for("static", filename="app.css"), datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), + current_path=request.path, ) if request.if_none_match.contains(etag): response = Response(status=304) diff --git a/tests/test_web.py b/tests/test_web.py index 257f88b..cc7deb8 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -52,7 +52,10 @@ def test_root_get_serves_datastar_shim() -> None: assert 'data-init="@post(window.location.pathname +' in body assert "retryMaxCount: Infinity" in body assert "data-on:online__window=" in body - assert '
' in body + assert '
Date: Mon, 30 Mar 2026 14:18:51 +0200 Subject: [PATCH 13/23] tweak sidebar --- repub/components.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/repub/components.py b/repub/components.py index bcf79f7..fb4aae3 100644 --- a/repub/components.py +++ b/repub/components.py @@ -79,19 +79,12 @@ def admin_sidebar(*, current_path: str) -> Renderable: badge="3", ), ], - h.div(class_="mt-10 rounded-3xl border border-white/10 bg-white/5 p-5")[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" - )["Operator notes"], - h.p(class_="mt-3 text-sm leading-6 text-slate-300")[ - "Single-operator control plane over Tailscale. The current pass is layout only, but it follows the new full-page Datastar render pattern." - ], - ], h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[ - h.p(class_="text-sm font-semibold text-white")["Output root"], - h.p(class_="mt-2 font-mono text-sm text-slate-300")["/srv/anynews/out"], + h.p(class_="text-sm font-semibold text-white")[ + "AnyNews Republisher v2.0" + ], h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[ - "Trusted network only" + "by Guardian Project" ], ], ], From 916968c57948644ab3daf82d2c065f47c3cbb144 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 14:18:55 +0200 Subject: [PATCH 14/23] reconcile stale execs --- repub/jobs.py | 34 +++++++++++++++++++++ tests/test_scheduler_runtime.py | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/repub/jobs.py b/repub/jobs.py index 8d7de38..eeb4494 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -80,6 +80,7 @@ class JobRuntime: if self._started: return + self._reconcile_stale_executions() self.scheduler.start() self.scheduler.add_job( self.poll_workers, @@ -311,6 +312,39 @@ class JobRuntime: if self.refresh_callback is not None: self.refresh_callback() + def _reconcile_stale_executions(self) -> None: + with database.connection_context(): + stale_executions = tuple( + JobExecution.select(JobExecution, Job) + .join(Job) + .where(JobExecution.running_status == JobExecutionStatus.RUNNING) + ) + + for execution in stale_executions: + job = cast(Job, execution.job) + execution_id = _execution_id(execution) + artifacts = JobArtifacts.for_execution( + log_dir=self.log_dir, + job_id=_job_id(job), + execution_id=execution_id, + ) + artifacts.log_path.parent.mkdir(parents=True, exist_ok=True) + with artifacts.log_path.open("a", encoding="utf-8") as log_handle: + log_handle.write( + "scheduler: execution marked failed after app restart\n" + ) + + execution.ended_at = utc_now() + execution.running_status = ( + JobExecutionStatus.CANCELED + if execution.stop_requested_at is not None + else JobExecutionStatus.FAILED + ) + execution.save() + + if stale_executions: + self._trigger_refresh() + def load_runs_view( *, log_dir: str | Path, now: datetime | None = None diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index df226dc..a2059d4 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -182,6 +182,58 @@ def test_job_runtime_cancel_marks_execution_canceled(tmp_path: Path) -> None: runtime.shutdown() +def test_job_runtime_start_reconciles_stale_running_execution(tmp_path: Path) -> None: + initialize_database(tmp_path / "stale-running.db") + source = create_source( + name="Stale source", + slug="stale-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/stale.xml", + ) + job = Job.get(Job.source == source) + execution = JobExecution.create( + job=job, + started_at="2026-03-30 12:30:00+00:00", + running_status=JobExecutionStatus.RUNNING, + ) + artifacts = JobArtifacts.for_execution( + log_dir=tmp_path / "out" / "logs", + job_id=job.id, + execution_id=int(execution.get_id()), + ) + artifacts.log_path.parent.mkdir(parents=True, exist_ok=True) + artifacts.log_path.write_text( + "worker: process lost during app restart\n", + encoding="utf-8", + ) + + runtime = JobRuntime( + log_dir=tmp_path / "out" / "logs", + worker_duration_seconds=0.5, + worker_stats_interval_seconds=0.05, + worker_failure_probability=0.0, + ) + try: + runtime.start() + reconciled_execution = JobExecution.get_by_id(execution.get_id()) + + assert reconciled_execution.running_status == JobExecutionStatus.FAILED + assert reconciled_execution.ended_at is not None + assert "marked failed after app restart" in artifacts.log_path.read_text( + encoding="utf-8" + ) + finally: + runtime.shutdown() + + def test_render_runs_uses_database_backed_jobs_and_executions( monkeypatch, tmp_path: Path ) -> None: From 8af28c2f68d16886dbb805da704b4d4ff536649d Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:04:41 +0200 Subject: [PATCH 15/23] implement scrapy + pygea job runner --- repub/job_runner.py | 505 +++++++++++++++++++++++++++----- repub/jobs.py | 48 +-- repub/media.py | 34 ++- repub/pages/dashboard.py | 2 +- repub/pages/runs.py | 26 +- tests/test_pipelines.py | 140 +++++++++ tests/test_scheduler_runtime.py | 229 ++++++++++++--- tests/test_web.py | 67 +++++ 8 files changed, 888 insertions(+), 163 deletions(-) diff --git a/repub/job_runner.py b/repub/job_runner.py index 61abacb..9ad69c7 100644 --- a/repub/job_runner.py +++ b/repub/job_runner.py @@ -2,121 +2,464 @@ from __future__ import annotations import argparse import json -import random import signal import sys -import time +from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path +from typing import Any + +from pygea.config import LoggingConfig, PygeaConfig, ResultsConfig, RuntimeConfig +from scrapy.crawler import CrawlerProcess +from scrapy.statscollectors import StatsCollector +from twisted.python.failure import Failure + +from repub.config import ( + FeedConfig, + RepublisherConfig, + build_base_settings, + build_feed_settings, +) +from repub.crawl import prepare_output_dirs +from repub.model import ( + Job, + Source, + SourceFeed, + SourcePangea, + database, + initialize_database, +) +from repub.spiders.rss_spider import RssFeedSpider + + +def _json_default(value: Any) -> Any: + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=UTC).isoformat() + return value.astimezone(UTC).isoformat() + return str(value) + + +def _normalized_stats(stats: dict[str, Any]) -> dict[str, Any]: + cache_store = int(stats.get("httpcache/store", 0)) + cache_hits = int(stats.get("httpcache/hit", 0)) + cache_misses = int(stats.get("httpcache/miss", 0)) + return { + **stats, + "requests_count": int(stats.get("downloader/request_count", 0)), + "items_count": int(stats.get("item_scraped_count", 0)), + "warnings_count": int(stats.get("log_count/WARNING", 0)), + "errors_count": int(stats.get("log_count/ERROR", 0)), + "bytes_count": int(stats.get("downloader/response_bytes", 0)), + "retries_count": int(stats.get("retry/count", 0)), + "exceptions_count": int(stats.get("spider_exceptions/count", 0)), + "cache_size_count": cache_store, + "cache_object_count": cache_store + cache_hits + cache_misses, + } + + +class ExecutionStatsCollector(StatsCollector): + def __init__(self, crawler: Any): + super().__init__(crawler) + self._stats_path = Path(crawler.settings["REPUB_JOB_STATS_PATH"]) + self._stats_path.parent.mkdir(parents=True, exist_ok=True) + + def set_value(self, key: str, value: Any, spider: Any | None = None) -> None: + super().set_value(key, value, spider) + self._write_snapshot() + + def set_stats(self, stats: dict[str, Any], spider: Any | None = None) -> None: + super().set_stats(stats, spider) + self._write_snapshot() + + def inc_value( + self, + key: str, + count: int = 1, + start: int = 0, + spider: Any | None = None, + ) -> None: + super().inc_value(key, count, start, spider) + self._write_snapshot() + + def max_value(self, key: str, value: Any, spider: Any | None = None) -> None: + super().max_value(key, value, spider) + self._write_snapshot() + + def min_value(self, key: str, value: Any, spider: Any | None = None) -> None: + super().min_value(key, value, spider) + self._write_snapshot() + + def clear_stats(self, spider: Any | None = None) -> None: + super().clear_stats(spider) + self._write_snapshot() + + def open_spider(self, spider: Any | None = None) -> None: + super().open_spider(spider) + self._write_snapshot() + + def _persist_stats(self, stats: dict[str, Any]) -> None: + self._write_snapshot(stats) + + def _write_snapshot(self, stats: dict[str, Any] | None = None) -> None: + payload = { + "timestamp": datetime.now(UTC).isoformat(), + **_normalized_stats(self._stats if stats is None else stats), + } + with self._stats_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True, default=_json_default)) + handle.write("\n") + + +def pangea_feed_class(): + from pygea.pangeafeed import PangeaFeed + + return PangeaFeed + + +def generate_pangea_feed( + *, + name: str, + slug: str, + domain: str, + category_name: str, + content_type: str, + only_newest: bool, + max_articles: int, + oldest_article: int, + include_authors: bool, + exclude_media: bool, + include_content: bool, + content_format: str, + out_dir: str | Path, + log_path: str | Path, +) -> Path: + resolved_out_dir = Path(out_dir).resolve() + resolved_log_path = Path(log_path).resolve() + config = PygeaConfig( + config_path=resolved_out_dir / "pygea-runtime.toml", + domain=domain, + default_content_type=content_type, + feeds=( + { + "name": category_name, + "slug": slug, + "only_newest": only_newest, + "content_type": content_type, + }, + ), + runtime=RuntimeConfig( + api_key=None, + max_articles=max_articles, + oldest_article=oldest_article, + authors_p=include_authors, + no_media_p=exclude_media, + content_inc_p=include_content, + content_format=content_format, + verbose_p=True, + ), + results=ResultsConfig( + output_to_file_p=True, + output_file_name="rss.xml", + output_directory=resolved_out_dir, + ), + logging=LoggingConfig( + log_file=resolved_log_path, + default_log_level="INFO", + ), + ) + feed_class = pangea_feed_class() + feed = feed_class(config, list(config.feeds)) + feed.acquire_content() + feed.generate_feed() + output_path = feed.disgorge(slug) + if output_path is None: + raise RuntimeError(f"pygea did not write an output file for {name!r}") + return output_path.resolve() + + +@dataclass(frozen=True) +class JobSourceConfig: + source_name: str + source_slug: str + source_type: str + spider_arguments: dict[str, str] + feed_url: str | None = None + pangea_domain: str | None = None + pangea_category: str | None = None + content_type: str | None = None + only_newest: bool = True + max_articles: int = 10 + oldest_article: int = 3 + include_authors: bool = True + exclude_media: bool = False + include_content: bool = True + content_format: str = "MOBILE_3" def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Simulated republisher worker") + parser = argparse.ArgumentParser(description="Run a republisher job worker") parser.add_argument("--job-id", type=int, required=True) parser.add_argument("--execution-id", type=int, required=True) + parser.add_argument("--db-path", required=True) + parser.add_argument("--out-dir", required=True) parser.add_argument("--stats-path", required=True) - parser.add_argument("--duration-seconds", type=float, required=True) - parser.add_argument("--interval-seconds", type=float, required=True) - parser.add_argument("--failure-probability", type=float, required=True) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) - rng = random.Random(f"{args.job_id}:{args.execution_id}") - stats_path = Path(args.stats_path) - stats_path.parent.mkdir(parents=True, exist_ok=True) stop_requested = False + process: CrawlerProcess | None = None def request_stop(signum: int, frame: object | None) -> None: del signum, frame nonlocal stop_requested - if stop_requested: - return stop_requested = True print( f"worker[{args.job_id}:{args.execution_id}]: graceful stop requested", flush=True, ) + if process is None: + return + try: + from twisted.internet import reactor + + call_from_thread = getattr(reactor, "callFromThread", None) + if callable(call_from_thread): + call_from_thread(process.stop) + else: + process.stop() + except Exception as error: + print( + f"worker[{args.job_id}:{args.execution_id}]: failed to stop reactor gracefully: {error}", + flush=True, + ) signal.signal(signal.SIGTERM, request_stop) signal.signal(signal.SIGINT, request_stop) - counters = { - "requests_count": 0, - "items_count": 0, - "warnings_count": 0, - "errors_count": 0, - "bytes_count": 0, - "retries_count": 0, - "exceptions_count": 0, - "cache_size_count": 0, - "cache_object_count": 0, - } + try: + source_config = _load_job_source_config( + db_path=args.db_path, job_id=args.job_id + ) + except Exception as error: + print( + f"worker[{args.job_id}:{args.execution_id}]: failed to load job config: {error}", + flush=True, + ) + return 1 + out_dir = Path(args.out_dir).resolve() + stats_path = Path(args.stats_path).resolve() + log_path = stats_path.with_suffix(".log") + + try: + feed = _resolve_feed( + source_config=source_config, + out_dir=out_dir, + log_path=log_path, + ) + process = CrawlerProcess( + _build_crawl_settings( + out_dir=out_dir, + feed=feed, + stats_path=stats_path, + ) + ) + print( + f"worker[{args.job_id}:{args.execution_id}]: starting crawl for {source_config.source_slug}", + flush=True, + ) + exit_code = _run_crawl( + process=process, + feed=feed, + spider_arguments=source_config.spider_arguments, + ) + except Exception as error: + print( + f"worker[{args.job_id}:{args.execution_id}]: crawl failed: {error}", + flush=True, + ) + return 1 + + if stop_requested: + print( + f"worker[{args.job_id}:{args.execution_id}]: stopping after graceful request", + flush=True, + ) + return 130 + + if exit_code == 0: + print( + f"worker[{args.job_id}:{args.execution_id}]: completed successfully", + flush=True, + ) + return exit_code + + +def _load_job_source_config(*, db_path: str, job_id: int) -> JobSourceConfig: + initialize_database(db_path) + primary_key = getattr(Job, "_meta").primary_key + with database.connection_context(): + job = ( + Job.select(Job, Source) + .join(Source) + .where(primary_key == job_id) + .get_or_none() + ) + if job is None: + raise ValueError(f"job {job_id} does not exist") + + source = job.source + spider_arguments = _parse_spider_arguments(job.spider_arguments) + if source.source_type == "feed": + feed = SourceFeed.get_or_none(SourceFeed.source == source) + if feed is None: + raise ValueError( + f"feed source {source.slug!r} is missing its feed config" + ) + return JobSourceConfig( + source_name=source.name, + source_slug=source.slug, + source_type=source.source_type, + spider_arguments=spider_arguments, + feed_url=feed.feed_url, + ) + + pangea = SourcePangea.get_or_none(SourcePangea.source == source) + if pangea is None: + raise ValueError( + f"pangea source {source.slug!r} is missing its pangea config" + ) + return JobSourceConfig( + source_name=source.name, + source_slug=source.slug, + source_type=source.source_type, + spider_arguments=spider_arguments, + pangea_domain=pangea.domain, + pangea_category=pangea.category_name, + content_type=pangea.content_type, + only_newest=bool(pangea.only_newest), + max_articles=int(pangea.max_articles), + oldest_article=int(pangea.oldest_article), + include_authors=bool(pangea.include_authors), + exclude_media=bool(pangea.exclude_media), + include_content=bool(pangea.include_content), + content_format=pangea.content_format, + ) + + +def _parse_spider_arguments(raw_value: str) -> dict[str, str]: + arguments: dict[str, str] = {} + for raw_line in raw_value.splitlines(): + line = raw_line.strip() + if line == "": + continue + key, separator, value = line.partition("=") + key = key.strip() + if separator == "" or key == "": + raise ValueError( + f"invalid spider argument {raw_line!r}; expected key=value" + ) + arguments[key] = value + return arguments + + +def _resolve_feed( + *, + source_config: JobSourceConfig, + out_dir: Path, + log_path: Path, +) -> FeedConfig: + if source_config.source_type == "feed": + assert source_config.feed_url is not None + return FeedConfig( + name=source_config.source_name, + slug=source_config.source_slug, + url=source_config.feed_url, + ) + + generated_feed_path = generate_pangea_feed( + name=source_config.source_name, + slug=source_config.source_slug, + domain=_require_value(source_config.pangea_domain, "pangea_domain"), + category_name=_require_value(source_config.pangea_category, "pangea_category"), + content_type=_require_value(source_config.content_type, "content_type"), + only_newest=source_config.only_newest, + max_articles=source_config.max_articles, + oldest_article=source_config.oldest_article, + include_authors=source_config.include_authors, + exclude_media=source_config.exclude_media, + include_content=source_config.include_content, + content_format=source_config.content_format, + out_dir=out_dir, + log_path=log_path.with_suffix(".pygea.log"), + ) print( - f"worker[{args.job_id}:{args.execution_id}]: starting simulated crawl", + f"pygea: generated intermediate feed at {generated_feed_path}", flush=True, ) - started = time.monotonic() - iteration = 0 - with stats_path.open("a", encoding="utf-8") as stats_file: - while time.monotonic() - started < args.duration_seconds: - time.sleep(args.interval_seconds) - iteration += 1 - counters["requests_count"] += rng.randint(1, 5) - counters["items_count"] += rng.randint(0, 2) - counters["bytes_count"] += rng.randint(500, 3000) - counters["cache_size_count"] += rng.randint(0, 1) - counters["cache_object_count"] += rng.randint(0, 2) - if rng.random() < 0.1: - counters["warnings_count"] += 1 - if rng.random() < 0.05: - counters["retries_count"] += 1 - - snapshot = { - "timestamp": datetime.now(UTC).isoformat(), - "iteration": iteration, - **counters, - } - stats_file.write(json.dumps(snapshot, sort_keys=True) + "\n") - stats_file.flush() - print( - "stats: " - f"requests={counters['requests_count']} " - f"items={counters['items_count']} " - f"bytes={counters['bytes_count']}", - flush=True, - ) - if stop_requested: - print( - f"worker[{args.job_id}:{args.execution_id}]: stopping after graceful request", - flush=True, - ) - return 130 - - if rng.random() < args.failure_probability: - counters["errors_count"] += 1 - counters["exceptions_count"] += 1 - stats_file.write( - json.dumps( - {"timestamp": datetime.now(UTC).isoformat(), **counters}, - sort_keys=True, - ) - + "\n" - ) - stats_file.flush() - print( - f"worker[{args.job_id}:{args.execution_id}]: simulated failure", - flush=True, - ) - return 1 - - print( - f"worker[{args.job_id}:{args.execution_id}]: completed successfully", - flush=True, + return FeedConfig( + name=source_config.source_name, + slug=source_config.source_slug, + url=generated_feed_path.as_uri(), ) - return 0 + + +def _build_crawl_settings(*, out_dir: Path, feed: FeedConfig, stats_path: Path): + base_settings = build_base_settings( + RepublisherConfig( + config_path=out_dir / "job-runner.toml", + out_dir=out_dir, + feeds=(feed,), + scrapy_settings={}, + ) + ) + prepare_output_dirs(out_dir, feed.slug) + settings = build_feed_settings(base_settings, out_dir=out_dir, feed_slug=feed.slug) + settings.set("LOG_FILE", None, priority="cmdline") + settings.set( + "STATS_CLASS", + "repub.job_runner.ExecutionStatsCollector", + priority="cmdline", + ) + settings.set("REPUB_JOB_STATS_PATH", str(stats_path), priority="cmdline") + return settings + + +def _run_crawl( + *, + process: CrawlerProcess, + feed: FeedConfig, + spider_arguments: dict[str, str], +) -> int: + results: list[Failure | None] = [] + deferred = process.crawl( + RssFeedSpider, + feed_name=feed.slug, + url=feed.url, + **spider_arguments, + ) + + def handle_success(_: object) -> None: + results.append(None) + return None + + def handle_error(failure: Failure) -> None: + print(failure.getTraceback(), flush=True) + results.append(failure) + return None + + deferred.addCallbacks(handle_success, handle_error) + process.start() + return 1 if any(result is not None for result in results) else 0 + + +def _require_value(value: str | None, field_name: str) -> str: + if value is None or value == "": + raise ValueError(f"missing {field_name}") + return value if __name__ == "__main__": diff --git a/repub/jobs.py b/repub/jobs.py index eeb4494..3ccec78 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -188,14 +188,12 @@ class JobRuntime: str(job_id), "--execution-id", str(execution_id), + "--db-path", + str(database.database), + "--out-dir", + str(self.log_dir.parent), "--stats-path", str(artifacts.stats_path), - "--duration-seconds", - str(self.worker_duration_seconds), - "--interval-seconds", - str(self.worker_stats_interval_seconds), - "--failure-probability", - str(self.worker_failure_probability), ], stdout=log_handle, stderr=subprocess.STDOUT, @@ -390,7 +388,7 @@ def load_runs_view( for job in jobs ), "completed": tuple( - _project_completed_execution(execution, resolved_log_dir) + _project_completed_execution(execution, resolved_log_dir, reference_time) for execution in completed_executions ), } @@ -401,6 +399,7 @@ def load_dashboard_view( ) -> dict[str, object]: reference_time = now or datetime.now(UTC) runs_view = load_runs_view(log_dir=log_dir, now=reference_time) + output_dir = Path(log_dir).parent with database.connection_context(): failed_last_day = ( JobExecution.select() @@ -414,7 +413,7 @@ def load_dashboard_view( upcoming_ready = sum( 1 for job in runs_view["upcoming"] if str(job["run_reason"]) == "Ready" ) - footprint_bytes = _directory_size(Path(log_dir)) + footprint_bytes = _directory_size(output_dir) return { "running": runs_view["running"], "snapshot": { @@ -538,7 +537,7 @@ def _project_upcoming_job( "slug": job.source.slug, "job_id": job_id, "next_run": ( - _humanize_future_time(reference_time, next_run) + _humanize_relative_time(reference_time, next_run) if next_run is not None else ("Running now" if running_execution is not None else "Not scheduled") ), @@ -565,7 +564,7 @@ def _project_upcoming_job( def _project_completed_execution( - execution: JobExecution, log_dir: Path + execution: JobExecution, log_dir: Path, reference_time: datetime ) -> dict[str, object]: job = cast(Job, execution.job) job_id = _job_id(job) @@ -573,18 +572,22 @@ def _project_completed_execution( artifacts = JobArtifacts.for_execution( log_dir=log_dir, job_id=job_id, execution_id=execution_id ) + ended_at = ( + _coerce_datetime(cast(datetime | str, execution.ended_at)) + if execution.ended_at is not None + else None + ) return { "source": job.source.name, "slug": job.source.slug, "job_id": job_id, "execution_id": execution_id, "ended_at": ( - _coerce_datetime(cast(datetime | str, execution.ended_at)).strftime( - "%Y-%m-%d %H:%M UTC" - ) - if execution.ended_at is not None + _humanize_relative_time(reference_time, ended_at) + if ended_at is not None else "Pending" ), + "ended_at_iso": ended_at.isoformat() if ended_at is not None else None, "status": _execution_status_label(execution), "status_tone": _execution_status_tone(execution), "stats": _stats_summary(execution), @@ -678,20 +681,25 @@ def _format_bytes(value: int) -> str: return f"{value / (1024 * 1024 * 1024):.1f} GB" -def _humanize_future_time(reference_time: datetime, target_time: datetime) -> str: +def _humanize_relative_time(reference_time: datetime, target_time: datetime) -> str: delta_seconds = int(round((target_time - reference_time).total_seconds())) - if delta_seconds <= 0: + if delta_seconds == 0: return "now" + absolute_delta_seconds = abs(delta_seconds) units = ( ("day", 24 * 60 * 60), ("hour", 60 * 60), ("minute", 60), ) for label, size in units: - if delta_seconds >= size: - count = max(1, round(delta_seconds / size)) + if absolute_delta_seconds >= size: + count = max(1, round(absolute_delta_seconds / size)) suffix = "" if count == 1 else "s" - return f"in {count} {label}{suffix}" + if delta_seconds > 0: + return f"in {count} {label}{suffix}" + return f"{count} {label}{suffix} ago" - return f"in {delta_seconds} seconds" + if delta_seconds > 0: + return f"in {absolute_delta_seconds} seconds" + return f"{absolute_delta_seconds} seconds ago" diff --git a/repub/media.py b/repub/media.py index b964d0a..53499cc 100644 --- a/repub/media.py +++ b/repub/media.py @@ -54,12 +54,25 @@ class VideoMeta(TypedDict): bit_rate: float +def _decode_ffmpeg_output(output: Any) -> str: + if isinstance(output, bytes): + return output.decode("utf-8", errors="replace") + return str(output) + + +def _print_ffmpeg_error_output(error: ffmpeg.Error) -> None: + if error.stderr: + print(_decode_ffmpeg_output(error.stderr), file=sys.stderr) + if error.stdout: + print(_decode_ffmpeg_output(error.stdout)) + + def probe_media(file_path) -> Dict[str, Any]: """Probes `file_path` using ffmpeg's ffprobe and returns the data.""" try: return ffmpeg.probe(file_path) except ffmpeg.Error as e: - print(e.stderr, file=sys.stderr) + _print_ffmpeg_error_output(e) logger.error(f"Failed to probe io {file_path}") logger.error(e) raise RuntimeError(f"Failed to probe io {file_path}") from e @@ -217,7 +230,7 @@ def transcode_audio(input_file: str, output_dir: str, params: Dict[str, str]) -> **params, loglevel="quiet", ) - .run() + .run(capture_stdout=True, capture_stderr=True) ) before = os.path.getsize(input_file) / 1024 after = os.path.getsize(output_file) / 1024 @@ -229,8 +242,7 @@ def transcode_audio(input_file: str, output_dir: str, params: Dict[str, str]) -> ) return output_file except ffmpeg.Error as e: - print(e.stderr, file=sys.stderr) - print(e.stdout) + _print_ffmpeg_error_output(e) logger.error(e) raise RuntimeError(f"Failed to compress audio {input_file}") from e @@ -310,7 +322,7 @@ def transcode_video(input_file: str, output_dir: str, params: Dict[str, Any]) -> **params, # loglevel="quiet", ) - .run() + .run(capture_stdout=True, capture_stderr=True) ) else: passes = params["passes"] @@ -323,16 +335,18 @@ def transcode_video(input_file: str, output_dir: str, params: Dict[str, Any]) -> "-stats" ) logger.info("Running pass #1") - std_out, std_err = ffoutput.run(capture_stdout=True) - print(std_out) - print(std_err) + ffoutput.run(capture_stdout=True, capture_stderr=True) logger.info("Running pass #2") ffoutput = ffinput.output(video, audio, output_file, **passes[1]) ffoutput = ffoutput.global_args( # "-loglevel", "quiet", "-stats" ) - ffoutput.run(overwrite_output=True) + ffoutput.run( + capture_stdout=True, + capture_stderr=True, + overwrite_output=True, + ) before = os.path.getsize(input_file) / 1024 after = os.path.getsize(output_file) / 1024 @@ -344,7 +358,7 @@ def transcode_video(input_file: str, output_dir: str, params: Dict[str, Any]) -> ) return output_file except ffmpeg.Error as e: - print(e.stderr, file=sys.stderr) + _print_ffmpeg_error_output(e) logger.error("Failed to transcode") logger.error(e) raise RuntimeError(f"Failed to transcode video: {e.stderr.decode()}") from e diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index 67b93a8..e58ffd1 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -118,7 +118,7 @@ def operational_snapshot(*, snapshot: Mapping[str, str] | None = None) -> Render stat_card( label="Artifact footprint", value=values["artifact_footprint"], - detail="Current log and stats artifact size under out/logs.", + detail="Current artifact size under the output path.", ), ], ] diff --git a/repub/pages/runs.py b/repub/pages/runs.py index 94acee7..c4d2eda 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -144,6 +144,20 @@ def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: + ended_at = _maybe_text(execution, "ended_at_iso") + ended_at_label: Node = h.p(class_="font-medium text-slate-900")[ + _text(execution, "ended_at") + ] + if ended_at is not None: + ended_at_label = h.time( + { + "data-ended-at": ended_at, + "title": ended_at, + }, + datetime=ended_at, + class_="font-medium text-slate-900", + )[_text(execution, "ended_at")] + return ( h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], @@ -157,7 +171,7 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: ], ], h.div[ - h.p(class_="font-medium text-slate-900")[_text(execution, "ended_at")], + ended_at_label, h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "summary")], ], status_badge( @@ -262,10 +276,12 @@ window.repubFormatNextRuns = window.repubFormatNextRuns || (() => { return relativeFormatter.format(0, 'second'); }; const format = () => { - document.querySelectorAll('time[data-next-run-at]').forEach((element) => { - const nextRunAt = element.getAttribute('data-next-run-at'); - if (!nextRunAt) return; - const targetDate = new Date(nextRunAt); + document.querySelectorAll('time[data-next-run-at], time[data-ended-at]').forEach((element) => { + const relativeAt = + element.getAttribute('data-next-run-at') ?? + element.getAttribute('data-ended-at'); + if (!relativeAt) return; + const targetDate = new Date(relativeAt); if (Number.isNaN(targetDate.getTime())) return; element.textContent = formatRelative(targetDate); element.title = absoluteFormatter.format(targetDate); diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index 60485c5..e6904a6 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -1,8 +1,10 @@ +import sys from pathlib import Path from types import SimpleNamespace import pytest +from repub import media from repub.config import ( FeedConfig, RepublisherConfig, @@ -48,3 +50,141 @@ def test_pipeline_from_crawler_uses_configured_store( assert pipeline.settings is crawler.settings assert pipeline.store.basedir == crawler.settings[store_setting] + + +def test_transcode_audio_captures_ffmpeg_output(monkeypatch, tmp_path: Path) -> None: + input_file = tmp_path / "input.mp3" + input_file.write_bytes(b"12345") + output_dir = tmp_path / "audio-out" + output_dir.mkdir() + run_calls: list[dict[str, object]] = [] + + class FakeOutput: + def __init__(self, output_path: Path): + self.output_path = output_path + + def run(self, **kwargs): + run_calls.append(kwargs) + self.output_path.write_bytes(b"12") + return b"", b"" + + class FakeInput: + def output(self, output_file: str, **params): + del params + return FakeOutput(Path(output_file)) + + monkeypatch.setattr(media.ffmpeg, "input", lambda _: FakeInput()) + + result = media.transcode_audio( + str(input_file), + str(output_dir), + {"extension": "mp3", "acodec": "libmp3lame"}, + ) + + assert result == str(output_dir / "converted.mp3") + assert run_calls == [{"capture_stdout": True, "capture_stderr": True}] + + +def test_transcode_video_two_pass_does_not_print_ffmpeg_output( + monkeypatch, tmp_path: Path +) -> None: + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"12345") + output_dir = tmp_path / "video-out" + output_dir.mkdir() + run_calls: list[dict[str, object]] = [] + printed: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + class FakeOutput: + def __init__(self, output_path: Path | None): + self.output_path = output_path + + def global_args(self, *args): + del args + return self + + def run(self, **kwargs): + run_calls.append(kwargs) + if self.output_path is not None: + self.output_path.write_bytes(b"12") + return b"pass-out", b"pass-err" + + class FakeInput: + video = object() + audio = object() + + def output(self, *args, **params): + del params + output_path = next( + ( + Path(arg) + for arg in args + if isinstance(arg, str) and arg.endswith(".mp4") + ), + None, + ) + return FakeOutput(output_path) + + monkeypatch.setattr(media.ffmpeg, "input", lambda _: FakeInput()) + monkeypatch.setattr( + "builtins.print", lambda *args, **kwargs: printed.append((args, kwargs)) + ) + + result = media.transcode_video( + str(input_file), + str(output_dir), + { + "extension": "mp4", + "passes": [ + {"f": "null"}, + {"c:v": "libx264"}, + ], + }, + ) + + assert result == str(output_dir / "converted.mp4") + assert run_calls == [ + {"capture_stdout": True, "capture_stderr": True}, + { + "capture_stdout": True, + "capture_stderr": True, + "overwrite_output": True, + }, + ] + assert printed == [] + + +def test_transcode_video_prints_ffmpeg_output_on_error( + monkeypatch, tmp_path: Path +) -> None: + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"12345") + output_dir = tmp_path / "video-out" + output_dir.mkdir() + printed: list[tuple[str, bool]] = [] + + class FakeOutput: + def run(self, **kwargs): + del kwargs + raise media.ffmpeg.Error("ffmpeg", b"video-stdout", b"video-stderr") + + class FakeInput: + def output(self, *args, **params): + del args, params + return FakeOutput() + + def fake_print(*args, **kwargs): + printed.append((str(args[0]), kwargs.get("file") is sys.stderr)) + + monkeypatch.setattr(media.ffmpeg, "input", lambda _: FakeInput()) + monkeypatch.setattr("builtins.print", fake_print) + + with pytest.raises(RuntimeError): + media.transcode_video( + str(input_file), + str(output_dir), + {"extension": "mp4", "c:v": "libx264"}, + ) + + assert ("video-stderr", True) in printed + assert ("video-stdout", False) in printed diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index a2059d4..30385e5 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -2,10 +2,15 @@ from __future__ import annotations import asyncio import json +import socketserver +import threading import time +from datetime import UTC, datetime, timedelta +from http.server import BaseHTTPRequestHandler from pathlib import Path -from repub.jobs import JobArtifacts, JobRuntime +from repub.job_runner import generate_pangea_feed +from repub.jobs import JobArtifacts, JobRuntime, load_runs_view from repub.model import ( Job, JobExecution, @@ -16,6 +21,10 @@ from repub.model import ( ) from repub.web import create_app, get_job_runtime, render_execution_logs, render_runs +FIXTURE_FEED_PATH = ( + Path(__file__).resolve().parents[1] / "demo" / "fixtures" / "local-feed.rss" +).resolve() + def test_job_runtime_syncs_enabled_jobs_into_apscheduler(tmp_path: Path) -> None: initialize_database(tmp_path / "scheduler.db") @@ -91,7 +100,7 @@ def test_job_runtime_run_now_writes_log_and_stats_and_marks_success( cron_day_of_month="*", cron_day_of_week="*", cron_month="*", - feed_url="https://example.com/manual.xml", + feed_url=FIXTURE_FEED_PATH.as_uri(), ) job = Job.get(Job.source == source) @@ -120,9 +129,11 @@ def test_job_runtime_run_now_writes_log_and_stats_and_marks_success( assert execution.bytes_count > 0 assert artifacts.log_path.exists() assert artifacts.stats_path.exists() - assert "starting simulated crawl" in artifacts.log_path.read_text( - encoding="utf-8" - ) + output_path = tmp_path / "out" / "manual-source.rss" + assert output_path.exists() + output_text = output_path.read_text(encoding="utf-8") + assert "Local Demo Feed" in output_text + assert "Local Demo Entry" in output_text stats_lines = [ json.loads(line) @@ -136,50 +147,51 @@ def test_job_runtime_run_now_writes_log_and_stats_and_marks_success( def test_job_runtime_cancel_marks_execution_canceled(tmp_path: Path) -> None: initialize_database(tmp_path / "cancel.db") - source = create_source( - name="Cancelable source", - slug="cancelable-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/cancelable.xml", - ) - job = Job.get(Job.source == source) + with _slow_feed_server() as feed_url: + source = create_source( + name="Cancelable source", + slug="cancelable-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url=feed_url, + ) + job = Job.get(Job.source == source) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=2.0, - worker_stats_interval_seconds=0.1, - worker_failure_probability=0.0, - ) - try: - runtime.start() - execution_id = runtime.run_job_now(job.id, reason="manual") - assert execution_id is not None - _wait_for_running_execution(execution_id) - - runtime.request_execution_cancel(execution_id) - execution = _wait_for_terminal_execution(execution_id) - artifacts = JobArtifacts.for_execution( + runtime = JobRuntime( log_dir=tmp_path / "out" / "logs", - job_id=job.id, - execution_id=execution_id, + worker_duration_seconds=2.0, + worker_stats_interval_seconds=0.1, + worker_failure_probability=0.0, ) + try: + runtime.start() + execution_id = runtime.run_job_now(job.id, reason="manual") + assert execution_id is not None + _wait_for_running_execution(execution_id) - assert execution.running_status == JobExecutionStatus.CANCELED - assert execution.ended_at is not None - assert execution.stop_requested_at is not None - assert "graceful stop requested" in artifacts.log_path.read_text( - encoding="utf-8" - ) - finally: - runtime.shutdown() + runtime.request_execution_cancel(execution_id) + execution = _wait_for_terminal_execution(execution_id) + artifacts = JobArtifacts.for_execution( + log_dir=tmp_path / "out" / "logs", + job_id=job.id, + execution_id=execution_id, + ) + + assert execution.running_status == JobExecutionStatus.CANCELED + assert execution.ended_at is not None + assert execution.stop_requested_at is not None + assert "graceful stop requested" in artifacts.log_path.read_text( + encoding="utf-8" + ) + finally: + runtime.shutdown() def test_job_runtime_start_reconciles_stale_running_execution(tmp_path: Path) -> None: @@ -234,6 +246,93 @@ def test_job_runtime_start_reconciles_stale_running_execution(tmp_path: Path) -> runtime.shutdown() +def test_generate_pangea_feed_writes_rss_file(monkeypatch, tmp_path: Path) -> None: + class StubPangeaFeed: + def __init__(self, config, feeds): + self.config = config + self.feed = feeds[0] + + def acquire_content(self) -> None: + return None + + def generate_feed(self) -> None: + return None + + def disgorge(self, slug: str): + output_path = self.config.results.output_directory / slug / "rss.xml" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + "Pangea Fixture\n", + encoding="utf-8", + ) + return output_path + + monkeypatch.setattr( + "repub.job_runner.pangea_feed_class", + lambda: StubPangeaFeed, + ) + + output_path = generate_pangea_feed( + name="Pangea source", + slug="pangea-source", + domain="example.org", + category_name="News", + content_type="articles", + only_newest=True, + max_articles=10, + oldest_article=3, + include_authors=True, + exclude_media=False, + include_content=True, + content_format="MOBILE_3", + out_dir=tmp_path / "out", + log_path=tmp_path / "out" / "logs" / "pangea.log", + ) + + assert output_path == (tmp_path / "out" / "pangea-source" / "rss.xml") + assert output_path.exists() + assert "Pangea Fixture" in output_path.read_text(encoding="utf-8") + + +def test_load_runs_view_humanizes_completed_execution_end_time( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "runs-view.db" + log_dir = tmp_path / "out" / "logs" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + app = create_app() + app.config["REPUB_LOG_DIR"] = log_dir + source = create_source( + name="Completed source", + slug="completed-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/completed.xml", + ) + job = Job.get(Job.source == source) + reference_time = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) + ended_at = reference_time - timedelta(hours=2) + JobExecution.create( + job=job, + running_status=JobExecutionStatus.SUCCEEDED, + ended_at=ended_at, + ) + + view = load_runs_view(log_dir=app.config["REPUB_LOG_DIR"], now=reference_time) + completed = view["completed"][0] + + assert completed["ended_at"] == "2 hours ago" + assert completed["ended_at_iso"] == ended_at.isoformat() + + def test_render_runs_uses_database_backed_jobs_and_executions( monkeypatch, tmp_path: Path ) -> None: @@ -259,7 +358,7 @@ def test_render_runs_uses_database_backed_jobs_and_executions( cron_day_of_month="*", cron_day_of_week="*", cron_month="*", - feed_url="https://example.com/runs-page.xml", + feed_url=FIXTURE_FEED_PATH.as_uri(), ) job = Job.get(Job.source == source) runtime = get_job_runtime(app) @@ -396,3 +495,41 @@ def _wait_for_terminal_execution( return execution time.sleep(0.02) raise AssertionError(f"execution {execution_id} did not finish in time") + + +class _SlowFeedRequestHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + time.sleep(2.0) + payload = FIXTURE_FEED_PATH.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "application/rss+xml; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args: object) -> None: + del format, args + + +class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + + +class _slow_feed_server: + def __enter__(self) -> str: + self._server = _ThreadedTCPServer(("127.0.0.1", 0), _SlowFeedRequestHandler) + self._thread = threading.Thread( + target=self._server.serve_forever, + kwargs={"poll_interval": 0.01}, + daemon=True, + ) + self._thread.start() + host = str(self._server.server_address[0]) + port = int(self._server.server_address[1]) + return f"http://{host}:{port}/slow-feed.rss" + + def __exit__(self, exc_type, exc, tb) -> None: + del exc_type, exc, tb + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=1) diff --git a/tests/test_web.py b/tests/test_web.py index cc7deb8..1486367 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -6,6 +6,7 @@ from typing import Any, cast from repub.components import status_badge from repub.datastar import RefreshBroker, render_sse_event, render_stream +from repub.jobs import load_dashboard_view from repub.model import ( Job, JobExecution, @@ -15,6 +16,7 @@ from repub.model import ( SourcePangea, create_source, ) +from repub.pages.runs import runs_page from repub.web import ( create_app, get_refresh_broker, @@ -34,6 +36,37 @@ def test_status_badge_uses_green_done_tone() -> None: assert "Succeeded" in badge +def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> ( + None +): + ended_at = "2026-01-15T10:00:00+00:00" + body = str( + runs_page( + completed_executions=( + { + "source": "Completed source", + "slug": "completed-source", + "job_id": 7, + "execution_id": 42, + "ended_at": "2 hours ago", + "ended_at_iso": ended_at, + "status": "Succeeded", + "status_tone": "done", + "stats": "1 requests • 1 items • 1 bytes", + "summary": "Worker exited successfully", + "log_href": "/job/7/execution/42/logs", + }, + ) + ) + ) + + assert "data-ended-at" in body + assert f'data-ended-at="{ended_at}"' in body + assert f'datetime="{ended_at}"' in body + assert f'title="{ended_at}"' in body + assert ">2 hours ago<" in body + + def test_root_get_serves_datastar_shim() -> None: async def run() -> None: client = create_app().test_client() @@ -179,6 +212,40 @@ def test_render_dashboard_shows_dashboard_information_architecture( asyncio.run(run()) +def test_load_dashboard_view_measures_log_artifact_path( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "dashboard-footprint.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + create_app() + out_dir = tmp_path / "out" + log_dir = out_dir / "logs" + cache_dir = out_dir / "httpcache" + log_dir.mkdir(parents=True) + cache_dir.mkdir(parents=True) + (log_dir / "run.log").write_bytes(b"x" * 1024) + (cache_dir / "cache.bin").write_bytes(b"y" * 2048) + + snapshot = load_dashboard_view(log_dir=log_dir)["snapshot"] + + assert cast(dict[str, str], snapshot)["artifact_footprint"] == "3.0 KB" + + +def test_render_dashboard_describes_log_artifact_footprint( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "dashboard-footprint-copy.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + body = str(await render_dashboard(app)) + + assert "Current artifact size under the output path." in body + + asyncio.run(run()) + + def test_render_sources_shows_table_and_create_link() -> None: async def run() -> None: body = str(await render_sources()) From 36cf98a91c390ce0a6d256b55c34197bc5293e7d Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:10:47 +0200 Subject: [PATCH 16/23] fix output paths --- .gitignore | 2 ++ repub/config.py | 2 +- repub/job_runner.py | 2 +- tests/test_config.py | 2 +- tests/test_file_feeds.py | 2 +- tests/test_scheduler_runtime.py | 10 ++++++---- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index bf0de74..6358b46 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ logs archive *egg-info *.db +*.db-shm +*.db-wal diff --git a/repub/config.py b/repub/config.py index 38cbf56..517d69c 100644 --- a/repub/config.py +++ b/repub/config.py @@ -192,7 +192,7 @@ def build_feed_settings( { "REPUBLISHER_OUT_DIR": str(out_dir), "FEEDS": { - str(out_dir / f"{feed_slug}.rss"): { + str(feed_dir / "feed.rss"): { "format": "rss", "postprocessing": [], "feed_name": feed_slug, diff --git a/repub/job_runner.py b/repub/job_runner.py index 9ad69c7..28fb025 100644 --- a/repub/job_runner.py +++ b/repub/job_runner.py @@ -160,7 +160,7 @@ def generate_pangea_feed( ), results=ResultsConfig( output_to_file_p=True, - output_file_name="rss.xml", + output_file_name="pangea.rss", output_directory=resolved_out_dir, ), logging=LoggingConfig( diff --git a/tests/test_config.py b/tests/test_config.py index 55d7063..23c4830 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -146,7 +146,7 @@ def test_build_feed_settings_derives_output_paths_from_feed_slug( assert feed_settings["VIDEO_STORE"] == str(out_dir / "info-marti" / "video") assert feed_settings["FILES_STORE"] == str(out_dir / "info-marti" / "files") assert feed_settings["FEEDS"] == { - str(out_dir / "info-marti.rss"): { + str(out_dir / "info-marti" / "feed.rss"): { "format": "rss", "postprocessing": [], "feed_name": "info-marti", diff --git a/tests/test_file_feeds.py b/tests/test_file_feeds.py index 835bc8e..b63dab1 100644 --- a/tests/test_file_feeds.py +++ b/tests/test_file_feeds.py @@ -29,7 +29,7 @@ DOWNLOAD_TIMEOUT = 5 exit_code = entrypoint_module.entrypoint(["--config", str(config_path)]) - output_path = tmp_path / "out" / "local-file.rss" + output_path = tmp_path / "out" / "local-file" / "feed.rss" assert exit_code == 0 assert output_path.exists() output = output_path.read_text(encoding="utf-8") diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index 30385e5..05e9623 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -129,7 +129,7 @@ def test_job_runtime_run_now_writes_log_and_stats_and_marks_success( assert execution.bytes_count > 0 assert artifacts.log_path.exists() assert artifacts.stats_path.exists() - output_path = tmp_path / "out" / "manual-source.rss" + output_path = tmp_path / "out" / "manual-source" / "feed.rss" assert output_path.exists() output_text = output_path.read_text(encoding="utf-8") assert "Local Demo Feed" in output_text @@ -246,7 +246,9 @@ def test_job_runtime_start_reconciles_stale_running_execution(tmp_path: Path) -> runtime.shutdown() -def test_generate_pangea_feed_writes_rss_file(monkeypatch, tmp_path: Path) -> None: +def test_generate_pangea_feed_writes_pangea_rss_file( + monkeypatch, tmp_path: Path +) -> None: class StubPangeaFeed: def __init__(self, config, feeds): self.config = config @@ -259,7 +261,7 @@ def test_generate_pangea_feed_writes_rss_file(monkeypatch, tmp_path: Path) -> No return None def disgorge(self, slug: str): - output_path = self.config.results.output_directory / slug / "rss.xml" + output_path = self.config.results.output_directory / slug / "pangea.rss" output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text( "Pangea Fixture\n", @@ -289,7 +291,7 @@ def test_generate_pangea_feed_writes_rss_file(monkeypatch, tmp_path: Path) -> No log_path=tmp_path / "out" / "logs" / "pangea.log", ) - assert output_path == (tmp_path / "out" / "pangea-source" / "rss.xml") + assert output_path == (tmp_path / "out" / "pangea-source" / "pangea.rss") assert output_path.exists() assert "Pangea Fixture" in output_path.read_text(encoding="utf-8") From beac98104720913312a5caf6d5d7c5274ae5aac7 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:20:27 +0200 Subject: [PATCH 17/23] update readme --- README.md | 70 +++++++++++++++++++++++++------------------------------ 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index b7353ec..ec2a848 100644 --- a/README.md +++ b/README.md @@ -4,60 +4,55 @@ The AnyNews Republisher is a tool for mirroring news content to alternative dist The organization with the original news content is the "publisher". -The AnyNews Republisher can be configured with various publisher news sources. Then on an interval the Republisher crawls the sources, mirrors the content (text and media) offline into an RSS feed. +The AnyNews Republisher is managed through a local web UI. Sources, schedules, and job executions are stored in SQLite. On an interval the Republisher crawls the configured sources and mirrors the content (text and media) offline into an RSS feed. The [AnyNews app][app] can then be configured to use this mirror (or more than one such mirror). The Republisher currently accepts the following source input types: -- RSS Feeds +- RSS and Atom feeds +- Pangea sources via `pygea` [app]: https://gitlab.com/guardianproject/anynews/anynews-web-client +## Usage +Sync dependencies and start the admin UI: -``` shell -nix develop +```sh uv sync --all-groups -cat > repub.toml <<'EOF' -out_dir = "out" - -[[feeds]] -name = "Guardian Project Podcast" -slug = "gp-pod" -url = "https://guardianproject.info/podcast/podcast.xml" - -[[feeds]] -name = "NASA Breaking News" -slug = "nasa" -url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" -EOF -uv run repub --config repub.toml +uv run repub ``` -`out_dir` may be relative or absolute. Relative paths are resolved against the -directory containing the config file. Each feed now needs a user-provided -`slug`, which is used for output paths and filenames. Optional Scrapy runtime -overrides can be set in the same file: +By default the UI listens on `127.0.0.1:8080`. You can override that with `REPUB_HOST` and `REPUB_PORT`, or with: -```toml -[scrapy.settings] -LOG_LEVEL = "DEBUG" -DOWNLOAD_TIMEOUT = 30 +```sh +uv run repub serve --host 0.0.0.0 --port 8080 ``` -Additional feed definitions can also be imported from one or more TOML files, -including a `pygea`-generated `manifest.toml`: +Important: the admin UI has no built-in authentication. Keep it bound to localhost or put it behind a trusted network layer such as Tailscale. -```toml -feed_config_files = ["/absolute/path/to/pygea/feed/manifest.toml"] +Once the UI is running: + +1. Open `http://127.0.0.1:8080/`. +2. Create a source. Feed sources take a feed URL. Pangea sources take a domain plus category configuration. +3. Configure the job schedule and any spider arguments. +4. Use `Run now` to trigger an immediate crawl, or leave the job enabled for scheduled runs. +5. Watch running jobs and logs live from the Runs pages. + +Operational notes: + +- The default database path is `republisher.db`. Set `REPUBLISHER_DB_PATH` to use a different SQLite file. +- Mirrored feeds are written under `out/feeds//`. +- Job logs and stats artifacts are written under `out/logs/`. + +The legacy one-shot config-driven crawler is still available: + +```sh +uv run repub crawl -c repub.toml ``` -Imported files only need `[[feeds]]` entries with `name`, `slug`, and `url`. - -See [`demo/README.md`](/home/abel/src/guardianproject/anynews/republisher-redux/demo/README.md) for a self-contained example config. - -## TODO +## Roadmap - [x] Offlines RSS feed xml - [x] Downloads media and enclosures @@ -68,9 +63,8 @@ See [`demo/README.md`](/home/abel/src/guardianproject/anynews/republisher-redux/ - [ ] Image compression - Do we want this? -> DEFERED for now - [x] Download and rewrite media embedded in content/CDATA fields - [x] Config file to drive the program -- [ ] Add sqlite database and simple admin UI to replace config -- [ ] Integrate pygea as input source -- [ ] Daemonize the program +- [x] Add sqlite database and simple admin UI to replace config +- [x] Integrate pygea as input source - [ ] Operationalize with metrics and error reporting ## License From 6fd3b598ab11b7f33edfb9cdcb498428db338350 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:21:39 +0200 Subject: [PATCH 18/23] output to out/feeds/* --- repub/config.py | 12 ++- repub/crawl.py | 5 +- repub/job_runner.py | 4 +- repub/jobs.py | 35 ++++++++ repub/pages/dashboard.py | 53 ++++++++++++ repub/spiders/rss_spider.py | 6 +- repub/web.py | 1 + tests/test_config.py | 26 ++++-- tests/test_file_feeds.py | 30 ++++++- tests/test_scheduler_runtime.py | 4 +- tests/test_web.py | 138 ++++++++++++++++++++++++++++++++ 11 files changed, 298 insertions(+), 16 deletions(-) diff --git a/repub/config.py b/repub/config.py index 517d69c..62a8376 100644 --- a/repub/config.py +++ b/repub/config.py @@ -30,6 +30,14 @@ class RepublisherConfig: scrapy_settings: dict[str, Any] +def feed_output_dir(*, out_dir: Path, feed_slug: str) -> Path: + return out_dir / "feeds" / feed_slug + + +def feed_output_path(*, out_dir: Path, feed_slug: str) -> Path: + return feed_output_dir(out_dir=out_dir, feed_slug=feed_slug) / "feed.rss" + + def _resolve_path(base_path: Path, value: str) -> Path: path = Path(value).expanduser() if not path.is_absolute(): @@ -173,7 +181,7 @@ def build_feed_settings( out_dir: Path, feed_slug: str, ) -> Settings: - feed_dir = out_dir / feed_slug + feed_dir = feed_output_dir(out_dir=out_dir, feed_slug=feed_slug) image_dir = base_settings.get("REPUBLISHER_IMAGE_DIR", IMAGE_DIR) video_dir = base_settings.get("REPUBLISHER_VIDEO_DIR", VIDEO_DIR) audio_dir = base_settings.get("REPUBLISHER_AUDIO_DIR", AUDIO_DIR) @@ -192,7 +200,7 @@ def build_feed_settings( { "REPUBLISHER_OUT_DIR": str(out_dir), "FEEDS": { - str(feed_dir / "feed.rss"): { + str(feed_output_path(out_dir=out_dir, feed_slug=feed_slug)): { "format": "rss", "postprocessing": [], "feed_name": feed_slug, diff --git a/repub/crawl.py b/repub/crawl.py index 8b36142..afa789f 100644 --- a/repub/crawl.py +++ b/repub/crawl.py @@ -11,6 +11,7 @@ from repub.config import ( FeedConfig, build_base_settings, build_feed_settings, + feed_output_dir, load_config, ) from repub.media import check_runtime @@ -30,7 +31,9 @@ class FeedNameFilter: def prepare_output_dirs(out_dir: Path, feed_name: str) -> None: (out_dir / "logs").mkdir(parents=True, exist_ok=True) (out_dir / "httpcache").mkdir(parents=True, exist_ok=True) - (out_dir / feed_name).mkdir(parents=True, exist_ok=True) + feed_output_dir(out_dir=out_dir, feed_slug=feed_name).mkdir( + parents=True, exist_ok=True + ) def create_feed_crawler( diff --git a/repub/job_runner.py b/repub/job_runner.py index 28fb025..5419cbd 100644 --- a/repub/job_runner.py +++ b/repub/job_runner.py @@ -19,6 +19,7 @@ from repub.config import ( RepublisherConfig, build_base_settings, build_feed_settings, + feed_output_dir, ) from repub.crawl import prepare_output_dirs from repub.model import ( @@ -136,6 +137,7 @@ def generate_pangea_feed( ) -> Path: resolved_out_dir = Path(out_dir).resolve() resolved_log_path = Path(log_path).resolve() + pangea_out_dir = feed_output_dir(out_dir=resolved_out_dir, feed_slug=slug) config = PygeaConfig( config_path=resolved_out_dir / "pygea-runtime.toml", domain=domain, @@ -161,7 +163,7 @@ def generate_pangea_feed( results=ResultsConfig( output_to_file_p=True, output_file_name="pangea.rss", - output_directory=resolved_out_dir, + output_directory=pangea_out_dir.parent, ), logging=LoggingConfig( log_file=resolved_log_path, diff --git a/repub/jobs.py b/repub/jobs.py index 3ccec78..9c2a598 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -11,6 +11,7 @@ from typing import Callable, TextIO, cast from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger +from repub.config import feed_output_dir, feed_output_path from repub.model import Job, JobExecution, JobExecutionStatus, Source, database, utc_now SCHEDULER_JOB_PREFIX = "job-" @@ -401,6 +402,7 @@ def load_dashboard_view( runs_view = load_runs_view(log_dir=log_dir, now=reference_time) output_dir = Path(log_dir).parent with database.connection_context(): + sources = tuple(Source.select().order_by(Source.name.asc())) failed_last_day = ( JobExecution.select() .where( @@ -416,6 +418,10 @@ def load_dashboard_view( footprint_bytes = _directory_size(output_dir) return { "running": runs_view["running"], + "source_feeds": tuple( + _project_source_feed(source, output_dir, reference_time) + for source in sources + ), "snapshot": { "running_now": str(len(runs_view["running"])), "upcoming_today": str(upcoming_ready), @@ -605,6 +611,35 @@ def _project_completed_execution( } +def _project_source_feed( + source: Source, output_dir: Path, reference_time: datetime +) -> dict[str, object]: + source_slug = str(source.slug) + source_dir = feed_output_dir(out_dir=output_dir, feed_slug=source_slug) + feed_path = feed_output_path(out_dir=output_dir, feed_slug=source_slug) + feed_exists = feed_path.exists() + updated_at = ( + datetime.fromtimestamp(feed_path.stat().st_mtime, tz=UTC) + if feed_exists + else None + ) + return { + "source": source.name, + "slug": source_slug, + "feed_href": f"/feeds/{source_slug}/feed.rss", + "feed_status_label": "Available" if feed_exists else "Missing", + "feed_status_tone": "done" if feed_exists else "failed", + "feed_exists": feed_exists, + "last_updated": ( + _humanize_relative_time(reference_time, updated_at) + if updated_at is not None + else "Never published" + ), + "last_updated_iso": updated_at.isoformat() if updated_at is not None else None, + "artifact_footprint": _format_bytes(_directory_size(source_dir)), + } + + def _execution_status_label(execution: JobExecution) -> str: status = JobExecutionStatus(execution.running_status) return { diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index e58ffd1..6e3ce3b 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -13,6 +13,7 @@ from repub.components import ( muted_action_link, stat_card, status_badge, + table_section, ) @@ -188,6 +189,56 @@ def running_executions_table( ] +def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]: + last_updated_iso = source_feed.get("last_updated_iso") + last_updated = ( + h.time( + datetime=str(last_updated_iso), + title=str(last_updated_iso), + class_="font-medium text-slate-900", + )[str(source_feed["last_updated"])] + if last_updated_iso is not None + else h.p(class_="font-medium text-slate-900")[str(source_feed["last_updated"])] + ) + return ( + h.div[ + h.div(class_="font-semibold text-slate-950")[str(source_feed["source"])], + h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[ + str(source_feed["slug"]) + ], + ], + h.div(class_="min-w-64")[ + inline_link( + href=str(source_feed["feed_href"]), + label=str(source_feed["feed_href"]), + tone="amber", + ) + ], + status_badge( + label=str(source_feed["feed_status_label"]), + tone=str(source_feed["feed_status_tone"]), + ), + last_updated, + h.p(class_="font-medium text-slate-900")[ + str(source_feed["artifact_footprint"]) + ], + ) + + +def published_feeds_table( + *, source_feeds: tuple[Mapping[str, object], ...] | None = None +) -> Renderable: + rows = tuple(_source_feed_row(source_feed) for source_feed in (source_feeds or ())) + return table_section( + eyebrow="Published feeds", + title="Published feeds", + subtitle="Per-source public feed paths under /feeds, with current availability and disk usage.", + headers=("Source", "Feed URL", "Status", "Last updated", "Disk usage"), + rows=rows, + actions=muted_action_link(href="/sources", label="Manage sources"), + ) + + def dashboard_page() -> Renderable: return dashboard_page_with_data() @@ -196,6 +247,7 @@ def dashboard_page_with_data( *, snapshot: Mapping[str, str] | None = None, running_executions: tuple[Mapping[str, object], ...] | None = None, + source_feeds: tuple[Mapping[str, object], ...] | None = None, ) -> Renderable: return h.main( id="morph", @@ -207,6 +259,7 @@ def dashboard_page_with_data( dashboard_header(), operational_snapshot(snapshot=snapshot), running_executions_table(running_executions=running_executions), + published_feeds_table(source_feeds=source_feeds), ] ], ] diff --git a/repub/spiders/rss_spider.py b/repub/spiders/rss_spider.py index ac3180d..29ccc92 100644 --- a/repub/spiders/rss_spider.py +++ b/repub/spiders/rss_spider.py @@ -8,7 +8,7 @@ from scrapy.utils.spider import iterate_spider_output from repub.items import ChannelElementItem, ElementItem from repub.rss import CDATA, CONTENT, ITUNES, MEDIA, E, munge_cdata_html, normalize_date -from repub.utils import FileType, determine_file_type, local_file_path +from repub.utils import FileType, determine_file_type, local_file_path, local_image_path class BaseRssFeedSpider(Spider): @@ -34,13 +34,15 @@ class BaseRssFeedSpider(Spider): def rewrite_file_url(self, file_type: FileType, url): file_dir = self.settings["REPUBLISHER_FILE_DIR"] + local_path = local_file_path(url) if file_type == FileType.IMAGE: file_dir = self.settings["REPUBLISHER_IMAGE_DIR"] + local_path = local_image_path(url) elif file_type == FileType.VIDEO: file_dir = self.settings["REPUBLISHER_VIDEO_DIR"] elif file_type == FileType.AUDIO: file_dir = self.settings["REPUBLISHER_AUDIO_DIR"] - return f"/{file_dir}/{local_file_path(url)}" + return f"{file_dir}/{local_path}" def rewrite_image_url(self, url): return self.rewrite_file_url(FileType.IMAGE, url) diff --git a/repub/web.py b/repub/web.py index f380bb4..06341d3 100644 --- a/repub/web.py +++ b/repub/web.py @@ -284,6 +284,7 @@ async def render_dashboard(app: Quart | None = None) -> Renderable: return dashboard_page_with_data( snapshot=cast(dict[str, str], view["snapshot"]), running_executions=cast(tuple[dict[str, object], ...], view["running"]), + source_feeds=cast(tuple[dict[str, object], ...], view["source_feeds"]), ) diff --git a/tests/test_config.py b/tests/test_config.py index 23c4830..34da4ea 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -141,12 +141,20 @@ def test_build_feed_settings_derives_output_paths_from_feed_slug( assert feed_settings["REPUBLISHER_OUT_DIR"] == str(out_dir) assert feed_settings["LOG_FILE"] == str(out_dir / "logs" / "info-marti.log") assert feed_settings["HTTPCACHE_DIR"] == str(out_dir / "httpcache") - assert feed_settings["IMAGES_STORE"] == str(out_dir / "info-marti" / "images") - assert feed_settings["AUDIO_STORE"] == str(out_dir / "info-marti" / "audio") - assert feed_settings["VIDEO_STORE"] == str(out_dir / "info-marti" / "video") - assert feed_settings["FILES_STORE"] == str(out_dir / "info-marti" / "files") + assert feed_settings["IMAGES_STORE"] == str( + out_dir / "feeds" / "info-marti" / "images" + ) + assert feed_settings["AUDIO_STORE"] == str( + out_dir / "feeds" / "info-marti" / "audio" + ) + assert feed_settings["VIDEO_STORE"] == str( + out_dir / "feeds" / "info-marti" / "video" + ) + assert feed_settings["FILES_STORE"] == str( + out_dir / "feeds" / "info-marti" / "files" + ) assert feed_settings["FEEDS"] == { - str(out_dir / "info-marti" / "feed.rss"): { + str(out_dir / "feeds" / "info-marti" / "feed.rss"): { "format": "rss", "postprocessing": [], "feed_name": "info-marti", @@ -181,5 +189,9 @@ def test_build_feed_settings_uses_runtime_media_dir_overrides(tmp_path: Path) -> assert feed_settings["REPUBLISHER_VIDEO_DIR"] == "videos-custom" assert feed_settings["REPUBLISHER_AUDIO_DIR"] == "audio-custom" - assert feed_settings["VIDEO_STORE"] == str(out_dir / "gp-pod" / "videos-custom") - assert feed_settings["AUDIO_STORE"] == str(out_dir / "gp-pod" / "audio-custom") + assert feed_settings["VIDEO_STORE"] == str( + out_dir / "feeds" / "gp-pod" / "videos-custom" + ) + assert feed_settings["AUDIO_STORE"] == str( + out_dir / "feeds" / "gp-pod" / "audio-custom" + ) diff --git a/tests/test_file_feeds.py b/tests/test_file_feeds.py index b63dab1..1518898 100644 --- a/tests/test_file_feeds.py +++ b/tests/test_file_feeds.py @@ -1,6 +1,10 @@ from pathlib import Path +from scrapy.settings import Settings + from repub import entrypoint as entrypoint_module +from repub.spiders.rss_spider import RssFeedSpider +from repub.utils import FileType, local_audio_path, local_image_path def test_entrypoint_supports_file_feed_urls(tmp_path: Path, monkeypatch) -> None: @@ -29,9 +33,33 @@ DOWNLOAD_TIMEOUT = 5 exit_code = entrypoint_module.entrypoint(["--config", str(config_path)]) - output_path = tmp_path / "out" / "local-file" / "feed.rss" + output_path = tmp_path / "out" / "feeds" / "local-file" / "feed.rss" assert exit_code == 0 assert output_path.exists() output = output_path.read_text(encoding="utf-8") assert "Local Demo Feed" in output assert "Local Demo Entry" in output + + +def test_rss_spider_rewrites_public_asset_urls_as_relative_paths() -> None: + spider = RssFeedSpider(feed_name="demo", url="https://example.com/feed.rss") + spider.settings = Settings( + values={ + "REPUBLISHER_IMAGE_DIR": "images", + "REPUBLISHER_FILE_DIR": "files", + "REPUBLISHER_AUDIO_DIR": "audio", + "REPUBLISHER_VIDEO_DIR": "video", + } + ) + + assert ( + spider.rewrite_image_url("https://example.com/media/photo.jpg") + == f"images/{local_image_path('https://example.com/media/photo.jpg')}" + ) + assert ( + spider.rewrite_file_url( + FileType.AUDIO, + "https://example.com/media/podcast.mp3", + ) + == f"audio/{local_audio_path('https://example.com/media/podcast.mp3')}" + ) diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index 05e9623..22f9144 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -129,7 +129,7 @@ def test_job_runtime_run_now_writes_log_and_stats_and_marks_success( assert execution.bytes_count > 0 assert artifacts.log_path.exists() assert artifacts.stats_path.exists() - output_path = tmp_path / "out" / "manual-source" / "feed.rss" + output_path = tmp_path / "out" / "feeds" / "manual-source" / "feed.rss" assert output_path.exists() output_text = output_path.read_text(encoding="utf-8") assert "Local Demo Feed" in output_text @@ -291,7 +291,7 @@ def test_generate_pangea_feed_writes_pangea_rss_file( log_path=tmp_path / "out" / "logs" / "pangea.log", ) - assert output_path == (tmp_path / "out" / "pangea-source" / "pangea.rss") + assert output_path == (tmp_path / "out" / "feeds" / "pangea-source" / "pangea.rss") assert output_path.exists() assert "Pangea Fixture" in output_path.read_text(encoding="utf-8") diff --git a/tests/test_web.py b/tests/test_web.py index 1486367..0946bdd 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import os +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast @@ -205,6 +207,7 @@ def test_render_dashboard_shows_dashboard_information_architecture( assert "Operational snapshot" in body assert "Running executions" in body + assert "Published feeds" in body assert 'href="/sources"' in body assert 'href="/runs"' in body assert "Create source" in body @@ -246,6 +249,141 @@ def test_render_dashboard_describes_log_artifact_footprint( asyncio.run(run()) +def test_load_dashboard_view_lists_source_feed_artifacts( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "dashboard-feeds.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + app = create_app() + out_dir = tmp_path / "out" + log_dir = out_dir / "logs" + app.config["REPUB_LOG_DIR"] = log_dir + log_dir.mkdir(parents=True) + + create_source( + name="Available source", + slug="available-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/available.xml", + ) + create_source( + name="Missing source", + slug="missing-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/missing.xml", + ) + + feed_dir = out_dir / "feeds" / "available-source" + feed_dir.mkdir(parents=True) + feed_path = feed_dir / "feed.rss" + feed_path.write_bytes(b"x" * 1024) + (feed_dir / "audio.mp3").write_bytes(b"y" * 2048) + reference_time = datetime(2026, 3, 30, 12, 30, tzinfo=UTC) + updated_at = reference_time - timedelta(minutes=32) + updated_at_epoch = updated_at.timestamp() + os.utime(feed_path, (updated_at_epoch, updated_at_epoch)) + + source_feeds = cast( + tuple[dict[str, object], ...], + load_dashboard_view(log_dir=log_dir, now=reference_time)["source_feeds"], + ) + + assert source_feeds == ( + { + "source": "Available source", + "slug": "available-source", + "feed_href": "/feeds/available-source/feed.rss", + "feed_status_label": "Available", + "feed_status_tone": "done", + "feed_exists": True, + "last_updated": "32 minutes ago", + "last_updated_iso": updated_at.isoformat(), + "artifact_footprint": "3.0 KB", + }, + { + "source": "Missing source", + "slug": "missing-source", + "feed_href": "/feeds/missing-source/feed.rss", + "feed_status_label": "Missing", + "feed_status_tone": "failed", + "feed_exists": False, + "last_updated": "Never published", + "last_updated_iso": None, + "artifact_footprint": "0 B", + }, + ) + + +def test_render_dashboard_shows_source_feed_links_and_statuses( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "dashboard-feed-links.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + app = create_app() + app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs" + + create_source( + name="Published source", + slug="published-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/published.xml", + ) + create_source( + name="Missing source", + slug="missing-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/missing.xml", + ) + + async def run() -> None: + published_feed = tmp_path / "out" / "feeds" / "published-source" / "feed.rss" + published_feed.parent.mkdir(parents=True) + published_feed.write_text("\n", encoding="utf-8") + + body = str(await render_dashboard(app)) + + assert "Published feeds" in body + assert 'href="/feeds/published-source/feed.rss"' in body + assert 'href="/feeds/missing-source/feed.rss"' in body + assert "Available" in body + assert "Missing" in body + assert "Never published" in body + + asyncio.run(run()) + + def test_render_sources_shows_table_and_create_link() -> None: async def run() -> None: body = str(await render_sources()) From d8f2e03d36d80ad5acfc5068b20866a66187202f Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:23:34 +0200 Subject: [PATCH 19/23] be consistent with env var names --- README.md | 2 +- repub/entrypoint.py | 6 +++--- tests/test_entrypoint.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ec2a848..706b052 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ uv sync --all-groups uv run repub ``` -By default the UI listens on `127.0.0.1:8080`. You can override that with `REPUB_HOST` and `REPUB_PORT`, or with: +By default the UI listens on `127.0.0.1:8080`. You can override that with `REPUBLISHER_HOST` and `REPUBLISHER_PORT`, or with: ```sh uv run repub serve --host 0.0.0.0 --port 8080 diff --git a/repub/entrypoint.py b/repub/entrypoint.py index d0de180..12ce84c 100644 --- a/repub/entrypoint.py +++ b/repub/entrypoint.py @@ -34,12 +34,12 @@ def parse_args(argv: list[str] | None = None) -> tuple[str, argparse.Namespace]: serve_parser = subparsers.add_parser("serve", help="Start the republisher web UI") serve_parser.add_argument( "--host", - default=os.environ.get("REPUB_HOST", "127.0.0.1"), + default=os.environ.get("REPUBLISHER_HOST", "127.0.0.1"), help="Host interface for the web UI", ) serve_parser.add_argument( "--port", - default=os.environ.get("REPUB_PORT", "8080"), + default=os.environ.get("REPUBLISHER_PORT", "8080"), help="Port for the web UI", ) @@ -72,7 +72,7 @@ def entrypoint(argv: list[str] | None = None) -> int: try: port = int(args.port) except ValueError: - logger.error("Invalid REPUB_PORT/--port value: %s", args.port) + logger.error("Invalid REPUBLISHER_PORT/--port value: %s", args.port) return 2 app = create_app() diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 50eb470..7d3454b 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -1,6 +1,7 @@ +import io from types import SimpleNamespace -from repub.entrypoint import FeedNameFilter +from repub.entrypoint import FeedNameFilter, entrypoint, logger, parse_args def test_feed_name_filter_accepts_matching_item() -> None: @@ -15,3 +16,31 @@ def test_feed_name_filter_rejects_non_matching_item() -> None: feed_filter = FeedNameFilter({"feed_name": "nasa"}) assert feed_filter.accepts(item) is False + + +def test_parse_args_uses_republisher_host_and_port_env_vars(monkeypatch) -> None: + monkeypatch.setenv("REPUBLISHER_HOST", "0.0.0.0") + monkeypatch.setenv("REPUBLISHER_PORT", "9090") + + command, args = parse_args(["serve"]) + + assert command == "serve" + assert args.host == "0.0.0.0" + assert args.port == "9090" + + +def test_entrypoint_rejects_invalid_republisher_port(monkeypatch) -> None: + monkeypatch.setenv("REPUBLISHER_PORT", "not-a-number") + stream = io.StringIO() + original_streams = [handler.stream for handler in logger.handlers] + for handler in logger.handlers: + handler.stream = stream + + try: + exit_code = entrypoint(["serve"]) + finally: + for handler, original_stream in zip(logger.handlers, original_streams): + handler.stream = original_stream + + assert exit_code == 2 + assert "Invalid REPUBLISHER_PORT/--port value" in stream.getvalue() From 947ef8e8339199a472156a29352b2e10eca390de Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:25:10 +0200 Subject: [PATCH 20/23] remove most subtitles --- repub/components.py | 7 +++---- repub/pages/dashboard.py | 8 -------- repub/pages/runs.py | 10 +--------- repub/pages/shim.py | 3 --- repub/pages/sources.py | 16 ---------------- tests/test_entrypoint.py | 11 ++++++++--- tests/test_web.py | 4 +--- 7 files changed, 13 insertions(+), 46 deletions(-) diff --git a/repub/components.py b/repub/components.py index fb4aae3..4113244 100644 --- a/repub/components.py +++ b/repub/components.py @@ -55,7 +55,6 @@ def admin_sidebar(*, current_path: str) -> Renderable: h.p( class_="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300" )["Republisher"], - h.p(class_="text-sm text-slate-300")["Admin spike"], ], ], h.nav(class_="mt-10 space-y-2")[ @@ -147,7 +146,7 @@ def page_shell( current_path: str, eyebrow: str, title: str, - description: str, + description: str | None = None, actions: Node | None = None, content: Node, ) -> Renderable: @@ -190,7 +189,7 @@ def table_section( *, eyebrow: str | None = None, title: str, - subtitle: str, + subtitle: str | None = None, headers: tuple[str, ...], rows: tuple[tuple[Node, ...], ...], actions: Node | None = None, @@ -217,7 +216,7 @@ def table_section( class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" )[eyebrow], h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[title], - 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, ], diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index 6e3ce3b..e0f841a 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -69,9 +69,6 @@ def dashboard_header() -> Renderable: h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[ "Republisher" ], - h.p(class_="mt-1 text-sm text-slate-600")[ - "Operational status and live executions." - ], ], h.div(class_="flex flex-wrap gap-2")[ header_action_link(href="/sources/create", label="Create source"), @@ -98,7 +95,6 @@ def operational_snapshot(*, snapshot: Mapping[str, str] | None = None) -> Render "Operational snapshot" ], ], - h.p(class_="text-xs text-slate-500")["Live values from the database"], ], h.dl(class_="grid gap-3 md:grid-cols-2 xl:grid-cols-4")[ stat_card( @@ -156,9 +152,6 @@ def running_executions_table( h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[ "Running executions" ], - h.p(class_="mt-1 text-sm text-slate-600")[ - "Dashboard keeps only the in-flight executions visible here. The full run history lives on the Runs page." - ], ], muted_action_link(href="/runs", label="Open runs"), ], @@ -232,7 +225,6 @@ def published_feeds_table( return table_section( eyebrow="Published feeds", title="Published feeds", - subtitle="Per-source public feed paths under /feeds, with current availability and disk usage.", headers=("Source", "Feed URL", "Status", "Last updated", "Disk usage"), rows=rows, actions=muted_action_link(href="/sources", label="Manage sources"), diff --git a/repub/pages/runs.py b/repub/pages/runs.py index c4d2eda..0b911f3 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -206,13 +206,11 @@ def runs_page( current_path="/runs", eyebrow="Execution control", title="Runs", - description="Running executions first, then the schedule queue, then completed history. Logs are routed through app URLs instead of direct file serving.", actions=muted_action_link(href="/sources", label="Back to sources"), content=( table_section( eyebrow="Live work", title="Running job executions", - subtitle="Operators can inspect the live log stream, request a graceful stop, and escalate to a hard kill after the 15 second deadline if needed.", headers=( "Source", "Execution", @@ -226,7 +224,6 @@ def runs_page( table_section( eyebrow="Queue", title="Upcoming jobs", - subtitle="Scheduled work shows enable or disable state, run-now affordances, and destructive delete controls. Deleting removes the source-linked job and its execution history.", headers=( "Source", "Next run", @@ -240,7 +237,6 @@ def runs_page( table_section( eyebrow="History", title="Completed job executions", - subtitle="Recent execution history keeps the summary counters visible and links back to the plain text log view.", headers=( "Source", "Execution", @@ -308,7 +304,7 @@ def execution_logs_page( if log_view is None: log_view = { "title": f"Job {job_id} / execution {execution_id}", - "description": "Plain text log view routed through the app.", + "description": "", "status_label": "Unavailable", "status_tone": "failed", "log_text": "", @@ -331,7 +327,6 @@ def execution_logs_page( current_path=f"/job/{job_id}/execution/{execution_id}/logs", eyebrow="Execution log", title=_text(log_view, "title"), - description=_text(log_view, "description"), actions=muted_action_link(href="/runs", label="Back to runs"), content=( section_card( @@ -344,9 +339,6 @@ def execution_logs_page( h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ f"/job/{job_id}/execution/{execution_id}/logs" ], - h.p(class_="mt-2 text-sm text-slate-600")[ - _text(log_view, "description") - ], ], status_badge( label=_text(log_view, "status_label"), diff --git a/repub/pages/shim.py b/repub/pages/shim.py index e66d255..d7bf552 100644 --- a/repub/pages/shim.py +++ b/repub/pages/shim.py @@ -51,9 +51,6 @@ def shim_page( h.h1( class_="mt-1 text-3xl font-semibold tracking-tight text-slate-950" )["Loading page"], - h.p(class_="mt-1 text-sm text-slate-600")[ - "Rendering the latest server view for this route." - ], ], ] ], diff --git a/repub/pages/sources.py b/repub/pages/sources.py index f8ba517..dd67691 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -92,7 +92,6 @@ def sources_table( return table_section( eyebrow="Inventory", title="Sources", - subtitle="Configured feed and Pangea sources live here as tables, with clear schedule and job state visibility instead of card-based CRUD.", headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"), rows=rows, actions=header_action_link(href="/sources/create", label="Create source"), @@ -106,7 +105,6 @@ def sources_page( current_path="/sources", eyebrow="Source management", title="Sources", - description="Configured feed and Pangea sources live here as tables, with clear schedule and job state visibility instead of card-based CRUD.", actions=header_action_link(href="/sources/create", label="Create source"), content=sources_table(sources=sources), ) @@ -122,11 +120,6 @@ def source_form( slug = _value(source, "slug") title = "Source and job setup" if mode == "create" else "Edit source" eyebrow = "Create" if mode == "create" else "Edit" - description = ( - "Create the source and its paired job record." - if mode == "create" - else "Update the existing source and its paired job record." - ) status_label = "New source" if mode == "create" else "Existing source" submit_label = "Create source" if mode == "create" else "Save changes" initial_signals = "{sourceType: 'pangea'}" @@ -143,7 +136,6 @@ def source_form( class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" )[eyebrow], h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[title], - h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[description], ], status_badge(label=status_label, tone="scheduled"), ], @@ -215,9 +207,6 @@ def source_form( h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[ "Feed settings" ], - h.p(class_="mt-2 text-sm text-slate-600")[ - "Shown only when the source type is set to feed." - ], ], h.div(class_="grid gap-4 md:grid-cols-2")[ input_field( @@ -240,9 +229,6 @@ def source_form( h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[ "Pangea settings" ], - h.p(class_="mt-2 text-sm text-slate-600")[ - "Shown only when the source type is set to pangea." - ], ], h.div(class_="grid gap-4 lg:grid-cols-3")[ input_field( @@ -414,7 +400,6 @@ def create_source_page(*, action_path: str = "/actions/sources/create") -> Rende current_path="/sources/create", eyebrow="Source creation", title="Create source", - description="Create a new source and its paired job configuration.", actions=actions, content=source_form(mode="create", action_path=action_path), ) @@ -434,7 +419,6 @@ def edit_source_page( current_path=f"/sources/{slug}/edit", eyebrow="Source editing", title="Edit source", - description="Update an existing source and its paired job configuration.", actions=actions, content=source_form(mode="edit", action_path=action_path, source=source), ) diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 7d3454b..a13b5c5 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -1,5 +1,7 @@ import io +import logging from types import SimpleNamespace +from typing import cast from repub.entrypoint import FeedNameFilter, entrypoint, logger, parse_args @@ -32,14 +34,17 @@ def test_parse_args_uses_republisher_host_and_port_env_vars(monkeypatch) -> None def test_entrypoint_rejects_invalid_republisher_port(monkeypatch) -> None: monkeypatch.setenv("REPUBLISHER_PORT", "not-a-number") stream = io.StringIO() - original_streams = [handler.stream for handler in logger.handlers] - for handler in logger.handlers: + handlers = [ + cast(logging.StreamHandler[io.StringIO], handler) for handler in logger.handlers + ] + original_streams = [handler.stream for handler in handlers] + for handler in handlers: handler.stream = stream try: exit_code = entrypoint(["serve"]) finally: - for handler, original_stream in zip(logger.handlers, original_streams): + for handler, original_stream in zip(handlers, original_streams): handler.stream = original_stream assert exit_code == 2 diff --git a/tests/test_web.py b/tests/test_web.py index 0946bdd..9035c59 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -388,7 +388,6 @@ def test_render_sources_shows_table_and_create_link() -> None: async def run() -> None: body = str(await render_sources()) - assert "Configured feed and Pangea sources live here as tables" in body assert ">Sources<" in body assert 'href="/sources/create"' in body assert "guardian-feed" not in body @@ -401,7 +400,7 @@ def test_render_create_source_shows_dedicated_form_page() -> None: async def run() -> None: body = str(await render_create_source()) - assert "Create a new source and its paired job configuration." in body + assert ">Create source<" in body assert "Source and job setup" in body assert "data-signals__ifmissing" in body assert "/actions/sources/create" in body @@ -890,7 +889,6 @@ 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()}/logs" in body - assert "Route: /job/" in body assert "waiting for more log lines" in body asyncio.run(run()) From 8716579508ab63de8ef910fe11e45d3204e111f9 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:25:28 +0200 Subject: [PATCH 21/23] humanize sizes --- repub/jobs.py | 15 +++++++- tests/test_jobs.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/test_jobs.py diff --git a/repub/jobs.py b/repub/jobs.py index 9c2a598..0912089 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -665,10 +665,11 @@ def _execution_status_tone(execution: JobExecution) -> str: def _stats_summary(execution: JobExecution) -> str: + bytes_count = cast(int, execution.bytes_count) return ( f"{execution.requests_count} requests" f" • {execution.items_count} items" - f" • {execution.bytes_count} bytes" + f" • {_format_summary_bytes(bytes_count)}" ) @@ -716,6 +717,18 @@ def _format_bytes(value: int) -> str: return f"{value / (1024 * 1024 * 1024):.1f} GB" +def _format_summary_bytes(value: int) -> str: + if value == 1: + return "1 byte" + if value < 1024: + return f"{value} bytes" + if value < 1024 * 1024: + return f"{value / 1024:.1f} KiB" + if value < 1024 * 1024 * 1024: + return f"{value / (1024 * 1024):.1f} MiB" + return f"{value / (1024 * 1024 * 1024):.1f} GiB" + + def _humanize_relative_time(reference_time: datetime, target_time: datetime) -> str: delta_seconds = int(round((target_time - reference_time).total_seconds())) if delta_seconds == 0: diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..fa3a70d --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +from repub.jobs import load_runs_view +from repub.model import ( + Job, + JobExecution, + JobExecutionStatus, + create_source, + initialize_database, +) + + +def test_load_runs_view_humanizes_completed_execution_summary_bytes( + tmp_path: Path, +) -> None: + initialize_database(tmp_path / "jobs-completed.db") + source = create_source( + name="Completed source", + slug="completed-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/completed.xml", + ) + job = Job.get(Job.source == source) + JobExecution.create( + job=job, + running_status=JobExecutionStatus.SUCCEEDED, + ended_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), + requests_count=14, + items_count=11, + bytes_count=16_410_269, + ) + + view = load_runs_view( + log_dir=tmp_path / "out" / "logs", + now=datetime(2026, 3, 30, 12, 30, tzinfo=UTC), + ) + + assert view["completed"][0]["stats"] == "14 requests • 11 items • 15.7 MiB" + + +def test_load_runs_view_humanizes_running_execution_summary_bytes( + tmp_path: Path, +) -> None: + initialize_database(tmp_path / "jobs-running.db") + source = create_source( + name="Running source", + slug="running-source", + source_type="feed", + notes="", + spider_arguments="", + enabled=False, + cron_minute="*/5", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/running.xml", + ) + job = Job.get(Job.source == source) + JobExecution.create( + job=job, + running_status=JobExecutionStatus.RUNNING, + started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), + requests_count=14, + items_count=11, + bytes_count=1_536, + ) + + view = load_runs_view( + log_dir=tmp_path / "out" / "logs", + now=datetime(2026, 3, 30, 12, 30, tzinfo=UTC), + ) + + assert view["running"][0]["stats"] == "14 requests • 11 items • 1.5 KiB" From 0803617e62935c27095b30f21252c51cbeb23a66 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:28:56 +0200 Subject: [PATCH 22/23] add empty table placeholders --- repub/components.py | 16 +++++++++++++--- repub/pages/dashboard.py | 16 +++++++++++++--- repub/pages/runs.py | 3 +++ repub/pages/sources.py | 1 + tests/test_web.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/repub/components.py b/repub/components.py index 4113244..6ecf837 100644 --- a/repub/components.py +++ b/repub/components.py @@ -190,6 +190,7 @@ def table_section( eyebrow: str | None = None, title: str, subtitle: str | None = None, + empty_message: str, headers: tuple[str, ...], rows: tuple[tuple[Node, ...], ...], actions: Node | None = None, @@ -208,6 +209,17 @@ def table_section( ), ] + body_rows: Node + if rows: + body_rows = (render_row(row) for row in rows) + else: + body_rows = h.tr[ + h.td( + colspan=str(len(headers)), + class_="px-4 py-8 text-center text-sm text-slate-500 sm:px-6", + )[empty_message] + ] + return h.section[ h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[ h.div[ @@ -238,9 +250,7 @@ def table_section( ) ] ], - h.tbody(class_="divide-y divide-slate-200 bg-white")[ - (render_row(row) for row in rows) - ], + h.tbody(class_="divide-y divide-slate-200 bg-white")[body_rows], ] ] ], diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index e0f841a..8f61b53 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -143,6 +143,17 @@ def running_executions_table( ), ] + body_rows: Node + if rows: + body_rows = (render_row(row) for row in rows) + else: + body_rows = h.tr[ + h.td( + colspan=str(len(headers)), + class_="px-4 py-8 text-center text-sm text-slate-500", + )["No job executions are running."] + ] + return h.section[ h.div(class_="mb-3 flex items-end justify-between gap-4")[ h.div[ @@ -173,9 +184,7 @@ def running_executions_table( ) ] ], - h.tbody(class_="divide-y divide-slate-200 bg-white")[ - (render_row(row) for row in rows) - ], + h.tbody(class_="divide-y divide-slate-200 bg-white")[body_rows], ] ] ], @@ -225,6 +234,7 @@ def published_feeds_table( return table_section( eyebrow="Published feeds", title="Published feeds", + empty_message="No feeds have been published yet.", headers=("Source", "Feed URL", "Status", "Last updated", "Disk usage"), rows=rows, actions=muted_action_link(href="/sources", label="Manage sources"), diff --git a/repub/pages/runs.py b/repub/pages/runs.py index 0b911f3..a42c751 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -211,6 +211,7 @@ def runs_page( table_section( eyebrow="Live work", title="Running job executions", + empty_message="No job executions are running.", headers=( "Source", "Execution", @@ -224,6 +225,7 @@ def runs_page( table_section( eyebrow="Queue", title="Upcoming jobs", + empty_message="No jobs are scheduled.", headers=( "Source", "Next run", @@ -237,6 +239,7 @@ def runs_page( table_section( eyebrow="History", title="Completed job executions", + empty_message="No job executions have completed yet.", headers=( "Source", "Execution", diff --git a/repub/pages/sources.py b/repub/pages/sources.py index dd67691..26e4a5e 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -92,6 +92,7 @@ def sources_table( return table_section( eyebrow="Inventory", title="Sources", + empty_message="No sources yet.", headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"), rows=rows, actions=header_action_link(href="/sources/create", label="Create source"), diff --git a/tests/test_web.py b/tests/test_web.py index 9035c59..e668543 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -215,6 +215,20 @@ def test_render_dashboard_shows_dashboard_information_architecture( asyncio.run(run()) +def test_render_dashboard_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "dashboard-empty.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + body = str(await render_dashboard(app)) + + assert "No job executions are running." in body + assert "No feeds have been published yet." in body + + asyncio.run(run()) + + def test_load_dashboard_view_measures_log_artifact_path( monkeypatch, tmp_path: Path ) -> None: @@ -390,6 +404,7 @@ def test_render_sources_shows_table_and_create_link() -> None: assert ">Sources<" in body assert 'href="/sources/create"' in body + assert "No sources yet." in body assert "guardian-feed" not in body assert "podcast-audio" not in body @@ -840,6 +855,21 @@ def test_render_runs_shows_running_upcoming_and_completed_tables( asyncio.run(run()) +def test_render_runs_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "runs-empty.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + body = str(await render_runs(app)) + + assert body.count("No job executions are running.") == 1 + assert "No jobs are scheduled." in body + assert "No job executions have completed yet." in body + + asyncio.run(run()) + + def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> None: db_path = tmp_path / "logs-render.db" monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) From 31e1da937f15af236724ef1862981fcae4b2068a Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 15:36:12 +0200 Subject: [PATCH 23/23] add dev-mode --- README.md | 10 +++++ repub/entrypoint.py | 11 +++-- repub/jobs.py | 6 --- repub/web.py | 31 +++++++------- tests/test_dev_mode.py | 71 +++++++++++++++++++++++++++++++++ tests/test_entrypoint.py | 36 +++++++++++++++++ tests/test_scheduler_runtime.py | 32 ++------------- 7 files changed, 146 insertions(+), 51 deletions(-) create mode 100644 tests/test_dev_mode.py diff --git a/README.md b/README.md index 706b052..bde7dee 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,22 @@ uv sync --all-groups uv run repub ``` +With no arguments, `uv run repub` starts the web UI in local dev mode and serves published feed files from `/feeds/...` out of `out/feeds/...`. + By default the UI listens on `127.0.0.1:8080`. You can override that with `REPUBLISHER_HOST` and `REPUBLISHER_PORT`, or with: ```sh uv run repub serve --host 0.0.0.0 --port 8080 ``` +If you invoke the `serve` subcommand explicitly, use `--dev-mode` to expose published feeds directly from the Quart app: + +```sh +uv run repub serve --dev-mode +``` + +In `--dev-mode`, requests under `/feeds/...` are served from `out/feeds/...`. + Important: the admin UI has no built-in authentication. Keep it bound to localhost or put it behind a trusted network layer such as Tailscale. Once the UI is running: diff --git a/repub/entrypoint.py b/repub/entrypoint.py index 12ce84c..71861a6 100644 --- a/repub/entrypoint.py +++ b/repub/entrypoint.py @@ -42,6 +42,11 @@ def parse_args(argv: list[str] | None = None) -> tuple[str, argparse.Namespace]: default=os.environ.get("REPUBLISHER_PORT", "8080"), help="Port for the web UI", ) + serve_parser.add_argument( + "--dev-mode", + action="store_true", + help="Serve published feeds from /feeds for local development", + ) crawl_parser = subparsers.add_parser("crawl", help="Run the feed crawler once") crawl_parser.add_argument( @@ -51,11 +56,11 @@ def parse_args(argv: list[str] | None = None) -> tuple[str, argparse.Namespace]: help="Path to runtime config TOML file", ) if not raw_args: - raw_args = ["serve"] + raw_args = ["serve", "--dev-mode"] elif raw_args[0] in {"-c", "--config"}: raw_args = ["crawl", *raw_args] elif raw_args[0] not in {"serve", "crawl"}: - raw_args = ["serve", *raw_args] + raw_args = ["serve", "--dev-mode", *raw_args] args = parser.parse_args(raw_args) command = args.command or "serve" @@ -75,7 +80,7 @@ def entrypoint(argv: list[str] | None = None) -> int: logger.error("Invalid REPUBLISHER_PORT/--port value: %s", args.port) return 2 - app = create_app() + app = create_app(dev_mode=bool(args.dev_mode)) app.run(host=args.host, port=port) return 0 diff --git a/repub/jobs.py b/repub/jobs.py index 0912089..5774195 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -61,16 +61,10 @@ class JobRuntime: self, *, log_dir: str | Path, - worker_duration_seconds: float = 20.0, - worker_stats_interval_seconds: float = 1.0, - worker_failure_probability: float = 0.3, refresh_callback: Callable[[], None] | None = None, graceful_stop_seconds: float = 15.0, ) -> None: self.log_dir = Path(log_dir) - self.worker_duration_seconds = worker_duration_seconds - self.worker_stats_interval_seconds = worker_stats_interval_seconds - self.worker_failure_probability = worker_failure_probability self.refresh_callback = refresh_callback self.graceful_stop_seconds = graceful_stop_seconds self.scheduler = BackgroundScheduler(timezone=UTC) diff --git a/repub/web.py b/repub/web.py index 06341d3..0b3e1cd 100644 --- a/repub/web.py +++ b/repub/web.py @@ -13,7 +13,7 @@ from datastar_py.quart import DatastarResponse, read_signals from datastar_py.sse import DatastarEvent from htpy import Renderable from peewee import IntegrityError -from quart import Quart, Response, request, url_for +from quart import Quart, Response, request, send_from_directory, url_for from repub.datastar import RefreshBroker, render_stream from repub.jobs import ( @@ -46,6 +46,7 @@ from repub.pages.sources import PANGEA_CONTENT_FORMATS, PANGEA_CONTENT_TYPES REFRESH_BROKER_KEY = "repub.refresh_broker" JOB_RUNTIME_KEY = "repub.job_runtime" DEFAULT_LOG_DIR = Path("out/logs") +DEFAULT_FEEDS_DIR = Path("out/feeds") RenderFunction = Callable[[], Awaitable[Renderable]] @@ -95,16 +96,27 @@ def _render_shim_page( return body, etag -def create_app() -> Quart: +def create_app(*, dev_mode: bool = False) -> Quart: app = Quart(__name__) app.config["REPUB_DB_PATH"] = str(initialize_database()) app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR) - app.config.setdefault("REPUB_JOB_WORKER_DURATION_SECONDS", 20.0) - app.config.setdefault("REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS", 1.0) - app.config.setdefault("REPUB_JOB_WORKER_FAILURE_PROBABILITY", 0.3) + app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR) + app.config["REPUB_DEV_MODE"] = dev_mode app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() app.extensions[JOB_RUNTIME_KEY] = None + @app.get("/feeds/") + 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("/") @app.get("/sources") @app.get("/sources/create") @@ -257,15 +269,6 @@ def get_job_runtime(app: Quart) -> JobRuntime: if runtime is None: runtime = JobRuntime( log_dir=app.config["REPUB_LOG_DIR"], - worker_duration_seconds=float( - app.config["REPUB_JOB_WORKER_DURATION_SECONDS"] - ), - worker_stats_interval_seconds=float( - app.config["REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS"] - ), - worker_failure_probability=float( - app.config["REPUB_JOB_WORKER_FAILURE_PROBABILITY"] - ), refresh_callback=lambda: trigger_refresh(app), ) app.extensions[JOB_RUNTIME_KEY] = runtime diff --git a/tests/test_dev_mode.py b/tests/test_dev_mode.py new file mode 100644 index 0000000..f58d640 --- /dev/null +++ b/tests/test_dev_mode.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from repub.web import create_app + + +def test_dev_mode_serves_published_feeds(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "dev-mode.db" + feeds_dir = tmp_path / "out" / "feeds" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app(dev_mode=True) + app.config["REPUB_FEEDS_DIR"] = feeds_dir + feed_path = feeds_dir / "demo-source" / "feed.rss" + feed_path.parent.mkdir(parents=True) + feed_path.write_text("\n", encoding="utf-8") + + client = app.test_client() + response = await client.get("/feeds/demo-source/feed.rss") + + assert response.status_code == 200 + assert response.mimetype == "application/rss+xml" + assert await response.get_data(as_text=True) == "\n" + + asyncio.run(run()) + + +def test_dev_mode_serves_feed_enclosure_assets(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "dev-mode-assets.db" + feeds_dir = tmp_path / "out" / "feeds" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app(dev_mode=True) + app.config["REPUB_FEEDS_DIR"] = feeds_dir + enclosure_path = feeds_dir / "demo-source" / "audio" / "episode.mp3" + enclosure_path.parent.mkdir(parents=True) + enclosure_path.write_bytes(b"mp3-data") + + client = app.test_client() + response = await client.get("/feeds/demo-source/audio/episode.mp3") + + assert response.status_code == 200 + assert await response.get_data() == b"mp3-data" + + asyncio.run(run()) + + +def test_default_mode_does_not_serve_published_feeds( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "default-mode.db" + feeds_dir = tmp_path / "out" / "feeds" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + app.config["REPUB_FEEDS_DIR"] = feeds_dir + feed_path = feeds_dir / "demo-source" / "feed.rss" + feed_path.parent.mkdir(parents=True) + feed_path.write_text("\n", encoding="utf-8") + + client = app.test_client() + response = await client.get("/feeds/demo-source/feed.rss") + + assert response.status_code == 404 + + asyncio.run(run()) diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index a13b5c5..bc0e6a0 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -31,6 +31,20 @@ def test_parse_args_uses_republisher_host_and_port_env_vars(monkeypatch) -> None assert args.port == "9090" +def test_parse_args_supports_dev_mode_flag() -> None: + command, args = parse_args(["serve", "--dev-mode"]) + + assert command == "serve" + assert args.dev_mode is True + + +def test_parse_args_defaults_to_dev_mode_when_no_args() -> None: + command, args = parse_args([]) + + assert command == "serve" + assert args.dev_mode is True + + def test_entrypoint_rejects_invalid_republisher_port(monkeypatch) -> None: monkeypatch.setenv("REPUBLISHER_PORT", "not-a-number") stream = io.StringIO() @@ -49,3 +63,25 @@ def test_entrypoint_rejects_invalid_republisher_port(monkeypatch) -> None: assert exit_code == 2 assert "Invalid REPUBLISHER_PORT/--port value" in stream.getvalue() + + +def test_entrypoint_passes_dev_mode_to_create_app(monkeypatch) -> None: + recorded: dict[str, object] = {} + + class StubApp: + def run(self, *, host: str, port: int) -> None: + recorded["host"] = host + recorded["port"] = port + + def fake_create_app(*, dev_mode: bool) -> StubApp: + recorded["dev_mode"] = dev_mode + return StubApp() + + monkeypatch.setattr("repub.entrypoint.create_app", fake_create_app) + + exit_code = entrypoint( + ["serve", "--dev-mode", "--host", "0.0.0.0", "--port", "9090"] + ) + + assert exit_code == 0 + assert recorded == {"dev_mode": True, "host": "0.0.0.0", "port": 9090} diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index 22f9144..d9964ff 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -59,12 +59,7 @@ def test_job_runtime_syncs_enabled_jobs_into_apscheduler(tmp_path: Path) -> None enabled_job = Job.get(Job.source == enabled_source) disabled_job = Job.get(Job.source == disabled_source) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=0.4, - worker_stats_interval_seconds=0.05, - worker_failure_probability=0.0, - ) + runtime = JobRuntime(log_dir=tmp_path / "out" / "logs") try: runtime.start() runtime.sync_jobs() @@ -104,12 +99,7 @@ def test_job_runtime_run_now_writes_log_and_stats_and_marks_success( ) job = Job.get(Job.source == source) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=0.35, - worker_stats_interval_seconds=0.05, - worker_failure_probability=0.0, - ) + runtime = JobRuntime(log_dir=tmp_path / "out" / "logs") try: runtime.start() execution_id = runtime.run_job_now(job.id, reason="manual") @@ -164,12 +154,7 @@ def test_job_runtime_cancel_marks_execution_canceled(tmp_path: Path) -> None: ) job = Job.get(Job.source == source) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=2.0, - worker_stats_interval_seconds=0.1, - worker_failure_probability=0.0, - ) + runtime = JobRuntime(log_dir=tmp_path / "out" / "logs") try: runtime.start() execution_id = runtime.run_job_now(job.id, reason="manual") @@ -227,12 +212,7 @@ def test_job_runtime_start_reconciles_stale_running_execution(tmp_path: Path) -> encoding="utf-8", ) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=0.5, - worker_stats_interval_seconds=0.05, - worker_failure_probability=0.0, - ) + runtime = JobRuntime(log_dir=tmp_path / "out" / "logs") try: runtime.start() reconciled_execution = JobExecution.get_by_id(execution.get_id()) @@ -344,10 +324,6 @@ def test_render_runs_uses_database_backed_jobs_and_executions( app = create_app() app.config["REPUB_LOG_DIR"] = log_dir - app.config["REPUB_JOB_WORKER_DURATION_SECONDS"] = 0.35 - app.config["REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS"] = 0.05 - app.config["REPUB_JOB_WORKER_FAILURE_PROBABILITY"] = 0.0 - source = create_source( name="Runs page source", slug="runs-page-source",