feat(web): Executions table gets real columns and status icons
No cell stacks two facts on separate lines with <br> any more (Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started, VM/IP-Uptime each get their own column, 8 -> 13 total). The card is already .card--wide with its own .table-wrap, so the extra width scrolls horizontally rather than the page. Status pills carry a check/x/play icon for the three outcomes that are a real verdict or an in-progress state (success/failure/running); queued, cancelled, skipped stay plain text. Every pill in the column -- including the VM-only orphan/running pills on unmatched-VM rows -- is forced to the same width with left-aligned content instead of a ragged column.
This commit is contained in:
@@ -9,6 +9,7 @@ degraded ``operations/`` fetch, an unset timestamp, ...).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -179,6 +180,47 @@ def run_badge(status: str | None) -> str:
|
|||||||
return _RUN_BADGES.get(status or "", "badge-muted")
|
return _RUN_BADGES.get(status or "", "badge-muted")
|
||||||
|
|
||||||
|
|
||||||
|
#: Icon id for a run status: a check/x for the two pass/fail verdicts, a play
|
||||||
|
#: glyph for "still going". ``queued``/``cancelled``/``skipped`` are neither
|
||||||
|
#: a verdict nor in progress, so they stay plain text -- an icon there would
|
||||||
|
#: just be noise repeating the badge colour.
|
||||||
|
_RUN_BADGE_ICONS: dict[str, str] = {
|
||||||
|
"success": "i-check",
|
||||||
|
"failure": "i-x",
|
||||||
|
"running": "i-play",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_badge_icon(status: str | None) -> str:
|
||||||
|
"""Icon id for :func:`run_badge`'s pill, or ``""`` for non-terminal states."""
|
||||||
|
return _RUN_BADGE_ICONS.get(status or "", "")
|
||||||
|
|
||||||
|
|
||||||
|
#: Marketing noise in ``/proc/cpuinfo``'s ``model name`` that costs tile width
|
||||||
|
#: and says nothing an operator needs: trademark marks, the clock-speed tail
|
||||||
|
#: (the CPU tile already shows live load, and the base clock is not the
|
||||||
|
#: current one), and the core-count tail (the tile prints the real ``ncpu``).
|
||||||
|
_CPU_NOISE = re.compile(
|
||||||
|
r"\((?:R|TM|tm|r)\)"
|
||||||
|
r"|\s+(?:CPU|Processor)?\s*@\s*[\d.]+\s*[GM]Hz"
|
||||||
|
r"|\s+\d+-Core\s+Processor"
|
||||||
|
r"|\s+CPU$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_short(model: str | None) -> str:
|
||||||
|
"""Trim a ``/proc/cpuinfo`` model name to what fits a status tile.
|
||||||
|
|
||||||
|
``"Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz"`` -> ``"Intel Core i9-13900K"``.
|
||||||
|
An empty/absent model stays ``""`` (not ``"-"``) so the template can drop
|
||||||
|
the line entirely instead of rendering a dash under the CPU label. The full
|
||||||
|
untrimmed name still shows in the Platform card.
|
||||||
|
"""
|
||||||
|
if not model:
|
||||||
|
return ""
|
||||||
|
return " ".join(_CPU_NOISE.sub(" ", model).split())
|
||||||
|
|
||||||
|
|
||||||
def clone_job(name: str | None) -> ParsedClone:
|
def clone_job(name: str | None) -> ParsedClone:
|
||||||
"""Parse a VM/clone name into its ``job_id`` + ``started`` (Stage C1).
|
"""Parse a VM/clone name into its ``job_id`` + ``started`` (Stage C1).
|
||||||
|
|
||||||
@@ -202,16 +244,20 @@ def register(templates: Jinja2Templates) -> None:
|
|||||||
templates.env.filters["thousands"] = thousands
|
templates.env.filters["thousands"] = thousands
|
||||||
templates.env.filters["clone_job"] = clone_job
|
templates.env.filters["clone_job"] = clone_job
|
||||||
templates.env.filters["run_badge"] = run_badge
|
templates.env.filters["run_badge"] = run_badge
|
||||||
|
templates.env.filters["run_badge_icon"] = run_badge_icon
|
||||||
|
templates.env.filters["cpu_short"] = cpu_short
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"clone_job",
|
"clone_job",
|
||||||
|
"cpu_short",
|
||||||
"duration",
|
"duration",
|
||||||
"humanbytes",
|
"humanbytes",
|
||||||
"localtime",
|
"localtime",
|
||||||
"register",
|
"register",
|
||||||
"relative_time",
|
"relative_time",
|
||||||
"run_badge",
|
"run_badge",
|
||||||
|
"run_badge_icon",
|
||||||
"shorten_path",
|
"shorten_path",
|
||||||
"thousands",
|
"thousands",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
Actions run (the live ones included), with the build VM(s) that run spawned
|
Actions run (the live ones included), with the build VM(s) that run spawned
|
||||||
grouped into its VM cell, Remove action and all.
|
grouped into its VM cell, Remove action and all.
|
||||||
|
|
||||||
|
Every fact gets its own column -- no cell stacks two data points with
|
||||||
|
<br> (Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started, VM/IP are
|
||||||
|
each a pair that used to share one cell on two lines). A wide table is the
|
||||||
|
accepted cost here: the card is already `.card--wide` and its own
|
||||||
|
`.table-wrap` scrolls horizontally rather than the page.
|
||||||
|
|
||||||
Degraded states (no token, expired token, Gitea unreachable) render a notice
|
Degraded states (no token, expired token, Gitea unreachable) render a notice
|
||||||
and still list every running VM below, so this card is never less useful
|
and still list every running VM below, so this card is never less useful
|
||||||
than the VM table it replaces. Orphan VMs are NOT handled here: the sweep
|
than the VM table it replaces. Orphan VMs are NOT handled here: the sweep
|
||||||
lives in the Maintenance card and the count in the status-header VMs tile;
|
lives in the Maintenance card (Stage F retired the status-header VMs tile
|
||||||
|
that carried the count);
|
||||||
``executions.unmatched_vms`` only exists so a powered-on VM whose run has
|
``executions.unmatched_vms`` only exists so a powered-on VM whose run has
|
||||||
scrolled out of the window (or that no run ever claimed) keeps its row and
|
scrolled out of the window (or that no run ever claimed) keeps its row and
|
||||||
its Remove button.
|
its Remove button.
|
||||||
@@ -47,12 +54,17 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Run</th>
|
<th>Run</th>
|
||||||
|
<th>Repo</th>
|
||||||
<th>Workflow</th>
|
<th>Workflow</th>
|
||||||
<th>Branch / commit</th>
|
<th>Branch</th>
|
||||||
|
<th>Commit</th>
|
||||||
<th>Trigger</th>
|
<th>Trigger</th>
|
||||||
|
<th>Actor</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
|
<th>Started</th>
|
||||||
<th>VM</th>
|
<th>VM</th>
|
||||||
|
<th>IP / Uptime</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -65,32 +77,35 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
#{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %}
|
#{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if run.repo %}<br><span class="text-dim">{{ run.repo }}</span>{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ run.repo or "—" }}</td>
|
||||||
<td>{{ run.workflow or "—" }}</td>
|
<td>{{ run.workflow or "—" }}</td>
|
||||||
|
<td>{{ run.branch or "—" }}</td>
|
||||||
|
<td>{% if run.commit %}<code class="text-dim">{{ run.commit[:7] }}</code>{% else %}—{% endif %}</td>
|
||||||
|
<td>{{ run.event or "—" }}</td>
|
||||||
|
<td>{{ run.actor or "—" }}</td>
|
||||||
|
{% set run_icon = run.status | run_badge_icon %}
|
||||||
|
<td><span class="badge {{ run.status | run_badge }}">{% if run_icon %}<svg class="icon" aria-hidden="true"><use href="#{{ run_icon }}"></use></svg>{% endif %}{{ run.status }}</span></td>
|
||||||
|
<td>{{ run.duration_sec | duration }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ run.branch or "—" }}
|
{% if run.started %}
|
||||||
{% if run.commit %}<br><code class="text-dim">{{ run.commit[:7] }}</code>{% endif %}
|
<span class="text-dim" title="{{ run.started | localtime }}">{{ run.started | relative_time }}</span>
|
||||||
</td>
|
{% else %}
|
||||||
<td>
|
<span class="text-dim">—</span>
|
||||||
{{ run.event or "—" }}
|
{% endif %}
|
||||||
{% if run.actor %}<br><span class="text-dim">{{ run.actor }}</span>{% endif %}
|
|
||||||
</td>
|
|
||||||
<td><span class="badge {{ run.status | run_badge }}">{{ run.status }}</span></td>
|
|
||||||
<td>
|
|
||||||
{{ run.duration_sec | duration }}
|
|
||||||
{% if run.started %}<br><span class="text-dim" title="{{ run.started | localtime }}">{{ run.started | relative_time }}</span>{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% for vm in run.vms %}
|
{% for vm in run.vms %}
|
||||||
<div>
|
<div>{{ vm.name }}</div>
|
||||||
{{ vm.name }}
|
|
||||||
<br><span class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-dim">—</span>
|
<span class="text-dim">—</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% for vm in run.vms %}
|
||||||
|
<div class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% for vm in run.vms %}
|
{% for vm in run.vms %}
|
||||||
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
|
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
|
||||||
@@ -105,9 +120,10 @@
|
|||||||
{% for vm in executions.unmatched_vms %}
|
{% for vm in executions.unmatched_vms %}
|
||||||
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
|
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
|
||||||
{% set parsed = vm.name | clone_job %}
|
{% set parsed = vm.name | clone_job %}
|
||||||
<td>
|
<td><span class="text-dim">{{ parsed.job_id or "no run" }}</span></td>
|
||||||
<span class="text-dim">{{ parsed.job_id or "no run" }}</span>
|
<td><span class="text-dim">—</span></td>
|
||||||
</td>
|
<td><span class="text-dim">—</span></td>
|
||||||
|
<td><span class="text-dim">—</span></td>
|
||||||
<td><span class="text-dim">—</span></td>
|
<td><span class="text-dim">—</span></td>
|
||||||
<td><span class="text-dim">—</span></td>
|
<td><span class="text-dim">—</span></td>
|
||||||
<td><span class="text-dim">—</span></td>
|
<td><span class="text-dim">—</span></td>
|
||||||
@@ -115,9 +131,10 @@
|
|||||||
{% if vm.is_orphan %}
|
{% if vm.is_orphan %}
|
||||||
<span class="badge badge-warn">orphan</span>
|
<span class="badge badge-warn">orphan</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-info">running</span>
|
<span class="badge badge-info"><svg class="icon" aria-hidden="true"><use href="#i-play"></use></svg>running</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td><span class="text-dim">—</span></td>
|
||||||
<td>
|
<td>
|
||||||
{% if parsed.started %}
|
{% if parsed.started %}
|
||||||
<span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
|
<span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
|
||||||
@@ -125,10 +142,8 @@
|
|||||||
<span class="text-dim">—</span>
|
<span class="text-dim">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>{{ vm.name }}</td>
|
||||||
{{ vm.name }}
|
<td class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</td>
|
||||||
<br><span class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
|
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
|
||||||
hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML show:top">
|
hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML show:top">
|
||||||
|
|||||||
@@ -77,6 +77,14 @@
|
|||||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
|
||||||
|
<!-- Executions card: pass/fail run-status pills -- i-check pairs with
|
||||||
|
i-alert everywhere else in this sprite, but a failed *run* is a
|
||||||
|
boolean outcome, not a resource warning, so it gets its own cross. -->
|
||||||
|
<symbol id="i-x" viewBox="0 0 24 24">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
<!-- Executions card: a run/play glyph, stroked like every other icon here. -->
|
<!-- Executions card: a run/play glyph, stroked like every other icon here. -->
|
||||||
<symbol id="i-play" viewBox="0 0 24 24">
|
<symbol id="i-play" viewBox="0 0 24 24">
|
||||||
<circle cx="12" cy="12" r="9"></circle>
|
<circle cx="12" cy="12" r="9"></circle>
|
||||||
|
|||||||
@@ -270,6 +270,102 @@ def test_status_badges(
|
|||||||
assert f">{status}<" in resp.text
|
assert f">{status}<" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status", "icon"),
|
||||||
|
[
|
||||||
|
("success", "i-check"),
|
||||||
|
("failure", "i-x"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_status_badge_carries_a_check_or_x_icon(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str, icon: str
|
||||||
|
) -> None:
|
||||||
|
"""A pass/fail outcome should read without needing the badge colour."""
|
||||||
|
_stub_view(
|
||||||
|
monkeypatch,
|
||||||
|
ExecutionsView(
|
||||||
|
runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app = create_app(_make_config(tmp_path))
|
||||||
|
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get("/executions")
|
||||||
|
|
||||||
|
assert f'href="#{icon}"' in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_badge_carries_a_play_icon_for_running(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""In progress is its own state, not a verdict -- but still not bare text."""
|
||||||
|
_stub_view(
|
||||||
|
monkeypatch,
|
||||||
|
ExecutionsView(
|
||||||
|
runs=[_execution(status="running")], unmatched_vms=[], source="admin", error=None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app = create_app(_make_config(tmp_path))
|
||||||
|
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get("/executions")
|
||||||
|
|
||||||
|
# The card's own header icon is also #i-play -- count, don't just look
|
||||||
|
# for presence, or this assertion is trivially true either way.
|
||||||
|
assert resp.text.count('href="#i-play"') == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["queued", "cancelled", "unknown"])
|
||||||
|
def test_status_badge_has_no_icon_for_non_terminal_states(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str
|
||||||
|
) -> None:
|
||||||
|
"""queued/cancelled/unknown are neither a verdict nor in progress -- plain text."""
|
||||||
|
_stub_view(
|
||||||
|
monkeypatch,
|
||||||
|
ExecutionsView(
|
||||||
|
runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app = create_app(_make_config(tmp_path))
|
||||||
|
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get("/executions")
|
||||||
|
|
||||||
|
assert '<td><span class="badge badge-' in resp.text
|
||||||
|
assert "#i-check" not in resp.text
|
||||||
|
assert "#i-x" not in resp.text
|
||||||
|
assert "#i-x" not in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_cell_stacks_two_facts_on_separate_lines(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started and VM/IP each
|
||||||
|
get their own column now -- no cell relies on a <br> to show two facts."""
|
||||||
|
vm = _vm("Clone_412-1_20260725_115000_linux")
|
||||||
|
_stub_view(
|
||||||
|
monkeypatch,
|
||||||
|
ExecutionsView(
|
||||||
|
runs=[_execution(vms=[vm])], unmatched_vms=[], source="admin", error=None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app = create_app(_make_config(tmp_path))
|
||||||
|
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get("/executions")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "<br>" not in resp.text
|
||||||
|
for header in ("Repo", "Trigger", "Actor", "Started", "IP / Uptime"):
|
||||||
|
assert f"<th>{header}</th>" in resp.text
|
||||||
|
assert "Simone" in resp.text # actor, now its own column
|
||||||
|
assert vm.ip in resp.text
|
||||||
|
|
||||||
|
|
||||||
def test_empty_state_when_no_runs_and_no_vms(
|
def test_empty_state_when_no_runs_and_no_vms(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -305,6 +401,9 @@ def test_unmatched_vm_keeps_its_row_and_remove_button(
|
|||||||
assert vm.name in resp.text
|
assert vm.name in resp.text
|
||||||
assert "370-1" in resp.text # job id parsed out of the clone name
|
assert "370-1" in resp.text # job id parsed out of the clone name
|
||||||
assert "Remove" in resp.text
|
assert "Remove" in resp.text
|
||||||
|
# Same word, same column as a run's "running" status pill -- same icon.
|
||||||
|
# The card's own header icon is also #i-play, hence count == 2 not 1.
|
||||||
|
assert resp.text.count('href="#i-play"') == 2
|
||||||
|
|
||||||
|
|
||||||
def test_end_to_end_against_a_stubbed_gitea(
|
def test_end_to_end_against_a_stubbed_gitea(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
from ci_orchestrator.operations.vms import ParsedClone
|
from ci_orchestrator.operations.vms import ParsedClone
|
||||||
from ci_orchestrator.web.filters import (
|
from ci_orchestrator.web.filters import (
|
||||||
clone_job,
|
clone_job,
|
||||||
|
cpu_short,
|
||||||
duration,
|
duration,
|
||||||
humanbytes,
|
humanbytes,
|
||||||
localtime,
|
localtime,
|
||||||
@@ -23,6 +24,25 @@ from ci_orchestrator.web.filters import (
|
|||||||
thousands,
|
thousands,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── cpu_short ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpu_short_strips_intel_trademark_noise_and_clock_tail() -> None:
|
||||||
|
assert cpu_short("Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz") == "Intel Core i9-13900K"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpu_short_strips_amd_core_count_tail() -> None:
|
||||||
|
assert cpu_short("AMD Ryzen 9 5950X 16-Core Processor") == "AMD Ryzen 9 5950X"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpu_short_passes_through_an_already_short_name() -> None:
|
||||||
|
assert cpu_short("Apple M2 Pro") == "Apple M2 Pro"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpu_short_of_empty_stays_empty_so_the_template_can_hide_the_line() -> None:
|
||||||
|
assert cpu_short("") == ""
|
||||||
|
assert cpu_short(None) == ""
|
||||||
|
|
||||||
# ── humanbytes ───────────────────────────────────────────────────────────
|
# ── humanbytes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user