republisher/repub/pages/runs.py

695 lines
23 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,
) -> Node:
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"#{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]) -> 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")]
return (
_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,
),
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-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")],
],
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]) -> 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")]
return (
_live_status_cell(
execution_id=_text(execution, "execution_id"),
status="Queued",
status_tone="queued",
clock_label="Waiting",
calendar_label=queued_label,
),
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-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"],
],
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) -> str:
return f"/actions/runs/completed-page/{page}"
def _pagination_button(
*,
label: str,
page: int,
current: bool = False,
class_name: str,
) -> Renderable:
attributes = {
"data-on:pointerdown": f"@post('{_completed_page_action_path(page)}')",
}
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,
) -> 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),
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),
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,
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,
) -> 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,
)
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="/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,
) -> 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)
live_rows = running_rows + queued_rows
live_row_attrs = tuple(
_queue_row_attrs(execution) for execution in running_items + queued_items
)
return table_section(
eyebrow="Live work",
title="Running jobs",
empty_message="No jobs are running or queued.",
headers=(
"State",
"Source",
"Details",
"Actions",
),
rows=live_rows,
row_attrs=live_row_attrs,
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,
) -> 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="/runs",
eyebrow="Execution control",
title="Runs",
actions=muted_action_link(href="/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,
),
relative_time_formatter_script(),
),
)
def execution_logs_page(
*,
job_id: int,
execution_id: int,
log_view: Mapping[str, object] | None = None,
) -> 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"/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"),
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"/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")],
)
),
),
)