完善直播节点监控与边缘代理
This commit is contained in:
1 parent
058bb4b99f
commit
20cd99efa4
19 files changed
+371
-17
No files matched your search
@@ -6,17 +6,63 @@ from fastapi.responses import FileResponse
|
|||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
import httpx
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import psutil
|
||||||
from database import init_db
|
from database import init_db
|
||||||
from services.transcoder import transcode_mgr
|
from services.transcoder import transcode_mgr
|
||||||
from routes import streams_router, nodes_router, auth_router
|
from routes import streams_router, nodes_router, auth_router
|
||||||
from routes.settings_route import router as settings_router
|
from routes.settings_route import router as settings_router
|
||||||
from config import settings
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
load_nodes()
|
||||||
await init_db()
|
await init_db()
|
||||||
|
origin_monitor = asyncio.create_task(monitor_origin())
|
||||||
yield
|
yield
|
||||||
|
origin_monitor.cancel()
|
||||||
transcode_mgr.stop_all()
|
transcode_mgr.stop_all()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ pydantic>=2.7.0
|
|||||||
pydantic-settings>=2.2.0
|
pydantic-settings>=2.2.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
psutil>=5.9.0
|
||||||
+24
-2
@@ -5,14 +5,16 @@ from config import settings
|
|||||||
from routes.auth import require_auth
|
from routes.auth import require_auth
|
||||||
from services.load_balancer import lb
|
from services.load_balancer import lb
|
||||||
from services.multicast_detector import multicast_detector
|
from services.multicast_detector import multicast_detector
|
||||||
|
from services.node_store import save_nodes
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
|
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
|
||||||
|
|
||||||
|
|
||||||
class NodeUpdate(BaseModel):
|
class NodeUpdate(BaseModel):
|
||||||
weight: int
|
weight: int | None = None
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
subnet: str | None = None
|
subnet: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class StrategyUpdate(BaseModel):
|
class StrategyUpdate(BaseModel):
|
||||||
@@ -27,6 +29,12 @@ class NodeHeartbeat(BaseModel):
|
|||||||
bandwidth_mbps: float | None = None
|
bandwidth_mbps: float | None = None
|
||||||
active_connections: int | None = None
|
active_connections: int | None = None
|
||||||
hls_readers: 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):
|
class NodeRegister(BaseModel):
|
||||||
@@ -83,15 +91,22 @@ async def update_node(node_id: str, body: NodeUpdate, _: bool = Depends(require_
|
|||||||
found = False
|
found = False
|
||||||
for n in settings.edge_nodes:
|
for n in settings.edge_nodes:
|
||||||
if n["id"] == node_id:
|
if n["id"] == node_id:
|
||||||
|
if body.weight is not None:
|
||||||
n["weight"] = body.weight
|
n["weight"] = body.weight
|
||||||
if body.enabled is not None:
|
if body.enabled is not None:
|
||||||
n["enabled"] = body.enabled
|
n["enabled"] = body.enabled
|
||||||
if body.subnet is not None:
|
if body.subnet is not None:
|
||||||
n["subnet"] = body.subnet
|
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
|
found = True
|
||||||
break
|
break
|
||||||
if not found:
|
if not found:
|
||||||
raise HTTPException(status_code=404, detail="Node not found")
|
raise HTTPException(status_code=404, detail="Node not found")
|
||||||
|
save_nodes()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -100,13 +115,13 @@ async def register_node(body: NodeRegister):
|
|||||||
for n in settings.edge_nodes:
|
for n in settings.edge_nodes:
|
||||||
if n["id"] == body.node_id:
|
if n["id"] == body.node_id:
|
||||||
n.update({
|
n.update({
|
||||||
"name": body.name,
|
|
||||||
"host": body.host,
|
"host": body.host,
|
||||||
"hls_port": body.hls_port,
|
"hls_port": body.hls_port,
|
||||||
"api_port": body.api_port,
|
"api_port": body.api_port,
|
||||||
"weight": body.weight,
|
"weight": body.weight,
|
||||||
"subnet": body.subnet,
|
"subnet": body.subnet,
|
||||||
})
|
})
|
||||||
|
save_nodes()
|
||||||
return {"ok": True, "action": "updated"}
|
return {"ok": True, "action": "updated"}
|
||||||
settings.edge_nodes.append({
|
settings.edge_nodes.append({
|
||||||
"id": body.node_id,
|
"id": body.node_id,
|
||||||
@@ -119,6 +134,7 @@ async def register_node(body: NodeRegister):
|
|||||||
"subnet": body.subnet,
|
"subnet": body.subnet,
|
||||||
"is_origin": False,
|
"is_origin": False,
|
||||||
})
|
})
|
||||||
|
save_nodes()
|
||||||
return {"ok": True, "action": "registered"}
|
return {"ok": True, "action": "registered"}
|
||||||
|
|
||||||
|
|
||||||
@@ -130,6 +146,12 @@ async def node_heartbeat(body: NodeHeartbeat):
|
|||||||
"bandwidth_mbps": body.bandwidth_mbps,
|
"bandwidth_mbps": body.bandwidth_mbps,
|
||||||
"active_connections": body.active_connections,
|
"active_connections": body.active_connections,
|
||||||
"hls_readers": body.hls_readers,
|
"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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@@ -63,13 +63,22 @@ async def list_streams(db: AsyncSession = Depends(get_db)):
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
|
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)
|
svc = StreamService(db)
|
||||||
|
try:
|
||||||
stream = await svc.create_stream(
|
stream = await svc.create_stream(
|
||||||
title=body.title,
|
title=title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
cover_url=body.cover_url,
|
cover_url=body.cover_url,
|
||||||
scheduled_at=body.scheduled_at,
|
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)
|
rtmp_url = media_service.get_rtmp_url(stream.stream_key)
|
||||||
return {
|
return {
|
||||||
"id": stream.id,
|
"id": stream.id,
|
||||||
@@ -179,6 +188,7 @@ async def get_stream_by_key(stream_key: str, request: Request, db: AsyncSession
|
|||||||
"title": s.title,
|
"title": s.title,
|
||||||
"status": s.status.value,
|
"status": s.status.value,
|
||||||
"multicast_available": urls["multicast_available"],
|
"multicast_available": urls["multicast_available"],
|
||||||
|
"selected_node": urls.get("selected_node"),
|
||||||
"lines": urls["lines"],
|
"lines": urls["lines"],
|
||||||
"qualities": urls["qualities"],
|
"qualities": urls["qualities"],
|
||||||
}
|
}
|
||||||
@@ -132,6 +132,12 @@ class LoadBalancer:
|
|||||||
"bandwidth_mbps": h.get("bandwidth_mbps"),
|
"bandwidth_mbps": h.get("bandwidth_mbps"),
|
||||||
"active_connections": h.get("active_connections"),
|
"active_connections": h.get("active_connections"),
|
||||||
"hls_readers": h.get("hls_readers"),
|
"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,
|
} if h else None,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -7,6 +7,7 @@ from services.multicast_detector import multicast_detector
|
|||||||
from services.transcoder import transcode_mgr
|
from services.transcoder import transcode_mgr
|
||||||
from config import settings
|
from config import settings
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class StreamService:
|
class StreamService:
|
||||||
@@ -17,13 +18,19 @@ class StreamService:
|
|||||||
cover_url: str | None = None,
|
cover_url: str | None = None,
|
||||||
scheduled_at: str | None = None) -> Stream:
|
scheduled_at: str | None = None) -> Stream:
|
||||||
import uuid
|
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(
|
stream = Stream(
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
cover_url=cover_url,
|
cover_url=cover_url,
|
||||||
stream_key=uuid.uuid4().hex,
|
stream_key=uuid.uuid4().hex,
|
||||||
status=StreamStatus.pending,
|
status=StreamStatus.pending,
|
||||||
scheduled_at=scheduled_at,
|
scheduled_at=scheduled_datetime,
|
||||||
)
|
)
|
||||||
self.db.add(stream)
|
self.db.add(stream)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -113,4 +120,8 @@ class StreamService:
|
|||||||
"lines": lines,
|
"lines": lines,
|
||||||
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in settings.qualities],
|
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in settings.qualities],
|
||||||
"multicast_available": multicast_detector.is_available(),
|
"multicast_available": multicast_detector.is_available(),
|
||||||
|
"selected_node": {
|
||||||
|
"id": best_node["id"],
|
||||||
|
"name": best_node["name"],
|
||||||
|
} if best_node else None,
|
||||||
}
|
}
|
||||||
@@ -5,12 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
NodeName string
|
NodeName string
|
||||||
OriginURL string
|
OriginURL string
|
||||||
|
DiscoveryURL string
|
||||||
HlsPort int
|
HlsPort int
|
||||||
ApiPort int
|
ApiPort int
|
||||||
Subnet string
|
Subnet string
|
||||||
@@ -26,6 +28,8 @@ type Agent struct {
|
|||||||
monitor *Monitor
|
monitor *Monitor
|
||||||
nodeID string
|
nodeID string
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
|
connected bool
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config, mtxFS embed.FS) (*Agent, error) {
|
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)
|
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)
|
a.mtx = NewMediaMTXManager(a.cfg, a.mediamtxFS)
|
||||||
if err := a.mtx.Extract(); err != nil {
|
if err := a.mtx.Extract(); err != nil {
|
||||||
return fmt.Errorf("extract mediamtx: %w", err)
|
return fmt.Errorf("extract mediamtx: %w", err)
|
||||||
@@ -56,12 +67,10 @@ func (a *Agent) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.monitor = NewMonitor()
|
a.monitor = NewMonitor()
|
||||||
a.hb = NewHeartbeat(a.cfg, a.nodeID)
|
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
if err := a.hb.Register(); err != nil {
|
if err := a.connect(); err != nil {
|
||||||
log.Printf("Warning: registration failed: %v (will retry)", err)
|
log.Printf("Warning: connection failed: %v (will retry every 10 seconds)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go a.heartbeatLoop()
|
go a.heartbeatLoop()
|
||||||
@@ -87,8 +96,15 @@ func (a *Agent) heartbeatLoop() {
|
|||||||
case <-a.stopCh:
|
case <-a.stopCh:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
if !a.Connected() {
|
||||||
|
if err := a.connect(); err != nil {
|
||||||
|
log.Printf("Connection retry failed: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
metrics := a.monitor.Snapshot()
|
metrics := a.monitor.Snapshot()
|
||||||
if err := a.hb.Send(metrics); err != nil {
|
if err := a.hb.Send(metrics); err != nil {
|
||||||
|
a.setConnected(false)
|
||||||
log.Printf("Heartbeat error: %v", err)
|
log.Printf("Heartbeat error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,12 +120,37 @@ func (a *Agent) monitorLoop() {
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
a.monitor.Tick()
|
a.monitor.Tick()
|
||||||
|
if a.mtx != nil {
|
||||||
|
a.monitor.SetMediaMetrics(a.mtx.Metrics())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) NodeID() string { return a.nodeID }
|
func (a *Agent) NodeID() string { return a.nodeID }
|
||||||
func (a *Agent) Config() Config { return a.cfg }
|
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 {
|
func (a *Agent) Metrics() Metrics {
|
||||||
if a.monitor != nil {
|
if a.monitor != nil {
|
||||||
return a.monitor.Snapshot()
|
return a.monitor.Snapshot()
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +23,12 @@ type HeartbeatPayload struct {
|
|||||||
BandwidthMbps float64 `json:"bandwidth_mbps"`
|
BandwidthMbps float64 `json:"bandwidth_mbps"`
|
||||||
ActiveConnections int `json:"active_connections"`
|
ActiveConnections int `json:"active_connections"`
|
||||||
HlsReaders int `json:"hls_readers"`
|
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 {
|
type RegisterPayload struct {
|
||||||
@@ -54,6 +62,37 @@ func (h *Heartbeat) Register() error {
|
|||||||
return h.post("/api/nodes/register", payload)
|
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 {
|
func (h *Heartbeat) Send(m Metrics) error {
|
||||||
payload := HeartbeatPayload{
|
payload := HeartbeatPayload{
|
||||||
NodeID: h.nodeID,
|
NodeID: h.nodeID,
|
||||||
@@ -62,6 +101,12 @@ func (h *Heartbeat) Send(m Metrics) error {
|
|||||||
BandwidthMbps: m.Bandwidth,
|
BandwidthMbps: m.Bandwidth,
|
||||||
ActiveConnections: m.ActiveConns,
|
ActiveConnections: m.ActiveConns,
|
||||||
HlsReaders: m.HlsReaders,
|
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)
|
return h.post("/api/nodes/heartbeat", payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MediaMTXManager struct {
|
type MediaMTXManager struct {
|
||||||
@@ -17,6 +19,15 @@ type MediaMTXManager struct {
|
|||||||
binPath string
|
binPath string
|
||||||
configPath string
|
configPath string
|
||||||
cmd *exec.Cmd
|
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 {
|
func NewMediaMTXManager(cfg Config, fs embed.FS) *MediaMTXManager {
|
||||||
@@ -116,14 +127,57 @@ paths:
|
|||||||
|
|
||||||
func (m *MediaMTXManager) Start() error {
|
func (m *MediaMTXManager) Start() error {
|
||||||
m.cmd = exec.Command(m.binPath, m.configPath)
|
m.cmd = exec.Command(m.binPath, m.configPath)
|
||||||
m.cmd.Stdout = os.Stdout
|
stdout, err := m.cmd.StdoutPipe()
|
||||||
m.cmd.Stderr = os.Stderr
|
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 {
|
if err := m.cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("start mediamtx: %w", err)
|
return fmt.Errorf("start mediamtx: %w", err)
|
||||||
}
|
}
|
||||||
|
go m.parseLogs(stdout)
|
||||||
|
go m.parseLogs(stderr)
|
||||||
return nil
|
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() {
|
func (m *MediaMTXManager) Stop() {
|
||||||
if m.cmd != nil && m.cmd.Process != nil {
|
if m.cmd != nil && m.cmd.Process != nil {
|
||||||
m.cmd.Process.Kill()
|
m.cmd.Process.Kill()
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ type Metrics struct {
|
|||||||
CPU float64
|
CPU float64
|
||||||
Memory float64
|
Memory float64
|
||||||
Bandwidth float64
|
Bandwidth float64
|
||||||
|
NetworkIn float64
|
||||||
|
NetworkOut float64
|
||||||
ActiveConns int
|
ActiveConns int
|
||||||
HlsReaders int
|
HlsReaders int
|
||||||
|
ViewerCount int
|
||||||
|
FrameDrops int
|
||||||
|
PacketLoss int
|
||||||
|
MediaAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Monitor struct {
|
type Monitor struct {
|
||||||
@@ -54,8 +60,9 @@ func (m *Monitor) Tick() {
|
|||||||
} else {
|
} else {
|
||||||
elapsed := now.Sub(m.prevNetTime).Seconds()
|
elapsed := now.Sub(m.prevNetTime).Seconds()
|
||||||
if elapsed > 0 {
|
if elapsed > 0 {
|
||||||
bytesTotal := (curIn - m.prevNetIn) + (curOut - m.prevNetOut)
|
metrics.NetworkIn = float64(curIn-m.prevNetIn) * 8 / elapsed / 1e6
|
||||||
metrics.Bandwidth = float64(bytesTotal) * 8 / elapsed / 1e6
|
metrics.NetworkOut = float64(curOut-m.prevNetOut) * 8 / elapsed / 1e6
|
||||||
|
metrics.Bandwidth = metrics.NetworkIn + metrics.NetworkOut
|
||||||
}
|
}
|
||||||
m.prevNetIn = curIn
|
m.prevNetIn = curIn
|
||||||
m.prevNetOut = curOut
|
m.prevNetOut = curOut
|
||||||
@@ -81,3 +88,13 @@ func (m *Monitor) Snapshot() Metrics {
|
|||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
return m.current
|
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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
Executable
+31
@@ -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"
|
||||||
@@ -25,6 +25,7 @@ func main() {
|
|||||||
cfg := agent.Config{
|
cfg := agent.Config{
|
||||||
NodeName: "edge-node",
|
NodeName: "edge-node",
|
||||||
OriginURL: getEnv("STREAMCORE_ORIGIN", defaultOriginURL),
|
OriginURL: getEnv("STREAMCORE_ORIGIN", defaultOriginURL),
|
||||||
|
DiscoveryURL: getEnv("STREAMCORE_DISCOVERY_URL", ""),
|
||||||
HlsPort: 8889,
|
HlsPort: 8889,
|
||||||
ApiPort: 9998,
|
ApiPort: 9998,
|
||||||
Subnet: getEnv("STREAMCORE_SUBNET", "0.0.0.0/0"),
|
Subnet: getEnv("STREAMCORE_SUBNET", "0.0.0.0/0"),
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func onReady(ag *agent.Agent) {
|
|||||||
|
|
||||||
mStatus := systray.AddMenuItem("Node: "+ag.NodeID(), "Node")
|
mStatus := systray.AddMenuItem("Node: "+ag.NodeID(), "Node")
|
||||||
mStatus.Disable()
|
mStatus.Disable()
|
||||||
|
mConnection := systray.AddMenuItem("Connection: connecting", "Master connection")
|
||||||
|
mConnection.Disable()
|
||||||
|
|
||||||
systray.AddSeparator()
|
systray.AddSeparator()
|
||||||
|
|
||||||
@@ -46,6 +48,11 @@ func onReady(ag *agent.Agent) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
if ag.Connected() {
|
||||||
|
mConnection.SetTitle("Connection: connected")
|
||||||
|
} else {
|
||||||
|
mConnection.SetTitle("Connection: disconnected")
|
||||||
|
}
|
||||||
m := ag.Metrics()
|
m := ag.Metrics()
|
||||||
mCPU.SetTitle(fmt.Sprintf("CPU: %.1f%%", m.CPU))
|
mCPU.SetTitle(fmt.Sprintf("CPU: %.1f%%", m.CPU))
|
||||||
mMem.SetTitle(fmt.Sprintf("MEM: %.1f%%", m.Memory))
|
mMem.SetTitle(fmt.Sprintf("MEM: %.1f%%", m.Memory))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons'
|
||||||
import api from '../../api/client'
|
import api from '../../api/client'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
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 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 }
|
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
|
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('更新失败') }
|
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
|
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)'}` }} />
|
boxShadow: `0 0 6px ${v ? 'rgba(91,140,90,0.3)' : 'rgba(181,74,74,0.3)'}` }} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '名称', dataIndex: 'name', key: 'name',
|
{ title: '名称', dataIndex: 'name', key: 'name', width: 190,
|
||||||
render: (v: string, r: NodeInfo) => <span><Text strong>{v}</Text>{r.is_origin && <Tag color="warning" style={{ marginLeft: 8 }}>Origin</Tag>}</span> },
|
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: 'host', key: 'host', width: 130 },
|
||||||
{ title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => <Text code style={{ fontSize: 12 }}>{v}</Text> },
|
{ title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => <Text code style={{ fontSize: 12 }}>{v}</Text> },
|
||||||
{ title: 'CPU', key: 'cpu', width: 80,
|
{ title: 'CPU', key: 'cpu', width: 80,
|
||||||
@@ -58,6 +64,12 @@ export default function NodeManager() {
|
|||||||
: <Text type="secondary">--</Text> },
|
: <Text type="secondary">--</Text> },
|
||||||
{ title: '带宽', key: 'bw', width: 85,
|
{ 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> },
|
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,
|
{ title: '连接', key: 'conn', width: 60,
|
||||||
render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null ? r.metrics.active_connections : <Text type="secondary">--</Text> },
|
render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null ? r.metrics.active_connections : <Text type="secondary">--</Text> },
|
||||||
{ title: '权重', dataIndex: 'weight', key: 'weight', width: 120,
|
{ title: '权重', dataIndex: 'weight', key: 'weight', width: 120,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd'
|
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd'
|
||||||
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
||||||
import { createStream } from '../../api/streams'
|
import { createStream } from '../../api/streams'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
const { TextArea } = Input
|
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() })
|
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 })
|
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
|
||||||
message.success('直播已创建')
|
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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface WatchInfo {
|
|||||||
title: string
|
title: string
|
||||||
status: string
|
status: string
|
||||||
multicast_available: boolean
|
multicast_available: boolean
|
||||||
|
selected_node?: { id: string; name: string } | null
|
||||||
lines: LineInfo[]
|
lines: LineInfo[]
|
||||||
qualities: QualityInfo[]
|
qualities: QualityInfo[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function WatchPage() {
|
|||||||
const [activeLine, setActiveLine] = useState<string>('')
|
const [activeLine, setActiveLine] = useState<string>('')
|
||||||
const [activeQuality, setActiveQuality] = useState<string>('')
|
const [activeQuality, setActiveQuality] = useState<string>('')
|
||||||
const hlsRef = useRef<Hls | null>(null)
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
|
const currentLine = info?.lines.find(line => line.node_id === activeLine)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -102,6 +103,8 @@ export default function WatchPage() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
<Space size={16}>
|
<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 }}>
|
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
|
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
|
||||||
{info.multicast_available ? '组播' : '单播'}
|
{info.multicast_available ? '组播' : '单播'}
|
||||||
|
|||||||
Reference in new issue
Block a user