完善直播节点监控与边缘代理

This commit is contained in:
admin committed 2026-08-12 21:14:42 +08:00
1 parent 058bb4b99f
commit 20cd99efa4
19 files changed
+371 -17

No files matched your search

+46
View File
@@ -6,17 +6,63 @@ from fastapi.responses import FileResponse
from starlette.responses import RedirectResponse
from starlette.background import BackgroundTask
import httpx
import asyncio
import time
import psutil
from database import init_db
from services.transcoder import transcode_mgr
from routes import streams_router, nodes_router, auth_router
from routes.settings_route import router as settings_router
from config import settings
from services.load_balancer import lb
from services.node_store import load_nodes
async def monitor_origin():
previous = psutil.net_io_counters()
previous_time = time.monotonic()
while True:
await asyncio.sleep(10)
current = psutil.net_io_counters()
current_time = time.monotonic()
elapsed = current_time - previous_time
network_in = ((current.bytes_recv - previous.bytes_recv) * 8 / elapsed / 1e6) if elapsed > 0 else None
network_out = ((current.bytes_sent - previous.bytes_sent) * 8 / elapsed / 1e6) if elapsed > 0 else None
media_available = False
viewer_count = None
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list")
response.raise_for_status()
payload = response.json()
viewer_count = sum(item.get("readers", 0) for item in payload.get("items", []))
media_available = True
except Exception:
pass
lb.report_health("default", {
"cpu_percent": psutil.cpu_percent(),
"memory_percent": psutil.virtual_memory().percent,
"bandwidth_mbps": network_in + network_out if network_in is not None and network_out is not None else None,
"network_in_mbps": network_in,
"network_out_mbps": network_out,
"active_connections": len(psutil.net_connections(kind="tcp")),
"hls_readers": viewer_count,
"viewer_count": viewer_count,
"frame_drop_indicators": None,
"packet_loss_indicators": None,
"media_metrics_available": False,
})
previous = current
previous_time = current_time
@asynccontextmanager
async def lifespan(app: FastAPI):
load_nodes()
await init_db()
origin_monitor = asyncio.create_task(monitor_origin())
yield
origin_monitor.cancel()
transcode_mgr.stop_all()
+1
View File
@@ -6,3 +6,4 @@ pydantic>=2.7.0
pydantic-settings>=2.2.0
python-multipart>=0.0.9
httpx>=0.27.0
psutil>=5.9.0
+24 -2
View File
@@ -5,14 +5,16 @@ from config import settings
from routes.auth import require_auth
from services.load_balancer import lb
from services.multicast_detector import multicast_detector
from services.node_store import save_nodes
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
class NodeUpdate(BaseModel):
weight: int
weight: int | None = None
enabled: bool | None = None
subnet: str | None = None
name: str | None = None
class StrategyUpdate(BaseModel):
@@ -27,6 +29,12 @@ class NodeHeartbeat(BaseModel):
bandwidth_mbps: float | None = None
active_connections: int | None = None
hls_readers: int | None = None
network_in_mbps: float | None = None
network_out_mbps: float | None = None
viewer_count: int | None = None
frame_drop_indicators: int | None = None
packet_loss_indicators: int | None = None
media_metrics_available: bool = False
class NodeRegister(BaseModel):
@@ -83,15 +91,22 @@ async def update_node(node_id: str, body: NodeUpdate, _: bool = Depends(require_
found = False
for n in settings.edge_nodes:
if n["id"] == node_id:
if body.weight is not None:
n["weight"] = body.weight
if body.enabled is not None:
n["enabled"] = body.enabled
if body.subnet is not None:
n["subnet"] = body.subnet
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="Node name cannot be empty")
n["name"] = name
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Node not found")
save_nodes()
return {"ok": True}
@@ -100,13 +115,13 @@ async def register_node(body: NodeRegister):
for n in settings.edge_nodes:
if n["id"] == body.node_id:
n.update({
"name": body.name,
"host": body.host,
"hls_port": body.hls_port,
"api_port": body.api_port,
"weight": body.weight,
"subnet": body.subnet,
})
save_nodes()
return {"ok": True, "action": "updated"}
settings.edge_nodes.append({
"id": body.node_id,
@@ -119,6 +134,7 @@ async def register_node(body: NodeRegister):
"subnet": body.subnet,
"is_origin": False,
})
save_nodes()
return {"ok": True, "action": "registered"}
@@ -130,6 +146,12 @@ async def node_heartbeat(body: NodeHeartbeat):
"bandwidth_mbps": body.bandwidth_mbps,
"active_connections": body.active_connections,
"hls_readers": body.hls_readers,
"network_in_mbps": body.network_in_mbps,
"network_out_mbps": body.network_out_mbps,
"viewer_count": body.viewer_count,
"frame_drop_indicators": body.frame_drop_indicators,
"packet_loss_indicators": body.packet_loss_indicators,
"media_metrics_available": body.media_metrics_available,
})
return {"ok": True}
+11 -1
View File
@@ -63,13 +63,22 @@ async def list_streams(db: AsyncSession = Depends(get_db)):
@router.post("")
async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
title = body.title.strip()
if not title:
raise HTTPException(status_code=422, detail="直播标题不能为空")
svc = StreamService(db)
try:
stream = await svc.create_stream(
title=body.title,
title=title,
description=body.description,
cover_url=body.cover_url,
scheduled_at=body.scheduled_at,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail="创建直播失败,请检查日期格式和数据库状态") from exc
rtmp_url = media_service.get_rtmp_url(stream.stream_key)
return {
"id": stream.id,
@@ -179,6 +188,7 @@ async def get_stream_by_key(stream_key: str, request: Request, db: AsyncSession
"title": s.title,
"status": s.status.value,
"multicast_available": urls["multicast_available"],
"selected_node": urls.get("selected_node"),
"lines": urls["lines"],
"qualities": urls["qualities"],
}
+6
View File
@@ -132,6 +132,12 @@ class LoadBalancer:
"bandwidth_mbps": h.get("bandwidth_mbps"),
"active_connections": h.get("active_connections"),
"hls_readers": h.get("hls_readers"),
"network_in_mbps": h.get("network_in_mbps"),
"network_out_mbps": h.get("network_out_mbps"),
"viewer_count": h.get("viewer_count"),
"frame_drop_indicators": h.get("frame_drop_indicators"),
"packet_loss_indicators": h.get("packet_loss_indicators"),
"media_metrics_available": h.get("media_metrics_available", False),
} if h else None,
}
return result
+27
View File
@@ -0,0 +1,27 @@
import json
from pathlib import Path
from config import settings
STORE_PATH = Path(__file__).resolve().parent.parent / "nodes.json"
def load_nodes() -> None:
if not STORE_PATH.exists():
return
try:
nodes = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
if isinstance(nodes, list):
settings.edge_nodes = nodes
def save_nodes() -> None:
temporary = STORE_PATH.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(settings.edge_nodes, ensure_ascii=False, indent=2),
encoding="utf-8",
)
temporary.replace(STORE_PATH)
+12 -1
View File
@@ -7,6 +7,7 @@ from services.multicast_detector import multicast_detector
from services.transcoder import transcode_mgr
from config import settings
import json
from datetime import datetime
class StreamService:
@@ -17,13 +18,19 @@ class StreamService:
cover_url: str | None = None,
scheduled_at: str | None = None) -> Stream:
import uuid
scheduled_datetime = None
if scheduled_at:
try:
scheduled_datetime = datetime.fromisoformat(scheduled_at.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("预定开播时间格式无效") from exc
stream = Stream(
title=title,
description=description,
cover_url=cover_url,
stream_key=uuid.uuid4().hex,
status=StreamStatus.pending,
scheduled_at=scheduled_at,
scheduled_at=scheduled_datetime,
)
self.db.add(stream)
await self.db.commit()
@@ -113,4 +120,8 @@ class StreamService:
"lines": lines,
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in settings.qualities],
"multicast_available": multicast_detector.is_available(),
"selected_node": {
"id": best_node["id"],
"name": best_node["name"],
} if best_node else None,
}
+45 -4
View File
@@ -5,12 +5,14 @@ import (
"fmt"
"log"
"os"
"sync"
"time"
)
type Config struct {
NodeName string
OriginURL string
DiscoveryURL string
HlsPort int
ApiPort int
Subnet string
@@ -26,6 +28,8 @@ type Agent struct {
monitor *Monitor
nodeID string
stopCh chan struct{}
connected bool
mu sync.RWMutex
}
func New(cfg Config, mtxFS embed.FS) (*Agent, error) {
@@ -42,6 +46,13 @@ func (a *Agent) Start() error {
return fmt.Errorf("create data dir: %w", err)
}
a.hb = NewHeartbeat(a.cfg, a.nodeID)
if err := a.hb.Discover(); err != nil {
log.Printf("Warning: initial discovery failed: %v (will retry every 10 seconds)", err)
} else {
a.cfg.OriginURL = a.hb.OriginURL()
}
a.mtx = NewMediaMTXManager(a.cfg, a.mediamtxFS)
if err := a.mtx.Extract(); err != nil {
return fmt.Errorf("extract mediamtx: %w", err)
@@ -56,12 +67,10 @@ func (a *Agent) Start() error {
}
a.monitor = NewMonitor()
a.hb = NewHeartbeat(a.cfg, a.nodeID)
time.Sleep(2 * time.Second)
if err := a.hb.Register(); err != nil {
log.Printf("Warning: registration failed: %v (will retry)", err)
if err := a.connect(); err != nil {
log.Printf("Warning: connection failed: %v (will retry every 10 seconds)", err)
}
go a.heartbeatLoop()
@@ -87,8 +96,15 @@ func (a *Agent) heartbeatLoop() {
case <-a.stopCh:
return
case <-ticker.C:
if !a.Connected() {
if err := a.connect(); err != nil {
log.Printf("Connection retry failed: %v", err)
continue
}
}
metrics := a.monitor.Snapshot()
if err := a.hb.Send(metrics); err != nil {
a.setConnected(false)
log.Printf("Heartbeat error: %v", err)
}
}
@@ -104,12 +120,37 @@ func (a *Agent) monitorLoop() {
return
case <-ticker.C:
a.monitor.Tick()
if a.mtx != nil {
a.monitor.SetMediaMetrics(a.mtx.Metrics())
}
}
}
}
func (a *Agent) NodeID() string { return a.nodeID }
func (a *Agent) Config() Config { return a.cfg }
func (a *Agent) Connected() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.connected
}
func (a *Agent) setConnected(connected bool) {
a.mu.Lock()
a.connected = connected
a.mu.Unlock()
}
func (a *Agent) connect() error {
if err := a.hb.Discover(); err != nil {
return err
}
if err := a.hb.Register(); err != nil {
return err
}
a.setConnected(true)
return nil
}
func (a *Agent) Metrics() Metrics {
if a.monitor != nil {
return a.monitor.Snapshot()
+45
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
@@ -21,6 +23,12 @@ type HeartbeatPayload struct {
BandwidthMbps float64 `json:"bandwidth_mbps"`
ActiveConnections int `json:"active_connections"`
HlsReaders int `json:"hls_readers"`
NetworkInMbps float64 `json:"network_in_mbps"`
NetworkOutMbps float64 `json:"network_out_mbps"`
ViewerCount int `json:"viewer_count"`
FrameDrops int `json:"frame_drop_indicators"`
PacketLoss int `json:"packet_loss_indicators"`
MediaAvailable bool `json:"media_metrics_available"`
}
type RegisterPayload struct {
@@ -54,6 +62,37 @@ func (h *Heartbeat) Register() error {
return h.post("/api/nodes/register", payload)
}
func (h *Heartbeat) Discover() error {
if h.cfg.DiscoveryURL == "" {
return nil
}
resp, err := h.client.Get(h.cfg.DiscoveryURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("discovery status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return err
}
host := strings.TrimSpace(string(body))
if host == "" {
return fmt.Errorf("discovery returned an empty address")
}
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "http://" + host
}
h.cfg.OriginURL = strings.TrimRight(host, "/")
return nil
}
func (h *Heartbeat) OriginURL() string {
return h.cfg.OriginURL
}
func (h *Heartbeat) Send(m Metrics) error {
payload := HeartbeatPayload{
NodeID: h.nodeID,
@@ -62,6 +101,12 @@ func (h *Heartbeat) Send(m Metrics) error {
BandwidthMbps: m.Bandwidth,
ActiveConnections: m.ActiveConns,
HlsReaders: m.HlsReaders,
NetworkInMbps: m.NetworkIn,
NetworkOutMbps: m.NetworkOut,
ViewerCount: m.ViewerCount,
FrameDrops: m.FrameDrops,
PacketLoss: m.PacketLoss,
MediaAvailable: m.MediaAvailable,
}
return h.post("/api/nodes/heartbeat", payload)
}
+56 -2
View File
@@ -1,6 +1,7 @@
package agent
import (
"bufio"
"embed"
"fmt"
"io"
@@ -9,6 +10,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
)
type MediaMTXManager struct {
@@ -17,6 +19,15 @@ type MediaMTXManager struct {
binPath string
configPath string
cmd *exec.Cmd
mu sync.RWMutex
metrics MediaMetrics
}
type MediaMetrics struct {
ViewerCount int
FrameDrops int
PacketLoss int
Available bool
}
func NewMediaMTXManager(cfg Config, fs embed.FS) *MediaMTXManager {
@@ -116,14 +127,57 @@ paths:
func (m *MediaMTXManager) Start() error {
m.cmd = exec.Command(m.binPath, m.configPath)
m.cmd.Stdout = os.Stdout
m.cmd.Stderr = os.Stderr
stdout, err := m.cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("capture mediamtx stdout: %w", err)
}
stderr, err := m.cmd.StderrPipe()
if err != nil {
return fmt.Errorf("capture mediamtx stderr: %w", err)
}
if err := m.cmd.Start(); err != nil {
return fmt.Errorf("start mediamtx: %w", err)
}
go m.parseLogs(stdout)
go m.parseLogs(stderr)
return nil
}
func (m *MediaMTXManager) parseLogs(reader io.Reader) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
fmt.Fprintln(os.Stdout, line)
lower := strings.ToLower(line)
m.mu.Lock()
if strings.Contains(lower, "reader opened") || strings.Contains(lower, "hls client connected") {
m.metrics.Available = true
m.metrics.ViewerCount++
}
if strings.Contains(lower, "reader closed") || strings.Contains(lower, "hls client disconnected") {
m.metrics.Available = true
if m.metrics.ViewerCount > 0 {
m.metrics.ViewerCount--
}
}
if strings.Contains(lower, "frame drop") || strings.Contains(lower, "dropped frame") {
m.metrics.Available = true
m.metrics.FrameDrops++
}
if strings.Contains(lower, "packet loss") || strings.Contains(lower, "lost packet") {
m.metrics.Available = true
m.metrics.PacketLoss++
}
m.mu.Unlock()
}
}
func (m *MediaMTXManager) Metrics() MediaMetrics {
m.mu.RLock()
defer m.mu.RUnlock()
return m.metrics
}
func (m *MediaMTXManager) Stop() {
if m.cmd != nil && m.cmd.Process != nil {
m.cmd.Process.Kill()
+19 -2
View File
@@ -14,8 +14,14 @@ type Metrics struct {
CPU float64
Memory float64
Bandwidth float64
NetworkIn float64
NetworkOut float64
ActiveConns int
HlsReaders int
ViewerCount int
FrameDrops int
PacketLoss int
MediaAvailable bool
}
type Monitor struct {
@@ -54,8 +60,9 @@ func (m *Monitor) Tick() {
} else {
elapsed := now.Sub(m.prevNetTime).Seconds()
if elapsed > 0 {
bytesTotal := (curIn - m.prevNetIn) + (curOut - m.prevNetOut)
metrics.Bandwidth = float64(bytesTotal) * 8 / elapsed / 1e6
metrics.NetworkIn = float64(curIn-m.prevNetIn) * 8 / elapsed / 1e6
metrics.NetworkOut = float64(curOut-m.prevNetOut) * 8 / elapsed / 1e6
metrics.Bandwidth = metrics.NetworkIn + metrics.NetworkOut
}
m.prevNetIn = curIn
m.prevNetOut = curOut
@@ -81,3 +88,13 @@ func (m *Monitor) Snapshot() Metrics {
defer m.mu.RUnlock()
return m.current
}
func (m *Monitor) SetMediaMetrics(media MediaMetrics) {
m.mu.Lock()
m.current.ViewerCount = media.ViewerCount
m.current.HlsReaders = media.ViewerCount
m.current.FrameDrops = media.FrameDrops
m.current.PacketLoss = media.PacketLoss
m.current.MediaAvailable = media.Available
m.mu.Unlock()
}
+14
View File
@@ -0,0 +1,14 @@
param(
[string]$Executable = "$PSScriptRoot\dist\streamcore-edge-windows-amd64.exe"
)
$resolved = (Resolve-Path $Executable).Path
$taskName = "StreamCore Edge Agent"
$action = New-ScheduledTaskAction -Execute $resolved
$trigger = New-ScheduledTaskTrigger -AtStartup
$settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Seconds 10)
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
Write-Host "Installed and started scheduled task: $taskName"
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
set -eu
BIN="$(readlink -f "${1:-./dist/streamcore-edge-linux-amd64}")"
SERVICE="/etc/systemd/system/streamcore-edge.service"
if [ ! -x "$BIN" ]; then
echo "Edge agent executable not found: $BIN" >&2
exit 1
fi
cat > "$SERVICE" <<EOF
[Unit]
Description=StreamCore Edge Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$BIN
Restart=always
RestartSec=10
EnvironmentFile=-/etc/default/streamcore-edge
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now streamcore-edge.service
echo "Installed and started streamcore-edge.service"
+1
View File
@@ -25,6 +25,7 @@ func main() {
cfg := agent.Config{
NodeName: "edge-node",
OriginURL: getEnv("STREAMCORE_ORIGIN", defaultOriginURL),
DiscoveryURL: getEnv("STREAMCORE_DISCOVERY_URL", ""),
HlsPort: 8889,
ApiPort: 9998,
Subnet: getEnv("STREAMCORE_SUBNET", "0.0.0.0/0"),
+7
View File
@@ -26,6 +26,8 @@ func onReady(ag *agent.Agent) {
mStatus := systray.AddMenuItem("Node: "+ag.NodeID(), "Node")
mStatus.Disable()
mConnection := systray.AddMenuItem("Connection: connecting", "Master connection")
mConnection.Disable()
systray.AddSeparator()
@@ -46,6 +48,11 @@ func onReady(ag *agent.Agent) {
for {
select {
case <-ticker.C:
if ag.Connected() {
mConnection.SetTitle("Connection: connected")
} else {
mConnection.SetTitle("Connection: disconnected")
}
m := ag.Metrics()
mCPU.SetTitle(fmt.Sprintf("CPU: %.1f%%", m.CPU))
mMem.SetTitle(fmt.Sprintf("MEM: %.1f%%", m.Memory))
+16 -4
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react'
import { Table, InputNumber, Switch, Typography, message, Tag, Statistic, Row, Col, Button, Progress } from 'antd'
import { Table, Input, InputNumber, Switch, Typography, message, Tag, Statistic, Row, Col, Button, Progress } from 'antd'
import { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons'
import api from '../../api/client'
const { Title, Text } = Typography
interface NodeMetrics { cpu_percent: number | null; memory_percent: number | null; bandwidth_mbps: number | null; active_connections: number | null; hls_readers: number | null }
interface NodeMetrics { cpu_percent: number | null; memory_percent: number | null; bandwidth_mbps: number | null; active_connections: number | null; hls_readers: number | null; network_in_mbps: number | null; network_out_mbps: number | null; viewer_count: number | null; frame_drop_indicators: number | null; packet_loss_indicators: number | null; media_metrics_available: boolean }
interface NodeInfo { id: string; name: string; host: string; hls_port: number; api_port: number; weight: number; enabled: boolean; subnet: string; is_origin: boolean; online: boolean; metrics: NodeMetrics | null }
interface StrategyInfo { strategy: string; multicast_enabled: boolean; multicast_available: boolean }
@@ -31,6 +31,12 @@ export default function NodeManager() {
const node = nodes.find(n => n.id === nid); if (!node) return
try { await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en }); setNodes(p => p.map(n => n.id === nid ? { ...n, enabled: en } : n)) } catch { message.error('更新失败') }
}
async function updateName(nid: string, name: string) {
const trimmed = name.trim()
if (!trimmed) { message.error('节点名称不能为空'); loadData(); return }
try { await api.put(`/nodes/${nid}`, { name: trimmed }); setNodes(p => p.map(n => n.id === nid ? { ...n, name: trimmed } : n)); message.success('名称已更新') }
catch { message.error('名称更新失败'); loadData() }
}
const onlineCount = nodes.filter(n => n.online).length
@@ -42,8 +48,8 @@ export default function NodeManager() {
boxShadow: `0 0 6px ${v ? 'rgba(91,140,90,0.3)' : 'rgba(181,74,74,0.3)'}` }} />
),
},
{ title: '名称', dataIndex: 'name', key: 'name',
render: (v: string, r: NodeInfo) => <span><Text strong>{v}</Text>{r.is_origin && <Tag color="warning" style={{ marginLeft: 8 }}>Origin</Tag>}</span> },
{ title: '名称', dataIndex: 'name', key: 'name', width: 190,
render: (v: string, r: NodeInfo) => <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Input defaultValue={v} size="small" onPressEnter={e => updateName(r.id, e.currentTarget.value)} onBlur={e => { if (e.currentTarget.value !== v) updateName(r.id, e.currentTarget.value) }} />{r.is_origin && <Tag color="warning" style={{ margin: 0 }}>主节点</Tag>}</span> },
{ title: '地址', dataIndex: 'host', key: 'host', width: 130 },
{ title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => <Text code style={{ fontSize: 12 }}>{v}</Text> },
{ title: 'CPU', key: 'cpu', width: 80,
@@ -58,6 +64,12 @@ export default function NodeManager() {
: <Text type="secondary">--</Text> },
{ title: '带宽', key: 'bw', width: 85,
render: (_: any, r: NodeInfo) => r.metrics?.bandwidth_mbps != null ? <Text style={{ fontSize: 12 }}>{r.metrics.bandwidth_mbps.toFixed(1)} Mbps</Text> : <Text type="secondary">--</Text> },
{ title: '网络入/出', key: 'network', width: 125,
render: (_: any, r: NodeInfo) => r.metrics?.network_in_mbps != null && r.metrics?.network_out_mbps != null ? <Text style={{ fontSize: 12 }}>{r.metrics.network_in_mbps.toFixed(1)} / {r.metrics.network_out_mbps.toFixed(1)} Mbps</Text> : <Text type="secondary">不可用</Text> },
{ title: '观众', key: 'viewers', width: 65,
render: (_: any, r: NodeInfo) => r.metrics?.viewer_count != null ? r.metrics.viewer_count : <Text type="secondary">不可用</Text> },
{ title: '媒体异常', key: 'media', width: 120,
render: (_: any, r: NodeInfo) => r.metrics?.media_metrics_available ? <Text style={{ fontSize: 12 }}>丢帧 {r.metrics.frame_drop_indicators ?? 0} / 丢包 {r.metrics.packet_loss_indicators ?? 0}</Text> : <Text type="secondary">不可用</Text> },
{ title: '连接', key: 'conn', width: 60,
render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null ? r.metrics.active_connections : <Text type="secondary">--</Text> },
{ title: '权重', dataIndex: 'weight', key: 'weight', width: 120,
+6 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd'
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { createStream } from '../../api/streams'
import axios from 'axios'
const { Title, Text } = Typography
const { TextArea } = Input
@@ -18,9 +19,13 @@ export default function StreamForm() {
const res = await createStream({ title: values.title, description: values.description, cover_url: values.cover_url, scheduled_at: values.scheduled_at?.toISOString() })
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
message.success('直播已创建')
} catch { message.error('创建失败') }
} catch (error) {
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : undefined
message.error(typeof detail === 'string' ? detail : '创建失败,请稍后重试')
} finally {
setLoading(false)
}
}
if (result) {
return (
+1
View File
@@ -26,6 +26,7 @@ export interface WatchInfo {
title: string
status: string
multicast_available: boolean
selected_node?: { id: string; name: string } | null
lines: LineInfo[]
qualities: QualityInfo[]
}
+3
View File
@@ -19,6 +19,7 @@ export default function WatchPage() {
const [activeLine, setActiveLine] = useState<string>('')
const [activeQuality, setActiveQuality] = useState<string>('')
const hlsRef = useRef<Hls | null>(null)
const currentLine = info?.lines.find(line => line.node_id === activeLine)
useEffect(() => {
if (!id) return
@@ -102,6 +103,8 @@ export default function WatchPage() {
</Tag>
</Space>
<Space size={16}>
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
<Tag style={{ margin: 0 }}>清晰度:{activeQuality || '不可用'}</Tag>
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
{info.multicast_available ? '组播' : '单播'}