Refine publisher dashboard layout
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 11:11:36 +02:00
parent 2147d9c999
commit 813f19f355
8 changed files with 350 additions and 53 deletions

View file

@ -291,29 +291,37 @@ def table_section(
headers: tuple[str, ...], headers: tuple[str, ...],
rows: tuple[tuple[Node, ...], ...], rows: tuple[tuple[Node, ...], ...],
row_attrs: tuple[Mapping[str, str], ...] | None = None, row_attrs: tuple[Mapping[str, str], ...] | None = None,
header_classes: tuple[str, ...] | None = None,
cell_classes: tuple[str, ...] | None = None,
table_class: str = "relative w-full min-w-[64rem] divide-y divide-slate-200 table-auto",
first_header_class: str | None = None, first_header_class: str | None = None,
first_cell_class: str | None = None, first_cell_class: str | None = None,
actions: Node | None = None, actions: Node | None = None,
) -> Renderable: ) -> Renderable:
def header_class(index: int) -> str:
if header_classes is not None and index < len(header_classes):
return header_classes[index]
if index == 0 and first_header_class is not None:
return first_header_class
return "px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-3 sm:first:pl-4"
def cell_class(index: int) -> str:
if cell_classes is not None and index < len(cell_classes):
return cell_classes[index]
if index == 0:
return (
first_cell_class
or "py-3 pr-5 pl-3 text-sm font-medium text-slate-950 sm:pl-4"
)
return "px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600"
def render_row( def render_row(
row: tuple[Node, ...], attrs: Mapping[str, str] | None = None row: tuple[Node, ...], attrs: Mapping[str, str] | None = None
) -> Renderable: ) -> Renderable:
first_cell, *other_cells = row
row_attributes = dict(attrs or {}) row_attributes = dict(attrs or {})
row_attributes["class"] = f"align-top {row_attributes.get('class', '')}".strip() row_attributes["class"] = f"align-top {row_attributes.get('class', '')}".strip()
return h.tr(row_attributes)[ return h.tr(row_attributes)[
h.td( (h.td(class_=cell_class(index))[cell] for index, cell in enumerate(row)),
class_=(
first_cell_class
or "py-3 pr-5 pl-3 text-sm font-medium text-slate-950 sm:pl-4"
)
)[first_cell],
(
h.td(
class_="px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600"
)[cell]
for cell in other_cells
),
] ]
body_rows: Node body_rows: Node
@ -362,19 +370,13 @@ def table_section(
) )
)[ )[
h.div(class_="overflow-x-auto")[ h.div(class_="overflow-x-auto")[
h.table( h.table(class_=table_class)[
class_="relative w-full min-w-[64rem] 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(
scope="col", scope="col",
class_=( class_=header_class(index),
first_header_class
if index == 0 and first_header_class is not None
else "px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-3 sm:first:pl-4"
),
)[header] )[header]
for index, header in enumerate(headers) for index, header in enumerate(headers)
) )

View file

@ -93,31 +93,73 @@ def operational_snapshot(*, snapshot: Mapping[str, str] | None = None) -> Render
] ]
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( def _source_feed_row(
source_feed: Mapping[str, object], *, show_feed_url: bool source_feed: Mapping[str, object], *, show_feed_url: bool, compact_mobile: bool
) -> tuple[Node, ...]: ) -> tuple[Node, ...]:
last_updated_iso = source_feed.get("last_updated_iso") last_updated = _source_feed_time(
last_updated = ( source_feed,
h.time( iso_key="last_updated_iso",
datetime=str(last_updated_iso), label_key="last_updated",
title=str(last_updated_iso), class_name="font-medium text-slate-900",
class_="font-medium text-slate-900",
)[str(source_feed["last_updated"])]
if last_updated_iso is not None
else h.p(class_="font-medium text-slate-900")[str(source_feed["last_updated"])]
) )
next_run_iso = source_feed.get("next_run_at") next_run = _source_feed_time(
next_run = ( source_feed,
h.time( iso_key="next_run_at",
{ label_key="next_run",
"data-next-run-at": str(next_run_iso), class_name="font-medium text-slate-900",
"title": str(next_run_iso), data_attr="data-next-run-at",
}, )
datetime=str(next_run_iso), mobile_meta = (
class_="font-medium text-slate-900", h.div(class_="mt-2 grid gap-1 text-xs text-slate-500 md:hidden")[
)[str(source_feed["next_run"])] h.p(class_="flex flex-wrap gap-x-1.5")[
if next_run_iso is not None h.span(class_="font-medium text-slate-600")["Updated"],
else h.p(class_="font-medium text-slate-900")[str(source_feed["next_run"])] _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 = ( feed_url_cells = (
( (
@ -138,6 +180,7 @@ def _source_feed_row(
h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[ h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[
str(source_feed["slug"]) str(source_feed["slug"])
], ],
mobile_meta,
], ],
*feed_url_cells, *feed_url_cells,
status_badge( status_badge(
@ -160,12 +203,40 @@ def published_feeds_table(
manage_sources_href: str | None = "/admin/sources", manage_sources_href: str | None = "/admin/sources",
show_heading: bool = True, show_heading: bool = True,
show_feed_url: bool = True, show_feed_url: bool = True,
compact_mobile: bool = False,
) -> Renderable: ) -> Renderable:
rows = tuple( rows = tuple(
_source_feed_row(source_feed, show_feed_url=show_feed_url) _source_feed_row(
source_feed,
show_feed_url=show_feed_url,
compact_mobile=compact_mobile,
)
for source_feed in (source_feeds or ()) for source_feed in (source_feeds or ())
) )
feed_url_headers = ("Feed URL",) if show_feed_url else () 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( return table_section(
eyebrow="Published feeds" if show_heading else None, eyebrow="Published feeds" if show_heading else None,
title="Published feeds" if show_heading else None, title="Published feeds" if show_heading else None,
@ -179,6 +250,13 @@ def published_feeds_table(
"Actions", "Actions",
), ),
rows=rows, 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=( actions=(
muted_action_link(href=manage_sources_href, label="Manage sources") muted_action_link(href=manage_sources_href, label="Manage sources")
if manage_sources_href is not None if manage_sources_href is not None

View file

@ -47,11 +47,13 @@ def publisher_page(
manage_sources_href=None, manage_sources_href=None,
show_heading=False, show_heading=False,
show_feed_url=False, show_feed_url=False,
compact_mobile=True,
), ),
live_work_section( live_work_section(
running_executions=running_executions, running_executions=running_executions,
queued_executions=queued_executions, queued_executions=queued_executions,
show_row_actions=False, show_row_actions=False,
compact_mobile=True,
), ),
relative_time_formatter_script(), relative_time_formatter_script(),
), ),

View file

@ -161,8 +161,11 @@ def _live_status_cell(
status_tone: str, status_tone: str,
clock_label: str, clock_label: str,
calendar_label: Node, calendar_label: Node,
compact_mobile: bool = False,
) -> Node: ) -> Node:
return h.div(class_="min-w-[10rem]")[ return h.div(
class_=("min-w-0 md:min-w-[10rem]" if compact_mobile else "min-w-[10rem]")
)[
h.div(class_="flex items-center gap-2")[ h.div(class_="flex items-center gap-2")[
h.span(class_="font-mono text-xs text-slate-500")[f"#{execution_id}"], h.span(class_="font-mono text-xs text-slate-500")[f"#{execution_id}"],
h.span( h.span(
@ -191,7 +194,10 @@ def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]:
def _running_row( def _running_row(
execution: Mapping[str, object], *, show_row_actions: bool = True execution: Mapping[str, object],
*,
show_row_actions: bool = True,
compact_mobile: bool = False,
) -> tuple[Node, ...]: ) -> tuple[Node, ...]:
started_at = _maybe_text(execution, "started_at_iso") started_at = _maybe_text(execution, "started_at_iso")
started_at_label: Node = h.p(class_="truncate")[_text(execution, "started_at")] started_at_label: Node = h.p(class_="truncate")[_text(execution, "started_at")]
@ -205,6 +211,20 @@ def _running_row(
class_="truncate", class_="truncate",
)[_text(execution, "started_at")] )[_text(execution, "started_at")]
mobile_details = (
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")["Stats"],
h.span[_text(execution, "stats")],
],
h.p(class_="flex flex-wrap gap-x-1.5")[
h.span(class_="font-medium text-slate-600")["Worker"],
h.span[_text(execution, "worker")],
],
]
if compact_mobile
else None
)
cells = ( cells = (
_live_status_cell( _live_status_cell(
execution_id=_text(execution, "execution_id"), execution_id=_text(execution, "execution_id"),
@ -213,12 +233,14 @@ def _running_row(
clock_label=_maybe_text(execution, "duration") clock_label=_maybe_text(execution, "duration")
or _text(execution, "runtime"), or _text(execution, "runtime"),
calendar_label=started_at_label, calendar_label=started_at_label,
compact_mobile=compact_mobile,
), ),
h.div[ h.div[
h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.div(class_="font-semibold text-slate-950")[_text(execution, "source")],
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[
_text(execution, "slug") _text(execution, "slug")
], ],
mobile_details,
], ],
h.div(class_="max-w-xs whitespace-normal")[ h.div(class_="max-w-xs whitespace-normal")[
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")], h.p(class_="font-medium text-slate-900")[_text(execution, "stats")],
@ -245,7 +267,10 @@ def _running_row(
def _queued_row( def _queued_row(
execution: Mapping[str, object], *, show_row_actions: bool = True execution: Mapping[str, object],
*,
show_row_actions: bool = True,
compact_mobile: bool = False,
) -> tuple[Node, ...]: ) -> tuple[Node, ...]:
queued_at = _maybe_text(execution, "queued_at_iso") queued_at = _maybe_text(execution, "queued_at_iso")
queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")] queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")]
@ -259,6 +284,20 @@ def _queued_row(
class_="truncate", class_="truncate",
)[_text(execution, "queued_at")] )[_text(execution, "queued_at")]
mobile_details = (
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")["Queued"],
h.span[f"Queue position #{_text(execution, 'queue_position')}"],
],
h.p(class_="flex flex-wrap gap-x-1.5")[
h.span(class_="font-medium text-slate-600")["State"],
h.span["waiting for capacity"],
],
]
if compact_mobile
else None
)
cells = ( cells = (
_live_status_cell( _live_status_cell(
execution_id=_text(execution, "execution_id"), execution_id=_text(execution, "execution_id"),
@ -266,12 +305,14 @@ def _queued_row(
status_tone="queued", status_tone="queued",
clock_label="Waiting", clock_label="Waiting",
calendar_label=queued_label, calendar_label=queued_label,
compact_mobile=compact_mobile,
), ),
h.div[ h.div[
h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.div(class_="font-semibold text-slate-950")[_text(execution, "source")],
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[
_text(execution, "slug") _text(execution, "slug")
], ],
mobile_details,
], ],
h.div(class_="max-w-xs whitespace-normal")[ h.div(class_="max-w-xs whitespace-normal")[
h.p(class_="font-medium text-slate-900")[ h.p(class_="font-medium text-slate-900")[
@ -525,21 +566,49 @@ def live_work_section(
queued_executions: tuple[Mapping[str, object], ...] | None = None, queued_executions: tuple[Mapping[str, object], ...] | None = None,
actions: Node | None = None, actions: Node | None = None,
show_row_actions: bool = True, show_row_actions: bool = True,
compact_mobile: bool = False,
) -> Renderable: ) -> Renderable:
running_items = running_executions or () running_items = running_executions or ()
queued_items = queued_executions or () queued_items = queued_executions or ()
running_rows = tuple( running_rows = tuple(
_running_row(execution, show_row_actions=show_row_actions) _running_row(
execution,
show_row_actions=show_row_actions,
compact_mobile=compact_mobile,
)
for execution in running_items for execution in running_items
) )
queued_rows = tuple( queued_rows = tuple(
_queued_row(execution, show_row_actions=show_row_actions) _queued_row(
execution,
show_row_actions=show_row_actions,
compact_mobile=compact_mobile,
)
for execution in queued_items for execution in queued_items
) )
live_rows = running_rows + queued_rows live_rows = running_rows + queued_rows
live_row_attrs = tuple( live_row_attrs = tuple(
_queue_row_attrs(execution) for execution in running_items + queued_items _queue_row_attrs(execution) for execution in running_items + queued_items
) )
use_compact_columns = compact_mobile and not show_row_actions
header_classes = (
(
"w-[34%] px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 md:w-[24%] sm:pl-4",
"w-[66%] px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 md:w-[34%]",
"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-[42%]",
)
if use_compact_columns
else None
)
cell_classes = (
(
"w-[34%] py-3 pr-3 pl-3 text-sm font-medium text-slate-950 md:w-[24%] sm:pl-4",
"w-[66%] px-2.5 py-3 align-top text-sm whitespace-normal text-slate-600 md:w-[34%]",
"hidden px-2.5 py-3 align-top text-sm whitespace-normal text-slate-600 md:table-cell md:w-[42%]",
)
if use_compact_columns
else None
)
return table_section( return table_section(
eyebrow="Live work", eyebrow="Live work",
title="Running jobs", title="Running jobs",
@ -551,6 +620,13 @@ def live_work_section(
), ),
rows=live_rows, rows=live_rows,
row_attrs=live_row_attrs, row_attrs=live_row_attrs,
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=actions, actions=actions,
) )

View file

@ -320,6 +320,9 @@
.hidden { .hidden {
display: none; display: none;
} }
.inline {
display: inline;
}
.inline-flex { .inline-flex {
display: inline-flex; display: inline-flex;
} }
@ -367,6 +370,18 @@
.w-40 { .w-40 {
width: calc(var(--spacing) * 40); width: calc(var(--spacing) * 40);
} }
.w-\[26\%\] {
width: 26%;
}
.w-\[34\%\] {
width: 34%;
}
.w-\[48\%\] {
width: 48%;
}
.w-\[66\%\] {
width: 66%;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@ -388,6 +403,9 @@
.max-w-xs { .max-w-xs {
max-width: var(--container-xs); max-width: var(--container-xs);
} }
.min-w-0 {
min-width: calc(var(--spacing) * 0);
}
.min-w-32 { .min-w-32 {
min-width: calc(var(--spacing) * 32); min-width: calc(var(--spacing) * 32);
} }
@ -409,6 +427,9 @@
.table-auto { .table-auto {
table-layout: auto; table-layout: auto;
} }
.table-fixed {
table-layout: fixed;
}
.translate-x-5 { .translate-x-5 {
--tw-translate-x: calc(var(--spacing) * 5); --tw-translate-x: calc(var(--spacing) * 5);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@ -455,6 +476,9 @@
.justify-end { .justify-end {
justify-content: flex-end; justify-content: flex-end;
} }
.gap-1 {
gap: calc(var(--spacing) * 1);
}
.gap-1\.5 { .gap-1\.5 {
gap: calc(var(--spacing) * 1.5); gap: calc(var(--spacing) * 1.5);
} }
@ -501,6 +525,9 @@
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)));
} }
} }
.gap-x-1\.5 {
column-gap: calc(var(--spacing) * 1.5);
}
.-space-x-px { .-space-x-px {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
@ -732,6 +759,9 @@
.pt-6 { .pt-6 {
padding-top: calc(var(--spacing) * 6); padding-top: calc(var(--spacing) * 6);
} }
.pr-3 {
padding-right: calc(var(--spacing) * 3);
}
.pr-5 { .pr-5 {
padding-right: calc(var(--spacing) * 5); padding-right: calc(var(--spacing) * 5);
} }
@ -744,6 +774,9 @@
.text-left { .text-left {
text-align: left; text-align: left;
} }
.text-right {
text-align: right;
}
.align-top { .align-top {
vertical-align: top; vertical-align: top;
} }
@ -1144,6 +1177,61 @@
} }
} }
} }
.md\:hidden {
@media (width >= 48rem) {
display: none;
}
}
.md\:table-cell {
@media (width >= 48rem) {
display: table-cell;
}
}
.md\:w-\[12\%\] {
@media (width >= 48rem) {
width: 12%;
}
}
.md\:w-\[16\%\] {
@media (width >= 48rem) {
width: 16%;
}
}
.md\:w-\[18\%\] {
@media (width >= 48rem) {
width: 18%;
}
}
.md\:w-\[22\%\] {
@media (width >= 48rem) {
width: 22%;
}
}
.md\:w-\[24\%\] {
@media (width >= 48rem) {
width: 24%;
}
}
.md\:w-\[32\%\] {
@media (width >= 48rem) {
width: 32%;
}
}
.md\:w-\[34\%\] {
@media (width >= 48rem) {
width: 34%;
}
}
.md\:w-\[42\%\] {
@media (width >= 48rem) {
width: 42%;
}
}
.md\:min-w-\[10rem\] {
@media (width >= 48rem) {
min-width: 10rem;
}
}
.md\:grid-cols-2 { .md\:grid-cols-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View file

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any, cast
from datastar_py.quart import DatastarResponse from datastar_py.quart import DatastarResponse
from quart import Quart, Response from quart import Quart, Response, redirect
from repub.web.app import _page_patch_response, _shim_page_response, render_dashboard from repub.web.app import _page_patch_response, _shim_page_response, render_dashboard
@ -12,6 +12,11 @@ RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[A
def register_dashboard_routes(app: Quart, *, admin_required: RouteGuard) -> None: def register_dashboard_routes(app: Quart, *, admin_required: RouteGuard) -> None:
@app.get("/admin/")
@admin_required
async def admin_dashboard_trailing_slash() -> Response:
return cast(Response, redirect("/admin"))
@app.get("/admin") @app.get("/admin")
@admin_required @admin_required
async def admin_dashboard_home() -> Response: async def admin_dashboard_home() -> Response:

View file

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any, cast
from datastar_py.quart import DatastarResponse from datastar_py.quart import DatastarResponse
from quart import Quart, Response from quart import Quart, Response, redirect
from repub.web.app import _page_patch_response, _shim_page_response, render_publisher from repub.web.app import _page_patch_response, _shim_page_response, render_publisher
@ -17,6 +17,11 @@ def register_publisher_dashboard_routes(
publisher_required: RouteGuard, publisher_required: RouteGuard,
admin_required: RouteGuard, admin_required: RouteGuard,
) -> None: ) -> None:
@app.get("/publisher/")
@publisher_required
async def publisher_trailing_slash() -> Response:
return cast(Response, redirect("/publisher"))
@app.get("/publisher") @app.get("/publisher")
@publisher_required @publisher_required
async def publisher_home() -> Response: async def publisher_home() -> Response:
@ -36,6 +41,11 @@ def register_publisher_dashboard_routes(
), ),
) )
@app.get("/admin/publisher/")
@admin_required
async def admin_publisher_trailing_slash() -> Response:
return cast(Response, redirect("/admin/publisher"))
@app.get("/admin/publisher") @app.get("/admin/publisher")
@admin_required @admin_required
async def admin_publisher_home() -> Response: async def admin_publisher_home() -> Response:

View file

@ -414,6 +414,28 @@ def test_root_get_redirects_to_publisher() -> None:
asyncio.run(run()) asyncio.run(run())
@pytest.mark.parametrize(
("source_path", "target_path"),
(
("/admin/", "/admin"),
("/publisher/", "/publisher"),
("/admin/publisher/", "/admin/publisher"),
),
)
def test_dashboard_trailing_slashes_redirect_to_canonical_paths(
source_path: str, target_path: str
) -> None:
async def run() -> None:
client = create_app().test_client()
response = await client.get(source_path)
assert response.status_code == 302
assert response.headers["Location"] == target_path
asyncio.run(run())
def test_admin_get_serves_datastar_shim_with_admin_static_assets() -> None: def test_admin_get_serves_datastar_shim_with_admin_static_assets() -> None:
async def run() -> None: async def run() -> None:
client = create_app().test_client() client = create_app().test_client()
@ -1153,6 +1175,12 @@ def test_render_publisher_shows_published_feeds_with_publisher_actions(
assert 'href="/feeds/publisher-source/feed.rss"' not in body assert 'href="/feeds/publisher-source/feed.rss"' not in body
assert "Available" in body assert "Available" in body
assert "Next run" in body assert "Next run" in body
assert "md:w-[32%]" in body
assert "table-fixed" in body
assert "min-w-0" in body
assert "md:hidden" in body
assert ">Updated<" in body
assert ">Next<" in body
assert "Disk usage" not in body assert "Disk usage" not in body
assert f"/publisher/actions/jobs/{job.id}/run-now" in body assert f"/publisher/actions/jobs/{job.id}/run-now" in body
assert f"/admin/actions/jobs/{job.id}/run-now" not in body assert f"/admin/actions/jobs/{job.id}/run-now" not in body
@ -1222,6 +1250,14 @@ def test_render_publisher_shows_live_work_without_admin_controls(
assert "Publisher running source" in body assert "Publisher running source" in body
assert "Publisher queued source" in body assert "Publisher queued source" in body
assert "Queue position #1" in body assert "Queue position #1" in body
assert "w-[34%]" in body
assert "w-[66%]" in body
assert "hidden" in body
assert "md:table-cell" in body
assert "md:min-w-[10rem]" in body
assert ">Stats<" in body
assert ">Worker<" in body
assert ">Queued<" in body
assert f"/publisher/job/{running_job.id}/execution/" not in body assert f"/publisher/job/{running_job.id}/execution/" not in body
assert ( assert (
f"/publisher/actions/executions/{int(running_execution.get_id())}/cancel" f"/publisher/actions/executions/{int(running_execution.get_id())}/cancel"