diff --git a/repub/components.py b/repub/components.py index 49693e9..e71fffc 100644 --- a/repub/components.py +++ b/repub/components.py @@ -291,29 +291,37 @@ def table_section( headers: tuple[str, ...], rows: tuple[tuple[Node, ...], ...], 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_cell_class: str | None = None, actions: Node | None = None, ) -> 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( row: tuple[Node, ...], attrs: Mapping[str, str] | None = None ) -> Renderable: - first_cell, *other_cells = row row_attributes = dict(attrs or {}) row_attributes["class"] = f"align-top {row_attributes.get('class', '')}".strip() return h.tr(row_attributes)[ - h.td( - 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 - ), + (h.td(class_=cell_class(index))[cell] for index, cell in enumerate(row)), ] body_rows: Node @@ -362,19 +370,13 @@ def table_section( ) )[ h.div(class_="overflow-x-auto")[ - h.table( - class_="relative w-full min-w-[64rem] divide-y divide-slate-200 table-auto" - )[ + h.table(class_=table_class)[ h.thead(class_="bg-stone-50")[ h.tr[ ( h.th( scope="col", - class_=( - 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" - ), + class_=header_class(index), )[header] for index, header in enumerate(headers) ) diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index de6bfbb..8439d45 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -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( - source_feed: Mapping[str, object], *, show_feed_url: bool + source_feed: Mapping[str, object], *, show_feed_url: bool, compact_mobile: bool ) -> tuple[Node, ...]: - last_updated_iso = source_feed.get("last_updated_iso") - last_updated = ( - h.time( - datetime=str(last_updated_iso), - title=str(last_updated_iso), - 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"])] + 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_iso = source_feed.get("next_run_at") - next_run = ( - h.time( - { - "data-next-run-at": str(next_run_iso), - "title": str(next_run_iso), - }, - datetime=str(next_run_iso), - class_="font-medium text-slate-900", - )[str(source_feed["next_run"])] - if next_run_iso is not None - else h.p(class_="font-medium text-slate-900")[str(source_feed["next_run"])] + 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 = ( ( @@ -138,6 +180,7 @@ def _source_feed_row( h.p(class_="mt-0.5 font-mono text-[11px] text-slate-500")[ str(source_feed["slug"]) ], + mobile_meta, ], *feed_url_cells, status_badge( @@ -160,12 +203,40 @@ def published_feeds_table( 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) + _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, @@ -179,6 +250,13 @@ def published_feeds_table( "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 diff --git a/repub/pages/publisher.py b/repub/pages/publisher.py index 675379d..5a62b60 100644 --- a/repub/pages/publisher.py +++ b/repub/pages/publisher.py @@ -47,11 +47,13 @@ def publisher_page( manage_sources_href=None, show_heading=False, show_feed_url=False, + compact_mobile=True, ), live_work_section( running_executions=running_executions, queued_executions=queued_executions, show_row_actions=False, + compact_mobile=True, ), relative_time_formatter_script(), ), diff --git a/repub/pages/runs.py b/repub/pages/runs.py index af7d48b..b7a345e 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -161,8 +161,11 @@ def _live_status_cell( status_tone: str, clock_label: str, calendar_label: Node, + compact_mobile: bool = False, ) -> 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.span(class_="font-mono text-xs text-slate-500")[f"#{execution_id}"], h.span( @@ -191,7 +194,10 @@ def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]: 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, ...]: started_at = _maybe_text(execution, "started_at_iso") started_at_label: Node = h.p(class_="truncate")[_text(execution, "started_at")] @@ -205,6 +211,20 @@ def _running_row( class_="truncate", )[_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 = ( _live_status_cell( execution_id=_text(execution, "execution_id"), @@ -213,12 +233,14 @@ def _running_row( clock_label=_maybe_text(execution, "duration") or _text(execution, "runtime"), calendar_label=started_at_label, + compact_mobile=compact_mobile, ), h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], + mobile_details, ], h.div(class_="max-w-xs whitespace-normal")[ h.p(class_="font-medium text-slate-900")[_text(execution, "stats")], @@ -245,7 +267,10 @@ def _running_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, ...]: queued_at = _maybe_text(execution, "queued_at_iso") queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")] @@ -259,6 +284,20 @@ def _queued_row( class_="truncate", )[_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 = ( _live_status_cell( execution_id=_text(execution, "execution_id"), @@ -266,12 +305,14 @@ def _queued_row( status_tone="queued", clock_label="Waiting", calendar_label=queued_label, + compact_mobile=compact_mobile, ), h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], + mobile_details, ], h.div(class_="max-w-xs whitespace-normal")[ h.p(class_="font-medium text-slate-900")[ @@ -525,21 +566,49 @@ def live_work_section( queued_executions: tuple[Mapping[str, object], ...] | None = None, actions: Node | None = None, show_row_actions: bool = True, + compact_mobile: bool = False, ) -> Renderable: running_items = running_executions or () queued_items = queued_executions or () 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 ) 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 ) live_rows = running_rows + queued_rows live_row_attrs = tuple( _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( eyebrow="Live work", title="Running jobs", @@ -551,6 +620,13 @@ def live_work_section( ), rows=live_rows, 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, ) diff --git a/repub/static/app.css b/repub/static/app.css index 11ab841..683f22f 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -320,6 +320,9 @@ .hidden { display: none; } + .inline { + display: inline; + } .inline-flex { display: inline-flex; } @@ -367,6 +370,18 @@ .w-40 { width: calc(var(--spacing) * 40); } + .w-\[26\%\] { + width: 26%; + } + .w-\[34\%\] { + width: 34%; + } + .w-\[48\%\] { + width: 48%; + } + .w-\[66\%\] { + width: 66%; + } .w-full { width: 100%; } @@ -388,6 +403,9 @@ .max-w-xs { max-width: var(--container-xs); } + .min-w-0 { + min-width: calc(var(--spacing) * 0); + } .min-w-32 { min-width: calc(var(--spacing) * 32); } @@ -409,6 +427,9 @@ .table-auto { table-layout: auto; } + .table-fixed { + table-layout: fixed; + } .translate-x-5 { --tw-translate-x: calc(var(--spacing) * 5); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -455,6 +476,9 @@ .justify-end { justify-content: flex-end; } + .gap-1 { + gap: calc(var(--spacing) * 1); + } .gap-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))); } } + .gap-x-1\.5 { + column-gap: calc(var(--spacing) * 1.5); + } .-space-x-px { :where(& > :not(:last-child)) { --tw-space-x-reverse: 0; @@ -732,6 +759,9 @@ .pt-6 { padding-top: calc(var(--spacing) * 6); } + .pr-3 { + padding-right: calc(var(--spacing) * 3); + } .pr-5 { padding-right: calc(var(--spacing) * 5); } @@ -744,6 +774,9 @@ .text-left { text-align: left; } + .text-right { + text-align: right; + } .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 { @media (width >= 48rem) { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/repub/web/admin/pages/dashboard.py b/repub/web/admin/pages/dashboard.py index 7556290..167fbcd 100644 --- a/repub/web/admin/pages/dashboard.py +++ b/repub/web/admin/pages/dashboard.py @@ -1,10 +1,10 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, cast 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 @@ -12,6 +12,11 @@ RouteGuard = Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[A 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") @admin_required async def admin_dashboard_home() -> Response: diff --git a/repub/web/publisher/pages/dashboard.py b/repub/web/publisher/pages/dashboard.py index d661087..427c0de 100644 --- a/repub/web/publisher/pages/dashboard.py +++ b/repub/web/publisher/pages/dashboard.py @@ -1,10 +1,10 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, cast 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 @@ -17,6 +17,11 @@ def register_publisher_dashboard_routes( publisher_required: RouteGuard, admin_required: RouteGuard, ) -> None: + @app.get("/publisher/") + @publisher_required + async def publisher_trailing_slash() -> Response: + return cast(Response, redirect("/publisher")) + @app.get("/publisher") @publisher_required 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") @admin_required async def admin_publisher_home() -> Response: diff --git a/tests/test_web.py b/tests/test_web.py index 45ac776..bcdea95 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -414,6 +414,28 @@ def test_root_get_redirects_to_publisher() -> None: 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: async def run() -> None: 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 "Available" 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 f"/publisher/actions/jobs/{job.id}/run-now" 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 queued source" 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/actions/executions/{int(running_execution.get_id())}/cancel"