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>
This commit is contained in:
@@ -34,7 +34,9 @@ interface StreamSession {
|
||||
running: boolean
|
||||
// stream health
|
||||
status?: 'connecting' | 'ok' | 'reconnecting' | 'stopped'
|
||||
reconnect_count?: number
|
||||
reconnect_count?: number // total ffmpeg restarts (sessions + failures)
|
||||
session_count?: number // normal provider session timeouts (expected, no interruption)
|
||||
failure_count?: number // real quick failures (<25s) — actual errors
|
||||
last_data_ago?: number
|
||||
stream_info?: StreamInfo
|
||||
// ffmpeg process info
|
||||
@@ -119,25 +121,49 @@ function StreamInfoBadge({ info }: { info: StreamInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HealthBadge({ status, reconnectCount }: {
|
||||
function HealthBadge({ status, sessionCount, failureCount }: {
|
||||
status?: string
|
||||
reconnectCount?: number
|
||||
sessionCount?: number
|
||||
failureCount?: number
|
||||
}) {
|
||||
if (!status || status === 'ok') {
|
||||
if (reconnectCount && reconnectCount > 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 text-yellow-300 border border-yellow-800" title={`${reconnectCount} reconexión(es)`}>
|
||||
<RefreshCw size={9} /> {reconnectCount}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-green-500"><span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block" /> OK</span>
|
||||
}
|
||||
const hasFail = failureCount != null && failureCount > 0
|
||||
const hasSess = sessionCount != null && sessionCount > 0
|
||||
|
||||
if (status === 'connecting') {
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-gray-400"><RefreshCw size={9} className="animate-spin" /> Conectando</span>
|
||||
}
|
||||
if (status === 'reconnecting') {
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-orange-400"><RefreshCw size={9} className="animate-spin" /> Reconectando {reconnectCount ? `(×${reconnectCount})` : ''}</span>
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-orange-400">
|
||||
<RefreshCw size={9} className="animate-spin" /> Reconectando
|
||||
{hasFail && <span className="ml-1 px-1 bg-red-900/40 text-red-300 border border-red-800 rounded">{failureCount} fallo{failureCount! > 1 ? 's' : ''}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (!status || status === 'ok') {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-green-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block" /> OK
|
||||
</span>
|
||||
{hasSess && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] text-gray-500"
|
||||
title={`${sessionCount} rotación(es) de sesión del proveedor — el proveedor cierra la conexión cada ~2 min (normal). El buffer absorbe el cambio sin interrupción.`}
|
||||
>
|
||||
<RefreshCw size={8} /> {sessionCount} sesión{sessionCount! > 1 ? 'es' : ''}
|
||||
</span>
|
||||
)}
|
||||
{hasFail && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1 bg-red-900/40 text-red-300 border border-red-800 rounded"
|
||||
title={`${failureCount} fallo(s) real(es) detectado(s) — conexión rechazada, DNS, auth u otro error. Ver Registro → Fallos de stream.`}
|
||||
>
|
||||
<AlertTriangle size={8} /> {failureCount} fallo{failureCount! > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-red-400"><AlertTriangle size={9} /> {status}</span>
|
||||
}
|
||||
@@ -431,7 +457,8 @@ export default function Dashboard() {
|
||||
<td className="px-4 py-2.5">
|
||||
<HealthBadge
|
||||
status={s.status}
|
||||
reconnectCount={s.reconnect_count}
|
||||
sessionCount={s.session_count}
|
||||
failureCount={s.failure_count}
|
||||
/>
|
||||
{s.ffmpeg_pid && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
|
||||
+26
-16
@@ -39,6 +39,7 @@ interface StreamEvent {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -375,42 +376,51 @@ function EventsTab() {
|
||||
<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>
|
||||
<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">No hay eventos registrados — los fallos aparecerán aquí automáticamente</td></tr>
|
||||
<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">
|
||||
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{fmtDate(ev.created_at)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<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">
|
||||
<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">
|
||||
<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 text-gray-200 max-w-[180px] truncate" title={ev.channel_name ?? ''}>
|
||||
{ev.channel_name || `#${ev.channel_id}` || '—'}
|
||||
<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 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 ?? ''}>
|
||||
<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 text-right text-gray-500 text-xs tabular-nums">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user