Add publisher dashboard routes
All checks were successful
buildbot/nix-eval Build done.
buildbot/nix-build Build done.
buildbot/nix-effects Build done.

This commit is contained in:
Abel Luck 2026-06-02 10:18:59 +02:00
parent 96551c2788
commit e4a5246ab3
31 changed files with 1603 additions and 516 deletions

View file

@ -9,7 +9,6 @@ from htpy import Node, Renderable
from repub.components import (
action_button,
app_shell,
header_action_link,
inline_link,
muted_action_link,
stat_card,
@ -19,7 +18,9 @@ from repub.components import (
from repub.pages.runs import live_work_section, relative_time_formatter_script
def dashboard_header() -> Renderable:
def dashboard_header(
*, path_prefix: str = "/admin", reader_app_url: str | None = None
) -> Renderable:
return h.section[
h.div(
class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
@ -30,8 +31,20 @@ def dashboard_header() -> Renderable:
],
],
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"),
(
muted_action_link(
href=reader_app_url,
label="Open AnyNews",
target="_blank",
rel="noopener noreferrer",
)
if reader_app_url is not None
else None
),
muted_action_link(
href=f"{path_prefix}/publisher",
label="Publisher View",
),
],
]
]
@ -124,9 +137,6 @@ def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]:
),
last_updated,
next_run,
h.p(class_="font-medium text-slate-900")[
str(source_feed["artifact_footprint"])
],
action_button(
label="Run now",
disabled=bool(source_feed["run_disabled"]),
@ -136,12 +146,15 @@ def _source_feed_row(source_feed: Mapping[str, object]) -> tuple[Node, ...]:
def published_feeds_table(
*, source_feeds: tuple[Mapping[str, object], ...] | None = None
*,
source_feeds: tuple[Mapping[str, object], ...] | None = None,
manage_sources_href: str | None = "/admin/sources",
show_heading: bool = True,
) -> Renderable:
rows = tuple(_source_feed_row(source_feed) for source_feed in (source_feeds or ()))
return table_section(
eyebrow="Published feeds",
title="Published feeds",
eyebrow="Published feeds" if show_heading else None,
title="Published feeds" if show_heading else None,
empty_message="No feeds have been published yet.",
headers=(
"Source",
@ -149,11 +162,14 @@ def published_feeds_table(
"Status",
"Last updated",
"Next run",
"Disk usage",
"Actions",
),
rows=rows,
actions=muted_action_link(href="/sources", label="Manage sources"),
actions=(
muted_action_link(href=manage_sources_href, label="Manage sources")
if manage_sources_href is not None
else None
),
)
@ -163,26 +179,32 @@ def dashboard_page() -> Renderable:
def dashboard_page_with_data(
*,
current_path: str = "/admin",
path_prefix: str = "/admin",
snapshot: Mapping[str, str] | None = None,
running_executions: tuple[Mapping[str, object], ...] | None = None,
queued_executions: tuple[Mapping[str, object], ...] | None = None,
source_feeds: tuple[Mapping[str, object], ...] | None = None,
reader_app_url: str | None = None,
) -> Renderable:
running_items = running_executions or ()
queued_items = queued_executions or ()
source_items = source_feeds or ()
return app_shell(
current_path="/",
current_path=current_path,
source_count=len(source_items),
running_count=len(running_items),
content=(
dashboard_header(),
dashboard_header(path_prefix=path_prefix, reader_app_url=reader_app_url),
operational_snapshot(snapshot=snapshot),
live_work_section(
running_executions=running_items,
queued_executions=queued_items,
),
published_feeds_table(source_feeds=source_items),
published_feeds_table(
source_feeds=source_items,
manage_sources_href=f"{path_prefix}/sources",
),
relative_time_formatter_script(),
),
)

View file

@ -1,19 +1,57 @@
from __future__ import annotations
from collections.abc import Mapping
import htpy as h
from htpy import Renderable
from repub.components import app_shell
from repub.components import publisher_shell
from repub.pages.dashboard import published_feeds_table
from repub.pages.runs import live_work_section, relative_time_formatter_script
def publisher_page(*, current_path: str) -> Renderable:
return app_shell(
def publisher_page(
*,
current_path: str,
source_feeds: tuple[Mapping[str, object], ...] | None = None,
running_executions: tuple[Mapping[str, object], ...] | None = None,
queued_executions: tuple[Mapping[str, object], ...] | None = None,
reader_app_url: str | None = None,
) -> Renderable:
return publisher_shell(
current_path=current_path,
content=(
h.section[
h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[
"Hello publishers"
]
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"
)["Republisher"],
h.h1(
class_="mt-1 text-3xl font-semibold tracking-tight text-slate-950"
)["Published feeds"],
],
reader_app_url
and h.a(
href=reader_app_url,
target="_blank",
rel="noopener noreferrer",
class_="inline-flex shrink-0 items-center justify-center rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-amber-300",
)["Open AnyNews"],
],
],
published_feeds_table(
source_feeds=source_feeds,
manage_sources_href=None,
show_heading=False,
),
live_work_section(
running_executions=running_executions,
queued_executions=queued_executions,
show_row_actions=False,
),
relative_time_formatter_script(),
),
)

View file

@ -190,7 +190,9 @@ def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]:
}
def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
def _running_row(
execution: Mapping[str, object], *, show_row_actions: bool = True
) -> tuple[Node, ...]:
started_at = _maybe_text(execution, "started_at_iso")
started_at_label: Node = h.p(class_="truncate")[_text(execution, "started_at")]
if started_at is not None:
@ -203,7 +205,7 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
class_="truncate",
)[_text(execution, "started_at")]
return (
cells = (
_live_status_cell(
execution_id=_text(execution, "execution_id"),
status=_text(execution, "status"),
@ -222,6 +224,11 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")],
h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "worker")],
],
)
if not show_row_actions:
return cells
return (
*cells,
h.div(class_="flex flex-wrap items-center gap-2")[
inline_link(
href=_text(execution, "log_href"),
@ -237,7 +244,9 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
)
def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
def _queued_row(
execution: Mapping[str, object], *, show_row_actions: bool = True
) -> tuple[Node, ...]:
queued_at = _maybe_text(execution, "queued_at_iso")
queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")]
if queued_at is not None:
@ -250,7 +259,7 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
class_="truncate",
)[_text(execution, "queued_at")]
return (
cells = (
_live_status_cell(
execution_id=_text(execution, "execution_id"),
status="Queued",
@ -270,6 +279,11 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
],
h.p(class_="mt-0.5 text-xs text-slate-500")["waiting for capacity"],
],
)
if not show_row_actions:
return cells
return (
*cells,
h.div(class_="flex flex-wrap items-center gap-2")[
action_button(
label=_queue_icon("up"),
@ -362,8 +376,8 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
)
def _completed_page_action_path(page: int) -> str:
return f"/actions/runs/completed-page/{page}"
def _completed_page_action_path(page: int, *, path_prefix: str = "/admin") -> str:
return f"{path_prefix}/actions/runs/completed-page/{page}"
def _pagination_button(
@ -372,9 +386,12 @@ def _pagination_button(
page: int,
current: bool = False,
class_name: str,
path_prefix: str = "/admin",
) -> Renderable:
attributes = {
"data-on:pointerdown": f"@post('{_completed_page_action_path(page)}')",
"data-on:pointerdown": (
f"@post('{_completed_page_action_path(page, path_prefix=path_prefix)}')"
),
}
if current:
attributes["aria-current"] = "page"
@ -391,6 +408,7 @@ def _completed_history_pagination(
completed_page_size: int,
completed_total_count: int,
completed_total_pages: int,
path_prefix: str = "/admin",
) -> Renderable | None:
if completed_total_count <= completed_page_size:
return None
@ -410,6 +428,7 @@ def _completed_history_pagination(
_pagination_button(
label="Previous",
page=max(1, completed_page - 1),
path_prefix=path_prefix,
class_name=(
"relative inline-flex items-center rounded-xl border border-slate-200 "
"bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-stone-50"
@ -418,6 +437,7 @@ def _completed_history_pagination(
_pagination_button(
label="Next",
page=min(completed_total_pages, completed_page + 1),
path_prefix=path_prefix,
class_name=(
"relative ml-3 inline-flex items-center rounded-xl border border-slate-200 "
"bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-stone-50"
@ -443,6 +463,7 @@ def _completed_history_pagination(
label=str(page_number),
page=page_number,
current=page_number == completed_page,
path_prefix=path_prefix,
class_name=(
"relative z-10 inline-flex items-center bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950"
if page_number == completed_page
@ -463,12 +484,14 @@ def _completed_history_section(
completed_page_size: int,
completed_total_count: int,
completed_total_pages: int,
path_prefix: str = "/admin",
) -> Renderable:
pagination = _completed_history_pagination(
completed_page=completed_page,
completed_page_size=completed_page_size,
completed_total_count=completed_total_count,
completed_total_pages=completed_total_pages,
path_prefix=path_prefix,
)
return h.section[
table_section(
@ -486,7 +509,7 @@ def _completed_history_section(
action_button(
label="Clear history",
tone="danger",
post_path="/actions/completed-executions/clear",
post_path=f"{path_prefix}/actions/completed-executions/clear",
)
if completed_total_count > 0
else None
@ -501,11 +524,18 @@ def live_work_section(
running_executions: tuple[Mapping[str, object], ...] | None = None,
queued_executions: tuple[Mapping[str, object], ...] | None = None,
actions: Node | None = None,
show_row_actions: bool = True,
) -> Renderable:
running_items = running_executions or ()
queued_items = queued_executions or ()
running_rows = tuple(_running_row(execution) for execution in running_items)
queued_rows = tuple(_queued_row(execution) for execution in queued_items)
running_rows = tuple(
_running_row(execution, show_row_actions=show_row_actions)
for execution in running_items
)
queued_rows = tuple(
_queued_row(execution, show_row_actions=show_row_actions)
for execution in queued_items
)
live_rows = running_rows + queued_rows
live_row_attrs = tuple(
_queue_row_attrs(execution) for execution in running_items + queued_items
@ -515,10 +545,9 @@ def live_work_section(
title="Running jobs",
empty_message="No jobs are running or queued.",
headers=(
"State",
"Source",
"Details",
"Actions",
("State", "Source", "Details", "Actions")
if show_row_actions
else ("State", "Source", "Details")
),
rows=live_rows,
row_attrs=live_row_attrs,
@ -585,6 +614,7 @@ def runs_page(
completed_total_count: int | None = None,
completed_total_pages: int | None = None,
source_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
upcoming_items = upcoming_jobs or ()
completed_items = completed_executions or ()
@ -598,10 +628,13 @@ def runs_page(
)
return page_shell(
current_path="/runs",
current_path=f"{path_prefix}/runs",
eyebrow="Execution control",
title="Runs",
actions=muted_action_link(href="/sources", label="Back to sources"),
actions=muted_action_link(
href=f"{path_prefix}/sources",
label="Back to sources",
),
source_count=source_count,
running_count=len(running_executions or ()),
content=(
@ -629,6 +662,7 @@ def runs_page(
completed_page_size=completed_page_size,
completed_total_count=resolved_completed_total_count,
completed_total_pages=resolved_completed_total_pages,
path_prefix=path_prefix,
),
relative_time_formatter_script(),
),
@ -640,6 +674,7 @@ def execution_logs_page(
job_id: int,
execution_id: int,
log_view: Mapping[str, object] | None = None,
path_prefix: str = "/admin",
) -> Renderable:
if log_view is None:
log_view = {
@ -664,10 +699,10 @@ def execution_logs_page(
)
return page_shell(
current_path=f"/job/{job_id}/execution/{execution_id}/logs",
current_path=f"{path_prefix}/job/{job_id}/execution/{execution_id}/logs",
eyebrow="Execution log",
title=_text(log_view, "title"),
actions=muted_action_link(href="/runs", label="Back to runs"),
actions=muted_action_link(href=f"{path_prefix}/runs", label="Back to runs"),
content=(
section_card(
content=(
@ -677,7 +712,7 @@ def execution_logs_page(
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"
f"{path_prefix}/job/{job_id}/execution/{execution_id}/logs"
],
],
status_badge(

View file

@ -23,12 +23,13 @@ def _value(settings: Mapping[str, object] | None, key: str, default: str = "") -
def settings_page(
*,
settings: Mapping[str, object] | None = None,
action_path: str = "/actions/settings",
action_path: str = "/admin/actions/settings",
source_count: int = 0,
running_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
return page_shell(
current_path="/settings",
current_path=f"{path_prefix}/settings",
eyebrow="Configuration",
title="Settings",
description="Global runtime controls for the republisher.",
@ -85,7 +86,10 @@ def settings_page(
),
],
h.div(class_="flex flex-wrap justify-end gap-3 pt-2")[
muted_action_link(href="/", label="Back to dashboard"),
muted_action_link(
href=path_prefix,
label="Back to dashboard",
),
action_button(
label="Save settings",
tone="dark",

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import htpy as h
from htpy import Node, Renderable
from repub.components import app_shell
from repub.components import app_shell, publisher_shell
ON_LOAD_JS = (
"@post(window.location.pathname + "
@ -17,6 +17,7 @@ TAB_ID_JS = "self.crypto.randomUUID().substring(0,8)"
def shim_page(
*, datastar_src: str, current_path: str, head: Node | None = None
) -> Renderable:
shell = app_shell if current_path.startswith("/admin") else publisher_shell
return h.html(lang="en")[
h.head[
h.meta(charset="UTF-8"),
@ -33,7 +34,7 @@ def shim_page(
}
),
h.noscript["Your browser does not support JavaScript!"],
app_shell(
shell(
current_path=current_path,
content=(
h.section[

View file

@ -55,7 +55,9 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo
return bool(value)
def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]:
def _source_row(
source: Mapping[str, object], *, path_prefix: str = "/admin"
) -> tuple[Node, ...]:
return (
h.div[
h.div(class_="font-semibold text-slate-950")[str(source["name"])],
@ -78,28 +80,37 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]:
],
h.div(class_="flex flex-nowrap items-center gap-3 whitespace-nowrap")[
inline_link(
href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber"
href=f"{path_prefix}/sources/{source['slug']}/edit",
label="Edit",
tone="amber",
),
action_button(
label="Delete",
tone="danger",
post_path=f"/actions/sources/{source['slug']}/delete",
post_path=f"{path_prefix}/actions/sources/{source['slug']}/delete",
),
],
)
def sources_table(
*, sources: tuple[Mapping[str, object], ...] | None = None
*,
sources: tuple[Mapping[str, object], ...] | None = None,
path_prefix: str = "/admin",
) -> Renderable:
rows = tuple(_source_row(source) for source in (sources or ()))
rows = tuple(
_source_row(source, path_prefix=path_prefix) for source in (sources or ())
)
return table_section(
eyebrow="Inventory",
title="Sources",
empty_message="No sources yet.",
headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"),
rows=rows,
actions=header_action_link(href="/sources/create", label="Create source"),
actions=header_action_link(
href=f"{path_prefix}/sources/create",
label="Create source",
),
)
@ -107,15 +118,16 @@ def sources_page(
*,
sources: tuple[Mapping[str, object], ...] | None = None,
running_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
source_items = sources or ()
return page_shell(
current_path="/sources",
current_path=f"{path_prefix}/sources",
eyebrow="Source management",
title="Sources",
source_count=len(source_items),
running_count=running_count,
content=sources_table(sources=source_items),
content=sources_table(sources=source_items, path_prefix=path_prefix),
)
@ -124,6 +136,7 @@ def source_form(
mode: str,
action_path: str,
source: Mapping[str, object] | None = None,
path_prefix: str = "/admin",
) -> Renderable:
source_type = _value(source, "source_type", "pangea")
slug = _value(source, "slug")
@ -397,7 +410,7 @@ def source_form(
h.div(
class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-6"
)[
muted_action_link(href="/sources", label="Cancel"),
muted_action_link(href=f"{path_prefix}/sources", label="Cancel"),
action_button(
label=submit_label,
tone="dark",
@ -412,22 +425,27 @@ def source_form(
def create_source_page(
*,
action_path: str = "/actions/sources/create",
action_path: str = "/admin/actions/sources/create",
source_count: int = 0,
running_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
actions = (
muted_action_link(href="/sources", label="Back to sources"),
header_action_link(href="/runs", label="View runs"),
muted_action_link(href=f"{path_prefix}/sources", label="Back to sources"),
header_action_link(href=f"{path_prefix}/runs", label="View runs"),
)
return page_shell(
current_path="/sources/create",
current_path=f"{path_prefix}/sources/create",
eyebrow="Source creation",
title="Create source",
actions=actions,
source_count=source_count,
running_count=running_count,
content=source_form(mode="create", action_path=action_path),
content=source_form(
mode="create",
action_path=action_path,
path_prefix=path_prefix,
),
)
@ -438,17 +456,23 @@ def edit_source_page(
action_path: str,
source_count: int = 0,
running_count: int = 0,
path_prefix: str = "/admin",
) -> Renderable:
actions = (
muted_action_link(href="/sources", label="Back to sources"),
header_action_link(href="/runs", label="View runs"),
muted_action_link(href=f"{path_prefix}/sources", label="Back to sources"),
header_action_link(href=f"{path_prefix}/runs", label="View runs"),
)
return page_shell(
current_path=f"/sources/{slug}/edit",
current_path=f"{path_prefix}/sources/{slug}/edit",
eyebrow="Source editing",
title="Edit source",
actions=actions,
source_count=source_count,
running_count=running_count,
content=source_form(mode="edit", action_path=action_path, source=source),
content=source_form(
mode="edit",
action_path=action_path,
source=source,
path_prefix=path_prefix,
),
)