feat: show ffmpeg PID and CPU% per stream in dashboard
Backend samples /proc/{pid}/stat every 2s to calculate CPU usage of
each ffmpeg process and exposes ffmpeg_pid + ffmpeg_cpu_pct in stats().
Dashboard shows a cyan ffmpeg badge with live CPU% per stream row,
plus a new StatCard with total ffmpeg CPU across all active streams.
This commit is contained in:
@@ -29,6 +29,7 @@ is re-encoded — quality is bit-for-bit identical to the source.
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -103,6 +104,11 @@ class BroadcastGroup:
|
|||||||
self._status: str = "connecting"
|
self._status: str = "connecting"
|
||||||
self._last_data_at: float = time.time()
|
self._last_data_at: float = time.time()
|
||||||
|
|
||||||
|
# ffmpeg process tracking
|
||||||
|
self._ffmpeg_pid: int | None = None
|
||||||
|
self._ffmpeg_cpu: float = 0.0
|
||||||
|
self._cpu_task: asyncio.Task | None = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -124,6 +130,9 @@ class BroadcastGroup:
|
|||||||
self._task = asyncio.create_task(
|
self._task = asyncio.create_task(
|
||||||
self._pump_with_retry(), name=f"pump-ch{self.channel_id}"
|
self._pump_with_retry(), name=f"pump-ch{self.channel_id}"
|
||||||
)
|
)
|
||||||
|
self._cpu_task = asyncio.create_task(
|
||||||
|
self._monitor_cpu(), name=f"cpu-ch{self.channel_id}"
|
||||||
|
)
|
||||||
asyncio.create_task(self._probe_info(), name=f"probe-ch{self.channel_id}")
|
asyncio.create_task(self._probe_info(), name=f"probe-ch{self.channel_id}")
|
||||||
|
|
||||||
async def add_client(self, user_id: str, username: str = "", is_priority: bool = False) -> ClientHandle:
|
async def add_client(self, user_id: str, username: str = "", is_priority: bool = False) -> ClientHandle:
|
||||||
@@ -159,6 +168,8 @@ class BroadcastGroup:
|
|||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
self._status = "stopped"
|
self._status = "stopped"
|
||||||
|
if self._cpu_task and not self._cpu_task.done():
|
||||||
|
self._cpu_task.cancel()
|
||||||
if self._task and not self._task.done():
|
if self._task and not self._task.done():
|
||||||
self._task.cancel()
|
self._task.cancel()
|
||||||
try:
|
try:
|
||||||
@@ -197,12 +208,43 @@ class BroadcastGroup:
|
|||||||
"reconnect_count": self._reconnect_count,
|
"reconnect_count": self._reconnect_count,
|
||||||
"last_data_ago": round(time.time() - self._last_data_at, 1),
|
"last_data_ago": round(time.time() - self._last_data_at, 1),
|
||||||
"stream_info": self.stream_info,
|
"stream_info": self.stream_info,
|
||||||
|
# ffmpeg process info
|
||||||
|
"ffmpeg_pid": self._ffmpeg_pid,
|
||||||
|
"ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _monitor_cpu(self) -> None:
|
||||||
|
"""Sample /proc/{pid}/stat every 2 s and update self._ffmpeg_cpu (%)."""
|
||||||
|
clk_tck = float(os.sysconf(os.sysconf_names.get("SC_CLK_TCK", 2)) or 100)
|
||||||
|
prev_ticks: int | None = None
|
||||||
|
prev_wall: float | None = None
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
pid = self._ffmpeg_pid
|
||||||
|
if pid is None:
|
||||||
|
self._ffmpeg_cpu = 0.0
|
||||||
|
prev_ticks = prev_wall = None
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/stat") as f:
|
||||||
|
parts = f.read().split()
|
||||||
|
ticks = int(parts[13]) + int(parts[14]) # utime + stime
|
||||||
|
wall = time.time()
|
||||||
|
if prev_ticks is not None and prev_wall is not None:
|
||||||
|
dt = wall - prev_wall
|
||||||
|
if dt > 0:
|
||||||
|
self._ffmpeg_cpu = (ticks - prev_ticks) / clk_tck / dt * 100
|
||||||
|
prev_ticks = ticks
|
||||||
|
prev_wall = wall
|
||||||
|
except (FileNotFoundError, IndexError, ValueError, OSError):
|
||||||
|
self._ffmpeg_cpu = 0.0
|
||||||
|
prev_ticks = prev_wall = None
|
||||||
|
|
||||||
async def _probe_info(self) -> None:
|
async def _probe_info(self) -> None:
|
||||||
from .probe import probe_stream
|
from .probe import probe_stream
|
||||||
try:
|
try:
|
||||||
@@ -288,6 +330,7 @@ class BroadcastGroup:
|
|||||||
|
|
||||||
self._status = "ok"
|
self._status = "ok"
|
||||||
self._last_data_at = time.time()
|
self._last_data_at = time.time()
|
||||||
|
self._ffmpeg_pid = proc.pid
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[ch{self.channel_id}] ffmpeg started pid={proc.pid} "
|
f"[ch{self.channel_id}] ffmpeg started pid={proc.pid} "
|
||||||
f"(reconnects so far: {self._reconnect_count})"
|
f"(reconnects so far: {self._reconnect_count})"
|
||||||
@@ -325,6 +368,8 @@ class BroadcastGroup:
|
|||||||
await self._dispatch(chunk, now)
|
await self._dispatch(chunk, now)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
self._ffmpeg_pid = None
|
||||||
|
self._ffmpeg_cpu = 0.0
|
||||||
stderr_task.cancel()
|
stderr_task.cancel()
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle } from 'lucide-react'
|
import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle, Cpu } from 'lucide-react'
|
||||||
import api from '../lib/api'
|
import api from '../lib/api'
|
||||||
|
|
||||||
interface StreamClient {
|
interface StreamClient {
|
||||||
@@ -37,6 +37,9 @@ interface StreamSession {
|
|||||||
reconnect_count?: number
|
reconnect_count?: number
|
||||||
last_data_ago?: number
|
last_data_ago?: number
|
||||||
stream_info?: StreamInfo
|
stream_info?: StreamInfo
|
||||||
|
// ffmpeg process info (live streams only)
|
||||||
|
ffmpeg_pid?: number | null
|
||||||
|
ffmpeg_cpu_pct?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SlotInfo {
|
interface SlotInfo {
|
||||||
@@ -195,9 +198,10 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalClients = streams.reduce((a, s) => a + s.client_count, 0)
|
const totalClients = streams.reduce((a, s) => a + s.client_count, 0)
|
||||||
const busySlots = slots.filter(s => s.status === 'busy').length
|
const busySlots = slots.filter(s => s.status === 'busy').length
|
||||||
const totalBpsDown = streams.reduce((a, s) => a + (s.bps_down || 0), 0)
|
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 anyReconnecting = streams.some(s => s.status === 'reconnecting')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -225,11 +229,16 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats cards */}
|
{/* Stats cards */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
<StatCard icon={<Activity size={20} className="text-blue-400" />} label="Streams activos" value={streams.length} />
|
<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={<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}`} />
|
<StatCard icon={<Server size={20} className="text-orange-400" />} label="Slots ocupados" value={`${busySlots}/${slots.length}`} />
|
||||||
<StatCard icon={<Wifi size={20} className="text-purple-400" />} label="↓ Total proveedor" value={formatBps(totalBpsDown)} />
|
<StatCard icon={<Wifi size={20} className="text-purple-400" />} label="↓ Total proveedor" value={formatBps(totalBpsDown)} />
|
||||||
|
<StatCard
|
||||||
|
icon={<Cpu size={20} className={totalCpu > 80 ? 'text-red-400' : totalCpu > 40 ? 'text-yellow-400' : 'text-cyan-400'} />}
|
||||||
|
label="ffmpeg CPU total"
|
||||||
|
value={`${totalCpu.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider slots */}
|
{/* Provider slots */}
|
||||||
@@ -309,6 +318,23 @@ export default function Dashboard() {
|
|||||||
reconnectCount={s.reconnect_count}
|
reconnectCount={s.reconnect_count}
|
||||||
lastDataAgo={s.last_data_ago}
|
lastDataAgo={s.last_data_ago}
|
||||||
/>
|
/>
|
||||||
|
{s.ffmpeg_pid && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||||||
|
<span className="inline-flex items-center gap-1 bg-cyan-900/40 text-cyan-300 text-[10px] px-1.5 py-0.5 rounded border border-cyan-800 font-mono">
|
||||||
|
<Cpu size={9} />
|
||||||
|
ffmpeg
|
||||||
|
</span>
|
||||||
|
{s.ffmpeg_cpu_pct !== undefined && (
|
||||||
|
<span className={`font-mono text-[10px] tabular-nums ${
|
||||||
|
s.ffmpeg_cpu_pct > 80 ? 'text-red-400' :
|
||||||
|
s.ffmpeg_cpu_pct > 40 ? 'text-yellow-400' :
|
||||||
|
'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{s.ffmpeg_cpu_pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user