Files
KiraTV/frontend/src/pages/Logs.tsx
T
KiraStream 4bfc7592a6 fix: distinguir sesiones normales del proveedor de fallos reales en logs y dashboard
- 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>
2026-05-19 15:12:04 +00:00

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>
)
}