负载均衡重构: 子网感知选择 + 节点健康监控 + 节点注册上报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:
1 parent
5f19fc6aa5
commit
980b91f41f
7 files changed
+319
-87
No files matched your search
+74
-2
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user