Files
live/frontend/src/admin/pages/LiveBoard.tsx
T
adminandClaude Opus 4.8 996e3fea39 feat: consolidate on the shadcn frontend and harden playback
Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
  keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
  one router and one design system to maintain.

Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
  single 4s segment, so playback ran at up to 1.10x and the forward buffer
  bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
  buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
  hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
  the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
  gesture that restored sound also toggled playback off. One owner now.

Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
  transcoded ones, so a source option exists even when every ladder entry
  downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
  MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.

Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
  never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
  left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
  JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
  and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
  and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
  httpx client open for the streamed body.

Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
  .venv; remove generated screenshots, load test scripts and stray files.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-09-22 18:07:56 +08:00

299 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { RefreshCw, Maximize2, Minimize2, Users, Signal, TriangleAlert, Cpu, MemoryStick, HardDrive, Download, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import api from '@/api/client'
import { getSystemMetrics, type SystemMetrics } from '@/api/system'
import {
createRemoteSession,
fetchStreams,
fetchViewerSessions,
sendRemoteCommand,
type StreamInfo,
type ViewerSessionInfo,
} from '@/api/streams'
import { formatBytes } from '@/lib/status'
import { PageHeader } from '@/components/page-header'
import { StatCard } from '@/components/stat-card'
interface NodeMetric {
id: string
name: string
online: boolean
metrics?: {
cpu_percent?: number | null
memory_percent?: number | null
network_in_mbps?: number | null
network_out_mbps?: number | null
viewer_count?: number | null
} | null
}
const QUALITIES = ['1080p', '720p', '480p', '360p']
export default function LiveBoard() {
const [streams, setStreams] = useState<StreamInfo[]>([])
const [streamId, setStreamId] = useState<number>()
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
const [nodes, setNodes] = useState<NodeMetric[]>([])
const [quality, setQuality] = useState('auto')
const [nodeId, setNodeId] = useState('auto')
const [wallMode, setWallMode] = useState(false)
async function load() {
try {
const programs = await fetchStreams(true)
setStreams(programs)
const nextId = streamId || programs.find((p) => p.status === 'live')?.id
if (nextId) {
setStreamId(nextId)
setSessions(await fetchViewerSessions(nextId))
} else {
setSessions([])
}
} catch {
toast.error('看板数据加载失败')
} finally {
try {
setSystemMetrics(await getSystemMetrics())
} catch {
setSystemMetrics(null)
}
try {
const nodeResponse = await api.get<NodeMetric[]>('/nodes')
setNodes(nodeResponse.data)
} catch {
setNodes([])
}
setLoading(false)
}
}
useEffect(() => {
void load()
const timer = window.setInterval(load, 5000)
return () => window.clearInterval(timer)
}, [streamId])
async function controlViewers() {
if (!streamId || selected.size === 0) return
try {
const session = await createRemoteSession(streamId)
const targets = Array.from(selected)
if (nodeId !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_node', targets, nodeId)
if (quality !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_quality', targets, quality)
if (nodeId === 'auto' && quality === 'auto') await sendRemoteCommand(streamId, session.token, 'restore_auto', targets)
toast.success(`已向 ${selected.size} 个观众下发节点与清晰度策略`)
} catch {
toast.error('控制命令下发失败,请确认节目已启用审计式播放控制')
}
}
const activeSessions = sessions.filter((s) => Date.now() - new Date(s.last_heartbeat).getTime() < 30000)
const buffering = activeSessions.filter(
(s) => s.playback_state === 'buffering' || Number(s.buffered_seconds || 0) < 1,
)
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className={wallMode ? 'fixed inset-0 z-50 overflow-auto bg-background p-6' : 'space-y-6'}>
<PageHeader title="直播看板" kicker="Live Board" description="实时检查节目、观众会话、线路与播放健康度">
<Select value={streamId ? String(streamId) : undefined} onValueChange={(v) => { setStreamId(Number(v)); setSelected(new Set()) }}>
<SelectTrigger className="w-56">
<SelectValue placeholder="选择直播节目" />
</SelectTrigger>
<SelectContent>
{streams.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.title} · {p.status}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={() => setWallMode((v) => !v)}>
{wallMode ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
{wallMode ? '退出大屏' : '大屏模式'}
</Button>
<Button variant="outline" onClick={load}>
<RefreshCw className="h-4 w-4" />
刷新
</Button>
</PageHeader>
<div className="grid gap-4 sm:grid-cols-3">
<StatCard label="活跃观众" value={activeSessions.length} icon={Users} />
<StatCard label="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} icon={Signal} tone="success" />
<StatCard label="疑似卡顿" value={buffering.length} icon={TriangleAlert} tone={buffering.length ? 'warning' : 'default'} />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard label="CPU 使用率" value={`${systemMetrics?.cpu_percent.toFixed(1) ?? '—'}%`} icon={Cpu} />
<StatCard label="内存使用率" value={`${systemMetrics?.memory_percent.toFixed(1) ?? '—'}%`} icon={MemoryStick} />
<StatCard label="存储使用率" value={`${systemMetrics?.disk_percent.toFixed(1) ?? '—'}%`} icon={HardDrive} />
<StatCard label="下载总量" value={formatBytes(systemMetrics?.network_received_bytes)} icon={Download} />
<StatCard label="上传总量" value={formatBytes(systemMetrics?.network_sent_bytes)} icon={Upload} />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">节点实时指标</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{nodes.map((node) => (
<div key={node.id} className="rounded-md border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="font-medium">{node.name}</span>
<Badge variant={node.online ? 'default' : 'secondary'}>{node.online ? '在线' : '离线'}</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div>CPU:{node.metrics?.cpu_percent?.toFixed(1) ?? '-'}%</div>
<div>内存:{node.metrics?.memory_percent?.toFixed(1) ?? '-'}%</div>
<div>NET IN:{node.metrics?.network_in_mbps?.toFixed(2) ?? '-'} Mbps</div>
<div>NET OUT:{node.metrics?.network_out_mbps?.toFixed(2) ?? '-'} Mbps</div>
<div>观众:{node.metrics?.viewer_count ?? 0}</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{streamId && (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">审计式观众会话</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Select value={nodeId} onValueChange={setNodeId}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">节点:保持自动</SelectItem>
{nodes.filter((n) => n.online).map((n) => (
<SelectItem key={n.id} value={n.id}>
节点:{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={quality} onValueChange={setQuality}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">恢复自动</SelectItem>
{QUALITIES.map((q) => (
<SelectItem key={q} value={q}>
{q}
</SelectItem>
))}
</SelectContent>
</Select>
<Button disabled={selected.size === 0} onClick={controlViewers}>
批量切换
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={sessions.length > 0 && selected.size === sessions.length}
onChange={(e) => setSelected(e.target.checked ? new Set(sessions.map((s) => s.session_id)) : new Set())}
className="h-4 w-4 accent-primary"
/>
</TableHead>
<TableHead>会话</TableHead>
<TableHead>节点</TableHead>
<TableHead>清晰度</TableHead>
<TableHead>状态</TableHead>
<TableHead>缓冲</TableHead>
<TableHead>远程调整</TableHead>
<TableHead>最后心跳</TableHead>
<TableHead>错误</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && sessions.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : sessions.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center text-muted-foreground">
暂无观众会话
</TableCell>
</TableRow>
) : (
sessions.map((s) => (
<TableRow key={s.session_id}>
<TableCell>
<input
type="checkbox"
checked={selected.has(s.session_id)}
onChange={() => toggleSelect(s.session_id)}
className="h-4 w-4 accent-primary"
/>
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{s.session_id.slice(0, 12)}…</code>
</TableCell>
<TableCell>{s.node_name || '未知'}</TableCell>
<TableCell>{s.quality || '自动'}</TableCell>
<TableCell>
<Badge variant={s.playback_state === 'playing' ? 'default' : s.playback_state === 'buffering' ? 'secondary' : 'outline'}>
{s.playback_state}
</Badge>
</TableCell>
<TableCell>{(Number(s.buffered_seconds || 0)).toFixed(1)}s</TableCell>
<TableCell>{s.remote_adjusted ? <Badge className="bg-amber-600">已调整</Badge> : '否'}</TableCell>
<TableCell className="text-muted-foreground">{new Date(s.last_heartbeat).toLocaleTimeString()}</TableCell>
<TableCell className="max-w-0 truncate text-muted-foreground">{s.error || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)
}