4bfc7592a6
- Solo registra "caída" cuando ffmpeg sale en <25s (error real: DNS, 403, auth, stall) - Timeouts de sesión del proveedor (~2 min) NO se registran como fallos — son normales - "Recuperado" se registra cuando el stream vuelve a funcionar tras un fallo real - Añade clasificación automática de causa: conexión rechazada, DNS, 403, timeout... - Dashboard: "reinicios" ahora distingue sesiones del proveedor (gris, normal) vs fallos (rojo) - Registro: nueva columna "Duración fallo", proveedor+dominio juntos, filas con color de fondo - StreamEvent: campo pump_duration_secs para persistir duración del fallo o interrupción Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
480 lines
24 KiB
TypeScript
480 lines
24 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import api from '../lib/api'
|
|
import { Search, Trash2, RefreshCw, Users, Tv, Database, Clock, AlertTriangle, CheckCircle } from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
|
|
// ─── Connection log types ───────────────────────────────────────────────────
|
|
|
|
interface LogEntry {
|
|
id: number
|
|
user_id: number
|
|
username: string
|
|
channel_name: string | null
|
|
content_type: string | null
|
|
stream_id: string | null
|
|
started_at: string | null
|
|
ended_at: string | null
|
|
duration_seconds: number | null
|
|
bytes_transferred: number
|
|
client_ip: string | null
|
|
}
|
|
|
|
interface Stats {
|
|
total_sessions: number
|
|
total_bytes: number
|
|
avg_duration_seconds: number
|
|
unique_users: number
|
|
top_users: { username: string; sessions: number; bytes: number }[]
|
|
top_channels: { channel_name: string; sessions: number }[]
|
|
}
|
|
|
|
// ─── Stream event types ──────────────────────────────────────────────────────
|
|
|
|
interface StreamEvent {
|
|
id: number
|
|
channel_id: number | null
|
|
channel_name: string | null
|
|
provider_name: string | null
|
|
event_type: 'caida' | 'recuperado' | string
|
|
domain: string | null
|
|
error_message: string | null
|
|
attempt_number: number | null
|
|
pump_duration_secs: number | null // for caída: seconds ffmpeg ran; for recuperado: outage length
|
|
created_at: string | null
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function fmtBytes(b: number): string {
|
|
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB'
|
|
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB'
|
|
if (b >= 1e3) return (b / 1e3).toFixed(0) + ' KB'
|
|
return b + ' B'
|
|
}
|
|
|
|
function fmtDuration(s: number | null): string {
|
|
if (!s) return '—'
|
|
if (s < 60) return `${s}s`
|
|
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
|
|
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
|
}
|
|
|
|
function fmtDate(iso: string | null): string {
|
|
if (!iso) return '—'
|
|
return new Date(iso).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })
|
|
}
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
live: 'bg-blue-500/20 text-blue-400',
|
|
movie: 'bg-purple-500/20 text-purple-400',
|
|
series: 'bg-orange-500/20 text-orange-400',
|
|
}
|
|
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
|
|
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
|
|
|
function StatCard({ icon: Icon, label, value, color }: { icon: typeof Clock; label: string; value: string; color: string }) {
|
|
return (
|
|
<div className="bg-dark-800 rounded-xl p-4 flex items-center gap-3">
|
|
<Icon size={20} className={color} />
|
|
<div>
|
|
<p className="text-xs text-gray-500">{label}</p>
|
|
<p className="text-lg font-bold">{value}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Connection Logs tab ──────────────────────────────────────────────────────
|
|
|
|
function ConnectionsTab() {
|
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
|
const [stats, setStats] = useState<Stats | null>(null)
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(1)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const [filterUsername, setFilterUsername] = useState('')
|
|
const [filterChannel, setFilterChannel] = useState('')
|
|
const [filterType, setFilterType] = useState('')
|
|
const [filterDateFrom, setFilterDateFrom] = useState('')
|
|
const [filterDateTo, setFilterDateTo] = useState('')
|
|
|
|
const limit = 50
|
|
|
|
const loadStats = useCallback(async () => {
|
|
try {
|
|
const { data } = await api.get('/logs/stats')
|
|
setStats(data)
|
|
} catch {}
|
|
}, [])
|
|
|
|
const loadLogs = useCallback(async (p = 1) => {
|
|
setLoading(true)
|
|
try {
|
|
const params: Record<string, string | number> = { page: p, limit }
|
|
if (filterUsername) params.username = filterUsername
|
|
if (filterChannel) params.channel = filterChannel
|
|
if (filterType) params.content_type = filterType
|
|
if (filterDateFrom) params.date_from = filterDateFrom
|
|
if (filterDateTo) params.date_to = filterDateTo
|
|
const { data } = await api.get('/logs', { params })
|
|
setLogs(data.items)
|
|
setTotal(data.total)
|
|
setPage(p)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [filterUsername, filterChannel, filterType, filterDateFrom, filterDateTo])
|
|
|
|
useEffect(() => { loadStats(); loadLogs(1) }, []) // eslint-disable-line
|
|
|
|
const doSearch = () => { loadStats(); loadLogs(1) }
|
|
|
|
const clearLogs = async () => {
|
|
if (!confirm('¿Eliminar todos los registros de conexión?')) return
|
|
await api.delete('/logs')
|
|
setLogs([])
|
|
setTotal(0)
|
|
loadStats()
|
|
}
|
|
|
|
const totalPages = Math.ceil(total / limit)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex justify-end gap-2">
|
|
<button onClick={() => { loadStats(); loadLogs(page) }} className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm transition-colors">
|
|
<RefreshCw size={14} /> Actualizar
|
|
</button>
|
|
<button onClick={clearLogs} className="flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors">
|
|
<Trash2 size={14} /> Limpiar todo
|
|
</button>
|
|
</div>
|
|
|
|
{stats && (
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<StatCard icon={Clock} label="Total sesiones" value={stats.total_sessions.toLocaleString()} color="text-primary-400" />
|
|
<StatCard icon={Database} label="Datos totales" value={fmtBytes(stats.total_bytes)} color="text-green-400" />
|
|
<StatCard icon={Clock} label="Duración media" value={fmtDuration(stats.avg_duration_seconds)} color="text-yellow-400" />
|
|
<StatCard icon={Users} label="Usuarios únicos" value={stats.unique_users.toString()} color="text-blue-400" />
|
|
</div>
|
|
)}
|
|
|
|
{stats && (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="bg-dark-800 rounded-xl p-4">
|
|
<h3 className="text-sm font-semibold mb-3 text-gray-300 flex items-center gap-2"><Users size={14} /> Top usuarios</h3>
|
|
<div className="space-y-2">
|
|
{stats.top_users.map((u, i) => (
|
|
<div key={u.username} className="flex items-center gap-2 text-sm">
|
|
<span className="text-gray-600 w-4">{i + 1}</span>
|
|
<span className="flex-1 text-gray-200 font-medium">{u.username}</span>
|
|
<span className="text-gray-400">{u.sessions} sesiones</span>
|
|
<span className="text-green-400 text-xs">{fmtBytes(u.bytes)}</span>
|
|
</div>
|
|
))}
|
|
{stats.top_users.length === 0 && <p className="text-xs text-gray-600">Sin datos</p>}
|
|
</div>
|
|
</div>
|
|
<div className="bg-dark-800 rounded-xl p-4">
|
|
<h3 className="text-sm font-semibold mb-3 text-gray-300 flex items-center gap-2"><Tv size={14} /> Top canales/contenido</h3>
|
|
<div className="space-y-2">
|
|
{stats.top_channels.map((c, i) => (
|
|
<div key={c.channel_name} className="flex items-center gap-2 text-sm">
|
|
<span className="text-gray-600 w-4">{i + 1}</span>
|
|
<span className="flex-1 text-gray-200 truncate">{c.channel_name}</span>
|
|
<span className="text-gray-400">{c.sessions}</span>
|
|
</div>
|
|
))}
|
|
{stats.top_channels.length === 0 && <p className="text-xs text-gray-600">Sin datos</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-dark-800 rounded-xl p-4">
|
|
<div className="flex flex-wrap gap-2 items-end">
|
|
<div className="relative">
|
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
|
<input value={filterUsername} onChange={e => setFilterUsername(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Usuario" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-36 focus:outline-none focus:border-primary-500" />
|
|
</div>
|
|
<div className="relative">
|
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
|
<input value={filterChannel} onChange={e => setFilterChannel(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Canal / película" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500" />
|
|
</div>
|
|
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none">
|
|
<option value="">Todos los tipos</option>
|
|
<option value="live">Directo</option>
|
|
<option value="movie">Película</option>
|
|
<option value="series">Serie</option>
|
|
</select>
|
|
<input type="date" value={filterDateFrom} onChange={e => setFilterDateFrom(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" />
|
|
<span className="text-gray-500 text-sm">→</span>
|
|
<input type="date" value={filterDateTo} onChange={e => setFilterDateTo(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" />
|
|
<button onClick={doSearch} className="flex items-center gap-2 px-4 py-1.5 bg-primary-500 hover:bg-primary-600 rounded-lg text-sm transition-colors">
|
|
<Search size={13} /> Buscar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-dark-800 rounded-xl overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
|
<span className="text-sm text-gray-400">{total.toLocaleString()} registros</span>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<button onClick={() => loadLogs(page - 1)} disabled={page === 1} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">←</button>
|
|
<span className="text-gray-400">{page} / {totalPages}</span>
|
|
<button onClick={() => loadLogs(page + 1)} disabled={page === totalPages} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">→</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 border-b border-dark-700">
|
|
<th className="text-left px-4 py-2">Usuario</th>
|
|
<th className="text-left px-4 py-2">IP</th>
|
|
<th className="text-left px-4 py-2">Canal / Contenido</th>
|
|
<th className="text-left px-4 py-2">Tipo</th>
|
|
<th className="text-left px-4 py-2">Inicio</th>
|
|
<th className="text-left px-4 py-2">Duración</th>
|
|
<th className="text-right px-4 py-2">Datos</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && <tr><td colSpan={7} className="text-center py-8 text-gray-500">Cargando...</td></tr>}
|
|
{!loading && logs.length === 0 && <tr><td colSpan={7} className="text-center py-8 text-gray-500">No hay registros</td></tr>}
|
|
{logs.map(log => (
|
|
<tr key={log.id} className="border-b border-dark-700/50 hover:bg-dark-700/30 transition-colors">
|
|
<td className="px-4 py-2 font-medium text-gray-200">{log.username}</td>
|
|
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{log.client_ip || '—'}</td>
|
|
<td className="px-4 py-2 text-gray-300 max-w-xs truncate">{log.channel_name || log.stream_id || '—'}</td>
|
|
<td className="px-4 py-2">
|
|
{log.content_type ? (
|
|
<span className={clsx('text-xs px-2 py-0.5 rounded-full', TYPE_COLORS[log.content_type] || 'bg-gray-700 text-gray-400')}>
|
|
{TYPE_LABELS[log.content_type] || log.content_type}
|
|
</span>
|
|
) : '—'}
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-400 text-xs">{fmtDate(log.started_at)}</td>
|
|
<td className="px-4 py-2 text-gray-400">{fmtDuration(log.duration_seconds)}</td>
|
|
<td className="px-4 py-2 text-right text-green-400 text-xs">{fmtBytes(log.bytes_transferred)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Stream Events tab ────────────────────────────────────────────────────────
|
|
|
|
function EventsTab() {
|
|
const [events, setEvents] = useState<StreamEvent[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(1)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const [filterChannel, setFilterChannel] = useState('')
|
|
const [filterType, setFilterType] = useState('')
|
|
const [filterDateFrom, setFilterDateFrom] = useState('')
|
|
const [filterDateTo, setFilterDateTo] = useState('')
|
|
|
|
const limit = 50
|
|
|
|
const loadEvents = useCallback(async (p = 1) => {
|
|
setLoading(true)
|
|
try {
|
|
const params: Record<string, string | number> = { page: p, limit }
|
|
if (filterChannel) params.channel = filterChannel
|
|
if (filterType) params.event_type = filterType
|
|
if (filterDateFrom) params.date_from = filterDateFrom
|
|
if (filterDateTo) params.date_to = filterDateTo
|
|
const { data } = await api.get('/logs/events', { params })
|
|
setEvents(data.items)
|
|
setTotal(data.total)
|
|
setPage(p)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [filterChannel, filterType, filterDateFrom, filterDateTo])
|
|
|
|
useEffect(() => { loadEvents(1) }, []) // eslint-disable-line
|
|
|
|
const doSearch = () => loadEvents(1)
|
|
|
|
const clearEvents = async () => {
|
|
if (!confirm('¿Eliminar todos los registros de fallos?')) return
|
|
await api.delete('/logs/events')
|
|
setEvents([])
|
|
setTotal(0)
|
|
}
|
|
|
|
const totalPages = Math.ceil(total / limit)
|
|
|
|
const caidas = events.filter(e => e.event_type === 'caida').length
|
|
const recuperados = events.filter(e => e.event_type === 'recuperado').length
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex justify-end gap-2">
|
|
<button onClick={() => loadEvents(page)} className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm transition-colors">
|
|
<RefreshCw size={14} /> Actualizar
|
|
</button>
|
|
<button onClick={clearEvents} className="flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors">
|
|
<Trash2 size={14} /> Limpiar todo
|
|
</button>
|
|
</div>
|
|
|
|
{/* Summary cards */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<StatCard icon={AlertTriangle} label="Total fallos registrados" value={total.toLocaleString()} color="text-orange-400" />
|
|
<StatCard icon={AlertTriangle} label="Caídas (en vista actual)" value={caidas.toString()} color="text-red-400" />
|
|
<StatCard icon={CheckCircle} label="Recuperaciones (en vista actual)" value={recuperados.toString()} color="text-emerald-400" />
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-dark-800 rounded-xl p-4">
|
|
<div className="flex flex-wrap gap-2 items-end">
|
|
<div className="relative">
|
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
|
<input value={filterChannel} onChange={e => setFilterChannel(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Canal / proveedor" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500" />
|
|
</div>
|
|
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none">
|
|
<option value="">Todos los eventos</option>
|
|
<option value="caida">Caídas</option>
|
|
<option value="recuperado">Recuperaciones</option>
|
|
</select>
|
|
<input type="date" value={filterDateFrom} onChange={e => setFilterDateFrom(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" />
|
|
<span className="text-gray-500 text-sm">→</span>
|
|
<input type="date" value={filterDateTo} onChange={e => setFilterDateTo(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" />
|
|
<button onClick={doSearch} className="flex items-center gap-2 px-4 py-1.5 bg-primary-500 hover:bg-primary-600 rounded-lg text-sm transition-colors">
|
|
<Search size={13} /> Buscar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Events table */}
|
|
<div className="bg-dark-800 rounded-xl overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
|
<span className="text-sm text-gray-400">{total.toLocaleString()} eventos</span>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<button onClick={() => loadEvents(page - 1)} disabled={page === 1} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">←</button>
|
|
<span className="text-gray-400">{page} / {totalPages}</span>
|
|
<button onClick={() => loadEvents(page + 1)} disabled={page === totalPages} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">→</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 border-b border-dark-700">
|
|
<th className="text-left px-4 py-2">Fecha</th>
|
|
<th className="text-left px-4 py-2">Tipo</th>
|
|
<th className="text-left px-4 py-2">Canal</th>
|
|
<th className="text-left px-4 py-2">Proveedor · Dominio</th>
|
|
<th className="text-left px-4 py-2">Causa</th>
|
|
<th className="text-left px-4 py-2 whitespace-nowrap" title="Para caídas: tiempo que duró ffmpeg antes de fallar. Para recuperados: duración total de la interrupción.">Duración fallo</th>
|
|
<th className="text-right px-4 py-2" title="Número de intentos fallidos antes de recuperar (solo en recuperados)">Intentos</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && <tr><td colSpan={7} className="text-center py-8 text-gray-500">Cargando...</td></tr>}
|
|
{!loading && events.length === 0 && (
|
|
<tr><td colSpan={7} className="text-center py-8 text-gray-500">Sin fallos registrados — los errores reales del proveedor aparecerán aquí automáticamente</td></tr>
|
|
)}
|
|
{events.map(ev => (
|
|
<tr key={ev.id} className={`border-b border-dark-700/50 hover:bg-dark-700/30 transition-colors ${ev.event_type === 'caida' ? 'bg-red-950/10' : ev.event_type === 'recuperado' ? 'bg-emerald-950/10' : ''}`}>
|
|
<td className="px-4 py-2.5 text-gray-400 text-xs whitespace-nowrap">{fmtDate(ev.created_at)}</td>
|
|
<td className="px-4 py-2.5">
|
|
{ev.event_type === 'caida' ? (
|
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-500/20 text-red-400 border border-red-800/50">
|
|
<AlertTriangle size={10} /> Caída
|
|
</span>
|
|
) : ev.event_type === 'recuperado' ? (
|
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-800/50">
|
|
<CheckCircle size={10} /> Recuperado
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-gray-400">{ev.event_type}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-gray-200 max-w-[180px] truncate text-xs" title={ev.channel_name ?? ''}>
|
|
{ev.channel_name || (ev.channel_id ? `#${ev.channel_id}` : '—')}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-xs">
|
|
<span className="text-gray-400">{ev.provider_name || '—'}</span>
|
|
{ev.domain && <span className="block text-gray-600 font-mono text-[10px]">{ev.domain}</span>}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-gray-300 text-xs max-w-[260px]" title={ev.error_message ?? ''}>
|
|
{ev.error_message || '—'}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-xs tabular-nums">
|
|
{ev.pump_duration_secs != null ? (
|
|
<span className={ev.event_type === 'caida' ? 'text-red-400' : 'text-emerald-400'}>
|
|
{fmtDuration(ev.pump_duration_secs)}
|
|
</span>
|
|
) : '—'}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right text-gray-500 text-xs tabular-nums">
|
|
{ev.attempt_number ?? '—'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
|
|
|
type Tab = 'connections' | 'events'
|
|
|
|
export default function Logs() {
|
|
const [activeTab, setActiveTab] = useState<Tab>('connections')
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-xl font-bold">Registro</h1>
|
|
</div>
|
|
|
|
{/* Tab bar */}
|
|
<div className="flex gap-1 border-b border-dark-700 pb-0">
|
|
<button
|
|
onClick={() => setActiveTab('connections')}
|
|
className={clsx(
|
|
'px-4 py-2 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px',
|
|
activeTab === 'connections'
|
|
? 'border-primary-500 text-primary-400 bg-dark-800'
|
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
|
)}
|
|
>
|
|
Conexiones de usuarios
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('events')}
|
|
className={clsx(
|
|
'px-4 py-2 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px flex items-center gap-1.5',
|
|
activeTab === 'events'
|
|
? 'border-red-500 text-red-400 bg-dark-800'
|
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
|
)}
|
|
>
|
|
<AlertTriangle size={13} /> Fallos de stream
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'connections' && <ConnectionsTab />}
|
|
{activeTab === 'events' && <EventsTab />}
|
|
</div>
|
|
)
|
|
}
|