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()], ], ] ], ], )