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"