separeate pages

This commit is contained in:
Abel Luck 2026-03-30 13:11:37 +02:00
parent 3fc999a69b
commit 9e826fcee8
9 changed files with 1376 additions and 924 deletions

View file

@ -19,7 +19,7 @@ def base_layout(*, page_title: str, stylesheet_href: str, content: Node) -> Rend
def nav_link( def nav_link(
*, label: str, active: bool = False, badge: str | None = None *, label: str, href: str, active: bool = False, badge: str | None = None
) -> Renderable: ) -> Renderable:
link_class = ( link_class = (
"group flex items-center justify-between rounded-xl px-3 py-2 text-sm font-medium transition " "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" "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], h.span[label],
badge and h.span(class_=badge_class)[badge], 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: def stat_card(*, label: str, value: str, detail: str) -> Renderable:
return h.div( return h.div(
class_="rounded-3xl bg-white/85 p-5 shadow-sm ring-1 ring-slate-200 backdrop-blur" 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: def toggle_field(
wrapper_class = ( *,
"group relative inline-flex w-11 shrink-0 rounded-full p-0.5 outline-offset-2 outline-amber-500 transition " label: str,
+ ("bg-amber-500" if checked else "bg-slate-200") description: str,
) signal_name: str,
knob_class = ( checked: bool = False,
"size-5 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform " ) -> Renderable:
+ ("translate-x-5" if checked else "translate-x-0") 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(class_="flex items-start justify-between gap-4")[
h.div[ h.div[
h.h3(class_="text-sm font-semibold text-slate-900")[label], h.h3(class_="text-sm font-semibold text-slate-900")[label],
h.p(class_="mt-1 text-sm text-slate-600")[description], h.p(class_="mt-1 text-sm text-slate-600")[description],
], ],
h.label(class_="mt-0.5 cursor-pointer")[ h.label(class_="mt-0.5 cursor-pointer")[
h.div(class_=wrapper_class)[ h.div(
h.span(class_=knob_class), {
h.input(type="checkbox", checked=checked, class_="sr-only"), "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",
),
], ],
], ],
] ]

View file

@ -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.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",
]

View file

@ -4,653 +4,184 @@ import htpy as h
from htpy import Node, Renderable from htpy import Node, Renderable
from repub.components import ( from repub.components import (
input_field, admin_sidebar,
nav_link, header_action_link,
select_field, inline_button,
inline_link,
muted_action_link,
stat_card, stat_card,
status_badge, status_badge,
textarea_field,
toggle_field,
) )
from repub.pages.runs import RUNNING_EXECUTIONS
def sidebar() -> Renderable: def _running_execution_row(execution: dict[str, str | bool]) -> tuple[Node, ...]:
return h.aside( status_tone = "running" if execution["is_running"] else "done"
class_="relative overflow-hidden bg-slate-950 px-6 py-8 text-white lg:min-h-screen" return (
)[
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.div[
h.p( h.div(class_="font-semibold text-slate-950")[execution["source"]],
class_="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300" h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[
)["Republisher"], execution["slug"]
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"],
],
],
demo_action_panel(),
]
def overview_section(*, active_jobs: str) -> 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[
h.div(class_="mb-4 flex items-end justify-between")[ h.p(class_="font-medium text-slate-900")[f"#{execution['execution_id']}"],
h.div[ h.p(class_="mt-0.5 text-[11px] text-slate-500")[
h.h3(class_="text-lg font-semibold text-slate-950")[ f"job {execution['job_id']}"
"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.div[
h.dt( h.p(class_="font-medium text-slate-900")[execution["started_at"]],
class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" h.p(class_="mt-0.5 text-[11px] text-slate-500")[execution["runtime"]],
)["Schedule"],
h.dd(class_="mt-1 text-sm text-slate-900")[schedule],
], ],
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_="mt-5 flex flex-wrap gap-2")[ h.div(class_="flex flex-nowrap items-center gap-3")[
h.button( inline_link(
type="button", href=str(execution["log_href"]),
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", label="View log",
)["Edit"], tone="amber",
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"),
), ),
inline_button(label="Stop", tone="danger"),
], ],
]
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( def dashboard_header() -> Renderable:
*, title: str, subtitle: str, rows: tuple[tuple[Node, ...], ...] return h.section[
) -> Renderable: h.div(
headers = ("Source", "Window", "Status", "Stats", "Actions") class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
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.div[
h.h2(class_="text-xl font-semibold text-slate-950")[title], h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[
h.p(class_="mt-1 text-sm text-slate-600")[subtitle], "Republisher"
]
], ],
h.div(class_="mt-6 overflow-hidden rounded-3xl ring-1 ring-slate-200")[ h.p(class_="mt-1 text-sm text-slate-600")[
h.table(class_="min-w-full divide-y divide-slate-200 text-left text-sm")[ "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 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
),
]
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"
)["Live work"],
h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[
"Running executions"
],
h.p(class_="mt-1 text-sm text-slate-600")[
"Dashboard keeps only the in-flight executions visible here. The full run history lives on the Runs page."
],
],
muted_action_link(href="/runs", label="Open runs"),
],
h.div(
class_="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200"
)[
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.thead(class_="bg-stone-50")[
h.tr[ h.tr[
( (
h.th( h.th(
class_="px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" 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] )[header]
for header in headers for header in headers
) )
] ]
], ],
h.tbody(class_="divide-y divide-slate-200 bg-white")[ h.tbody(class_="divide-y divide-slate-200 bg-white")[
(h.tr[class_cells(row)] for row in rows) (render_row(row) for row in rows)
], ],
] ]
]
], ],
] ]
def log_panel() -> Renderable: def dashboard_page() -> 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_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"],
],
),
)
return h.main( return h.main(
id="morph", id="morph",
class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]",
)[ )[
sidebar(), admin_sidebar(current_path="/"),
h.div(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[ 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-6")[ h.div(class_="mx-auto max-w-7xl space-y-5")[
page_header(), dashboard_header(),
overview_section(active_jobs=active_jobs), operational_snapshot(),
h.div( running_executions_table(),
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()],
],
] ]
], ],
] ]

360
repub/pages/runs.py Normal file
View file

@ -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 ...",
)
)
],
)
),
),
)

337
repub/pages/sources.py Normal file
View file

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

View file

@ -15,6 +15,7 @@
--color-amber-400: oklch(82.8% 0.189 84.429); --color-amber-400: oklch(82.8% 0.189 84.429);
--color-amber-500: oklch(76.9% 0.188 70.08); --color-amber-500: oklch(76.9% 0.188 70.08);
--color-amber-600: oklch(66.6% 0.179 58.318); --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-800: oklch(47.3% 0.137 46.201);
--color-amber-950: oklch(27.9% 0.077 45.635); --color-amber-950: oklch(27.9% 0.077 45.635);
--color-emerald-100: oklch(95% 0.052 163.051); --color-emerald-100: oklch(95% 0.052 163.051);
@ -24,9 +25,6 @@
--color-sky-800: oklch(44.3% 0.11 240.79); --color-sky-800: oklch(44.3% 0.11 240.79);
--color-rose-50: oklch(96.9% 0.015 12.422); --color-rose-50: oklch(96.9% 0.015 12.422);
--color-rose-100: oklch(94.1% 0.03 12.58); --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-700: oklch(51.4% 0.222 16.935);
--color-rose-800: oklch(45.5% 0.188 13.697); --color-rose-800: oklch(45.5% 0.188 13.697);
--color-slate-50: oklch(98.4% 0.003 247.858); --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-50: oklch(98.5% 0.001 106.423);
--color-stone-100: oklch(97% 0.001 106.424); --color-stone-100: oklch(97% 0.001 106.424);
--color-stone-200: oklch(92.3% 0.003 48.717); --color-stone-200: oklch(92.3% 0.003 48.717);
--color-black: #000;
--color-white: #fff; --color-white: #fff;
--spacing: 0.25rem; --spacing: 0.25rem;
--container-sm: 24rem;
--container-2xl: 42rem; --container-2xl: 42rem;
--container-3xl: 48rem; --container-3xl: 48rem;
--container-7xl: 80rem; --container-7xl: 80rem;
@ -59,12 +57,8 @@
--text-lg--line-height: calc(1.75 / 1.125); --text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem; --text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25); --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: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875); --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-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-black: 900; --font-weight-black: 900;
@ -244,9 +238,6 @@
.absolute { .absolute {
position: absolute; position: absolute;
} }
.fixed {
position: fixed;
}
.relative { .relative {
position: relative; position: relative;
} }
@ -310,15 +301,12 @@
.mt-auto { .mt-auto {
margin-top: auto; margin-top: auto;
} }
.mb-4 { .mb-3 {
margin-bottom: calc(var(--spacing) * 4); margin-bottom: calc(var(--spacing) * 3);
} }
.block { .block {
display: block; display: block;
} }
.contents {
display: contents;
}
.flex { .flex {
display: flex; display: flex;
} }
@ -328,6 +316,9 @@
.hidden { .hidden {
display: none; display: none;
} }
.inline {
display: inline;
}
.inline-flex { .inline-flex {
display: inline-flex; display: inline-flex;
} }
@ -366,19 +357,39 @@
.max-w-7xl { .max-w-7xl {
max-width: var(--container-7xl); max-width: var(--container-7xl);
} }
.min-w-full { .max-w-40 {
min-width: 100%; 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 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
.table-auto {
table-layout: auto;
}
.translate-x-0 { .translate-x-0 {
--tw-translate-x: calc(var(--spacing) * 0); --tw-translate-x: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
} }
.translate-x-5 { .cursor-not-allowed {
--tw-translate-x: calc(var(--spacing) * 5); cursor: not-allowed;
translate: var(--tw-translate-x) var(--tw-translate-y);
} }
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
@ -386,6 +397,9 @@
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
.flex-nowrap {
flex-wrap: nowrap;
}
.flex-wrap { .flex-wrap {
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -426,6 +440,20 @@
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); 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 { .space-y-6 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
@ -433,13 +461,6 @@
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.space-y-8 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)));
}
}
.divide-y { .divide-y {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-divide-y-reverse: 0; --tw-divide-y-reverse: 0;
@ -454,6 +475,11 @@
border-color: var(--color-slate-200); border-color: var(--color-slate-200);
} }
} }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-hidden { .overflow-hidden {
overflow: hidden; overflow: hidden;
} }
@ -466,8 +492,8 @@
.rounded-3xl { .rounded-3xl {
border-radius: var(--radius-3xl); border-radius: var(--radius-3xl);
} }
.rounded-\[2rem\] { .rounded-\[1\.5rem\] {
border-radius: 2rem; border-radius: 1.5rem;
} }
.rounded-full { .rounded-full {
border-radius: calc(infinity * 1px); border-radius: calc(infinity * 1px);
@ -487,10 +513,6 @@
border-top-style: var(--tw-border-style); border-top-style: var(--tw-border-style);
border-top-width: 1px; border-top-width: 1px;
} }
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.border-slate-200 { .border-slate-200 {
border-color: var(--color-slate-200); border-color: var(--color-slate-200);
} }
@ -515,15 +537,6 @@
.bg-amber-400 { .bg-amber-400 {
background-color: var(--color-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 { .bg-emerald-100 {
background-color: var(--color-emerald-100); background-color: var(--color-emerald-100);
} }
@ -533,12 +546,6 @@
.bg-rose-100 { .bg-rose-100 {
background-color: var(--color-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 { .bg-sky-100 {
background-color: var(--color-sky-100); background-color: var(--color-sky-100);
} }
@ -569,24 +576,12 @@
background-color: color-mix(in oklab, var(--color-white) 5%, transparent); 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 { .bg-white\/85 {
background-color: color-mix(in srgb, #fff 85%, transparent); background-color: color-mix(in srgb, #fff 85%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-white) 85%, transparent); 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 { .bg-linear-to-br {
--tw-gradient-position: to bottom right; --tw-gradient-position: to bottom right;
@supports (background-image: linear-gradient(in lab, red, red)) { @supports (background-image: linear-gradient(in lab, red, red)) {
@ -639,9 +634,6 @@
.p-5 { .p-5 {
padding: calc(var(--spacing) * 5); padding: calc(var(--spacing) * 5);
} }
.p-6 {
padding: calc(var(--spacing) * 6);
}
.px-2 { .px-2 {
padding-inline: calc(var(--spacing) * 2); padding-inline: calc(var(--spacing) * 2);
} }
@ -681,15 +673,18 @@
.py-4 { .py-4 {
padding-block: calc(var(--spacing) * 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 { .py-8 {
padding-block: calc(var(--spacing) * 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-left {
text-align: left; text-align: left;
} }
@ -699,10 +694,6 @@
.font-mono { .font-mono {
font-family: var(--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 { .text-3xl {
font-size: var(--text-3xl); font-size: var(--text-3xl);
line-height: var(--tw-leading, var(--text-3xl--line-height)); line-height: var(--tw-leading, var(--text-3xl--line-height));
@ -746,10 +737,6 @@
--tw-font-weight: var(--font-weight-semibold); --tw-font-weight: var(--font-weight-semibold);
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\] { .tracking-\[0\.18em\] {
--tw-tracking: 0.18em; --tw-tracking: 0.18em;
letter-spacing: 0.18em; letter-spacing: 0.18em;
@ -766,12 +753,21 @@
--tw-tracking: var(--tracking-tight); --tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
} }
.whitespace-normal {
white-space: normal;
}
.whitespace-nowrap {
white-space: nowrap;
}
.text-amber-300 { .text-amber-300 {
color: var(--color-amber-300); color: var(--color-amber-300);
} }
.text-amber-600 { .text-amber-600 {
color: var(--color-amber-600); color: var(--color-amber-600);
} }
.text-amber-700 {
color: var(--color-amber-700);
}
.text-amber-800 { .text-amber-800 {
color: var(--color-amber-800); color: var(--color-amber-800);
} }
@ -784,9 +780,6 @@
.text-emerald-800 { .text-emerald-800 {
color: var(--color-emerald-800); color: var(--color-emerald-800);
} }
.text-rose-200 {
color: var(--color-rose-200);
}
.text-rose-700 { .text-rose-700 {
color: var(--color-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)); --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); 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 { .shadow-xs {
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); --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); 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); --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); 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 { .ring-slate-200 {
--tw-ring-color: var(--color-slate-200); --tw-ring-color: var(--color-slate-200);
} }
@ -892,6 +872,11 @@
color: var(--color-slate-400); color: var(--color-slate-400);
} }
} }
.first\:pl-4 {
&:first-child {
padding-left: calc(var(--spacing) * 4);
}
}
.hover\:bg-amber-300 { .hover\:bg-amber-300 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -899,10 +884,10 @@
} }
} }
} }
.hover\:bg-rose-50 { .hover\:bg-emerald-200 {
&:hover { &:hover {
@media (hover: 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\:bg-slate-50 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -964,15 +939,26 @@
} }
} }
} }
.hover\:bg-white\/15 { .hover\:text-amber-800 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
background-color: color-mix(in srgb, #fff 15%, transparent); color: var(--color-amber-800);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-white) 15%, transparent);
} }
} }
} }
.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);
}
}
} }
.hover\:text-white { .hover\:text-white {
&:hover { &:hover {
@ -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 { .sm\:grid-cols-2 {
@media (width >= 40rem) { @media (width >= 40rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -1017,37 +1025,37 @@
align-items: flex-end; align-items: flex-end;
} }
} }
.sm\:items-start {
@media (width >= 40rem) {
align-items: flex-start;
}
}
.sm\:justify-between { .sm\:justify-between {
@media (width >= 40rem) { @media (width >= 40rem) {
justify-content: space-between; justify-content: space-between;
} }
} }
.sm\:p-8 { .sm\:px-5 {
@media (width >= 40rem) { @media (width >= 40rem) {
padding: calc(var(--spacing) * 8); padding-inline: calc(var(--spacing) * 5);
} }
} }
.sm\:px-6 { .sm\:pl-4 {
@media (width >= 40rem) { @media (width >= 40rem) {
padding-inline: calc(var(--spacing) * 6); padding-left: calc(var(--spacing) * 4);
} }
} }
.sm\:px-8 { .sm\:pl-6 {
@media (width >= 40rem) { @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) { @media (width >= 40rem) {
font-size: var(--text-4xl); &:first-child {
line-height: var(--tw-leading, var(--text-4xl--line-height)); padding-left: calc(var(--spacing) * 6);
} }
} }
.sm\:text-base {
@media (width >= 40rem) {
font-size: var(--text-base);
line-height: var(--tw-leading, var(--text-base--line-height));
}
} }
.md\:grid-cols-2 { .md\:grid-cols-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
@ -1079,39 +1087,14 @@
grid-template-columns: 18rem minmax(0,1fr); grid-template-columns: 18rem minmax(0,1fr);
} }
} }
.lg\:flex-row { .lg\:px-6 {
@media (width >= 64rem) { @media (width >= 64rem) {
flex-direction: row; padding-inline: calc(var(--spacing) * 6);
} }
} }
.lg\:items-end { .lg\:py-5 {
@media (width >= 64rem) { @media (width >= 64rem) {
align-items: flex-end; padding-block: calc(var(--spacing) * 5);
}
}
.lg\:justify-between {
@media (width >= 64rem) {
justify-content: space-between;
}
}
.lg\:px-8 {
@media (width >= 64rem) {
padding-inline: calc(var(--spacing) * 8);
}
}
.lg\:py-8 {
@media (width >= 64rem) {
padding-block: calc(var(--spacing) * 8);
}
}
.xl\:grid-cols-2 {
@media (width >= 80rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.xl\:grid-cols-3 {
@media (width >= 80rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
.xl\:grid-cols-4 { .xl\:grid-cols-4 {
@ -1124,9 +1107,9 @@
grid-template-columns: repeat(5, minmax(0, 1fr)); 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) { @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);
} }
} }
} }

View file

@ -1,3 +1 @@
@import "tailwindcss"; @import "tailwindcss" source("../");
@source "../components.py";
@source "../web.py";

View file

@ -2,23 +2,28 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import suppress
from typing import cast from typing import cast
import htpy as h import htpy as h
from datastar_py import ServerSentEventGenerator as SSE from datastar_py.quart import DatastarResponse
from datastar_py.quart import DatastarResponse, read_signals
from datastar_py.sse import DatastarEvent from datastar_py.sse import DatastarEvent
from htpy import Renderable from htpy import Renderable
from quart import Quart, Response, request, url_for from quart import Quart, Response, request, url_for
from repub.datastar import RefreshBroker, render_stream 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" 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]: 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 return body, etag
def create_app(*, enable_demo_refresh: bool = True) -> Quart: def create_app() -> Quart:
app = Quart(__name__) app = Quart(__name__)
app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() 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("/") @app.get("/")
async def index() -> Response: @app.get("/sources")
@app.get("/sources/create")
@app.get("/runs")
@app.get("/job/<int:job_id>/execution/<int:execution_id>/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( body, etag = _render_shim_page(
stylesheet_href=url_for("static", filename="app.css"), stylesheet_href=url_for("static", filename="app.css"),
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), 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 return response
@app.post("/") @app.post("/")
async def index_patch() -> DatastarResponse: async def dashboard_patch() -> DatastarResponse:
queue = get_refresh_broker(app).subscribe() return _page_patch_response(app, render_dashboard)
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))
@app.post("/demo/decrement") @app.post("/sources")
async def demo_decrement() -> DatastarResponse: async def sources_patch() -> DatastarResponse:
amount, error = _validated_decrement_amount(await read_signals()) return _page_patch_response(app, render_sources)
if error is not None:
return DatastarResponse(SSE.patch_signals({"decrementError": error}))
set_active_jobs(app, max(0, get_active_jobs(app) - amount)) @app.post("/sources/create")
trigger_refresh(app) async def create_source_patch() -> DatastarResponse:
return DatastarResponse(SSE.patch_signals({"decrementError": ""})) 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/<int:job_id>/execution/<int:execution_id>/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 return app
@ -99,8 +96,34 @@ def trigger_refresh(app: Quart, event: object = "refresh-event") -> None:
get_refresh_broker(app).publish(event) get_refresh_broker(app).publish(event)
async def render_dashboard(app: Quart) -> Renderable: async def render_dashboard() -> Renderable:
return admin_component(active_jobs=str(get_active_jobs(app))) 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( async def _unsubscribe_on_close(
@ -111,35 +134,3 @@ async def _unsubscribe_on_close(
yield event yield event
finally: finally:
get_refresh_broker(app).unsubscribe(cast(asyncio.Queue[object], queue)) 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

View file

@ -6,16 +6,18 @@ from typing import Any, cast
from repub.datastar import RefreshBroker, render_sse_event, render_stream from repub.datastar import RefreshBroker, render_sse_event, render_stream
from repub.web import ( from repub.web import (
create_app, create_app,
get_active_jobs,
get_refresh_broker, get_refresh_broker,
render_create_source,
render_dashboard, render_dashboard,
set_active_jobs, render_execution_logs,
render_runs,
render_sources,
) )
def test_root_get_serves_datastar_shim() -> None: def test_root_get_serves_datastar_shim() -> None:
async def run() -> None: async def run() -> None:
client = create_app(enable_demo_refresh=False).test_client() client = create_app().test_client()
response = await client.get("/") response = await client.get("/")
body = await response.get_data(as_text=True) 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: def test_root_get_honors_if_none_match() -> None:
async def run() -> None: async def run() -> None:
client = create_app(enable_demo_refresh=False).test_client() client = create_app().test_client()
initial = await client.get("/") initial = await client.get("/")
etag = initial.headers["ETag"] etag = initial.headers["ETag"]
@ -51,9 +53,9 @@ def test_root_get_honors_if_none_match() -> None:
asyncio.run(run()) asyncio.run(run())
def test_root_post_serves_morph_component() -> None: def test_dashboard_post_serves_morph_component() -> None:
async def run() -> 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: async with client.request("/?u=shim", method="POST") as connection:
await connection.send_complete() await connection.send_complete()
chunk = await asyncio.wait_for(connection.receive(), timeout=1) 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"event: datastar-patch-elements" in chunk
assert b"id: " in chunk assert b"id: " in chunk
assert b'<main id="morph"' in chunk assert b'<main id="morph"' in chunk
assert b"Operational snapshot" in chunk
assert b"Running executions" in chunk
await connection.disconnect() await connection.disconnect()
asyncio.run(run()) asyncio.run(run())
@ -88,7 +92,7 @@ def test_render_sse_event_skips_unchanged_view() -> None:
def test_app_refresh_broker_publishes_events() -> None: def test_app_refresh_broker_publishes_events() -> None:
async def run() -> None: async def run() -> None:
app = create_app(enable_demo_refresh=False) app = create_app()
broker = get_refresh_broker(app) broker = get_refresh_broker(app)
queue = broker.subscribe() queue = broker.subscribe()
@ -123,72 +127,75 @@ def test_render_stream_yields_on_connect_and_refresh() -> None:
asyncio.run(run()) 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: async def run() -> None:
app = create_app(enable_demo_refresh=False) body = str(await render_dashboard())
assert get_active_jobs(app) == 12
set_active_jobs(app, 27)
async with app.app_context(): assert "Operational snapshot" in body
body = str(await render_dashboard(app)) assert "Running executions" in body
assert 'href="/sources"' in body
assert "27" in body assert 'href="/runs"' in body
assert "Temporary live demo counter for Datastar refresh testing" in body assert "/job/7/execution/104/logs" in body
assert "/demo/decrement" in body assert "Create source" in body
assert "data-bind:decrement-amount" in body
asyncio.run(run()) 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: async def run() -> None:
app = create_app(enable_demo_refresh=False) body = str(await render_sources())
broker = get_refresh_broker(app)
queue = broker.subscribe()
client = app.test_client()
response = await client.post( assert "Configured feed and Pangea sources live here as tables" in body
"/demo/decrement", assert ">Sources<" in body
headers={"Datastar-Request": "true"}, assert 'href="/sources/create"' in body
json={"decrementAmount": "3"}, assert "guardian-feed" in body
) assert "podcast-audio" in body
body = await response.get_data(as_text=True)
event = await asyncio.wait_for(queue.get(), timeout=1)
assert response.status_code == 200
assert get_active_jobs(app) == 9
assert event == "refresh-event"
assert 'data: signals {"decrementError":""}' in body
broker.unsubscribe(queue)
asyncio.run(run()) 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: async def run() -> None:
app = create_app(enable_demo_refresh=False) body = str(await render_create_source())
broker = get_refresh_broker(app)
queue = broker.subscribe()
client = app.test_client()
response = await client.post( assert "Dedicated create page for the source form" in body
"/demo/decrement", assert "Source and job setup" in body
headers={"Datastar-Request": "true"}, assert "data-signals__ifmissing" in body
json={"decrementAmount": "2"}, assert 'data-show="$sourceType === &#39;feed&#39;"' in body
) assert 'data-show="$sourceType === &#39;pangea&#39;"' in body
body = await response.get_data(as_text=True) assert "jobEnabled" in body
assert "onlyNewest" in body
assert response.status_code == 200 assert "includeAuthors" in body
assert get_active_jobs(app) == 12 assert "excludeMedia" in body
assert "odd integer" in body assert "Pangea domain" in body
assert "Feed URL" in body
try: assert "Cron schedule" in body
await asyncio.wait_for(queue.get(), timeout=0.1) assert "Initial job state" in body
except TimeoutError:
pass asyncio.run(run())
else:
raise AssertionError("invalid decrement should not publish a refresh")
finally: def test_render_runs_shows_running_upcoming_and_completed_tables() -> None:
broker.unsubscribe(queue) 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()) asyncio.run(run())