From 0b3b1b2731bd17980f1516ed1c3141807c190fab Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 09:24:46 +0200 Subject: [PATCH 1/3] Add persistent job run queue --- repub/jobs.py | 209 +++++++++++++-- repub/pages/runs.py | 69 ++++- repub/sql/003_job_execution_queue.sql | 6 + repub/web.py | 7 + tests/test_jobs.py | 159 +++++++++++- tests/test_model.py | 34 +++ tests/test_scheduler_runtime.py | 356 +++++++++++++++++++++++++- tests/test_web.py | 234 ++++++++++++++++- 8 files changed, 1047 insertions(+), 27 deletions(-) create mode 100644 repub/sql/003_job_execution_queue.sql diff --git a/repub/jobs.py b/repub/jobs.py index b5441ac..3e7ef3a 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -14,6 +14,7 @@ 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 ( @@ -143,6 +144,7 @@ class JobRuntime: ) self.sync_jobs() self._started = True + self._start_queued_jobs() def shutdown(self) -> None: for execution_id in tuple(self._workers): @@ -183,20 +185,84 @@ class JobRuntime: self.scheduler.remove_job(scheduled_job.id) def run_scheduled_job(self, job_id: int) -> None: - self.run_job_now(job_id, reason="scheduled") + self.enqueue_job_run(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: - with database.connection_context(): + 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(): job = Job.get_or_none(id=job_id) if job is None: return None - if self._max_concurrent_jobs_reached(): - 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) + 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( @@ -206,15 +272,25 @@ class JobRuntime: .exists() ) if already_running: - return None + continue - execution = JobExecution.create( - job=job, - started_at=utc_now(), - running_status=JobExecutionStatus.RUNNING, + 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_id = _execution_id(execution) + if claimed: + return JobExecution.get_by_id(_execution_id(execution)) + return None + 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 ) @@ -250,8 +326,6 @@ class JobRuntime: log_handle=log_handle, artifacts=artifacts, ) - self._trigger_refresh() - return execution_id def _max_concurrent_jobs_reached(self) -> bool: return ( @@ -282,18 +356,51 @@ class JobRuntime: self._trigger_refresh() 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 set_job_enabled(self, job_id: int, *, enabled: bool) -> bool: with database.connection_context(): - job = Job.get_or_none(id=job_id) - if job is None: - return False - job.enabled = enabled - job.save() + 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() + ) 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) @@ -315,8 +422,12 @@ class JobRuntime: worker.log_handle.close() del self._workers[execution_id] + any_finished = True self._trigger_refresh() + if any_finished: + self._start_queued_jobs() + def _apply_stats(self, worker: RunningWorker) -> None: if not worker.artifacts.stats_path.exists(): return @@ -451,7 +562,15 @@ 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) @@ -477,16 +596,31 @@ def load_runs_view( ) running_by_job = { - _job_id(execution.job): execution for execution in running_executions + _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 } return { "running": tuple( - _project_running_execution(execution, resolved_log_dir, reference_time) + _project_running_execution( + execution, + resolved_log_dir, + reference_time, + queued_follow_up=queued_by_job.get(_job_id(cast(Job, execution.job))), + ) for execution in running_executions ), + "queued": tuple( + _project_queued_execution(execution, reference_time, position=position) + for position, execution in enumerate(queued_executions, start=1) + ), "upcoming": tuple( _project_upcoming_job(job, running_by_job.get(job.id), reference_time) for job in jobs + if job.id not in queued_by_job ), "completed": tuple( _project_completed_execution(execution, resolved_log_dir, reference_time) @@ -596,7 +730,11 @@ def _scheduler_job_id(job_id: int) -> str: def _project_running_execution( - execution: JobExecution, log_dir: Path, reference_time: datetime + execution: JobExecution, + log_dir: Path, + reference_time: datetime, + *, + queued_follow_up: JobExecution | None = None, ) -> dict[str, object]: job = cast(Job, execution.job) job_id = _job_id(job) @@ -624,7 +762,36 @@ def _project_running_execution( ), "log_href": f"/job/{job_id}/execution/{execution_id}/logs", "log_exists": artifacts.log_path.exists(), - "cancel_post_path": f"/actions/executions/{execution_id}/cancel", + "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 +) -> dict[str, object]: + job = cast(Job, execution.job) + queued_at = _coerce_datetime(cast(datetime | str, execution.created_at)) + return { + "source": job.source.name, + "slug": job.source.slug, + "job_id": _job_id(job), + "execution_id": _execution_id(execution), + "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(execution)}/cancel" + ), } diff --git a/repub/pages/runs.py b/repub/pages/runs.py index 058f1bf..ca9cce9 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -87,7 +87,7 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: tone="amber", ), _action_button( - label="Stop", + label=_text(execution, "cancel_label"), tone="danger", post_path=_maybe_text(execution, "cancel_post_path"), ), @@ -95,6 +95,54 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: ) +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.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')}" + ], + ], + queued_label, + h.div[ + h.p(class_="font-medium text-slate-900")[ + f"#{_text(execution, 'queue_position')}" + ], + ], + _action_button( + label=_text(execution, "run_label"), + disabled=_flag(execution, "run_disabled"), + post_path=_maybe_text(execution, "run_post_path"), + ), + h.div(class_="flex flex-nowrap items-center gap-2")[ + _action_button( + label="Cancel", + tone="danger", + post_path=_maybe_text(execution, "cancel_post_path"), + ) + ], + ) + + def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: next_run_at = _maybe_text(job, "next_run_at") next_run_label: Node = h.p(class_="font-medium text-slate-900")[ @@ -192,14 +240,17 @@ 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) upcoming_rows = tuple(_upcoming_row(job) for job in upcoming_items) completed_rows = tuple(_completed_row(execution) for execution in completed_items) @@ -227,7 +278,21 @@ def runs_page( ), table_section( eyebrow="Queue", - title="Upcoming jobs", + title="Queued job executions", + empty_message="No queued executions are waiting.", + headers=( + "Source", + "Execution", + "Queued", + "Position", + "Run now", + "Actions", + ), + rows=queued_rows, + ), + table_section( + eyebrow="Schedule", + title="Scheduled jobs", empty_message="No jobs are scheduled.", headers=( "Source", diff --git a/repub/sql/003_job_execution_queue.sql b/repub/sql/003_job_execution_queue.sql new file mode 100644 index 0000000..b8dbb51 --- /dev/null +++ b/repub/sql/003_job_execution_queue.sql @@ -0,0 +1,6 @@ +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/web.py b/repub/web.py index ae8b832..05e0cc4 100644 --- a/repub/web.py +++ b/repub/web.py @@ -276,6 +276,12 @@ 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("/job//execution//logs") async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse: async def render() -> Renderable: @@ -370,6 +376,7 @@ 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 fa3a70d..5450cf1 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from repub.jobs import load_runs_view @@ -83,3 +83,160 @@ 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", + ) + + +def test_load_runs_view_separates_queued_jobs_from_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 all(row["slug"] != "queued-source" for row in view["upcoming"]) + assert tuple(row["slug"] for row in view["upcoming"]) == ("scheduled-source",) + assert view["upcoming"][0]["run_reason"] == "Ready" + assert view["upcoming"][0]["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 3d0729d..4ff67f6 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -169,6 +169,40 @@ 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 2af4326..3fda18a 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -186,13 +186,24 @@ 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 None + assert second_execution_id is not None + second_execution = _wait_for_execution_status( + second_execution_id, + JobExecutionStatus.PENDING, + ) 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 @@ -200,6 +211,332 @@ 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: @@ -571,7 +908,7 @@ def test_render_runs_uses_database_backed_jobs_and_executions( assert "runs-page-source" in body assert "Running job executions" in body - assert "Upcoming jobs" in body + assert "Scheduled 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 @@ -719,6 +1056,21 @@ 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 70a5bb5..da4daef 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -92,6 +92,35 @@ def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_ti assert ">2 hours ago<" in body +def test_runs_page_renders_queued_execution_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", + }, + ) + ) + ) + + assert "Queued job executions" 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() @@ -1069,7 +1098,8 @@ def test_render_runs_shows_running_upcoming_and_completed_tables( body = str(await render_runs(app)) assert "Running job executions" in body - assert "Upcoming jobs" in body + assert "Queued job executions" in body + assert "Scheduled 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 @@ -1089,12 +1119,214 @@ def test_render_runs_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None body = str(await render_runs(app)) assert body.count("No job executions are running.") == 1 + assert "No queued executions are waiting." in body assert "No jobs are scheduled." in body assert "No job executions have completed yet." in body asyncio.run(run()) +def test_render_runs_shows_queued_execution_separately_from_scheduled_jobs( + 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 "Queued job executions" in body + assert "Scheduled jobs" in body + assert "queued-source" in body + assert "scheduled-source" in body + assert ( + f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel" + 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 + + asyncio.run(run()) + + +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_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_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)) From a88eba7dd14e84f9f2effe053145518bb10802a7 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 10:04:33 +0200 Subject: [PATCH 2/3] tighten whitespace, DRY shell and buttons --- AGENTS.md | 2 +- repub/components.py | 180 ++++++++++++++++++++++++++------------- repub/pages/dashboard.py | 29 +++---- repub/pages/runs.py | 67 +++++---------- repub/pages/settings.py | 18 ++-- repub/pages/shim.py | 67 +++++++-------- repub/pages/sources.py | 38 ++------- repub/static/app.css | 93 ++++++++++++++------ tests/test_web.py | 170 +++++++++++++++++++++++++++++++++++- 9 files changed, 439 insertions(+), 225 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7e9c932..9c288fa 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 ./path/to/input.css -o ./path/to/output.css` and add `--watch` when you need live rebuilds. +- Generate CSS with `tailwindcss -i ./repub/static/app.tailwind.css -o ./repub/static/app.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 035b74c..8cb0dda 100644 --- a/repub/components.py +++ b/repub/components.py @@ -4,6 +4,41 @@ import htpy as h from htpy import Node, Renderable +def _button_classes(*, tone: str, emphasis: str, disabled: bool = False) -> str: + base = ( + "inline-flex 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", + } + 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[ @@ -43,15 +78,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-6 py-8 text-white lg:min-h-screen" + class_="relative overflow-hidden bg-slate-950 px-4 py-6 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-3")[ + h.div(class_="flex items-center gap-2.5")[ h.div( - class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" + class_="flex size-10 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" )["AR"], h.div[ h.p( @@ -59,7 +94,7 @@ def admin_sidebar( )["Republisher"], ], ], - h.nav(class_="mt-10 space-y-2")[ + h.nav(class_="mt-8 space-y-2")[ nav_link( label="Dashboard", href="/", @@ -86,7 +121,7 @@ def admin_sidebar( badge="App", ), ], - h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[ + h.div(class_="mt-auto rounded-3xl bg-white/5 p-4 ring-1 ring-white/10")[ h.p(class_="text-sm font-semibold text-white")[ "AnyNews Republisher v2.0" ], @@ -101,21 +136,21 @@ def admin_sidebar( def header_action_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - 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", + class_=_button_classes(tone="amber", emphasis="regular"), )[label] def header_secondary_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - 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", + class_=_button_classes(tone="header-secondary", emphasis="regular"), )[label] def muted_action_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - 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", + class_=_button_classes(tone="muted", emphasis="soft"), )[label] @@ -131,22 +166,58 @@ def inline_link(*, href: str, label: str, tone: str = "default") -> Renderable: )[label] +def action_button( + *, + label: str, + tone: str = "default", + emphasis: str = "compact", + disabled: bool = False, + button_type: str = "button", + post_path: 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}')" + return h.button( + attributes, + type=button_type, + disabled=disabled, + class_=_button_classes(tone=tone, emphasis=emphasis, disabled=disabled), + )[label] + + def inline_button( *, label: str, tone: str = "default", disabled: bool = False ) -> Renderable: - 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] - ) - return h.button( - type="button", + return action_button( + label=label, + tone=tone, + emphasis="compact", + 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 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] + ], + ] def page_shell( @@ -160,39 +231,30 @@ def page_shell( running_count: int = 0, content: Node, ) -> Renderable: - 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, + 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, ), - 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: @@ -212,12 +274,12 @@ def table_section( def render_row(row: tuple[Node, ...]) -> Renderable: first_cell, *other_cells = row 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")[ + h.td(class_="py-3 pr-5 pl-3 text-sm font-medium text-slate-950 sm:pl-4")[ first_cell ], ( h.td( - class_="px-3 py-4 align-top text-sm whitespace-nowrap text-slate-600" + class_="px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600" )[cell] for cell in other_cells ), @@ -230,12 +292,14 @@ def table_section( body_rows = h.tr[ h.td( colspan=str(len(headers)), - class_="px-4 py-8 text-center text-sm text-slate-500 sm:px-6", + class_="px-3 py-8 text-center text-sm text-slate-500 sm:px-4", )[empty_message] ] return h.section[ - h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[ + h.div( + class_="flex flex-col gap-2.5 sm:flex-row sm:items-end sm:justify-between" + )[ h.div[ eyebrow and h.p( @@ -251,14 +315,14 @@ def table_section( )[ h.div(class_="overflow-x-auto")[ h.table( - class_="relative w-full min-w-[72rem] divide-y divide-slate-200 table-auto" + class_="relative w-full min-w-[64rem] divide-y divide-slate-200 table-auto" )[ h.thead(class_="bg-stone-50")[ h.tr[ ( h.th( scope="col", - 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", + class_="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", )[header] for header in headers ) diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index ef75847..ad68076 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 ( - admin_sidebar, + app_shell, header_action_link, inline_button, inline_link, @@ -253,21 +253,14 @@ def dashboard_page_with_data( ) -> Renderable: running_items = running_executions or () source_items = source_feeds or () - 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), + 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), ), - 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 ca9cce9..24418dd 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -6,6 +6,7 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( + action_button, inline_link, muted_action_link, page_shell, @@ -15,34 +16,6 @@ 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]) @@ -62,7 +35,7 @@ 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")[ + h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], @@ -73,20 +46,20 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: ], 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")], + h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "runtime")], ], status_badge(label=_text(execution, "status"), tone="running"), - h.div(class_="min-w-56 whitespace-normal")[ + h.div(class_="max-w-xs 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.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "worker")], ], - h.div(class_="flex flex-nowrap items-center gap-3")[ + h.div(class_="flex flex-wrap items-center gap-2")[ inline_link( href=_text(execution, "log_href"), label="View log", tone="amber", ), - _action_button( + action_button( label=_text(execution, "cancel_label"), tone="danger", post_path=_maybe_text(execution, "cancel_post_path"), @@ -113,7 +86,7 @@ def _queued_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")[ + h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], @@ -128,13 +101,13 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]: f"#{_text(execution, 'queue_position')}" ], ], - _action_button( + action_button( label=_text(execution, "run_label"), disabled=_flag(execution, "run_disabled"), post_path=_maybe_text(execution, "run_post_path"), ), - h.div(class_="flex flex-nowrap items-center gap-2")[ - _action_button( + h.div(class_="flex flex-wrap items-center gap-2")[ + action_button( label="Cancel", tone="danger", post_path=_maybe_text(execution, "cancel_post_path"), @@ -161,7 +134,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-1 font-mono text-xs text-slate-500")[_text(job, "slug")], + h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[_text(job, "slug")], ], h.div[next_run_label,], h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")], @@ -169,20 +142,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-40 whitespace-normal text-sm text-slate-500")[ + h.p(class_="max-w-32 whitespace-normal text-sm text-slate-500")[ _text(job, "run_reason") ], - h.div(class_="flex flex-nowrap items-center gap-2")[ - _action_button( + h.div(class_="flex flex-wrap items-center gap-2")[ + action_button( label="Run now", disabled=_flag(job, "run_disabled"), post_path=_maybe_text(job, "run_post_path"), ), - _action_button( + 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"), @@ -209,7 +182,7 @@ 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")[ + h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], @@ -220,13 +193,13 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: ], h.div[ ended_at_label, - h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "summary")], + h.p(class_="mt-0.5 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.div(class_="max-w-[14rem] whitespace-normal")[ h.p(class_="font-medium text-slate-900")[_text(execution, "stats")] ], inline_link( diff --git a/repub/pages/settings.py b/repub/pages/settings.py index d2730c0..efe513d 100644 --- a/repub/pages/settings.py +++ b/repub/pages/settings.py @@ -5,7 +5,13 @@ from collections.abc import Mapping import htpy as h from htpy import Renderable -from repub.components import input_field, muted_action_link, page_shell, section_card +from repub.components import ( + action_button, + input_field, + muted_action_link, + page_shell, + section_card, +) def _value(settings: Mapping[str, object] | None, key: str, default: str = "") -> str: @@ -71,10 +77,12 @@ def settings_page( ], h.div(class_="flex flex-wrap justify-end gap-3 pt-2")[ muted_action_link(href="/", label="Back to dashboard"), - 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"], + action_button( + label="Save settings", + tone="dark", + emphasis="regular", + button_type="submit", + ), ], ], ) diff --git a/repub/pages/shim.py b/repub/pages/shim.py index 1b8723f..e23ceac 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 admin_sidebar +from repub.components import app_shell ON_LOAD_JS = ( "@post(window.location.pathname + " @@ -33,43 +33,34 @@ def shim_page( } ), h.noscript["Your browser does not support JavaScript!"], - 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" + 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_="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.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 ad0c93a..93c625f 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -6,6 +6,7 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( + action_button, header_action_link, inline_link, input_field, @@ -54,29 +55,6 @@ 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[ @@ -99,12 +77,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-nowrap items-center gap-3")[ + h.div(class_="flex flex-wrap items-center gap-2")[ 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", @@ -422,10 +400,12 @@ 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"), - 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], + action_button( + label=submit_label, + tone="dark", + emphasis="regular", + button_type="submit", + ), ], ], ) diff --git a/repub/static/app.css b/repub/static/app.css index 9bb5f7e..659b7c2 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -42,6 +42,7 @@ --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; @@ -289,8 +290,8 @@ .mt-5 { margin-top: calc(var(--spacing) * 5); } - .mt-10 { - margin-top: calc(var(--spacing) * 10); + .mt-8 { + margin-top: calc(var(--spacing) * 8); } .mt-auto { margin-top: auto; @@ -320,9 +321,9 @@ width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); } - .size-11 { - width: calc(var(--spacing) * 11); - height: calc(var(--spacing) * 11); + .size-10 { + width: calc(var(--spacing) * 10); + height: calc(var(--spacing) * 10); } .h-5 { height: calc(var(--spacing) * 5); @@ -354,30 +355,33 @@ .max-w-7xl { max-width: var(--container-7xl); } - .max-w-40 { - max-width: calc(var(--spacing) * 40); + .max-w-32 { + max-width: calc(var(--spacing) * 32); + } + .max-w-\[14rem\] { + max-width: 14rem; } .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; } @@ -427,6 +431,9 @@ .gap-2 { gap: calc(var(--spacing) * 2); } + .gap-2\.5 { + gap: calc(var(--spacing) * 2.5); + } .gap-3 { gap: calc(var(--spacing) * 3); } @@ -552,6 +559,9 @@ .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); } @@ -649,9 +659,6 @@ .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); } @@ -673,6 +680,9 @@ .py-4 { padding-block: calc(var(--spacing) * 4); } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } .py-8 { padding-block: calc(var(--spacing) * 8); } @@ -682,9 +692,15 @@ .pt-6 { padding-top: calc(var(--spacing) * 6); } + .pr-5 { + padding-right: calc(var(--spacing) * 5); + } .pr-6 { padding-right: calc(var(--spacing) * 6); } + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } .pl-4 { padding-left: calc(var(--spacing) * 4); } @@ -820,6 +836,12 @@ .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; } @@ -879,6 +901,11 @@ 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); @@ -1036,30 +1063,25 @@ justify-content: space-between; } } + .sm\:px-4 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 4); + } + } .sm\:px-5 { @media (width >= 40rem) { padding-inline: calc(var(--spacing) * 5); } } - .sm\:px-6 { - @media (width >= 40rem) { - padding-inline: calc(var(--spacing) * 6); - } - } .sm\:pl-4 { @media (width >= 40rem) { padding-left: calc(var(--spacing) * 4); } } - .sm\:pl-6 { - @media (width >= 40rem) { - padding-left: calc(var(--spacing) * 6); - } - } - .sm\:first\:pl-6 { + .sm\:first\:pl-4 { @media (width >= 40rem) { &:first-child { - padding-left: calc(var(--spacing) * 6); + padding-left: calc(var(--spacing) * 4); } } } @@ -1088,16 +1110,31 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .lg\:grid-cols-\[14rem_minmax\(0\,1fr\)\] { + @media (width >= 64rem) { + grid-template-columns: 14rem minmax(0,1fr); + } + } .lg\:grid-cols-\[18rem_minmax\(0\,1fr\)\] { @media (width >= 64rem) { grid-template-columns: 18rem minmax(0,1fr); } } + .lg\:px-5 { + @media (width >= 64rem) { + padding-inline: calc(var(--spacing) * 5); + } + } .lg\:px-6 { @media (width >= 64rem) { padding-inline: calc(var(--spacing) * 6); } } + .lg\:py-4 { + @media (width >= 64rem) { + padding-block: calc(var(--spacing) * 4); + } + } .lg\:py-5 { @media (width >= 64rem) { padding-block: calc(var(--spacing) * 5); diff --git a/tests/test_web.py b/tests/test_web.py index da4daef..2b9c61a 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 status_badge, toggle_field +from repub.components import action_button, 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,6 +61,52 @@ 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 ): @@ -140,6 +186,8 @@ def test_root_get_serves_datastar_shim() -> None: assert "retryMaxCount: Infinity" in body assert "data-on:online__window=" in body assert '
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 + + asyncio.run(run()) + + def test_render_runs_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None: db_path = tmp_path / "runs-empty.db" monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) @@ -1231,6 +1300,87 @@ def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up( 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", + }, + ), + 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 ">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: @@ -1327,6 +1477,24 @@ def test_toggle_job_enabled_action_removes_queued_execution( 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)) From 99fd33f7706e9dee65b37162c94f6c787ecf9a92 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 10:23:46 +0200 Subject: [PATCH 3/3] runs queue order manipulation and whitespace tightnening --- repub/components.py | 47 +++++++--- repub/datastar.py | 17 +++- repub/jobs.py | 151 +++++++++++++++++++++++++++++--- repub/pages/runs.py | 127 ++++++++++++++++----------- repub/static/app.css | 60 ++++++++----- repub/static/app.tailwind.css | 7 ++ repub/web.py | 12 ++- tests/test_jobs.py | 24 +++-- tests/test_scheduler_runtime.py | 36 +++++++- tests/test_web.py | 118 ++++++++++++++++++++++--- 10 files changed, 478 insertions(+), 121 deletions(-) diff --git a/repub/components.py b/repub/components.py index 8cb0dda..e93ee87 100644 --- a/repub/components.py +++ b/repub/components.py @@ -1,17 +1,18 @@ 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 items-center justify-center rounded-full font-semibold transition " - ) + 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", @@ -168,20 +169,24 @@ def inline_link(*, href: str, label: str, tone: str = "default") -> Renderable: def action_button( *, - label: str, + 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] @@ -269,14 +274,24 @@ 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, ...]) -> Renderable: + def render_row( + row: tuple[Node, ...], attrs: Mapping[str, str] | None = None + ) -> Renderable: first_cell, *other_cells = row - return h.tr(class_="align-top")[ - h.td(class_="py-3 pr-5 pl-3 text-sm font-medium text-slate-950 sm:pl-4")[ - first_cell - ], + 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], ( h.td( class_="px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600" @@ -287,7 +302,11 @@ def table_section( body_rows: Node if rows: - body_rows = (render_row(row) for row in 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) + ) else: body_rows = h.tr[ h.td( @@ -322,9 +341,13 @@ def table_section( ( h.th( scope="col", - class_="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_=( + 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" + ), )[header] - for header in headers + for index, header in enumerate(headers) ) ] ], diff --git a/repub/datastar.py b/repub/datastar.py index d11efe5..8c63b02 100644 --- a/repub/datastar.py +++ b/repub/datastar.py @@ -47,13 +47,20 @@ def _publish_event(queue: asyncio.Queue[object], event: object) -> None: async def render_sse_event( - render: RenderFunction, *, last_event_id: str | None = None + render: RenderFunction, + *, + last_event_id: str | None = None, + use_view_transition: bool = False, ) -> 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) + return event_id, SSE.patch_elements( + html, + event_id=event_id, + use_view_transition=use_view_transition, + ) async def render_stream( @@ -71,9 +78,11 @@ async def render_stream( yield event while True: - await queue.get() + event_name = await queue.get() last_event_id, event = await render_sse_event( - render, last_event_id=last_event_id + render, + last_event_id=last_event_id, + use_view_transition=event_name == "queue-reordered", ) if event is not None: yield event diff --git a/repub/jobs.py b/repub/jobs.py index 3e7ef3a..f339195 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -107,7 +107,7 @@ class JobRuntime: self, *, log_dir: str | Path, - refresh_callback: Callable[[], None] | None = None, + refresh_callback: Callable[[object], None] | None = None, graceful_stop_seconds: float = 15.0, ) -> None: self.log_dir = Path(log_dir) @@ -117,6 +117,7 @@ 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: @@ -353,7 +354,7 @@ class JobRuntime: ) worker.process.terminate() - self._trigger_refresh() + self._trigger_refresh("queue-reordered") return True def cancel_queued_execution(self, execution_id: int) -> bool: @@ -375,6 +376,75 @@ class JobRuntime: 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(): @@ -428,6 +498,8 @@ class JobRuntime: 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 @@ -471,9 +543,27 @@ class JobRuntime: ): worker.process.kill() - def _trigger_refresh(self) -> None: + def _trigger_refresh(self, event: object = "refresh-event") -> None: if self.refresh_callback is not None: - self.refresh_callback() + 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() + ) def _reconcile_stale_executions(self) -> None: live_workers = _find_live_workers() @@ -614,13 +704,22 @@ def load_runs_view( for execution in running_executions ), "queued": tuple( - _project_queued_execution(execution, reference_time, position=position) + _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), reference_time) + _project_upcoming_job( + job, + running_by_job.get(job.id), + queued_by_job.get(job.id), + reference_time, + ) for job in jobs - if job.id not in queued_by_job ), "completed": tuple( _project_completed_execution(execution, resolved_log_dir, reference_time) @@ -772,15 +871,20 @@ def _project_running_execution( def _project_queued_execution( - execution: JobExecution, reference_time: datetime, *, position: int + 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(execution), + "execution_id": execution_id, "queued_at": _humanize_relative_time(reference_time, queued_at), "queued_at_iso": queued_at.isoformat(), "queue_position": position, @@ -789,14 +893,27 @@ def _project_queued_execution( "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(execution)}/cancel" + "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" ), } def _project_upcoming_job( - job: Job, running_execution: JobExecution | None, reference_time: datetime + job: Job, + running_execution: JobExecution | None, + queued_execution: JobExecution | None, + reference_time: datetime, ) -> dict[str, object]: job_id = _job_id(job) trigger = _job_trigger(job) @@ -805,6 +922,12 @@ 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, @@ -826,8 +949,8 @@ def _project_upcoming_job( ), "enabled_label": "Enabled" if job.enabled else "Disabled", "enabled_tone": "scheduled" if job.enabled else "idle", - "run_disabled": running_execution is not None, - "run_reason": "Already running" if running_execution is not None else "Ready", + "run_disabled": run_disabled, + "run_reason": run_reason, "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/runs.py b/repub/pages/runs.py index 24418dd..8ed592a 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -31,19 +31,47 @@ def _flag(values: Mapping[str, object], key: str) -> bool: return bool(values[key]) +def _queue_icon(direction: str) -> Renderable: + path = ( + "M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18" + if direction == "up" + else "M19.5 13.5 12 21m0 0-7.5-7.5M12 21V3" + ) + return h.svg( + xmlns="http://www.w3.org/2000/svg", + fill="none", + viewBox="0 0 24 24", + stroke_width="1.5", + stroke="currentColor", + class_="size-4", + )[ + h.path( + stroke_linecap="round", + stroke_linejoin="round", + d=path, + ) + ] + + +def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]: + return { + "style": ( + "view-transition-name: " f"running-job-{_text(execution, 'execution_id')};" + ) + } + + def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: return ( + h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[ + f"#{_text(execution, 'execution_id')}" + ], h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], - h.div[ - h.p(class_="font-medium text-slate-900")[ - 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")], @@ -84,34 +112,43 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]: )[_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") ], ], - h.div[ - h.p(class_="font-medium text-slate-900")[ - f"#{_text(execution, 'execution_id')}" - ], - ], queued_label, - h.div[ + status_badge(label="Queued", tone="idle"), + h.div(class_="max-w-xs whitespace-normal")[ h.p(class_="font-medium text-slate-900")[ - f"#{_text(execution, 'queue_position')}" + f"Queue position #{_text(execution, 'queue_position')}" ], + h.p(class_="mt-0.5 text-xs text-slate-500")["waiting for capacity"], ], - action_button( - label=_text(execution, "run_label"), - disabled=_flag(execution, "run_disabled"), - post_path=_maybe_text(execution, "run_post_path"), - ), h.div(class_="flex flex-wrap items-center gap-2")[ + action_button( + label=_queue_icon("up"), + emphasis="icon", + title="Move up", + disabled=_flag(execution, "move_up_disabled"), + post_path=_maybe_text(execution, "move_up_post_path"), + ), + action_button( + label=_queue_icon("down"), + emphasis="icon", + title="Move down", + disabled=_flag(execution, "move_down_disabled"), + post_path=_maybe_text(execution, "move_down_post_path"), + ), action_button( label="Cancel", tone="danger", post_path=_maybe_text(execution, "cancel_post_path"), - ) + ), ], ) @@ -180,21 +217,16 @@ 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")[ _text(execution, "slug") ], ], - h.div[ - h.p(class_="font-medium text-slate-900")[ - f"#{_text(execution, 'execution_id')}" - ], - ], - h.div[ - ended_at_label, - h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "summary")], - ], + h.div[ended_at_label,], status_badge( label=_text(execution, "status"), tone=_text(execution, "status_tone"), @@ -224,6 +256,10 @@ def runs_page( 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) @@ -237,31 +273,20 @@ def runs_page( content=( table_section( eyebrow="Live work", - title="Running job executions", - empty_message="No job executions are running.", + title="Running jobs", + empty_message="No jobs are running or queued.", headers=( + "#", "Source", - "Execution", - "Started", - "Status", - "Stats", + "Activity", + "State", + "Details", "Actions", ), - rows=running_rows, - ), - table_section( - eyebrow="Queue", - title="Queued job executions", - empty_message="No queued executions are waiting.", - headers=( - "Source", - "Execution", - "Queued", - "Position", - "Run now", - "Actions", - ), - rows=queued_rows, + rows=live_rows, + row_attrs=live_row_attrs, + first_header_class="w-px py-2.5 pr-2 pl-3 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 sm:pl-3", + first_cell_class="w-px py-3 pr-2 pl-3 text-sm font-medium text-slate-950 sm:pl-3", ), table_section( eyebrow="Schedule", @@ -282,14 +307,16 @@ def runs_page( title="Completed job executions", empty_message="No job executions have completed yet.", headers=( + "#", "Source", - "Execution", "Ended", - "Status", + "State", "Summary", "Log", ), rows=completed_rows, + first_header_class="w-px py-2.5 pr-2 pl-3 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 sm:pl-3", + first_cell_class="w-px py-3 pr-2 pl-3 text-sm font-medium text-slate-950 sm:pl-3", ), h.script[ """ diff --git a/repub/static/app.css b/repub/static/app.css index 659b7c2..3d3aa98 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -317,10 +317,18 @@ .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); @@ -349,6 +357,9 @@ .w-full { width: 100%; } + .w-px { + width: 1px; + } .max-w-3xl { max-width: var(--container-3xl); } @@ -457,13 +468,6 @@ 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; @@ -632,6 +636,9 @@ --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); } @@ -692,12 +699,21 @@ .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); } @@ -1068,9 +1084,14 @@ padding-inline: calc(var(--spacing) * 4); } } - .sm\:px-5 { + .sm\:pl-2\.5 { @media (width >= 40rem) { - padding-inline: calc(var(--spacing) * 5); + padding-left: calc(var(--spacing) * 2.5); + } + } + .sm\:pl-3 { + @media (width >= 40rem) { + padding-left: calc(var(--spacing) * 3); } } .sm\:pl-4 { @@ -1115,31 +1136,16 @@ grid-template-columns: 14rem minmax(0,1fr); } } - .lg\:grid-cols-\[18rem_minmax\(0\,1fr\)\] { - @media (width >= 64rem) { - grid-template-columns: 18rem minmax(0,1fr); - } - } .lg\:px-5 { @media (width >= 64rem) { padding-inline: calc(var(--spacing) * 5); } } - .lg\:px-6 { - @media (width >= 64rem) { - padding-inline: calc(var(--spacing) * 6); - } - } .lg\:py-4 { @media (width >= 64rem) { padding-block: calc(var(--spacing) * 4); } } - .lg\:py-5 { - @media (width >= 64rem) { - padding-block: calc(var(--spacing) * 5); - } - } .xl\:grid-cols-4 { @media (width >= 80rem) { grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -1156,6 +1162,12 @@ } } } +@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 742b073..089b841 100644 --- a/repub/static/app.tailwind.css +++ b/repub/static/app.tailwind.css @@ -1,2 +1,9 @@ @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 05e0cc4..8b0187c 100644 --- a/repub/web.py +++ b/repub/web.py @@ -282,6 +282,16 @@ def create_app(*, dev_mode: bool = False) -> Quart: 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: @@ -311,7 +321,7 @@ def get_job_runtime(app: Quart) -> JobRuntime: if runtime is None: runtime = JobRuntime( log_dir=app.config["REPUB_LOG_DIR"], - refresh_callback=lambda: trigger_refresh(app), + refresh_callback=lambda event="refresh-event": trigger_refresh(app, event), ) app.extensions[JOB_RUNTIME_KEY] = runtime return runtime diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 5450cf1..6159c74 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -147,9 +147,19 @@ def test_load_runs_view_projects_queued_executions_in_fifo_order( "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_separates_queued_jobs_from_scheduled_jobs( +def test_load_runs_view_keeps_queued_jobs_in_scheduled_jobs( tmp_path: Path, ) -> None: initialize_database(tmp_path / "jobs-queue-separation.db") @@ -194,10 +204,14 @@ def test_load_runs_view_separates_queued_jobs_from_scheduled_jobs( ) assert tuple(row["slug"] for row in view["queued"]) == ("queued-source",) - assert all(row["slug"] != "queued-source" for row in view["upcoming"]) - assert tuple(row["slug"] for row in view["upcoming"]) == ("scheduled-source",) - assert view["upcoming"][0]["run_reason"] == "Ready" - assert view["upcoming"][0]["run_disabled"] is False + 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( diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index 3fda18a..d87b1aa 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -628,6 +628,40 @@ 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: @@ -907,7 +941,7 @@ def test_render_runs_uses_database_backed_jobs_and_executions( body = str(await render_runs(app)) assert "runs-page-source" in body - assert "Running job executions" in body + assert "Running jobs" in body assert "Scheduled jobs" in body assert "Completed job executions" in body assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body diff --git a/tests/test_web.py b/tests/test_web.py index 2b9c61a..82324e1 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -138,7 +138,7 @@ def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_ti assert ">2 hours ago<" in body -def test_runs_page_renders_queued_execution_table() -> None: +def test_runs_page_renders_combined_running_jobs_table() -> None: body = str( runs_page( queued_executions=( @@ -156,12 +156,16 @@ def test_runs_page_renders_queued_execution_table() -> None: "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 "Queued job executions" in body + assert "Running jobs" in body assert "queued-source" in body assert ">Queued<" in body assert "/actions/queued-executions/42/cancel" in body @@ -296,6 +300,23 @@ def test_render_stream_yields_on_connect_and_refresh() -> 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: @@ -1118,7 +1139,7 @@ def test_settings_action_rejects_non_positive_max_concurrent_jobs( asyncio.run(run()) -def test_render_runs_shows_running_upcoming_and_completed_tables( +def test_render_runs_shows_running_scheduled_and_completed_tables( monkeypatch, tmp_path: Path ) -> None: db_path = tmp_path / "runs-render.db" @@ -1149,15 +1170,13 @@ def test_render_runs_shows_running_upcoming_and_completed_tables( body = str(await render_runs(app)) - assert "Running job executions" in body - assert "Queued job executions" in body + assert "Running jobs" in body assert "Scheduled 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 - assert "Already running" not in body asyncio.run(run()) @@ -1187,15 +1206,14 @@ 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 job executions are running.") == 1 - assert "No queued executions are waiting." in body + assert body.count("No jobs are running or queued.") == 1 assert "No jobs are scheduled." in body assert "No job executions have completed yet." in body asyncio.run(run()) -def test_render_runs_shows_queued_execution_separately_from_scheduled_jobs( +def test_render_runs_keeps_queued_execution_in_scheduled_jobs_table( monkeypatch, tmp_path: Path ) -> None: db_path = tmp_path / "runs-queued-render.db" @@ -1241,14 +1259,16 @@ def test_render_runs_shows_queued_execution_separately_from_scheduled_jobs( async def run() -> None: body = str(await render_runs(app)) - assert "Queued job executions" in body + 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()) @@ -1296,6 +1316,7 @@ def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up( in body ) assert ">Cancel<" in body + assert "Running jobs" in body asyncio.run(run()) @@ -1336,6 +1357,10 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction( "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=( @@ -1374,6 +1399,7 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction( ) ) + assert "Running jobs" in body assert ">Stop<" in body assert ">Cancel<" in body assert ">Run now<" in body @@ -1432,6 +1458,78 @@ def test_cancel_queued_execution_action_deletes_pending_row_without_touching_run 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: