565 lines
19 KiB
Python
565 lines
19 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 _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, ...]:
|
|
return (
|
|
h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[
|
|
f"#{_text(execution, 'execution_id')}"
|
|
],
|
|
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[
|
|
h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")],
|
|
h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "runtime")],
|
|
],
|
|
status_badge(label=_text(execution, "status"), tone="running"),
|
|
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_="font-medium text-slate-900")[
|
|
_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_="font-medium text-slate-900",
|
|
)[_text(execution, "queued_at")]
|
|
|
|
return (
|
|
h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[
|
|
f"#{_text(execution, 'execution_id')}"
|
|
],
|
|
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")
|
|
],
|
|
],
|
|
queued_label,
|
|
status_badge(label="Queued", tone="idle"),
|
|
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")],
|
|
],
|
|
h.div[next_run_label,],
|
|
h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")],
|
|
status_badge(
|
|
label=_text(job, "enabled_label"),
|
|
tone=_text(job, "enabled_tone"),
|
|
),
|
|
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, ...]:
|
|
ended_at = _maybe_text(execution, "ended_at_iso")
|
|
ended_at_label: Node = h.p(class_="font-medium text-slate-900")[
|
|
_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_="font-medium text-slate-900",
|
|
)[_text(execution, "ended_at")]
|
|
|
|
return (
|
|
h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[
|
|
f"#{_text(execution, 'execution_id')}"
|
|
],
|
|
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[ended_at_label,],
|
|
status_badge(
|
|
label=_text(execution, "status"),
|
|
tone=_text(execution, "status_tone"),
|
|
),
|
|
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=(
|
|
"#",
|
|
"Source",
|
|
"Ended",
|
|
"State",
|
|
"Summary",
|
|
"Log",
|
|
),
|
|
rows=completed_rows,
|
|
first_header_class="w-px py-2.5 pr-2 pl-3 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 sm:pl-3",
|
|
first_cell_class="w-px py-3 pr-2 pl-3 text-sm font-medium text-slate-950 sm:pl-3",
|
|
actions=(
|
|
action_button(
|
|
label="Clear history",
|
|
tone="danger",
|
|
post_path="/actions/completed-executions/clear",
|
|
)
|
|
if completed_total_count > 0
|
|
else None
|
|
),
|
|
),
|
|
pagination,
|
|
]
|
|
|
|
|
|
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:
|
|
running_items = running_executions or ()
|
|
queued_items = queued_executions or ()
|
|
upcoming_items = upcoming_jobs or ()
|
|
completed_items = completed_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
|
|
)
|
|
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_items),
|
|
content=(
|
|
table_section(
|
|
eyebrow="Live work",
|
|
title="Running jobs",
|
|
empty_message="No jobs are running or queued.",
|
|
headers=(
|
|
"#",
|
|
"Source",
|
|
"Activity",
|
|
"State",
|
|
"Details",
|
|
"Actions",
|
|
),
|
|
rows=live_rows,
|
|
row_attrs=live_row_attrs,
|
|
first_header_class="w-px py-2.5 pr-2 pl-3 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 sm:pl-3",
|
|
first_cell_class="w-px py-3 pr-2 pl-3 text-sm font-medium text-slate-950 sm:pl-3",
|
|
),
|
|
table_section(
|
|
eyebrow="Schedule",
|
|
title="Scheduled jobs",
|
|
empty_message="No jobs are scheduled.",
|
|
headers=(
|
|
"Source",
|
|
"Next run",
|
|
"Cron",
|
|
"State",
|
|
"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,
|
|
),
|
|
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]').forEach((element) => {
|
|
const relativeAt =
|
|
element.getAttribute('data-next-run-at') ??
|
|
element.getAttribute('data-ended-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 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")],
|
|
)
|
|
),
|
|
),
|
|
)
|