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

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

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
+25 -3
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:
n["weight"] = body.weight
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}
+17 -7
View File
@@ -62,14 +62,23 @@ 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)):
_: bool = Depends(require_auth)):
title = body.title.strip()
if not title:
raise HTTPException(status_code=422, detail="直播标题不能为空")
svc = StreamService(db)
stream = await svc.create_stream(
title=body.title,
description=body.description,
cover_url=body.cover_url,
scheduled_at=body.scheduled_at,
)
try:
stream = await svc.create_stream(
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,
}
+54 -13
View File
@@ -5,17 +5,19 @@ import (
"fmt"
"log"
"os"
"sync"
"time"
)
type Config struct {
NodeName string
OriginURL string
HlsPort int
ApiPort int
Subnet string
DataDir string
Weight int
NodeName string
OriginURL string
DiscoveryURL string
HlsPort int
ApiPort int
Subnet string
DataDir string
Weight int
}
type Agent struct {
@@ -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) 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()
+24 -7
View File
@@ -11,11 +11,17 @@ import (
)
type Metrics struct {
CPU float64
Memory float64
Bandwidth float64
ActiveConns int
HlsReaders int
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"
+9 -8
View File
@@ -15,7 +15,7 @@ import (
var mediamtxFS embed.FS
const (
defaultOriginURL = "http://127.0.0.1:8080"
defaultOriginURL = "http://127.0.0.1:8080"
heartbeatInterval = 10 * time.Second
)
@@ -23,13 +23,14 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
cfg := agent.Config{
NodeName: "edge-node",
OriginURL: getEnv("STREAMCORE_ORIGIN", defaultOriginURL),
HlsPort: 8889,
ApiPort: 9998,
Subnet: getEnv("STREAMCORE_SUBNET", "0.0.0.0/0"),
DataDir: getEnv("STREAMCORE_DATA_DIR", ""),
Weight: 100,
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"),
DataDir: getEnv("STREAMCORE_DATA_DIR", ""),
Weight: 100,
}
if cfg.DataDir == "" {
+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,
+7 -2
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,8 +19,12 @@ 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('创建失败') }
setLoading(false)
} catch (error) {
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : undefined
message.error(typeof detail === 'string' ? detail : '创建失败,请稍后重试')
} finally {
setLoading(false)
}
}
if (result) {
+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 ? '组播' : '单播'}