From 9e826fcee886e6abf2ca08850048631e7ebcd1a9 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 13:11:37 +0200 Subject: [PATCH] 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())