feat: expand livestream management platform
This commit is contained in:
1 parent
383c066d86
commit
ed62d42a63
12 files changed
+223
-24
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Button, Typography } from 'antd'
|
||||
import {
|
||||
@@ -20,6 +20,16 @@ const menuItems = [
|
||||
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
]
|
||||
|
||||
const adminPageImports = [
|
||||
() => import('./pages/Dashboard'),
|
||||
() => import('./pages/LiveBoard'),
|
||||
() => import('./pages/StreamList'),
|
||||
() => import('./pages/StreamForm'),
|
||||
() => import('./pages/ReplayEditor'),
|
||||
() => import('./pages/NodeManager'),
|
||||
() => import('./pages/Settings'),
|
||||
]
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { brand } = useBrand()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -13,6 +14,27 @@ import {
|
||||
|
||||
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>()
|
||||
@@ -20,7 +42,9 @@ export default function LiveBoard() {
|
||||
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() {
|
||||
@@ -38,7 +62,9 @@ export default function LiveBoard() {
|
||||
message.error('看板数据加载失败')
|
||||
} finally {
|
||||
try {
|
||||
setSystemMetrics(await getSystemMetrics())
|
||||
const [metrics, nodeResponse] = await Promise.all([getSystemMetrics(), api.get<NodeMetric[]>('/nodes')])
|
||||
setSystemMetrics(metrics)
|
||||
setNodes(nodeResponse.data)
|
||||
} catch {
|
||||
setSystemMetrics(null)
|
||||
}
|
||||
@@ -56,9 +82,11 @@ export default function LiveBoard() {
|
||||
if (!streamId || selected.length === 0) return
|
||||
try {
|
||||
const session = await createRemoteSession(streamId)
|
||||
const action = quality === 'auto' ? 'restore_auto' : 'switch_quality'
|
||||
await sendRemoteCommand(streamId, session.token, action, selected.map(String), quality === 'auto' ? undefined : quality)
|
||||
message.success(`已向 ${selected.length} 个观众下发播放策略`)
|
||||
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('控制命令下发失败,请确认节目已启用审计式播放控制')
|
||||
}
|
||||
@@ -95,17 +123,26 @@ export default function LiveBoard() {
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" prefix={<DashboardOutlined />} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="存储使用率" value={systemMetrics?.disk_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /></Card></Col>
|
||||
<Col xs={24} md={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" prefix={<DashboardOutlined />} /></Card></Col>
|
||||
<Col xs={24} md={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" 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="%" 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)} /></Card></Col>
|
||||
<Col xs={24} md={12}><Card><Statistic title="NET I/O 上传总量" value={formatBytes(systemMetrics?.network_sent_bytes)} /></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={quality} onChange={setQuality} style={{ width: 150 }} options={[
|
||||
<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' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert, Select, Switch } from 'antd'
|
||||
import { Form, Input, InputNumber, Button, DatePicker, Typography, message, Space, Alert, Select, Switch, Card, Row, Col } from 'antd'
|
||||
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
||||
import { createStream } from '../../api/streams'
|
||||
import axios from 'axios'
|
||||
@@ -24,6 +24,7 @@ export default function StreamForm() {
|
||||
transcode_profile: values.transcode_profile,
|
||||
homepage_visible: values.homepage_visible,
|
||||
remote_control_enabled: values.remote_control_enabled,
|
||||
renditions: values.renditions,
|
||||
})
|
||||
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
|
||||
message.success('直播已创建')
|
||||
@@ -83,7 +84,13 @@ export default function StreamForm() {
|
||||
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
|
||||
</Text>
|
||||
|
||||
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{ transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false }}>
|
||||
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{
|
||||
transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false,
|
||||
renditions: [
|
||||
{ label: '1080p', enabled: true, width: 1920, height: 1080, fps: 30, codec: 'libx264', bitrate: '5000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
|
||||
{ label: '720p', enabled: true, width: 1280, height: 720, fps: 30, codec: 'libx264', bitrate: '2500k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
|
||||
],
|
||||
}}>
|
||||
<Form.Item name="title" label="节目标题"
|
||||
rules={[{ required: true, message: '请输入节目标题' }]}>
|
||||
<Input placeholder="输入节目标题" style={{ borderRadius: 8 }} />
|
||||
@@ -97,9 +104,34 @@ export default function StreamForm() {
|
||||
<Form.Item name="scheduled_at" label="预定开播时间">
|
||||
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="transcode_profile" label="转码配置">
|
||||
<Form.Item name="transcode_profile" label="预设配置" extra="可直接使用预设,也可在下方逐档调整参数">
|
||||
<Select options={[{ value: 'standard', label: '标准多档' }, { value: 'bandwidth-saver', label: '带宽节省' }]} />
|
||||
</Form.Item>
|
||||
<Form.List name="renditions">
|
||||
{(fields, { add, remove }) => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 24 }}>
|
||||
<Text strong>自定义清晰度与码率</Text>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Card key={key} size="small" extra={<Button danger type="link" onClick={() => remove(name)}>删除</Button>}>
|
||||
<Row gutter={[12, 0]}>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'enabled']} label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'label']} label="标签" rules={[{ required: true }]}><Input /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'width']} label="宽度" rules={[{ required: true }]}><InputNumber min={160} max={7680} style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'height']} label="高度" rules={[{ required: true }]}><InputNumber min={90} max={4320} style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'fps']} label="FPS"><InputNumber min={1} max={120} style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'codec']} label="编码"><Select options={[{ value: 'libx264', label: 'H.264' }, { value: 'libx265', label: 'H.265' }]} /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'bitrate']} label="视频码率"><Input placeholder="2500k" /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'crf']} label="CRF"><InputNumber min={0} max={51} style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'preset']} label="预设"><Select options={['ultrafast','superfast','veryfast','faster','fast','medium','slow'].map(value => ({ value, label: value }))} /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'audio_bitrate']} label="音频码率"><Input placeholder="128k" /></Form.Item></Col>
|
||||
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'keyframe_seconds']} label="关键帧秒数"><InputNumber min={1} max={10} style={{ width: '100%' }} /></Form.Item></Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Button onClick={() => add({ label: '480p', enabled: true, width: 854, height: 480, fps: 30, codec: 'libx264', bitrate: '1000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 })}>添加清晰度</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name="homepage_visible" label="首页公开" valuePropName="checked">
|
||||
<Switch checkedChildren="公开" unCheckedChildren="仅私有链接" />
|
||||
</Form.Item>
|
||||
|
||||
Reference in new issue
Block a user