Polish live runs status layout
This commit is contained in:
parent
ba33491479
commit
73617cd40c
6 changed files with 203 additions and 48 deletions
|
|
@ -915,7 +915,9 @@ def _project_running_execution(
|
||||||
"slug": job.source.slug,
|
"slug": job.source.slug,
|
||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
"execution_id": execution_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",
|
"runtime": f"running for {int(runtime.total_seconds())}s",
|
||||||
"status": "Stopping" if execution.stop_requested_at else "Running",
|
"status": "Stopping" if execution.stop_requested_at else "Running",
|
||||||
"stats": _stats_summary(execution),
|
"stats": _stats_summary(execution),
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,19 @@ def _calendar_icon() -> Renderable:
|
||||||
|
|
||||||
|
|
||||||
def _status_icon(tone: str) -> 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(
|
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"
|
"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:
|
def _status_tone_classes(tone: str) -> str:
|
||||||
return {
|
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",
|
"scheduled": "bg-sky-100 text-sky-800",
|
||||||
"idle": "bg-slate-200 text-slate-700",
|
"idle": "bg-slate-200 text-slate-700",
|
||||||
"failed": "bg-rose-100 text-rose-800",
|
"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]:
|
def _queue_row_attrs(execution: Mapping[str, object]) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"style": (
|
"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, ...]:
|
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 (
|
return (
|
||||||
h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[
|
_live_status_cell(
|
||||||
f"#{_text(execution, 'execution_id')}"
|
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[
|
||||||
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[
|
|
||||||
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.div(class_="max-w-xs whitespace-normal")[
|
||||||
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")],
|
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")],
|
||||||
h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "worker")],
|
h.p(class_="mt-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, ...]:
|
def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
||||||
queued_at = _maybe_text(execution, "queued_at_iso")
|
queued_at = _maybe_text(execution, "queued_at_iso")
|
||||||
queued_label: Node = h.p(class_="font-medium text-slate-900")[
|
queued_label: Node = h.p(class_="truncate")[_text(execution, "queued_at")]
|
||||||
_text(execution, "queued_at")
|
|
||||||
]
|
|
||||||
if queued_at is not None:
|
if queued_at is not None:
|
||||||
queued_label = h.time(
|
queued_label = h.time(
|
||||||
{
|
{
|
||||||
|
|
@ -195,21 +247,23 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
||||||
"title": queued_at,
|
"title": queued_at,
|
||||||
},
|
},
|
||||||
datetime=queued_at,
|
datetime=queued_at,
|
||||||
class_="font-medium text-slate-900",
|
class_="truncate",
|
||||||
)[_text(execution, "queued_at")]
|
)[_text(execution, "queued_at")]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
h.p(class_="w-px whitespace-nowrap font-medium text-slate-900")[
|
_live_status_cell(
|
||||||
f"#{_text(execution, 'execution_id')}"
|
execution_id=_text(execution, "execution_id"),
|
||||||
],
|
status="Queued",
|
||||||
|
status_tone="queued",
|
||||||
|
clock_label="Waiting",
|
||||||
|
calendar_label=queued_label,
|
||||||
|
),
|
||||||
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")
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
queued_label,
|
|
||||||
status_badge(label="Queued", tone="idle"),
|
|
||||||
h.div(class_="max-w-xs whitespace-normal")[
|
h.div(class_="max-w-xs whitespace-normal")[
|
||||||
h.p(class_="font-medium text-slate-900")[
|
h.p(class_="font-medium text-slate-900")[
|
||||||
f"Queue position #{_text(execution, 'queue_position')}"
|
f"Queue position #{_text(execution, 'queue_position')}"
|
||||||
|
|
@ -486,17 +540,13 @@ def runs_page(
|
||||||
title="Running jobs",
|
title="Running jobs",
|
||||||
empty_message="No jobs are running or queued.",
|
empty_message="No jobs are running or queued.",
|
||||||
headers=(
|
headers=(
|
||||||
"#",
|
|
||||||
"Source",
|
|
||||||
"Activity",
|
|
||||||
"State",
|
"State",
|
||||||
|
"Source",
|
||||||
"Details",
|
"Details",
|
||||||
"Actions",
|
"Actions",
|
||||||
),
|
),
|
||||||
rows=live_rows,
|
rows=live_rows,
|
||||||
row_attrs=live_row_attrs,
|
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(
|
table_section(
|
||||||
eyebrow="Schedule",
|
eyebrow="Schedule",
|
||||||
|
|
@ -544,10 +594,11 @@ window.repubFormatNextRuns = window.repubFormatNextRuns || (() => {
|
||||||
return relativeFormatter.format(0, 'second');
|
return relativeFormatter.format(0, 'second');
|
||||||
};
|
};
|
||||||
const format = () => {
|
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 =
|
const relativeAt =
|
||||||
element.getAttribute('data-next-run-at') ??
|
element.getAttribute('data-next-run-at') ??
|
||||||
element.getAttribute('data-ended-at');
|
element.getAttribute('data-ended-at') ??
|
||||||
|
element.getAttribute('data-started-at');
|
||||||
if (!relativeAt) return;
|
if (!relativeAt) return;
|
||||||
const targetDate = new Date(relativeAt);
|
const targetDate = new Date(relativeAt);
|
||||||
if (Number.isNaN(targetDate.getTime())) return;
|
if (Number.isNaN(targetDate.getTime())) return;
|
||||||
|
|
|
||||||
|
|
@ -373,9 +373,6 @@
|
||||||
.w-full {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.w-px {
|
|
||||||
width: 1px;
|
|
||||||
}
|
|
||||||
.max-w-3xl {
|
.max-w-3xl {
|
||||||
max-width: var(--container-3xl);
|
max-width: var(--container-3xl);
|
||||||
}
|
}
|
||||||
|
|
@ -425,6 +422,9 @@
|
||||||
--tw-translate-x: calc(var(--spacing) * 5);
|
--tw-translate-x: calc(var(--spacing) * 5);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
}
|
}
|
||||||
|
.rotate-180 {
|
||||||
|
rotate: 180deg;
|
||||||
|
}
|
||||||
.animate-pulse {
|
.animate-pulse {
|
||||||
animation: var(--animate-pulse);
|
animation: var(--animate-pulse);
|
||||||
}
|
}
|
||||||
|
|
@ -738,21 +738,12 @@
|
||||||
.pt-6 {
|
.pt-6 {
|
||||||
padding-top: calc(var(--spacing) * 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 {
|
.pr-5 {
|
||||||
padding-right: calc(var(--spacing) * 5);
|
padding-right: calc(var(--spacing) * 5);
|
||||||
}
|
}
|
||||||
.pr-6 {
|
.pr-6 {
|
||||||
padding-right: calc(var(--spacing) * 6);
|
padding-right: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
.pl-2 {
|
|
||||||
padding-left: calc(var(--spacing) * 2);
|
|
||||||
}
|
|
||||||
.pl-3 {
|
.pl-3 {
|
||||||
padding-left: calc(var(--spacing) * 3);
|
padding-left: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
|
@ -1158,16 +1149,6 @@
|
||||||
padding-inline: calc(var(--spacing) * 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 {
|
.sm\:pl-4 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
padding-left: calc(var(--spacing) * 4);
|
padding-left: calc(var(--spacing) * 4);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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(
|
def test_load_runs_view_projects_queued_executions_in_fifo_order(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -911,6 +911,45 @@ def test_load_runs_view_humanizes_completed_execution_end_time(
|
||||||
assert completed["ended_at_iso"] == ended_at.isoformat()
|
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(
|
def test_render_runs_uses_database_backed_jobs_and_executions(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -217,9 +217,58 @@ def test_runs_page_renders_combined_running_jobs_table() -> None:
|
||||||
assert "Running jobs" in body
|
assert "Running jobs" in body
|
||||||
assert "queued-source" in body
|
assert "queued-source" in body
|
||||||
assert ">Queued<" in body
|
assert ">Queued<" in body
|
||||||
|
assert "bg-amber-200 text-amber-950" in body
|
||||||
assert "/actions/queued-executions/42/cancel" 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"<th[^>]*>State</th>\s*<th[^>]*>Source</th>\s*<th[^>]*>Details</th>",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
assert not re.search(
|
||||||
|
r"<th[^>]*>#</th>\s*<th[^>]*>Source</th>\s*<th[^>]*>Activity</th>\s*<th[^>]*>State</th>",
|
||||||
|
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:
|
def test_runs_page_moves_scheduled_jobs_state_column_to_second_position() -> None:
|
||||||
body = str(
|
body = str(
|
||||||
runs_page(
|
runs_page(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue