338 lines
14 KiB
Python
338 lines
14 KiB
Python
|
|
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(),
|
||
|
|
)
|