Refine runs table state layout

This commit is contained in:
Abel Luck 2026-03-31 12:25:46 +02:00
parent db1d9b44b7
commit ba33491479
6 changed files with 241 additions and 31 deletions

View file

@ -1038,11 +1038,17 @@ def _project_completed_execution(
if execution.ended_at is not None if execution.ended_at is not None
else None else None
) )
started_at = (
_coerce_datetime(cast(datetime | str, execution.started_at))
if execution.started_at is not None
else None
)
return { return {
"source": job.source.name, "source": job.source.name,
"slug": job.source.slug, "slug": job.source.slug,
"job_id": job_id, "job_id": job_id,
"execution_id": execution_id, "execution_id": execution_id,
"duration": _format_duration(started_at, ended_at),
"ended_at": ( "ended_at": (
_humanize_relative_time(reference_time, ended_at) _humanize_relative_time(reference_time, ended_at)
if ended_at is not None 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" 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]: def _find_live_workers() -> dict[int, LiveWorker]:
proc_dir = Path("/proc") proc_dir = Path("/proc")
if not proc_dir.exists(): if not proc_dir.exists():

View file

@ -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]: def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]:
return { return {
"style": ( "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.div(class_="font-semibold text-slate-950")[_text(job, "source")],
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[_text(job, "slug")], h.p(class_="mt-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( status_badge(
label=_text(job, "enabled_label"), label=_text(job, "enabled_label"),
tone=_text(job, "enabled_tone"), 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")[ h.p(class_="max-w-32 whitespace-normal text-sm text-slate-500")[
_text(job, "run_reason") _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, ...]: 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 ( return (
h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[ _completed_status_cell(execution),
f"#{_text(execution, 'execution_id')}"
],
h.div[ h.div[
h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], h.div(class_="font-semibold text-slate-950")[_text(execution, "source")],
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[
_text(execution, "slug") _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.div(class_="max-w-[14rem] whitespace-normal")[
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")] h.p(class_="font-medium text-slate-900")[_text(execution, "stats")]
], ],
@ -356,16 +422,12 @@ def _completed_history_section(
title="Completed job executions", title="Completed job executions",
empty_message="No job executions have completed yet.", empty_message="No job executions have completed yet.",
headers=( headers=(
"#",
"Source",
"Ended",
"State", "State",
"Source",
"Summary", "Summary",
"Log", "Log",
), ),
rows=completed_rows, 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=( actions=(
action_button( action_button(
label="Clear history", label="Clear history",
@ -442,9 +504,9 @@ def runs_page(
empty_message="No jobs are scheduled.", empty_message="No jobs are scheduled.",
headers=( headers=(
"Source", "Source",
"State",
"Next run", "Next run",
"Cron", "Cron",
"State",
"Run now", "Run now",
"Actions", "Actions",
), ),

View file

@ -284,6 +284,9 @@
.mt-1 { .mt-1 {
margin-top: calc(var(--spacing) * 1); margin-top: calc(var(--spacing) * 1);
} }
.mt-1\.5 {
margin-top: calc(var(--spacing) * 1.5);
}
.mt-2 { .mt-2 {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
} }
@ -326,6 +329,10 @@
.table { .table {
display: table; display: table;
} }
.size-3\.5 {
width: calc(var(--spacing) * 3.5);
height: calc(var(--spacing) * 3.5);
}
.size-4 { .size-4 {
width: calc(var(--spacing) * 4); width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4); height: calc(var(--spacing) * 4);
@ -396,6 +403,9 @@
.min-w-64 { .min-w-64 {
min-width: calc(var(--spacing) * 64); min-width: calc(var(--spacing) * 64);
} }
.min-w-\[10rem\] {
min-width: 10rem;
}
.min-w-\[64rem\] { .min-w-\[64rem\] {
min-width: 64rem; min-width: 64rem;
} }
@ -451,6 +461,9 @@
.justify-end { .justify-end {
justify-content: flex-end; justify-content: flex-end;
} }
.gap-1\.5 {
gap: calc(var(--spacing) * 1.5);
}
.gap-2 { .gap-2 {
gap: calc(var(--spacing) * 2); gap: calc(var(--spacing) * 2);
} }
@ -466,6 +479,13 @@
.gap-6 { .gap-6 {
gap: calc(var(--spacing) * 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 { .space-y-2 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;

View file

@ -4,7 +4,7 @@ import re
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from io import BytesIO from io import BytesIO
from lxml import etree import lxml.etree as etree
from scrapy.http import TextResponse from scrapy.http import TextResponse
from scrapy.settings import Settings from scrapy.settings import Settings

View file

@ -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" 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( def test_load_runs_view_humanizes_running_execution_summary_bytes(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:

View file

@ -145,6 +145,48 @@ def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_ti
assert ">2 hours ago<" in body 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"<th[^>]*>State</th>\s*<th[^>]*>Source</th>\s*<th[^>]*>Summary</th>",
body,
)
assert not re.search(
r"<th[^>]*>#</th>\s*<th[^>]*>State</th>\s*<th[^>]*>Source</th>\s*<th[^>]*>Summary</th>",
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: def test_runs_page_renders_combined_running_jobs_table() -> None:
body = str( body = str(
runs_page( runs_page(
@ -178,6 +220,40 @@ def test_runs_page_renders_combined_running_jobs_table() -> None:
assert "/actions/queued-executions/42/cancel" in body 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"<th[^>]*>Source</th>\s*<th[^>]*>State</th>\s*<th[^>]*>Next run</th>",
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: def test_sources_page_removes_view_runs_action_and_last_run_caption() -> None:
body = str( body = str(
sources_page( sources_page(