websockets and network interface datat added
This commit is contained in:
@@ -1,16 +1,21 @@
|
||||
"""Network inspection and bridge management endpoints."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
from pyroute2 import IPRoute, NDB
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
import src.shared_objects as shared
|
||||
from src.config import settings
|
||||
from src.utilities.bridge_link_state_manager import bridge_link_state_manager
|
||||
from src.utilities.interface_bridge_helpers import get_bridge_ports_once
|
||||
from src.utilities.interface_bridge_helpers import get_bridge_ports_once, read_interface_ethernet_profile
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("network_router")
|
||||
|
||||
ip: IPRoute | None = None
|
||||
ndb: NDB | None = None
|
||||
@@ -33,6 +38,10 @@ class InterfaceInfo(BaseModel):
|
||||
mac: Optional[str] = Field(None, description="MAC address.")
|
||||
mtu: int = Field(..., description="Maximum transmission unit.")
|
||||
flags: List[str] = Field(..., description="Decoded interface flags.")
|
||||
ethernet_profile: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Current speed/duplex/autoneg profile when available.",
|
||||
)
|
||||
addresses: List[InterfaceAddress] = Field(..., description="Assigned IP addresses.")
|
||||
|
||||
|
||||
@@ -57,6 +66,10 @@ class BridgeInterfaceInfo(BaseModel):
|
||||
ifname: str = Field(..., description="Interface name.")
|
||||
state: Optional[str] = Field(None, description="Operational state.")
|
||||
mtu: Optional[int] = Field(None, description="Interface MTU.")
|
||||
ethernet_profile: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Current speed/duplex/autoneg profile when available.",
|
||||
)
|
||||
|
||||
|
||||
class BridgeInfo(BaseModel):
|
||||
@@ -124,10 +137,23 @@ class BridgeLinkStateWatcherStatus(BaseModel):
|
||||
None,
|
||||
description="Low-rate fallback recheck interval while the bridge is degraded.",
|
||||
)
|
||||
failure_holdoff_seconds: Optional[float] = Field(
|
||||
None,
|
||||
description="Transient failure debounce before suppressing siblings.",
|
||||
)
|
||||
members: Dict[str, BridgeMemberLinkStateInfo] = Field(default_factory=dict, description="Per-member link-state snapshot.")
|
||||
message: Optional[str] = Field(None, description="Optional informational message.")
|
||||
|
||||
|
||||
class FullStateResponse(BaseModel):
|
||||
"""Interfaces, routes, bridges, and active watcher state."""
|
||||
|
||||
interfaces: List[InterfaceInfo] = Field(default_factory=list)
|
||||
routes: List[RouteInfo] = Field(default_factory=list)
|
||||
bridges: List[BridgeInfo] = Field(default_factory=list)
|
||||
watchers: List[BridgeLinkStateWatcherStatus] = Field(default_factory=list)
|
||||
|
||||
|
||||
def init_network_api() -> None:
|
||||
"""Initialize lazy pyroute2 clients."""
|
||||
global ip, ndb
|
||||
@@ -222,6 +248,40 @@ def _watcher_status_response(payload: dict[str, Any]) -> BridgeLinkStateWatcherS
|
||||
return BridgeLinkStateWatcherStatus.model_validate(payload)
|
||||
|
||||
|
||||
def _build_full_state_response(ip_route: Optional[IPRoute] = None) -> FullStateResponse:
|
||||
"""Build the current full network snapshot used by HTTP and websocket consumers."""
|
||||
ip_instance = ip_route or get_iproute()
|
||||
return FullStateResponse(
|
||||
interfaces=get_interfaces(ip_instance),
|
||||
routes=get_routes(ip_instance),
|
||||
bridges=get_bridges(),
|
||||
watchers=list_bridge_link_state_watchers(),
|
||||
)
|
||||
|
||||
|
||||
def build_full_state_payload(ip_route: Optional[IPRoute] = None) -> dict[str, Any]:
|
||||
"""Return the current network snapshot as a JSON-safe dictionary."""
|
||||
return _build_full_state_response(ip_route).model_dump(mode="json")
|
||||
|
||||
|
||||
def publish_network_state_update(reason: str) -> None:
|
||||
"""Publish the latest network snapshot to websocket subscribers."""
|
||||
broadcaster = getattr(shared, "network_broadcaster", None)
|
||||
if broadcaster is None:
|
||||
return
|
||||
|
||||
try:
|
||||
broadcaster.sync_publish(
|
||||
{
|
||||
"type": "network_state",
|
||||
"reason": reason,
|
||||
"snapshot": build_full_state_payload(),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to publish network state update")
|
||||
|
||||
|
||||
@router.get("/interfaces", response_model=List[InterfaceInfo])
|
||||
def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
|
||||
"""List host interfaces with addresses and decoded flags."""
|
||||
@@ -247,6 +307,7 @@ def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
|
||||
mac=attrs.get("IFLA_ADDRESS"),
|
||||
mtu=attrs.get("IFLA_MTU"),
|
||||
flags=parse_flags(link.get("flags", 0)),
|
||||
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME")),
|
||||
addresses=parse_addresses(addrs),
|
||||
)
|
||||
)
|
||||
@@ -310,6 +371,7 @@ def get_raw_links(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
|
||||
mac=attrs.get("IFLA_ADDRESS"),
|
||||
mtu=attrs.get("IFLA_MTU", 0),
|
||||
flags=[],
|
||||
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME", "unknown")),
|
||||
addresses=parse_addresses(addrs),
|
||||
)
|
||||
)
|
||||
@@ -336,6 +398,7 @@ def get_bridges() -> List[BridgeInfo]:
|
||||
ifname=iface.ifname,
|
||||
state=getattr(iface, "operstate", None),
|
||||
mtu=getattr(iface, "mtu", None),
|
||||
ethernet_profile=read_interface_ethernet_profile(iface.ifname),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -353,14 +416,10 @@ def get_bridges() -> List[BridgeInfo]:
|
||||
return bridges_list
|
||||
|
||||
|
||||
@router.get("/full-state")
|
||||
def full_state(ip: IPRoute = Depends(get_iproute)) -> dict:
|
||||
@router.get("/full-state", response_model=FullStateResponse)
|
||||
def full_state(ip: IPRoute = Depends(get_iproute)) -> FullStateResponse:
|
||||
"""Return interfaces, routes, and bridges in one response."""
|
||||
return {
|
||||
"interfaces": get_interfaces(ip),
|
||||
"routes": get_routes(ip),
|
||||
"bridges": get_bridges(),
|
||||
}
|
||||
return _build_full_state_response(ip)
|
||||
|
||||
|
||||
@router.post("/bridge/create")
|
||||
@@ -380,6 +439,8 @@ def create_bridge(req: BridgeCreateRequest, ip: IPRoute = Depends(get_iproute))
|
||||
ip.link("set", index=idx, state="up")
|
||||
ip.link("set", index=idx, master=br_idx)
|
||||
|
||||
publish_network_state_update("bridge_created")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"bridge": req.name,
|
||||
@@ -397,6 +458,8 @@ def remove_bridge(req: BridgeRemoveRequest, ip: IPRoute = Depends(get_iproute))
|
||||
ip.link("set", index=br_idx, state="down")
|
||||
ip.link("del", index=br_idx)
|
||||
|
||||
publish_network_state_update("bridge_removed")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"deleted": req.name,
|
||||
@@ -449,6 +512,7 @@ def enable_bridge_link_state_watcher(
|
||||
bridge_name=bridge_name,
|
||||
recovery_holdoff_seconds=req.recovery_holdoff_seconds,
|
||||
)
|
||||
publish_network_state_update("bridge_watcher_enabled")
|
||||
return _watcher_status_response(status)
|
||||
|
||||
|
||||
@@ -461,4 +525,70 @@ def disable_bridge_link_state_watcher(
|
||||
if not bridge_exists(bridge_name, ip):
|
||||
raise HTTPException(status_code=404, detail=f"Bridge {bridge_name} not found")
|
||||
|
||||
return _watcher_status_response(bridge_link_state_manager.disable(bridge_name))
|
||||
status = _watcher_status_response(bridge_link_state_manager.disable(bridge_name))
|
||||
publish_network_state_update("bridge_watcher_disabled")
|
||||
return status
|
||||
|
||||
|
||||
@router.websocket("/ws/state")
|
||||
async def websocket_network_state(ws: WebSocket) -> None:
|
||||
"""Stream full network snapshots to websocket clients whenever the backend publishes updates."""
|
||||
await ws.accept()
|
||||
broadcaster = getattr(shared, "network_broadcaster", None)
|
||||
if broadcaster is None:
|
||||
await ws.send_json({"error": "network broadcaster not available"})
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
queue: Optional[asyncio.Queue] = None
|
||||
try:
|
||||
await ws.send_json({"type": "network_state", "reason": "initial", "snapshot": build_full_state_payload()})
|
||||
queue = await broadcaster.subscribe()
|
||||
|
||||
while True:
|
||||
queue_task = asyncio.create_task(queue.get())
|
||||
receive_task = asyncio.create_task(ws.receive())
|
||||
done, pending = await asyncio.wait({queue_task, receive_task}, return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
if receive_task in done:
|
||||
try:
|
||||
inbound = receive_task.result()
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
|
||||
if inbound.get("type") == "websocket.disconnect":
|
||||
break
|
||||
|
||||
if queue_task not in done:
|
||||
if ws.client_state is not WebSocketState.CONNECTED:
|
||||
break
|
||||
continue
|
||||
|
||||
message = queue_task.result()
|
||||
if isinstance(message, dict) and message.get("type") == "__broadcaster_shutdown__":
|
||||
break
|
||||
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
if queue is not None:
|
||||
try:
|
||||
await broadcaster.unsubscribe(queue)
|
||||
except Exception:
|
||||
logger.exception("Failed to unsubscribe network websocket queue")
|
||||
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user