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.p(class_="mt-1 text-xs text-slate-500")[ f"job {_text(execution, 'job_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, ...]: 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[ h.p(class_="font-medium text-slate-900")[_text(job, "next_run")], h.p(class_="mt-1 text-xs text-slate-500")[f"job {_text(job, 'job_id')}"], ], 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, ...]: 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.p(class_="mt-1 text-xs text-slate-500")[ f"job {_text(execution, 'job_id')}" ], ], h.div[ h.p(class_="font-medium text-slate-900")[_text(execution, "ended_at")], 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, ), ), ) 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")], ) ), ), )