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]>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|