feat: A/V health monitor, buffer bar and stream health dashboard

Backend (restream.py):
- Add _health_check_loop task: checks data flow every 5 s, updates
  av_health (ok/warning/error), auto-corrects stuck ffmpeg via SIGTERM
- Health thresholds: warning >12 s, error >25 s — normal reconnects
  (1-4 s) no longer trigger false warnings
- Count ffmpeg stderr A/V-warning events (av_desync_count) as metric
- Expose buffer_pct (server-side queue fill of slowest client),
  corrections (health-triggered ffmpeg restarts), av_health in stats()
- Revert wallclock timestamp flag: -use_wallclock_as_timestamps 1 was
  generating hundreds of spurious demuxer warnings per session, causing
  ffmpeg to stall and producing continuous video freezes
- Simplify ffmpeg cmd to minimal safe set: -reconnect 1
  -reconnect_streamed 1 -reconnect_delay_max 2

Dashboard (Dashboard.tsx):
- Add 6th StatCard "Salud A/V" with dynamic color and correction count
- Add "Salud A/V" column per stream: badge ok/aviso/error,
  corrections counter (violet), desync event count (grey)
- Add BufferBar: thin progress bar showing server queue fill %
  (green <40 %, yellow 40-70 %, red >70 %)
- Alert banner in header when any stream is in error av_health state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joaquin
2026-05-18 01:14:37 +02:00
parent cccdf9f137
commit 57c8f6fb64
2 changed files with 204 additions and 28 deletions
+121 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle, Cpu } from 'lucide-react'
import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle, Cpu, HeartPulse, ShieldCheck } from 'lucide-react'
import api from '../lib/api'
interface StreamClient {
@@ -32,14 +32,19 @@ interface StreamSession {
bps_down: number
bps_up: number
running: boolean
// health (live streams only)
// stream health
status?: 'connecting' | 'ok' | 'reconnecting' | 'stopped'
reconnect_count?: number
last_data_ago?: number
stream_info?: StreamInfo
// ffmpeg process info (live streams only)
// ffmpeg process info
ffmpeg_pid?: number | null
ffmpeg_cpu_pct?: number
// A/V health (from health monitor)
av_health?: 'ok' | 'warning' | 'error'
av_desync_count?: number // cumulative ffmpeg AV-warning events
corrections?: number // auto-corrections by health monitor
buffer_pct?: number // server-side queue fill % (0 = client fast, 100 = client slow)
}
interface SlotInfo {
@@ -74,13 +79,6 @@ function formatDuration(startTs: number) {
function formatResolution(info: StreamInfo) {
if (!info.width || !info.height) return null
const res = info.height >= 1060 ? '4K' : info.height >= 1060 ? '4K'
: info.height >= 2000 ? '4K'
: info.height >= 1060 ? 'FHD'
: info.height >= 1040 ? 'FHD'
: info.height >= 700 ? 'HD'
: info.height >= 460 ? 'SD'
: 'LD'
const label =
info.width >= 3840 ? '4K' :
info.width >= 1920 ? 'FHD' :
@@ -118,10 +116,9 @@ function StreamInfoBadge({ info }: { info: StreamInfo }) {
)
}
function HealthBadge({ status, reconnectCount, lastDataAgo }: {
function HealthBadge({ status, reconnectCount }: {
status?: string
reconnectCount?: number
lastDataAgo?: number
}) {
if (!status || status === 'ok') {
if (reconnectCount && reconnectCount > 0) {
@@ -142,6 +139,70 @@ function HealthBadge({ status, reconnectCount, lastDataAgo }: {
return <span className="inline-flex items-center gap-1 text-[10px] text-red-400"><AlertTriangle size={9} /> {status}</span>
}
function AVHealthBadge({ health, desyncs, corrections }: {
health?: 'ok' | 'warning' | 'error'
desyncs?: number
corrections?: number
}) {
const hasCorrections = corrections != null && corrections > 0
const hasDesyncs = desyncs != null && desyncs > 0
const badge = health === 'error'
? <span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 text-red-300 border border-red-800">
<AlertTriangle size={9} /> A/V error
</span>
: health === 'warning'
? <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">
<HeartPulse size={9} /> A/V aviso
</span>
: <span className="inline-flex items-center gap-1 text-[10px] text-emerald-500">
<ShieldCheck size={9} /> A/V ok
</span>
return (
<div className="flex flex-wrap items-center gap-1 mt-1">
{badge}
{hasCorrections && (
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-violet-900/40 text-violet-300 border border-violet-800" title="Correcciones automáticas del monitor de salud">
<RefreshCw size={9} /> ×{corrections}
</span>
)}
{hasDesyncs && (
<span className="text-[10px] text-gray-600 tabular-nums" title="Eventos A/V en stderr de ffmpeg (informativos)">
{desyncs} ev.
</span>
)}
</div>
)
}
function BufferBar({ pct }: { pct?: number }) {
const v = pct ?? 0
const color =
v >= 70 ? 'bg-red-500' :
v >= 40 ? 'bg-yellow-400' :
'bg-emerald-500'
const label =
v >= 70 ? 'text-red-400' :
v >= 40 ? 'text-yellow-400' :
'text-emerald-500'
return (
<div className="mt-1.5 space-y-0.5" title={`Buffer servidor: ${v}% — 0% = cliente consume sin retraso, 100% = cliente lento/bloqueado`}>
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-500">Buffer</span>
<span className={`text-[10px] font-mono tabular-nums ${label}`}>{v.toFixed(0)}%</span>
</div>
<div className="w-full h-1.5 rounded-full bg-dark-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ${color}`}
style={{ width: `${Math.min(v, 100)}%` }}
/>
</div>
</div>
)
}
const TYPE_ICONS: Record<string, React.ReactNode> = {
live: <Tv size={12} className="text-blue-400" />,
movie: <Film size={12} className="text-purple-400" />,
@@ -203,6 +264,20 @@ export default function Dashboard() {
const totalBpsDown = streams.reduce((a, s) => a + (s.bps_down || 0), 0)
const totalCpu = streams.reduce((a, s) => a + (s.ffmpeg_cpu_pct ?? 0), 0)
const anyReconnecting = streams.some(s => s.status === 'reconnecting')
const streamsOk = streams.filter(s => !s.av_health || s.av_health === 'ok').length
const streamsWarn = streams.filter(s => s.av_health === 'warning').length
const streamsErr = streams.filter(s => s.av_health === 'error').length
const totalCorrections = streams.reduce((a, s) => a + (s.corrections ?? 0), 0)
const healthColor =
streamsErr > 0 ? 'text-red-400' :
streamsWarn > 0 ? 'text-yellow-400' :
'text-emerald-400'
const healthValue =
streams.length === 0 ? '—' :
streamsErr > 0 ? `${streamsErr} error${streamsErr > 1 ? 'es' : ''}` :
streamsWarn > 0 ? `${streamsWarn} aviso${streamsWarn > 1 ? 's' : ''}` :
`${streamsOk} ok`
return (
<div className="p-6 space-y-6">
@@ -215,6 +290,12 @@ export default function Dashboard() {
Reconectando stream(s)...
</span>
)}
{streamsErr > 0 && (
<span className="flex items-center gap-1.5 text-xs text-red-400 bg-red-900/30 border border-red-800 px-2 py-1 rounded-lg">
<AlertTriangle size={11} />
{streamsErr} stream{streamsErr > 1 ? 's' : ''} con error A/V
</span>
)}
<div className={`flex items-center gap-2 text-xs ${connected ? 'text-green-400' : 'text-red-400'}`}>
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400 animate-pulse' : 'bg-red-400'}`} />
{connected ? 'Conectado en tiempo real' : 'Desconectado'}
@@ -229,7 +310,7 @@ export default function Dashboard() {
)}
{/* Stats cards */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-6 gap-4">
<StatCard icon={<Activity size={20} className="text-blue-400" />} label="Streams activos" value={streams.length} />
<StatCard icon={<Users size={20} className="text-green-400" />} label="Usuarios conectados" value={totalClients} />
<StatCard icon={<Server size={20} className="text-orange-400" />} label="Slots ocupados" value={`${busySlots}/${slots.length}`} />
@@ -239,6 +320,12 @@ export default function Dashboard() {
label="ffmpeg CPU total"
value={`${totalCpu.toFixed(2)}%`}
/>
<StatCard
icon={<HeartPulse size={20} className={healthColor} />}
label={`Salud A/V${totalCorrections > 0 ? ` · ${totalCorrections} corr.` : ''}`}
value={healthValue}
valueClass={healthColor}
/>
</div>
{/* Provider slots */}
@@ -280,6 +367,7 @@ export default function Dashboard() {
<th className="px-4 py-2">Proveedor</th>
<th className="px-4 py-2">Stream</th>
<th className="px-4 py-2">Estado</th>
<th className="px-4 py-2">Salud A/V</th>
<th className="px-4 py-2">Usuarios</th>
<th className="px-4 py-2">↓ / ↑</th>
<th className="px-4 py-2">Tiempo</th>
@@ -312,11 +400,12 @@ export default function Dashboard() {
: <span className="text-gray-600 text-xs italic">—</span>
}
</td>
{/* Stream status + ffmpeg */}
<td className="px-4 py-2.5">
<HealthBadge
status={s.status}
reconnectCount={s.reconnect_count}
lastDataAgo={s.last_data_ago}
/>
{s.ffmpeg_pid && (
<div className="mt-1.5 flex items-center gap-1.5">
@@ -336,6 +425,17 @@ export default function Dashboard() {
</div>
)}
</td>
{/* A/V health + buffer */}
<td className="px-4 py-2.5 min-w-[140px]">
<AVHealthBadge
health={s.av_health}
desyncs={s.av_desync_count}
corrections={s.corrections}
/>
<BufferBar pct={s.buffer_pct} />
</td>
<td className="px-4 py-2.5">
<div className="flex flex-col gap-1">
{s.clients.map(c => (
@@ -373,13 +473,18 @@ export default function Dashboard() {
)
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) {
function StatCard({ icon, label, value, valueClass }: {
icon: React.ReactNode
label: string
value: string | number
valueClass?: string
}) {
return (
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center gap-4">
<div className="bg-dark-700 p-2.5 rounded-lg">{icon}</div>
<div>
<p className="text-xs text-gray-500">{label}</p>
<p className="text-2xl font-bold">{value}</p>
<p className={`text-2xl font-bold ${valueClass ?? ''}`}>{value}</p>
</div>
</div>
)