363 lines
12 KiB
Python
363 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
|
|
import htpy as h
|
|
from htpy import Node, Renderable
|
|
|
|
from repub.components import (
|
|
inline_link,
|
|
muted_action_link,
|
|
page_shell,
|
|
section_card,
|
|
status_badge,
|
|
table_section,
|
|
)
|
|
|
|
|
|
def _action_button(
|
|
*,
|
|
label: str,
|
|
tone: str = "default",
|
|
disabled: bool = False,
|
|
post_path: str | None = None,
|
|
) -> Renderable:
|
|
classes = {
|
|
"default": "bg-stone-100 text-slate-700 hover:bg-stone-200",
|
|
"danger": "bg-rose-50 text-rose-700 hover:bg-rose-100",
|
|
}
|
|
class_name = (
|
|
"cursor-not-allowed bg-slate-100 text-slate-400" if disabled else classes[tone]
|
|
)
|
|
attributes: dict[str, str] = {}
|
|
if post_path is not None and not disabled:
|
|
attributes["data-on:pointerdown"] = f"@post('{post_path}')"
|
|
return h.button(
|
|
attributes,
|
|
type="button",
|
|
disabled=disabled,
|
|
class_=(
|
|
"inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 "
|
|
f"text-sm font-semibold transition {class_name}"
|
|
),
|
|
)[label]
|
|
|
|
|
|
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 _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
|
return (
|
|
h.div[
|
|
h.div(class_="font-semibold text-slate-950")[_text(execution, "source")],
|
|
h.p(class_="mt-1 font-mono text-xs text-slate-500")[
|
|
_text(execution, "slug")
|
|
],
|
|
],
|
|
h.div[
|
|
h.p(class_="font-medium text-slate-900")[
|
|
f"#{_text(execution, 'execution_id')}"
|
|
],
|
|
],
|
|
h.div[
|
|
h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")],
|
|
h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "runtime")],
|
|
],
|
|
status_badge(label=_text(execution, "status"), tone="running"),
|
|
h.div(class_="min-w-56 whitespace-normal")[
|
|
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")],
|
|
h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "worker")],
|
|
],
|
|
h.div(class_="flex flex-nowrap items-center gap-3")[
|
|
inline_link(
|
|
href=_text(execution, "log_href"),
|
|
label="View log",
|
|
tone="amber",
|
|
),
|
|
_action_button(
|
|
label="Stop",
|
|
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-1 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-40 whitespace-normal text-sm text-slate-500")[
|
|
_text(job, "run_reason")
|
|
],
|
|
h.div(class_="flex flex-nowrap 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.div[
|
|
h.div(class_="font-semibold text-slate-950")[_text(execution, "source")],
|
|
h.p(class_="mt-1 font-mono text-xs text-slate-500")[
|
|
_text(execution, "slug")
|
|
],
|
|
],
|
|
h.div[
|
|
h.p(class_="font-medium text-slate-900")[
|
|
f"#{_text(execution, 'execution_id')}"
|
|
],
|
|
],
|
|
h.div[
|
|
ended_at_label,
|
|
h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "summary")],
|
|
],
|
|
status_badge(
|
|
label=_text(execution, "status"),
|
|
tone=_text(execution, "status_tone"),
|
|
),
|
|
h.div(class_="min-w-48 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 runs_page(
|
|
*,
|
|
running_executions: tuple[Mapping[str, object], ...] | None = None,
|
|
upcoming_jobs: tuple[Mapping[str, object], ...] | None = None,
|
|
completed_executions: tuple[Mapping[str, object], ...] | None = None,
|
|
) -> Renderable:
|
|
running_items = running_executions or ()
|
|
upcoming_items = upcoming_jobs or ()
|
|
completed_items = completed_executions or ()
|
|
running_rows = tuple(_running_row(execution) for execution in running_items)
|
|
upcoming_rows = tuple(_upcoming_row(job) for job in upcoming_items)
|
|
completed_rows = tuple(_completed_row(execution) for execution in completed_items)
|
|
|
|
return page_shell(
|
|
current_path="/runs",
|
|
eyebrow="Execution control",
|
|
title="Runs",
|
|
description="Running executions first, then the schedule queue, then completed history. Logs are routed through app URLs instead of direct file serving.",
|
|
actions=muted_action_link(href="/sources", label="Back to sources"),
|
|
content=(
|
|
table_section(
|
|
eyebrow="Live work",
|
|
title="Running job executions",
|
|
subtitle="Operators can inspect the live log stream, request a graceful stop, and escalate to a hard kill after the 15 second deadline if needed.",
|
|
headers=(
|
|
"Source",
|
|
"Execution",
|
|
"Started",
|
|
"Status",
|
|
"Stats",
|
|
"Actions",
|
|
),
|
|
rows=running_rows,
|
|
),
|
|
table_section(
|
|
eyebrow="Queue",
|
|
title="Upcoming jobs",
|
|
subtitle="Scheduled work shows enable or disable state, run-now affordances, and destructive delete controls. Deleting removes the source-linked job and its execution history.",
|
|
headers=(
|
|
"Source",
|
|
"Next run",
|
|
"Cron",
|
|
"State",
|
|
"Run now",
|
|
"Actions",
|
|
),
|
|
rows=upcoming_rows,
|
|
),
|
|
table_section(
|
|
eyebrow="History",
|
|
title="Completed job executions",
|
|
subtitle="Recent execution history keeps the summary counters visible and links back to the plain text log view.",
|
|
headers=(
|
|
"Source",
|
|
"Execution",
|
|
"Ended",
|
|
"Status",
|
|
"Summary",
|
|
"Log",
|
|
),
|
|
rows=completed_rows,
|
|
),
|
|
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": "Plain text log view routed through the app.",
|
|
"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"),
|
|
description=_text(log_view, "description"),
|
|
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"
|
|
],
|
|
h.p(class_="mt-2 text-sm text-slate-600")[
|
|
_text(log_view, "description")
|
|
],
|
|
],
|
|
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")],
|
|
)
|
|
),
|
|
),
|
|
)
|