184 lines
9.5 KiB
TypeScript
184 lines
9.5 KiB
TypeScript
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 {
|
||
createRemoteSession,
|
||
fetchStreams,
|
||
fetchViewerSessions,
|
||
sendRemoteCommand,
|
||
type StreamInfo,
|
||
type ViewerSessionInfo,
|
||
} from '../../api/streams'
|
||
|
||
const { Title, Text } = Typography
|
||
|
||
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 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]}`
|
||
}
|
||
|
||
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 [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(program => program.status === 'live')?.id
|
||
if (nextId) {
|
||
setStreamId(nextId)
|
||
setSessions(await fetchViewerSessions(nextId))
|
||
} else {
|
||
setSessions([])
|
||
}
|
||
} catch {
|
||
message.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.length === 0) return
|
||
try {
|
||
const session = await createRemoteSession(streamId)
|
||
const targets = selected.map(String)
|
||
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} 个观众下发节点与清晰度策略`)
|
||
} catch {
|
||
message.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
|
||
|
||
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>
|
||
<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>
|
||
|
||
<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>
|
||
</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>
|
||
)
|
||
} |