republisher/repub/pages/runs.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

806 lines
27 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
import htpy as h
from htpy import Node, Renderable
from repub.components import (
action_button,
inline_link,
muted_action_link,
page_shell,
section_card,
status_badge,
table_section,
)
def _text(values: Mapping[str, object], key: str) -> str:
return str(values[key])
def _maybe_text(values: Mapping[str, object], key: str) -> str | None:
value = values.get(key)
if value in {None, ""}:
return None
return str(value)
def _flag(values: Mapping[str, object], key: str) -> bool:
return bool(values[key])
def _queue_icon(direction: str) -> Renderable:
path = (
"M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18"
if direction == "up"
else "M19.5 13.5 12 21m0 0-7.5-7.5M12 21V3"
)
return h.svg(
xmlns="http://www.w3.org/2000/svg",
fill="none",
viewBox="0 0 24 24",
stroke_width="1.5",
stroke="currentColor",
class_="size-4",
)[
h.path(
stroke_linecap="round",
stroke_linejoin="round",
d=path,
)
]
def _icon(path: str, *, class_name: str = "size-4") -> Renderable:
return h.svg(
xmlns="http://www.w3.org/2000/svg",
fill="none",
viewBox="0 0 24 24",
stroke_width="1.5",
stroke="currentColor",
class_=class_name,
)[
h.path(
stroke_linecap="round",
stroke_linejoin="round",
d=path,
)
]
def _clock_icon() -> Renderable:
return _icon("M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
def _calendar_icon() -> Renderable:
return _icon(
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z"
)
def _status_icon(tone: str) -> Renderable:
if tone == "running":
return h.svg(
xmlns="http://www.w3.org/2000/svg",
fill="currentColor",
viewBox="0 0 640 640",
class_="size-3.5 rotate-180",
)[
h.path(
d="M512 320C512 214 426 128 320 128L320 512C426 512 512 426 512 320zM64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320z",
)
]
if tone == "queued":
return _clock_icon()
return _icon(
(
"M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
if tone == "done"
else "M9.75 9.75l4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
),
class_name="size-3.5",
)
def _status_tone_classes(tone: str) -> str:
return {
"running": "bg-sky-100 text-sky-800",
"queued": "bg-amber-200 text-amber-950",
"scheduled": "bg-sky-100 text-sky-800",
"idle": "bg-slate-200 text-slate-700",
"failed": "bg-rose-100 text-rose-800",
"done": "bg-emerald-100 text-emerald-800",
}[tone]
def _completed_status_cell(execution: Mapping[str, object]) -> Node:
duration = _maybe_text(execution, "duration") or "--:--:--"
ended_at = _maybe_text(execution, "ended_at_iso")
ended_at_label: Node = h.p(class_="truncate")[_text(execution, "ended_at")]
if ended_at is not None:
ended_at_label = h.time(
{
"data-ended-at": ended_at,
"title": ended_at,
},
datetime=ended_at,
class_="truncate",
)[_text(execution, "ended_at")]
return h.div(class_="min-w-[10rem]")[
h.div(class_="flex items-center gap-2")[
h.span(class_="font-mono text-xs text-slate-500")[
f"#{_text(execution, 'execution_id')}"
],
h.span(
class_=(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs "
f"font-semibold {_status_tone_classes(_text(execution, 'status_tone'))}"
)
)[
_status_icon(_text(execution, "status_tone")),
h.span[_text(execution, "status")],
],
],
h.div(class_="mt-1.5 space-y-1 text-xs text-slate-500")[
h.p(class_="flex items-center gap-1.5")[_clock_icon(), h.span[duration]],
h.p(class_="flex items-center gap-1.5")[
_calendar_icon(),
ended_at_label,
],
],
]
def _live_status_cell(
*,
execution_id: str,
status: str,
status_tone: str,
clock_label: str,
calendar_label: Node,
compact_mobile: bool = False,
) -> Node:
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(
class_=(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs "
f"font-semibold {_status_tone_classes(status_tone)}"
)
)[
_status_icon(status_tone),
h.span[status],
],
],
h.div(class_="mt-1.5 space-y-1 text-xs text-slate-500")[
h.p(class_="flex items-center gap-1.5")[_clock_icon(), h.span[clock_label]],
h.p(class_="flex items-center gap-1.5")[_calendar_icon(), calendar_label],
],
]
def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]:
return {
"style": (
"view-transition-name: " f"running-job-{_text(execution, 'execution_id')};"
)
}
def _running_row(
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")]
if started_at is not None:
started_at_label = h.time(
{
"data-started-at": started_at,
"title": started_at,
},
datetime=started_at,
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"),
status=_text(execution, "status"),
status_tone="running",
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")],
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"),
label="View log",
tone="amber",
),
action_button(
label=_text(execution, "cancel_label"),
tone="danger",
post_path=_maybe_text(execution, "cancel_post_path"),
),
],
)
def _queued_row(
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")]
if queued_at is not None:
queued_label = h.time(
{
"data-queued-at": queued_at,
"title": queued_at,
},
datetime=queued_at,
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"),
status="Queued",
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")[
f"Queue position #{_text(execution, 'queue_position')}"
],
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"),
emphasis="icon",
title="Move up",
disabled=_flag(execution, "move_up_disabled"),
post_path=_maybe_text(execution, "move_up_post_path"),
),
action_button(
label=_queue_icon("down"),
emphasis="icon",
title="Move down",
disabled=_flag(execution, "move_down_disabled"),
post_path=_maybe_text(execution, "move_down_post_path"),
),
action_button(
label="Cancel",
tone="danger",
post_path=_maybe_text(execution, "cancel_post_path"),
),
],
)
def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]:
next_run_at = _maybe_text(job, "next_run_at")
next_run_label: Node = h.p(class_="font-medium text-slate-900")[
_text(job, "next_run")
]
if next_run_at is not None:
next_run_label = h.time(
{
"data-next-run-at": next_run_at,
"title": next_run_at,
},
datetime=next_run_at,
class_="font-medium text-slate-900",
)[_text(job, "next_run")]
return (
h.div[
h.div(class_="font-semibold text-slate-950")[_text(job, "source")],
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[_text(job, "slug")],
],
status_badge(
label=_text(job, "enabled_label"),
tone=_text(job, "enabled_tone"),
),
h.div[next_run_label,],
h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")],
h.p(class_="max-w-32 whitespace-normal text-sm text-slate-500")[
_text(job, "run_reason")
],
h.div(class_="flex flex-wrap items-center gap-2")[
action_button(
label="Run now",
disabled=_flag(job, "run_disabled"),
post_path=_maybe_text(job, "run_post_path"),
),
action_button(
label=_text(job, "toggle_label"),
post_path=_maybe_text(job, "toggle_post_path"),
),
action_button(
label="Delete",
tone="danger",
post_path=_maybe_text(job, "delete_post_path"),
),
],
)
def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
return (
_completed_status_cell(execution),
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")
],
],
h.div(class_="max-w-[14rem] whitespace-normal")[
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")]
],
inline_link(
href=_text(execution, "log_href"),
label="View log",
tone="amber",
),
)
def _completed_page_action_path(page: int, *, path_prefix: str = "/admin") -> str:
return f"{path_prefix}/actions/runs/completed-page/{page}"
def _pagination_button(
*,
label: str,
page: int,
current: bool = False,
class_name: str,
path_prefix: str = "/admin",
) -> Renderable:
attributes = {
"data-on:pointerdown": (
f"@post('{_completed_page_action_path(page, path_prefix=path_prefix)}')"
),
}
if current:
attributes["aria-current"] = "page"
return h.button(
attributes,
type="button",
class_=class_name,
)[label]
def _completed_history_pagination(
*,
completed_page: int,
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
start_result = ((completed_page - 1) * completed_page_size) + 1
end_result = min(completed_total_count, completed_page * completed_page_size)
button_class = (
"relative inline-flex items-center px-4 py-2 text-sm font-semibold text-slate-700 "
"cursor-pointer "
"ring-1 ring-inset ring-slate-200 hover:bg-stone-50"
)
return h.div(
class_="flex items-center justify-between border-t border-slate-200 bg-white px-4 py-3 sm:px-6"
)[
h.div(class_="flex flex-1 justify-between sm:hidden")[
_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"
),
),
_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"
),
),
],
h.div(class_="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between")[
h.p(class_="text-sm text-slate-600")[
"Showing ",
h.span(class_="font-medium text-slate-950")[str(start_result)],
" to ",
h.span(class_="font-medium text-slate-950")[str(end_result)],
" of ",
h.span(class_="font-medium text-slate-950")[str(completed_total_count)],
" results",
],
h.nav(
aria_label="Completed execution pagination",
class_="isolate inline-flex -space-x-px rounded-xl shadow-xs",
)[
(
_pagination_button(
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
else button_class
),
)
for page_number in range(1, completed_total_pages + 1)
)
],
],
]
def _completed_history_section(
*,
completed_rows: tuple[tuple[Node, ...], ...],
completed_page: int,
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(
eyebrow="History",
title="Completed job executions",
empty_message="No job executions have completed yet.",
headers=(
"State",
"Source",
"Summary",
"Log",
),
rows=completed_rows,
actions=(
action_button(
label="Clear history",
tone="danger",
post_path=f"{path_prefix}/actions/completed-executions/clear",
)
if completed_total_count > 0
else None
),
),
pagination,
]
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,
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,
compact_mobile=compact_mobile,
)
for execution in running_items
)
queued_rows = tuple(
_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",
empty_message="No jobs are running or queued.",
headers=(
("State", "Source", "Details", "Actions")
if show_row_actions
else ("State", "Source", "Details")
),
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,
)
def relative_time_formatter_script() -> Renderable:
return h.script[
"""
window.repubFormatNextRuns = window.repubFormatNextRuns || (() => {
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const absoluteFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
timeZoneName: 'short',
});
const formatRelative = (targetDate) => {
const diffSeconds = Math.round((targetDate.getTime() - Date.now()) / 1000);
const units = [
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1],
];
for (const [unit, size] of units) {
if (Math.abs(diffSeconds) >= size || unit === 'second') {
return relativeFormatter.format(Math.round(diffSeconds / size), unit);
}
}
return relativeFormatter.format(0, 'second');
};
const format = () => {
document.querySelectorAll('time[data-next-run-at], time[data-ended-at], time[data-started-at]').forEach((element) => {
const relativeAt =
element.getAttribute('data-next-run-at') ??
element.getAttribute('data-ended-at') ??
element.getAttribute('data-started-at');
if (!relativeAt) return;
const targetDate = new Date(relativeAt);
if (Number.isNaN(targetDate.getTime())) return;
element.textContent = formatRelative(targetDate);
element.title = absoluteFormatter.format(targetDate);
});
};
format();
if (!window.repubNextRunTimer) {
window.repubNextRunTimer = window.setInterval(format, 30000);
}
});
window.repubFormatNextRuns();
"""
]
def runs_page(
*,
running_executions: tuple[Mapping[str, object], ...] | None = None,
queued_executions: tuple[Mapping[str, object], ...] | None = None,
upcoming_jobs: tuple[Mapping[str, object], ...] | None = None,
completed_executions: tuple[Mapping[str, object], ...] | None = None,
completed_page: int = 1,
completed_page_size: int = 20,
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 ()
upcoming_rows = tuple(_upcoming_row(job) for job in upcoming_items)
completed_rows = tuple(_completed_row(execution) for execution in completed_items)
resolved_completed_total_count = (
len(completed_items) if completed_total_count is None else completed_total_count
)
resolved_completed_total_pages = (
1 if completed_total_pages is None else completed_total_pages
)
return page_shell(
current_path=f"{path_prefix}/runs",
eyebrow="Execution control",
title="Runs",
actions=muted_action_link(
href=f"{path_prefix}/sources",
label="Back to sources",
),
source_count=source_count,
running_count=len(running_executions or ()),
content=(
live_work_section(
running_executions=running_executions,
queued_executions=queued_executions,
),
table_section(
eyebrow="Schedule",
title="Scheduled jobs",
empty_message="No jobs are scheduled.",
headers=(
"Source",
"State",
"Next run",
"Cron",
"Run now",
"Actions",
),
rows=upcoming_rows,
),
_completed_history_section(
completed_rows=completed_rows,
completed_page=completed_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(),
),
)
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 = {
"title": f"Job {job_id} / execution {execution_id}",
"description": "",
"status_label": "Unavailable",
"status_tone": "failed",
"log_text": "",
"error_message": "Execution log is only available from persisted job runs.",
}
error_message = _maybe_text(log_view, "error_message")
error_notice = (
h.div(
class_="mt-3 rounded-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-800"
)[
h.p["Execution log unavailable"],
h.p(class_="mt-1 font-normal")[error_message],
]
if error_message is not None
else None
)
return page_shell(
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=f"{path_prefix}/runs", label="Back to runs"),
content=(
section_card(
content=(
h.div(class_="flex items-end justify-between gap-4")[
h.div[
h.p(
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"{path_prefix}/job/{job_id}/execution/{execution_id}/logs"
],
],
status_badge(
label=_text(log_view, "status_label"),
tone=_text(log_view, "status_tone"),
),
],
error_notice,
h.pre(
class_="mt-3 overflow-x-auto rounded-[1.5rem] bg-slate-950 p-5 text-xs leading-6 text-emerald-200"
)[_text(log_view, "log_text")],
)
),
),
)