with htpy and css
This commit is contained in:
parent
4b376c54a2
commit
9ce576e7e8
9 changed files with 2217 additions and 17 deletions
|
|
@ -239,7 +239,10 @@
|
|||
inherit src;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
nativeBuildInputs = [ testVenv ];
|
||||
nativeBuildInputs = [
|
||||
pkgs.pyright
|
||||
testVenv
|
||||
];
|
||||
checkPhase = ''
|
||||
runHook preCheck
|
||||
pyright
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ dependencies = [
|
|||
"aiosqlite>=0.21.0,<0.22.0",
|
||||
"datastar-py>=0.8.0,<0.9.0",
|
||||
"greenlet>=3.2.4,<4.0.0",
|
||||
"htpy>=25.12.0,<26.0.0",
|
||||
"peewee>=3.19.0,<4.0.0",
|
||||
"pygea @ git+https://guardianproject.dev/anynews/pygea.git",
|
||||
]
|
||||
|
|
@ -65,6 +66,14 @@ max-line-length = "88"
|
|||
|
||||
[tool.pyright]
|
||||
include = ["repub", "tests"]
|
||||
exclude = [
|
||||
"repub/crawl.py",
|
||||
"repub/exporters.py",
|
||||
"repub/media.py",
|
||||
"repub/rss.py",
|
||||
"repub/spiders",
|
||||
"repub/srcset.py",
|
||||
]
|
||||
pythonVersion = "3.13"
|
||||
typeCheckingMode = "basic"
|
||||
reportMissingImports = false
|
||||
|
|
|
|||
154
repub/components.py
Normal file
154
repub/components.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import htpy as h
|
||||
from htpy import Node, Renderable
|
||||
|
||||
|
||||
def base_layout(*, page_title: str, stylesheet_href: str, content: Node) -> Renderable:
|
||||
return h.html(lang="en", class_="h-full bg-slate-100")[
|
||||
h.head[
|
||||
h.meta(charset="utf-8"),
|
||||
h.meta(name="viewport", content="width=device-width, initial-scale=1"),
|
||||
h.title[page_title],
|
||||
h.link(rel="stylesheet", href=stylesheet_href),
|
||||
],
|
||||
h.body(
|
||||
class_="h-full bg-linear-to-br from-stone-100 via-amber-50 to-orange-100 text-slate-900"
|
||||
)[content],
|
||||
]
|
||||
|
||||
|
||||
def nav_link(
|
||||
*, label: str, active: bool = False, badge: str | None = None
|
||||
) -> Renderable:
|
||||
link_class = (
|
||||
"group flex items-center justify-between rounded-xl px-3 py-2 text-sm font-medium transition "
|
||||
+ (
|
||||
"bg-white text-slate-950 shadow-sm ring-1 ring-white/10"
|
||||
if active
|
||||
else "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||
)
|
||||
)
|
||||
badge_class = "rounded-full px-2 py-0.5 text-[11px] font-semibold " + (
|
||||
"bg-amber-200 text-amber-950" if active else "bg-slate-800 text-slate-300"
|
||||
)
|
||||
|
||||
return h.a(href="#", class_=link_class)[
|
||||
h.span[label],
|
||||
badge and h.span(class_=badge_class)[badge],
|
||||
]
|
||||
|
||||
|
||||
def stat_card(*, label: str, value: str, detail: str) -> Renderable:
|
||||
return h.div(
|
||||
class_="rounded-3xl bg-white/85 p-5 shadow-sm ring-1 ring-slate-200 backdrop-blur"
|
||||
)[
|
||||
h.dt(class_="text-sm font-medium text-slate-500")[label],
|
||||
h.dd(class_="mt-3 text-3xl font-semibold tracking-tight text-slate-950")[value],
|
||||
h.p(class_="mt-2 text-sm text-slate-600")[detail],
|
||||
]
|
||||
|
||||
|
||||
def input_field(
|
||||
*,
|
||||
label: str,
|
||||
field_id: str,
|
||||
value: str = "",
|
||||
placeholder: str = "",
|
||||
help_text: str | None = None,
|
||||
) -> Renderable:
|
||||
return h.div[
|
||||
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
|
||||
label
|
||||
],
|
||||
h.input(
|
||||
id=field_id,
|
||||
name=field_id,
|
||||
type="text",
|
||||
value=value,
|
||||
placeholder=placeholder,
|
||||
class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500",
|
||||
),
|
||||
help_text and h.p(class_="mt-2 text-xs text-slate-500")[help_text],
|
||||
]
|
||||
|
||||
|
||||
def select_field(
|
||||
*,
|
||||
label: str,
|
||||
field_id: str,
|
||||
options: tuple[str, ...],
|
||||
selected: str,
|
||||
help_text: str | None = None,
|
||||
) -> Renderable:
|
||||
return h.div[
|
||||
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
|
||||
label
|
||||
],
|
||||
h.select(
|
||||
id=field_id,
|
||||
name=field_id,
|
||||
class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 focus:outline-hidden focus:ring-2 focus:ring-amber-500",
|
||||
)[
|
||||
(
|
||||
h.option(value=option, selected=option == selected)[option]
|
||||
for option in options
|
||||
)
|
||||
],
|
||||
help_text and h.p(class_="mt-2 text-xs text-slate-500")[help_text],
|
||||
]
|
||||
|
||||
|
||||
def textarea_field(
|
||||
*, label: str, field_id: str, value: str, rows: str = "4"
|
||||
) -> Renderable:
|
||||
return h.div[
|
||||
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
|
||||
label
|
||||
],
|
||||
h.textarea(
|
||||
id=field_id,
|
||||
name=field_id,
|
||||
rows=rows,
|
||||
class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500",
|
||||
)[value],
|
||||
]
|
||||
|
||||
|
||||
def toggle_field(*, label: str, description: str, checked: bool = False) -> Renderable:
|
||||
wrapper_class = (
|
||||
"group relative inline-flex w-11 shrink-0 rounded-full p-0.5 outline-offset-2 outline-amber-500 transition "
|
||||
+ ("bg-amber-500" if checked else "bg-slate-200")
|
||||
)
|
||||
knob_class = (
|
||||
"size-5 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform "
|
||||
+ ("translate-x-5" if checked else "translate-x-0")
|
||||
)
|
||||
|
||||
return h.div(class_="rounded-3xl bg-stone-50 p-4 ring-1 ring-slate-200")[
|
||||
h.div(class_="flex items-start justify-between gap-4")[
|
||||
h.div[
|
||||
h.h3(class_="text-sm font-semibold text-slate-900")[label],
|
||||
h.p(class_="mt-1 text-sm text-slate-600")[description],
|
||||
],
|
||||
h.label(class_="mt-0.5 cursor-pointer")[
|
||||
h.div(class_=wrapper_class)[
|
||||
h.span(class_=knob_class),
|
||||
h.input(type="checkbox", checked=checked, class_="sr-only"),
|
||||
],
|
||||
],
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def status_badge(*, label: str, tone: str) -> Renderable:
|
||||
tones = {
|
||||
"running": "bg-emerald-100 text-emerald-800",
|
||||
"scheduled": "bg-sky-100 text-sky-800",
|
||||
"idle": "bg-slate-200 text-slate-700",
|
||||
"failed": "bg-rose-100 text-rose-800",
|
||||
"done": "bg-amber-100 text-amber-800",
|
||||
}
|
||||
return h.span(
|
||||
class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}"
|
||||
)[label]
|
||||
1
repub/pages/__init__.py
Normal file
1
repub/pages/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from repub.pages.dashboard import admin_page
|
||||
598
repub/pages/dashboard.py
Normal file
598
repub/pages/dashboard.py
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import htpy as h
|
||||
from htpy import Node, Renderable
|
||||
|
||||
from repub.components import (
|
||||
base_layout,
|
||||
input_field,
|
||||
nav_link,
|
||||
select_field,
|
||||
stat_card,
|
||||
status_badge,
|
||||
textarea_field,
|
||||
toggle_field,
|
||||
)
|
||||
|
||||
|
||||
def sidebar() -> Renderable:
|
||||
return h.aside(
|
||||
class_="relative overflow-hidden bg-slate-950 px-6 py-8 text-white lg:min-h-screen"
|
||||
)[
|
||||
h.div(
|
||||
class_="absolute inset-x-0 top-0 h-40 bg-radial from-amber-400/25 via-amber-400/10 to-transparent"
|
||||
),
|
||||
h.div(class_="relative flex h-full flex-col")[
|
||||
h.div(class_="flex items-center gap-3")[
|
||||
h.div(
|
||||
class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950"
|
||||
)["AR"],
|
||||
h.div[
|
||||
h.p(
|
||||
class_="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300"
|
||||
)["Republisher"],
|
||||
h.p(class_="text-sm text-slate-300")["Admin spike"],
|
||||
],
|
||||
],
|
||||
h.nav(class_="mt-10 space-y-2")[
|
||||
nav_link(label="Dashboard", active=True, badge="Live"),
|
||||
nav_link(label="Sources", badge="12"),
|
||||
nav_link(label="Runs", badge="3"),
|
||||
nav_link(label="Schedule"),
|
||||
nav_link(label="Settings"),
|
||||
],
|
||||
h.div(class_="mt-10 rounded-3xl border border-white/10 bg-white/5 p-5")[
|
||||
h.p(
|
||||
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300"
|
||||
)["Operator notes"],
|
||||
h.p(class_="mt-3 text-sm leading-6 text-slate-300")[
|
||||
"Single-operator control plane over Tailscale. Everything here is static HTML for the v1 UI spike."
|
||||
],
|
||||
],
|
||||
h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[
|
||||
h.p(class_="text-sm font-semibold text-white")["Output root"],
|
||||
h.p(class_="mt-2 font-mono text-sm text-slate-300")["/srv/anynews/out"],
|
||||
h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[
|
||||
"Trusted network only"
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def page_header() -> Renderable:
|
||||
return h.section(
|
||||
class_="rounded-[2rem] bg-slate-950 px-6 py-8 text-white shadow-xl sm:px-8"
|
||||
)[
|
||||
h.div(class_="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between")[
|
||||
h.div(class_="max-w-3xl")[
|
||||
h.p(
|
||||
class_="text-xs font-semibold uppercase tracking-[0.3em] text-amber-300"
|
||||
)["Republisher Redux"],
|
||||
h.h1(class_="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl")[
|
||||
"Admin UI"
|
||||
],
|
||||
h.p(
|
||||
class_="mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base"
|
||||
)[
|
||||
"One page for source management, job scheduling, live execution visibility, and operator settings. This pass is HTML and CSS only."
|
||||
],
|
||||
],
|
||||
h.div(class_="flex flex-wrap gap-3")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm hover:bg-amber-300",
|
||||
)["Add source"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white hover:bg-white/10",
|
||||
)["Run scheduler health check"],
|
||||
],
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def overview_section() -> Renderable:
|
||||
return h.section[
|
||||
h.div(class_="mb-4 flex items-end justify-between")[
|
||||
h.div[
|
||||
h.p(
|
||||
class_="text-sm font-semibold uppercase tracking-[0.22em] text-slate-500"
|
||||
)["Overview"],
|
||||
h.h2(
|
||||
class_="mt-1 text-2xl font-semibold tracking-tight text-slate-950"
|
||||
)["Operational snapshot"],
|
||||
],
|
||||
h.p(class_="text-sm text-slate-500")["Updated from static fixture data"],
|
||||
],
|
||||
h.dl(class_="grid gap-4 md:grid-cols-2 xl:grid-cols-4")[
|
||||
stat_card(label="Active jobs", value="12", detail="9 scheduled, 3 paused"),
|
||||
stat_card(label="Running now", value="2", detail="RSS and Pangea workers"),
|
||||
stat_card(
|
||||
label="Completed today", value="34", detail="31 succeeded, 3 failed"
|
||||
),
|
||||
stat_card(
|
||||
label="Output size", value="18.4 GB", detail="Media and rewritten feeds"
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def source_form_section() -> Renderable:
|
||||
return h.section(
|
||||
class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200"
|
||||
)[
|
||||
h.div(class_="border-b border-slate-200 px-6 py-5 sm:px-8")[
|
||||
h.p(
|
||||
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
|
||||
)["Create or edit"],
|
||||
h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[
|
||||
"Source and job setup"
|
||||
],
|
||||
h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[
|
||||
"The form shows the intended v1 structure: source fields, subtype fields, cron controls, and job toggles. No persistence is wired yet."
|
||||
],
|
||||
],
|
||||
h.div(class_="space-y-8 px-6 py-6 sm:px-8")[
|
||||
h.div(class_="grid gap-4 md:grid-cols-2")[
|
||||
input_field(
|
||||
label="Source name",
|
||||
field_id="source-name",
|
||||
value="Pangea mobile articles",
|
||||
),
|
||||
input_field(
|
||||
label="Slug",
|
||||
field_id="source-slug",
|
||||
value="pangea-mobile",
|
||||
help_text="Immutable after creation.",
|
||||
),
|
||||
select_field(
|
||||
label="Source type",
|
||||
field_id="source-type",
|
||||
options=("feed", "pangea"),
|
||||
selected="pangea",
|
||||
),
|
||||
input_field(
|
||||
label="Feed URL",
|
||||
field_id="feed-url",
|
||||
placeholder="https://example.com/feed.xml",
|
||||
),
|
||||
],
|
||||
h.div(class_="grid gap-4 lg:grid-cols-3")[
|
||||
input_field(
|
||||
label="Pangea domain",
|
||||
field_id="pangea-domain",
|
||||
value="guardianproject.info",
|
||||
),
|
||||
input_field(
|
||||
label="Category name", field_id="pangea-category", value="News"
|
||||
),
|
||||
select_field(
|
||||
label="Content format",
|
||||
field_id="content-format",
|
||||
options=("MOBILE_3", "MOBILE_2", "WEB"),
|
||||
selected="MOBILE_3",
|
||||
),
|
||||
input_field(
|
||||
label="Content type", field_id="content-type", value="articles"
|
||||
),
|
||||
input_field(label="Max articles", field_id="max-articles", value="10"),
|
||||
input_field(
|
||||
label="Oldest article (days)", field_id="oldest-article", value="3"
|
||||
),
|
||||
],
|
||||
h.div(class_="grid gap-4 lg:grid-cols-2")[
|
||||
textarea_field(
|
||||
label="Notes",
|
||||
field_id="source-notes",
|
||||
value="Primary Pangea mobile article mirror for the operator landing page.",
|
||||
),
|
||||
textarea_field(
|
||||
label="Spider arguments",
|
||||
field_id="spider-arguments",
|
||||
value="language=en,download_media=true",
|
||||
),
|
||||
],
|
||||
h.div[
|
||||
h.div(class_="mb-4 flex items-end justify-between")[
|
||||
h.div[
|
||||
h.h3(class_="text-lg font-semibold text-slate-950")[
|
||||
"Cron schedule"
|
||||
],
|
||||
h.p(class_="mt-1 text-sm text-slate-600")[
|
||||
"Stored in UTC and displayed in the browser timezone."
|
||||
],
|
||||
]
|
||||
],
|
||||
h.div(class_="grid gap-4 sm:grid-cols-2 xl:grid-cols-5")[
|
||||
input_field(label="Minute", field_id="cron-minute", value="15"),
|
||||
input_field(label="Hour", field_id="cron-hour", value="*/4"),
|
||||
input_field(
|
||||
label="Day of month", field_id="cron-day-of-month", value="*"
|
||||
),
|
||||
input_field(
|
||||
label="Day of week", field_id="cron-day-of-week", value="1-6"
|
||||
),
|
||||
input_field(label="Month", field_id="cron-month", value="*"),
|
||||
],
|
||||
],
|
||||
h.div(class_="grid gap-4 xl:grid-cols-2")[
|
||||
toggle_field(
|
||||
label="Job enabled",
|
||||
description="If disabled, the scheduler keeps the source definition but skips automatic runs.",
|
||||
checked=True,
|
||||
),
|
||||
toggle_field(
|
||||
label="Only newest",
|
||||
description="Restrict Pangea fetches to the newest material available in the selected category.",
|
||||
checked=True,
|
||||
),
|
||||
toggle_field(
|
||||
label="Include authors",
|
||||
description="Carry author bylines into rendered output where the upstream provides them.",
|
||||
checked=True,
|
||||
),
|
||||
toggle_field(
|
||||
label="Exclude media",
|
||||
description="Use article text only and skip image/media attachment mirroring for this source.",
|
||||
checked=False,
|
||||
),
|
||||
],
|
||||
],
|
||||
h.div(
|
||||
class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 px-6 py-4 sm:px-8"
|
||||
)[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50",
|
||||
)["Cancel"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800",
|
||||
)["Save source"],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def source_card(
|
||||
*, name: str, slug: str, source_type: str, schedule: str, state: Node
|
||||
) -> Renderable:
|
||||
return h.div(class_="rounded-3xl bg-stone-50 p-5 ring-1 ring-slate-200")[
|
||||
h.div(class_="flex items-start justify-between gap-4")[
|
||||
h.div[
|
||||
h.h3(class_="text-base font-semibold text-slate-950")[name],
|
||||
h.p(class_="mt-1 font-mono text-xs text-slate-500")[slug],
|
||||
],
|
||||
state,
|
||||
],
|
||||
h.dl(class_="mt-4 grid gap-3 sm:grid-cols-2")[
|
||||
h.div[
|
||||
h.dt(
|
||||
class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"
|
||||
)["Type"],
|
||||
h.dd(class_="mt-1 text-sm text-slate-900")[source_type],
|
||||
],
|
||||
h.div[
|
||||
h.dt(
|
||||
class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"
|
||||
)["Schedule"],
|
||||
h.dd(class_="mt-1 text-sm text-slate-900")[schedule],
|
||||
],
|
||||
],
|
||||
h.div(class_="mt-5 flex flex-wrap gap-2")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50",
|
||||
)["Edit"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50",
|
||||
)["Run now"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-rose-700 ring-1 ring-rose-200 hover:bg-rose-50",
|
||||
)["Delete"],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def configured_sources_section() -> Renderable:
|
||||
return h.section(
|
||||
class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
||||
)[
|
||||
h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[
|
||||
h.div[
|
||||
h.p(
|
||||
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
|
||||
)["Configured"],
|
||||
h.h2(class_="mt-2 text-xl font-semibold text-slate-950")["Sources"],
|
||||
],
|
||||
h.p(class_="text-sm text-slate-500")[
|
||||
"Static cards for the CRUD list state"
|
||||
],
|
||||
],
|
||||
h.div(class_="mt-6 grid gap-4 xl:grid-cols-3")[
|
||||
source_card(
|
||||
name="Guardian feed mirror",
|
||||
slug="guardian-feed",
|
||||
source_type="RSS feed",
|
||||
schedule="Every 30 minutes",
|
||||
state=status_badge(label="Scheduled", tone="scheduled"),
|
||||
),
|
||||
source_card(
|
||||
name="Pangea mobile articles",
|
||||
slug="pangea-mobile",
|
||||
source_type="Pangea",
|
||||
schedule="Every 4 hours",
|
||||
state=status_badge(label="Running", tone="running"),
|
||||
),
|
||||
source_card(
|
||||
name="Podcast enclosure mirror",
|
||||
slug="podcast-audio",
|
||||
source_type="RSS feed",
|
||||
schedule="Paused",
|
||||
state=status_badge(label="Idle", tone="idle"),
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def class_cells(row: tuple[Node, ...]) -> tuple[Renderable, ...]:
|
||||
return tuple(
|
||||
h.td(class_="px-4 py-4 align-top text-slate-700")[cell] for cell in row
|
||||
)
|
||||
|
||||
|
||||
def job_table_section(
|
||||
*, title: str, subtitle: str, rows: tuple[tuple[Node, ...], ...]
|
||||
) -> Renderable:
|
||||
headers = ("Source", "Window", "Status", "Stats", "Actions")
|
||||
return h.section(
|
||||
class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
||||
)[
|
||||
h.div(class_="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between")[
|
||||
h.div[
|
||||
h.h2(class_="text-xl font-semibold text-slate-950")[title],
|
||||
h.p(class_="mt-1 text-sm text-slate-600")[subtitle],
|
||||
]
|
||||
],
|
||||
h.div(class_="mt-6 overflow-hidden rounded-3xl ring-1 ring-slate-200")[
|
||||
h.table(class_="min-w-full divide-y divide-slate-200 text-left text-sm")[
|
||||
h.thead(class_="bg-stone-50")[
|
||||
h.tr[
|
||||
(
|
||||
h.th(
|
||||
class_="px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"
|
||||
)[header]
|
||||
for header in headers
|
||||
)
|
||||
]
|
||||
],
|
||||
h.tbody(class_="divide-y divide-slate-200 bg-white")[
|
||||
(h.tr[class_cells(row)] for row in rows)
|
||||
],
|
||||
]
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def log_panel() -> Renderable:
|
||||
return h.section(
|
||||
class_="rounded-[2rem] bg-slate-950 p-6 text-white shadow-xl sm:p-8"
|
||||
)[
|
||||
h.div(class_="flex items-end justify-between gap-4")[
|
||||
h.div[
|
||||
h.p(
|
||||
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300"
|
||||
)["Live view"],
|
||||
h.h2(class_="mt-2 text-xl font-semibold")["Execution log"],
|
||||
],
|
||||
status_badge(label="Streaming", tone="running"),
|
||||
],
|
||||
h.pre(
|
||||
class_="mt-5 overflow-x-auto rounded-3xl bg-black/30 p-4 text-xs leading-6 text-emerald-200 ring-1 ring-white/10"
|
||||
)[
|
||||
"\n".join(
|
||||
[
|
||||
"11:42:01 scheduler: run_now requested for job 7",
|
||||
"11:42:02 worker[7]: starting pangea-mobile",
|
||||
"11:42:08 stats: requests=18 items=4 bytes=1.8MB",
|
||||
"11:42:11 stats: requests=26 items=7 bytes=2.6MB",
|
||||
"11:42:17 worker[7]: writing out/logs/exec-0007.log",
|
||||
"11:42:24 worker[7]: finished successfully",
|
||||
]
|
||||
)
|
||||
],
|
||||
h.div(class_="mt-5 flex flex-wrap gap-3")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/10 hover:bg-white/15",
|
||||
)["Open full log"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-rose-500/15 px-4 py-2.5 text-sm font-semibold text-rose-200 ring-1 ring-rose-400/20 hover:bg-rose-500/20",
|
||||
)["Stop job"],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def settings_panel() -> Renderable:
|
||||
return h.section(
|
||||
class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
||||
)[
|
||||
h.p(class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600")[
|
||||
"Global"
|
||||
],
|
||||
h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[
|
||||
"Application settings"
|
||||
],
|
||||
h.div(class_="mt-6 grid gap-4")[
|
||||
input_field(label="Bind host", field_id="bind-host", value="127.0.0.1"),
|
||||
input_field(label="Bind port", field_id="bind-port", value="8080"),
|
||||
input_field(
|
||||
label="Output root", field_id="output-root", value="/srv/anynews/out"
|
||||
),
|
||||
input_field(
|
||||
label="Log directory",
|
||||
field_id="log-directory",
|
||||
value="/srv/anynews/out/logs",
|
||||
),
|
||||
],
|
||||
h.div(class_="mt-6 flex flex-wrap gap-3")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800",
|
||||
)["Save settings"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50",
|
||||
)["Reload schedule preview"],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def admin_page(*, stylesheet_href: str) -> Renderable:
|
||||
running_rows = (
|
||||
(
|
||||
h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"],
|
||||
h.span["Started 11:42"],
|
||||
status_badge(label="Running", tone="running"),
|
||||
h.div["26 requests • 7 items • 2.6 MB"],
|
||||
h.div(class_="flex flex-wrap gap-2")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["View log"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100",
|
||||
)["Stop"],
|
||||
],
|
||||
),
|
||||
(
|
||||
h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"],
|
||||
h.span["Started 11:33"],
|
||||
status_badge(label="Running", tone="running"),
|
||||
h.div["91 requests • 13 items • 5.1 MB"],
|
||||
h.div(class_="flex flex-wrap gap-2")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["View log"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100",
|
||||
)["Stop"],
|
||||
],
|
||||
),
|
||||
)
|
||||
upcoming_rows = (
|
||||
(
|
||||
h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"],
|
||||
h.span["Today, 12:15"],
|
||||
status_badge(label="Scheduled", tone="scheduled"),
|
||||
h.div["cron: 15 */4 * * 1-6"],
|
||||
h.div(class_="flex flex-wrap gap-2")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["Run now"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["Disable"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100",
|
||||
)["Delete"],
|
||||
],
|
||||
),
|
||||
(
|
||||
h.div(class_="font-semibold text-slate-950")["Weekly digest feed"],
|
||||
h.span["Tomorrow, 08:00"],
|
||||
status_badge(label="Idle", tone="idle"),
|
||||
h.div["cron: 0 8 * * 1"],
|
||||
h.div(class_="flex flex-wrap gap-2")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["Run now"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["Enable"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100",
|
||||
)["Delete"],
|
||||
],
|
||||
),
|
||||
)
|
||||
completed_rows = (
|
||||
(
|
||||
h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"],
|
||||
h.span["Ended 10:57"],
|
||||
status_badge(label="Succeeded", tone="done"),
|
||||
h.div["204 requests • 28 items • 9.4 MB"],
|
||||
h.div(class_="flex flex-wrap gap-2")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["View log"],
|
||||
],
|
||||
),
|
||||
(
|
||||
h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"],
|
||||
h.span["Ended 09:12"],
|
||||
status_badge(label="Failed", tone="failed"),
|
||||
h.div["timeout after 3 retries"],
|
||||
h.div(class_="flex flex-wrap gap-2")[
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["View log"],
|
||||
h.button(
|
||||
type="button",
|
||||
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
|
||||
)["Retry"],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
return base_layout(
|
||||
page_title="Republisher Admin UI",
|
||||
stylesheet_href=stylesheet_href,
|
||||
content=h.div(class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]")[
|
||||
sidebar(),
|
||||
h.main(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[
|
||||
h.div(class_="mx-auto max-w-7xl space-y-6")[
|
||||
page_header(),
|
||||
overview_section(),
|
||||
h.div(
|
||||
class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]"
|
||||
)[
|
||||
h.div(class_="space-y-6")[
|
||||
source_form_section(),
|
||||
configured_sources_section(),
|
||||
job_table_section(
|
||||
title="Running executions",
|
||||
subtitle="Operators can inspect active crawls and stop them if needed.",
|
||||
rows=running_rows,
|
||||
),
|
||||
job_table_section(
|
||||
title="Upcoming jobs",
|
||||
subtitle="Schedule preview with enable, disable, run now, and delete affordances.",
|
||||
rows=upcoming_rows,
|
||||
),
|
||||
job_table_section(
|
||||
title="Completed executions",
|
||||
subtitle="Recent history with direct access to text logs.",
|
||||
rows=completed_rows,
|
||||
),
|
||||
],
|
||||
h.div(class_="space-y-6")[log_panel(), settings_panel()],
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
)
|
||||
1430
repub/static/app.css
Normal file
1430
repub/static/app.css
Normal file
File diff suppressed because it is too large
Load diff
3
repub/static/app.tailwind.css
Normal file
3
repub/static/app.tailwind.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@import "tailwindcss";
|
||||
@source "../components.py";
|
||||
@source "../web.py";
|
||||
20
repub/web.py
20
repub/web.py
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from quart import Quart
|
||||
from quart import Quart, url_for
|
||||
|
||||
from repub.pages import admin_page
|
||||
|
||||
|
||||
def create_app() -> Quart:
|
||||
|
|
@ -8,20 +10,6 @@ def create_app() -> Quart:
|
|||
|
||||
@app.get("/")
|
||||
async def index() -> str:
|
||||
return """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Republisher</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Hello, world!</h1>
|
||||
<p>Republisher web UI is starting here.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return str(admin_page(stylesheet_href=url_for("static", filename="app.css")))
|
||||
|
||||
return app
|
||||
|
|
|
|||
14
uv.lock
generated
14
uv.lock
generated
|
|
@ -504,6 +504,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "htpy"
|
||||
version = "25.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/23/e00bbc355e70444d16c90a0f1fdce108c67379fe65e9312cd026c13db976/htpy-25.12.0.tar.gz", hash = "sha256:7d3f4aaa10b35c5e46dfa804df1f3f18772caf8efee6e6a035b5dee89a5d6af8", size = 291259, upload-time = "2025-12-01T20:35:01.666Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/f1/a2f2caf14b03e7fab4801ac6018a4ac996de3e82a573e7aa21f3cb11a7cc/htpy-25.12.0-py3-none-any.whl", hash = "sha256:642e69278d6f8f4643acc2d2d13c21682ceb5fb4860ecbbce042f171577fff54", size = 21141, upload-time = "2025-12-01T20:35:00.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hypercorn"
|
||||
version = "0.18.0"
|
||||
|
|
@ -1077,6 +1089,7 @@ dependencies = [
|
|||
{ name = "feedparser" },
|
||||
{ name = "ffmpeg-python" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "htpy" },
|
||||
{ name = "lxml" },
|
||||
{ name = "peewee" },
|
||||
{ name = "pillow" },
|
||||
|
|
@ -1108,6 +1121,7 @@ requires-dist = [
|
|||
{ name = "feedparser", specifier = ">=6.0.11,<7.0.0" },
|
||||
{ name = "ffmpeg-python", specifier = ">=0.2.0,<0.3.0" },
|
||||
{ name = "greenlet", specifier = ">=3.2.4,<4.0.0" },
|
||||
{ name = "htpy", specifier = ">=25.12.0,<26.0.0" },
|
||||
{ name = "lxml", specifier = ">=5.2.1,<6.0.0" },
|
||||
{ name = "peewee", specifier = ">=3.19.0,<4.0.0" },
|
||||
{ name = "pillow", specifier = ">=10.3.0,<11.0.0" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue