完善直播节点监控与边缘代理
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.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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 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,
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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{
|
||||
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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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 ? '组播' : '单播'}
|
||||
|
||||
Reference in new issue
Block a user