Mejoras de estabilidad de stream IPTV y nuevo registro de fallos
- restream.py: eliminar -reconnect_streamed de ffmpeg para evitar rebobinados al reconectar (el proveedor re-enviaba desde keyframe anterior causando PTS backward). El loop de Python gestiona las reconexiones con conexión fresca. - restream.py: eliminar _drain_queue del loop de reconexión para que el buffer de la cola cubra el tiempo de reconexión sin pantalla negra en el player. - restream.py: añadir buffer_server_bytes/secs/capacity a stats() y método _log_stream_event() para persistir eventos de caída y recuperación en BD. - models/stream_event.py: nuevo modelo StreamEvent para registro de fallos. - database.py: registrar StreamEvent en init_db. - api/admin/logs.py: nuevos endpoints GET/DELETE /logs/events. - Dashboard.tsx: mostrar reserva de buffer del servidor (capacidad + estado). - Logs.tsx: añadir pestaña "Fallos de stream" con tabla de eventos persistidos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
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
|
||||
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</th>
|
||||
<th className="text-left px-4 py-2">Dominio</th>
|
||||
<th className="text-left px-4 py-2">Detalle</th>
|
||||
<th className="text-right px-4 py-2">Intento</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">No hay eventos registrados — los fallos 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">
|
||||
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{fmtDate(ev.created_at)}</td>
|
||||
<td className="px-4 py-2">
|
||||
{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">
|
||||
<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">
|
||||
<CheckCircle size={10} /> Recuperado
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">{ev.event_type}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-200 max-w-[180px] truncate" title={ev.channel_name ?? ''}>
|
||||
{ev.channel_name || `#${ev.channel_id}` || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs">{ev.provider_name || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{ev.domain || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs max-w-[240px] truncate" title={ev.error_message ?? ''}>
|
||||
{ev.error_message || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user