负载均衡重构: 子网感知选择 + 节点健康监控 + 节点注册上报API

- 新增 subnet-aware 负载策略: 根据客户端IP匹配最近/16子网节点
- 节点注册端点 (POST /api/nodes/register): 边缘节点自动注册
- 节点心跳端点 (POST /api/nodes/heartbeat): 上报CPU/RAM/带宽/连接数
- 健康汇总 (GET /api/nodes/health): 服务端汇总所有节点状态
- 配置节点新增subnet/is_origin字段
- 前端NodeManager: 实时健康状态/CPU/RAM进度条/带宽/连接数
- X-Forwarded-For/X-Real-IP透传支持客户端IP
This commit is contained in:
admin committed 2026-08-10 14:45:28 +08:00
1 parent 5f19fc6aa5
commit 980b91f41f
7 files changed
+319 -87

No files matched your search

+74 -2
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from config import settings
from routes.auth import require_auth
from services.load_balancer import lb
@@ -11,6 +12,7 @@ router = APIRouter(prefix="/api/nodes", tags=["nodes"])
class NodeUpdate(BaseModel):
weight: int
enabled: bool | None = None
subnet: str | None = None
class StrategyUpdate(BaseModel):
@@ -18,18 +20,42 @@ class StrategyUpdate(BaseModel):
multicast_enabled: bool | None = None
class NodeHeartbeat(BaseModel):
node_id: str
cpu_percent: float | None = None
memory_percent: float | None = None
bandwidth_mbps: float | None = None
active_connections: int | None = None
hls_readers: int | None = None
class NodeRegister(BaseModel):
node_id: str
name: str
host: str
hls_port: int
api_port: int
weight: int = 100
subnet: str = "0.0.0.0/0"
@router.get("")
async def list_nodes(_: bool = Depends(require_auth)):
nodes = []
for n in settings.edge_nodes:
nid = n["id"]
nodes.append({
"id": n["id"],
"id": nid,
"name": n["name"],
"host": n["host"],
"hls_port": n["hls_port"],
"api_port": n["api_port"],
"weight": n["weight"],
"enabled": n.get("enabled", True),
"subnet": n.get("subnet", "0.0.0.0/0"),
"is_origin": n.get("is_origin", False),
"online": lb.is_online(nid),
"metrics": lb._node_health.get(nid),
})
return nodes
@@ -45,7 +71,7 @@ async def get_strategy(_: bool = Depends(require_auth)):
@router.put("/strategy")
async def update_strategy(body: StrategyUpdate, _: bool = Depends(require_auth)):
if body.strategy in ("round-robin", "weighted", "least-conn"):
if body.strategy in ("round-robin", "weighted", "least-conn", "subnet"):
settings.load_balance_strategy = body.strategy
if body.multicast_enabled is not None:
settings.multicast_enabled = body.multicast_enabled
@@ -60,8 +86,54 @@ async def update_node(node_id: str, body: NodeUpdate, _: bool = Depends(require_
n["weight"] = body.weight
if body.enabled is not None:
n["enabled"] = body.enabled
if body.subnet is not None:
n["subnet"] = body.subnet
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Node not found")
return {"ok": True}
@router.post("/register")
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,
})
return {"ok": True, "action": "updated"}
settings.edge_nodes.append({
"id": body.node_id,
"name": body.name,
"host": body.host,
"hls_port": body.hls_port,
"api_port": body.api_port,
"weight": body.weight,
"enabled": True,
"subnet": body.subnet,
"is_origin": False,
})
return {"ok": True, "action": "registered"}
@router.post("/heartbeat")
async def node_heartbeat(body: NodeHeartbeat):
lb.report_health(body.node_id, {
"cpu_percent": body.cpu_percent,
"memory_percent": body.memory_percent,
"bandwidth_mbps": body.bandwidth_mbps,
"active_connections": body.active_connections,
"hls_readers": body.hls_readers,
})
return {"ok": True}
@router.get("/health")
async def get_all_health():
return lb.get_health_summary()
+20 -5
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from fastapi import APIRouter, HTTPException, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
@@ -26,6 +26,18 @@ class StreamUpdate(BaseModel):
scheduled_at: Optional[str] = None
def _get_client_ip(request: Request) -> str | None:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
client = request.headers.get("X-Real-IP")
if client:
return client
if request.client:
return request.client.host
return None
@router.get("")
async def list_streams(db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
@@ -114,17 +126,19 @@ async def delete_stream(stream_id: int, db: AsyncSession = Depends(get_db),
@router.get("/{stream_id}/watch")
async def get_watch_urls(stream_id: int, db: AsyncSession = Depends(get_db)):
async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
stream = await svc.get_stream(stream_id)
if not stream:
raise HTTPException(status_code=404, detail="Stream not found")
urls = await svc.get_watch_urls(stream.stream_key)
client_ip = _get_client_ip(request)
urls = await svc.get_watch_urls(stream.stream_key, client_ip)
return {
"stream_id": stream.id,
"title": stream.title,
"status": stream.status.value,
"multicast_available": urls["multicast_available"],
"selected_node": urls.get("selected_node"),
"lines": urls["lines"],
"qualities": urls["qualities"],
}
@@ -153,12 +167,13 @@ async def stream_end(stream_id: int, db: AsyncSession = Depends(get_db),
@router.get("/detail/{stream_key}")
async def get_stream_by_key(stream_key: str, db: AsyncSession = Depends(get_db)):
async def get_stream_by_key(stream_key: str, request: Request, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
s = await svc.get_stream_by_key(stream_key)
if not s:
raise HTTPException(status_code=404, detail="Stream not found")
urls = await svc.get_watch_urls(stream_key)
client_ip = _get_client_ip(request)
urls = await svc.get_watch_urls(stream_key, client_ip)
return {
"stream_id": s.id,
"title": s.title,