Refine runs table state layout
This commit is contained in:
parent
db1d9b44b7
commit
ba33491479
6 changed files with 241 additions and 31 deletions
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue