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

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