feat: F1 — panel admin web (FastAPI, solo 127.0.0.1, X-Admin-Token, SSH tunnel)

This commit is contained in:
markosbenitez
2026-05-30 01:37:14 -03:00
parent 61a15c5c50
commit 8bd1a0dd5b
12 changed files with 552 additions and 0 deletions

View 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
View 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>

View 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 %}

View 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 %}