republisher/repub/pages/dashboard.py
2026-03-30 12:13:04 +02:00

598 lines
24 KiB
Python

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