First commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[theme]
|
||||
# Color primario — verde TIP (botones, checkboxes, sliders, links activos)
|
||||
primaryColor = "#27500A"
|
||||
|
||||
# Fondo de la app
|
||||
backgroundColor = "#0e1117"
|
||||
|
||||
# Fondo de inputs, sidebars, expanders
|
||||
secondaryBackgroundColor = "#1a1a1a"
|
||||
|
||||
# Color del texto principal
|
||||
textColor = "#fafafa"
|
||||
|
||||
# Fuente
|
||||
font = "sans serif"
|
||||
@@ -0,0 +1,11 @@
|
||||
# .streamlit/secrets.toml
|
||||
# Configuración de la API
|
||||
[api]
|
||||
base_url = "http://localhost:8000"
|
||||
timeout = 30
|
||||
|
||||
# Configuración de la app
|
||||
[app]
|
||||
name = "Sistema de Inteligencia de Amenazas"
|
||||
version = "1.0.0"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import streamlit as st
|
||||
from src.auth import is_authenticated, mostrar_pantalla_login
|
||||
import os
|
||||
|
||||
st.set_page_config(
|
||||
page_title="TIP — Threat Intelligence Platform",
|
||||
page_icon="🛡️",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded"
|
||||
)
|
||||
|
||||
def main():
|
||||
if not is_authenticated():
|
||||
if st.session_state.get("show_help_before_login"):
|
||||
from src.pages.views.help import show_documentation
|
||||
show_documentation()
|
||||
if st.button("Volver al login", key="back_to_login"):
|
||||
st.session_state.pop("show_help_before_login", None)
|
||||
st.session_state.tip_tab = "login"
|
||||
st.rerun()
|
||||
else:
|
||||
mostrar_pantalla_login(str(os.getenv('USERS_URL')))
|
||||
else:
|
||||
from src.pages.dashboard import mostrar_dashboard
|
||||
mostrar_dashboard()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,439 @@
|
||||
# src/api.py
|
||||
import requests
|
||||
import streamlit as st
|
||||
from typing import List, Optional, Dict, Any
|
||||
import os
|
||||
|
||||
|
||||
class APIClient:
|
||||
"""Cliente simple para interactuar con la API"""
|
||||
|
||||
def __init__(self, user_url: str, feeder_url: str, backup_url: str):
|
||||
self.user_url = user_url
|
||||
self.feeder_url = feeder_url
|
||||
self.backup_url = backup_url
|
||||
|
||||
def _get_headers(self) -> Optional[Dict[str, str]]:
|
||||
if "token" not in st.session_state:
|
||||
return None
|
||||
return {
|
||||
"Authorization": f"Bearer {st.session_state['token']}",
|
||||
"accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def _request(self, method: str, url: str, **kwargs) -> Optional[Any]:
|
||||
"""Realiza petición HTTP con manejo común de errores y headers.
|
||||
|
||||
Args:
|
||||
method: 'get', 'post', 'put', 'delete'
|
||||
url: URL completa
|
||||
**kwargs: parámetros (json, params, etc.) para requests
|
||||
"""
|
||||
headers = self._get_headers()
|
||||
if headers is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
func = getattr(requests, method)
|
||||
response = func(url, headers=headers, timeout=10, **kwargs)
|
||||
|
||||
if response.status_code in (200, 201, 204):
|
||||
if response.content:
|
||||
return response.json()
|
||||
return {}
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Error en {method.upper()} {url}: {str(e)}")
|
||||
return None
|
||||
|
||||
## Verificaciones ##
|
||||
|
||||
def has_connection(self):
|
||||
"""Verifica la conexión con la API de Telegram (Para funcionamiento del feeder)"""
|
||||
try:
|
||||
response = requests.get(f"{self.feeder_url}/manage/connection-status", timeout=5)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
## USERS ##
|
||||
def login(self, email: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""Realiza login en la API"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.user_url}/api/v1/login",
|
||||
json={"email": email, "password": password},
|
||||
timeout=10
|
||||
)
|
||||
return response.json() if response.status_code == 200 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_pending_users(self, skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.user_url}/api/v1/users/pending",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def activate_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.user_url}/api/v1/users/{user_id}/activate"
|
||||
)
|
||||
|
||||
def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"delete",
|
||||
f"{self.user_url}/api/v1/users/{user_id}"
|
||||
)
|
||||
|
||||
def update_user(self, user_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"put",
|
||||
f"{self.user_url}/api/v1/users/{user_id}",
|
||||
json=payload
|
||||
)
|
||||
|
||||
def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.user_url}/api/v1/users/{user_id}"
|
||||
)
|
||||
|
||||
def search_users(self, q: str = "", skip: int = 0, limit: int = 50) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.user_url}/api/v1/users",
|
||||
params={"q": q, "skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def search_messages(self, q: str = "", group_id=None, date_from=None, date_to=None, skip: int = 0, limit: int = 100):
|
||||
if not q.strip() and not group_id and not date_from and not date_to:
|
||||
return self._request("get", f"{self.feeder_url}/messages/",
|
||||
params={"skip": skip, "limit": limit})
|
||||
params = {"q": q, "skip": skip, "limit": limit}
|
||||
if group_id:
|
||||
params["group_id"] = group_id
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
return self._request("get", f"{self.feeder_url}/messages/search/", params=params)
|
||||
|
||||
def get_user_modifications(self, user_id: Optional[int] = None, skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.user_url}/api/v1/logs/users",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
### FEEDER ###
|
||||
## Alerts ##
|
||||
def get_alerts(self, skip: int = 0, limit: int = 100, status=None, severity=None, date_from=None, date_to=None):
|
||||
params = {"skip": skip, "limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if severity:
|
||||
params["severity"] = severity
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
return self._request("get", f"{self.feeder_url}/alerts/", params=params)
|
||||
|
||||
def get_message(self, group_id: int, message_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/groups/{group_id}/messages/{message_id}"
|
||||
)
|
||||
|
||||
def add_note_to_alert(self, alert_id: int, user_id: int, content: str) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {"alert_id": alert_id,"user_id": user_id, "content": f"{content}"}
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/notes/",
|
||||
json=payload
|
||||
)
|
||||
|
||||
def get_notes_for_alert(self, alert_id: int) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/alerts/{alert_id}/notes"
|
||||
)
|
||||
|
||||
def mark_alert_as_resolved(self, alert_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/alerts/{alert_id}/resolve"
|
||||
)
|
||||
|
||||
def mark_alert_as_pending(self, alert_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/alerts/{alert_id}/reopen"
|
||||
)
|
||||
|
||||
def set_alert_in_progress(self, alert_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/alerts/{alert_id}/in-progress"
|
||||
)
|
||||
|
||||
## Groups ##
|
||||
def get_groups(self) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/groups/"
|
||||
)
|
||||
|
||||
def get_group(self, group_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/groups/{group_id}"
|
||||
)
|
||||
|
||||
|
||||
def add_channel(self, channel_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/manage/",
|
||||
params={"channel": channel_id}
|
||||
)
|
||||
|
||||
## Telegram Session ##
|
||||
def telegram_status(self) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/telegram-status"
|
||||
)
|
||||
|
||||
def init_session_telegram(self) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/manage/init-session"
|
||||
)
|
||||
|
||||
def verify_code(self, flow_id: str, code: str, password: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {"flow_id": flow_id, "code": code}
|
||||
if password:
|
||||
payload["password"] = password
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/manage/verify-code",
|
||||
json=payload
|
||||
)
|
||||
|
||||
## Rules ##
|
||||
def get_rules(self, skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/rules/",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def get_rule(self, rule_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/rules/{rule_id}"
|
||||
)
|
||||
|
||||
def search_rules(self, q: str = "", skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/rules/search",
|
||||
params={"q": q, "skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def create_rule(self, rule_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/rules/",
|
||||
json=rule_data
|
||||
)
|
||||
|
||||
def update_rule(self, rule_id: int, rule_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"put",
|
||||
f"{self.feeder_url}/rules/{rule_id}",
|
||||
json=rule_data
|
||||
)
|
||||
|
||||
##Attachment
|
||||
def get_attachment(self, attachment_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Obtiene los metadatos de un adjunto por su ID."""
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/attachments/{attachment_id}"
|
||||
)
|
||||
|
||||
def download_attachment(self, attachment_id: int) -> Optional[bytes]:
|
||||
"""
|
||||
Descarga el contenido binario de un adjunto directamente desde Telegram vía la API.
|
||||
Devuelve los bytes del archivo, o None si falla.
|
||||
"""
|
||||
headers = self._get_headers()
|
||||
if headers is None:
|
||||
return None
|
||||
# El endpoint de descarga no requiere Content-Type JSON
|
||||
download_headers = {
|
||||
"Authorization": headers["Authorization"],
|
||||
"accept": "*/*"
|
||||
}
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.feeder_url}/attachments/{attachment_id}/download",
|
||||
headers=download_headers,
|
||||
timeout=(10, 600) # tiempo de espera más alto para archivos grandes
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
except Exception as e:
|
||||
st.error(f"Error al descargar adjunto {attachment_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_attachments_by_message(self, message_id: int, group_id: int) -> Optional[list]:
|
||||
"""
|
||||
Obtiene los adjuntos asociados a un mensaje específico.
|
||||
Filtra desde el listado general de adjuntos por message_id y group_id.
|
||||
Nota: si en el futuro la API expone un endpoint dedicado, reemplazar esta implementación.
|
||||
"""
|
||||
# Por ahora los adjuntos vienen embebidos en el mensaje via MessageResponse.attachments
|
||||
# Este método es un helper para obtenerlos si solo tenemos los IDs
|
||||
message = self.get_message(group_id, message_id)
|
||||
if message is None:
|
||||
return None
|
||||
return message.get("attachments", [])
|
||||
|
||||
## Audit ##
|
||||
def get_audit_logs(
|
||||
self,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
date_from=None,
|
||||
date_to=None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
params = {"skip": skip, "limit": limit}
|
||||
if entity_type:
|
||||
params["entity_type"] = entity_type
|
||||
if entity_id:
|
||||
params["entity_id"] = entity_id
|
||||
if action:
|
||||
params["action"] = action
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
return self._request("get", f"{self.feeder_url}/audit/", params=params)
|
||||
|
||||
## Stats ##
|
||||
def get_stats(
|
||||
self,
|
||||
date_from: str = None,
|
||||
date_to: str = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Obtiene todas las estadísticas agregadas del sistema."""
|
||||
params = {}
|
||||
if date_from: params["date_from"] = date_from
|
||||
if date_to: params["date_to"] = date_to
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/stats/",
|
||||
params=params
|
||||
)
|
||||
|
||||
## Senders ##
|
||||
def get_senders(self, skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Obtiene la lista de remitentes cargados por el feeder."""
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/senders/",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def get_sender(self, sender_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Obtiene un remitente por su ID de Telegram."""
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/senders/{sender_id}"
|
||||
)
|
||||
|
||||
def get_messages_by_sender(
|
||||
self,
|
||||
sender_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Obtiene todos los mensajes enviados por un sender específico."""
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/senders/{sender_id}/messages/",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
|
||||
## Backup ##
|
||||
def get_backup_status(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Consulta el estado de disponibilidad de cada componente de backup
|
||||
sin generar el archivo.
|
||||
"""
|
||||
if not self.backup_url:
|
||||
return None
|
||||
return self._request("get", f"{self.backup_url}/backup/status")
|
||||
|
||||
def download_backup(
|
||||
self,
|
||||
include_db: bool = True,
|
||||
include_config: bool = True,
|
||||
include_ssl: bool = True,
|
||||
include_sessions: bool = True,
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Llama al endpoint /backup del microservicio y devuelve los bytes del ZIP.
|
||||
Usa un timeout largo (10 min) porque mysqldump puede tardar.
|
||||
"""
|
||||
if not self.backup_url:
|
||||
return None
|
||||
|
||||
headers = self._get_headers()
|
||||
if headers is None:
|
||||
return None
|
||||
|
||||
dl_headers = {
|
||||
"Authorization": headers["Authorization"],
|
||||
"accept": "application/zip",
|
||||
}
|
||||
params = {
|
||||
"include_db": str(include_db).lower(),
|
||||
"include_config": str(include_config).lower(),
|
||||
"include_ssl": str(include_ssl).lower(),
|
||||
"include_sessions": str(include_sessions).lower(),
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.backup_url}/backup",
|
||||
headers=dl_headers,
|
||||
params=params,
|
||||
timeout=(15, 600), # (connect, read) — dump puede tardar
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
except requests.exceptions.Timeout:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Crear instancia global
|
||||
api_client = APIClient(str(os.getenv('USERS_URL')),str(os.getenv('FEEDER_URL')), str(os.getenv('BACKUP_URL')))
|
||||
@@ -0,0 +1,346 @@
|
||||
import streamlit as st
|
||||
import streamlit.components.v1 as components
|
||||
import functools
|
||||
import requests
|
||||
from typing import Callable
|
||||
import os
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoradores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def requiere_login(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_authenticated():
|
||||
mostrar_pantalla_login(str(os.getenv('USERS_URL', '')))
|
||||
st.stop()
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def requiere_rol(roles_permitidos: list[str]):
|
||||
def decorador(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
@requiere_login
|
||||
def wrapper(*args, **kwargs):
|
||||
rol_usuario = get_user_role()
|
||||
if rol_usuario not in roles_permitidos:
|
||||
st.error(f"Acceso denegado. Se requiere: {', '.join(roles_permitidos)}")
|
||||
st.info(f"Tu rol actual: {rol_usuario}")
|
||||
st.stop()
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorador
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers de sesión
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
return "token" in st.session_state and "usuario" in st.session_state
|
||||
|
||||
def get_user_info() -> dict:
|
||||
return st.session_state.get("usuario", {})
|
||||
|
||||
def get_user_role() -> str:
|
||||
return st.session_state.get("usuario", {}).get("rol", "invitado")
|
||||
|
||||
def get_auth_token() -> str:
|
||||
return st.session_state.get("token", "")
|
||||
|
||||
def logout():
|
||||
for key in ["token", "usuario", "token_type", "show_register",
|
||||
"registration_success", "tip_tab"]:
|
||||
st.session_state.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Solo el panel izquierdo en iframe — sin inputs ni botones
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LEFT_PANEL_HTML = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
body { background: #27500A; border-radius: 12px; padding: 40px 36px; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.logo-box { width: 38px; height: 38px; border-radius: 10px; background: rgba(255,255,255,0.15); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.brand-name { font-size: 18px; font-weight: 600; color: white; line-height: 1.1; }
|
||||
.brand-sub { font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: 0.08em; }
|
||||
.copy { flex: 1; padding: 32px 0 0; }
|
||||
.tagline { font-size: 22px; font-weight: 500; color: white; line-height: 1.4; margin-bottom: 14px; }
|
||||
.tagline span { color: #C0DD97; }
|
||||
.desc { font-size: 13px; color: rgba(255,255,255,0.55); line-height: 1.7; margin-bottom: 22px; }
|
||||
.features { display: flex; flex-direction: column; gap: 12px; }
|
||||
.feature { display: flex; align-items: center; gap: 10px; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: #C0DD97; flex-shrink: 0; }
|
||||
.feature span { font-size: 12px; color: rgba(255,255,255,0.65); }
|
||||
.footer { font-size: 11px; color: rgba(255,255,255,0.28); padding-top: 28px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brand">
|
||||
<div class="logo-box">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="8" stroke="rgba(255,255,255,0.7)" stroke-width="1.5"/>
|
||||
<path d="M7 10l2.5 2.5L14 7" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-name">TIP</div>
|
||||
<div class="brand-sub">THREAT INTELLIGENCE PLATFORM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copy">
|
||||
<div class="tagline">Monitoreo de inteligencia<br><span>en tiempo real</span></div>
|
||||
<div class="desc">Plataforma interna para el seguimiento, análisis y gestión de amenazas detectadas en canales de Telegram.</div>
|
||||
<div class="features">
|
||||
<div class="feature"><div class="dot"></div><span>Detección automática por reglas y regex</span></div>
|
||||
<div class="feature"><div class="dot"></div><span>Alertas con trazabilidad completa</span></div>
|
||||
<div class="feature"><div class="dot"></div><span>Auditoría de todas las acciones del sistema</span></div>
|
||||
<div class="feature"><div class="dot"></div><span>Exportación de reportes PDF</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">Municipio de Paraná · Uso interno</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS para estilizar los formularios nativos de Streamlit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FORM_CSS = """
|
||||
<style>
|
||||
[data-testid="stSidebar"] { display: none; }
|
||||
[data-testid="stHeader"] { display: none; }
|
||||
.block-container { padding: 8vh 2rem 0 !important; max-width: 100% !important; }
|
||||
|
||||
/* ---- Tabs: unidad visual conectada ---- */
|
||||
/* Tab activo */
|
||||
div[data-testid="column"]:nth-child(1) button[kind="primary"],
|
||||
div[data-testid="column"]:nth-child(2) button[kind="primary"] {
|
||||
background-color: #27500A !important;
|
||||
border: 0.5px solid #27500A !important;
|
||||
color: #fff !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
/* Tab inactivo */
|
||||
div[data-testid="column"]:nth-child(1) button[kind="secondary"],
|
||||
div[data-testid="column"]:nth-child(2) button[kind="secondary"] {
|
||||
background-color: #1a1a1a !important;
|
||||
border: 0.5px solid rgba(255,255,255,0.15) !important;
|
||||
color: rgba(255,255,255,0.5) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
div[data-testid="column"]:nth-child(1) button[kind="secondary"]:hover,
|
||||
div[data-testid="column"]:nth-child(2) button[kind="secondary"]:hover {
|
||||
color: rgba(255,255,255,0.85) !important;
|
||||
background-color: rgba(255,255,255,0.05) !important;
|
||||
}
|
||||
/* Redondear extremos como una sola unidad */
|
||||
div[data-testid="column"]:nth-child(1) button {
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
div[data-testid="column"]:nth-child(2) button {
|
||||
border-radius: 0 6px 6px 0 !important;
|
||||
}
|
||||
|
||||
/* ---- Botón link "Registrarse / Ingresar" ---- */
|
||||
div[data-testid="stButton"]:not(div[data-testid="stFormSubmitButton"]) button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: #3B6D11 !important;
|
||||
font-size: 12px !important;
|
||||
padding: 0 !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
div[data-testid="stButton"]:not(div[data-testid="stFormSubmitButton"]) button:hover {
|
||||
color: #639922 !important;
|
||||
}
|
||||
|
||||
/* ---- Botón submit del form ---- */
|
||||
div[data-testid="stFormSubmitButton"] button {
|
||||
width: 100% !important;
|
||||
height: 38px !important;
|
||||
background-color: #27500A !important;
|
||||
border: none !important;
|
||||
border-radius: 6px !important;
|
||||
color: #fff !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
div[data-testid="stFormSubmitButton"] button:hover {
|
||||
background-color: #3B6D11 !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pantalla de login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mostrar_pantalla_login(user_url: str):
|
||||
if "tip_tab" not in st.session_state:
|
||||
st.session_state.tip_tab = "login"
|
||||
|
||||
if st.session_state.get("registration_success"):
|
||||
st.session_state.tip_tab = "login"
|
||||
st.session_state.registration_success = False
|
||||
|
||||
st.markdown(FORM_CSS, unsafe_allow_html=True)
|
||||
|
||||
# Centrar verticalmente en la pantalla
|
||||
_, center, _ = st.columns([0.5, 9, 0.5])
|
||||
with center:
|
||||
col_left, col_right = st.columns(2, gap="small")
|
||||
|
||||
# Panel izquierdo — iframe con fondo verde
|
||||
with col_left:
|
||||
components.html(LEFT_PANEL_HTML, height=560, scrolling=False)
|
||||
|
||||
# Panel derecho — formularios 100% nativos de Streamlit
|
||||
with col_right:
|
||||
st.markdown("<div style='padding: 40px 16px 0;'>", unsafe_allow_html=True)
|
||||
|
||||
if st.session_state.tip_tab == "login":
|
||||
_form_login(user_url)
|
||||
else:
|
||||
_form_registro(user_url)
|
||||
|
||||
st.markdown("</div>", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _form_login(user_url: str):
|
||||
st.subheader("Bienvenido")
|
||||
st.caption("Ingresá con tu cuenta institucional")
|
||||
|
||||
with st.form("tip_login_form", clear_on_submit=False):
|
||||
email = st.text_input("Correo electrónico", placeholder="Ingresá tu correo")
|
||||
password = st.text_input("Contraseña", type="password", placeholder="••••••••")
|
||||
submitted = st.form_submit_button(
|
||||
"Ingresar al sistema",
|
||||
use_container_width=True,
|
||||
type="primary"
|
||||
)
|
||||
|
||||
if submitted:
|
||||
if not email or not password:
|
||||
st.warning("Completá ambos campos para continuar.")
|
||||
return
|
||||
with st.spinner("Verificando credenciales..."):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{user_url}/api/v1/login",
|
||||
json={"email": email, "password": password},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
datos = response.json()
|
||||
st.session_state.update({
|
||||
"token": datos["access_token"],
|
||||
"token_type": datos.get("token_type", "bearer"),
|
||||
"usuario": datos["user"]
|
||||
})
|
||||
st.rerun()
|
||||
elif response.status_code == 401:
|
||||
st.error("Credenciales incorrectas. Verificá tu correo y contraseña.")
|
||||
else:
|
||||
st.error(f"Error del servidor ({response.status_code}). Intentá más tarde.")
|
||||
except requests.exceptions.ConnectionError:
|
||||
st.error("No se puede conectar al servidor.")
|
||||
except requests.exceptions.Timeout:
|
||||
st.error("El servidor está tardando demasiado.")
|
||||
except Exception as e:
|
||||
st.error(f"Error inesperado: {str(e)}")
|
||||
|
||||
st.markdown("""
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:16px 0 8px;">
|
||||
<div style="flex:1;height:0.5px;background:rgba(255,255,255,0.1);"></div>
|
||||
<span style="font-size:11px;color:rgba(255,255,255,0.35);">¿No tenés cuenta?</span>
|
||||
<div style="flex:1;height:0.5px;background:rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
if st.button("Registrarse", key="link_to_register", use_container_width=False):
|
||||
st.session_state.tip_tab = "register"
|
||||
st.rerun()
|
||||
|
||||
st.markdown("<div style='height:10px;'></div>", unsafe_allow_html=True)
|
||||
if st.button("Ayuda", key="login_help", use_container_width=False):
|
||||
st.session_state.show_help_before_login = True
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _form_registro(user_url: str):
|
||||
st.subheader("Crear cuenta")
|
||||
st.caption("Tu cuenta quedará pendiente de aprobación")
|
||||
|
||||
with st.form("tip_register_form", clear_on_submit=True):
|
||||
nombre = st.text_input("Nombre completo", placeholder="Juan Pérez")
|
||||
email = st.text_input("Correo electrónico", placeholder="Ingresá tu correo")
|
||||
password = st.text_input("Contraseña", type="password", placeholder="Mínimo 8 caracteres")
|
||||
submitted = st.form_submit_button(
|
||||
"Solicitar acceso",
|
||||
use_container_width=True,
|
||||
type="primary"
|
||||
)
|
||||
|
||||
st.info("Un administrador revisará tu solicitud antes de que puedas ingresar al sistema.")
|
||||
|
||||
if submitted:
|
||||
if not nombre or not email or not password:
|
||||
st.warning("Completá todos los campos.")
|
||||
return
|
||||
with st.spinner("Creando cuenta..."):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{user_url}/api/v1/users/common",
|
||||
json={"name": nombre, "email": email, "password": password},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code in (200, 201):
|
||||
st.success("Cuenta creada. Un administrador la activará pronto.")
|
||||
st.session_state.registration_success = True
|
||||
st.session_state.tip_tab = "login"
|
||||
st.rerun()
|
||||
else:
|
||||
try:
|
||||
detalle = response.json().get("detail", "")
|
||||
except Exception:
|
||||
detalle = str(response.status_code)
|
||||
st.error(f"Error al crear la cuenta: {detalle}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
st.error("No se puede conectar al servidor.")
|
||||
except requests.exceptions.Timeout:
|
||||
st.error("El servidor está tardando demasiado.")
|
||||
except Exception as e:
|
||||
st.error(f"Error inesperado: {str(e)}")
|
||||
|
||||
st.markdown("""
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:16px 0 8px;">
|
||||
<div style="flex:1;height:0.5px;background:rgba(255,255,255,0.1);"></div>
|
||||
<span style="font-size:11px;color:rgba(255,255,255,0.35);">¿Ya tenés cuenta?</span>
|
||||
<div style="flex:1;height:0.5px;background:rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
if st.button("Ingresar", key="link_to_login", use_container_width=False):
|
||||
st.session_state.tip_tab = "login"
|
||||
st.rerun()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compatibilidad hacia atrás
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mostrar_pantalla_registro(user_url: str):
|
||||
st.session_state.tip_tab = "register"
|
||||
mostrar_pantalla_login(user_url)
|
||||
@@ -0,0 +1,484 @@
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
from src.auth import get_user_info
|
||||
from os import getenv
|
||||
from src.api import api_client
|
||||
from src.pages.views.audit import mostrar_audit
|
||||
import requests
|
||||
|
||||
def _render_backup_status_badge(ok: bool, label: str):
|
||||
"""Renderiza un badge de estado inline."""
|
||||
color = "#27500A" if ok else "#6b1a1a"
|
||||
bg = "#EAF3DE" if ok else "#FAE8E8"
|
||||
icon = "✓" if ok else "✗"
|
||||
st.markdown(
|
||||
f'<span style="background:{bg};color:{color};font-size:11px;font-weight:600;'
|
||||
f'padding:2px 8px;border-radius:4px;font-family:monospace;">'
|
||||
f'{icon} {label}</span>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
def _mostrar_seccion_backup():
|
||||
"""
|
||||
Sección completa de Backup del Sistema.
|
||||
Se renderiza dentro de la pestaña Sistema, debajo del bloque del alimentador.
|
||||
"""
|
||||
api = api_client
|
||||
|
||||
st.subheader("Backup del Sistema")
|
||||
|
||||
backup_url_configured = bool(getenv("BACKUP_URL", ""))
|
||||
|
||||
if not backup_url_configured:
|
||||
st.warning(
|
||||
"La variable de entorno `BACKUP_URL` no está configurada. "
|
||||
"El servicio de backup no está disponible."
|
||||
)
|
||||
return
|
||||
|
||||
# ── Estado del servicio ───────────────────────────────────────────────
|
||||
col_status, col_refresh = st.columns([5, 1])
|
||||
with col_refresh:
|
||||
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
|
||||
if st.button("↻ Verificar", key="backup_refresh_status", use_container_width=True):
|
||||
st.session_state.pop("backup_status_cache", None)
|
||||
|
||||
with col_status:
|
||||
if "backup_status_cache" not in st.session_state:
|
||||
with st.spinner("Consultando servicio de backup..."):
|
||||
bk_status = api.get_backup_status()
|
||||
st.session_state.backup_status_cache = bk_status
|
||||
else:
|
||||
bk_status = st.session_state.backup_status_cache
|
||||
|
||||
if bk_status is None:
|
||||
st.error("No se pudo conectar al servicio de backup. Verificá que el contenedor esté activo.")
|
||||
return
|
||||
|
||||
# Tabla de estado de componentes
|
||||
comps = bk_status.get("components", {})
|
||||
checked_at = bk_status.get("checked_at", "")
|
||||
try:
|
||||
checked_at = datetime.fromisoformat(checked_at).strftime("%d/%m/%Y %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
st.caption(f"Estado verificado: {checked_at} UTC")
|
||||
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
st.markdown("**Bases de datos**")
|
||||
db_f = comps.get("db_feeder", {})
|
||||
db_u = comps.get("db_users", {})
|
||||
_render_backup_status_badge(
|
||||
db_f.get("reachable", False),
|
||||
"Feeder DB" + ("" if db_f.get("url_configured") else " (sin URL)")
|
||||
)
|
||||
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
|
||||
_render_backup_status_badge(
|
||||
db_u.get("reachable", False),
|
||||
"Users DB" + ("" if db_u.get("url_configured") else " (sin URL)")
|
||||
)
|
||||
|
||||
with col2:
|
||||
st.markdown("**Configuración**")
|
||||
nginx = comps.get("nginx_conf", {})
|
||||
dc = comps.get("docker_compose", {})
|
||||
ssl = comps.get("ssl", {})
|
||||
_render_backup_status_badge(nginx.get("exists", False), "nginx.conf")
|
||||
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
|
||||
_render_backup_status_badge(dc.get("exists", False), "docker-compose.yml")
|
||||
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
|
||||
ssl_files = ssl.get("files", [])
|
||||
_render_backup_status_badge(
|
||||
ssl.get("exists", False) and len(ssl_files) > 0,
|
||||
f"SSL ({len(ssl_files)} archivos)"
|
||||
)
|
||||
|
||||
with col3:
|
||||
st.markdown("**Sesiones Telegram**")
|
||||
sess = comps.get("telegram_sessions", {})
|
||||
sess_files = sess.get("files", [])
|
||||
_render_backup_status_badge(
|
||||
sess.get("exists", False) and len(sess_files) > 0,
|
||||
f"Sesiones ({len(sess_files)} archivos)"
|
||||
)
|
||||
|
||||
# ── Selector de componentes ───────────────────────────────────────────
|
||||
st.markdown("<div style='height:16px'></div>", unsafe_allow_html=True)
|
||||
st.markdown("**Seleccioná qué incluir en el backup:**")
|
||||
|
||||
col_c1, col_c2, col_c3, col_c4 = st.columns(4)
|
||||
with col_c1:
|
||||
inc_db = st.checkbox("Bases de datos", value=True, key="bk_inc_db")
|
||||
with col_c2:
|
||||
inc_cfg = st.checkbox("Configuración", value=True, key="bk_inc_cfg")
|
||||
with col_c3:
|
||||
inc_ssl = st.checkbox("Certificados SSL", value=True, key="bk_inc_ssl")
|
||||
with col_c4:
|
||||
inc_sess = st.checkbox("Sesiones Telegram", value=True, key="bk_inc_sess")
|
||||
|
||||
if not any([inc_db, inc_cfg, inc_ssl, inc_sess]):
|
||||
st.warning("Seleccioná al menos un componente para generar el backup.")
|
||||
return
|
||||
|
||||
# ── Botón de generación y descarga ───────────────────────────────────
|
||||
st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True)
|
||||
|
||||
col_btn, col_info_bk = st.columns([2, 3])
|
||||
with col_btn:
|
||||
generar = st.button(
|
||||
"⬇️ Generar y Descargar Backup",
|
||||
key="bk_generate_btn",
|
||||
type="primary",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
with col_info_bk:
|
||||
selected = []
|
||||
if inc_db: selected.append("DB")
|
||||
if inc_cfg: selected.append("Config")
|
||||
if inc_ssl: selected.append("SSL")
|
||||
if inc_sess: selected.append("Sesiones")
|
||||
st.caption(f"Se incluirá: {', '.join(selected)}")
|
||||
if inc_db:
|
||||
st.caption("⚠️ El dump de bases de datos puede tardar varios minutos según su tamaño.")
|
||||
|
||||
if generar:
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"tip_backup_{timestamp}.zip"
|
||||
|
||||
with st.spinner("Generando backup… esto puede tardar si incluye bases de datos."):
|
||||
zip_bytes = api.download_backup(
|
||||
include_db=inc_db,
|
||||
include_config=inc_cfg,
|
||||
include_ssl=inc_ssl,
|
||||
include_sessions=inc_sess,
|
||||
)
|
||||
|
||||
if zip_bytes:
|
||||
size_mb = len(zip_bytes) / (1024 * 1024)
|
||||
st.success(f"Backup generado correctamente ({size_mb:.2f} MB). Hacé clic en Guardar para descargarlo.")
|
||||
st.download_button(
|
||||
label=f"💾 Guardar {filename}",
|
||||
data=zip_bytes,
|
||||
file_name=filename,
|
||||
mime="application/zip",
|
||||
key="bk_download_btn",
|
||||
use_container_width=False,
|
||||
)
|
||||
else:
|
||||
st.error(
|
||||
"No se pudo generar el backup. "
|
||||
"Verificá que el servicio esté activo y que las bases de datos sean accesibles."
|
||||
)
|
||||
|
||||
def mostrar_users():
|
||||
"""Página para mostrar usuarios pendientes de aprobación."""
|
||||
api = api_client
|
||||
|
||||
if "user_page" not in st.session_state:
|
||||
st.session_state.user_page = 1
|
||||
if "user_per_page" not in st.session_state:
|
||||
st.session_state.user_per_page = 10
|
||||
|
||||
skip = (st.session_state.user_page - 1) * st.session_state.user_per_page
|
||||
limit = st.session_state.user_per_page
|
||||
|
||||
with st.spinner("Cargando usuarios..."):
|
||||
users_data = api.get_pending_users(skip=skip, limit=limit)
|
||||
|
||||
if users_data is None:
|
||||
st.error("No se pudieron cargar usuarios.")
|
||||
st.session_state.user_page = 1
|
||||
return
|
||||
|
||||
total_recibidos = len(users_data) if users_data else 0
|
||||
hay_mas_paginas = total_recibidos == limit
|
||||
|
||||
if total_recibidos > 0:
|
||||
st.info(f"Mostrando usuarios pendientes {skip + 1} - {skip + total_recibidos}")
|
||||
else:
|
||||
st.info("No hay usuarios pendientes de aprobación")
|
||||
if st.session_state.user_page > 1:
|
||||
st.session_state.user_page = 1
|
||||
st.rerun()
|
||||
|
||||
if users_data and total_recibidos > 0:
|
||||
for i, user in enumerate(users_data, 1):
|
||||
with st.expander(f"Usuario: {user.get('name')}", expanded=False):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.write(f"**Correo electrónico:** {user.get('email')}")
|
||||
st.write(f"**Rol:** {user.get('rol')}")
|
||||
st.write(f"**Activo:** {user.get('active', 'N/A')}")
|
||||
with col2:
|
||||
try:
|
||||
fc = datetime.fromisoformat(str(user.get("creation_time", ""))).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
fc = user.get("creation_time", "")
|
||||
try:
|
||||
fm = datetime.fromisoformat(str(user.get("update_time", ""))).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
fm = user.get("update_time", "")
|
||||
st.write(f"**Fecha de creación:** {fc}")
|
||||
st.write(f"**Fecha de modificación:** {fm}")
|
||||
|
||||
col_activate, col_reject = st.columns(2)
|
||||
with col_activate:
|
||||
if st.button("Activar Usuario", key=f"res_{user.get('id', i)}_{skip}"):
|
||||
result = api.activate_user(user.get('id', i))
|
||||
if result is not None:
|
||||
st.success(f"Usuario {user.get('id', 'N/A')} activado con éxito")
|
||||
else:
|
||||
st.error(f"No se pudo activar al usuario {user.get('id', 'N/A')}")
|
||||
with col_reject:
|
||||
if st.button("Rechazar Usuario", key=f"rej_{user.get('id', i)}_{skip}"):
|
||||
result = api.delete_user(user.get('id', i))
|
||||
if result is not None:
|
||||
st.success(f"Usuario {user.get('name', 'N/A')} eliminado con éxito")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(f"No se pudo eliminar al usuario {user.get('name', 'N/A')}")
|
||||
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("Anterior", disabled=st.session_state.user_page <= 1, use_container_width=True):
|
||||
st.session_state.user_page = max(1, st.session_state.user_page - 1)
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {st.session_state.user_page}**")
|
||||
st.caption(f"Usuarios pendientes: {total_recibidos}")
|
||||
with col_next:
|
||||
if st.button("Siguiente", disabled=not hay_mas_paginas, use_container_width=True):
|
||||
st.session_state.user_page += 1
|
||||
st.rerun()
|
||||
|
||||
if total_recibidos == 0 and st.session_state.user_page > 1:
|
||||
st.warning("No hay usuarios en esta página.")
|
||||
if st.button("Volver a la primera página"):
|
||||
st.session_state.user_page = 1
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _form_crear_usuario(user_url: str):
|
||||
"""
|
||||
Formulario nativo para crear un usuario desde el panel admin.
|
||||
Reemplaza mostrar_pantalla_registro() que pisaba el layout completo.
|
||||
"""
|
||||
api = api_client
|
||||
st.subheader("Crear usuario")
|
||||
|
||||
with st.form("admin_create_user_form", clear_on_submit=True):
|
||||
nombre = st.text_input("Nombre completo", placeholder="Juan Pérez")
|
||||
email = st.text_input("Correo electrónico", placeholder="Ingresá el correo")
|
||||
password = st.text_input("Contraseña", type="password", placeholder="Mínimo 8 caracteres")
|
||||
col_rol, col_active = st.columns(2)
|
||||
with col_rol:
|
||||
rol = st.selectbox("Rol", ["operator", "admin"])
|
||||
with col_active:
|
||||
active = st.checkbox("Activar inmediatamente", value=True)
|
||||
submitted = st.form_submit_button("Crear usuario", use_container_width=True, type="primary")
|
||||
|
||||
if submitted:
|
||||
if not nombre or not email or not password:
|
||||
st.warning("Completá todos los campos.")
|
||||
return
|
||||
with st.spinner("Creando usuario..."):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{user_url}/api/v1/users/common",
|
||||
json={"name": nombre, "email": email, "password": password},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code in (200, 201):
|
||||
st.success(f"Usuario {nombre} creado correctamente.")
|
||||
else:
|
||||
try:
|
||||
detalle = response.json().get("detail", "")
|
||||
except Exception:
|
||||
detalle = str(response.status_code)
|
||||
st.error(f"Error: {detalle}")
|
||||
except Exception as e:
|
||||
st.error(f"Error de conexión: {str(e)}")
|
||||
|
||||
|
||||
def mostrar_panel_admin():
|
||||
"""Panel de administración."""
|
||||
usuario = get_user_info()
|
||||
|
||||
tab1, tab2, tab3, tab4 = st.tabs(["Usuarios", "Logs", "Sistema", "Auditoría"])
|
||||
|
||||
with tab1:
|
||||
st.subheader("Gestión de Usuarios")
|
||||
stab1, stab2, stab3 = st.tabs(["Usuarios pendientes", "Crear usuario", "Editar usuario"])
|
||||
with stab1:
|
||||
mostrar_users()
|
||||
with stab2:
|
||||
_form_crear_usuario(getenv("USERS_URL", ""))
|
||||
with stab3:
|
||||
editar_usuario_admin()
|
||||
|
||||
with tab2:
|
||||
st.subheader("Historial de modificaciones de usuarios")
|
||||
api = api_client
|
||||
with st.spinner("Cargando historial..."):
|
||||
mods = api.get_user_modifications(skip=0, limit=100)
|
||||
if not mods:
|
||||
st.info("No se encontraron modificaciones recientes.")
|
||||
else:
|
||||
try:
|
||||
import pandas as pd
|
||||
st.dataframe(pd.DataFrame(mods), use_container_width=True)
|
||||
except Exception:
|
||||
for m in mods:
|
||||
st.write(m)
|
||||
|
||||
with tab3:
|
||||
tab1, tab2 = st.tabs(["Alimentador", "Backup del Sistema"])
|
||||
with tab1:
|
||||
st.subheader("Ajustes de Alimentador")
|
||||
api = api_client
|
||||
with st.spinner("Verificando estado..."):
|
||||
status = api.telegram_status()
|
||||
|
||||
if status is None:
|
||||
st.error("No se pudo verificar el estado del alimentador.")
|
||||
elif status.get("is_active"):
|
||||
st.success("El alimentador está activo y funcionando correctamente.")
|
||||
else:
|
||||
st.error("El alimentador está caído o no disponible.")
|
||||
st.subheader("Reiniciar Sesión de Telegram")
|
||||
st.info("Si el alimentador está caído, podés reiniciar la sesión de Telegram acá.")
|
||||
|
||||
feeder_ok = api.has_connection()
|
||||
|
||||
if not feeder_ok:
|
||||
st.warning("No hay conexión con la API de Telegram. Révisa la conexión a Internet")
|
||||
|
||||
if st.button("Iniciar Nueva Sesión", type="primary", disabled=not feeder_ok):
|
||||
with st.spinner("Iniciando sesión..."):
|
||||
init_result = api.init_session_telegram()
|
||||
if init_result:
|
||||
st.success("Sesión iniciada. Revisá Telegram para el código de verificación.")
|
||||
st.session_state.waiting_for_code = True
|
||||
st.session_state.flow_id = init_result.get("flow_id")
|
||||
else:
|
||||
st.error("Error al iniciar la sesión.")
|
||||
|
||||
if st.session_state.get("waiting_for_code"):
|
||||
st.subheader("Verificar Código")
|
||||
code = st.text_input("Código de verificación de Telegram:", type="password")
|
||||
password = st.text_input("Contraseña 2FA (si aplica):", type="password")
|
||||
if st.button("Verificar Código", type="primary"):
|
||||
if code:
|
||||
with st.spinner("Verificando..."):
|
||||
verify_result = api.verify_code(
|
||||
st.session_state.flow_id, code,
|
||||
password if password else None
|
||||
)
|
||||
if verify_result:
|
||||
st.success("Sesión verificada. El alimentador debería estar activo.")
|
||||
st.session_state.waiting_for_code = False
|
||||
st.session_state.flow_id = None
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al verificar el código. Intentá de nuevo.")
|
||||
datetime.time.sleep(2)
|
||||
st.rerun()
|
||||
else:
|
||||
st.warning("Ingresá el código de verificación.")
|
||||
with tab2:
|
||||
_mostrar_seccion_backup()
|
||||
|
||||
with tab4:
|
||||
mostrar_audit()
|
||||
|
||||
|
||||
def editar_usuario_admin():
|
||||
"""Interfaz admin para buscar y editar usuarios."""
|
||||
api = api_client
|
||||
q = ""
|
||||
|
||||
with st.spinner("Cargando usuarios..."):
|
||||
try:
|
||||
users = api.search_users(q="", limit=500) or []
|
||||
except Exception:
|
||||
users = []
|
||||
|
||||
if not users:
|
||||
st.info("No se encontraron usuarios.")
|
||||
return
|
||||
|
||||
options = []
|
||||
id_map = {}
|
||||
q_lower = (q or "").strip().lower()
|
||||
|
||||
for u in users:
|
||||
name = (u.get('name') or '').strip()
|
||||
email = (u.get('email') or '').strip()
|
||||
uid = u.get('id')
|
||||
label = f"{name or '<sin-nombre>'} — {email} (id:{uid})"
|
||||
if q_lower:
|
||||
if q_lower in name.lower() or q_lower in email.lower() or q_lower in str(uid):
|
||||
options.append(label)
|
||||
id_map[label] = uid
|
||||
else:
|
||||
options.append(label)
|
||||
id_map[label] = uid
|
||||
|
||||
if not options:
|
||||
st.info("No hay usuarios que coincidan.")
|
||||
return
|
||||
|
||||
selected_label = st.selectbox("Seleccioná un usuario para editar", options, key="user_select")
|
||||
selected_id = id_map.get(selected_label)
|
||||
|
||||
if selected_id:
|
||||
user_detail = api.get_user(selected_id)
|
||||
if not user_detail:
|
||||
st.error("No se pudo obtener el detalle del usuario.")
|
||||
return
|
||||
|
||||
st.subheader(f"Editar: {user_detail.get('name', '')} (id:{selected_id})")
|
||||
|
||||
with st.form("admin_edit_user"):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
name_val = st.text_input("Nombre", value=user_detail.get('name', ''))
|
||||
email_val = st.text_input("Email", value=user_detail.get('email', ''))
|
||||
with col2:
|
||||
rol_options = ["operator", "admin"]
|
||||
current_rol = user_detail.get('rol', "operator") or "operator"
|
||||
try:
|
||||
idx = rol_options.index(current_rol)
|
||||
except ValueError:
|
||||
idx = 0
|
||||
rol_val = st.selectbox("Rol", options=rol_options, index=idx)
|
||||
active_val = st.checkbox("Activo", value=bool(user_detail.get('active', False)))
|
||||
|
||||
password_val = st.text_input("Nueva contraseña (opcional)", value="", type="password")
|
||||
submitted = st.form_submit_button("Guardar cambios", type="primary")
|
||||
|
||||
if submitted:
|
||||
payload = {
|
||||
"name": name_val,
|
||||
"email": email_val,
|
||||
"rol": rol_val,
|
||||
"active": bool(active_val)
|
||||
}
|
||||
if password_val:
|
||||
payload['password'] = password_val
|
||||
with st.spinner("Aplicando cambios..."):
|
||||
res = api.update_user(selected_id, payload)
|
||||
if res is not None:
|
||||
st.success("Usuario actualizado correctamente.")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al actualizar usuario.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mostrar_panel_admin()
|
||||
@@ -0,0 +1,296 @@
|
||||
import streamlit as st
|
||||
from src.auth import requiere_login, get_user_info, logout
|
||||
from src.api import api_client
|
||||
from src.pages import admin
|
||||
from src.pages.views.rules import editar_reglas, crear_regla
|
||||
from src.pages.views.groups import agregar_grupo, listar_grupos
|
||||
from src.pages.views.messages import mostrar_mensajes
|
||||
from src.pages.views.alerts import mostrar_alertas
|
||||
from src.pages.views.home import mostrar_inicio
|
||||
from src.pages.views.audit import mostrar_audit
|
||||
from src.pages.views.senders import mostrar_senders
|
||||
from src.pages.views.stats import mostrar_estadisticas
|
||||
from src.pages.views.components import safe_html
|
||||
from src.pages.views.help import show_documentation
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS global — identidad TIP
|
||||
# ---------------------------------------------------------------------------
|
||||
TIP_CSS = """
|
||||
<style>
|
||||
/* Sidebar background */
|
||||
[data-testid="stSidebar"] {
|
||||
background-color: #27500A !important;
|
||||
}
|
||||
[data-testid="stSidebar"] > div:first-child {
|
||||
background-color: #27500A !important;
|
||||
}
|
||||
|
||||
/* Todos los textos del sidebar en blanco */
|
||||
[data-testid="stSidebar"] * {
|
||||
color: rgba(255,255,255,0.85) !important;
|
||||
}
|
||||
|
||||
/* Radio buttons — ocultar los círculos nativos y darle estilo de nav */
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] > div {
|
||||
gap: 2px !important;
|
||||
}
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] label {
|
||||
background: transparent !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 7px 12px !important;
|
||||
cursor: pointer !important;
|
||||
font-size: 13px !important;
|
||||
transition: background 0.15s !important;
|
||||
border: none !important;
|
||||
}
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] label:hover {
|
||||
background: rgba(255,255,255,0.08) !important;
|
||||
}
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] label[data-checked="true"],
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] [aria-checked="true"] ~ div {
|
||||
background: rgba(255,255,255,0.14) !important;
|
||||
border-right: 2px solid #C0DD97 !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
/* Ocultar el círculo del radio */
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] [data-testid="stMarkdownContainer"] p {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Dividers del sidebar */
|
||||
[data-testid="stSidebar"] hr {
|
||||
border-color: rgba(255,255,255,0.12) !important;
|
||||
}
|
||||
|
||||
/* Captions / labels en sidebar */
|
||||
[data-testid="stSidebar"] .stCaption,
|
||||
[data-testid="stSidebar"] small {
|
||||
color: rgba(255,255,255,0.45) !important;
|
||||
}
|
||||
|
||||
/* Botón logout en sidebar */
|
||||
[data-testid="stSidebar"] button {
|
||||
background: rgba(255,255,255,0.08) !important;
|
||||
border: 0.5px solid rgba(255,255,255,0.2) !important;
|
||||
color: rgba(255,255,255,0.85) !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
[data-testid="stSidebar"] button:hover {
|
||||
background: rgba(255,255,255,0.15) !important;
|
||||
}
|
||||
|
||||
/* Métricas — acento verde en la primera */
|
||||
[data-testid="metric-container"] {
|
||||
background: var(--background-color) !important;
|
||||
border: 0.5px solid rgba(128,128,128,0.2) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def _sidebar_logo():
|
||||
"""Renderiza el logo TIP en el sidebar."""
|
||||
st.markdown("""
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:4px 0 12px;">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.15);
|
||||
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="8" stroke="rgba(255,255,255,0.6)" stroke-width="1.5"/>
|
||||
<path d="M7 10l2.5 2.5L14 7" stroke="white" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:600;color:white;line-height:1.1;">TIP</div>
|
||||
<div style="font-size:9px;color:rgba(255,255,255,0.45);letter-spacing:0.07em;
|
||||
text-transform:uppercase;">Threat Intelligence Platform</div>
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _sidebar_user(usuario: dict):
|
||||
"""Renderiza el bloque de usuario al pie del sidebar."""
|
||||
nombre = safe_html(usuario.get("name", "Usuario"))
|
||||
rol = safe_html(usuario.get("rol", "operator").capitalize())
|
||||
iniciales = safe_html("".join(p[0].upper() for p in nombre.split()[:2]))
|
||||
st.markdown(f"""
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 0 4px;
|
||||
border-top:0.5px solid rgba(255,255,255,0.12);margin-top:8px;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;
|
||||
background:rgba(255,255,255,0.18);display:flex;align-items:center;
|
||||
justify-content:center;font-size:11px;font-weight:500;
|
||||
color:white;flex-shrink:0;">{iniciales}</div>
|
||||
<div>
|
||||
<div style="font-size:12px;font-weight:500;color:white;">{nombre}</div>
|
||||
<div style="font-size:10px;color:rgba(255,255,255,0.45);">{rol}</div>
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _nav_section(label: str):
|
||||
"""Renderiza un separador de sección en el sidebar."""
|
||||
st.markdown(f"""
|
||||
<div style="font-size:10px;color:rgba(255,255,255,0.38);letter-spacing:0.08em;
|
||||
text-transform:uppercase;padding:10px 0 2px;">
|
||||
{safe_html(label)}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
@requiere_login
|
||||
def mostrar_dashboard():
|
||||
usuario = get_user_info()
|
||||
es_admin = usuario.get("rol") == "admin"
|
||||
|
||||
# Inyectar CSS
|
||||
st.markdown(TIP_CSS, unsafe_allow_html=True)
|
||||
|
||||
with st.sidebar:
|
||||
_sidebar_logo()
|
||||
st.markdown("<hr style='margin:0 0 8px;'>", unsafe_allow_html=True)
|
||||
|
||||
# --- Navegación ---
|
||||
_nav_section("Principal")
|
||||
|
||||
opciones_principales = [
|
||||
"🏠 Inicio",
|
||||
"🔔 Alertas",
|
||||
"💬 Mensajes",
|
||||
"📊 Estadísticas",
|
||||
"👥 Remitentes",
|
||||
]
|
||||
|
||||
opciones_config = [
|
||||
"📋 Reglas",
|
||||
"📡 Grupos y Canales",
|
||||
]
|
||||
|
||||
opciones_admin = [
|
||||
"📝 Panel de Administrador",
|
||||
] if es_admin else []
|
||||
|
||||
opciones_cuenta = ["⚙️ Mi Cuenta", "📋 Ayuda"]
|
||||
|
||||
todas = opciones_principales + opciones_config + (opciones_admin if es_admin else []) + opciones_cuenta
|
||||
|
||||
opcion = st.radio(
|
||||
"nav",
|
||||
todas,
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
|
||||
st.markdown("<hr style='margin:8px 0;'>", unsafe_allow_html=True)
|
||||
_sidebar_user(usuario)
|
||||
st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True)
|
||||
if st.button("Cerrar sesión", use_container_width=True):
|
||||
logout()
|
||||
st.rerun()
|
||||
|
||||
# --- Contenido principal ---
|
||||
opcion_clean = opcion.strip().split(" ", 1)[-1].strip()
|
||||
if opcion_clean != "Inicio":
|
||||
st.session_state.pop("show_help_from_home", None)
|
||||
|
||||
if opcion_clean == "Inicio":
|
||||
mostrar_inicio(usuario)
|
||||
|
||||
elif opcion_clean == "Alertas":
|
||||
st.title("Alertas")
|
||||
mostrar_alertas()
|
||||
|
||||
elif opcion_clean == "Mensajes":
|
||||
st.title("Mensajes")
|
||||
mostrar_mensajes()
|
||||
|
||||
elif opcion_clean == "Reglas":
|
||||
_mostrar_reglas()
|
||||
|
||||
elif opcion_clean == "Estadísticas":
|
||||
mostrar_estadisticas()
|
||||
|
||||
elif opcion_clean == "Grupos y Canales":
|
||||
_mostrar_grupos()
|
||||
|
||||
elif opcion_clean == "Remitentes":
|
||||
mostrar_senders()
|
||||
|
||||
elif opcion_clean == "Panel de Administrador" and es_admin:
|
||||
st.title("Panel de Administración")
|
||||
admin.mostrar_panel_admin()
|
||||
|
||||
elif opcion_clean == "Mi Cuenta":
|
||||
_mostrar_mi_cuenta(usuario)
|
||||
elif opcion_clean == "Ayuda":
|
||||
_mostrar_ayuda()
|
||||
else:
|
||||
mostrar_inicio(usuario)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secciones internas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mostrar_ayuda():
|
||||
show_documentation()
|
||||
|
||||
def _mostrar_reglas():
|
||||
st.title("Gestión de Reglas")
|
||||
tab1, tab2 = st.tabs(["Listar reglas", "Crear una regla"])
|
||||
with tab1:
|
||||
editar_reglas()
|
||||
with tab2:
|
||||
crear_regla()
|
||||
|
||||
def _mostrar_grupos():
|
||||
st.title("Grupos y Canales")
|
||||
tab1, tab3 = st.tabs(["Grupos activos", "Agregar grupo"])
|
||||
with tab1:
|
||||
listar_grupos()
|
||||
with tab3:
|
||||
agregar_grupo()
|
||||
|
||||
|
||||
def _mostrar_mi_cuenta(usuario: dict):
|
||||
st.title("Mi Cuenta")
|
||||
api = api_client
|
||||
|
||||
editar = st.checkbox("Activar edición")
|
||||
|
||||
with st.form("form_mi_cuenta"):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
name_val = st.text_input("Nombre completo", value=usuario.get("name", ""), disabled=not editar)
|
||||
email_val = st.text_input("Email", value=usuario.get("email", ""), disabled=not editar)
|
||||
with col2:
|
||||
st.text_input("Rol", value=usuario.get("rol", ""), disabled=True)
|
||||
st.text_input("ID usuario", value=str(usuario.get("id", "")), disabled=True)
|
||||
|
||||
password_val = st.text_input("Nueva contraseña (opcional)", value="", type="password", disabled=not editar)
|
||||
submitted = st.form_submit_button("Guardar cambios")
|
||||
|
||||
if submitted and editar:
|
||||
user_id = usuario.get("id")
|
||||
if not user_id:
|
||||
st.error("ID de usuario no disponible.")
|
||||
return
|
||||
payload = {"name": name_val, "email": email_val, "rol": usuario.get("rol"), "active": True}
|
||||
if password_val:
|
||||
payload["password"] = password_val
|
||||
with st.spinner("Guardando..."):
|
||||
res = api.update_user(user_id, payload)
|
||||
if res:
|
||||
st.success("Datos actualizados correctamente.")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al actualizar. Revisá los datos o contactá al administrador.")
|
||||
elif not editar:
|
||||
st.info("Marcá 'Activar edición' para modificar tus datos.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mostrar_dashboard()
|
||||
@@ -0,0 +1,547 @@
|
||||
from asyncio import sleep
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from src.auth import get_user_info
|
||||
|
||||
from io import BytesIO
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib import colors
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||||
from reportlab.lib.enums import TA_LEFT, TA_CENTER
|
||||
from xml.sax.saxutils import escape
|
||||
from src.pages.views.components import render_adjuntos
|
||||
from datetime import time
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fmt_fecha(fecha_str: str) -> str:
|
||||
try:
|
||||
return datetime.fromisoformat(str(fecha_str)).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
return str(fecha_str) or "N/A"
|
||||
|
||||
|
||||
def _estado_label(status: str) -> str:
|
||||
return "Resuelto" if status == "close" else "Pendiente" if status == "open" else "En Curso"
|
||||
|
||||
|
||||
def _p(text, style) -> Paragraph:
|
||||
"""Crea un Paragraph escapando caracteres especiales para evitar errores de XML."""
|
||||
return Paragraph(escape(str(text or "")), style)
|
||||
|
||||
|
||||
def generar_reporte_alerta_pdf(
|
||||
alert: dict,
|
||||
notes: list,
|
||||
motivo_cierre: str,
|
||||
usuarios: dict,
|
||||
message: dict = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Genera un reporte PDF de cierre de alerta.
|
||||
Retorna los bytes del PDF para usar con st.download_button.
|
||||
"""
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=letter,
|
||||
rightMargin=2 * cm,
|
||||
leftMargin=2 * cm,
|
||||
topMargin=2 * cm,
|
||||
bottomMargin=2 * cm,
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
titulo_style = ParagraphStyle("Titulo", parent=styles["Title"], fontSize=18, textColor=colors.HexColor("#1a1a2e"), spaceAfter=6)
|
||||
subtitulo_style = ParagraphStyle("Subtitulo", parent=styles["Heading2"],fontSize=12, textColor=colors.HexColor("#16213e"), spaceBefore=14, spaceAfter=4)
|
||||
label_style = ParagraphStyle("Label", parent=styles["Normal"], fontSize=9, textColor=colors.HexColor("#555555"))
|
||||
valor_style = ParagraphStyle("Valor", parent=styles["Normal"], fontSize=10, textColor=colors.HexColor("#1a1a1a"))
|
||||
motivo_style = ParagraphStyle("Motivo", parent=styles["Normal"], fontSize=10, textColor=colors.HexColor("#1a1a1a"), backColor=colors.HexColor("#fff3cd"), borderPadding=(6, 8, 6, 8), leading=14)
|
||||
nota_content_style = ParagraphStyle("NotaContent", parent=styles["Normal"], fontSize=10, leading=14)
|
||||
footer_style = ParagraphStyle("Footer", parent=label_style, alignment=TA_CENTER, fontSize=8)
|
||||
right_style = ParagraphStyle("Right", parent=label_style, alignment=2)
|
||||
|
||||
story = []
|
||||
|
||||
# Encabezado
|
||||
story.append(Paragraph("Reporte de Cierre de Alerta", titulo_style))
|
||||
story.append(Paragraph(f"Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')}", label_style))
|
||||
story.append(HRFlowable(width="100%", thickness=2, color=colors.HexColor("#1a1a2e"), spaceAfter=12))
|
||||
|
||||
# Información general
|
||||
story.append(Paragraph("Información de la Alerta", subtitulo_style))
|
||||
|
||||
rule = alert.get("rule") or {}
|
||||
group = alert.get("group") or {}
|
||||
info_data = [
|
||||
["ID Alerta", str(alert.get("id", "N/A")), "Estado", _estado_label("Cerrada")],
|
||||
["Severidad", str(rule.get("severity", "N/A")), "Fecha", _fmt_fecha(alert.get("created_at", ""))],
|
||||
["Grupo", str(alert.get("group_id", "N/A")) + f" ({group.get('username', 'N/A')})", "Mensaje ID", str(alert.get("message_id", "N/A"))],
|
||||
]
|
||||
info_table = Table(info_data, colWidths=[3.5*cm, 6.5*cm, 3.5*cm, 6.5*cm])
|
||||
info_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#e8ecf0")),
|
||||
("BACKGROUND", (2, 0), (2, -1), colors.HexColor("#e8ecf0")),
|
||||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||||
("FONTNAME", (2, 0), (2, -1), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("PADDING", (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(info_table)
|
||||
|
||||
if rule.get("description"):
|
||||
story.append(Paragraph("Descripción de la Regla", subtitulo_style))
|
||||
story.append(_p(rule.get("description", ""), valor_style))
|
||||
|
||||
if rule.get("regex"):
|
||||
story.append(Spacer(1, 6))
|
||||
story.append(Paragraph("<b>Regex:</b>", label_style))
|
||||
story.append(_p(rule.get("regex", ""), valor_style))
|
||||
|
||||
# Mensaje que originó la alerta
|
||||
if message:
|
||||
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#cccccc"), spaceBefore=14, spaceAfter=6))
|
||||
story.append(Paragraph("Mensaje que Originó la Alerta", subtitulo_style))
|
||||
|
||||
sender = message.get("sender") or {}
|
||||
|
||||
story.append(Paragraph("<b>Remitente del mensaje:</b>", label_style))
|
||||
nombre_sender = f"{'N/A' if sender.get('first_name') == 'None' else sender.get('first_name', 'N/A')} {'' if sender.get('last_name') == 'None' else sender.get('last_name', 'N/A')}".strip() or "N/A"
|
||||
phone = "Sin teléfono" if sender.get("phone") == "None" else sender.get("phone", "N/A")
|
||||
sender_data = [
|
||||
["ID Telegram", str(sender.get("id_telegram", "N/A")), "Username", f"@{sender.get('username', 'N/A')}"],
|
||||
["Nombre", nombre_sender, "Tipo", str(sender.get("user", "N/A"))],
|
||||
["Teléfono", phone, "Fecha", _fmt_fecha(message.get("date", ""))],
|
||||
]
|
||||
sender_table = Table(sender_data, colWidths=[3.5*cm, 6.5*cm, 3.5*cm, 6.5*cm])
|
||||
sender_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#e8ecf0")),
|
||||
("BACKGROUND", (2, 0), (2, -1), colors.HexColor("#e8ecf0")),
|
||||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||||
("FONTNAME", (2, 0), (2, -1), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("PADDING", (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(sender_table)
|
||||
|
||||
story.append(Spacer(1, 8))
|
||||
story.append(Paragraph("<b>Contenido del mensaje:</b>", label_style))
|
||||
content_table = Table(
|
||||
[[_p(message.get("content") or "Mensaje vacío", nota_content_style)]],
|
||||
colWidths=[doc.width]
|
||||
)
|
||||
content_table.setStyle(TableStyle([
|
||||
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
("PADDING", (0, 0), (-1, -1), 8),
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f9f9f9")),
|
||||
]))
|
||||
story.append(content_table)
|
||||
|
||||
# Adjuntos en el PDF: solo listamos metadata (no podemos embeber binarios aquí)
|
||||
adjuntos = message.get("attachments") or []
|
||||
if adjuntos:
|
||||
story.append(Spacer(1, 6))
|
||||
story.append(Paragraph("<b>Adjuntos:</b>", label_style))
|
||||
for adj in adjuntos:
|
||||
adj_line = (
|
||||
f"• [{adj.get('type', 'N/A').upper()}] "
|
||||
f"{adj.get('description', 'Sin descripción')} "
|
||||
f"(ID: {adj.get('id', 'N/A')})"
|
||||
)
|
||||
story.append(_p(adj_line, valor_style))
|
||||
|
||||
# Motivo de cierre
|
||||
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#cccccc"), spaceBefore=14, spaceAfter=6))
|
||||
story.append(Paragraph("Motivo de Cierre", subtitulo_style))
|
||||
story.append(_p(motivo_cierre or "Sin motivo especificado.", motivo_style))
|
||||
|
||||
# Historial de notas
|
||||
story.append(Paragraph("Historial de Notas", subtitulo_style))
|
||||
|
||||
if not notes:
|
||||
story.append(Paragraph("No hay notas registradas para esta alerta.", label_style))
|
||||
else:
|
||||
for note in notes:
|
||||
user_id = note.get("user_id")
|
||||
user = usuarios.get(user_id) or {}
|
||||
nombre = escape(user.get("name", f"Usuario #{user_id}"))
|
||||
rol = escape(user.get("rol", ""))
|
||||
fecha_nota = _fmt_fecha(note.get("creation_date", ""))
|
||||
|
||||
header_table = Table(
|
||||
[[Paragraph(f"<b>{nombre}</b> — {rol}", label_style),
|
||||
Paragraph(fecha_nota, right_style)]],
|
||||
colWidths=[doc.width * 0.7, doc.width * 0.3]
|
||||
)
|
||||
header_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f0f4f8")),
|
||||
("PADDING", (0, 0), (-1, -1), 5),
|
||||
("LINEBELOW", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
]))
|
||||
story.append(header_table)
|
||||
|
||||
content_table = Table(
|
||||
[[_p(note.get("content", ""), nota_content_style)]],
|
||||
colWidths=[doc.width]
|
||||
)
|
||||
content_table.setStyle(TableStyle([
|
||||
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
("PADDING", (0, 0), (-1, -1), 8),
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.white),
|
||||
]))
|
||||
story.append(content_table)
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
# Pie de página
|
||||
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#cccccc"), spaceBefore=10))
|
||||
story.append(Paragraph(
|
||||
f"Documento generado automáticamente por TIP — Alerta #{alert.get('id', 'N/A')}",
|
||||
footer_style
|
||||
))
|
||||
|
||||
doc.build(story)
|
||||
return buffer.getvalue()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dialogs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.dialog("Ver mensaje", width="large")
|
||||
def _render_detalle_mensaje(message: dict):
|
||||
"""Renderiza el detalle completo del mensaje, su sender y sus adjuntos con descarga."""
|
||||
api = api_client
|
||||
|
||||
if not message:
|
||||
st.error("No se pudo cargar el mensaje.")
|
||||
return
|
||||
|
||||
# Datos del sender
|
||||
sender = message.get("sender") or {}
|
||||
st.subheader("👤 Remitente")
|
||||
mcol1, mcol2 = st.columns(2)
|
||||
with mcol1:
|
||||
st.write(f"**ID Telegram:** {sender.get('id_telegram', 'N/A')}")
|
||||
st.write(f"**Username:** @{sender.get('username', 'N/A')}")
|
||||
with mcol2:
|
||||
st.write(f"**Nombre:** {'N/A' if sender.get('first_name') == 'None' else sender.get('first_name', 'N/A')} {'' if sender.get('last_name') == 'None' else sender.get('last_name', '')}")
|
||||
st.write(f"**Teléfono:** {'Sin teléfono' if sender.get('phone') == 'None' else sender.get('phone', 'N/A')}")
|
||||
try:
|
||||
fecha = datetime.fromisoformat(str(message.get("date", "")))
|
||||
st.write(f"**Fecha:** {fecha.strftime('%d/%m/%Y %H:%M')}")
|
||||
except Exception:
|
||||
st.write(f"**Fecha:** {message.get('date', 'N/A')}")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Contenido del mensaje
|
||||
st.subheader("💬 Contenido")
|
||||
st.write(message.get("content") or "_Mensaje vacío_")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Adjuntos con descarga
|
||||
st.subheader("📎 Adjuntos")
|
||||
adjuntos = message.get("attachments") or []
|
||||
context_key = f"dialog_{message.get('id_mess_g', '')}_{message.get('group_id', '')}"
|
||||
render_adjuntos(adjuntos, api, context_key=context_key)
|
||||
|
||||
|
||||
@st.dialog("Administrar Alerta", width="large")
|
||||
def dialog_administrar_alerta(alert):
|
||||
api = api_client
|
||||
alert_id = alert.get("id")
|
||||
|
||||
if alert.get("status") == "open":
|
||||
updated = api.set_alert_in_progress(alert_id)
|
||||
if updated:
|
||||
alert = updated
|
||||
|
||||
rule = alert.get("rule", {})
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
alerta = "Pendiente" if alert.get("status") == "open" else "En Curso" if alert.get("status") == "in_progress" else "Resuelto"
|
||||
st.write(f"**Estado:** {alerta}")
|
||||
st.write(f"**Severidad:** {rule.get('severity', 'N/A') if rule else 'N/A'}")
|
||||
with col2:
|
||||
fecha = datetime.fromisoformat(str(alert.get("created_at", "")))
|
||||
st.write(f"**Fecha:** {fecha.strftime('%d/%m/%Y %H:%M')}")
|
||||
|
||||
# Mensaje + adjuntos dentro del dialog de administración
|
||||
message = api.get_message(alert.get("group_id"), alert.get("message_id"))
|
||||
if message:
|
||||
with st.expander("📨 Ver mensaje que originó la alerta", expanded=False):
|
||||
sender = message.get("sender") or {}
|
||||
st.write(f"**De:** {'N/A' if sender.get('first_name') == 'None' else sender.get('first_name', 'N/A')} {'' if sender.get('last_name') == 'None' else sender.get('last_name', '')} (@{sender.get('username', 'N/A')})")
|
||||
st.write(f"**Contenido:** {message.get('content', '_Sin contenido_')}")
|
||||
|
||||
adjuntos = message.get("attachments") or []
|
||||
if adjuntos:
|
||||
st.divider()
|
||||
context_key = f"adm_{alert_id}"
|
||||
render_adjuntos(adjuntos, api, context_key=context_key)
|
||||
|
||||
st.divider()
|
||||
st.subheader("📋 Historial de notas")
|
||||
notes = api.get_notes_for_alert(alert_id=alert_id)
|
||||
|
||||
if not notes:
|
||||
st.info("Sin notas todavía.")
|
||||
else:
|
||||
for note in notes:
|
||||
with st.container(border=True):
|
||||
col_user, col_date = st.columns([3, 1])
|
||||
user = api.get_user(user_id=note.get("user_id"))
|
||||
with col_user:
|
||||
if user:
|
||||
col_user_info1, col_user_info2 = st.columns(2)
|
||||
col_user_info1.write(f"Usuario: {user.get('name', 'Usuario')}")
|
||||
col_user_info2.write(f"Rol: {user.get('rol', 'Sin rol')}")
|
||||
else:
|
||||
st.write("Usuario: **No encontrado**")
|
||||
with col_date:
|
||||
fecha_nota = datetime.fromisoformat(str(note.get("creation_date", "")))
|
||||
st.caption(fecha_nota.strftime("%d/%m/%Y %H:%M"))
|
||||
st.write(note.get("content", ""))
|
||||
|
||||
st.divider()
|
||||
|
||||
with st.form("form_nota_dialog", clear_on_submit=True):
|
||||
content = st.text_area("Agregar nota a la alerta")
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
submitted = st.form_submit_button("Guardar")
|
||||
with col2:
|
||||
if alert.get("status") == "open" or alert.get("status") == "in_progress":
|
||||
marcar_resuelta = st.checkbox("Marcar como resuelta")
|
||||
state = "open"
|
||||
elif alert.get("status") == "close":
|
||||
marcar_resuelta = st.checkbox("Volver a abrir alerta")
|
||||
state = "close"
|
||||
else:
|
||||
marcar_resuelta = False
|
||||
state = "open"
|
||||
|
||||
if submitted and content:
|
||||
usuario = get_user_info()
|
||||
user_id = usuario.get("id")
|
||||
result = api.add_note_to_alert(alert_id=alert_id, user_id=user_id, content=content)
|
||||
|
||||
if marcar_resuelta:
|
||||
if state == "open":
|
||||
marked = api.mark_alert_as_resolved(alert_id=alert_id)
|
||||
if result and marked:
|
||||
st.success("Alerta cerrada correctamente, generando reporte PDF...")
|
||||
notes = api.get_notes_for_alert(alert_id=alert_id) or []
|
||||
usuarios = {}
|
||||
for note in notes:
|
||||
uid = note.get("user_id")
|
||||
if uid and uid not in usuarios:
|
||||
u = api.get_user(user_id=uid)
|
||||
if u:
|
||||
usuarios[uid] = u
|
||||
pdf_bytes = generar_reporte_alerta_pdf(
|
||||
alert=alert,
|
||||
notes=notes,
|
||||
motivo_cierre=content,
|
||||
usuarios=usuarios,
|
||||
message=message
|
||||
)
|
||||
st.download_button(
|
||||
label="📄 Descargar reporte PDF",
|
||||
data=pdf_bytes,
|
||||
file_name=f"reporte_alerta_{alert_id}.pdf",
|
||||
mime="application/pdf"
|
||||
)
|
||||
else:
|
||||
st.error("Error al guardar o actualizar estado de alerta.")
|
||||
elif state == "close":
|
||||
marked = api.mark_alert_as_pending(alert_id=alert_id)
|
||||
if result and marked:
|
||||
st.success("Nota y estado actualizados.")
|
||||
sleep(1)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al guardar o actualizar estado de alerta.")
|
||||
else:
|
||||
if result:
|
||||
st.success("Nota guardada.")
|
||||
st.session_state.open_dialog = True
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al guardar.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filtros, rendering de alertas y paginación
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_filtros() -> tuple:
|
||||
"""Renderiza los controles de filtro y devuelve los valores seleccionados."""
|
||||
col1, col_filtro1, col_filtro2 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
per_page = st.selectbox(
|
||||
"Alertas por página",
|
||||
options=[5, 10, 20, 50, 100],
|
||||
index=1,
|
||||
key="per_page_select"
|
||||
)
|
||||
if per_page != st.session_state.alerts_per_page:
|
||||
st.session_state.alerts_per_page = per_page
|
||||
st.session_state.alert_page = 1
|
||||
|
||||
with col_filtro1:
|
||||
filtro_severidad = st.selectbox("Filtrar por severidad", ["Todas", "Alta", "Media", "Baja"])
|
||||
with col_filtro2:
|
||||
filtro_estado = st.selectbox("Filtrar por estado", ["Todos", "Pendiente", "En Curso" ,"Resuelto"])
|
||||
|
||||
col_f1, col_h1, col_f2, col_h2 = st.columns(4)
|
||||
with col_f1:
|
||||
fecha_desde = st.date_input("Desde (fecha)", value=None, key="fecha_desde")
|
||||
with col_h1:
|
||||
hora_desde = st.time_input("Desde (hora)", value=time(0, 0), key="hora_desde",
|
||||
disabled=fecha_desde is None)
|
||||
with col_f2:
|
||||
fecha_hasta = st.date_input("Hasta (fecha)", value=None, key="fecha_hasta")
|
||||
with col_h2:
|
||||
hora_hasta = st.time_input("Hasta (hora)", value=time(23, 59, 59), key="hora_hasta",
|
||||
disabled=fecha_hasta is None)
|
||||
|
||||
# Combinar fecha + hora en datetime completo
|
||||
dt_desde = datetime.combine(fecha_desde, hora_desde) if fecha_desde else None
|
||||
dt_hasta = datetime.combine(fecha_hasta, hora_hasta) if fecha_hasta else None
|
||||
|
||||
return filtro_severidad, filtro_estado, dt_desde, dt_hasta
|
||||
|
||||
|
||||
def _render_alerta(alert: dict, i: int, skip: int, api):
|
||||
"""Renderiza una alerta individual con su expander y botones."""
|
||||
rule = alert.get("rule", {})
|
||||
descripcion = rule.get("description", "Sin descripción") if rule else "Sin descripción"
|
||||
message_id = alert.get("message_id")
|
||||
group_id = alert.get("group_id")
|
||||
message = api.get_message(group_id, message_id)
|
||||
|
||||
# Indica si el mensaje tiene adjuntos para mostrarlo en el título
|
||||
adjuntos = (message.get("attachments") or []) if message else []
|
||||
adj_label = f" 📎 {len(adjuntos)}" if adjuntos else ""
|
||||
|
||||
with st.expander(
|
||||
f"Alerta #{skip + i} - {descripcion[:50]}{'...' if len(descripcion) > 50 else ''}{adj_label}",
|
||||
expanded=False
|
||||
):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.write(f"**ID:** {message_id}")
|
||||
st.write(f"**Grupo:** {group_id}")
|
||||
if rule:
|
||||
st.write(f"**Severidad:** {rule.get('severity', 'N/A')}")
|
||||
with col2:
|
||||
fecha = datetime.fromisoformat(str(alert.get("created_at", "")))
|
||||
st.write(f"**Fecha:** {fecha.strftime('%d/%m/%Y %H:%M')}")
|
||||
estado_label = "Pendiente" if alert.get("status") == "open" else "En Curso" if alert.get("status") == "in_progress" else "Resuelto"
|
||||
st.write(f"**Estado:** {estado_label}")
|
||||
|
||||
st.write("**Descripción Regla:**")
|
||||
st.write(descripcion if rule else "Sin descripción disponible")
|
||||
_render_botones_alerta(alert, message, i, skip)
|
||||
|
||||
|
||||
def _render_botones_alerta(alert: dict, message: dict, i: int, skip: int):
|
||||
"""Renderiza los botones de acción de una alerta."""
|
||||
col_btn1, col_btn2 = st.columns(2)
|
||||
|
||||
with col_btn1:
|
||||
if st.button("Ver mensaje", key=f"det_{alert.get('id', i)}_{skip}"):
|
||||
_render_detalle_mensaje(message)
|
||||
with col_btn2:
|
||||
if st.button("Administrar alerta", key=f"res_{alert.get('id', i)}_{skip}"):
|
||||
st.session_state.selected_alert = alert
|
||||
dialog_administrar_alerta(alert)
|
||||
|
||||
|
||||
def _render_paginacion(total_recibidas: int, limit: int):
|
||||
"""Renderiza los controles de paginación."""
|
||||
hay_mas_paginas = total_recibidas == limit
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
|
||||
with col_prev:
|
||||
if st.button("◀️ Anterior", disabled=st.session_state.alert_page <= 1, use_container_width=True):
|
||||
st.session_state.alert_page -= 1
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {st.session_state.alert_page}**")
|
||||
st.caption(f"Alertas mostradas: {total_recibidas}")
|
||||
with col_next:
|
||||
if st.button("Siguiente ▶️", disabled=not hay_mas_paginas, use_container_width=True):
|
||||
st.session_state.alert_page += 1
|
||||
st.rerun()
|
||||
|
||||
|
||||
def mostrar_alertas():
|
||||
api = api_client
|
||||
|
||||
if "alert_page" not in st.session_state:
|
||||
st.session_state.alert_page = 1
|
||||
if "alerts_per_page" not in st.session_state:
|
||||
st.session_state.alerts_per_page = 10
|
||||
|
||||
severidad, estado, dt_desde, dt_hasta = _render_filtros()
|
||||
|
||||
status_param = None
|
||||
if estado == "Pendiente":
|
||||
status_param = "open"
|
||||
elif estado == "En Curso":
|
||||
status_param = "in_progress"
|
||||
elif estado == "Resuelto":
|
||||
status_param = "close"
|
||||
|
||||
severity_param = None if severidad == "Todas" else severidad
|
||||
|
||||
skip = (st.session_state.alert_page - 1) * st.session_state.alerts_per_page
|
||||
limit = st.session_state.alerts_per_page
|
||||
|
||||
with st.spinner("Cargando alertas..."):
|
||||
alerts_data = api.get_alerts(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status_param,
|
||||
severity=severity_param,
|
||||
date_from=dt_desde.isoformat() if dt_desde else None,
|
||||
date_to=dt_hasta.isoformat() if dt_hasta else None,
|
||||
)
|
||||
|
||||
if alerts_data is None:
|
||||
st.error("No se pudieron cargar las alertas. Verifica tu autenticación.")
|
||||
st.session_state.alert_page = 1
|
||||
return
|
||||
|
||||
if st.session_state.get("open_dialog") and st.session_state.get("selected_alert"):
|
||||
st.session_state.open_dialog = False
|
||||
dialog_administrar_alerta(st.session_state.selected_alert)
|
||||
|
||||
total_recibidas = len(alerts_data)
|
||||
|
||||
if total_recibidas > 0:
|
||||
st.info(f"Mostrando {total_recibidas} alerta(s) — Página {st.session_state.alert_page}")
|
||||
else:
|
||||
st.info("No hay alertas para mostrar")
|
||||
if st.session_state.alert_page > 1:
|
||||
st.session_state.alert_page = 1
|
||||
st.rerun()
|
||||
|
||||
for i, alert in enumerate(alerts_data, 1):
|
||||
_render_alerta(alert, i, skip, api)
|
||||
|
||||
_render_paginacion(total_recibidas, limit)
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
|
||||
|
||||
ENTITY_TYPE_OPTIONS = ["Todas", "rule", "alert", "group", "message", "sender"]
|
||||
ACTION_OPTIONS = ["Todas", "create", "update", "delete", "status_change"]
|
||||
|
||||
|
||||
def _fmt_fecha(fecha_str: str) -> str:
|
||||
try:
|
||||
return datetime.fromisoformat(str(fecha_str)).strftime("%d/%m/%Y %H:%M:%S")
|
||||
except Exception:
|
||||
return str(fecha_str) or "N/A"
|
||||
|
||||
|
||||
def _fmt_json(value: Optional[str]) -> str:
|
||||
"""Intenta formatear un JSON string de forma legible."""
|
||||
if not value:
|
||||
return "—"
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return json.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def mostrar_audit():
|
||||
"""Vista de logs de auditoría con filtros."""
|
||||
api = api_client
|
||||
|
||||
st.title("📋 Logs de Auditoría")
|
||||
|
||||
# --- Filtros ---
|
||||
with st.expander("🔍 Filtros", expanded=True):
|
||||
col1, col2, col3 = st.columns(3)
|
||||
with col1:
|
||||
entity_type_sel = st.selectbox("Entidad", ENTITY_TYPE_OPTIONS, key="audit_entity")
|
||||
action_sel = st.selectbox("Acción", ACTION_OPTIONS, key="audit_action")
|
||||
with col2:
|
||||
entity_id_sel = st.text_input("ID de entidad (opcional)", key="audit_entity_id",
|
||||
placeholder="ej: 42 o 2709_-1002447866299")
|
||||
user_id_sel = st.number_input("ID de usuario (0 = todos)", min_value=0,
|
||||
value=0, key="audit_user_id")
|
||||
with col3:
|
||||
fecha_desde = st.date_input("Desde", value=None, key="audit_desde")
|
||||
fecha_hasta = st.date_input("Hasta", value=None, key="audit_hasta")
|
||||
|
||||
per_page = st.selectbox("Registros por página", [10, 25, 50, 100], key="audit_per_page")
|
||||
|
||||
# --- Paginación ---
|
||||
if "audit_page" not in st.session_state:
|
||||
st.session_state.audit_page = 1
|
||||
|
||||
skip = (st.session_state.audit_page - 1) * per_page
|
||||
|
||||
# --- Llamada a la API ---
|
||||
with st.spinner("Cargando logs..."):
|
||||
logs = api.get_audit_logs(
|
||||
entity_type = None if entity_type_sel == "Todas" else entity_type_sel,
|
||||
entity_id = entity_id_sel.strip() or None,
|
||||
action = None if action_sel == "Todas" else action_sel,
|
||||
user_id = int(user_id_sel) if user_id_sel > 0 else None,
|
||||
date_from = fecha_desde.isoformat() if fecha_desde else None,
|
||||
date_to = fecha_hasta.isoformat() if fecha_hasta else None,
|
||||
skip = skip,
|
||||
limit = per_page,
|
||||
)
|
||||
|
||||
if logs is None:
|
||||
st.error("No se pudieron cargar los logs de auditoría.")
|
||||
return
|
||||
|
||||
total = len(logs)
|
||||
|
||||
if total == 0:
|
||||
st.info("No hay registros para los filtros seleccionados.")
|
||||
else:
|
||||
st.info(f"Mostrando {skip + 1}–{skip + total} — Página {st.session_state.audit_page}")
|
||||
|
||||
# --- Tabla de logs ---
|
||||
for log in logs:
|
||||
action_icons = {
|
||||
"create": "🟢",
|
||||
"update": "🟡",
|
||||
"delete": "🔴",
|
||||
"status_change":"🔵",
|
||||
}
|
||||
icon = action_icons.get(log.get("action", ""), "⚪")
|
||||
label = (
|
||||
f"{icon} [{_fmt_fecha(log.get('timestamp', ''))}] "
|
||||
f"{log.get('entity_type','').upper()} #{log.get('entity_id','')} — "
|
||||
f"{log.get('action','').upper()}"
|
||||
)
|
||||
|
||||
with st.expander(label, expanded=False):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.write(f"**ID log:** {log.get('id')}")
|
||||
st.write(f"**Entidad:** {log.get('entity_type')} `{log.get('entity_id')}`")
|
||||
st.write(f"**Acción:** {log.get('action')}")
|
||||
user_display = "🤖 Feeder" if log.get("user_id") == -1 else str(log.get("user_id") or "—")
|
||||
st.write(f"**Usuario:** {user_display}")
|
||||
st.write(f"**IP:** {log.get('ip_address') or '—'}")
|
||||
with col2:
|
||||
st.write(f"**Fecha:** {_fmt_fecha(log.get('timestamp', ''))}")
|
||||
|
||||
if log.get("before_value"):
|
||||
st.write("**Estado anterior:**")
|
||||
st.code(_fmt_json(log.get("before_value")), language="json")
|
||||
|
||||
if log.get("after_value"):
|
||||
st.write("**Estado posterior:**")
|
||||
st.code(_fmt_json(log.get("after_value")), language="json")
|
||||
|
||||
# --- Paginación ---
|
||||
hay_mas = total == per_page
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("◀️ Anterior", disabled=st.session_state.audit_page <= 1,
|
||||
use_container_width=True, key="audit_prev"):
|
||||
st.session_state.audit_page -= 1
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {st.session_state.audit_page}**")
|
||||
st.caption(f"Registros en esta página: {total}")
|
||||
with col_next:
|
||||
if st.button("Siguiente ▶️", disabled=not hay_mas,
|
||||
use_container_width=True, key="audit_next"):
|
||||
st.session_state.audit_page += 1
|
||||
st.rerun()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
src/pages/views/components.py
|
||||
Componentes reutilizables entre vistas.
|
||||
"""
|
||||
import streamlit as st
|
||||
import html
|
||||
|
||||
def safe_html(value: str, max_length: int = 3000) -> str:
|
||||
"""Escapa caracteres HTML y trunca el valor."""
|
||||
if not value:
|
||||
return ""
|
||||
return html.escape(str(value))[:max_length]
|
||||
|
||||
|
||||
ATTACHMENT_TYPE_MAP = {
|
||||
"photo": {"ext": "jpg", "mime": "image/jpeg", "icon": "🖼️"},
|
||||
"video": {"ext": "mp4", "mime": "video/mp4", "icon": "🎥"},
|
||||
"audio": {"ext": "mp3", "mime": "audio/mpeg", "icon": "🎵"},
|
||||
"document": {"ext": "bin", "mime": "application/octet-stream", "icon": "📄"},
|
||||
"sticker": {"ext": "webp", "mime": "image/webp", "icon": "🎭"},
|
||||
"voice": {"ext": "ogg", "mime": "audio/ogg", "icon": "🎙️"},
|
||||
"gif": {"ext": "gif", "mime": "image/gif", "icon": "🎞️"},
|
||||
}
|
||||
|
||||
|
||||
def render_adjuntos(adjuntos: list, api, context_key: str = ""):
|
||||
"""
|
||||
Renderiza la lista de adjuntos de un mensaje con botones de descarga.
|
||||
Reutilizable desde cualquier vista — alerts, messages, senders, etc.
|
||||
|
||||
Args:
|
||||
adjuntos: lista de dicts con datos del adjunto
|
||||
api: instancia de APIClient
|
||||
context_key: sufijo único para evitar colisión de keys de Streamlit
|
||||
"""
|
||||
if not adjuntos:
|
||||
st.caption("Sin adjuntos.")
|
||||
return
|
||||
|
||||
st.write(f"**📎 Adjuntos ({len(adjuntos)}):**")
|
||||
|
||||
for adj in adjuntos:
|
||||
adj_id = adj.get("id")
|
||||
adj_type = adj.get("type", "document")
|
||||
adj_desc = adj.get("description", "Sin descripción")
|
||||
|
||||
type_info = ATTACHMENT_TYPE_MAP.get(adj_type, ATTACHMENT_TYPE_MAP["document"])
|
||||
icon = type_info["icon"]
|
||||
ext = type_info["ext"]
|
||||
mime = type_info["mime"]
|
||||
|
||||
col_info, col_btn = st.columns([4, 2])
|
||||
with col_info:
|
||||
st.caption(
|
||||
f"{icon} **{adj_type.upper()}** — {adj_desc}"
|
||||
)
|
||||
with col_btn:
|
||||
if adj_id is None:
|
||||
st.caption("_sin ID_")
|
||||
continue
|
||||
|
||||
btn_key = f"dl_adj_{adj_id}_{context_key}"
|
||||
|
||||
if st.button("⬇️ Descargar", key=btn_key):
|
||||
with st.spinner(f"Descargando adjunto #{adj_id}..."):
|
||||
feeder_ok = api.has_connection()
|
||||
scraper_status = api.telegram_status()
|
||||
if not feeder_ok:
|
||||
st.error("No hay conexión con la API de Telegram. Révisa la conexión a Internet")
|
||||
continue
|
||||
if not scraper_status.get("is_active"):
|
||||
st.error("No se encontró una sesión de Telegram válida. Solicita ayuda a un admin")
|
||||
continue
|
||||
else:
|
||||
file_bytes = api.download_attachment(adj_id)
|
||||
|
||||
if file_bytes:
|
||||
filename = f"adjunto_{adj_id}.{ext}"
|
||||
st.download_button(
|
||||
label=f"💾 Guardar {filename}",
|
||||
data=file_bytes,
|
||||
file_name=filename,
|
||||
mime=mime,
|
||||
key=f"save_adj_{adj_id}_{context_key}"
|
||||
)
|
||||
else:
|
||||
st.error(
|
||||
f"No se pudo descargar el adjunto #{adj_id}. "
|
||||
"El archivo puede no estar disponible en Telegram."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from src.pages.views.components import safe_html
|
||||
|
||||
def agregar_grupo():
|
||||
api = api_client
|
||||
|
||||
col1, col2 = st.columns([3, 1])
|
||||
with col1:
|
||||
channel_url = st.text_input("ID del grupo/canal (número de Telegram)", value="", placeholder="Ej: https://web.telegram.org/a/#-10012142221")
|
||||
with col2:
|
||||
add_clicked = st.button("Agregar Grupo", key="add_group_btn", use_container_width=True)
|
||||
|
||||
if add_clicked:
|
||||
|
||||
channel_id_raw = channel_url.split(sep='#')[1]
|
||||
|
||||
feeder_ok = api.has_connection()
|
||||
scraper_status = api.telegram_status()
|
||||
|
||||
if not feeder_ok:
|
||||
st.error("No hay conexión con la API de Telegram. Révisa la conexión a Internet")
|
||||
return
|
||||
|
||||
if not scraper_status.get("is_active"):
|
||||
st.error("No se encontró una sesión de Telegram válida. Solicita ayuda a un admin")
|
||||
return
|
||||
|
||||
if not channel_id_raw:
|
||||
st.error("Debes indicar el ID del grupo/canal.")
|
||||
return
|
||||
|
||||
try:
|
||||
channel_id = int(channel_id_raw)
|
||||
except Exception:
|
||||
st.error("Hubo un error al intentar procesar el ID de la URL.")
|
||||
return
|
||||
|
||||
with st.spinner("Agregando grupo/canal..."):
|
||||
res = api.add_channel(channel_id)
|
||||
|
||||
if res is None:
|
||||
st.error("No se pudo agregar el grupo. Revisa la conexión o permisos.")
|
||||
else:
|
||||
if res.get("status") == "exists":
|
||||
st.error("El grupo/canal ya existe en el sistema.")
|
||||
else:
|
||||
st.success("Grupo/canal agregado correctamente.")
|
||||
try:
|
||||
st.json(res)
|
||||
except Exception:
|
||||
st.write(res)
|
||||
|
||||
def listar_grupos():
|
||||
"""Función para listar grupos (puede ser usada en el futuro para mostrar los grupos existentes)"""
|
||||
api = api_client
|
||||
with st.spinner("Cargando grupos..."):
|
||||
grupos = api.get_groups()
|
||||
|
||||
if grupos is None:
|
||||
st.error("No se pudieron cargar los grupos. Verifica tu autenticación.")
|
||||
return
|
||||
|
||||
# Filtro de búsqueda
|
||||
search_term = st.text_input("Buscar grupos por nombre o ID:", key="search_grupos")
|
||||
|
||||
# Filtrar grupos si hay término de búsqueda
|
||||
if search_term:
|
||||
grupos_filtrados = [
|
||||
grupo for grupo in grupos
|
||||
if search_term.lower() in str(grupo.get('name', '')).lower() or
|
||||
search_term.lower() in str(grupo.get('id_telegram', '')).lower()
|
||||
]
|
||||
else:
|
||||
grupos_filtrados = grupos
|
||||
|
||||
st.write(f"Mostrando {len(grupos_filtrados)} de {len(grupos)} grupos")
|
||||
|
||||
tipo_map = {
|
||||
"public_supergroup": "Canal publico", "private_supergroup": "Canal privado",
|
||||
"public_channel": "Canal publico", "private_channel": "Canal privado",
|
||||
"group": "Grupo publico", "private_group": "Grupo privado"
|
||||
}
|
||||
|
||||
for grupo in grupos_filtrados:
|
||||
#st.write(f"**ID Telegram:** {grupo.get('id_telegram', 'N/A')} | **Nombre:** {grupo.get('name', 'N/A')} | **Tipo:** {tipo_map.get(grupo.get('type', 'N/A'), 'Desconocido')}")
|
||||
#st.write(f"**Descripción:** {grupo.get('description', 'N/A')}")
|
||||
#st.divider()
|
||||
name = safe_html(grupo.get('name', 'N/A'))
|
||||
type = safe_html(tipo_map.get(grupo.get('type', 'N/A'), 'Desconocido'))
|
||||
group_id = safe_html(grupo.get('id_telegram', 'N/A'))
|
||||
description = safe_html(grupo.get('description', 'N/A'))
|
||||
|
||||
col_info, col_btn = st.columns([5, 1])
|
||||
|
||||
with col_info:
|
||||
st.markdown(
|
||||
f"**{name}** · {type}"
|
||||
f"<br><span style='font-size:11px;opacity:0.5;'>ID: {group_id} · Descripción: {description}</span>",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
with col_btn:
|
||||
st.link_button("Ir al grupo (Externo)", f"https://web.telegram.org/a/#{group_id}")
|
||||
#st.warning("Abriendo el grupo en una nueva pestaña...")
|
||||
|
||||
st.markdown(
|
||||
"<hr style='margin:6px 0;border:none;border-top:0.5px solid rgba(128,128,128,0.15);'>",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
@@ -0,0 +1,736 @@
|
||||
import streamlit as st
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CUSTOM CSS (dark theme, fonts, components)
|
||||
# ----------------------------------------------------------------------
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
/* Import fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
/* Global dark background */
|
||||
.stApp {
|
||||
background: #0d1117;
|
||||
}
|
||||
body, .stApp, .main > div {
|
||||
background-color: #0d1117;
|
||||
color: #e8f0fe;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
[data-testid="stSidebar"] {
|
||||
background-color: #111920;
|
||||
border-right: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
[data-testid="stSidebar"] .sidebar-content {
|
||||
background-color: #111920;
|
||||
}
|
||||
|
||||
/* Hide default Streamlit branding */
|
||||
#MainMenu {visibility: hidden;}
|
||||
footer {visibility: hidden;}
|
||||
header {visibility: hidden;}
|
||||
|
||||
/* Buttons for module navigation */
|
||||
.nav-button {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 0 6px 6px 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
margin-bottom: 2px;
|
||||
transition: all 0.15s;
|
||||
text-align: left;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 12.5px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
}
|
||||
.nav-button:hover {
|
||||
background: rgba(160,230,100,0.08);
|
||||
}
|
||||
.nav-button.active {
|
||||
background: rgba(160,230,100,0.1);
|
||||
border-left-color: #7ec030;
|
||||
color: #c8e6a0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
.solo-admin {
|
||||
font-size: 9px;
|
||||
color: #7ec030;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Role badges */
|
||||
.badge-admin {
|
||||
background: linear-gradient(135deg, #2d5a0e, #3d7a12);
|
||||
color: #c8e6a0;
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(160,230,100,0.25);
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.badge-operator {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.55);
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Step cards */
|
||||
.step-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-left: 3px solid rgba(160,230,100,0.4);
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.step-icon {
|
||||
font-size: 16px;
|
||||
min-width: 22px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #d4edb0;
|
||||
margin-bottom: 2px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.step-desc {
|
||||
font-size: 12.5px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Expanders (collapsible sections) */
|
||||
.streamlit-expanderHeader {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #c8e6a0 !important;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 0 !important;
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
.streamlit-expanderHeader:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
.streamlit-expanderContent {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Description text */
|
||||
.module-description {
|
||||
font-size: 13.5px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
line-height: 1.65;
|
||||
font-style: italic;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Header styling */
|
||||
.custom-header {
|
||||
background: linear-gradient(135deg, #1a2e0a 0%, #0d1a05 60%, #0d1117 100%);
|
||||
border-bottom: 1px solid rgba(160,230,100,0.15);
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.header-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(160,230,100,0.15);
|
||||
border: 1px solid rgba(160,230,100,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #c8e6a0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.header-sub {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.header-version {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# DATA (same as original JSX)
|
||||
# ----------------------------------------------------------------------
|
||||
MODULES = [
|
||||
{
|
||||
"id": "inicio",
|
||||
"icon": "🏠",
|
||||
"label": "Inicio",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Panel de bienvenida con métricas del sistema y estado del alimentador.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "¿Qué muestra esta pantalla?",
|
||||
"content": "Al iniciar sesión, el sistema te lleva al panel de inicio. Desde aquí podés ver un resumen del estado actual de la plataforma sin necesidad de navegar a ningún lado."
|
||||
},
|
||||
{
|
||||
"title": "Métricas principales",
|
||||
"steps": [
|
||||
{"icon": "🔔", "label": "Alertas abiertas", "desc": "Cantidad de alertas activas que requieren atención. Las alertas de hoy aparecen como un delta."},
|
||||
{"icon": "📡", "label": "Grupos activos", "desc": "Canales y grupos de Telegram que el sistema está monitoreando actualmente."},
|
||||
{"icon": "📋", "label": "Reglas activas / totales", "desc": "Cuántas reglas de detección están habilitadas y cuántas existen en total."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Alertas recientes",
|
||||
"content": "La columna izquierda muestra las últimas 5 alertas generadas. Cada tarjeta indica descripción de la regla, grupo de origen, fecha y estado (Abierta / En curso / Cerrada)."
|
||||
},
|
||||
{
|
||||
"title": "Estado del Scraper (Alimentador)",
|
||||
"content": "En la parte inferior aparece un banner verde 🟢 o rojo 🔴 que indica si el alimentador de Telegram está activo. Si está caído, los administradores verán la opción de reiniciar la sesión desde el Panel de Administrador."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alertas",
|
||||
"icon": "🔔",
|
||||
"label": "Alertas",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Gestión del ciclo de vida de alertas: revisión, notas y cierre con reporte PDF.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "¿Qué es una alerta?",
|
||||
"content": "Una alerta se genera automáticamente cuando un mensaje de Telegram coincide con el patrón (regex) de alguna regla activa. Cada alerta está vinculada al mensaje original, la regla que la disparó y el grupo de procedencia."
|
||||
},
|
||||
{
|
||||
"title": "Filtros disponibles",
|
||||
"steps": [
|
||||
{"icon": "⚠️", "label": "Severidad", "desc": "Alta / Media / Baja — heredada de la regla que disparó la alerta."},
|
||||
{"icon": "🔄", "label": "Estado", "desc": "Pendiente (open) · En Curso (in_progress) · Resuelto (close)."},
|
||||
{"icon": "📅", "label": "Rango de fechas", "desc": "Combiná fecha y hora de inicio y fin para acotar resultados."},
|
||||
{"icon": "📄", "label": "Resultados por página", "desc": "Seleccioná 5 / 10 / 20 / 50 / 100 alertas por página."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Ver el mensaje original",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Expandí la alerta", "desc": "Hacé clic sobre la tarjeta de la alerta para desplegarla."},
|
||||
{"icon": "2️⃣", "label": "Botón «Ver mensaje»", "desc": "Abre un diálogo con el remitente, contenido completo y adjuntos del mensaje."},
|
||||
{"icon": "3️⃣", "label": "Descargar adjuntos", "desc": "Si el mensaje tiene archivos, aparece el botón «⬇️ Descargar» por cada adjunto."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Administrar una alerta",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Botón «Administrar alerta»", "desc": "Abre el panel de gestión. El sistema cambia el estado a En Curso automáticamente si estaba Pendiente."},
|
||||
{"icon": "2️⃣", "label": "Leer el historial", "desc": "El panel muestra todas las notas previas con nombre de usuario, rol y fecha."},
|
||||
{"icon": "3️⃣", "label": "Agregar una nota", "desc": "Escribí en el campo de texto y presioná «Guardar»."},
|
||||
{"icon": "4️⃣", "label": "Resolver o reabrir", "desc": "Marcá el checkbox «Marcar como resuelta» antes de guardar para cerrar la alerta."},
|
||||
{"icon": "5️⃣", "label": "Descargar PDF", "desc": "Al resolver, se genera automáticamente un reporte PDF descargable con toda la trazabilidad."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mensajes",
|
||||
"icon": "💬",
|
||||
"label": "Mensajes",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Búsqueda y exploración de todos los mensajes capturados por el alimentador.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Cómo buscar mensajes",
|
||||
"steps": [
|
||||
{"icon": "🔍", "label": "Texto a buscar", "desc": "Podés ingresar una palabra o frase. Si lo dejás vacío, trae todos los mensajes paginados."},
|
||||
{"icon": "📡", "label": "Grupo", "desc": "Seleccioná un grupo específico o dejá «Todos los grupos»."},
|
||||
{"icon": "👤", "label": "Sender", "desc": "Filtrá por username, nombre o ID de Telegram del remitente."},
|
||||
{"icon": "📅", "label": "Rango de fechas y hora", "desc": "Combiná fecha de inicio y fin con hora exacta para búsquedas precisas."},
|
||||
{"icon": "▶️", "label": "Botón «🔍 Buscar»", "desc": "Ejecuta la búsqueda con los filtros seleccionados. Reinicia la paginación a la página 1."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Interpretar los resultados",
|
||||
"content": "Cada tarjeta muestra: fecha y hora, username del remitente, grupo de origen y un contador de adjuntos 📎. Expandí la tarjeta para ver el contenido completo y descargar los archivos adjuntos si los hubiera."
|
||||
},
|
||||
{
|
||||
"title": "Descargar adjuntos",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Expandí el mensaje", "desc": "Hacé clic sobre la tarjeta para desplegarla."},
|
||||
{"icon": "2️⃣", "label": "Botón «⬇️ Descargar»", "desc": "Inicia la descarga del archivo desde Telegram a través del servidor."},
|
||||
{"icon": "3️⃣", "label": "Botón «💾 Guardar»", "desc": "Una vez descargado, aparece el botón para guardar el archivo en tu computadora."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "reglas",
|
||||
"icon": "📋",
|
||||
"label": "Reglas",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Creación y edición de reglas de detección basadas en expresiones regulares.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "¿Qué es una regla?",
|
||||
"content": "Una regla define un patrón (expresión regular o texto simple) que el sistema evalúa contra cada nuevo mensaje entrante. Si hay coincidencia, se genera una alerta automáticamente."
|
||||
},
|
||||
{
|
||||
"title": "Crear una nueva regla",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Ir a Reglas → pestaña «Crear una regla»", "desc": "Navegá desde el menú lateral a «📋 Reglas» y hacé clic en la pestaña «Crear una regla»."},
|
||||
{"icon": "2️⃣", "label": "Descripción", "desc": "Escribí una descripción clara del propósito. Ej: «Detecta menciones de transferencias bancarias»."},
|
||||
{"icon": "3️⃣", "label": "Regex / Palabra clave", "desc": "Ingresá el patrón. Puede ser texto simple (ej: transferencia) o una regex completa (ej: (banco|cbu|alias)). Podés usar https://regex101.com para probarla."},
|
||||
{"icon": "4️⃣", "label": "Severidad", "desc": "Seleccioná Alta / Media / Baja según el nivel de riesgo."},
|
||||
{"icon": "5️⃣", "label": "Activa", "desc": "Si está marcada, la regla evalúa mensajes desde el momento en que se guarda."},
|
||||
{"icon": "6️⃣", "label": "Aplicar al histórico", "desc": "Si lo marcás, el sistema recorre TODOS los mensajes ya guardados y genera alertas retroactivas. Puede tardar si hay muchos mensajes."},
|
||||
{"icon": "7️⃣", "label": "Botón «Crear regla»", "desc": "Guarda la regla. Si la regex es inválida, el sistema lo indica antes de guardar."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Editar o desactivar una regla existente",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Pestaña «Listar reglas»", "desc": "Buscá la regla por descripción usando el campo de búsqueda."},
|
||||
{"icon": "2️⃣", "label": "Expandí la regla", "desc": "Hacé clic sobre la tarjeta para verla."},
|
||||
{"icon": "3️⃣", "label": "Botón «Editar»", "desc": "Aparece el formulario de edición con los campos actuales pre-cargados."},
|
||||
{"icon": "4️⃣", "label": "Modificá los campos", "desc": "Podés cambiar descripción, regex, severidad o desactivarla desmarcando «Activa»."},
|
||||
{"icon": "5️⃣", "label": "«Guardar cambios» o «Cancelar»", "desc": "Confirma o descarta los cambios. La regla se actualiza inmediatamente."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "grupos",
|
||||
"icon": "📡",
|
||||
"label": "Grupos y Canales",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Alta y visualización de grupos o canales de Telegram monitoreados.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Ver grupos activos",
|
||||
"content": "La pestaña «Grupos activos» lista todos los grupos/canales que el sistema está monitoreando. Cada entrada muestra nombre, tipo (Canal público, Canal privado, Grupo) e ID de Telegram. El botón «Ir al grupo» abre Telegram Web en una nueva pestaña."
|
||||
},
|
||||
{
|
||||
"title": "Agregar un grupo o canal",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Obtener el ID de Telegram", "desc": "En Telegram Web (web.telegram.org), abrí el grupo/canal. La URL tendrá la forma .../#-100XXXXXXXXX. Copiá ese fragmento completo."},
|
||||
{"icon": "2️⃣", "label": "Pegar la URL", "desc": "En la pestaña «Agregar grupo», pegá la URL completa en el campo."},
|
||||
{"icon": "3️⃣", "label": "Botón «Agregar Grupo»", "desc": "El sistema valida el ID contra Telegram. Si es accesible, lo agrega a la base de datos con sus datos actuales (nombre, tipo, descripción)."},
|
||||
{"icon": "4️⃣", "label": "Resultado", "desc": "Verás un mensaje de éxito y los datos del grupo creado o actualizado. Si ya existía, se actualizan sus datos desde Telegram."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tipos de grupos soportados",
|
||||
"steps": [
|
||||
{"icon": "📢", "label": "Canal público / privado", "desc": "ID con prefijo -100. Monitorea mensajes del canal."},
|
||||
{"icon": "👥", "label": "Supergrupo público / privado", "desc": "ID con prefijo -100. Grupos grandes de Telegram."},
|
||||
{"icon": "💬", "label": "Grupo normal", "desc": "ID negativo simple sin el 100. Grupos pequeños de hasta 200 personas."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "estadisticas",
|
||||
"icon": "📊",
|
||||
"label": "Estadísticas",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Visualizaciones y métricas agregadas del período seleccionado.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Seleccionar período",
|
||||
"steps": [
|
||||
{"icon": "📅", "label": "Fecha y hora de inicio/fin", "desc": "Usá los cuatro controles (fecha desde, hora desde, fecha hasta, hora hasta) para definir el rango."},
|
||||
{"icon": "▶️", "label": "Botón «Aplicar»", "desc": "Recarga todas las métricas y gráficos para el período elegido. Por defecto muestra los últimos 30 días."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Resumen ejecutivo",
|
||||
"content": "Fila de 7 métricas: total de alertas, abiertas, en curso, cerradas, mensajes totales, grupos activos y reglas activas."
|
||||
},
|
||||
{
|
||||
"title": "Gráficos disponibles",
|
||||
"steps": [
|
||||
{"icon": "📈", "label": "Alertas en el tiempo", "desc": "Gráfico de líneas por día: abiertas (rojo) · en curso (naranja) · cerradas (verde)."},
|
||||
{"icon": "📊", "label": "Distribución por severidad", "desc": "Barras con cantidad de alertas por nivel de severidad y porcentaje."},
|
||||
{"icon": "📊", "label": "Alertas por grupo", "desc": "Top 10 grupos con más alertas generadas."},
|
||||
{"icon": "📊", "label": "Reglas más disparadas", "desc": "Top 10 reglas por cantidad de coincidencias, con su nivel de severidad."},
|
||||
{"icon": "📊", "label": "Volumen de mensajes por día", "desc": "Barras con la cantidad de mensajes capturados cada día."},
|
||||
{"icon": "🟩", "label": "Heatmap hora × día", "desc": "Tabla de calor que muestra en qué horas y días hay más actividad. El verde más oscuro = mayor volumen."},
|
||||
{"icon": "👤", "label": "Top remitentes", "desc": "Los 10 usuarios más activos, con su cantidad de mensajes y alertas generadas."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "remitentes",
|
||||
"icon": "👥",
|
||||
"label": "Remitentes",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Exploración de los usuarios de Telegram detectados por el alimentador.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Buscar un remitente",
|
||||
"steps": [
|
||||
{"icon": "🔍", "label": "Campo de búsqueda", "desc": "Filtrá por nombre, username o ID de Telegram. La búsqueda es local sobre la página actual."},
|
||||
{"icon": "📄", "label": "Por página", "desc": "Seleccioná 10 / 20 / 50 / 100 remitentes por página."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Ver el perfil de un remitente",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Botón «Ver»", "desc": "A la derecha de cada remitente. Abre su perfil con métricas."},
|
||||
{"icon": "2️⃣", "label": "Datos del perfil", "desc": "ID de Telegram, username, nombre completo, teléfono y tipo (👤 Usuario / 🤖 Bot / 📢 Canal)."},
|
||||
{"icon": "3️⃣", "label": "Mensajes enviados", "desc": "Lista paginada de todos los mensajes del remitente con grupo, fecha y adjuntos descargables."},
|
||||
{"icon": "4️⃣", "label": "← Volver", "desc": "Botón para regresar al listado de remitentes."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin",
|
||||
"icon": "📝",
|
||||
"label": "Panel de Administrador",
|
||||
"roles": ["admin"],
|
||||
"description": "Gestión de usuarios, historial de modificaciones, estado del alimentador y auditoría.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Acceso",
|
||||
"content": "Este módulo solo es visible para usuarios con rol admin. Aparece como «📝 Panel de Administrador» en el menú lateral."
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Usuarios → Usuarios pendientes",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Ver solicitudes", "desc": "Lista los usuarios que se registraron pero aún no fueron activados (active = false)."},
|
||||
{"icon": "2️⃣", "label": "Botón «Activar Usuario»", "desc": "Cambia active a true. El usuario podrá iniciar sesión desde ese momento."},
|
||||
{"icon": "3️⃣", "label": "Botón «Rechazar Usuario»", "desc": "Elimina permanentemente al usuario del sistema."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Usuarios → Crear usuario",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Completar el formulario", "desc": "Nombre, correo, contraseña, rol (operator / admin) y si se activa inmediatamente."},
|
||||
{"icon": "2️⃣", "label": "Botón «Crear usuario»", "desc": "El usuario queda registrado y activo si se marcó la opción correspondiente."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Usuarios → Editar usuario",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Seleccionar usuario", "desc": "Buscá en el desplegable por nombre, email o ID."},
|
||||
{"icon": "2️⃣", "label": "Modificar campos", "desc": "Nombre, email, rol, estado activo y contraseña (opcional)."},
|
||||
{"icon": "3️⃣", "label": "Botón «Guardar cambios»", "desc": "Aplica los cambios inmediatamente. Se registra en el log de auditoría de usuarios."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Logs",
|
||||
"content": "Muestra el historial completo de modificaciones de usuarios: quién modificó qué, cuándo y desde qué IP. Se presenta como tabla con todos los campos de auditoría."
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Sistema → Alimentador",
|
||||
"steps": [
|
||||
{"icon": "✅", "label": "Alimentador activo", "desc": "Muestra «El alimentador está activo y funcionando correctamente»."},
|
||||
{"icon": "⚠️", "label": "Alimentador caído", "desc": "Aparece el panel de reinicio de sesión de Telegram."},
|
||||
{"icon": "1️⃣", "label": "Botón «Iniciar Nueva Sesión»", "desc": "El sistema envía un código de verificación al teléfono configurado en Telegram."},
|
||||
{"icon": "2️⃣", "label": "Ingresar el código", "desc": "Copiá el código que llegó a Telegram y pegalo en el campo «Código de verificación»."},
|
||||
{"icon": "3️⃣", "label": "2FA (si aplica)", "desc": "Si tu cuenta tiene contraseña de dos factores, ingresala en el campo correspondiente."},
|
||||
{"icon": "4️⃣", "label": "Botón «Verificar Código»", "desc": "Completa la autenticación y reactiva el alimentador."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Sistema → Backup del Sistema",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Campos «Bases de datos, Configuración, Certificados SSL y Sesiones Telegram»", "desc": "Selecciona la configuración del backup."},
|
||||
{"icon": "2️⃣", "label": "Botón «Generar y Descargar Backup»", "desc": "Descarga un archivo comprimido con lo incluido en los campos anteriores"},
|
||||
{"icon": "⚠️", "label": "Sobre el backup", "desc": "Para restaurar el sistema, se debe incluir el comprimido en la raíz y ejecutar el script restore_backup.ps1|sh según el sistema operativo"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Auditoría",
|
||||
"content": "Log completo de acciones sobre todas las entidades del sistema (grupos, mensajes, remitentes, reglas, alertas). Ver sección «Auditoría» para más detalle."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "auditoria",
|
||||
"icon": "🔍",
|
||||
"label": "Auditoría",
|
||||
"roles": ["admin"],
|
||||
"description": "Consulta de logs de auditoría con filtros por entidad, acción, usuario y fecha.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Acceso",
|
||||
"content": "Disponible en la pestaña «Auditoría» dentro del Panel de Administrador. Solo accesible para administradores."
|
||||
},
|
||||
{
|
||||
"title": "Filtros disponibles",
|
||||
"steps": [
|
||||
{"icon": "🏷️", "label": "Entidad", "desc": "Filtrá por tipo: rule · alert · group · message · sender."},
|
||||
{"icon": "⚡", "label": "Acción", "desc": "create · update · delete · status_change."},
|
||||
{"icon": "🆔", "label": "ID de entidad", "desc": "ID específico de la entidad afectada. Para mensajes usa el formato id_mensaje_id_grupo."},
|
||||
{"icon": "👤", "label": "ID de usuario", "desc": "ID numérico del usuario que realizó la acción. El Feeder aparece como -1 🤖."},
|
||||
{"icon": "📅", "label": "Rango de fechas", "desc": "Desde / Hasta para acotar por período."},
|
||||
{"icon": "📄", "label": "Registros por página", "desc": "10 / 25 / 50 / 100."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Interpretar un registro",
|
||||
"steps": [
|
||||
{"icon": "🟢", "label": "create", "desc": "Se creó una entidad nueva."},
|
||||
{"icon": "🟡", "label": "update", "desc": "Se modificó una entidad existente. El registro muestra estado anterior y posterior."},
|
||||
{"icon": "🔴", "label": "delete", "desc": "Se eliminó una entidad. El registro conserva el estado previo a la eliminación."},
|
||||
{"icon": "🔵", "label": "status_change", "desc": "Cambio de estado en una alerta (open → in_progress → close)."},
|
||||
{"icon": "🤖", "label": "Usuario: Feeder", "desc": "La acción fue realizada automáticamente por el alimentador (system)."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cuenta",
|
||||
"icon": "⚙️",
|
||||
"label": "Mi Cuenta",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Edición de datos personales y contraseña del usuario autenticado.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Editar datos personales",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Ir a «⚙️ Mi Cuenta»", "desc": "Desde el menú lateral."},
|
||||
{"icon": "2️⃣", "label": "Marcar «Activar edición»", "desc": "Los campos están bloqueados por defecto para evitar cambios accidentales."},
|
||||
{"icon": "3️⃣", "label": "Modificar nombre y/o email", "desc": "El rol y el ID no son editables desde esta pantalla."},
|
||||
{"icon": "4️⃣", "label": "Nueva contraseña (opcional)", "desc": "Si dejás el campo vacío, la contraseña actual no cambia."},
|
||||
{"icon": "5️⃣", "label": "Botón «Guardar cambios»", "desc": "Aplica los cambios. Si hubo un error (email duplicado, etc.) aparece un mensaje de error descriptivo."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sesion",
|
||||
"icon": "🔐",
|
||||
"label": "Acceso al sistema",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Registro, inicio de sesión y cierre de sesión.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Registrarse",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Pantalla de login → «Registrarse»", "desc": "En la pantalla inicial, hacé clic en el botón «Registrarse» debajo del formulario de ingreso."},
|
||||
{"icon": "2️⃣", "label": "Completar el formulario", "desc": "Nombre completo, correo electrónico y contraseña (mínimo 8 caracteres)."},
|
||||
{"icon": "3️⃣", "label": "Botón «Solicitar acceso»", "desc": "Crea la cuenta con rol operator y estado inactivo. Un administrador debe aprobarla antes de que puedas ingresar."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Iniciar sesión",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Ingresá tu correo y contraseña", "desc": "En la pantalla principal."},
|
||||
{"icon": "2️⃣", "label": "Botón «Ingresar al sistema»", "desc": "Valida las credenciales. Si la cuenta está inactiva, recibirás un error 401."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Cerrar sesión",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Botón «Cerrar sesión»", "desc": "Al pie del menú lateral. Limpia el token y todos los datos de sesión."},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# HELPER FUNCTIONS (sin sidebar, solo renderizado)
|
||||
# ----------------------------------------------------------------------
|
||||
def render_role_badges(roles):
|
||||
html = '<div style="display: flex; gap: 6px; margin-bottom: 12px;">'
|
||||
if "admin" in roles:
|
||||
html += '<span style="background: linear-gradient(135deg, #2d5a0e, #3d7a12); color: #c8e6a0; font-size: 10px; font-family: monospace; padding: 3px 10px; border-radius: 3px; border: 1px solid rgba(160,230,100,0.25);">ADMIN</span>'
|
||||
if "operator" in roles:
|
||||
html += '<span style="background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.55); font-size: 10px; font-family: monospace; padding: 3px 10px; border-radius: 3px; border: 1px solid rgba(255,255,255,0.12);">OPERADOR</span>'
|
||||
html += '</div>'
|
||||
return html
|
||||
|
||||
def render_steps(steps):
|
||||
"""Renderiza cada paso como una tarjeta usando CSS nativo de Streamlit"""
|
||||
if not steps:
|
||||
return
|
||||
# Inyectar CSS una sola vez para estilizar las tarjetas
|
||||
st.markdown("""
|
||||
<style>
|
||||
.doc-step-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-left: 3px solid rgba(160,230,100,0.4);
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.doc-step-icon {
|
||||
font-size: 16px;
|
||||
min-width: 22px;
|
||||
}
|
||||
.doc-step-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #d4edb0;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.doc-step-desc {
|
||||
font-size: 12.5px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
for step in steps:
|
||||
html = f"""
|
||||
<div class="doc-step-card">
|
||||
<div class="doc-step-icon">{step['icon']}</div>
|
||||
<div>
|
||||
<div class="doc-step-label">{step['label']}</div>
|
||||
<div class="doc-step-desc">{step['desc']}</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
st.markdown(html, unsafe_allow_html=True)
|
||||
|
||||
def render_sections(sections):
|
||||
"""Renderiza cada sección como un expander"""
|
||||
for i, section in enumerate(sections):
|
||||
# Usamos una key única para cada expander basada en el índice
|
||||
with st.expander(section["title"], expanded=True):
|
||||
if "content" in section:
|
||||
st.markdown(f'<p style="font-size: 13px; color: rgba(255,255,255,0.6); line-height: 1.65;">{section["content"]}</p>', unsafe_allow_html=True)
|
||||
if "steps" in section:
|
||||
render_steps(section["steps"])
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# MAIN FUNCTION (sin sidebar)
|
||||
# ----------------------------------------------------------------------
|
||||
def show_documentation():
|
||||
# Inicializar estado del módulo activo
|
||||
if "doc_active_module" not in st.session_state:
|
||||
st.session_state.doc_active_module = MODULES[0]["id"]
|
||||
|
||||
# Título principal
|
||||
st.markdown("""
|
||||
<div style="margin-bottom: 24px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
|
||||
<div style="font-size: 28px; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(160,230,100,0.12), rgba(160,230,100,0.04)); border: 1px solid rgba(160,230,100,0.2); border-radius: 12px;">📘</div>
|
||||
<h1 style="margin: 0; font-size: 22px; font-weight: 700; color: #e8f5da;">Documentación</h1>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: rgba(255,255,255,0.5);">Manual de usuario de la Threat Intelligence Platform</p>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# Controles de búsqueda y filtro (en dos columnas)
|
||||
col1, col2 = st.columns([3, 2])
|
||||
with col1:
|
||||
search = st.text_input("🔍 Buscar módulo", placeholder="Ej: Alertas", key="doc_search", label_visibility="collapsed")
|
||||
with col2:
|
||||
role_filter = st.selectbox(
|
||||
"Filtrar por rol",
|
||||
options=["all", "admin", "operator"],
|
||||
format_func=lambda x: {"all": "Todos", "admin": "Admin", "operator": "Operador"}[x],
|
||||
key="doc_role_filter",
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
|
||||
# Filtrar módulos con búsqueda en label, descripción, títulos, contenido y pasos
|
||||
search_lower = search.strip().lower()
|
||||
|
||||
def matches_query(module):
|
||||
if not search_lower:
|
||||
return True
|
||||
|
||||
if search_lower in module["label"].lower() or search_lower in module["description"].lower():
|
||||
return True
|
||||
|
||||
for section in module.get("sections", []):
|
||||
if search_lower in section.get("title", "").lower():
|
||||
return True
|
||||
if search_lower in section.get("content", "").lower():
|
||||
return True
|
||||
for step in section.get("steps", []):
|
||||
if any(search_lower in str(step.get(key, "")).lower() for key in ("label", "desc", "icon")):
|
||||
return True
|
||||
return False
|
||||
|
||||
filtered_modules = [
|
||||
m for m in MODULES
|
||||
if (role_filter == "all" or role_filter in m["roles"]) and matches_query(m)
|
||||
]
|
||||
|
||||
if not filtered_modules:
|
||||
st.warning("No hay módulos que coincidan con la búsqueda.")
|
||||
return
|
||||
|
||||
# Navegación por pestañas (usando radio horizontal)
|
||||
module_labels = [f"{m['icon']} {m['label']}" for m in filtered_modules]
|
||||
module_ids = [m["id"] for m in filtered_modules]
|
||||
|
||||
# Asegurar que el activo esté en la lista
|
||||
if st.session_state.doc_active_module not in module_ids:
|
||||
st.session_state.doc_active_module = module_ids[0]
|
||||
|
||||
default_index = module_ids.index(st.session_state.doc_active_module)
|
||||
selected_label = st.radio(
|
||||
"Módulo",
|
||||
options=module_labels,
|
||||
index=default_index,
|
||||
key="doc_module_radio",
|
||||
label_visibility="collapsed",
|
||||
horizontal=True,
|
||||
)
|
||||
selected_idx = module_labels.index(selected_label)
|
||||
st.session_state.doc_active_module = module_ids[selected_idx]
|
||||
|
||||
# Mostrar el módulo activo
|
||||
active_module = next(m for m in filtered_modules if m["id"] == st.session_state.doc_active_module)
|
||||
|
||||
st.markdown("---")
|
||||
# Badges y descripción
|
||||
st.markdown(render_role_badges(active_module["roles"]), unsafe_allow_html=True)
|
||||
st.markdown(f'<p style="font-size: 13.5px; color: rgba(255,255,255,0.55); font-style: italic; margin-bottom: 24px;">{active_module["description"]}</p>', unsafe_allow_html=True)
|
||||
|
||||
# Secciones expandibles
|
||||
render_sections(active_module["sections"])
|
||||
@@ -0,0 +1,179 @@
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from src.pages.views.help import show_documentation
|
||||
|
||||
|
||||
def _metric_card(label: str, value: str, delta: str = "", delta_up: bool = True, accent: bool = False):
|
||||
border_top = "border-top: 2px solid #3B6D11;" if accent else ""
|
||||
delta_color = "#3B6D11" if delta_up else "#A32D2D"
|
||||
delta_html = f'<div style="font-size:11px;color:{delta_color};margin-top:4px;">{delta}</div>' if delta else ""
|
||||
st.markdown(f"""
|
||||
<div style="background:var(--background-color);border:0.5px solid rgba(128,128,128,0.2);
|
||||
border-radius:8px;padding:14px 16px;{border_top}">
|
||||
<div style="font-size:11px;opacity:0.55;margin-bottom:6px;">{label}</div>
|
||||
<div style="font-size:22px;font-weight:500;line-height:1;">{value}</div>
|
||||
{delta_html}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _scraper_banner(status):
|
||||
"""Banner de estado del scraper usando componentes nativos."""
|
||||
if status is None:
|
||||
st.warning("No se pudo obtener el estado del scraper.")
|
||||
return
|
||||
|
||||
is_active = status.get("is_active", False)
|
||||
last_cycle = status.get("last_cycle")
|
||||
last_error = status.get("last_error")
|
||||
last_cycle_txt = ""
|
||||
if last_cycle:
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_cycle)
|
||||
diff = datetime.utcnow() - dt
|
||||
mins = int(diff.total_seconds() // 60)
|
||||
last_cycle_txt = f"Último ciclo hace {mins} min" if mins < 60 else f"Último ciclo hace {mins // 60} h"
|
||||
except Exception:
|
||||
last_cycle_txt = str(last_cycle)
|
||||
|
||||
sub_txt = f"{last_cycle_txt}"
|
||||
dot = "🟢" if is_active else "🔴"
|
||||
estado = "Activo" if is_active else "Inactivo"
|
||||
|
||||
with st.container(border=True):
|
||||
st.markdown(f"**{dot} Scraper de Telegram — {estado}**")
|
||||
st.caption(sub_txt)
|
||||
if last_error:
|
||||
st.error(f"Error: {last_error[:120]}")
|
||||
|
||||
|
||||
def mostrar_inicio(usuario: dict):
|
||||
api = api_client
|
||||
nombre = usuario.get("name", "").split()[0]
|
||||
|
||||
if st.session_state.get("show_help_from_home"):
|
||||
show_documentation()
|
||||
return
|
||||
|
||||
st.markdown(f"""
|
||||
<div style="margin-bottom:20px;">
|
||||
<div style="font-size:22px;font-weight:500;">Bienvenido, {nombre}</div>
|
||||
<div style="font-size:13px;opacity:0.5;margin-top:2px;">
|
||||
{datetime.now().strftime("%A %d de %B de %Y").capitalize()}
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
with st.spinner("Cargando..."):
|
||||
alertas_abiertas = api.get_alerts(status="open", limit=100) or []
|
||||
alertas_hoy = api.get_alerts(
|
||||
status="open",
|
||||
date_from=datetime.utcnow().replace(hour=0, minute=0, second=0).isoformat(),
|
||||
limit=100
|
||||
) or []
|
||||
grupos = api.get_groups() or []
|
||||
reglas = api.get_rules(limit=200) or []
|
||||
scraper_status = api.telegram_status()
|
||||
|
||||
reglas_activas = [r for r in reglas if r.get("is_active")]
|
||||
|
||||
# Métricas
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
with col1:
|
||||
_metric_card(
|
||||
label="Alertas abiertas",
|
||||
value=str(len(alertas_abiertas)),
|
||||
delta=f"+{len(alertas_hoy)} hoy" if alertas_hoy else "",
|
||||
delta_up=False,
|
||||
accent=True
|
||||
)
|
||||
with col2:
|
||||
_metric_card(label="Grupos activos", value=str(len(grupos)))
|
||||
with col3:
|
||||
_metric_card(label="Reglas activas", value=str(len(reglas_activas)))
|
||||
with col4:
|
||||
_metric_card(label="Reglas totales", value=str(len(reglas)))
|
||||
|
||||
st.markdown("<div style='height:16px'></div>", unsafe_allow_html=True)
|
||||
|
||||
# Dos columnas
|
||||
col_left, col_right = st.columns(2)
|
||||
|
||||
SEV_COLOR = {"alta": "#E24B4A", "media": "#BA7517", "baja": "#639922"}
|
||||
STATUS_BG = {"open": ("#FAECE7", "#993C1D"), "close": ("#EAF3DE", "#27500A"), "in_progress": ("#FFF3E0", "#8C5A00")}
|
||||
STATUS_LABEL = {"open": "Abierta", "close": "Cerrada", "in_progress": "En curso"}
|
||||
|
||||
with col_left:
|
||||
st.markdown("**Alertas recientes**")
|
||||
alertas_recientes = api.get_alerts(limit=5) or []
|
||||
if not alertas_recientes:
|
||||
st.info("Sin alertas recientes.")
|
||||
else:
|
||||
for alert in alertas_recientes:
|
||||
rule = alert.get("rule") or {}
|
||||
sev = (rule.get("severity") or "media").lower()
|
||||
status = alert.get("status", "open")
|
||||
desc = (rule.get("description") or "Sin descripcion")[:55]
|
||||
group_id = api.get_group(alert.get("group_id", "")).get("name", alert.get("group_id", "N/A"))
|
||||
fecha = ""
|
||||
try:
|
||||
fecha = datetime.fromisoformat(
|
||||
str(alert.get("created_at", ""))
|
||||
).strftime("%d/%m %H:%M")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dot_c = SEV_COLOR.get(sev, "#888")
|
||||
bg, txt = STATUS_BG.get(status, ("#f0f0f0", "#444"))
|
||||
slabel = STATUS_LABEL.get(status, status)
|
||||
|
||||
st.markdown(
|
||||
'<div style="display:flex;align-items:flex-start;gap:10px;padding:8px 0;'
|
||||
'border-bottom:0.5px solid rgba(128,128,128,0.15);">'
|
||||
f'<div style="width:7px;height:7px;border-radius:50%;background:{dot_c};'
|
||||
'flex-shrink:0;margin-top:5px;"></div>'
|
||||
'<div style="flex:1;min-width:0;">'
|
||||
f'<div style="font-size:12px;line-height:1.4;">{desc}</div>'
|
||||
f'<div style="font-size:11px;opacity:0.5;margin-top:2px;">Grupo {group_id} · {fecha}</div>'
|
||||
'</div>'
|
||||
f'<div style="font-size:10px;padding:2px 8px;border-radius:10px;'
|
||||
f'background:{bg};color:{txt};flex-shrink:0;white-space:nowrap;">{slabel}</div>'
|
||||
'</div>',
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
|
||||
with col_right:
|
||||
st.markdown("**Grupos monitoreados**")
|
||||
tipo_map = {
|
||||
"public_supergroup": "Canal publico", "private_supergroup": "Canal privado",
|
||||
"public_channel": "Canal publico", "private_channel": "Canal privado",
|
||||
"group": "Grupo publico", "private_group": "Grupo privado"
|
||||
}
|
||||
if not grupos:
|
||||
st.info("Sin grupos activos.")
|
||||
else:
|
||||
for grupo in grupos[:5]:
|
||||
nombre_g = grupo.get("name", "Sin nombre")
|
||||
tipo_txt = tipo_map.get(grupo.get("type", ""), "Desconocido")
|
||||
st.markdown(
|
||||
'<div style="display:flex;align-items:center;gap:10px;padding:8px 0;'
|
||||
'border-bottom:0.5px solid rgba(128,128,128,0.15);">'
|
||||
'<div style="width:28px;height:28px;border-radius:6px;background:#EAF3DE;'
|
||||
'display:flex;align-items:center;justify-content:center;flex-shrink:0;">'
|
||||
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none">'
|
||||
'<path d="M2 10c0-2.76 1.34-4 5-4s5 1.24 5 4" stroke="#3B6D11" '
|
||||
'stroke-width="1.2" stroke-linecap="round"/>'
|
||||
'<circle cx="7" cy="4" r="2" stroke="#3B6D11" stroke-width="1.2"/>'
|
||||
'</svg></div>'
|
||||
'<div style="flex:1;min-width:0;">'
|
||||
f'<div style="font-size:12px;font-weight:500;white-space:nowrap;'
|
||||
f'overflow:hidden;text-overflow:ellipsis;">{nombre_g}</div>'
|
||||
f'<div style="font-size:10px;opacity:0.5;">{tipo_txt}</div>'
|
||||
'</div></div>',
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
|
||||
st.markdown("<div style='height:16px'></div>", unsafe_allow_html=True)
|
||||
|
||||
_scraper_banner(scraper_status)
|
||||
@@ -0,0 +1,193 @@
|
||||
from typing import Optional
|
||||
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from datetime import datetime, time
|
||||
from src.pages.views.components import render_adjuntos
|
||||
|
||||
def mostrar_mensajes():
|
||||
"""Página de mensajes con búsqueda y descarga de adjuntos."""
|
||||
api = api_client
|
||||
|
||||
# Inicializar estado
|
||||
if "messages_page" not in st.session_state:
|
||||
st.session_state.messages_page = 1
|
||||
if "messages_per_page" not in st.session_state:
|
||||
st.session_state.messages_per_page = 10
|
||||
if "messages_filters" not in st.session_state:
|
||||
st.session_state.messages_filters = {}
|
||||
|
||||
# Resultados por página
|
||||
per_page_options = [5, 10, 20, 50, 100]
|
||||
try:
|
||||
default_index = per_page_options.index(st.session_state.messages_per_page)
|
||||
except Exception:
|
||||
default_index = 1
|
||||
per_page = st.selectbox(
|
||||
"Resultados por página",
|
||||
options=per_page_options,
|
||||
index=default_index,
|
||||
key="messages_per_page_select"
|
||||
)
|
||||
if per_page != st.session_state.messages_per_page:
|
||||
st.session_state.messages_per_page = per_page
|
||||
st.session_state.messages_page = 1
|
||||
|
||||
# Cargar grupos para el selector
|
||||
with st.spinner("Cargando grupos..."):
|
||||
grupos = api.get_groups() or []
|
||||
grupos_map = {
|
||||
g.get("name", f"ID {g.get('id_telegram')}"): g.get("id_telegram")
|
||||
for g in grupos
|
||||
}
|
||||
|
||||
# Formulario de búsqueda
|
||||
with st.form("buscar_mensajes_form"):
|
||||
st.subheader("🔍 Filtros de búsqueda")
|
||||
|
||||
col_q, col_grupo = st.columns([3, 2])
|
||||
with col_q:
|
||||
q = st.text_input(
|
||||
"Texto a buscar (opcional)",
|
||||
placeholder="Dejar vacío para traer todos"
|
||||
)
|
||||
with col_grupo:
|
||||
opciones_grupos = ["Todos los grupos"] + list(grupos_map.keys())
|
||||
grupo_seleccionado = st.selectbox("Grupo", options=opciones_grupos)
|
||||
|
||||
col_sender, _ = st.columns([3, 2])
|
||||
with col_sender:
|
||||
sender_q = st.text_input(
|
||||
"Sender (username o ID)",
|
||||
placeholder="Ej: juanperez o 99887766"
|
||||
)
|
||||
|
||||
col_fecha1, col_hora1, col_fecha2, col_hora2 = st.columns(4)
|
||||
with col_fecha1:
|
||||
fecha_desde = st.date_input("Fecha desde", value=None)
|
||||
with col_hora1:
|
||||
hora_desde = st.time_input("Hora desde", value=None)
|
||||
with col_fecha2:
|
||||
fecha_hasta = st.date_input("Fecha hasta", value=None)
|
||||
with col_hora2:
|
||||
hora_hasta = st.time_input("Hora hasta", value=None)
|
||||
|
||||
submitted = st.form_submit_button("🔍 Buscar", use_container_width=True)
|
||||
|
||||
if submitted:
|
||||
group_id = (
|
||||
grupos_map.get(grupo_seleccionado)
|
||||
if grupo_seleccionado != "Todos los grupos"
|
||||
else None
|
||||
)
|
||||
|
||||
dt_desde = None
|
||||
dt_hasta = None
|
||||
if fecha_desde:
|
||||
dt_desde = datetime.combine(fecha_desde, hora_desde or time(0, 0))
|
||||
if fecha_hasta:
|
||||
dt_hasta = datetime.combine(fecha_hasta, hora_hasta or time(23, 59, 59))
|
||||
|
||||
st.session_state.messages_filters = {
|
||||
"submitted": True,
|
||||
"q": q.strip(),
|
||||
"group_id": group_id,
|
||||
"sender_q": sender_q.strip() if sender_q else None,
|
||||
"dt_desde": dt_desde.isoformat() if dt_desde else None,
|
||||
"dt_hasta": dt_hasta.isoformat() if dt_hasta else None,
|
||||
}
|
||||
st.session_state.messages_page = 1
|
||||
|
||||
filters = st.session_state.messages_filters
|
||||
if not filters.get("submitted"):
|
||||
st.info("Aplicá los filtros y presioná Buscar para ver resultados.")
|
||||
return
|
||||
|
||||
page = st.session_state.messages_page
|
||||
skip = (page - 1) * st.session_state.messages_per_page
|
||||
|
||||
with st.spinner("Buscando mensajes..."):
|
||||
results = api.search_messages(
|
||||
q=filters.get("q", ""),
|
||||
group_id=filters.get("group_id"),
|
||||
date_from=filters.get("dt_desde"),
|
||||
date_to=filters.get("dt_hasta"),
|
||||
skip=skip,
|
||||
limit=st.session_state.messages_per_page
|
||||
) or []
|
||||
|
||||
if results is None:
|
||||
st.error("No se pudieron obtener resultados.")
|
||||
return
|
||||
|
||||
# Filtro local por sender
|
||||
if filters.get("sender_q"):
|
||||
sq = filters["sender_q"].lower()
|
||||
results = [
|
||||
r for r in results
|
||||
if sq in str(r.get("sender", {}).get("username", "")).lower()
|
||||
or sq in str(r.get("sender", {}).get("id_telegram", "")).lower()
|
||||
or sq in str(r.get("sender", {}).get("first_name", "")).lower()
|
||||
]
|
||||
|
||||
if not results:
|
||||
st.info("No se encontraron mensajes con los filtros aplicados.")
|
||||
_render_paginacion_mensajes(page, 0, st.session_state.messages_per_page)
|
||||
return
|
||||
|
||||
total = len(results)
|
||||
st.subheader(f"Resultados — Página {page} ({total} mensaje{'s' if total != 1 else ''})")
|
||||
|
||||
for item in results:
|
||||
sender = item.get("sender") or {}
|
||||
grupo = item.get("group") or {}
|
||||
adjuntos = item.get("attachments") or []
|
||||
|
||||
fecha = ""
|
||||
if item.get("date"):
|
||||
try:
|
||||
fecha = datetime.fromisoformat(str(item["date"])).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
fecha = str(item["date"])
|
||||
|
||||
# Indicador de adjuntos en el título del expander
|
||||
adj_label = f" 📎 {len(adjuntos)}" if adjuntos else ""
|
||||
title = f"📨 {fecha} — {sender.get('username', 'N/A')} — {grupo.get('name', 'N/A')}{adj_label}"
|
||||
|
||||
with st.expander(title, expanded=False):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.write(f"**Contenido:** {item.get('content', 'Sin contenido')}")
|
||||
st.write(
|
||||
f"**Sender:** {sender.get('first_name', '')} "
|
||||
f"{sender.get('last_name', '')} "
|
||||
f"(@{sender.get('username', 'N/A')})"
|
||||
)
|
||||
with col2:
|
||||
st.write(f"**Grupo:** {grupo.get('name', 'N/A')}")
|
||||
st.write(f"**Fecha:** {fecha}")
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Sección de adjuntos con descarga ---
|
||||
context_key = f"{item.get('id_mess_g', '')}_{item.get('group_id', '')}"
|
||||
render_adjuntos(adjuntos, api, context_key=context_key)
|
||||
|
||||
_render_paginacion_mensajes(page, total, st.session_state.messages_per_page)
|
||||
|
||||
|
||||
def _render_paginacion_mensajes(page: int, total: int, per_page: int):
|
||||
hay_mas = total == per_page
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("◀️ Anterior", disabled=page <= 1, key=f"msg_prev_{page}"):
|
||||
st.session_state.messages_page = max(1, page - 1)
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {page}**")
|
||||
st.caption(f"Resultados en esta página: {total}")
|
||||
with col_next:
|
||||
if st.button("Siguiente ▶️", disabled=not hay_mas, key=f"msg_next_{page}"):
|
||||
st.session_state.messages_page = page + 1
|
||||
st.rerun()
|
||||
@@ -0,0 +1,207 @@
|
||||
import time
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
|
||||
|
||||
def crear_regla():
|
||||
"""Interfaz para crear una nueva regla."""
|
||||
api = api_client
|
||||
|
||||
st.subheader("Crear nueva regla")
|
||||
|
||||
submitted = False
|
||||
|
||||
with st.form("form_crear_regla", clear_on_submit=True):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
description_val = st.text_input("Descripción", placeholder="Ej: Detecta menciones de transferencias")
|
||||
regex_val = st.text_input("Regex / Palabra clave", placeholder="Ej: transferencia")
|
||||
with col2:
|
||||
severity_val = st.selectbox("Severidad", options=["Alta", "Media", "Baja"], index=1)
|
||||
is_active_val = st.checkbox("Activa", value=True)
|
||||
|
||||
apply_history_val = st.checkbox(
|
||||
"Aplicar al histórico",
|
||||
value=False,
|
||||
help="Buscará coincidencias en todos los mensajes ya cargados y generará alertas."
|
||||
)
|
||||
st.caption("Necesitas ayuda con expresiones regulares? Visita: https://regex101.com/")
|
||||
submitted = st.form_submit_button("Crear regla", use_container_width=True, type="primary")
|
||||
|
||||
if submitted:
|
||||
if not description_val or not regex_val:
|
||||
st.warning("Completá la descripción y la regex para continuar.")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"description": description_val,
|
||||
"regex": regex_val,
|
||||
"severity": severity_val.lower(),
|
||||
"is_active": is_active_val,
|
||||
"apply_to_history": apply_history_val,
|
||||
}
|
||||
with st.spinner("Creando regla..."):
|
||||
res = api.create_rule(payload)
|
||||
|
||||
if res is not None:
|
||||
st.success("Regla creada correctamente.")
|
||||
time.sleep(1)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al crear la regla.")
|
||||
|
||||
|
||||
def editar_reglas():
|
||||
"""
|
||||
Vista unificada: lista, busca y edita reglas en una sola pantalla.
|
||||
Reemplaza las antiguas listar_reglas() y editar_reglas() separadas.
|
||||
"""
|
||||
api = api_client
|
||||
|
||||
# --- Estado de paginación y edición ---
|
||||
if "rule_page" not in st.session_state: st.session_state.rule_page = 1
|
||||
if "rules_per_page" not in st.session_state: st.session_state.rules_per_page = 10
|
||||
if "rule_search" not in st.session_state: st.session_state.rule_search = ""
|
||||
if "editing_rule_id" not in st.session_state: st.session_state.editing_rule_id = None
|
||||
|
||||
# --- Controles superiores ---
|
||||
col_search, col_per_page = st.columns([3, 1])
|
||||
with col_search:
|
||||
q = st.text_input(
|
||||
"Buscar regla",
|
||||
value=st.session_state.rule_search,
|
||||
placeholder="Filtrá por descripción...",
|
||||
key="rule_search_input",
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
if q != st.session_state.rule_search:
|
||||
st.session_state.rule_search = q
|
||||
st.session_state.rule_page = 1
|
||||
st.rerun()
|
||||
|
||||
with col_per_page:
|
||||
per_page = st.selectbox(
|
||||
"Por página",
|
||||
options=[5, 10, 20, 50],
|
||||
index=[5, 10, 20, 50].index(st.session_state.rules_per_page)
|
||||
if st.session_state.rules_per_page in [5, 10, 20, 50] else 1,
|
||||
key="rule_per_page_sel"
|
||||
)
|
||||
if per_page != st.session_state.rules_per_page:
|
||||
st.session_state.rules_per_page = per_page
|
||||
st.session_state.rule_page = 1
|
||||
|
||||
skip = (st.session_state.rule_page - 1) * st.session_state.rules_per_page
|
||||
limit = st.session_state.rules_per_page
|
||||
|
||||
# --- Cargar reglas ---
|
||||
with st.spinner("Cargando reglas..."):
|
||||
try:
|
||||
if st.session_state.rule_search.strip():
|
||||
rules = api.search_rules(q=st.session_state.rule_search.strip(), limit=500) or []
|
||||
# Paginación local cuando se busca
|
||||
total = len(rules)
|
||||
rules_paged = rules[skip: skip + limit]
|
||||
else:
|
||||
rules_paged = api.get_rules(skip=skip, limit=limit) or []
|
||||
total = None # no sabemos el total sin buscar
|
||||
except Exception as e:
|
||||
st.error(f"Error cargando reglas: {e}")
|
||||
return
|
||||
|
||||
if not rules_paged:
|
||||
st.info("No se encontraron reglas.")
|
||||
if st.session_state.rule_page > 1:
|
||||
st.session_state.rule_page = 1
|
||||
st.rerun()
|
||||
return
|
||||
|
||||
hay_mas = len(rules_paged) == limit if total is None else (skip + limit) < total
|
||||
|
||||
if total is not None:
|
||||
st.caption(f"{total} regla{'s' if total != 1 else ''} encontrada{'s' if total != 1 else ''}")
|
||||
else:
|
||||
st.caption(f"Mostrando {skip + 1}–{skip + len(rules_paged)}")
|
||||
|
||||
SEV_COLOR = {"alta": "🔴", "media": "🟡", "baja": "🟢"}
|
||||
|
||||
# --- Lista de reglas ---
|
||||
for rule in rules_paged:
|
||||
rule_id = rule.get("id")
|
||||
desc = rule.get("description", "Sin descripción")
|
||||
regex = rule.get("regex", "")
|
||||
sev = (rule.get("severity") or "media").lower()
|
||||
is_active = rule.get("is_active", True)
|
||||
sev_icon = SEV_COLOR.get(sev, "⚪")
|
||||
active_txt = "Activa" if is_active else "Inactiva"
|
||||
|
||||
with st.expander(
|
||||
f"{sev_icon} **#{rule_id}** — {desc[:60]}{'...' if len(desc) > 60 else ''} · *{active_txt}*",
|
||||
expanded=(st.session_state.editing_rule_id == rule_id)
|
||||
):
|
||||
# Si está en modo edición para esta regla
|
||||
if st.session_state.editing_rule_id == rule_id:
|
||||
with st.form(f"form_edit_{rule_id}"):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
new_desc = st.text_input("Descripción", value=desc)
|
||||
new_regex = st.text_input("Regex / Palabra clave", value=regex)
|
||||
with col2:
|
||||
sev_opts = ["Alta", "Media", "Baja"]
|
||||
sev_idx = sev_opts.index(sev.capitalize()) if sev.capitalize() in sev_opts else 1
|
||||
new_sev = st.selectbox("Severidad", options=sev_opts, index=sev_idx)
|
||||
new_active = st.checkbox("Activa", value=is_active)
|
||||
|
||||
col_save, col_cancel = st.columns(2)
|
||||
with col_save:
|
||||
save = st.form_submit_button("Guardar cambios", use_container_width=True, type="primary")
|
||||
with col_cancel:
|
||||
cancel = st.form_submit_button("Cancelar", use_container_width=True)
|
||||
|
||||
if save:
|
||||
if not new_desc or not new_regex:
|
||||
st.warning("Descripción y regex son obligatorias.")
|
||||
else:
|
||||
payload = {
|
||||
"description": new_desc,
|
||||
"regex": new_regex,
|
||||
"severity": new_sev.lower(),
|
||||
"is_active": new_active,
|
||||
}
|
||||
with st.spinner("Guardando..."):
|
||||
res = api.update_rule(rule_id, payload)
|
||||
if res is not None:
|
||||
st.success("Regla actualizada.")
|
||||
st.session_state.editing_rule_id = None
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al actualizar la regla.")
|
||||
|
||||
if cancel:
|
||||
st.session_state.editing_rule_id = None
|
||||
st.rerun()
|
||||
|
||||
else:
|
||||
# Modo lectura
|
||||
col_info, col_btn = st.columns([4, 1])
|
||||
with col_info:
|
||||
st.caption(f"**Regex:** `{regex}`")
|
||||
st.caption(f"**Severidad:** {sev.capitalize()} · **Estado:** {active_txt}")
|
||||
with col_btn:
|
||||
if st.button("Editar", key=f"edit_btn_{rule_id}", use_container_width=True):
|
||||
st.session_state.editing_rule_id = rule_id
|
||||
st.rerun()
|
||||
|
||||
# --- Paginación ---
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("Anterior", disabled=st.session_state.rule_page <= 1, use_container_width=True):
|
||||
st.session_state.rule_page -= 1
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {st.session_state.rule_page}**")
|
||||
with col_next:
|
||||
if st.button("Siguiente", disabled=not hay_mas, use_container_width=True):
|
||||
st.session_state.rule_page += 1
|
||||
st.rerun()
|
||||
@@ -0,0 +1,224 @@
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from src.pages.views.components import render_adjuntos
|
||||
|
||||
|
||||
def _fmt_fecha(fecha_str: str) -> str:
|
||||
try:
|
||||
return datetime.fromisoformat(str(fecha_str)).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
return str(fecha_str) or "—"
|
||||
|
||||
|
||||
def _tipo_badge(tipo: str) -> str:
|
||||
return {"user": "👤 Usuario", "bot": "🤖 Bot", "channel": "📢 Canal"}.get(tipo, tipo)
|
||||
|
||||
|
||||
def mostrar_senders():
|
||||
"""
|
||||
Vista de remitentes (senders) cargados por el feeder.
|
||||
Permite buscar, ver info y ver mensajes de cada sender.
|
||||
"""
|
||||
api = api_client
|
||||
|
||||
# --- Estado ---
|
||||
if "sender_page" not in st.session_state: st.session_state.sender_page = 1
|
||||
if "senders_per_page" not in st.session_state: st.session_state.senders_per_page = 20
|
||||
if "sender_search" not in st.session_state: st.session_state.sender_search = ""
|
||||
if "selected_sender" not in st.session_state: st.session_state.selected_sender = None
|
||||
|
||||
# --- Si hay un sender seleccionado, mostrar su detalle ---
|
||||
if st.session_state.selected_sender is not None:
|
||||
_mostrar_detalle_sender(api)
|
||||
return
|
||||
|
||||
# --- Vista de listado ---
|
||||
st.title("Remitentes")
|
||||
st.caption("Usuarios de Telegram detectados por el feeder")
|
||||
|
||||
col_search, col_per_page = st.columns([3, 1])
|
||||
with col_search:
|
||||
q = st.text_input(
|
||||
"Buscar",
|
||||
value=st.session_state.sender_search,
|
||||
placeholder="Nombre, username o ID de Telegram...",
|
||||
label_visibility="collapsed",
|
||||
key="sender_search_input"
|
||||
)
|
||||
if q != st.session_state.sender_search:
|
||||
st.session_state.sender_search = q
|
||||
st.session_state.sender_page = 1
|
||||
st.rerun()
|
||||
|
||||
with col_per_page:
|
||||
per_page = st.selectbox(
|
||||
"Por página",
|
||||
options=[10, 20, 50, 100],
|
||||
index=[10, 20, 50, 100].index(st.session_state.senders_per_page)
|
||||
if st.session_state.senders_per_page in [10, 20, 50, 100] else 1,
|
||||
label_visibility="collapsed",
|
||||
key="sender_per_page_sel"
|
||||
)
|
||||
if per_page != st.session_state.senders_per_page:
|
||||
st.session_state.senders_per_page = per_page
|
||||
st.session_state.sender_page = 1
|
||||
|
||||
skip = (st.session_state.sender_page - 1) * st.session_state.senders_per_page
|
||||
limit = st.session_state.senders_per_page
|
||||
|
||||
with st.spinner("Cargando remitentes..."):
|
||||
senders = api.get_senders(skip=skip, limit=limit) or []
|
||||
|
||||
# Filtrado local por búsqueda
|
||||
if st.session_state.sender_search.strip():
|
||||
sq = st.session_state.sender_search.strip().lower()
|
||||
senders = [
|
||||
s for s in senders
|
||||
if sq in str(s.get("id_telegram", "")).lower()
|
||||
or sq in str(s.get("username") or "").lower()
|
||||
or sq in str(s.get("first_name") or "").lower()
|
||||
or sq in str(s.get("last_name") or "").lower()
|
||||
]
|
||||
|
||||
if not senders:
|
||||
st.info("No se encontraron remitentes.")
|
||||
if st.session_state.sender_page > 1:
|
||||
st.session_state.sender_page = 1
|
||||
st.rerun()
|
||||
return
|
||||
|
||||
hay_mas = len(senders) == limit
|
||||
st.caption(f"Mostrando {skip + 1}–{skip + len(senders)}")
|
||||
st.divider()
|
||||
|
||||
# --- Tabla de senders ---
|
||||
for sender in senders:
|
||||
sid = sender.get("id_telegram")
|
||||
username = sender.get("username") or "—"
|
||||
first_name = "N/A" if sender.get("first_name") == "None" else sender.get("first_name") or ""
|
||||
last_name = "" if sender.get("last_name") == "None" else sender.get("last_name") or ""
|
||||
nombre = f"{first_name} {last_name}".strip() or "Sin nombre"
|
||||
tipo = sender.get("type", "user")
|
||||
phone = "Sin teléfono" if sender.get("phone") == "None" else sender.get("phone") or "—"
|
||||
|
||||
col_info, col_btn = st.columns([5, 1])
|
||||
with col_info:
|
||||
st.markdown(
|
||||
f"**{nombre}** `@{username}` · {_tipo_badge(tipo)}"
|
||||
f"<br><span style='font-size:11px;opacity:0.5;'>ID: {sid} · Tel: {phone}</span>",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
with col_btn:
|
||||
if st.button("Ver", key=f"ver_sender_{sid}", use_container_width=True):
|
||||
st.session_state.selected_sender = sender
|
||||
st.rerun()
|
||||
|
||||
st.markdown(
|
||||
"<hr style='margin:6px 0;border:none;border-top:0.5px solid rgba(128,128,128,0.15);'>",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
|
||||
# --- Paginación ---
|
||||
st.divider()
|
||||
col_prev, col_info_pag, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("Anterior", disabled=st.session_state.sender_page <= 1,
|
||||
use_container_width=True, key="sender_prev"):
|
||||
st.session_state.sender_page -= 1
|
||||
st.rerun()
|
||||
with col_info_pag:
|
||||
st.write(f"**Página {st.session_state.sender_page}**")
|
||||
with col_next:
|
||||
if st.button("Siguiente", disabled=not hay_mas,
|
||||
use_container_width=True, key="sender_next"):
|
||||
st.session_state.sender_page += 1
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _mostrar_detalle_sender(api):
|
||||
"""Detalle de un sender: info + mensajes enviados."""
|
||||
sender = st.session_state.selected_sender
|
||||
sid = sender.get("id_telegram")
|
||||
username = sender.get("username") or "—"
|
||||
first_name = "N/A" if sender.get("first_name") == "None" else sender.get("first_name") or ""
|
||||
last_name = "" if sender.get("last_name") == "None" else sender.get("last_name") or ""
|
||||
nombre = f"{first_name} {last_name}".strip() or "Sin nombre"
|
||||
tipo = sender.get("type", "user")
|
||||
phone = "Sin teléfono" if sender.get("phone") == "None" else sender.get("phone") or "—"
|
||||
|
||||
# Botón volver
|
||||
if st.button("← Volver a remitentes", key="back_to_senders"):
|
||||
st.session_state.selected_sender = None
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Encabezado del sender ---
|
||||
st.title(nombre)
|
||||
col1, col2, col3 = st.columns(3)
|
||||
with col1:
|
||||
st.metric("Username", f"@{username}")
|
||||
with col2:
|
||||
st.metric("Tipo", _tipo_badge(tipo))
|
||||
with col3:
|
||||
st.metric("ID Telegram", str(sid))
|
||||
|
||||
col4, col5 = st.columns(2)
|
||||
with col4:
|
||||
st.metric("Teléfono", phone)
|
||||
with col5:
|
||||
st.metric("Apellido", last_name or "—")
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Mensajes del sender ---
|
||||
st.subheader("Mensajes enviados")
|
||||
|
||||
if "sender_msg_page" not in st.session_state: st.session_state.sender_msg_page = 1
|
||||
if "sender_msg_per_page" not in st.session_state: st.session_state.sender_msg_per_page = 10
|
||||
|
||||
msg_skip = (st.session_state.sender_msg_page - 1) * st.session_state.sender_msg_per_page
|
||||
msg_limit = st.session_state.sender_msg_per_page
|
||||
|
||||
with st.spinner("Cargando mensajes..."):
|
||||
mensajes = api.get_messages_by_sender(sid, skip=msg_skip, limit=msg_limit) or []
|
||||
|
||||
if not mensajes:
|
||||
st.info("Este remitente no tiene mensajes registrados.")
|
||||
return
|
||||
|
||||
hay_mas_msgs = len(mensajes) == msg_limit
|
||||
st.caption(f"Mostrando {msg_skip + 1}–{msg_skip + len(mensajes)}")
|
||||
|
||||
for msg in mensajes:
|
||||
grupo = msg.get("group") or {}
|
||||
fecha = _fmt_fecha(msg.get("date", ""))
|
||||
contenido = "_Sin contenido_" if msg.get("content") == "None" else msg.get("content") or "_Sin contenido_"
|
||||
adjuntos = msg.get("attachments") or []
|
||||
grupo_nombre = grupo.get("name") or str(msg.get("group_id", ""))
|
||||
|
||||
with st.expander(
|
||||
f"📨 {fecha} — {grupo_nombre} {'📎' if adjuntos else ''}",
|
||||
expanded=False
|
||||
):
|
||||
st.write(contenido)
|
||||
if adjuntos:
|
||||
context_key = f"sender_{sid}_{msg.get('id_mess_g', '')}_{msg.get('group_id', '')}"
|
||||
render_adjuntos(adjuntos, api, context_key=context_key)
|
||||
|
||||
# Paginación mensajes
|
||||
st.divider()
|
||||
col_prev, col_info_m, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("Anterior", disabled=st.session_state.sender_msg_page <= 1,
|
||||
use_container_width=True, key="sender_msg_prev"):
|
||||
st.session_state.sender_msg_page -= 1
|
||||
st.rerun()
|
||||
with col_info_m:
|
||||
st.write(f"**Página {st.session_state.sender_msg_page}**")
|
||||
with col_next:
|
||||
if st.button("Siguiente", disabled=not hay_mas_msgs,
|
||||
use_container_width=True, key="sender_msg_next"):
|
||||
st.session_state.sender_msg_page += 1
|
||||
st.rerun()
|
||||
@@ -0,0 +1,251 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from src.api import api_client
|
||||
from src.pages.views.components import safe_html
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEV_COLOR = {"alta": "🔴", "media": "🟡", "baja": "🟢"}
|
||||
DIAS_ORDER = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"]
|
||||
|
||||
|
||||
def _metric(label: str, value, delta: str = "", delta_up: bool = True, accent: bool = False):
|
||||
label = safe_html(label)
|
||||
delta = safe_html(delta)
|
||||
border = "border-top:2px solid #27500A;" if accent else ""
|
||||
delta_color = "#3B6D11" if delta_up else "#A32D2D"
|
||||
delta_html = f'<div style="font-size:11px;color:{delta_color};margin-top:4px;">{delta}</div>' if delta else ""
|
||||
st.markdown(f"""
|
||||
<div style="background:var(--background-color);border:0.5px solid rgba(128,128,128,0.2);
|
||||
border-radius:8px;padding:14px 16px;{border}">
|
||||
<div style="font-size:11px;opacity:0.55;margin-bottom:6px;">{label}</div>
|
||||
<div style="font-size:24px;font-weight:500;line-height:1;">{value}</div>
|
||||
{delta_html}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _section(title: str):
|
||||
title = safe_html(title)
|
||||
st.markdown(f"""
|
||||
<div style="font-size:15px;font-weight:500;margin:24px 0 12px;
|
||||
padding-bottom:8px;border-bottom:0.5px solid rgba(128,128,128,0.2);">
|
||||
{title}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vista principal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mostrar_estadisticas():
|
||||
api = api_client
|
||||
|
||||
st.title("Estadísticas")
|
||||
st.caption("Análisis de actividad del sistema en el período seleccionado")
|
||||
|
||||
# --- Filtro de período ---
|
||||
with st.expander("Filtrar período", expanded=True):
|
||||
col_f1, col_h1, col_f2, col_h2, col_btn = st.columns([2, 1, 2, 1, 1])
|
||||
with col_f1:
|
||||
fecha_desde = st.date_input(
|
||||
"Desde (fecha)",
|
||||
value=datetime.utcnow().date() - timedelta(days=30),
|
||||
key="stats_fecha_desde"
|
||||
)
|
||||
with col_h1:
|
||||
hora_desde = st.time_input("Hora", value=time(0, 0), key="stats_hora_desde")
|
||||
with col_f2:
|
||||
fecha_hasta = st.date_input(
|
||||
"Hasta (fecha)",
|
||||
value=datetime.utcnow().date(),
|
||||
key="stats_fecha_hasta"
|
||||
)
|
||||
with col_h2:
|
||||
hora_hasta = st.time_input("Hora", value=time(23, 59, 59), key="stats_hora_hasta")
|
||||
with col_btn:
|
||||
st.markdown("<div style='height:28px'></div>", unsafe_allow_html=True)
|
||||
aplicar = st.button("Aplicar", use_container_width=True, type="primary")
|
||||
|
||||
dt_desde = datetime.combine(fecha_desde, hora_desde)
|
||||
dt_hasta = datetime.combine(fecha_hasta, hora_hasta)
|
||||
|
||||
if dt_desde >= dt_hasta:
|
||||
st.warning("La fecha de inicio debe ser anterior a la fecha de fin.")
|
||||
return
|
||||
|
||||
# --- Cargar datos ---
|
||||
with st.spinner("Calculando estadísticas..."):
|
||||
data = api.get_stats(
|
||||
date_from=dt_desde.isoformat(),
|
||||
date_to=dt_hasta.isoformat()
|
||||
)
|
||||
|
||||
if data is None:
|
||||
st.error("No se pudieron cargar las estadísticas.")
|
||||
return
|
||||
|
||||
resumen = data.get("resumen", {})
|
||||
periodo = data.get("periodo", {})
|
||||
|
||||
# --- 1. Resumen ejecutivo ---
|
||||
_section("Resumen del período")
|
||||
|
||||
col1, col2, col3, col4, col5, col6, col7 = st.columns(7)
|
||||
with col1:
|
||||
_metric("Alertas totales", resumen.get("total_alertas", 0), accent=True)
|
||||
with col2:
|
||||
_metric("Abiertas", resumen.get("alertas_abiertas", 0), delta_up=False)
|
||||
with col3:
|
||||
_metric("En curso", resumen.get("alertas_en_progreso", 0), delta_up=False)
|
||||
with col4:
|
||||
_metric("Cerradas", resumen.get("alertas_cerradas", 0), delta_up=True)
|
||||
with col5:
|
||||
_metric("Mensajes", f"{resumen.get('total_mensajes', 0):,}")
|
||||
with col6:
|
||||
_metric("Grupos activos", resumen.get("grupos_activos", 0))
|
||||
with col7:
|
||||
_metric("Reglas activas", resumen.get("reglas_activas", 0))
|
||||
|
||||
# --- 2. Alertas en el tiempo ---
|
||||
_section("Actividad de alertas en el tiempo")
|
||||
|
||||
alertas_dia = data.get("alertas_por_dia", {})
|
||||
if alertas_dia:
|
||||
df_alertas = pd.DataFrame([
|
||||
{
|
||||
"Fecha": dia,
|
||||
"Abiertas": vals.get("abiertas", 0),
|
||||
"En curso": vals.get("en_progreso", 0),
|
||||
"Cerradas": vals.get("cerradas", 0),
|
||||
}
|
||||
for dia, vals in sorted(alertas_dia.items())
|
||||
]).set_index("Fecha")
|
||||
st.line_chart(df_alertas, color=["#E24B4A", "#F7931E", "#3B6D11"])
|
||||
else:
|
||||
st.info("Sin alertas en el período seleccionado.")
|
||||
|
||||
# --- 3. Distribución + Alertas por grupo ---
|
||||
col_sev, col_grupo = st.columns(2)
|
||||
|
||||
with col_sev:
|
||||
_section("Distribución por severidad")
|
||||
dist = data.get("distribucion_severidad", {})
|
||||
if dist:
|
||||
df_sev = pd.DataFrame([
|
||||
{"Severidad": sev.capitalize(), "Alertas": total}
|
||||
for sev, total in dist.items()
|
||||
]).set_index("Severidad")
|
||||
st.bar_chart(df_sev)
|
||||
# Tabla resumen
|
||||
for sev, total in sorted(dist.items(), key=lambda x: x[1], reverse=True):
|
||||
pct = round(total / sum(dist.values()) * 100) if dist else 0
|
||||
st.markdown(
|
||||
f"{SEV_COLOR.get(sev, '⚪')} **{sev.capitalize()}**: "
|
||||
f"{total} alertas ({pct}%)"
|
||||
)
|
||||
else:
|
||||
st.info("Sin datos de severidad.")
|
||||
|
||||
with col_grupo:
|
||||
_section("Alertas por grupo (top 10)")
|
||||
grupos = data.get("alertas_por_grupo", [])
|
||||
if grupos:
|
||||
df_grupos = pd.DataFrame(grupos).set_index("grupo")
|
||||
st.bar_chart(df_grupos["total"])
|
||||
else:
|
||||
st.info("Sin datos por grupo.")
|
||||
|
||||
# --- 4. Reglas más disparadas ---
|
||||
_section("Reglas más disparadas (top 10)")
|
||||
reglas = data.get("reglas_top", [])
|
||||
if reglas:
|
||||
df_reglas = pd.DataFrame(reglas)
|
||||
df_reglas["label"] = df_reglas.apply(
|
||||
lambda r: f"{SEV_COLOR.get(r['severity'], '⚪')} {r['description'][:40]}",
|
||||
axis=1
|
||||
)
|
||||
df_reglas = df_reglas.set_index("label")[["total"]]
|
||||
df_reglas.columns = ["Disparos"]
|
||||
st.bar_chart(df_reglas)
|
||||
else:
|
||||
st.info("Sin reglas disparadas en el período.")
|
||||
|
||||
# --- 5. Volumen de mensajes por día ---
|
||||
_section("Volumen de mensajes por día")
|
||||
msgs_dia = data.get("mensajes_por_dia", {})
|
||||
if msgs_dia:
|
||||
df_msgs = pd.DataFrame([
|
||||
{"Fecha": dia, "Mensajes": total}
|
||||
for dia, total in sorted(msgs_dia.items())
|
||||
]).set_index("Fecha")
|
||||
st.bar_chart(df_msgs)
|
||||
else:
|
||||
st.info("Sin mensajes en el período.")
|
||||
|
||||
# --- 6. Heatmap hora × día de semana ---
|
||||
_section("Heatmap de actividad — hora × día de semana")
|
||||
heatmap = data.get("heatmap", [])
|
||||
if heatmap:
|
||||
df_heat = pd.DataFrame(heatmap)
|
||||
# Pivot: filas = día, columnas = hora
|
||||
pivot = df_heat.pivot_table(
|
||||
index="dia",
|
||||
columns="hora",
|
||||
values="total",
|
||||
aggfunc="sum",
|
||||
fill_value=0
|
||||
)
|
||||
# Reordenar días
|
||||
dias_presentes = [d for d in DIAS_ORDER if d in pivot.index]
|
||||
pivot = pivot.reindex(dias_presentes)
|
||||
pivot.columns = [f"{h:02d}h" for h in pivot.columns]
|
||||
|
||||
# Normalizar para colorear
|
||||
max_val = pivot.values.max() if pivot.values.max() > 0 else 1
|
||||
|
||||
st.dataframe(
|
||||
pivot.style.background_gradient(
|
||||
cmap="Greens",
|
||||
vmin=0,
|
||||
vmax=max_val
|
||||
).format("{:.0f}"),
|
||||
use_container_width=True,
|
||||
height=280
|
||||
)
|
||||
st.caption("Valores = cantidad de mensajes. Color más oscuro = mayor actividad.")
|
||||
else:
|
||||
st.info("Sin datos de actividad horaria.")
|
||||
|
||||
# --- 7. Top senders ---
|
||||
_section("Top remitentes por actividad")
|
||||
senders = data.get("top_senders", [])
|
||||
if senders:
|
||||
df_send = pd.DataFrame(senders)
|
||||
df_send["Remitente"] = df_send.apply(
|
||||
lambda r: f"@{r['username']}" if r.get("username") else r["nombre"],
|
||||
axis=1
|
||||
)
|
||||
df_send["Con alertas"] = df_send["alertas"].apply(
|
||||
lambda x: "🔴 Sí" if x > 0 else "✅ No"
|
||||
)
|
||||
df_send = df_send.rename(columns={
|
||||
"mensajes": "Mensajes",
|
||||
"alertas": "Alertas generadas"
|
||||
})
|
||||
|
||||
col_chart, col_table = st.columns([2, 1])
|
||||
with col_chart:
|
||||
st.bar_chart(df_send.set_index("Remitente")["Mensajes"])
|
||||
with col_table:
|
||||
st.dataframe(
|
||||
df_send[["Remitente", "Mensajes", "Alertas generadas", "Con alertas"]],
|
||||
use_container_width=True,
|
||||
hide_index=True
|
||||
)
|
||||
else:
|
||||
st.info("Sin datos de remitentes.")
|
||||
Reference in New Issue
Block a user