feat: F1 — panel admin web (FastAPI, solo 127.0.0.1, X-Admin-Token, SSH tunnel)
This commit is contained in:
0
admin/__init__.py
Normal file
0
admin/__init__.py
Normal file
159
admin/app.py
Normal file
159
admin/app.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
admin/app.py — Panel de administración interno (FastAPI).
|
||||
|
||||
Solo escucha en 127.0.0.1. Acceso via SSH tunnel:
|
||||
ssh -L 9090:127.0.0.1:9090 pedrito@<IP_VPS>
|
||||
Luego: http://localhost:9090
|
||||
|
||||
Autenticación: header X-Admin-Token en cada request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import Depends, FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from admin.auth import require_token
|
||||
|
||||
_db_path: str = ""
|
||||
_admin_token: str = ""
|
||||
_data_dir: Path = Path("./data")
|
||||
|
||||
templates = Jinja2Templates(directory="admin/templates")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
app.state.db = await aiosqlite.connect(_db_path)
|
||||
app.state.db.row_factory = aiosqlite.Row
|
||||
yield
|
||||
await app.state.db.close()
|
||||
|
||||
|
||||
def create_app(db_path: str, admin_token: str, data_dir: Path) -> FastAPI:
|
||||
global _db_path, _admin_token, _data_dir
|
||||
_db_path = db_path
|
||||
_admin_token = admin_token
|
||||
_data_dir = data_dir
|
||||
|
||||
app = FastAPI(lifespan=_lifespan, docs_url=None, redoc_url=None)
|
||||
|
||||
auth = Depends(require_token(admin_token))
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _mask_cedula(credentials_enc: str | None) -> str:
|
||||
"""Muestra '***' — la cédula está cifrada y no se expone en el panel."""
|
||||
if not credentials_enc:
|
||||
return "—"
|
||||
return "***"
|
||||
|
||||
def _parse_detalle(detalle_json: str | None) -> str:
|
||||
if not detalle_json:
|
||||
return ""
|
||||
try:
|
||||
return json.dumps(json.loads(detalle_json), ensure_ascii=False, indent=None)
|
||||
except Exception:
|
||||
return detalle_json or ""
|
||||
|
||||
# ── rutas ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return RedirectResponse(url="/tenants")
|
||||
|
||||
@app.get("/tenants", response_class=HTMLResponse, dependencies=[auth])
|
||||
async def lista_tenants(request: Request):
|
||||
db: aiosqlite.Connection = request.app.state.db
|
||||
async with db.execute("SELECT * FROM tenants ORDER BY created_at DESC") as cur:
|
||||
rows = [dict(r) for r in await cur.fetchall()]
|
||||
for r in rows:
|
||||
r["cedula_masked"] = _mask_cedula(r.get("credentials_enc"))
|
||||
r.pop("credentials_enc", None)
|
||||
return templates.TemplateResponse("tenants.html", {"request": request, "tenants": rows})
|
||||
|
||||
@app.get("/tenants/{chat_id}", response_class=HTMLResponse, dependencies=[auth])
|
||||
async def detalle_tenant(request: Request, chat_id: int):
|
||||
db: aiosqlite.Connection = request.app.state.db
|
||||
async with db.execute("SELECT * FROM tenants WHERE chat_id = ?", (chat_id,)) as cur:
|
||||
row = await cur.fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h1>Tenant no encontrado</h1>", status_code=404)
|
||||
tenant: dict[str, Any] = dict(row)
|
||||
tenant["cedula_masked"] = _mask_cedula(tenant.get("credentials_enc"))
|
||||
tenant.pop("credentials_enc", None)
|
||||
|
||||
async with db.execute(
|
||||
"SELECT * FROM audit_log WHERE chat_id = ? ORDER BY timestamp DESC LIMIT 20",
|
||||
(chat_id,),
|
||||
) as cur:
|
||||
logs = [dict(r) for r in await cur.fetchall()]
|
||||
for entry in logs:
|
||||
entry["detalle_str"] = _parse_detalle(entry.get("detalle"))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenant.html", {"request": request, "tenant": tenant, "logs": logs}
|
||||
)
|
||||
|
||||
@app.post("/tenants/{chat_id}/resetear", dependencies=[auth])
|
||||
async def resetear_tenant(request: Request, chat_id: int):
|
||||
db: aiosqlite.Connection = request.app.state.db
|
||||
await db.execute("DELETE FROM tenants WHERE chat_id = ?", (chat_id,))
|
||||
await db.execute("DELETE FROM audit_log WHERE chat_id = ?", (chat_id,))
|
||||
await db.commit()
|
||||
snapshot = _data_dir / f"snapshot_{chat_id}.json"
|
||||
if snapshot.exists():
|
||||
snapshot.unlink()
|
||||
return RedirectResponse(url="/tenants", status_code=303)
|
||||
|
||||
@app.get("/audit", response_class=HTMLResponse, dependencies=[auth])
|
||||
async def audit_log(request: Request, chat_id: str = "", page: int = 1):
|
||||
db: aiosqlite.Connection = request.app.state.db
|
||||
limit = 200
|
||||
offset = (page - 1) * limit
|
||||
if chat_id.strip():
|
||||
try:
|
||||
cid = int(chat_id.strip())
|
||||
async with db.execute(
|
||||
"SELECT * FROM audit_log WHERE chat_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
(cid, limit, offset),
|
||||
) as cur:
|
||||
logs = [dict(r) for r in await cur.fetchall()]
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM audit_log WHERE chat_id = ?", (cid,)
|
||||
) as cur:
|
||||
total = (await cur.fetchone())[0]
|
||||
except ValueError:
|
||||
logs, total = [], 0
|
||||
else:
|
||||
async with db.execute(
|
||||
"SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
) as cur:
|
||||
logs = [dict(r) for r in await cur.fetchall()]
|
||||
async with db.execute("SELECT COUNT(*) FROM audit_log") as cur:
|
||||
total = (await cur.fetchone())[0]
|
||||
|
||||
for entry in logs:
|
||||
entry["detalle_str"] = _parse_detalle(entry.get("detalle"))
|
||||
|
||||
has_more = (offset + limit) < total
|
||||
return templates.TemplateResponse(
|
||||
"audit.html",
|
||||
{
|
||||
"request": request,
|
||||
"logs": logs,
|
||||
"chat_id_filter": chat_id,
|
||||
"page": page,
|
||||
"has_more": has_more,
|
||||
"total": total,
|
||||
},
|
||||
)
|
||||
|
||||
return app
|
||||
22
admin/auth.py
Normal file
22
admin/auth.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
admin/auth.py — Autenticación del panel de administración.
|
||||
|
||||
Token estático via header X-Admin-Token.
|
||||
Si ADMIN_TOKEN no está configurado, el panel rechaza todos los requests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
def require_token(token_esperado: str):
|
||||
"""Devuelve una dependencia FastAPI que valida X-Admin-Token."""
|
||||
async def _check(request: Request) -> None:
|
||||
if not token_esperado:
|
||||
raise HTTPException(status_code=403, detail="Panel no configurado: falta ADMIN_TOKEN")
|
||||
token = request.headers.get("X-Admin-Token", "")
|
||||
if not secrets.compare_digest(token, token_esperado):
|
||||
raise HTTPException(status_code=403, detail="Token inválido")
|
||||
return _check
|
||||
50
admin/templates/audit.html
Normal file
50
admin/templates/audit.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Audit log{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Audit log ({{ total }} registros)</h1>
|
||||
|
||||
<form class="filter-bar" method="get" action="/audit">
|
||||
<input type="text" name="chat_id" value="{{ chat_id_filter }}" placeholder="Filtrar por chat_id">
|
||||
<button type="submit" class="btn btn-primary">Filtrar</button>
|
||||
{% if chat_id_filter %}<a href="/audit" class="btn">Limpiar</a>{% endif %}
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp (UTC)</th>
|
||||
<th>chat_id</th>
|
||||
<th>Evento</th>
|
||||
<th>Resultado</th>
|
||||
<th>Detalle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in logs %}
|
||||
<tr>
|
||||
<td style="white-space:nowrap">{{ (entry.timestamp or "")[:19] }}</td>
|
||||
<td>
|
||||
{% if entry.chat_id %}
|
||||
<a href="/tenants/{{ entry.chat_id }}">{{ entry.chat_id }}</a>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td><code>{{ entry.evento }}</code></td>
|
||||
<td>{{ entry.resultado or "—" }}</td>
|
||||
<td style="max-width:350px;word-break:break-all"><code>{{ entry.detalle_str }}</code></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" style="text-align:center;color:#999">Sin registros</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/audit?chat_id={{ chat_id_filter }}&page={{ page - 1 }}" class="btn">← Más recientes</a>
|
||||
{% endif %}
|
||||
<span>Página {{ page }}</span>
|
||||
{% if has_more %}
|
||||
<a href="/audit?chat_id={{ chat_id_filter }}&page={{ page + 1 }}" class="btn">Más antiguos →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
48
admin/templates/base.html
Normal file
48
admin/templates/base.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pedrito Admin{% block title %}{% endblock %}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
|
||||
nav { background: #1a1a2e; color: #fff; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; }
|
||||
nav strong { font-size: 1.1rem; color: #e2e2ff; }
|
||||
nav a { color: #adb5ff; text-decoration: none; font-size: 0.95rem; }
|
||||
nav a:hover { color: #fff; }
|
||||
main { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
h1 { font-size: 1.4rem; margin-bottom: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
||||
th { background: #e8e8f0; text-align: left; padding: 0.6rem 0.8rem; font-size: 0.85rem; color: #555; }
|
||||
td { padding: 0.55rem 0.8rem; border-top: 1px solid #eee; font-size: 0.9rem; vertical-align: top; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
.badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 3px; font-size: 0.8rem; }
|
||||
.badge-activo { background: #d4edda; color: #155724; }
|
||||
.badge-pendiente { background: #fff3cd; color: #856404; }
|
||||
.badge-suspendido { background: #f8d7da; color: #721c24; }
|
||||
.btn { display: inline-block; padding: 0.35rem 0.75rem; border-radius: 4px; font-size: 0.85rem; text-decoration: none; border: none; cursor: pointer; }
|
||||
.btn-primary { background: #4a6cf7; color: #fff; }
|
||||
.btn-danger { background: #dc3545; color: #fff; }
|
||||
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
|
||||
form { display: inline; }
|
||||
code { font-size: 0.8rem; background: #f0f0f0; padding: 1px 4px; border-radius: 3px; }
|
||||
.detail-grid { display: grid; grid-template-columns: 180px 1fr; gap: 0.4rem 1rem; background: #fff; padding: 1rem 1.2rem; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.1); margin-bottom: 1.5rem; }
|
||||
.detail-grid dt { font-weight: 600; font-size: 0.85rem; color: #666; }
|
||||
.detail-grid dd { margin: 0; font-size: 0.9rem; }
|
||||
.filter-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
|
||||
.filter-bar input { padding: 0.4rem 0.7rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.9rem; }
|
||||
.pagination { margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center; font-size: 0.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<strong>⚖️ Pedrito Admin</strong>
|
||||
<a href="/tenants">Tenants</a>
|
||||
<a href="/audit">Audit log</a>
|
||||
</nav>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
47
admin/templates/tenant.html
Normal file
47
admin/templates/tenant.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Tenant {{ tenant.chat_id }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Tenant {{ tenant.chat_id }}</h1>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<dt>chat_id</dt> <dd>{{ tenant.chat_id }}</dd>
|
||||
<dt>Nombre</dt> <dd>{{ tenant.nombre_preferido or "—" }}</dd>
|
||||
<dt>Estado</dt> <dd><span class="badge badge-{{ tenant.estado or 'pendiente' }}">{{ tenant.estado or "pendiente" }}</span></dd>
|
||||
<dt>Tono</dt> <dd>{{ tenant.tono or "—" }}</dd>
|
||||
<dt>Timezone</dt> <dd>{{ tenant.timezone or "—" }}</dd>
|
||||
<dt>Cédula</dt> <dd><code>{{ tenant.cedula_masked }}</code></dd>
|
||||
<dt>Creado</dt> <dd>{{ (tenant.created_at or "")[:19] }}</dd>
|
||||
<dt>Último activo</dt><dd>{{ (tenant.last_active or "")[:19] }}</dd>
|
||||
</dl>
|
||||
|
||||
<form method="post" action="/tenants/{{ tenant.chat_id }}/resetear"
|
||||
onsubmit="return confirm('¿Resetear tenant {{ tenant.chat_id }}? Esta acción borra el tenant y su snapshot.')">
|
||||
<button type="submit" class="btn btn-danger">Resetear tenant</button>
|
||||
</form>
|
||||
|
||||
<h2 style="margin-top:2rem;font-size:1.1rem">Últimas 20 entradas del audit log</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Evento</th>
|
||||
<th>Resultado</th>
|
||||
<th>Detalle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in logs %}
|
||||
<tr>
|
||||
<td style="white-space:nowrap">{{ (entry.timestamp or "")[:19] }}</td>
|
||||
<td><code>{{ entry.evento }}</code></td>
|
||||
<td>{{ entry.resultado or "—" }}</td>
|
||||
<td style="max-width:400px;word-break:break-all"><code>{{ entry.detalle_str }}</code></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" style="text-align:center;color:#999">Sin registros</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:1rem"><a href="/tenants">← Volver a tenants</a></p>
|
||||
{% endblock %}
|
||||
41
admin/templates/tenants.html
Normal file
41
admin/templates/tenants.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Tenants{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Tenants ({{ tenants|length }})</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>chat_id</th>
|
||||
<th>Nombre</th>
|
||||
<th>Estado</th>
|
||||
<th>Tono</th>
|
||||
<th>Timezone</th>
|
||||
<th>Cédula</th>
|
||||
<th>Último activo</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in tenants %}
|
||||
<tr>
|
||||
<td><a href="/tenants/{{ t.chat_id }}">{{ t.chat_id }}</a></td>
|
||||
<td>{{ t.nombre_preferido or "—" }}</td>
|
||||
<td><span class="badge badge-{{ t.estado or 'pendiente' }}">{{ t.estado or "pendiente" }}</span></td>
|
||||
<td>{{ t.tono or "—" }}</td>
|
||||
<td>{{ t.timezone or "—" }}</td>
|
||||
<td><code>{{ t.cedula_masked }}</code></td>
|
||||
<td>{{ (t.last_active or "")[:19] }}</td>
|
||||
<td>
|
||||
<a href="/tenants/{{ t.chat_id }}" class="btn btn-primary btn-sm">Ver</a>
|
||||
<form method="post" action="/tenants/{{ t.chat_id }}/resetear"
|
||||
onsubmit="return confirm('¿Resetear tenant {{ t.chat_id }}? Esta acción borra el tenant y su snapshot.')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Resetear</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" style="text-align:center;color:#999">Sin tenants registrados</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user