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]>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-09-22 18:07:56 +08:00
1 parent 186e32caf1
commit 996e3fea39
70 files changed
+4844 -3070

No files matched your search

+214 -100
View File
@@ -1,8 +1,26 @@
import { useEffect, useState } from 'react'
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
import { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
import api from '../../api/client'
import { getSystemMetrics, type SystemMetrics } from '../../api/system'
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,
@@ -10,9 +28,10 @@ import {
sendRemoteCommand,
type StreamInfo,
type ViewerSessionInfo,
} from '../../api/streams'
const { Title, Text } = Typography
} from '@/api/streams'
import { formatBytes } from '@/lib/status'
import { PageHeader } from '@/components/page-header'
import { StatCard } from '@/components/stat-card'
interface NodeMetric {
id: string
@@ -27,19 +46,13 @@ interface NodeMetric {
} | null
}
const formatBytes = (value = 0) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let amount = Math.max(0, value)
let index = 0
while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1 }
return `${amount.toFixed(index ? 1 : 0)} ${units[index]}`
}
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<React.Key[]>([])
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[]>([])
@@ -51,7 +64,7 @@ export default function LiveBoard() {
try {
const programs = await fetchStreams(true)
setStreams(programs)
const nextId = streamId || programs.find(program => program.status === 'live')?.id
const nextId = streamId || programs.find((p) => p.status === 'live')?.id
if (nextId) {
setStreamId(nextId)
setSessions(await fetchViewerSessions(nextId))
@@ -59,7 +72,7 @@ export default function LiveBoard() {
setSessions([])
}
} catch {
message.error('看板数据加载失败')
toast.error('看板数据加载失败')
} finally {
try {
setSystemMetrics(await getSystemMetrics())
@@ -83,102 +96,203 @@ export default function LiveBoard() {
}, [streamId])
async function controlViewers() {
if (!streamId || selected.length === 0) return
if (!streamId || selected.size === 0) return
try {
const session = await createRemoteSession(streamId)
const targets = selected.map(String)
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)
message.success(`已向 ${selected.length} 个观众下发节点与清晰度策略`)
toast.success(`已向 ${selected.size} 个观众下发节点与清晰度策略`)
} catch {
message.error('控制命令下发失败,请确认节目已启用审计式播放控制')
toast.error('控制命令下发失败,请确认节目已启用审计式播放控制')
}
}
const activeSessions = sessions.filter(item => Date.now() - new Date(item.last_heartbeat).getTime() < 30000)
const buffering = activeSessions.filter(item => item.playback_state === 'buffering' || Number(item.buffered_seconds || 0) < 1)
const totalCardsLoading = !systemMetrics
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>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div>
<Title level={2} style={{ margin: 0 }}>直播看板</Title>
<Text type="secondary">实时检查节目、观众会话、线路与播放健康度</Text>
<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>
<Space wrap>
<Select
value={streamId}
placeholder="选择直播节目"
style={{ minWidth: 220 }}
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
onChange={value => { setStreamId(value); setSelected([]) }}
/>
<Button icon={wallMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />} onClick={() => setWallMode(value => !value)}>{wallMode ? '退出大屏' : '大屏模式'}</Button>
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
</Space>
</div>
</CardContent>
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}><Card><Statistic title="活跃观众" value={activeSessions.length} prefix={<TeamOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} prefix={<SignalFilled />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" loading={totalCardsLoading} prefix={<DashboardOutlined />} /></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" loading={totalCardsLoading} prefix={<DatabaseOutlined />} /><Text type="secondary">{formatBytes(systemMetrics?.memory_used_bytes)} / {formatBytes(systemMetrics?.memory_total_bytes)}</Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="存储使用率" value={systemMetrics?.disk_percent ?? 0} precision={1} suffix="%" loading={totalCardsLoading} prefix={<DatabaseOutlined />} /><Text type="secondary">{formatBytes(systemMetrics?.disk_used_bytes)} / {formatBytes(systemMetrics?.disk_total_bytes)}</Text></Card></Col>
<Col xs={24} md={12}><Card><Statistic title="NET I/O 下载总量" value={formatBytes(systemMetrics?.network_received_bytes)} loading={totalCardsLoading} /></Card></Col>
<Col xs={24} md={12}><Card><Statistic title="NET I/O 上传总量" value={formatBytes(systemMetrics?.network_sent_bytes)} loading={totalCardsLoading} /></Card></Col>
</Row>
<Card title="节点实时指标">
<Row gutter={[12, 12]}>
{nodes.map(node => <Col xs={24} md={12} xl={8} key={node.id}><Card size="small" title={<Space>{node.name}<Tag color={node.online ? 'green' : 'default'}>{node.online ? '在线' : '离线'}</Tag></Space>}><Space direction="vertical"><Text>CPU:{node.metrics?.cpu_percent?.toFixed(1) ?? '-'}%</Text><Text>内存:{node.metrics?.memory_percent?.toFixed(1) ?? '-'}%</Text><Text>NET IN:{node.metrics?.network_in_mbps?.toFixed(2) ?? '-'} Mbps</Text><Text>NET OUT:{node.metrics?.network_out_mbps?.toFixed(2) ?? '-'} Mbps</Text><Text>观众:{node.metrics?.viewer_count ?? 0}</Text></Space></Card></Col>)}
</Row>
{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>
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
<Card
title="审计式观众会话"
extra={
<Space wrap>
<Select value={nodeId} onChange={setNodeId} style={{ width: 180 }} options={[{ value: 'auto', label: '节点:保持自动' }, ...nodes.filter(node => node.online).map(node => ({ value: node.id, label: `节点:${node.name}` }))]} />
<Select value={quality} onChange={setQuality} style={{ width: 160 }} options={[
{ value: 'auto', label: '恢复自动' },
{ value: '1080p', label: '1080p' },
{ value: '720p', label: '720p' },
{ value: '480p', label: '480p' },
{ value: '360p', label: '360p' },
]} />
<Button type="primary" disabled={!selected.length} onClick={controlViewers}>批量切换</Button>
</Space>
}
>
<Table
rowKey="session_id"
loading={loading}
dataSource={sessions}
scroll={{ x: 850 }}
rowSelection={{ selectedRowKeys: selected, onChange: setSelected }}
pagination={{ pageSize: 20 }}
columns={[
{ title: '会话', dataIndex: 'session_id', ellipsis: true, width: 180 },
{ title: '节点', dataIndex: 'node_name', render: value => value || '未知' },
{ title: '清晰度', dataIndex: 'quality', render: value => value || '自动' },
{ title: '状态', dataIndex: 'playback_state', render: value => <Tag color={value === 'playing' ? 'green' : value === 'buffering' ? 'orange' : 'default'}>{value}</Tag> },
{ title: '缓冲', dataIndex: 'buffered_seconds', render: value => `${Number(value || 0).toFixed(1)}s` },
{ title: '远程调整', dataIndex: 'remote_adjusted', render: value => value ? <Tag color="gold">已调整</Tag> : '否' },
{ title: '最后心跳', dataIndex: 'last_heartbeat', render: value => new Date(value).toLocaleTimeString() },
{ title: '错误', dataIndex: 'error', ellipsis: true, render: value => value || '-' },
]}
/>
</Card>
)}
</Space>
)}
</div>
)
}
}