feat: add pty monitoring for claude code
This commit is contained in:
@@ -226,6 +226,18 @@ class ClaudeCodeBackendAdapter:
|
|||||||
def live_session_count(self) -> int:
|
def live_session_count(self) -> int:
|
||||||
return self._backend.live_session_count
|
return self._backend.live_session_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live_sessions(self) -> dict[str, Any]:
|
||||||
|
"""Live PTY processes keyed by claude session_id.
|
||||||
|
|
||||||
|
Pass-through to the underlying ``ClaudeCodeBackend``. The value
|
||||||
|
type is the claude-code-api ``PtyClaudeProcess``; admin code
|
||||||
|
consumes ``captured_output()`` / ``add_output_listener`` /
|
||||||
|
``write`` from it. Typed as ``Any`` to avoid leaking the lower
|
||||||
|
layer's type into the gateway's public surface.
|
||||||
|
"""
|
||||||
|
return self._backend.live_sessions
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
async def __aenter__(self) -> Self:
|
||||||
await self._backend.__aenter__()
|
await self._backend.__aenter__()
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ cookie session.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -35,7 +37,7 @@ from typing import TYPE_CHECKING, Annotated, Any
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import itsdangerous
|
import itsdangerous
|
||||||
from fastapi import FastAPI, Form, HTTPException, Request, status
|
from fastapi import FastAPI, Form, HTTPException, Request, WebSocket, status
|
||||||
from fastapi.responses import (
|
from fastapi.responses import (
|
||||||
HTMLResponse,
|
HTMLResponse,
|
||||||
RedirectResponse,
|
RedirectResponse,
|
||||||
@@ -510,12 +512,193 @@ class AdminFrontend(Frontend):
|
|||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- PTY terminal viewer ----
|
||||||
|
|
||||||
|
@app.get("/pty", response_class=HTMLResponse)
|
||||||
|
async def pty_list(request: Request) -> Response:
|
||||||
|
session = _require_session(request, signer, url_prefix=url_prefix)
|
||||||
|
sessions = _collect_pty_sessions(runtime)
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"pty_list.html",
|
||||||
|
user=session["user"],
|
||||||
|
csrf=session["csrf"],
|
||||||
|
sessions=sessions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/pty/{session_id}", response_class=HTMLResponse)
|
||||||
|
async def pty_view(session_id: str, request: Request) -> Response:
|
||||||
|
session = _require_session(request, signer, url_prefix=url_prefix)
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"pty.html",
|
||||||
|
user=session["user"],
|
||||||
|
csrf=session["csrf"],
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.websocket("/pty/{session_id}/ws")
|
||||||
|
async def pty_ws(websocket: WebSocket, session_id: str) -> None:
|
||||||
|
# Session check via cookie — websockets carry cookies, same
|
||||||
|
# signed payload as the HTTP routes. Reject before accepting
|
||||||
|
# so the client gets a clean 403 (which manifests as a WS
|
||||||
|
# connection failure on the JS side).
|
||||||
|
if _current_user_from_cookie(websocket, signer) is None:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
pty = _find_pty(runtime, session_id)
|
||||||
|
if pty is None:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
await _bridge_pty_websocket(websocket, pty)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
# ---- helpers ------------------------------------------------------------
|
# ---- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_pty_sessions(runtime: GatewayRuntime) -> list[dict[str, Any]]:
|
||||||
|
"""Enumerate live PTY sessions across all backends.
|
||||||
|
|
||||||
|
A backend qualifies if it exposes a ``live_sessions`` mapping
|
||||||
|
(currently only ``ClaudeCodeBackendAdapter``). Other backend types
|
||||||
|
are quietly skipped — the admin terminal viewer only makes sense for
|
||||||
|
PTY-backed agents.
|
||||||
|
"""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for agent_name, backend in runtime.backends.items():
|
||||||
|
live = getattr(backend, "live_sessions", None)
|
||||||
|
if not isinstance(live, dict):
|
||||||
|
continue
|
||||||
|
for session_id, pty in live.items():
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"agent": agent_name,
|
||||||
|
"session_id": session_id,
|
||||||
|
"pid": getattr(pty, "pid", None),
|
||||||
|
"buffer_size": len(pty.captured_output())
|
||||||
|
if hasattr(pty, "captured_output")
|
||||||
|
else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pty(runtime: GatewayRuntime, session_id: str) -> Any:
|
||||||
|
"""Locate a live PTY by its claude session_id, across all backends."""
|
||||||
|
for backend in runtime.backends.values():
|
||||||
|
live = getattr(backend, "live_sessions", None)
|
||||||
|
if not isinstance(live, dict):
|
||||||
|
continue
|
||||||
|
pty = live.get(session_id)
|
||||||
|
if pty is not None:
|
||||||
|
return pty
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_from_cookie(
|
||||||
|
websocket: WebSocket, signer: itsdangerous.URLSafeTimedSerializer
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Validate the admin session cookie on a WebSocket handshake.
|
||||||
|
|
||||||
|
Mirrors :func:`_current_user` but reads from the websocket's
|
||||||
|
request-style cookies. ``BadSignature`` / ``SignatureExpired`` /
|
||||||
|
``BadData`` all collapse to None — the caller closes the connection.
|
||||||
|
"""
|
||||||
|
raw = websocket.cookies.get(SESSION_COOKIE)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = signer.loads(raw, max_age=SESSION_MAX_AGE)
|
||||||
|
except itsdangerous.BadData:
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict) or "user" not in payload:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _bridge_pty_websocket(websocket: WebSocket, pty: Any) -> None:
|
||||||
|
"""Bidirectional bridge between a WebSocket and a live PtyClaudeProcess.
|
||||||
|
|
||||||
|
Output direction: the drain thread already runs continuously and
|
||||||
|
fans incoming bytes to all subscribed listeners. We register a
|
||||||
|
listener that drops each chunk into an :class:`asyncio.Queue` via
|
||||||
|
``call_soon_threadsafe`` (the drain runs on a thread, not the loop).
|
||||||
|
A dedicated sender task drains the queue into ``websocket.send_bytes``.
|
||||||
|
|
||||||
|
Input direction: each client text frame is encoded UTF-8 and written
|
||||||
|
to the PTY raw (``newline=False``) so xterm.js can pass through key
|
||||||
|
sequences (Enter, arrows, control chars) verbatim.
|
||||||
|
|
||||||
|
On either side closing, both tasks unwind and the listener is
|
||||||
|
removed. The initial ``captured_output()`` snapshot is sent so a
|
||||||
|
new client sees the current screen state, not a blank terminal.
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
out_queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1024)
|
||||||
|
|
||||||
|
def listener(chunk: bytes) -> None:
|
||||||
|
loop.call_soon_threadsafe(out_queue.put_nowait, chunk)
|
||||||
|
|
||||||
|
pty.add_output_listener(listener)
|
||||||
|
try:
|
||||||
|
# Initial replay so the client doesn't see a blank terminal.
|
||||||
|
initial = pty.captured_output()
|
||||||
|
if initial:
|
||||||
|
await websocket.send_bytes(bytes(initial))
|
||||||
|
|
||||||
|
async def writer() -> None:
|
||||||
|
while True:
|
||||||
|
chunk = await out_queue.get()
|
||||||
|
if chunk is None:
|
||||||
|
return
|
||||||
|
await websocket.send_bytes(chunk)
|
||||||
|
|
||||||
|
async def reader() -> None:
|
||||||
|
while True:
|
||||||
|
msg = await websocket.receive()
|
||||||
|
if msg["type"] == "websocket.disconnect":
|
||||||
|
return
|
||||||
|
data = msg.get("text") or msg.get("bytes")
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
payload = data.encode("utf-8") if isinstance(data, str) else bytes(data)
|
||||||
|
await pty.write(payload, newline=False)
|
||||||
|
|
||||||
|
writer_task = asyncio.create_task(writer(), name="pty-ws-writer")
|
||||||
|
reader_task = asyncio.create_task(reader(), name="pty-ws-reader")
|
||||||
|
try:
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{writer_task, reader_task}, return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
with contextlib.suppress(BaseException):
|
||||||
|
await t
|
||||||
|
for t in done:
|
||||||
|
# Surface a real error instead of hiding it in the
|
||||||
|
# task's stored exception — but never crash the bridge
|
||||||
|
# on a clean disconnect.
|
||||||
|
exc = t.exception()
|
||||||
|
if exc is not None and not isinstance(
|
||||||
|
exc, asyncio.CancelledError | RuntimeError | OSError
|
||||||
|
):
|
||||||
|
_log.warning("pty websocket task failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
out_queue.put_nowait(None)
|
||||||
|
finally:
|
||||||
|
pty.remove_output_listener(listener)
|
||||||
|
with contextlib.suppress(BaseException):
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- helpers (originals) ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_template_env() -> Environment:
|
def _build_template_env() -> Environment:
|
||||||
env = Environment(
|
env = Environment(
|
||||||
loader=PackageLoader("beaver_gateway.frontends.admin", "templates"),
|
loader=PackageLoader("beaver_gateway.frontends.admin", "templates"),
|
||||||
|
|||||||
@@ -187,6 +187,7 @@
|
|||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="{{ p }}/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
|
<a href="{{ p }}/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
|
||||||
<a href="{{ p }}/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
|
<a href="{{ p }}/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
|
||||||
|
<a href="{{ p }}/pty" class="{% if active == 'pty' %}active{% endif %}">PTY</a>
|
||||||
<a href="{{ p }}/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
|
<a href="{{ p }}/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
|
||||||
<a href="{{ p }}/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
|
<a href="{{ p }}/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% set active = "pty" %}
|
||||||
|
{% block title %}PTY {{ session_id }} · beaver-gateway{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
/* xterm.js styles inlined from the CDN bundle — keep them tight and
|
||||||
|
constrained to the terminal block so they don't leak into the rest
|
||||||
|
of the admin UI. */
|
||||||
|
.term-wrap {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.term-host {
|
||||||
|
height: 70vh;
|
||||||
|
}
|
||||||
|
.term-host .xterm {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.term-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.term-toolbar .status {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.term-toolbar .status.connected { color: #027a48; }
|
||||||
|
.term-toolbar .status.dropped { color: var(--danger); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||||
|
|
||||||
|
<h2>PTY <code>{{ session_id }}</code></h2>
|
||||||
|
<p class="muted">
|
||||||
|
Live view of the claude subprocess TUI. Keystrokes you type here go
|
||||||
|
straight into its stdin — Enter, arrows, Ctrl-C, paste, all work.
|
||||||
|
<a href="{{ p }}/pty">← back to list</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="term-toolbar">
|
||||||
|
<button id="reconnect-btn" type="button">Reconnect</button>
|
||||||
|
<button id="enter-btn" type="button" title="Send a single \r — handy if a paste is stuck in the input box">Send Enter</button>
|
||||||
|
<button id="ctrl-c-btn" type="button">Send Ctrl-C</button>
|
||||||
|
<span id="status" class="status">connecting…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="term-wrap">
|
||||||
|
<div id="term" class="term-host"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const sessionId = {{ session_id|tojson }};
|
||||||
|
const wsBase =
|
||||||
|
(location.protocol === "https:" ? "wss://" : "ws://") +
|
||||||
|
location.host +
|
||||||
|
{{ p|tojson }} +
|
||||||
|
"/pty/" + encodeURIComponent(sessionId) + "/ws";
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
theme: {
|
||||||
|
background: "#000000",
|
||||||
|
foreground: "#d0d0d0",
|
||||||
|
},
|
||||||
|
convertEol: false,
|
||||||
|
cursorBlink: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
});
|
||||||
|
const fit = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(document.getElementById("term"));
|
||||||
|
// Defer fit until after layout settles; xterm's measurement reads
|
||||||
|
// computed CSS that isn't stable until the page paints once.
|
||||||
|
requestAnimationFrame(() => fit.fit());
|
||||||
|
window.addEventListener("resize", () => fit.fit());
|
||||||
|
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = "status" + (cls ? " " + cls : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let manuallyClosed = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
manuallyClosed = false;
|
||||||
|
setStatus("connecting…", "");
|
||||||
|
ws = new WebSocket(wsBase);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
ws.onopen = () => setStatus("connected", "connected");
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!manuallyClosed) setStatus("disconnected", "dropped");
|
||||||
|
};
|
||||||
|
ws.onerror = () => setStatus("error", "dropped");
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
term.write(ev.data);
|
||||||
|
} else {
|
||||||
|
// ArrayBuffer — feed raw bytes to xterm. It expects either
|
||||||
|
// string or Uint8Array; the latter preserves byte boundaries
|
||||||
|
// exactly, which matters for ANSI sequences split across frames.
|
||||||
|
term.write(new Uint8Array(ev.data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keystrokes -> server. xterm.js gives us the exact byte sequence
|
||||||
|
// the terminal would emit (e.g. Enter -> "\r", arrows -> "\x1b[A"
|
||||||
|
// etc.), so we just pass it through.
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("reconnect-btn").addEventListener("click", () => {
|
||||||
|
if (ws) {
|
||||||
|
manuallyClosed = true;
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
});
|
||||||
|
document.getElementById("enter-btn").addEventListener("click", () => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\r");
|
||||||
|
term.focus();
|
||||||
|
});
|
||||||
|
document.getElementById("ctrl-c-btn").addEventListener("click", () => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x03");
|
||||||
|
term.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% set active = "pty" %}
|
||||||
|
{% block title %}PTY · beaver-gateway · admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Live PTY sessions</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Each row is a running <code>claude</code> subprocess. Open one to see what's
|
||||||
|
currently rendered on its TUI and (if needed) type into it directly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if sessions %}
|
||||||
|
<div class="card" style="padding: 0;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session ID</th>
|
||||||
|
<th>Agent</th>
|
||||||
|
<th>PID</th>
|
||||||
|
<th>Buffer</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sessions %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ s.session_id }}</code></td>
|
||||||
|
<td>{{ s.agent }}</td>
|
||||||
|
<td><span class="pill">{{ s.pid or "?" }}</span></td>
|
||||||
|
<td>{{ s.buffer_size }} bytes</td>
|
||||||
|
<td>
|
||||||
|
<a class="btn primary" href="{{ p }}/pty/{{ s.session_id }}">Open</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="banner">No live PTY sessions right now. Start a turn and refresh.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -287,7 +287,7 @@ local = [
|
|||||||
{ name = "raycast-api", version = "0.1.0", source = { editable = "../raycast-api" } },
|
{ name = "raycast-api", version = "0.1.0", source = { editable = "../raycast-api" } },
|
||||||
]
|
]
|
||||||
prod = [
|
prod = [
|
||||||
{ name = "claude-code-api", version = "0.1.0", source = { git = "https://git.kotikot.com/beaver/claude-code-api.git#339461716338c2e6850c7aa654a429efcaf80bf7" } },
|
{ name = "claude-code-api", version = "0.1.0", source = { git = "https://git.kotikot.com/beaver/claude-code-api.git#27e6e5a1bfc87d68213d08ef67e269350188c935" } },
|
||||||
{ name = "raycast-api", version = "0.1.0", source = { git = "https://git.kotikot.com/beaver/raycast-api.git#e73894c8e435da5c0709f92f69f11bcd0dab9afe" } },
|
{ name = "raycast-api", version = "0.1.0", source = { git = "https://git.kotikot.com/beaver/raycast-api.git#e73894c8e435da5c0709f92f69f11bcd0dab9afe" } },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -419,7 +419,7 @@ wheels = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-code-api"
|
name = "claude-code-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { git = "https://git.kotikot.com/beaver/claude-code-api.git#339461716338c2e6850c7aa654a429efcaf80bf7" }
|
source = { git = "https://git.kotikot.com/beaver/claude-code-api.git#27e6e5a1bfc87d68213d08ef67e269350188c935" }
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.14'",
|
"python_full_version >= '3.14'",
|
||||||
"python_full_version < '3.14'",
|
"python_full_version < '3.14'",
|
||||||
|
|||||||
Reference in New Issue
Block a user