republisher/repub/pages/dashboard.py
Abel Luck 813f19f355
All checks were successful
buildbot/nix-eval Build done.
buildbot/nix-build Build done.
buildbot/nix-effects Build done.
Refine publisher dashboard layout
2026-06-02 11:11:36 +02:00

302 lines
10 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from typing import cast
import htpy as h
from htpy import Node, Renderable
from repub.components import (
action_button,
app_shell,
inline_link,
muted_action_link,
stat_card,
status_badge,
table_section,
)
from repub.pages.runs import live_work_section, relative_time_formatter_script
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"
)[
h.div[
h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[
"Republisher"
],
],
h.div(class_="flex flex-wrap gap-2")[
(
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",
),
],
]
]
def operational_snapshot(*, snapshot: Mapping[str, str] | None = None) -> Renderable:
values = snapshot or {
"running_now": "0",
"upcoming_today": "0",
"failures_24h": "0",
"artifact_footprint": "0 B",
}
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.dl(class_="grid gap-3 md:grid-cols-2 xl:grid-cols-4")[
stat_card(
label="Running now",
value=values["running_now"],
detail="Currently active job executions.",
),
stat_card(
label="Upcoming today",
value=values["upcoming_today"],
detail="Enabled jobs that are ready for their next run.",
),
stat_card(
label="Failures in 24h",
value=values["failures_24h"],
detail="Recent failed executions recorded by the scheduler.",
),
stat_card(
label="Artifact footprint",
value=values["artifact_footprint"],
detail="Current artifact size under the output path.",
),
],
]
def _source_feed_time(
source_feed: Mapping[str, object],
*,
iso_key: str,
label_key: str,
class_name: str,
data_attr: str | None = None,
inline: bool = False,
) -> Node:
iso_value = source_feed.get(iso_key)
label = str(source_feed[label_key])
if iso_value is not None:
attrs = {
"datetime": str(iso_value),
"title": str(iso_value),
"class": class_name,
}
if data_attr is not None:
attrs[data_attr] = str(iso_value)
return h.time(attrs)[label]
if inline:
return h.span(class_=class_name)[label]
return h.p(class_=class_name)[label]
def _source_feed_row(
source_feed: Mapping[str, object], *, show_feed_url: bool, compact_mobile: bool
) -> tuple[Node, ...]:
last_updated = _source_feed_time(
source_feed,
iso_key="last_updated_iso",
label_key="last_updated",
class_name="font-medium text-slate-900",
)
next_run = _source_feed_time(
source_feed,
iso_key="next_run_at",
label_key="next_run",
class_name="font-medium text-slate-900",
data_attr="data-next-run-at",
)
mobile_meta = (
h.div(class_="mt-2 grid gap-1 text-xs text-slate-500 md:hidden")[
h.p(class_="flex flex-wrap gap-x-1.5")[
h.span(class_="font-medium text-slate-600")["Updated"],
_source_feed_time(
source_feed,
iso_key="last_updated_iso",
label_key="last_updated",
class_name="font-medium text-slate-700",
inline=True,
),
],
h.p(class_="flex flex-wrap gap-x-1.5")[
h.span(class_="font-medium text-slate-600")["Next"],
_source_feed_time(
source_feed,
iso_key="next_run_at",
label_key="next_run",
class_name="font-medium text-slate-700",
data_attr="data-next-run-at",
inline=True,
),
],
]
if compact_mobile
else None
)
feed_url_cells = (
(
h.div(class_="min-w-64")[
inline_link(
href=str(source_feed["feed_href"]),
label=str(source_feed["feed_href"]),
tone="amber",
)
],
)
if show_feed_url
else ()
)
return (
h.div[
h.div(class_="font-semibold text-slate-950")[str(source_feed["source"])],
h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[
str(source_feed["slug"])
],
mobile_meta,
],
*feed_url_cells,
status_badge(
label=str(source_feed["feed_status_label"]),
tone=str(source_feed["feed_status_tone"]),
),
last_updated,
next_run,
action_button(
label="Run now",
disabled=bool(source_feed["run_disabled"]),
post_path=cast(str | None, source_feed.get("run_post_path")),
),
)
def published_feeds_table(
*,
source_feeds: tuple[Mapping[str, object], ...] | None = None,
manage_sources_href: str | None = "/admin/sources",
show_heading: bool = True,
show_feed_url: bool = True,
compact_mobile: bool = False,
) -> Renderable:
rows = tuple(
_source_feed_row(
source_feed,
show_feed_url=show_feed_url,
compact_mobile=compact_mobile,
)
for source_feed in (source_feeds or ())
)
feed_url_headers = ("Feed URL",) if show_feed_url else ()
use_compact_columns = compact_mobile and not show_feed_url
header_classes = (
(
"w-[48%] px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 md:w-[32%] sm:pl-4",
"w-[26%] px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 md:w-[16%]",
"hidden px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 md:table-cell md:w-[22%]",
"hidden px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 md:table-cell md:w-[18%]",
"w-[26%] px-2.5 py-2.5 text-right text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 md:w-[12%]",
)
if use_compact_columns
else None
)
cell_classes = (
(
"w-[48%] py-3 pr-3 pl-3 text-sm font-medium text-slate-950 md:w-[32%] sm:pl-4",
"w-[26%] px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600 md:w-[16%]",
"hidden px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600 md:table-cell md:w-[22%]",
"hidden px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600 md:table-cell md:w-[18%]",
"w-[26%] px-2.5 py-3 text-right align-top text-sm whitespace-nowrap text-slate-600 md:w-[12%]",
)
if use_compact_columns
else None
)
return table_section(
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",
*feed_url_headers,
"Status",
"Last updated",
"Next run",
"Actions",
),
rows=rows,
header_classes=header_classes,
cell_classes=cell_classes,
table_class=(
"relative w-full min-w-0 divide-y divide-slate-200 table-fixed"
if use_compact_columns
else "relative w-full min-w-[64rem] divide-y divide-slate-200 table-auto"
),
actions=(
muted_action_link(href=manage_sources_href, label="Manage sources")
if manage_sources_href is not None
else None
),
)
def dashboard_page() -> Renderable:
return dashboard_page_with_data()
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,
source_count=len(source_items),
running_count=len(running_items),
content=(
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,
manage_sources_href=f"{path_prefix}/sources",
),
relative_time_formatter_script(),
),
)