From ba33491479c5d2900e139696b6166616afcba8da Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 12:25:46 +0200 Subject: [PATCH 1/2] Refine runs table state layout --- repub/jobs.py | 18 +++++ repub/pages/runs.py | 122 +++++++++++++++++++++++++--------- repub/static/app.css | 20 ++++++ tests/test_feed_validation.py | 2 +- tests/test_jobs.py | 34 ++++++++++ tests/test_web.py | 76 +++++++++++++++++++++ 6 files changed, 241 insertions(+), 31 deletions(-) diff --git a/repub/jobs.py b/repub/jobs.py index 5d3ed7f..b60d88f 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -1038,11 +1038,17 @@ def _project_completed_execution( if execution.ended_at is not None else None ) + started_at = ( + _coerce_datetime(cast(datetime | str, execution.started_at)) + if execution.started_at is not None + else None + ) return { "source": job.source.name, "slug": job.source.slug, "job_id": job_id, "execution_id": execution_id, + "duration": _format_duration(started_at, ended_at), "ended_at": ( _humanize_relative_time(reference_time, ended_at) if ended_at is not None @@ -1230,6 +1236,18 @@ def _humanize_relative_time(reference_time: datetime, target_time: datetime) -> return f"{absolute_delta_seconds} seconds ago" +def _format_duration( + started_at: datetime | None, ended_at: datetime | None +) -> str | None: + if started_at is None or ended_at is None: + return None + + total_seconds = max(0, int((ended_at - started_at).total_seconds())) + hours, remainder = divmod(total_seconds, 60 * 60) + minutes, seconds = divmod(remainder, 60) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + def _find_live_workers() -> dict[int, LiveWorker]: proc_dir = Path("/proc") if not proc_dir.exists(): diff --git a/repub/pages/runs.py b/repub/pages/runs.py index 6fd4416..cab3aad 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -53,6 +53,93 @@ def _queue_icon(direction: str) -> Renderable: ] +def _icon(path: str, *, class_name: str = "size-4") -> Renderable: + return h.svg( + xmlns="http://www.w3.org/2000/svg", + fill="none", + viewBox="0 0 24 24", + stroke_width="1.5", + stroke="currentColor", + class_=class_name, + )[ + h.path( + stroke_linecap="round", + stroke_linejoin="round", + d=path, + ) + ] + + +def _clock_icon() -> Renderable: + return _icon("M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z") + + +def _calendar_icon() -> Renderable: + return _icon( + "M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" + ) + + +def _status_icon(tone: str) -> Renderable: + return _icon( + ( + "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" + if tone == "done" + else "M9.75 9.75l4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" + ), + class_name="size-3.5", + ) + + +def _status_tone_classes(tone: str) -> str: + return { + "running": "bg-emerald-100 text-emerald-800", + "scheduled": "bg-sky-100 text-sky-800", + "idle": "bg-slate-200 text-slate-700", + "failed": "bg-rose-100 text-rose-800", + "done": "bg-emerald-100 text-emerald-800", + }[tone] + + +def _completed_status_cell(execution: Mapping[str, object]) -> Node: + duration = _maybe_text(execution, "duration") or "--:--:--" + ended_at = _maybe_text(execution, "ended_at_iso") + ended_at_label: Node = h.p(class_="truncate")[_text(execution, "ended_at")] + if ended_at is not None: + ended_at_label = h.time( + { + "data-ended-at": ended_at, + "title": ended_at, + }, + datetime=ended_at, + class_="truncate", + )[_text(execution, "ended_at")] + + return h.div(class_="min-w-[10rem]")[ + h.div(class_="flex items-center gap-2")[ + h.span(class_="font-mono text-xs text-slate-500")[ + f"#{_text(execution, 'execution_id')}" + ], + h.span( + class_=( + "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs " + f"font-semibold {_status_tone_classes(_text(execution, 'status_tone'))}" + ) + )[ + _status_icon(_text(execution, "status_tone")), + h.span[_text(execution, "status")], + ], + ], + h.div(class_="mt-1.5 space-y-1 text-xs text-slate-500")[ + h.p(class_="flex items-center gap-1.5")[_clock_icon(), h.span[duration]], + h.p(class_="flex items-center gap-1.5")[ + _calendar_icon(), + ended_at_label, + ], + ], + ] + + def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]: return { "style": ( @@ -173,12 +260,12 @@ def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: h.div(class_="font-semibold text-slate-950")[_text(job, "source")], h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[_text(job, "slug")], ], - h.div[next_run_label,], - h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")], status_badge( label=_text(job, "enabled_label"), tone=_text(job, "enabled_tone"), ), + h.div[next_run_label,], + h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")], h.p(class_="max-w-32 whitespace-normal text-sm text-slate-500")[ _text(job, "run_reason") ], @@ -202,35 +289,14 @@ def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: - ended_at = _maybe_text(execution, "ended_at_iso") - ended_at_label: Node = h.p(class_="font-medium text-slate-900")[ - _text(execution, "ended_at") - ] - if ended_at is not None: - ended_at_label = h.time( - { - "data-ended-at": ended_at, - "title": ended_at, - }, - datetime=ended_at, - class_="font-medium text-slate-900", - )[_text(execution, "ended_at")] - return ( - h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[ - f"#{_text(execution, 'execution_id')}" - ], + _completed_status_cell(execution), h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], - h.div[ended_at_label,], - status_badge( - label=_text(execution, "status"), - tone=_text(execution, "status_tone"), - ), h.div(class_="max-w-[14rem] whitespace-normal")[ h.p(class_="font-medium text-slate-900")[_text(execution, "stats")] ], @@ -356,16 +422,12 @@ def _completed_history_section( title="Completed job executions", empty_message="No job executions have completed yet.", headers=( - "#", - "Source", - "Ended", "State", + "Source", "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", actions=( action_button( label="Clear history", @@ -442,9 +504,9 @@ def runs_page( empty_message="No jobs are scheduled.", headers=( "Source", + "State", "Next run", "Cron", - "State", "Run now", "Actions", ), diff --git a/repub/static/app.css b/repub/static/app.css index 75508d6..91d5542 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -284,6 +284,9 @@ .mt-1 { margin-top: calc(var(--spacing) * 1); } + .mt-1\.5 { + margin-top: calc(var(--spacing) * 1.5); + } .mt-2 { margin-top: calc(var(--spacing) * 2); } @@ -326,6 +329,10 @@ .table { display: table; } + .size-3\.5 { + width: calc(var(--spacing) * 3.5); + height: calc(var(--spacing) * 3.5); + } .size-4 { width: calc(var(--spacing) * 4); height: calc(var(--spacing) * 4); @@ -396,6 +403,9 @@ .min-w-64 { min-width: calc(var(--spacing) * 64); } + .min-w-\[10rem\] { + min-width: 10rem; + } .min-w-\[64rem\] { min-width: 64rem; } @@ -451,6 +461,9 @@ .justify-end { justify-content: flex-end; } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } .gap-2 { gap: calc(var(--spacing) * 2); } @@ -466,6 +479,13 @@ .gap-6 { gap: calc(var(--spacing) * 6); } + .space-y-1 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-2 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; diff --git a/tests/test_feed_validation.py b/tests/test_feed_validation.py index d2aa172..6aacdb9 100644 --- a/tests/test_feed_validation.py +++ b/tests/test_feed_validation.py @@ -4,7 +4,7 @@ import re from email.utils import parsedate_to_datetime from io import BytesIO -from lxml import etree +import lxml.etree as etree from scrapy.http import TextResponse from scrapy.settings import Settings diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 45cc42b..df81fa4 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -49,6 +49,40 @@ def test_load_runs_view_humanizes_completed_execution_summary_bytes( assert view["completed"][0]["stats"] == "14 requests • 11 items • 15.7 MiB" +def test_load_runs_view_projects_completed_execution_duration( + tmp_path: Path, +) -> None: + initialize_database(tmp_path / "jobs-completed-duration.db") + source = create_source( + name="Completed source", + slug="completed-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/completed.xml", + ) + job = Job.get(Job.source == source) + JobExecution.create( + job=job, + running_status=JobExecutionStatus.SUCCEEDED, + started_at=datetime(2026, 3, 30, 11, 59, 12, tzinfo=UTC), + ended_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), + ) + + view = load_runs_view( + log_dir=tmp_path / "out" / "logs", + now=datetime(2026, 3, 30, 12, 30, tzinfo=UTC), + ) + + assert view["completed"][0]["duration"] == "00:00:48" + + def test_load_runs_view_humanizes_running_execution_summary_bytes( tmp_path: Path, ) -> None: diff --git a/tests/test_web.py b/tests/test_web.py index c75ab37..42bcb64 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -145,6 +145,48 @@ def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_ti assert ">2 hours ago<" in body +def test_runs_page_renders_completed_execution_state_cell_with_duration_and_end_time() -> ( + None +): + ended_at = "2026-01-15T10:00:00+00:00" + body = str( + runs_page( + completed_executions=( + { + "source": "Completed source", + "slug": "completed-source", + "job_id": 7, + "execution_id": 42, + "ended_at": "2 hours ago", + "ended_at_iso": ended_at, + "duration": "00:00:48", + "status": "Succeeded", + "status_tone": "done", + "stats": "1 requests • 1 items • 1 bytes", + "summary": "Worker exited successfully", + "log_href": "/job/7/execution/42/logs", + }, + ) + ) + ) + + assert re.search( + r"]*>State\s*]*>Source\s*]*>Summary", + body, + ) + assert not re.search( + r"]*>#\s*]*>State\s*]*>Source\s*]*>Summary", + body, + ) + assert ">#42<" in body + assert ">Ended<" not in body + assert ">00:00:48<" in body + assert 'data-ended-at="2026-01-15T10:00:00+00:00"' in body + assert "M12 6v6h4.5" in body + assert "M6.75 3v2.25M17.25 3v2.25" in body + assert "Succeeded" in body + + def test_runs_page_renders_combined_running_jobs_table() -> None: body = str( runs_page( @@ -178,6 +220,40 @@ def test_runs_page_renders_combined_running_jobs_table() -> None: assert "/actions/queued-executions/42/cancel" in body +def test_runs_page_moves_scheduled_jobs_state_column_to_second_position() -> None: + body = str( + runs_page( + upcoming_jobs=( + { + "source": "Parity source", + "slug": "parity-source", + "job_id": 7, + "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/7/toggle-enabled", + "run_post_path": "/actions/jobs/7/run-now", + "delete_post_path": "/actions/jobs/7/delete", + }, + ) + ) + ) + + assert re.search( + r"]*>Source\s*]*>State\s*]*>Next run", + body, + ) + assert re.search( + r"Parity source.*?>Enabled<.*?>in 5 minutes<.*?>\*/5 \* \* \* \*<", + body, + ) + + def test_sources_page_removes_view_runs_action_and_last_run_caption() -> None: body = str( sources_page( From 73617cd40cbb0c41dc7b51158170b55189f2a7e7 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 12:35:41 +0200 Subject: [PATCH 2/2] Polish live runs status layout --- repub/jobs.py | 4 +- repub/pages/runs.py | 101 ++++++++++++++++++++++++-------- repub/static/app.css | 25 +------- tests/test_jobs.py | 33 +++++++++++ tests/test_scheduler_runtime.py | 39 ++++++++++++ tests/test_web.py | 49 ++++++++++++++++ 6 files changed, 203 insertions(+), 48 deletions(-) diff --git a/repub/jobs.py b/repub/jobs.py index b60d88f..ad318ba 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -915,7 +915,9 @@ def _project_running_execution( "slug": job.source.slug, "job_id": job_id, "execution_id": execution_id, - "started_at": started_at.strftime("%Y-%m-%d %H:%M UTC"), + "duration": _format_duration(started_at, reference_time), + "started_at": _humanize_relative_time(reference_time, started_at), + "started_at_iso": started_at.isoformat(), "runtime": f"running for {int(runtime.total_seconds())}s", "status": "Stopping" if execution.stop_requested_at else "Running", "stats": _stats_summary(execution), diff --git a/repub/pages/runs.py b/repub/pages/runs.py index cab3aad..1906038 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -81,6 +81,19 @@ def _calendar_icon() -> Renderable: def _status_icon(tone: str) -> Renderable: + if tone == "running": + return h.svg( + xmlns="http://www.w3.org/2000/svg", + fill="currentColor", + viewBox="0 0 640 640", + class_="size-3.5 rotate-180", + )[ + h.path( + d="M512 320C512 214 426 128 320 128L320 512C426 512 512 426 512 320zM64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320z", + ) + ] + if tone == "queued": + return _clock_icon() return _icon( ( "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" @@ -93,7 +106,8 @@ def _status_icon(tone: str) -> Renderable: def _status_tone_classes(tone: str) -> str: return { - "running": "bg-emerald-100 text-emerald-800", + "running": "bg-sky-100 text-sky-800", + "queued": "bg-amber-200 text-amber-950", "scheduled": "bg-sky-100 text-sky-800", "idle": "bg-slate-200 text-slate-700", "failed": "bg-rose-100 text-rose-800", @@ -140,6 +154,34 @@ def _completed_status_cell(execution: Mapping[str, object]) -> Node: ] +def _live_status_cell( + *, + execution_id: str, + status: str, + status_tone: str, + clock_label: str, + calendar_label: Node, +) -> Node: + return h.div(class_="min-w-[10rem]")[ + h.div(class_="flex items-center gap-2")[ + h.span(class_="font-mono text-xs text-slate-500")[f"#{execution_id}"], + h.span( + class_=( + "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs " + f"font-semibold {_status_tone_classes(status_tone)}" + ) + )[ + _status_icon(status_tone), + h.span[status], + ], + ], + h.div(class_="mt-1.5 space-y-1 text-xs text-slate-500")[ + h.p(class_="flex items-center gap-1.5")[_clock_icon(), h.span[clock_label]], + h.p(class_="flex items-center gap-1.5")[_calendar_icon(), calendar_label], + ], + ] + + def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]: return { "style": ( @@ -149,21 +191,33 @@ def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]: def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: + started_at = _maybe_text(execution, "started_at_iso") + started_at_label: Node = h.p(class_="truncate")[_text(execution, "started_at")] + if started_at is not None: + started_at_label = h.time( + { + "data-started-at": started_at, + "title": started_at, + }, + datetime=started_at, + class_="truncate", + )[_text(execution, "started_at")] + return ( - h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[ - f"#{_text(execution, 'execution_id')}" - ], + _live_status_cell( + execution_id=_text(execution, "execution_id"), + status=_text(execution, "status"), + status_tone="running", + clock_label=_maybe_text(execution, "duration") + or _text(execution, "runtime"), + calendar_label=started_at_label, + ), h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], - h.div[ - h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")], - h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "runtime")], - ], - status_badge(label=_text(execution, "status"), tone="running"), h.div(class_="max-w-xs whitespace-normal")[ h.p(class_="font-medium text-slate-900")[_text(execution, "stats")], h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "worker")], @@ -185,9 +239,7 @@ 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") - ] + queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")] if queued_at is not None: queued_label = h.time( { @@ -195,21 +247,23 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]: "title": queued_at, }, datetime=queued_at, - class_="font-medium text-slate-900", + class_="truncate", )[_text(execution, "queued_at")] return ( - h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[ - f"#{_text(execution, 'execution_id')}" - ], + _live_status_cell( + execution_id=_text(execution, "execution_id"), + status="Queued", + status_tone="queued", + clock_label="Waiting", + calendar_label=queued_label, + ), h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], - queued_label, - status_badge(label="Queued", tone="idle"), h.div(class_="max-w-xs whitespace-normal")[ h.p(class_="font-medium text-slate-900")[ f"Queue position #{_text(execution, 'queue_position')}" @@ -486,17 +540,13 @@ def runs_page( title="Running jobs", empty_message="No jobs are running or queued.", headers=( - "#", - "Source", - "Activity", "State", + "Source", "Details", "Actions", ), rows=live_rows, row_attrs=live_row_attrs, - first_header_class="w-px py-2.5 pr-2 pl-3 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 sm:pl-3", - first_cell_class="w-px py-3 pr-2 pl-3 text-sm font-medium text-slate-950 sm:pl-3", ), table_section( eyebrow="Schedule", @@ -544,10 +594,11 @@ window.repubFormatNextRuns = window.repubFormatNextRuns || (() => { return relativeFormatter.format(0, 'second'); }; const format = () => { - document.querySelectorAll('time[data-next-run-at], time[data-ended-at]').forEach((element) => { + document.querySelectorAll('time[data-next-run-at], time[data-ended-at], time[data-started-at]').forEach((element) => { const relativeAt = element.getAttribute('data-next-run-at') ?? - element.getAttribute('data-ended-at'); + element.getAttribute('data-ended-at') ?? + element.getAttribute('data-started-at'); if (!relativeAt) return; const targetDate = new Date(relativeAt); if (Number.isNaN(targetDate.getTime())) return; diff --git a/repub/static/app.css b/repub/static/app.css index 91d5542..78b05de 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -373,9 +373,6 @@ .w-full { width: 100%; } - .w-px { - width: 1px; - } .max-w-3xl { max-width: var(--container-3xl); } @@ -425,6 +422,9 @@ --tw-translate-x: calc(var(--spacing) * 5); translate: var(--tw-translate-x) var(--tw-translate-y); } + .rotate-180 { + rotate: 180deg; + } .animate-pulse { animation: var(--animate-pulse); } @@ -738,21 +738,12 @@ .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); } @@ -1158,16 +1149,6 @@ padding-inline: calc(var(--spacing) * 6); } } - .sm\:pl-2\.5 { - @media (width >= 40rem) { - padding-left: calc(var(--spacing) * 2.5); - } - } - .sm\:pl-3 { - @media (width >= 40rem) { - padding-left: calc(var(--spacing) * 3); - } - } .sm\:pl-4 { @media (width >= 40rem) { padding-left: calc(var(--spacing) * 4); diff --git a/tests/test_jobs.py b/tests/test_jobs.py index df81fa4..db2b3a3 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -119,6 +119,39 @@ 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_running_execution_duration( + tmp_path: Path, +) -> None: + initialize_database(tmp_path / "jobs-running-duration.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, + running_status=JobExecutionStatus.RUNNING, + started_at=datetime(2026, 3, 30, 11, 59, 12, tzinfo=UTC), + ) + + view = load_runs_view( + log_dir=tmp_path / "out" / "logs", + now=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), + ) + + assert view["running"][0]["duration"] == "00:00:48" + + def test_load_runs_view_projects_queued_executions_in_fifo_order( tmp_path: Path, ) -> None: diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index a132402..c53b2da 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -911,6 +911,45 @@ def test_load_runs_view_humanizes_completed_execution_end_time( assert completed["ended_at_iso"] == ended_at.isoformat() +def test_load_runs_view_humanizes_running_execution_start_time( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "runs-running-view.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="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) + reference_time = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) + started_at = reference_time - timedelta(hours=2) + JobExecution.create( + job=job, + running_status=JobExecutionStatus.RUNNING, + started_at=started_at, + ) + + view = load_runs_view(log_dir=app.config["REPUB_LOG_DIR"], now=reference_time) + running = view["running"][0] + + assert running["started_at"] == "2 hours ago" + assert running["started_at_iso"] == started_at.isoformat() + + def test_render_runs_uses_database_backed_jobs_and_executions( monkeypatch, tmp_path: Path ) -> None: diff --git a/tests/test_web.py b/tests/test_web.py index 42bcb64..11b2443 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -217,9 +217,58 @@ def test_runs_page_renders_combined_running_jobs_table() -> None: assert "Running jobs" in body assert "queued-source" in body assert ">Queued<" in body + assert "bg-amber-200 text-amber-950" in body assert "/actions/queued-executions/42/cancel" in body +def test_runs_page_renders_running_state_cell_with_duration_and_started_at() -> None: + started_at = "2026-03-30T12:00:00+00:00" + body = str( + runs_page( + running_executions=( + { + "source": "Running source", + "slug": "running-source", + "job_id": 1, + "execution_id": 11, + "started_at": "2 minutes ago", + "started_at_iso": started_at, + "duration": "00:00:10", + "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", + }, + ) + ) + ) + + assert re.search( + r"]*>State\s*]*>Source\s*]*>Details", + body, + ) + assert not re.search( + r"]*>#\s*]*>Source\s*]*>Activity\s*]*>State", + body, + ) + assert ">#11<" in body + assert ">00:00:10<" in body + assert ">2 minutes ago<" in body + assert 'data-started-at="2026-03-30T12:00:00+00:00"' in body + assert 'title="2026-03-30T12:00:00+00:00"' in body + assert "bg-sky-100 text-sky-800" in body + assert 'viewBox="0 0 640 640"' in body + assert "rotate-180" in body + assert "M512 320C512 214 426 128 320 128L320 512" in body + assert "M12 6v6h4.5" in body + assert "M6.75 3v2.25M17.25 3v2.25" in body + assert "Running" in body + assert "time[data-next-run-at], time[data-ended-at], time[data-started-at]" in body + + def test_runs_page_moves_scheduled_jobs_state_column_to_second_position() -> None: body = str( runs_page(