diff --git a/AGENTS.md b/AGENTS.md index 9c288fa..7e9c932 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ The only way for actions to affect the view returned by the render-fn running in - Enter the dev environment with `nix develop` if you are not already inside it - Sync Python dependencies with `uv sync --all-groups`. - Run the app with `uv run repub`. -- Generate CSS with `tailwindcss -i ./repub/static/app.tailwind.css -o ./repub/static/app.css` and add `--watch` when you need live rebuilds. +- Generate CSS with `tailwindcss -i ./path/to/input.css -o ./path/to/output.css` and add `--watch` when you need live rebuilds. ```sh uv sync --all-groups diff --git a/repub/components.py b/repub/components.py index e93ee87..035b74c 100644 --- a/repub/components.py +++ b/repub/components.py @@ -1,45 +1,9 @@ from __future__ import annotations -from collections.abc import Mapping - import htpy as h from htpy import Node, Renderable -def _button_classes(*, tone: str, emphasis: str, disabled: bool = False) -> str: - base = "inline-flex shrink-0 items-center justify-center rounded-full font-semibold transition " - emphasis_classes = { - "compact": "px-3 py-1.5 text-sm", - "regular": "px-4 py-2.5 text-sm", - "soft": "px-3.5 py-2 text-sm", - "icon": "size-8 p-0", - } - tone_classes = { - "amber": "bg-amber-400 text-slate-950 hover:bg-amber-300", - "header-secondary": ( - "border border-white/15 bg-white/5 text-white hover:bg-white/10" - ), - "muted": "border border-slate-200 bg-white text-slate-700 shadow-sm hover:bg-slate-50", - "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", - "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", - "success": "bg-emerald-100 text-emerald-800 hover:bg-emerald-200", - "dark": "bg-slate-950 text-white hover:bg-slate-800", - } - disabled_classes = { - "default": "bg-slate-100 text-slate-400", - "danger": "bg-slate-100 text-slate-400", - "success": "bg-slate-100 text-slate-400", - "dark": "bg-slate-300 text-white/80", - } - interactive = "cursor-not-allowed" if disabled else "cursor-pointer" - colors = ( - disabled_classes.get(tone, "bg-slate-100 text-slate-400") - if disabled - else tone_classes[tone] - ) - return f"{base}{emphasis_classes[emphasis]} {interactive} {colors}" - - def base_layout(*, page_title: str, stylesheet_href: str, content: Node) -> Renderable: return h.html(lang="en", class_="h-full bg-slate-100")[ h.head[ @@ -79,15 +43,15 @@ def admin_sidebar( *, current_path: str, source_count: int = 0, running_count: int = 0 ) -> Renderable: return h.aside( - class_="relative overflow-hidden bg-slate-950 px-4 py-6 text-white lg:min-h-screen" + class_="relative overflow-hidden bg-slate-950 px-6 py-8 text-white lg:min-h-screen" )[ h.div( class_="absolute inset-x-0 top-0 h-40 bg-radial from-amber-400/25 via-amber-400/10 to-transparent" ), h.div(class_="relative flex h-full flex-col")[ - h.div(class_="flex items-center gap-2.5")[ + h.div(class_="flex items-center gap-3")[ h.div( - class_="flex size-10 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" + class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" )["AR"], h.div[ h.p( @@ -95,7 +59,7 @@ def admin_sidebar( )["Republisher"], ], ], - h.nav(class_="mt-8 space-y-2")[ + h.nav(class_="mt-10 space-y-2")[ nav_link( label="Dashboard", href="/", @@ -122,7 +86,7 @@ def admin_sidebar( badge="App", ), ], - h.div(class_="mt-auto rounded-3xl bg-white/5 p-4 ring-1 ring-white/10")[ + h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[ h.p(class_="text-sm font-semibold text-white")[ "AnyNews Republisher v2.0" ], @@ -137,21 +101,21 @@ def admin_sidebar( def header_action_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - class_=_button_classes(tone="amber", emphasis="regular"), + class_="inline-flex items-center rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm transition hover:bg-amber-300", )[label] def header_secondary_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - class_=_button_classes(tone="header-secondary", emphasis="regular"), + class_="inline-flex items-center rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/10", )[label] def muted_action_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - class_=_button_classes(tone="muted", emphasis="soft"), + class_="inline-flex items-center rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50", )[label] @@ -167,62 +131,22 @@ def inline_link(*, href: str, label: str, tone: str = "default") -> Renderable: )[label] -def action_button( - *, - label: Node, - tone: str = "default", - emphasis: str = "compact", - disabled: bool = False, - button_type: str = "button", - post_path: str | None = None, - title: str | None = None, -) -> Renderable: - attributes: dict[str, str] = {} - if post_path is not None and not disabled: - attributes["data-on:pointerdown"] = f"@post('{post_path}')" - if title is not None: - attributes["aria-label"] = title - return h.button( - attributes, - type=button_type, - disabled=disabled, - title=title, - class_=_button_classes(tone=tone, emphasis=emphasis, disabled=disabled), - )[label] - - def inline_button( *, label: str, tone: str = "default", disabled: bool = False ) -> Renderable: - return action_button( - label=label, - tone=tone, - emphasis="compact", - button_type="button", - disabled=disabled, + classes = { + "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", + "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", + "success": "bg-emerald-100 text-emerald-800 hover:bg-emerald-200", + } + class_name = ( + "cursor-not-allowed bg-slate-100 text-slate-400" if disabled else classes[tone] ) - - -def app_shell( - *, - current_path: str, - source_count: int = 0, - running_count: int = 0, - content: Node, -) -> Renderable: - return h.main( - id="morph", - class_="min-h-screen lg:grid lg:grid-cols-[14rem_minmax(0,1fr)]", - )[ - admin_sidebar( - current_path=current_path, - source_count=source_count, - running_count=running_count, - ), - h.div(class_="px-4 py-4 sm:px-4 lg:px-5 lg:py-4")[ - h.div(class_="mx-auto max-w-7xl space-y-4")[content] - ], - ] + return h.button( + type="button", + disabled=disabled, + class_=f"inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-semibold transition {class_name}", + )[label] def page_shell( @@ -236,30 +160,39 @@ def page_shell( running_count: int = 0, content: Node, ) -> Renderable: - return app_shell( - current_path=current_path, - source_count=source_count, - running_count=running_count, - content=( - h.section[ - h.div( - class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" - )[ - h.div(class_="max-w-3xl")[ - h.h1( - class_="text-3xl font-semibold tracking-tight text-slate-950" - )[title], - ( - description - and h.p(class_="mt-1 text-sm text-slate-600")[description] - ), - ], - actions and h.div(class_="flex flex-wrap gap-2")[actions], - ] - ], - content, + return h.main( + id="morph", + class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", + )[ + admin_sidebar( + current_path=current_path, + source_count=source_count, + running_count=running_count, ), - ) + h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ + h.div(class_="mx-auto max-w-7xl space-y-5")[ + h.section[ + h.div( + class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" + )[ + h.div(class_="max-w-3xl")[ + h.h1( + class_="text-3xl font-semibold tracking-tight text-slate-950" + )[title], + ( + description + and h.p(class_="mt-1 text-sm text-slate-600")[ + description + ] + ), + ], + actions and h.div(class_="flex flex-wrap gap-2")[actions], + ] + ], + content, + ] + ], + ] def section_card(*, content: Node) -> Renderable: @@ -274,27 +207,17 @@ def table_section( empty_message: str, headers: tuple[str, ...], rows: tuple[tuple[Node, ...], ...], - row_attrs: tuple[Mapping[str, str], ...] | None = None, - first_header_class: str | None = None, - first_cell_class: str | None = None, actions: Node | None = None, ) -> Renderable: - def render_row( - row: tuple[Node, ...], attrs: Mapping[str, str] | None = None - ) -> Renderable: + def render_row(row: tuple[Node, ...]) -> Renderable: first_cell, *other_cells = row - row_attributes = dict(attrs or {}) - row_attributes["class"] = f"align-top {row_attributes.get('class', '')}".strip() - return h.tr(row_attributes)[ - h.td( - class_=( - first_cell_class - or "py-3 pr-5 pl-3 text-sm font-medium text-slate-950 sm:pl-4" - ) - )[first_cell], + return h.tr(class_="align-top")[ + h.td(class_="py-4 pr-6 pl-4 text-sm font-medium text-slate-950 sm:pl-6")[ + first_cell + ], ( h.td( - class_="px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600" + class_="px-3 py-4 align-top text-sm whitespace-nowrap text-slate-600" )[cell] for cell in other_cells ), @@ -302,23 +225,17 @@ def table_section( body_rows: Node if rows: - row_attributes = row_attrs or tuple({} for _ in rows) - body_rows = ( - render_row(row, attrs) - for row, attrs in zip(rows, row_attributes, strict=False) - ) + body_rows = (render_row(row) for row in rows) else: body_rows = h.tr[ h.td( colspan=str(len(headers)), - class_="px-3 py-8 text-center text-sm text-slate-500 sm:px-4", + class_="px-4 py-8 text-center text-sm text-slate-500 sm:px-6", )[empty_message] ] return h.section[ - h.div( - class_="flex flex-col gap-2.5 sm:flex-row sm:items-end sm:justify-between" - )[ + h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[ h.div[ eyebrow and h.p( @@ -334,20 +251,16 @@ def table_section( )[ h.div(class_="overflow-x-auto")[ h.table( - class_="relative w-full min-w-[64rem] divide-y divide-slate-200 table-auto" + class_="relative w-full min-w-[72rem] divide-y divide-slate-200 table-auto" )[ h.thead(class_="bg-stone-50")[ h.tr[ ( h.th( scope="col", - class_=( - first_header_class - if index == 0 and first_header_class is not None - else "px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-3 sm:first:pl-4" - ), + class_="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-4 sm:first:pl-6", )[header] - for index, header in enumerate(headers) + for header in headers ) ] ], diff --git a/repub/datastar.py b/repub/datastar.py index 8c63b02..d11efe5 100644 --- a/repub/datastar.py +++ b/repub/datastar.py @@ -47,20 +47,13 @@ def _publish_event(queue: asyncio.Queue[object], event: object) -> None: async def render_sse_event( - render: RenderFunction, - *, - last_event_id: str | None = None, - use_view_transition: bool = False, + render: RenderFunction, *, last_event_id: str | None = None ) -> tuple[str | None, DatastarEvent | None]: html = _coerce_html(await render()) event_id = _render_hash(html) if event_id == last_event_id: return last_event_id, None - return event_id, SSE.patch_elements( - html, - event_id=event_id, - use_view_transition=use_view_transition, - ) + return event_id, SSE.patch_elements(html, event_id=event_id) async def render_stream( @@ -78,11 +71,9 @@ async def render_stream( yield event while True: - event_name = await queue.get() + await queue.get() last_event_id, event = await render_sse_event( - render, - last_event_id=last_event_id, - use_view_transition=event_name == "queue-reordered", + render, last_event_id=last_event_id ) if event is not None: yield event diff --git a/repub/jobs.py b/repub/jobs.py index f339195..b5441ac 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -14,7 +14,6 @@ from typing import Callable, TextIO, cast from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from peewee import IntegrityError from repub.config import feed_output_dir, feed_output_path from repub.model import ( @@ -107,7 +106,7 @@ class JobRuntime: self, *, log_dir: str | Path, - refresh_callback: Callable[[object], None] | None = None, + refresh_callback: Callable[[], None] | None = None, graceful_stop_seconds: float = 15.0, ) -> None: self.log_dir = Path(log_dir) @@ -117,7 +116,6 @@ class JobRuntime: self._workers: dict[int, RunningWorker] = {} self._run_lock = threading.Lock() self._started = False - self._last_runtime_refresh_at = 0.0 def start(self) -> None: if self._started: @@ -145,7 +143,6 @@ class JobRuntime: ) self.sync_jobs() self._started = True - self._start_queued_jobs() def shutdown(self) -> None: for execution_id in tuple(self._workers): @@ -186,84 +183,20 @@ class JobRuntime: self.scheduler.remove_job(scheduled_job.id) def run_scheduled_job(self, job_id: int) -> None: - self.enqueue_job_run(job_id, reason="scheduled") + self.run_job_now(job_id, reason="scheduled") def run_job_now(self, job_id: int, *, reason: str) -> int | None: - return self.enqueue_job_run(job_id, reason=reason) - - def enqueue_job_run(self, job_id: int, *, reason: str) -> int | None: del reason self.start() with self._run_lock: - execution_id = self._enqueue_job_run_locked(job_id) - self._start_queued_jobs_locked() - - if execution_id is not None: - self._trigger_refresh() - return execution_id - - def _enqueue_job_run_locked(self, job_id: int) -> int | None: - with database.connection_context(): - with database.atomic(): + with database.connection_context(): job = Job.get_or_none(id=job_id) if job is None: return None - pending_execution = JobExecution.get_or_none( - (JobExecution.job == job) - & (JobExecution.running_status == JobExecutionStatus.PENDING) - ) - if pending_execution is not None: - return _execution_id(pending_execution) + if self._max_concurrent_jobs_reached(): + return None - try: - execution = JobExecution.create( - job=job, - running_status=JobExecutionStatus.PENDING, - ) - except IntegrityError: - pending_execution = JobExecution.get_or_none( - (JobExecution.job == job) - & (JobExecution.running_status == JobExecutionStatus.PENDING) - ) - return ( - _execution_id(pending_execution) - if pending_execution is not None - else None - ) - return _execution_id(execution) - - def _start_queued_jobs(self) -> None: - with self._run_lock: - self._start_queued_jobs_locked() - - def _start_queued_jobs_locked(self) -> None: - while True: - if self._max_concurrent_jobs_reached(): - return - - claimed_execution = self._claim_next_pending_execution() - if claimed_execution is None: - return - - job = cast(Job, claimed_execution.job) - self._start_worker_for_execution( - job_id=_job_id(job), - execution_id=_execution_id(claimed_execution), - ) - - def _claim_next_pending_execution(self) -> JobExecution | None: - with database.connection_context(): - execution_primary_key = getattr(JobExecution, "_meta").primary_key - pending_executions = tuple( - JobExecution.select(JobExecution, Job) - .join(Job) - .where(JobExecution.running_status == JobExecutionStatus.PENDING) - .order_by(JobExecution.created_at.asc(), execution_primary_key.asc()) - ) - - for execution in pending_executions: - job = cast(Job, execution.job) already_running = ( JobExecution.select() .where( @@ -273,25 +206,15 @@ class JobRuntime: .exists() ) if already_running: - continue + return None - started_at = utc_now() - claimed = ( - JobExecution.update( - started_at=started_at, - running_status=JobExecutionStatus.RUNNING, - ) - .where( - (execution_primary_key == _execution_id(execution)) - & (JobExecution.running_status == JobExecutionStatus.PENDING) - ) - .execute() + execution = JobExecution.create( + job=job, + started_at=utc_now(), + running_status=JobExecutionStatus.RUNNING, ) - if claimed: - return JobExecution.get_by_id(_execution_id(execution)) - return None + execution_id = _execution_id(execution) - def _start_worker_for_execution(self, *, job_id: int, execution_id: int) -> None: artifacts = JobArtifacts.for_execution( log_dir=self.log_dir, job_id=job_id, execution_id=execution_id ) @@ -327,6 +250,8 @@ class JobRuntime: log_handle=log_handle, artifacts=artifacts, ) + self._trigger_refresh() + return execution_id def _max_concurrent_jobs_reached(self) -> bool: return ( @@ -354,123 +279,21 @@ class JobRuntime: ) worker.process.terminate() - self._trigger_refresh("queue-reordered") - return True - - def cancel_queued_execution(self, execution_id: int) -> bool: - with self._run_lock: - with database.connection_context(): - execution_primary_key = getattr(JobExecution, "_meta").primary_key - deleted = ( - JobExecution.delete() - .where( - (execution_primary_key == execution_id) - & (JobExecution.running_status == JobExecutionStatus.PENDING) - ) - .execute() - ) - - if not deleted: - return False - - self._trigger_refresh() - return True - - def move_queued_execution(self, execution_id: int, *, direction: str) -> bool: - offset = -1 if direction == "up" else 1 - with self._run_lock: - with database.connection_context(): - execution_primary_key = getattr(JobExecution, "_meta").primary_key - queued_executions = tuple( - JobExecution.select() - .where(JobExecution.running_status == JobExecutionStatus.PENDING) - .order_by( - JobExecution.created_at.asc(), execution_primary_key.asc() - ) - ) - current_index = next( - ( - index - for index, execution in enumerate(queued_executions) - if _execution_id(execution) == execution_id - ), - None, - ) - if current_index is None: - return False - - target_index = current_index + offset - if target_index < 0 or target_index >= len(queued_executions): - return False - - current_execution = queued_executions[current_index] - target_execution = queued_executions[target_index] - current_created_at = _coerce_datetime( - cast(datetime | str, current_execution.created_at) - ) - target_created_at = _coerce_datetime( - cast(datetime | str, target_execution.created_at) - ) - - with database.atomic(): - if current_created_at == target_created_at: - adjusted_created_at = target_created_at + timedelta( - microseconds=-1 if offset < 0 else 1 - ) - ( - JobExecution.update(created_at=adjusted_created_at) - .where( - execution_primary_key - == _execution_id(current_execution) - ) - .execute() - ) - else: - ( - JobExecution.update(created_at=target_created_at) - .where( - execution_primary_key - == _execution_id(current_execution) - ) - .execute() - ) - ( - JobExecution.update(created_at=current_created_at) - .where( - execution_primary_key == _execution_id(target_execution) - ) - .execute() - ) - self._trigger_refresh() return True def set_job_enabled(self, job_id: int, *, enabled: bool) -> bool: with database.connection_context(): - with database.atomic(): - job = Job.get_or_none(id=job_id) - if job is None: - return False - job.enabled = enabled - job.save() - if not enabled: - ( - JobExecution.delete() - .where( - (JobExecution.job == job) - & ( - JobExecution.running_status - == JobExecutionStatus.PENDING - ) - ) - .execute() - ) + job = Job.get_or_none(id=job_id) + if job is None: + return False + job.enabled = enabled + job.save() self.sync_jobs() self._trigger_refresh() return True def poll_workers(self) -> None: - any_finished = False for execution_id in tuple(self._workers): worker = self._workers[execution_id] self._apply_stats(worker) @@ -492,14 +315,8 @@ class JobRuntime: worker.log_handle.close() del self._workers[execution_id] - any_finished = True self._trigger_refresh() - if any_finished: - self._start_queued_jobs() - - self._refresh_running_runtime() - def _apply_stats(self, worker: RunningWorker) -> None: if not worker.artifacts.stats_path.exists(): return @@ -543,27 +360,9 @@ class JobRuntime: ): worker.process.kill() - def _trigger_refresh(self, event: object = "refresh-event") -> None: + def _trigger_refresh(self) -> None: if self.refresh_callback is not None: - self.refresh_callback(event) - - def _refresh_running_runtime(self) -> None: - if not self._has_running_executions(): - return - - current_time = time.monotonic() - if current_time - self._last_runtime_refresh_at < 1.0: - return - - self._last_runtime_refresh_at = current_time - self._trigger_refresh() - - def _has_running_executions(self) -> bool: - return ( - JobExecution.select() - .where(JobExecution.running_status == JobExecutionStatus.RUNNING) - .exists() - ) + self.refresh_callback() def _reconcile_stale_executions(self) -> None: live_workers = _find_live_workers() @@ -652,15 +451,7 @@ def load_runs_view( reference_time = now or datetime.now(UTC) resolved_log_dir = Path(log_dir) with database.connection_context(): - execution_primary_key = getattr(JobExecution, "_meta").primary_key jobs = tuple(Job.select(Job, Source).join(Source).order_by(Source.name.asc())) - queued_executions = tuple( - JobExecution.select(JobExecution, Job, Source) - .join(Job) - .join(Source) - .where(JobExecution.running_status == JobExecutionStatus.PENDING) - .order_by(JobExecution.created_at.asc(), execution_primary_key.asc()) - ) running_executions = tuple( JobExecution.select(JobExecution, Job, Source) .join(Job) @@ -686,39 +477,15 @@ def load_runs_view( ) running_by_job = { - _job_id(cast(Job, execution.job)): execution - for execution in running_executions - } - queued_by_job = { - _job_id(cast(Job, execution.job)): execution - for execution in queued_executions + _job_id(execution.job): execution for execution in running_executions } return { "running": tuple( - _project_running_execution( - execution, - resolved_log_dir, - reference_time, - queued_follow_up=queued_by_job.get(_job_id(cast(Job, execution.job))), - ) + _project_running_execution(execution, resolved_log_dir, reference_time) for execution in running_executions ), - "queued": tuple( - _project_queued_execution( - execution, - reference_time, - position=position, - total_count=len(queued_executions), - ) - for position, execution in enumerate(queued_executions, start=1) - ), "upcoming": tuple( - _project_upcoming_job( - job, - running_by_job.get(job.id), - queued_by_job.get(job.id), - reference_time, - ) + _project_upcoming_job(job, running_by_job.get(job.id), reference_time) for job in jobs ), "completed": tuple( @@ -829,11 +596,7 @@ def _scheduler_job_id(job_id: int) -> str: def _project_running_execution( - execution: JobExecution, - log_dir: Path, - reference_time: datetime, - *, - queued_follow_up: JobExecution | None = None, + execution: JobExecution, log_dir: Path, reference_time: datetime ) -> dict[str, object]: job = cast(Job, execution.job) job_id = _job_id(job) @@ -861,59 +624,12 @@ def _project_running_execution( ), "log_href": f"/job/{job_id}/execution/{execution_id}/logs", "log_exists": artifacts.log_path.exists(), - "cancel_label": "Cancel" if queued_follow_up is not None else "Stop", - "cancel_post_path": ( - f"/actions/queued-executions/{_execution_id(queued_follow_up)}/cancel" - if queued_follow_up is not None - else f"/actions/executions/{execution_id}/cancel" - ), - } - - -def _project_queued_execution( - execution: JobExecution, - reference_time: datetime, - *, - position: int, - total_count: int, -) -> dict[str, object]: - job = cast(Job, execution.job) - queued_at = _coerce_datetime(cast(datetime | str, execution.created_at)) - execution_id = _execution_id(execution) - return { - "source": job.source.name, - "slug": job.source.slug, - "job_id": _job_id(job), - "execution_id": execution_id, - "queued_at": _humanize_relative_time(reference_time, queued_at), - "queued_at_iso": queued_at.isoformat(), - "queue_position": position, - "status": "Queued", - "status_tone": "idle", - "run_label": "Queued", - "run_disabled": True, - "run_post_path": f"/actions/jobs/{_job_id(job)}/run-now", - "cancel_post_path": (f"/actions/queued-executions/{execution_id}/cancel"), - "move_up_disabled": position == 1, - "move_up_post_path": ( - None - if position == 1 - else f"/actions/queued-executions/{execution_id}/move-up" - ), - "move_down_disabled": position == total_count, - "move_down_post_path": ( - None - if position == total_count - else f"/actions/queued-executions/{execution_id}/move-down" - ), + "cancel_post_path": f"/actions/executions/{execution_id}/cancel", } def _project_upcoming_job( - job: Job, - running_execution: JobExecution | None, - queued_execution: JobExecution | None, - reference_time: datetime, + job: Job, running_execution: JobExecution | None, reference_time: datetime ) -> dict[str, object]: job_id = _job_id(job) trigger = _job_trigger(job) @@ -922,12 +638,6 @@ def _project_upcoming_job( if job.enabled and running_execution is None else None ) - run_disabled = running_execution is not None or queued_execution is not None - run_reason = ( - "Already running" - if running_execution is not None - else ("Queued" if queued_execution is not None else "Ready") - ) return { "source": job.source.name, "slug": job.source.slug, @@ -949,8 +659,8 @@ def _project_upcoming_job( ), "enabled_label": "Enabled" if job.enabled else "Disabled", "enabled_tone": "scheduled" if job.enabled else "idle", - "run_disabled": run_disabled, - "run_reason": run_reason, + "run_disabled": running_execution is not None, + "run_reason": "Already running" if running_execution is not None else "Ready", "toggle_label": "Disable" if job.enabled else "Enable", "toggle_enabled": not job.enabled, "run_post_path": f"/actions/jobs/{job_id}/run-now", diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index ad68076..ef75847 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -6,7 +6,7 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( - app_shell, + admin_sidebar, header_action_link, inline_button, inline_link, @@ -253,14 +253,21 @@ def dashboard_page_with_data( ) -> Renderable: running_items = running_executions or () source_items = source_feeds or () - return app_shell( - current_path="/", - source_count=len(source_items), - running_count=len(running_items), - content=( - dashboard_header(), - operational_snapshot(snapshot=snapshot), - running_executions_table(running_executions=running_items), - published_feeds_table(source_feeds=source_items), + return h.main( + id="morph", + class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", + )[ + admin_sidebar( + current_path="/", + source_count=len(source_items), + running_count=len(running_items), ), - ) + h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ + h.div(class_="mx-auto max-w-7xl space-y-5")[ + dashboard_header(), + operational_snapshot(snapshot=snapshot), + running_executions_table(running_executions=running_items), + published_feeds_table(source_feeds=source_items), + ] + ], + ] diff --git a/repub/pages/runs.py b/repub/pages/runs.py index 8ed592a..058f1bf 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -6,7 +6,6 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( - action_button, inline_link, muted_action_link, page_shell, @@ -16,6 +15,34 @@ from repub.components import ( ) +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]) @@ -31,121 +58,36 @@ 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")[ + 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-0.5 text-xs text-slate-500")[_text(execution, "runtime")], + h.p(class_="mt-1 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.div(class_="min-w-56 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.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "worker")], ], - h.div(class_="flex flex-wrap items-center gap-2")[ + 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=_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", + _action_button( + label="Stop", tone="danger", post_path=_maybe_text(execution, "cancel_post_path"), ), @@ -171,7 +113,7 @@ 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-0.5 font-mono text-xs text-slate-500")[_text(job, "slug")], + 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")], @@ -179,20 +121,20 @@ def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: label=_text(job, "enabled_label"), tone=_text(job, "enabled_tone"), ), - h.p(class_="max-w-32 whitespace-normal text-sm text-slate-500")[ + h.p(class_="max-w-40 whitespace-normal text-sm text-slate-500")[ _text(job, "run_reason") ], - h.div(class_="flex flex-wrap items-center gap-2")[ - action_button( + 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( + _action_button( label=_text(job, "toggle_label"), post_path=_maybe_text(job, "toggle_post_path"), ), - action_button( + _action_button( label="Delete", tone="danger", post_path=_maybe_text(job, "delete_post_path"), @@ -217,21 +159,26 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: )[_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")[ + h.p(class_="mt-1 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], - h.div[ended_at_label,], + 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_="max-w-[14rem] whitespace-normal")[ + h.div(class_="min-w-48 whitespace-normal")[ h.p(class_="font-medium text-slate-900")[_text(execution, "stats")] ], inline_link( @@ -245,21 +192,14 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: 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) @@ -273,24 +213,21 @@ def runs_page( content=( table_section( eyebrow="Live work", - title="Running jobs", - empty_message="No jobs are running or queued.", + title="Running job executions", + empty_message="No job executions are running.", headers=( - "#", "Source", - "Activity", - "State", - "Details", + "Execution", + "Started", + "Status", + "Stats", "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", + rows=running_rows, ), table_section( - eyebrow="Schedule", - title="Scheduled jobs", + eyebrow="Queue", + title="Upcoming jobs", empty_message="No jobs are scheduled.", headers=( "Source", @@ -307,16 +244,14 @@ def runs_page( title="Completed job executions", empty_message="No job executions have completed yet.", headers=( - "#", "Source", + "Execution", "Ended", - "State", + "Status", "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[ """ diff --git a/repub/pages/settings.py b/repub/pages/settings.py index efe513d..d2730c0 100644 --- a/repub/pages/settings.py +++ b/repub/pages/settings.py @@ -5,13 +5,7 @@ from collections.abc import Mapping import htpy as h from htpy import Renderable -from repub.components import ( - action_button, - input_field, - muted_action_link, - page_shell, - section_card, -) +from repub.components import input_field, muted_action_link, page_shell, section_card def _value(settings: Mapping[str, object] | None, key: str, default: str = "") -> str: @@ -77,12 +71,10 @@ def settings_page( ], h.div(class_="flex flex-wrap justify-end gap-3 pt-2")[ muted_action_link(href="/", label="Back to dashboard"), - action_button( - label="Save settings", - tone="dark", - emphasis="regular", - button_type="submit", - ), + h.button( + type="submit", + class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800", + )["Save settings"], ], ], ) diff --git a/repub/pages/shim.py b/repub/pages/shim.py index e23ceac..1b8723f 100644 --- a/repub/pages/shim.py +++ b/repub/pages/shim.py @@ -3,7 +3,7 @@ from __future__ import annotations import htpy as h from htpy import Node, Renderable -from repub.components import app_shell +from repub.components import admin_sidebar ON_LOAD_JS = ( "@post(window.location.pathname + " @@ -33,34 +33,43 @@ def shim_page( } ), h.noscript["Your browser does not support JavaScript!"], - app_shell( - current_path=current_path, - content=( - h.section[ - h.div( - class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" - )[ - h.div(class_="max-w-3xl")[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" - )["Connecting"], - h.h1( - class_="mt-1 text-3xl font-semibold tracking-tight text-slate-950" - )["Loading page"], - ], - ] - ], - h.section( - class_="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200" - )[ - h.div(class_="animate-pulse space-y-4 p-6")[ - h.div(class_="h-5 w-40 rounded-full bg-stone-100"), - h.div(class_="h-12 rounded-2xl bg-stone-100"), - h.div(class_="h-12 rounded-2xl bg-stone-100"), - h.div(class_="h-12 rounded-2xl bg-stone-100"), - ] - ], + h.main( + id="morph", + class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", + )[ + admin_sidebar( + current_path=current_path, + source_count=0, + running_count=0, ), - ), + h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ + h.div(class_="mx-auto max-w-7xl space-y-5")[ + h.section[ + h.div( + class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" + )[ + h.div(class_="max-w-3xl")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Connecting"], + h.h1( + class_="mt-1 text-3xl font-semibold tracking-tight text-slate-950" + )["Loading page"], + ], + ] + ], + h.section( + class_="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200" + )[ + h.div(class_="animate-pulse space-y-4 p-6")[ + h.div(class_="h-5 w-40 rounded-full bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + ] + ], + ] + ], + ], ], ] diff --git a/repub/pages/sources.py b/repub/pages/sources.py index 93c625f..ad0c93a 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -6,7 +6,6 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( - action_button, header_action_link, inline_link, input_field, @@ -55,6 +54,29 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo return bool(value) +def _action_button( + *, + label: str, + tone: str = "default", + 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", + } + attributes: dict[str, str] = {} + if post_path is not None: + attributes["data-on:pointerdown"] = f"@post('{post_path}')" + return h.button( + attributes, + type="button", + class_=( + "inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 " + f"text-sm font-semibold transition {classes[tone]}" + ), + )[label] + + def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ @@ -77,12 +99,12 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: ), h.p(class_="mt-2 text-xs text-slate-500")[str(source["last_run"])], ], - h.div(class_="flex flex-wrap items-center gap-2")[ + h.div(class_="flex flex-nowrap items-center gap-3")[ inline_link( href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber" ), inline_link(href="/runs", label="View runs"), - action_button( + _action_button( label="Delete", tone="danger", post_path=f"/actions/sources/{source['slug']}/delete", @@ -400,12 +422,10 @@ def source_form( class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-6" )[ muted_action_link(href="/sources", label="Cancel"), - action_button( - label=submit_label, - tone="dark", - emphasis="regular", - button_type="submit", - ), + h.button( + type="submit", + class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800", + )[submit_label], ], ], ) diff --git a/repub/sql/003_job_execution_queue.sql b/repub/sql/003_job_execution_queue.sql deleted file mode 100644 index b8dbb51..0000000 --- a/repub/sql/003_job_execution_queue.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE INDEX IF NOT EXISTS job_execution_pending_created_at_idx -ON job_execution (running_status, created_at ASC); - -CREATE UNIQUE INDEX IF NOT EXISTS job_execution_pending_unique_job_idx -ON job_execution (job_id) -WHERE running_status = 0; diff --git a/repub/static/app.css b/repub/static/app.css index 3d3aa98..9bb5f7e 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -42,7 +42,6 @@ --color-stone-200: oklch(92.3% 0.003 48.717); --color-white: #fff; --spacing: 0.25rem; - --container-xs: 20rem; --container-sm: 24rem; --container-3xl: 48rem; --container-7xl: 80rem; @@ -290,8 +289,8 @@ .mt-5 { margin-top: calc(var(--spacing) * 5); } - .mt-8 { - margin-top: calc(var(--spacing) * 8); + .mt-10 { + margin-top: calc(var(--spacing) * 10); } .mt-auto { margin-top: auto; @@ -317,21 +316,13 @@ .table { display: table; } - .size-4 { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); - } .size-5 { width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); } - .size-8 { - width: calc(var(--spacing) * 8); - height: calc(var(--spacing) * 8); - } - .size-10 { - width: calc(var(--spacing) * 10); - height: calc(var(--spacing) * 10); + .size-11 { + width: calc(var(--spacing) * 11); + height: calc(var(--spacing) * 11); } .h-5 { height: calc(var(--spacing) * 5); @@ -357,42 +348,36 @@ .w-full { width: 100%; } - .w-px { - width: 1px; - } .max-w-3xl { max-width: var(--container-3xl); } .max-w-7xl { max-width: var(--container-7xl); } - .max-w-32 { - max-width: calc(var(--spacing) * 32); - } - .max-w-\[14rem\] { - max-width: 14rem; + .max-w-40 { + max-width: calc(var(--spacing) * 40); } .max-w-sm { max-width: var(--container-sm); } - .max-w-xs { - max-width: var(--container-xs); - } .min-w-32 { min-width: calc(var(--spacing) * 32); } + .min-w-48 { + min-width: calc(var(--spacing) * 48); + } .min-w-56 { min-width: calc(var(--spacing) * 56); } .min-w-64 { min-width: calc(var(--spacing) * 64); } - .min-w-\[64rem\] { - min-width: 64rem; - } .min-w-\[70rem\] { min-width: 70rem; } + .min-w-\[72rem\] { + min-width: 72rem; + } .shrink-0 { flex-shrink: 0; } @@ -442,9 +427,6 @@ .gap-2 { gap: calc(var(--spacing) * 2); } - .gap-2\.5 { - gap: calc(var(--spacing) * 2.5); - } .gap-3 { gap: calc(var(--spacing) * 3); } @@ -468,6 +450,13 @@ margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-5 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-6 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -563,9 +552,6 @@ .bg-slate-200 { background-color: var(--color-slate-200); } - .bg-slate-300 { - background-color: var(--color-slate-300); - } .bg-slate-800 { background-color: var(--color-slate-800); } @@ -636,9 +622,6 @@ --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); } - .p-0 { - padding: calc(var(--spacing) * 0); - } .p-0\.5 { padding: calc(var(--spacing) * 0.5); } @@ -666,6 +649,9 @@ .px-4 { padding-inline: calc(var(--spacing) * 4); } + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -687,9 +673,6 @@ .py-4 { padding-block: calc(var(--spacing) * 4); } - .py-6 { - padding-block: calc(var(--spacing) * 6); - } .py-8 { padding-block: calc(var(--spacing) * 8); } @@ -699,24 +682,9 @@ .pt-6 { padding-top: calc(var(--spacing) * 6); } - .pr-1 { - padding-right: calc(var(--spacing) * 1); - } - .pr-2 { - padding-right: calc(var(--spacing) * 2); - } - .pr-5 { - padding-right: calc(var(--spacing) * 5); - } .pr-6 { padding-right: calc(var(--spacing) * 6); } - .pl-2 { - padding-left: calc(var(--spacing) * 2); - } - .pl-3 { - padding-left: calc(var(--spacing) * 3); - } .pl-4 { padding-left: calc(var(--spacing) * 4); } @@ -852,12 +820,6 @@ .text-white { color: var(--color-white); } - .text-white\/80 { - color: color-mix(in srgb, #fff 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 80%, transparent); - } - } .uppercase { text-transform: uppercase; } @@ -917,11 +879,6 @@ color: var(--color-slate-400); } } - .first\:pl-3 { - &:first-child { - padding-left: calc(var(--spacing) * 3); - } - } .first\:pl-4 { &:first-child { padding-left: calc(var(--spacing) * 4); @@ -1079,19 +1036,14 @@ justify-content: space-between; } } - .sm\:px-4 { + .sm\:px-5 { @media (width >= 40rem) { - padding-inline: calc(var(--spacing) * 4); + padding-inline: calc(var(--spacing) * 5); } } - .sm\:pl-2\.5 { + .sm\:px-6 { @media (width >= 40rem) { - padding-left: calc(var(--spacing) * 2.5); - } - } - .sm\:pl-3 { - @media (width >= 40rem) { - padding-left: calc(var(--spacing) * 3); + padding-inline: calc(var(--spacing) * 6); } } .sm\:pl-4 { @@ -1099,10 +1051,15 @@ padding-left: calc(var(--spacing) * 4); } } - .sm\:first\:pl-4 { + .sm\:pl-6 { + @media (width >= 40rem) { + padding-left: calc(var(--spacing) * 6); + } + } + .sm\:first\:pl-6 { @media (width >= 40rem) { &:first-child { - padding-left: calc(var(--spacing) * 4); + padding-left: calc(var(--spacing) * 6); } } } @@ -1131,19 +1088,19 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } - .lg\:grid-cols-\[14rem_minmax\(0\,1fr\)\] { + .lg\:grid-cols-\[18rem_minmax\(0\,1fr\)\] { @media (width >= 64rem) { - grid-template-columns: 14rem minmax(0,1fr); + grid-template-columns: 18rem minmax(0,1fr); } } - .lg\:px-5 { + .lg\:px-6 { @media (width >= 64rem) { - padding-inline: calc(var(--spacing) * 5); + padding-inline: calc(var(--spacing) * 6); } } - .lg\:py-4 { + .lg\:py-5 { @media (width >= 64rem) { - padding-block: calc(var(--spacing) * 4); + padding-block: calc(var(--spacing) * 5); } } .xl\:grid-cols-4 { @@ -1162,12 +1119,6 @@ } } } -@layer base { - ::view-transition-group(*) { - animation-duration: 180ms; - animation-timing-function: ease; - } -} @property --tw-translate-x { syntax: "*"; inherits: false; diff --git a/repub/static/app.tailwind.css b/repub/static/app.tailwind.css index 089b841..742b073 100644 --- a/repub/static/app.tailwind.css +++ b/repub/static/app.tailwind.css @@ -1,9 +1,2 @@ @import "tailwindcss" source("../"); @source inline("bg-amber-500 translate-x-5"); - -@layer base { - ::view-transition-group(*) { - animation-duration: 180ms; - animation-timing-function: ease; - } -} diff --git a/repub/web.py b/repub/web.py index 8b0187c..ae8b832 100644 --- a/repub/web.py +++ b/repub/web.py @@ -276,22 +276,6 @@ def create_app(*, dev_mode: bool = False) -> Quart: trigger_refresh(app) return Response(status=204) - @app.post("/actions/queued-executions//cancel") - async def cancel_queued_execution_action(execution_id: int) -> Response: - get_job_runtime(app).cancel_queued_execution(execution_id) - trigger_refresh(app) - return Response(status=204) - - @app.post("/actions/queued-executions//move-up") - async def move_queued_execution_up_action(execution_id: int) -> Response: - get_job_runtime(app).move_queued_execution(execution_id, direction="up") - return Response(status=204) - - @app.post("/actions/queued-executions//move-down") - async def move_queued_execution_down_action(execution_id: int) -> Response: - get_job_runtime(app).move_queued_execution(execution_id, direction="down") - return Response(status=204) - @app.post("/job//execution//logs") async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse: async def render() -> Renderable: @@ -321,7 +305,7 @@ def get_job_runtime(app: Quart) -> JobRuntime: if runtime is None: runtime = JobRuntime( log_dir=app.config["REPUB_LOG_DIR"], - refresh_callback=lambda event="refresh-event": trigger_refresh(app, event), + refresh_callback=lambda: trigger_refresh(app), ) app.extensions[JOB_RUNTIME_KEY] = runtime return runtime @@ -386,7 +370,6 @@ async def render_runs(app: Quart | None = None) -> Renderable: view = load_runs_view(log_dir=app.config["REPUB_LOG_DIR"]) return runs_page( running_executions=cast(tuple[dict[str, object], ...], view["running"]), - queued_executions=cast(tuple[dict[str, object], ...], view["queued"]), upcoming_jobs=cast(tuple[dict[str, object], ...], view["upcoming"]), completed_executions=cast(tuple[dict[str, object], ...], view["completed"]), source_count=len(load_sources()), diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 6159c74..fa3a70d 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from pathlib import Path from repub.jobs import load_runs_view @@ -83,174 +83,3 @@ def test_load_runs_view_humanizes_running_execution_summary_bytes( ) assert view["running"][0]["stats"] == "14 requests • 11 items • 1.5 KiB" - - -def test_load_runs_view_projects_queued_executions_in_fifo_order( - tmp_path: Path, -) -> None: - initialize_database(tmp_path / "jobs-queued.db") - first_source = create_source( - name="First queued source", - slug="first-queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/first.xml", - ) - second_source = create_source( - name="Second queued source", - slug="second-queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/second.xml", - ) - first_job = Job.get(Job.source == first_source) - second_job = Job.get(Job.source == second_source) - reference_time = datetime(2026, 3, 30, 12, 30, tzinfo=UTC) - first_created_at = reference_time - timedelta(minutes=7) - second_created_at = reference_time - timedelta(minutes=3) - first_execution = JobExecution.create( - job=first_job, - created_at=first_created_at, - running_status=JobExecutionStatus.PENDING, - ) - second_execution = JobExecution.create( - job=second_job, - created_at=second_created_at, - running_status=JobExecutionStatus.PENDING, - ) - - view = load_runs_view( - log_dir=tmp_path / "out" / "logs", - now=reference_time, - ) - - assert tuple(row["execution_id"] for row in view["queued"]) == ( - int(first_execution.get_id()), - int(second_execution.get_id()), - ) - assert tuple(row["queue_position"] for row in view["queued"]) == (1, 2) - assert tuple(row["queued_at"] for row in view["queued"]) == ( - "7 minutes ago", - "3 minutes ago", - ) - assert view["queued"][0]["move_up_disabled"] is True - assert ( - view["queued"][0]["move_down_post_path"] - == f"/actions/queued-executions/{int(first_execution.get_id())}/move-down" - ) - assert ( - view["queued"][1]["move_up_post_path"] - == f"/actions/queued-executions/{int(second_execution.get_id())}/move-up" - ) - assert view["queued"][1]["move_down_disabled"] is True - - -def test_load_runs_view_keeps_queued_jobs_in_scheduled_jobs( - tmp_path: Path, -) -> None: - initialize_database(tmp_path / "jobs-queue-separation.db") - queued_source = create_source( - name="Queued source", - slug="queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/queued.xml", - ) - scheduled_source = create_source( - name="Scheduled source", - slug="scheduled-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/scheduled.xml", - ) - queued_job = Job.get(Job.source == queued_source) - Job.get(Job.source == scheduled_source) - JobExecution.create( - job=queued_job, - running_status=JobExecutionStatus.PENDING, - ) - - view = load_runs_view( - log_dir=tmp_path / "out" / "logs", - now=datetime(2026, 3, 30, 12, 30, tzinfo=UTC), - ) - - assert tuple(row["slug"] for row in view["queued"]) == ("queued-source",) - assert tuple(row["slug"] for row in view["upcoming"]) == ( - "queued-source", - "scheduled-source", - ) - assert view["upcoming"][0]["run_reason"] == "Queued" - assert view["upcoming"][0]["run_disabled"] is True - assert view["upcoming"][1]["run_reason"] == "Ready" - assert view["upcoming"][1]["run_disabled"] is False - - -def test_load_runs_view_running_row_targets_queued_follow_up_cancel( - tmp_path: Path, -) -> None: - initialize_database(tmp_path / "jobs-running-cancel.db") - source = create_source( - name="Running source", - slug="running-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/running.xml", - ) - job = Job.get(Job.source == source) - JobExecution.create( - job=job, - started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), - running_status=JobExecutionStatus.RUNNING, - ) - pending_execution = JobExecution.create( - job=job, - created_at=datetime(2026, 3, 30, 12, 5, tzinfo=UTC), - running_status=JobExecutionStatus.PENDING, - ) - - view = load_runs_view( - log_dir=tmp_path / "out" / "logs", - now=datetime(2026, 3, 30, 12, 30, tzinfo=UTC), - ) - - running_row = view["running"][0] - assert running_row["cancel_label"] == "Cancel" - assert running_row["cancel_post_path"] == ( - f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel" - ) diff --git a/tests/test_model.py b/tests/test_model.py index 4ff67f6..3d0729d 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -169,40 +169,6 @@ def test_initialize_database_creates_scheduler_and_execution_indexes( connection.close() -def test_initialize_database_creates_run_queue_indexes(tmp_path: Path) -> None: - db_path = tmp_path / "queue-indexes.db" - - initialize_database(db_path) - - connection = sqlite3.connect(db_path) - try: - indexes = { - row[0]: row[1] - for row in connection.execute( - """ - SELECT name, sql - FROM sqlite_master - WHERE type = 'index' - AND name IN ( - 'job_execution_pending_created_at_idx', - 'job_execution_pending_unique_job_idx' - ) - """ - ) - } - assert set(indexes) == { - "job_execution_pending_created_at_idx", - "job_execution_pending_unique_job_idx", - } - assert indexes["job_execution_pending_unique_job_idx"] is not None - assert ( - "WHERE running_status = 0" - in indexes["job_execution_pending_unique_job_idx"] - ) - finally: - connection.close() - - def test_job_table_allows_exactly_one_job_per_source(tmp_path: Path) -> None: initialize_database(tmp_path / "jobs.db") diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index d87b1aa..2af4326 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -186,24 +186,13 @@ def test_job_runtime_respects_max_concurrent_jobs_setting(tmp_path: Path) -> Non second_execution_id = runtime.run_job_now(second_job.id, reason="manual") - assert second_execution_id is not None - second_execution = _wait_for_execution_status( - second_execution_id, - JobExecutionStatus.PENDING, - ) + assert second_execution_id is None assert ( JobExecution.select() .where(JobExecution.running_status == JobExecutionStatus.RUNNING) .count() == 1 ) - assert second_execution.started_at is None - assert ( - JobExecution.select() - .where(JobExecution.running_status == JobExecutionStatus.PENDING) - .count() - == 1 - ) runtime.request_execution_cancel(first_execution_id) finished_execution = _wait_for_terminal_execution(first_execution_id) assert finished_execution.running_status == JobExecutionStatus.CANCELED @@ -211,332 +200,6 @@ def test_job_runtime_respects_max_concurrent_jobs_setting(tmp_path: Path) -> Non runtime.shutdown() -def test_job_runtime_starts_queued_execution_after_capacity_opens( - tmp_path: Path, -) -> None: - db_path = tmp_path / "drain-queue.db" - log_dir = tmp_path / "out" / "logs" - initialize_database(db_path) - save_setting("max_concurrent_jobs", 1) - - with _slow_feed_server() as feed_url: - first_source = create_source( - name="First source", - slug="first-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url=feed_url, - ) - second_source = create_source( - name="Second source", - slug="second-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url=FIXTURE_FEED_PATH.as_uri(), - ) - first_job = Job.get(Job.source == first_source) - second_job = Job.get(Job.source == second_source) - - runtime = JobRuntime(log_dir=log_dir) - try: - runtime.start() - first_execution_id = runtime.run_job_now(first_job.id, reason="manual") - assert first_execution_id is not None - _wait_for_running_execution(first_execution_id) - - second_execution_id = runtime.run_job_now(second_job.id, reason="manual") - assert second_execution_id is not None - _wait_for_execution_status(second_execution_id, JobExecutionStatus.PENDING) - - runtime.request_execution_cancel(first_execution_id) - finished_execution = _wait_for_terminal_execution(first_execution_id) - assert finished_execution.running_status == JobExecutionStatus.CANCELED - - _wait_for_running_execution(second_execution_id) - drained_execution = _wait_for_terminal_execution(second_execution_id) - assert drained_execution.running_status == JobExecutionStatus.SUCCEEDED - assert drained_execution.started_at is not None - finally: - runtime.shutdown() - - -def test_job_runtime_deduplicates_manual_queue_requests(tmp_path: Path) -> None: - db_path = tmp_path / "queue-dedup.db" - log_dir = tmp_path / "out" / "logs" - initialize_database(db_path) - save_setting("max_concurrent_jobs", 1) - - with _slow_feed_server() as feed_url: - blocking_source = create_source( - name="Blocking source", - slug="blocking-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url=feed_url, - ) - queued_source = create_source( - name="Queued source", - slug="queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/queued.xml", - ) - blocking_job = Job.get(Job.source == blocking_source) - queued_job = Job.get(Job.source == queued_source) - - runtime = JobRuntime(log_dir=log_dir) - try: - runtime.start() - blocking_execution_id = runtime.run_job_now( - blocking_job.id, reason="manual" - ) - assert blocking_execution_id is not None - _wait_for_running_execution(blocking_execution_id) - - first_pending_id = runtime.run_job_now(queued_job.id, reason="manual") - second_pending_id = runtime.run_job_now(queued_job.id, reason="manual") - - assert first_pending_id is not None - assert second_pending_id == first_pending_id - assert ( - JobExecution.select() - .where( - (JobExecution.job == queued_job) - & (JobExecution.running_status == JobExecutionStatus.PENDING) - ) - .count() - == 1 - ) - finally: - runtime.shutdown() - - -def test_job_runtime_allows_one_running_and_one_pending_per_job( - tmp_path: Path, -) -> None: - db_path = tmp_path / "running-plus-pending.db" - log_dir = tmp_path / "out" / "logs" - initialize_database(db_path) - save_setting("max_concurrent_jobs", 1) - - with _slow_feed_server() as feed_url: - source = create_source( - name="Busy source", - slug="busy-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url=feed_url, - ) - job = Job.get(Job.source == source) - - runtime = JobRuntime(log_dir=log_dir) - try: - runtime.start() - running_execution_id = runtime.run_job_now(job.id, reason="manual") - assert running_execution_id is not None - _wait_for_running_execution(running_execution_id) - - pending_execution_id = runtime.run_job_now(job.id, reason="manual") - duplicate_pending_id = runtime.run_job_now(job.id, reason="manual") - runtime.run_scheduled_job(job.id) - - assert pending_execution_id is not None - assert duplicate_pending_id == pending_execution_id - assert ( - JobExecution.select() - .where(JobExecution.job == job) - .where(JobExecution.running_status == JobExecutionStatus.RUNNING) - .count() - == 1 - ) - assert ( - JobExecution.select() - .where(JobExecution.job == job) - .where(JobExecution.running_status == JobExecutionStatus.PENDING) - .count() - == 1 - ) - finally: - runtime.shutdown() - - -def test_job_runtime_start_drains_pending_rows_created_before_start( - tmp_path: Path, -) -> None: - db_path = tmp_path / "startup-drain.db" - log_dir = tmp_path / "out" / "logs" - initialize_database(db_path) - source = create_source( - name="Queued source", - slug="queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url=FIXTURE_FEED_PATH.as_uri(), - ) - job = Job.get(Job.source == source) - pending_execution = JobExecution.create( - job=job, - running_status=JobExecutionStatus.PENDING, - ) - - runtime = JobRuntime(log_dir=log_dir) - try: - runtime.start() - _wait_for_running_execution(int(pending_execution.get_id())) - drained_execution = _wait_for_terminal_execution( - int(pending_execution.get_id()) - ) - - assert drained_execution.running_status == JobExecutionStatus.SUCCEEDED - assert drained_execution.started_at is not None - finally: - runtime.shutdown() - - -def test_job_runtime_scheduled_runs_use_the_persistent_queue( - tmp_path: Path, -) -> None: - db_path = tmp_path / "scheduled-queue.db" - log_dir = tmp_path / "out" / "logs" - initialize_database(db_path) - save_setting("max_concurrent_jobs", 1) - - with _slow_feed_server() as feed_url: - first_source = create_source( - name="First scheduled source", - slug="first-scheduled-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url=feed_url, - ) - second_source = create_source( - name="Second scheduled source", - slug="second-scheduled-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/second-scheduled.xml", - ) - first_job = Job.get(Job.source == first_source) - second_job = Job.get(Job.source == second_source) - - runtime = JobRuntime(log_dir=log_dir) - try: - runtime.start() - runtime.run_scheduled_job(first_job.id) - first_execution = JobExecution.get(JobExecution.job == first_job) - _wait_for_running_execution(int(first_execution.get_id())) - - runtime.run_scheduled_job(second_job.id) - second_execution = JobExecution.get(JobExecution.job == second_job) - - assert second_execution.running_status == JobExecutionStatus.PENDING - assert second_execution.started_at is None - finally: - runtime.shutdown() - - -def test_job_runtime_cancel_pending_follow_up_keeps_running_worker_alive( - tmp_path: Path, -) -> None: - db_path = tmp_path / "cancel-pending.db" - log_dir = tmp_path / "out" / "logs" - initialize_database(db_path) - save_setting("max_concurrent_jobs", 1) - - with _slow_feed_server() as feed_url: - source = create_source( - name="Cancelable queued source", - slug="cancelable-queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url=feed_url, - ) - job = Job.get(Job.source == source) - - runtime = JobRuntime(log_dir=log_dir) - try: - runtime.start() - running_execution_id = runtime.run_job_now(job.id, reason="manual") - assert running_execution_id is not None - _wait_for_running_execution(running_execution_id) - - pending_execution_id = runtime.run_job_now(job.id, reason="manual") - assert pending_execution_id is not None - _wait_for_execution_status(pending_execution_id, JobExecutionStatus.PENDING) - - assert runtime.cancel_queued_execution(pending_execution_id) is True - assert JobExecution.get_or_none(id=pending_execution_id) is None - assert ( - JobExecution.get_by_id(running_execution_id).running_status - == JobExecutionStatus.RUNNING - ) - finally: - runtime.shutdown() - - def test_job_runtime_cancel_marks_execution_canceled(tmp_path: Path) -> None: initialize_database(tmp_path / "cancel.db") with _slow_feed_server() as feed_url: @@ -628,40 +291,6 @@ def test_job_runtime_start_reconciles_stale_running_execution(tmp_path: Path) -> runtime.shutdown() -def test_job_runtime_publishes_refresh_while_jobs_are_running(tmp_path: Path) -> None: - initialize_database(tmp_path / "runtime-refresh.db") - source = create_source( - name="Running source", - slug="running-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=False, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/running.xml", - ) - job = Job.get(Job.source == source) - JobExecution.create( - job=job, - started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), - running_status=JobExecutionStatus.RUNNING, - ) - events: list[object] = [] - - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - refresh_callback=events.append, - ) - runtime._last_runtime_refresh_at = time.monotonic() - 2.0 - runtime.poll_workers() - - assert "refresh-event" in events - - def test_job_runtime_start_reattaches_live_worker_after_app_restart( tmp_path: Path, ) -> None: @@ -941,8 +570,8 @@ def test_render_runs_uses_database_backed_jobs_and_executions( body = str(await render_runs(app)) assert "runs-page-source" in body - assert "Running jobs" in body - assert "Scheduled jobs" in body + assert "Running job executions" in body + assert "Upcoming jobs" in body assert "Completed job executions" in body assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body assert "Succeeded" in body @@ -1090,21 +719,6 @@ def _wait_for_running_execution( raise AssertionError(f"execution {execution_id} never entered RUNNING state") -def _wait_for_execution_status( - execution_id: int, - status: JobExecutionStatus, - *, - timeout_seconds: float = 2.0, -) -> JobExecution: - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - execution = JobExecution.get_by_id(execution_id) - if execution.running_status == status: - return execution - time.sleep(0.02) - raise AssertionError(f"execution {execution_id} never entered {status.name}") - - def _wait_for_terminal_execution( execution_id: int, *, timeout_seconds: float = 4.0 ) -> JobExecution: diff --git a/tests/test_web.py b/tests/test_web.py index 82324e1..70a5bb5 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast -from repub.components import action_button, status_badge, toggle_field +from repub.components import status_badge, toggle_field from repub.datastar import RefreshBroker, render_sse_event, render_stream from repub.jobs import load_dashboard_view from repub.model import ( @@ -61,52 +61,6 @@ def test_toggle_field_active_state_utilities_exist_in_built_css() -> None: assert ".translate-x-5" in css -def test_action_button_adds_cursor_pointer_for_active_buttons() -> None: - markup = str(action_button(label="Run now")) - - assert "cursor-pointer" in markup - assert 'type="button"' in markup - - -def test_action_button_omits_post_handler_when_disabled() -> None: - markup = str( - action_button( - label="Queued", - disabled=True, - post_path="/actions/jobs/7/run-now", - ) - ) - - assert "cursor-not-allowed" in markup - assert "@post(" not in markup - - -def test_action_button_supports_submit_variant() -> None: - markup = str( - action_button( - label="Save settings", - tone="dark", - button_type="submit", - ) - ) - - assert 'type="submit"' in markup - assert "bg-slate-950" in markup - assert "cursor-pointer" in markup - - -def test_action_button_supports_datastar_pointerdown_post() -> None: - markup = str( - action_button( - label="Delete", - tone="danger", - post_path="/actions/jobs/7/delete", - ) - ) - - assert 'data-on:pointerdown="@post('/actions/jobs/7/delete')"' in markup - - def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> ( None ): @@ -138,39 +92,6 @@ def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_ti assert ">2 hours ago<" in body -def test_runs_page_renders_combined_running_jobs_table() -> None: - body = str( - runs_page( - queued_executions=( - { - "source": "Queued source", - "slug": "queued-source", - "job_id": 7, - "execution_id": 42, - "queued_at": "2 minutes ago", - "queued_at_iso": "2026-03-30T12:28:00+00:00", - "queue_position": 1, - "status": "Queued", - "status_tone": "idle", - "run_label": "Queued", - "run_disabled": True, - "run_post_path": "/actions/jobs/7/run-now", - "cancel_post_path": "/actions/queued-executions/42/cancel", - "move_up_disabled": True, - "move_up_post_path": None, - "move_down_disabled": True, - "move_down_post_path": None, - }, - ) - ) - ) - - assert "Running jobs" in body - assert "queued-source" in body - assert ">Queued<" in body - assert "/actions/queued-executions/42/cancel" in body - - def test_root_get_serves_datastar_shim() -> None: async def run() -> None: client = create_app().test_client() @@ -190,8 +111,6 @@ def test_root_get_serves_datastar_shim() -> None: assert "retryMaxCount: Infinity" in body assert "data-on:online__window=" in body assert '
None: asyncio.run(run()) -def test_render_stream_uses_view_transition_for_queue_reorders() -> None: - async def run() -> None: - queue = RefreshBroker().subscribe() - - async def render() -> str: - return '
queue
' - - stream = render_stream(queue, render, render_on_connect=False) - await queue.put("queue-reordered") - event = await anext(stream) - await stream.aclose() - - assert "useViewTransition true" in str(event) - - asyncio.run(run()) - - def test_render_dashboard_shows_dashboard_information_architecture( monkeypatch, tmp_path: Path ) -> None: @@ -333,8 +235,6 @@ def test_render_dashboard_shows_dashboard_information_architecture( assert 'href="/sources"' in body assert 'href="/runs"' in body assert "Create source" in body - assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body - assert "lg:px-5 lg:py-4" in body asyncio.run(run()) @@ -765,8 +665,6 @@ def test_render_settings_shows_current_max_concurrent_jobs( assert "/actions/settings" in body assert 'value="3"' in body assert "Max concurrent jobs" in body - assert 'type="submit"' in body - assert "cursor-pointer" in body asyncio.run(run()) @@ -1139,7 +1037,7 @@ def test_settings_action_rejects_non_positive_max_concurrent_jobs( asyncio.run(run()) -def test_render_runs_shows_running_scheduled_and_completed_tables( +def test_render_runs_shows_running_upcoming_and_completed_tables( monkeypatch, tmp_path: Path ) -> None: db_path = tmp_path / "runs-render.db" @@ -1170,30 +1068,14 @@ def test_render_runs_shows_running_scheduled_and_completed_tables( body = str(await render_runs(app)) - assert "Running jobs" in body - assert "Scheduled jobs" in body + assert "Running job executions" in body + assert "Upcoming jobs" in body assert "Completed job executions" in body assert "runs-render-source" in body assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body assert "data-next-run-at" in body assert "in " in body - - asyncio.run(run()) - - -def test_render_runs_uses_compact_shell_and_table_classes( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "runs-compact.db" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - - async def run() -> None: - app = create_app() - body = str(await render_runs(app)) - - assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body - assert "lg:px-5 lg:py-4" in body - assert "min-w-[64rem]" in body + assert "Already running" not in body asyncio.run(run()) @@ -1206,393 +1088,13 @@ def test_render_runs_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None app = create_app() body = str(await render_runs(app)) - assert body.count("No jobs are running or queued.") == 1 + assert body.count("No job executions are running.") == 1 assert "No jobs are scheduled." in body assert "No job executions have completed yet." in body asyncio.run(run()) -def test_render_runs_keeps_queued_execution_in_scheduled_jobs_table( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "runs-queued-render.db" - log_dir = tmp_path / "out" / "logs" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - app = create_app() - app.config["REPUB_LOG_DIR"] = log_dir - - queued_source = create_source( - name="Queued source", - slug="queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/queued.xml", - ) - create_source( - name="Scheduled source", - slug="scheduled-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/scheduled.xml", - ) - queued_job = Job.get(Job.source == queued_source) - queued_execution = JobExecution.create( - job=queued_job, - running_status=JobExecutionStatus.PENDING, - ) - - async def run() -> None: - body = str(await render_runs(app)) - - assert "Running jobs" in body - assert "Scheduled jobs" in body - assert "queued-source" in body - assert "scheduled-source" in body - assert ">Queued<" in body - assert ( - f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel" - in body - ) - assert "Ready" in body - - asyncio.run(run()) - - -def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "runs-cancel-follow-up.db" - log_dir = tmp_path / "out" / "logs" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - app = create_app() - app.config["REPUB_LOG_DIR"] = log_dir - - source = create_source( - name="Busy source", - slug="busy-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/busy.xml", - ) - job = Job.get(Job.source == source) - running_execution = JobExecution.create( - job=job, - started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), - running_status=JobExecutionStatus.RUNNING, - ) - pending_execution = JobExecution.create( - job=job, - running_status=JobExecutionStatus.PENDING, - ) - - async def run() -> None: - body = str(await render_runs(app)) - - assert f"/job/{job.id}/execution/{int(running_execution.get_id())}/logs" in body - assert ( - f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel" - in body - ) - assert ">Cancel<" in body - assert "Running jobs" in body - - asyncio.run(run()) - - -def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction() -> ( - None -): - body = str( - runs_page( - running_executions=( - { - "source": "Running source", - "slug": "running-source", - "job_id": 1, - "execution_id": 11, - "started_at": "2026-03-30 12:00 UTC", - "runtime": "running for 10s", - "status": "Running", - "stats": "1 requests • 1 items • 1 byte", - "worker": "streaming stats from worker", - "log_href": "/job/1/execution/11/logs", - "cancel_label": "Stop", - "cancel_post_path": "/actions/executions/11/cancel", - }, - ), - queued_executions=( - { - "source": "Queued source", - "slug": "queued-source", - "job_id": 2, - "execution_id": 22, - "queued_at": "2 minutes ago", - "queued_at_iso": "2026-03-30T12:28:00+00:00", - "queue_position": 1, - "status": "Queued", - "status_tone": "idle", - "run_label": "Queued", - "run_disabled": True, - "run_post_path": "/actions/jobs/2/run-now", - "cancel_post_path": "/actions/queued-executions/22/cancel", - "move_up_disabled": True, - "move_up_post_path": None, - "move_down_disabled": True, - "move_down_post_path": None, - }, - ), - upcoming_jobs=( - { - "source": "Scheduled source", - "slug": "scheduled-source", - "job_id": 3, - "next_run": "in 5 minutes", - "next_run_at": "2026-03-30T12:35:00+00:00", - "schedule": "*/5 * * * *", - "enabled_label": "Enabled", - "enabled_tone": "scheduled", - "run_disabled": False, - "run_reason": "Ready", - "toggle_label": "Disable", - "toggle_post_path": "/actions/jobs/3/toggle-enabled", - "run_post_path": "/actions/jobs/3/run-now", - "delete_post_path": "/actions/jobs/3/delete", - }, - ), - completed_executions=( - { - "source": "Completed source", - "slug": "completed-source", - "job_id": 4, - "execution_id": 44, - "ended_at": "2 minutes ago", - "ended_at_iso": "2026-03-30T12:28:00+00:00", - "status": "Succeeded", - "status_tone": "done", - "stats": "1 requests • 1 items • 1 byte", - "summary": "Worker exited successfully", - "log_href": "/job/4/execution/44/logs", - }, - ), - ) - ) - - assert "Running jobs" in body - assert ">Stop<" in body - assert ">Cancel<" in body - assert ">Run now<" in body - assert ">Disable<" in body - assert "/job/4/execution/44/logs" in body - - -def test_cancel_queued_execution_action_deletes_pending_row_without_touching_running_execution( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "cancel-queued-action.db" - log_dir = tmp_path / "out" / "logs" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - - async def run() -> None: - app = create_app() - app.config["REPUB_LOG_DIR"] = log_dir - client = app.test_client() - - source = create_source( - name="Busy source", - slug="busy-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/busy.xml", - ) - job = Job.get(Job.source == source) - running_execution = JobExecution.create( - job=job, - started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), - running_status=JobExecutionStatus.RUNNING, - ) - pending_execution = JobExecution.create( - job=job, - running_status=JobExecutionStatus.PENDING, - ) - - response = await client.post( - f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel" - ) - - assert response.status_code == 204 - assert JobExecution.get_or_none(id=int(pending_execution.get_id())) is None - assert ( - JobExecution.get_by_id(int(running_execution.get_id())).running_status - == JobExecutionStatus.RUNNING - ) - - asyncio.run(run()) - - -def test_move_queued_execution_action_reorders_queue( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "move-queued-action.db" - log_dir = tmp_path / "out" / "logs" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - - async def run() -> None: - app = create_app() - app.config["REPUB_LOG_DIR"] = log_dir - client = app.test_client() - - first_source = create_source( - name="First queued source", - slug="first-queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/first.xml", - ) - second_source = create_source( - name="Second queued source", - slug="second-queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/second.xml", - ) - first_job = Job.get(Job.source == first_source) - second_job = Job.get(Job.source == second_source) - first_execution = JobExecution.create( - job=first_job, - created_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), - running_status=JobExecutionStatus.PENDING, - ) - second_execution = JobExecution.create( - job=second_job, - created_at=datetime(2026, 3, 30, 12, 5, tzinfo=UTC), - running_status=JobExecutionStatus.PENDING, - ) - - response = await client.post( - f"/actions/queued-executions/{int(second_execution.get_id())}/move-up" - ) - - assert response.status_code == 204 - body = str(await render_runs(app)) - assert body.index("second-queued-source") < body.index("first-queued-source") - assert ( - f"/actions/queued-executions/{int(second_execution.get_id())}/move-down" - in body - ) - assert ( - f"/actions/queued-executions/{int(first_execution.get_id())}/move-up" - in body - ) - - asyncio.run(run()) - - -def test_toggle_job_enabled_action_removes_queued_execution( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "toggle-removes-queue.db" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - - async def run() -> None: - app = create_app() - client = app.test_client() - - source = create_source( - name="Queued source", - slug="queued-source", - source_type="feed", - notes="", - spider_arguments="", - enabled=True, - cron_minute="*/5", - cron_hour="*", - cron_day_of_month="*", - cron_day_of_week="*", - cron_month="*", - feed_url="https://example.com/queued.xml", - ) - job = Job.get(Job.source == source) - queued_execution = JobExecution.create( - job=job, - running_status=JobExecutionStatus.PENDING, - ) - - response = await client.post(f"/actions/jobs/{job.id}/toggle-enabled") - - assert response.status_code == 204 - assert Job.get_by_id(job.id).enabled is False - assert JobExecution.get_or_none(id=int(queued_execution.get_id())) is None - body = str(await render_runs(app)) - assert ( - f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel" - not in body - ) - assert "Disabled" in body - - asyncio.run(run()) - - -def test_render_create_source_uses_shared_submit_button( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "create-source-shared-submit.db" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - - async def run() -> None: - app = create_app() - body = str(await render_create_source(app)) - - assert 'type="submit"' in body - assert "Create source" in body - assert "cursor-pointer" in body - assert "bg-slate-950" in body - - asyncio.run(run()) - - def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> None: db_path = tmp_path / "logs-render.db" monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))