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 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, 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) 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, ), 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", ), 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")], ) ), ), )