feat: add ansible routes and templates (browse, run, stream, detail)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
# fablednetmon/ansible/routes.py
|
||||||
|
from __future__ import annotations
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
Blueprint, Response, current_app, redirect, render_template,
|
||||||
|
request, session, url_for,
|
||||||
|
)
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from fablednetmon.ansible import executor, sources as src_module
|
||||||
|
from fablednetmon.auth.middleware import require_role
|
||||||
|
from fablednetmon.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||||
|
from fablednetmon.models.users import UserRole
|
||||||
|
|
||||||
|
ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sources() -> list[dict]:
|
||||||
|
ansible_cfg = current_app.config.get("ANSIBLE", {})
|
||||||
|
return src_module.get_sources(ansible_cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@ansible_bp.get("/")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def index():
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50)
|
||||||
|
)
|
||||||
|
runs = result.scalars().all()
|
||||||
|
return await render_template("ansible/index.html", runs=runs)
|
||||||
|
|
||||||
|
|
||||||
|
@ansible_bp.get("/browse")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def browse():
|
||||||
|
all_sources = _get_sources()
|
||||||
|
source_data = []
|
||||||
|
for source in all_sources:
|
||||||
|
playbooks = src_module.discover_playbooks(source["path"])
|
||||||
|
inventories = src_module.discover_inventories(source["path"])
|
||||||
|
source_data.append({
|
||||||
|
"source": source,
|
||||||
|
"playbooks": playbooks,
|
||||||
|
"inventories": inventories,
|
||||||
|
})
|
||||||
|
return await render_template("ansible/browse.html", source_data=source_data)
|
||||||
|
|
||||||
|
|
||||||
|
@ansible_bp.get("/browse/<source_name>/<path:playbook_path>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def view_playbook(source_name: str, playbook_path: str):
|
||||||
|
all_sources = _get_sources()
|
||||||
|
source = next((s for s in all_sources if s["name"] == source_name), None)
|
||||||
|
if source is None:
|
||||||
|
return "Source not found", 404
|
||||||
|
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||||
|
if contents is None:
|
||||||
|
return "Playbook not found", 404
|
||||||
|
return await render_template(
|
||||||
|
"ansible/browse.html",
|
||||||
|
source_data=[],
|
||||||
|
view_source=source_name,
|
||||||
|
view_path=playbook_path,
|
||||||
|
view_contents=contents,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ansible_bp.post("/runs")
|
||||||
|
@require_role(UserRole.operator)
|
||||||
|
async def create_run():
|
||||||
|
form = await request.form
|
||||||
|
playbook_path = form.get("playbook_path", "").strip()
|
||||||
|
source_name = form.get("source_name", "").strip()
|
||||||
|
inventory_path = form.get("inventory_path", "").strip()
|
||||||
|
|
||||||
|
all_sources = _get_sources()
|
||||||
|
source = next((s for s in all_sources if s["name"] == source_name), None)
|
||||||
|
if source is None:
|
||||||
|
return "Source not found", 404
|
||||||
|
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
run = AnsibleRun(
|
||||||
|
id=run_id,
|
||||||
|
playbook_path=playbook_path,
|
||||||
|
inventory_path=inventory_path,
|
||||||
|
source_name=source_name,
|
||||||
|
triggered_by=session["user_id"],
|
||||||
|
status=AnsibleRunStatus.running,
|
||||||
|
started_at=now,
|
||||||
|
)
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
async with db.begin():
|
||||||
|
db.add(run)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
asyncio.create_task(
|
||||||
|
executor.start_run(
|
||||||
|
current_app._get_current_object(), # type: ignore[attr-defined]
|
||||||
|
run_id,
|
||||||
|
playbook_path,
|
||||||
|
inventory_path,
|
||||||
|
source["path"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"ansible/run_started.html",
|
||||||
|
run=run,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ansible_bp.get("/runs/<run_id>/stream")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def stream_run(run_id: str):
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id))
|
||||||
|
run = result.scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
return "Not found", 404
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
if run.status != AnsibleRunStatus.running:
|
||||||
|
yield f"event: done\ndata: {run.status.value}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
q = executor.register_listener(run_id)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await q.get()
|
||||||
|
if isinstance(msg, executor._Done):
|
||||||
|
yield f"event: done\ndata: {msg.status}\n\n"
|
||||||
|
break
|
||||||
|
# Escape newlines in SSE data field
|
||||||
|
safe = msg.replace("\n", " ")
|
||||||
|
yield f"event: output\ndata: {safe}\n\n"
|
||||||
|
finally:
|
||||||
|
executor.deregister_listener(run_id, q)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate(),
|
||||||
|
content_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ansible_bp.get("/runs/<run_id>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def run_detail(run_id: str):
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id))
|
||||||
|
run = result.scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
return "Not found", 404
|
||||||
|
return await render_template("ansible/run_detail.html", run=run)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Browse Playbooks — FabledNetMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||||
|
<h1 style="color:#c0c0ff;">Browse Playbooks</h1>
|
||||||
|
<a href="/ansible/" style="color:#a0a0ff;font-size:0.9rem;">← Run History</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if view_contents is defined %}
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
|
||||||
|
<h3 style="color:#8080ff;">{{ view_path }}</h3>
|
||||||
|
<a href="/ansible/browse" style="color:#a0a0ff;font-size:0.85rem;">← Back to browse</a>
|
||||||
|
</div>
|
||||||
|
<pre style="background:#0a0a1a;padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:#c0c0c0;max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not source_data %}
|
||||||
|
{% if view_contents is not defined %}
|
||||||
|
<div class="card">
|
||||||
|
<p style="color:#606080;">No Ansible sources configured. Add sources under <code>ansible.sources</code> in <code>config.yaml</code>.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% for sd in source_data %}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
|
||||||
|
<h3 style="color:#8080ff;">{{ sd.source.name }}</h3>
|
||||||
|
<span style="color:#404060;font-size:0.8rem;background:#12122a;padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span>
|
||||||
|
<span style="color:#505070;font-size:0.8rem;">{{ sd.source.path }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not sd.playbooks %}
|
||||||
|
<p style="color:#606080;font-size:0.9rem;">No playbooks found at this path.</p>
|
||||||
|
{% else %}
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin-bottom:1rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#12122a;">
|
||||||
|
<th style="padding:0.5rem 0.75rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.82rem;">Playbook</th>
|
||||||
|
<th style="padding:0.5rem 0.75rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.82rem;width:120px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pb in sd.playbooks %}
|
||||||
|
<tr style="border-top:1px solid #1a1a3a;">
|
||||||
|
<td style="padding:0.5rem 0.75rem;color:#c0c0e0;font-size:0.88rem;font-family:monospace;">{{ pb }}</td>
|
||||||
|
<td style="padding:0.5rem 0.75rem;">
|
||||||
|
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb }}" style="color:#6060c0;font-size:0.82rem;margin-right:0.75rem;">View</a>
|
||||||
|
<a href="#run-form-{{ sd.source.name }}"
|
||||||
|
onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='block';
|
||||||
|
document.getElementById('run-playbook-{{ sd.source.name }}').value='{{ pb }}';
|
||||||
|
document.getElementById('run-playbook-display-{{ sd.source.name }}').value='{{ pb }}';
|
||||||
|
return false;"
|
||||||
|
style="color:#a0a0ff;font-size:0.82rem;">Run</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{# Run trigger form for this source #}
|
||||||
|
<div id="run-form-{{ sd.source.name }}" style="display:none;background:#12122a;border-radius:4px;padding:1rem;border:1px solid #2a2a4a;">
|
||||||
|
<h4 style="color:#8080ff;margin-bottom:0.75rem;">Trigger Run</h4>
|
||||||
|
<form hx-post="/ansible/runs" hx-target="#run-result-{{ sd.source.name }}" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="source_name" value="{{ sd.source.name }}">
|
||||||
|
<input type="hidden" id="run-playbook-{{ sd.source.name }}" name="playbook_path" value="">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Playbook</label>
|
||||||
|
<input type="text" id="run-playbook-display-{{ sd.source.name }}"
|
||||||
|
readonly style="color:#8080a0;" value="">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Inventory</label>
|
||||||
|
<select name="inventory_path" style="width:100%;padding:0.5rem 0.75rem;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;font-size:1rem;">
|
||||||
|
{% for inv in sd.inventories %}
|
||||||
|
<option value="{{ inv }}">{{ inv }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="">— Enter manually below —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Or enter inventory path manually</label>
|
||||||
|
<input type="text" id="manual-inv-{{ sd.source.name }}" placeholder="inventories/production/hosts"
|
||||||
|
style="color:#c0c0e0;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;align-items:center;">
|
||||||
|
<button type="button" class="btn"
|
||||||
|
onclick="var manual=document.getElementById('manual-inv-{{ sd.source.name }}').value.trim();
|
||||||
|
if(manual){document.querySelector('[name=inventory_path]').value=manual;}
|
||||||
|
htmx.trigger(this.closest('form'),'submit');">Execute</button>
|
||||||
|
<a href="#" onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='none';return false;"
|
||||||
|
style="color:#606080;font-size:0.9rem;">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="run-result-{{ sd.source.name }}" style="margin-top:1rem;"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Ansible Runs — FabledNetMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||||
|
<h1 style="color:#c0c0ff;">Ansible Runs</h1>
|
||||||
|
<a href="/ansible/browse" class="btn">Browse Playbooks</a>
|
||||||
|
</div>
|
||||||
|
{% if not runs %}
|
||||||
|
<div class="card">
|
||||||
|
<p style="color:#606080;">No runs yet. <a href="/ansible/browse" style="color:#a0a0ff;">Browse playbooks</a> to trigger a run.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#12122a;">
|
||||||
|
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;">Playbook</th>
|
||||||
|
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;">Source</th>
|
||||||
|
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;">Status</th>
|
||||||
|
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;">Started</th>
|
||||||
|
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for run in runs %}
|
||||||
|
{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}[run.status.value] %}
|
||||||
|
<tr style="border-top:1px solid #2a2a4a;">
|
||||||
|
<td style="padding:0.75rem 1rem;color:#e0e0e0;font-size:0.9rem;">{{ run.playbook_path }}</td>
|
||||||
|
<td style="padding:0.75rem 1rem;color:#8080a0;font-size:0.9rem;">{{ run.source_name }}</td>
|
||||||
|
<td style="padding:0.75rem 1rem;">
|
||||||
|
<span style="color:{{ status_color }};font-size:0.85rem;font-weight:bold;">{{ run.status.value.upper() }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.75rem 1rem;color:#606080;font-size:0.85rem;">{{ run.started_at.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||||
|
<td style="padding:0.75rem 1rem;">
|
||||||
|
<a href="/ansible/runs/{{ run.id }}" style="color:#a0a0ff;font-size:0.85rem;">View</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Run {{ run.id[:8] }} — FabledNetMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
||||||
|
<a href="/ansible/" style="color:#6060c0;font-size:0.9rem;">← Runs</a>
|
||||||
|
<h1 style="color:#c0c0ff;">Run Detail</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}[run.status.value] %}
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
|
||||||
|
<span style="color:#606080;">ID</span><span style="color:#a0a0c0;font-family:monospace;">{{ run.id }}</span>
|
||||||
|
<span style="color:#606080;">Playbook</span><span style="color:#e0e0e0;">{{ run.playbook_path }}</span>
|
||||||
|
<span style="color:#606080;">Inventory</span><span style="color:#e0e0e0;">{{ run.inventory_path }}</span>
|
||||||
|
<span style="color:#606080;">Source</span><span style="color:#e0e0e0;">{{ run.source_name }}</span>
|
||||||
|
<span style="color:#606080;">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
|
||||||
|
<span style="color:#606080;">Started</span><span style="color:#a0a0c0;">{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
|
||||||
|
{% if run.finished_at %}
|
||||||
|
<span style="color:#606080;">Finished</span><span style="color:#a0a0c0;">{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:0;">
|
||||||
|
<div style="padding:0.75rem 1rem;background:#12122a;border-bottom:1px solid #2a2a4a;">
|
||||||
|
<span style="color:#a0a0c0;font-size:0.85rem;">Output</span>
|
||||||
|
</div>
|
||||||
|
{% if run.status.value == "running" %}
|
||||||
|
<pre id="live-output"
|
||||||
|
style="margin:0;padding:1rem;background:#080810;font-size:0.78rem;color:#a0c0a0;max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }}</pre>
|
||||||
|
<div id="run-done-status" style="padding:0.5rem 1rem;font-size:0.85rem;color:#606080;">
|
||||||
|
Streaming…
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
|
||||||
|
var pre = document.getElementById('live-output');
|
||||||
|
var status = document.getElementById('run-done-status');
|
||||||
|
es.addEventListener('output', function(e) {
|
||||||
|
pre.textContent += e.data + '\n';
|
||||||
|
pre.scrollTop = pre.scrollHeight;
|
||||||
|
});
|
||||||
|
es.addEventListener('done', function(e) {
|
||||||
|
es.close();
|
||||||
|
status.textContent = 'Finished: ' + e.data;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% else %}
|
||||||
|
<pre style="margin:0;padding:1rem;background:#080810;font-size:0.78rem;color:#a0c0a0;max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "(no output)" }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<div style="background:#0f0f1e;border:1px solid #2a2a4a;border-radius:6px;padding:1rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:0.75rem;">
|
||||||
|
<span style="color:#a0a000;font-weight:bold;">RUNNING</span>
|
||||||
|
<span style="color:#a0a0c0;font-size:0.9rem;">{{ run.playbook_path }}</span>
|
||||||
|
<a href="/ansible/runs/{{ run.id }}" style="color:#6060c0;font-size:0.82rem;margin-left:auto;">Full view →</a>
|
||||||
|
</div>
|
||||||
|
<div hx-ext="sse" sse-connect="/ansible/runs/{{ run.id }}/stream">
|
||||||
|
<pre id="sse-output-{{ run.id }}"
|
||||||
|
style="background:#080810;padding:0.75rem;border-radius:4px;font-family:monospace;font-size:0.78rem;color:#a0c0a0;max-height:400px;overflow-y:auto;white-space:pre-wrap;margin:0;"
|
||||||
|
sse-swap="output"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div id="sse-status-{{ run.id }}" style="margin-top:0.5rem;font-size:0.85rem;color:#606080;">
|
||||||
|
Streaming output…
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
|
||||||
|
var pre = document.getElementById('sse-output-{{ run.id }}');
|
||||||
|
var status = document.getElementById('sse-status-{{ run.id }}');
|
||||||
|
es.addEventListener('output', function(e) {
|
||||||
|
pre.textContent += e.data + '\n';
|
||||||
|
pre.scrollTop = pre.scrollHeight;
|
||||||
|
});
|
||||||
|
es.addEventListener('done', function(e) {
|
||||||
|
es.close();
|
||||||
|
status.textContent = 'Finished: ' + e.data;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user