From 4c491cf4b1663cf89952d1505eef1fbb6022b176 Mon Sep 17 00:00:00 2001 From: malmert Date: Mon, 9 Mar 2026 15:29:53 +0100 Subject: [PATCH] test nic state link api --- backend/src/api/network_api.py | 108 +++++- backend/src/config.py | 10 + .../utilities/bridge_link_state_manager.py | 289 ++++++++++++++++ .../src/utilities/interface_bridge_helpers.py | 45 ++- frontend/src/api/apiClient.ts | 26 ++ frontend/src/pages/Network.tsx | 317 ++++++++++++++++-- frontend/src/types/network.ts | 26 ++ 7 files changed, 799 insertions(+), 22 deletions(-) create mode 100644 backend/src/utilities/bridge_link_state_manager.py diff --git a/backend/src/api/network_api.py b/backend/src/api/network_api.py index 120831e..cc15138 100644 --- a/backend/src/api/network_api.py +++ b/backend/src/api/network_api.py @@ -1,11 +1,15 @@ """Network inspection and bridge management endpoints.""" -from typing import List, Optional +from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from pyroute2 import IPRoute, NDB +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 + router = APIRouter() ip: IPRoute | None = None @@ -79,6 +83,41 @@ class BridgeRemoveRequest(BaseModel): name: str +class BridgeLinkStateEnableRequest(BaseModel): + """Payload for enabling bridge member link-state propagation.""" + + poll_interval_seconds: float = Field( + settings.bridge_link_state_poll_interval_seconds, + gt=0, + description="Polling interval for member link-state checks.", + ) + + +class BridgeMemberLinkStateInfo(BaseModel): + """Current link-state snapshot for one bridge member.""" + + ifname: str = Field(..., description="Interface name.") + admin_up: Optional[bool] = Field(None, description="Whether the interface has IFF_UP set.") + carrier_up: Optional[bool] = Field(None, description="Whether the interface currently reports carrier.") + operstate: Optional[str] = Field(None, description="Kernel operational state string.") + link_ready: bool = Field(..., description="Whether the member currently looks usable for forwarding.") + suppressed: bool = Field(..., description="Whether the watcher administratively suppressed this member.") + + +class BridgeLinkStateWatcherStatus(BaseModel): + """Status for one bridge link-state propagation watcher.""" + + bridge: str = Field(..., description="Bridge interface name.") + active: bool = Field(..., description="Whether the watcher thread is currently active.") + poll_interval_seconds: Optional[float] = Field(None, description="Watcher polling interval.") + last_poll_ts: Optional[float] = Field(None, description="Unix timestamp of the last poll.") + last_error: Optional[str] = Field(None, description="Most recent watcher error, if any.") + last_action: Optional[str] = Field(None, description="Most recent propagation action.") + suppressed_members: List[str] = Field(default_factory=list, description="Members currently forced down by the watcher.") + members: Dict[str, BridgeMemberLinkStateInfo] = Field(default_factory=dict, description="Per-member link-state snapshot.") + message: Optional[str] = Field(None, description="Optional informational message.") + + def init_network_api() -> None: """Initialize lazy pyroute2 clients.""" global ip, ndb @@ -91,6 +130,7 @@ def init_network_api() -> None: def shutdown_network_api() -> None: """Close pyroute2 clients if they were initialized.""" global ip, ndb + bridge_link_state_manager.stop() if ip: ip.close() ip = None @@ -167,6 +207,11 @@ def bridge_exists(name: str, ip_route: IPRoute) -> bool: return bool(ip_route.link_lookup(ifname=name)) +def _watcher_status_response(payload: dict[str, Any]) -> BridgeLinkStateWatcherStatus: + """Convert an internal watcher status dictionary to the API response model.""" + return BridgeLinkStateWatcherStatus.model_validate(payload) + + @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.""" @@ -346,3 +391,64 @@ def remove_bridge(req: BridgeRemoveRequest, ip: IPRoute = Depends(get_iproute)) "status": "ok", "deleted": req.name, } + + +@router.get("/bridge/link-state-watchers", response_model=List[BridgeLinkStateWatcherStatus]) +def list_bridge_link_state_watchers() -> List[BridgeLinkStateWatcherStatus]: + """List all bridge member link-state propagation watchers.""" + return [_watcher_status_response(status) for status in bridge_link_state_manager.list_statuses()] + + +@router.get("/bridge/{bridge_name}/link-state-watcher", response_model=BridgeLinkStateWatcherStatus) +def get_bridge_link_state_watcher( + bridge_name: str, + ip: IPRoute = Depends(get_iproute), +) -> BridgeLinkStateWatcherStatus: + """Return the watcher status for one bridge.""" + if not bridge_exists(bridge_name, ip): + raise HTTPException(status_code=404, detail=f"Bridge {bridge_name} not found") + + status = bridge_link_state_manager.get_status(bridge_name) + if status is None: + return BridgeLinkStateWatcherStatus( + bridge=bridge_name, + active=False, + message="watcher not enabled", + ) + return _watcher_status_response(status) + + +@router.post("/bridge/{bridge_name}/link-state-watcher/enable", response_model=BridgeLinkStateWatcherStatus) +def enable_bridge_link_state_watcher( + bridge_name: str, + req: BridgeLinkStateEnableRequest, + ip: IPRoute = Depends(get_iproute), +) -> BridgeLinkStateWatcherStatus: + """Enable member link-state propagation for a bridge.""" + if not bridge_exists(bridge_name, ip): + raise HTTPException(status_code=404, detail=f"Bridge {bridge_name} not found") + + members = get_bridge_ports_once(bridge_name) + if len(members) < 2: + raise HTTPException( + status_code=400, + detail=f"Bridge {bridge_name} must have at least two member interfaces", + ) + + status = bridge_link_state_manager.enable( + bridge_name=bridge_name, + poll_interval_seconds=req.poll_interval_seconds, + ) + return _watcher_status_response(status) + + +@router.post("/bridge/{bridge_name}/link-state-watcher/disable", response_model=BridgeLinkStateWatcherStatus) +def disable_bridge_link_state_watcher( + bridge_name: str, + ip: IPRoute = Depends(get_iproute), +) -> BridgeLinkStateWatcherStatus: + """Disable member link-state propagation for a bridge.""" + 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)) diff --git a/backend/src/config.py b/backend/src/config.py index 724fd34..cbe10ed 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -52,6 +52,8 @@ class BackendSettings: sniffer_buffer_drain_interval_seconds: float sniffer_thread_join_timeout_seconds: float bridge_bpf_build_dir: str + bridge_link_state_poll_interval_seconds: float + bridge_link_state_thread_join_timeout_seconds: float telemetry_process_stop_timeout_seconds: float telemetry_reader_join_timeout_seconds: float tshark_enabled: bool @@ -86,6 +88,14 @@ def load_settings() -> BackendSettings: sniffer_buffer_drain_interval_seconds=_env_float("BACKEND_SNIFFER_BUFFER_DRAIN_INTERVAL_SECONDS", 5.0), sniffer_thread_join_timeout_seconds=_env_float("BACKEND_SNIFFER_THREAD_JOIN_TIMEOUT_SECONDS", 2.0), bridge_bpf_build_dir=_env_str("BACKEND_BRIDGE_BPF_BUILD_DIR", "/tmp/mitm-bpf"), + bridge_link_state_poll_interval_seconds=_env_float( + "BACKEND_BRIDGE_LINK_STATE_POLL_INTERVAL_SECONDS", + 0.25, + ), + bridge_link_state_thread_join_timeout_seconds=_env_float( + "BACKEND_BRIDGE_LINK_STATE_THREAD_JOIN_TIMEOUT_SECONDS", + 2.0, + ), telemetry_process_stop_timeout_seconds=_env_float("BACKEND_TELEMETRY_PROCESS_STOP_TIMEOUT_SECONDS", 3.0), telemetry_reader_join_timeout_seconds=_env_float("BACKEND_TELEMETRY_READER_JOIN_TIMEOUT_SECONDS", 2.0), tshark_enabled=_env_bool("BACKEND_TSHARK_ENABLED", True), diff --git a/backend/src/utilities/bridge_link_state_manager.py b/backend/src/utilities/bridge_link_state_manager.py new file mode 100644 index 0000000..9e80db5 --- /dev/null +++ b/backend/src/utilities/bridge_link_state_manager.py @@ -0,0 +1,289 @@ +"""Background watcher that propagates bridge member link failures to sibling ports.""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from pyroute2 import IPRoute + +from src.config import settings +from src.utilities.interface_bridge_helpers import ( + check_interface_exists, + get_bridge_ports_once, + read_interface_admin_up, + read_interface_carrier, + read_interface_operstate, +) + +logger = logging.getLogger("bridge_link_state_manager") + + +@dataclass +class MemberLinkState: + """Current sysfs-derived state for one bridge member.""" + + ifname: str + admin_up: Optional[bool] + carrier_up: Optional[bool] + operstate: Optional[str] + + @property + def link_ready(self) -> bool: + """Return whether the member currently looks healthy enough to forward.""" + if self.admin_up is not True: + return False + if self.carrier_up is False: + return False + if self.operstate in {"down", "lowerlayerdown", "notpresent"}: + return False + return True + + def to_dict(self, suppressed: bool = False) -> Dict[str, Any]: + """Serialize member state for API responses.""" + return { + "ifname": self.ifname, + "admin_up": self.admin_up, + "carrier_up": self.carrier_up, + "operstate": self.operstate, + "link_ready": self.link_ready, + "suppressed": suppressed, + } + + +class BridgeLinkStateWatcher: + """Watch one bridge and mirror member failures to the other bridge members.""" + + def __init__(self, bridge_name: str, poll_interval_seconds: float) -> None: + self.bridge_name = bridge_name + self.poll_interval_seconds = poll_interval_seconds + self._stop_event = threading.Event() + self._lock = threading.Lock() + self._thread = threading.Thread( + target=self._run, + daemon=True, + name=f"bridge-link-state-{bridge_name}", + ) + self._running = False + self._suppressed_members: dict[str, bool] = {} + self._last_poll_ts: Optional[float] = None + self._last_error: Optional[str] = None + self._last_action: Optional[str] = None + self._member_states: dict[str, MemberLinkState] = {} + + def start(self) -> None: + """Start the watcher thread.""" + with self._lock: + if self._running: + return + self._running = True + self._thread.start() + + def stop(self) -> None: + """Stop the watcher and restore interfaces that were suppressed by it.""" + self._stop_event.set() + if self._thread.is_alive(): + self._thread.join(timeout=settings.bridge_link_state_thread_join_timeout_seconds) + + try: + self._restore_suppressed_members(reason="watcher_stopped") + except Exception: + logger.exception("Failed to restore suppressed members for bridge=%s", self.bridge_name) + + with self._lock: + self._running = False + + def status(self) -> dict[str, Any]: + """Return a JSON-serializable snapshot of the watcher state.""" + with self._lock: + members = { + ifname: state.to_dict(suppressed=ifname in self._suppressed_members) + for ifname, state in sorted(self._member_states.items()) + } + return { + "bridge": self.bridge_name, + "active": self._running and self._thread.is_alive() and not self._stop_event.is_set(), + "poll_interval_seconds": self.poll_interval_seconds, + "last_poll_ts": self._last_poll_ts, + "last_error": self._last_error, + "last_action": self._last_action, + "suppressed_members": sorted(self._suppressed_members), + "members": members, + } + + def _run(self) -> None: + while not self._stop_event.is_set(): + try: + self._poll_once() + with self._lock: + self._last_error = None + except Exception as exc: + logger.exception("Bridge link-state poll failed for bridge=%s", self.bridge_name) + with self._lock: + self._last_error = str(exc) + self._stop_event.wait(self.poll_interval_seconds) + + def _poll_once(self) -> None: + members = [iface for iface in get_bridge_ports_once(self.bridge_name) if check_interface_exists(iface)] + states = {iface: self._read_member_state(iface) for iface in members} + now = time.time() + + with self._lock: + self._last_poll_ts = now + self._member_states = states + self._suppressed_members = { + iface: restore_up + for iface, restore_up in self._suppressed_members.items() + if iface in states + } + + if len(states) < 2: + self._restore_suppressed_members(reason="bridge_has_fewer_than_two_members") + return + + suppressed_snapshot = set(self._suppressed_members) + failing_members = sorted( + ifname + for ifname, state in states.items() + if ifname not in suppressed_snapshot and not state.link_ready + ) + + if failing_members: + self._suppress_other_members(states, failing_members) + return + + self._restore_suppressed_members(reason="all_members_recovered") + + def _read_member_state(self, ifname: str) -> MemberLinkState: + return MemberLinkState( + ifname=ifname, + admin_up=read_interface_admin_up(ifname), + carrier_up=read_interface_carrier(ifname), + operstate=read_interface_operstate(ifname), + ) + + def _suppress_other_members(self, states: dict[str, MemberLinkState], failing_members: list[str]) -> None: + desired_suppressed = set(states) - set(failing_members) + + with self._lock: + current_suppressed = dict(self._suppressed_members) + + next_suppressed: dict[str, bool] = {} + changed_members: list[str] = [] + + for ifname in sorted(desired_suppressed): + restore_up = current_suppressed.get(ifname, states[ifname].admin_up is True) + if ifname not in current_suppressed and states[ifname].admin_up is True: + self._set_interface_admin_state(ifname, target_up=False) + changed_members.append(ifname) + next_suppressed[ifname] = restore_up + + with self._lock: + self._suppressed_members = next_suppressed + action = ( + f"suppressed {changed_members} because failing members={failing_members}" + if changed_members + else f"holding suppressed members because failing members={failing_members}" + ) + self._last_action = action + + def _restore_suppressed_members(self, reason: str) -> None: + with self._lock: + suppressed = dict(self._suppressed_members) + + if not suppressed: + with self._lock: + self._last_action = f"no_restore_needed ({reason})" + return + + restored_members: list[str] = [] + for ifname, restore_up in sorted(suppressed.items()): + if not check_interface_exists(ifname): + continue + if restore_up: + self._set_interface_admin_state(ifname, target_up=True) + restored_members.append(ifname) + + with self._lock: + self._suppressed_members = {} + self._last_action = f"restored {restored_members} ({reason})" + + def _set_interface_admin_state(self, ifname: str, target_up: bool) -> None: + if not check_interface_exists(ifname): + raise RuntimeError(f"Interface {ifname} disappeared while propagating link state") + + state_name = "up" if target_up else "down" + with IPRoute() as ipr: + indices = ipr.link_lookup(ifname=ifname) + if not indices: + raise RuntimeError(f"Interface {ifname} not found while propagating link state") + ipr.link("set", index=indices[0], state=state_name) + + +class BridgeLinkStateManager: + """Track bridge link-state watchers keyed by bridge name.""" + + def __init__(self) -> None: + self._watchers: dict[str, BridgeLinkStateWatcher] = {} + self._lock = threading.Lock() + + def enable(self, bridge_name: str, poll_interval_seconds: Optional[float] = None) -> dict[str, Any]: + """Start or replace the watcher for the given bridge.""" + normalized_interval = poll_interval_seconds or settings.bridge_link_state_poll_interval_seconds + with self._lock: + old_watcher = self._watchers.pop(bridge_name, None) + + if old_watcher is not None: + old_watcher.stop() + + watcher = BridgeLinkStateWatcher(bridge_name, normalized_interval) + watcher.start() + + with self._lock: + self._watchers[bridge_name] = watcher + + return watcher.status() + + def disable(self, bridge_name: str) -> dict[str, Any]: + """Stop the watcher for one bridge.""" + with self._lock: + watcher = self._watchers.pop(bridge_name, None) + + if watcher is None: + return { + "bridge": bridge_name, + "active": False, + "message": "watcher not enabled", + } + + watcher.stop() + status = watcher.status() + status["active"] = False + return status + + def get_status(self, bridge_name: str) -> Optional[dict[str, Any]]: + """Return the current watcher status for one bridge, if present.""" + with self._lock: + watcher = self._watchers.get(bridge_name) + return watcher.status() if watcher is not None else None + + def list_statuses(self) -> list[dict[str, Any]]: + """Return the current status for all bridge watchers.""" + with self._lock: + watchers = list(self._watchers.values()) + return [watcher.status() for watcher in sorted(watchers, key=lambda item: item.bridge_name)] + + def stop(self) -> None: + """Stop all running watchers.""" + with self._lock: + watchers = list(self._watchers.values()) + self._watchers.clear() + + for watcher in watchers: + watcher.stop() + + +bridge_link_state_manager = BridgeLinkStateManager() diff --git a/backend/src/utilities/interface_bridge_helpers.py b/backend/src/utilities/interface_bridge_helpers.py index 2cc3c1a..3d602f6 100644 --- a/backend/src/utilities/interface_bridge_helpers.py +++ b/backend/src/utilities/interface_bridge_helpers.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Optional +from typing import List, Optional import logging import os @@ -30,6 +30,49 @@ def check_interface_up(iface: str) -> bool: return False +def _read_sysfs_text(path: str) -> Optional[str]: + """Read one sysfs file and return stripped text, or `None` if unavailable.""" + try: + with open(path, "r") as f: + return f.read().strip() + except FileNotFoundError: + return None + except Exception: + logger.exception("Error reading sysfs path %s", path) + return None + + +def read_interface_operstate(iface: str) -> Optional[str]: + """Return the kernel operstate string for an interface.""" + return _read_sysfs_text(f"/sys/class/net/{iface}/operstate") + + +def read_interface_carrier(iface: str) -> Optional[bool]: + """Return the carrier state for an interface if available.""" + value = _read_sysfs_text(f"/sys/class/net/{iface}/carrier") + if value is None: + return None + + try: + return int(value, 10) == 1 + except ValueError: + logger.warning("Unexpected carrier value for %s: %r", iface, value) + return None + + +def read_interface_admin_up(iface: str) -> Optional[bool]: + """Return whether the interface has the IFF_UP flag set.""" + value = _read_sysfs_text(f"/sys/class/net/{iface}/flags") + if value is None: + return None + + try: + return bool(int(value, 0) & 0x1) + except ValueError: + logger.warning("Unexpected flags value for %s: %r", iface, value) + return None + + def _read_bridge_ports_from_sysfs(bridge: str) -> List[str]: """ Read bridge member interfaces from sysfs. Internal helper that always reads. diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 375d824..c3a8e1e 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -3,6 +3,8 @@ import axios from 'axios'; import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall'; import { BridgeCreateRequest, + BridgeLinkStateEnableRequest, + BridgeLinkStateWatcherStatus, BridgeInfo, BridgeRemoveRequest, FullState, @@ -90,6 +92,30 @@ export const removeBridge = async (req: BridgeRemoveRequest) => { return res.data; }; +export const fetchBridgeLinkStateWatchers = async (): Promise => { + const res = await api.get('/network/bridge/link-state-watchers'); + return res.data; +}; + +export const enableBridgeLinkStateWatcher = async ( + bridgeName: string, + req: BridgeLinkStateEnableRequest, +): Promise => { + const res = await api.post( + `/network/bridge/${encodeURIComponent(bridgeName)}/link-state-watcher/enable`, + req, + ); + return res.data; +}; + +export const disableBridgeLinkStateWatcher = async (bridgeName: string): Promise => { + const res = await api.post( + `/network/bridge/${encodeURIComponent(bridgeName)}/link-state-watcher/disable`, + {}, + ); + return res.data; +}; + export const startSniffer = async (payload: SnifferStartRequest): Promise => { const res = await api.post('/sniffer/start', payload); return res.data; diff --git a/frontend/src/pages/Network.tsx b/frontend/src/pages/Network.tsx index 5527e4f..0ba3c4c 100644 --- a/frontend/src/pages/Network.tsx +++ b/frontend/src/pages/Network.tsx @@ -3,8 +3,10 @@ import { Button, Card, Col, + Descriptions, Form, Input, + InputNumber, Modal, notification, Popconfirm, @@ -18,20 +20,43 @@ import { import type { ColumnsType } from 'antd/es/table'; import { useEffect, useMemo, useState } from 'react'; -import { createBridge, fetchFullState, removeBridge } from '../api/apiClient'; -import type { BridgeInfo, FullState, InterfaceInfo } from '../types/network'; +import { + createBridge, + disableBridgeLinkStateWatcher, + enableBridgeLinkStateWatcher, + fetchBridgeLinkStateWatchers, + fetchFullState, + removeBridge, +} from '../api/apiClient'; +import type { + BridgeInfo, + BridgeLinkStateWatcherStatus, + FullState, + InterfaceInfo, +} from '../types/network'; const { Title, Paragraph } = Typography; export default function Network() { const [bridgeModalVisible, setBridgeModalVisible] = useState(false); + const [watcherModalVisible, setWatcherModalVisible] = useState(false); const [bridgeForm] = Form.useForm(); + const [watcherForm] = Form.useForm(); const [networkState, setNetworkState] = useState(); + const [watcherStatuses, setWatcherStatuses] = useState>({}); + const [watcherBridgeName, setWatcherBridgeName] = useState(); + const [watcherBusyBridge, setWatcherBusyBridge] = useState(); - const getFullState = (silent = false) => { - fetchFullState() - .then((state) => { + const getNetworkPageState = (silent = false) => { + Promise.all([fetchFullState(), fetchBridgeLinkStateWatchers()]) + .then(([state, watchers]) => { setNetworkState(state); + setWatcherStatuses( + watchers.reduce>((acc, watcher) => { + acc[watcher.bridge] = watcher; + return acc; + }, {}), + ); if (!silent) { notification.success({ message: 'Success', @@ -49,9 +74,26 @@ export default function Network() { }; useEffect(() => { - getFullState(true); + getNetworkPageState(true); }, []); + const hasActiveWatchers = useMemo( + () => Object.values(watcherStatuses).some((watcher) => watcher.active), + [watcherStatuses], + ); + + useEffect(() => { + if (!hasActiveWatchers) { + return undefined; + } + + const intervalId = window.setInterval(() => { + getNetworkPageState(true); + }, 2000); + + return () => window.clearInterval(intervalId); + }, [hasActiveWatchers]); + const interfaceOptions = useMemo( () => networkState?.interfaces.map((iface: InterfaceInfo) => ({ @@ -61,6 +103,53 @@ export default function Network() { [networkState], ); + const watcherData = useMemo( + () => + (networkState?.bridges ?? []).map((bridge) => ({ + bridge, + watcher: watcherStatuses[bridge.ifname], + })), + [networkState, watcherStatuses], + ); + + const watcherMemberColumns = useMemo( + () => [ + { title: 'Member', dataIndex: 'ifname', key: 'ifname' }, + { + title: 'Admin', + dataIndex: 'admin_up', + key: 'admin_up', + width: 90, + render: (value: boolean | null | undefined) => {value ? 'up' : 'down'}, + }, + { + title: 'Carrier', + dataIndex: 'carrier_up', + key: 'carrier_up', + width: 100, + render: (value: boolean | null | undefined) => {value ? 'up' : 'down'}, + }, + { + title: 'Operstate', + dataIndex: 'operstate', + key: 'operstate', + render: (value: string | null | undefined) => {value ?? 'unknown'}, + }, + { + title: 'Role', + key: 'role', + width: 120, + render: (_: unknown, record: { link_ready: boolean; suppressed: boolean }) => { + if (record.suppressed) { + return suppressed; + } + return {record.link_ready ? 'ready' : 'failing'}; + }, + }, + ], + [], + ); + const interfaceColumns: ColumnsType = useMemo( () => [ { title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 }, @@ -100,23 +189,58 @@ export default function Network() { key: 'members', render: (members: any[]) => (members?.length ? members.map((member) => member.ifname).join(', ') : '—'), }, + { + title: 'Link Watcher', + key: 'watcher', + width: 160, + render: (_, record: BridgeInfo) => { + const watcher = watcherStatuses[record.ifname]; + if (!watcher?.active) { + return disabled; + } + if (watcher.last_error) { + return error; + } + if (watcher.suppressed_members.length > 0) { + return propagating; + } + return active; + }, + }, { title: 'Actions', key: 'actions', - width: 140, + width: 260, render: (_, record: BridgeInfo) => ( - handleRemoveBridge(record.ifname)} - okText="Remove" - cancelText="Cancel" - > - + ) : ( + + )} + handleRemoveBridge(record.ifname)} + okText="Remove" + cancelText="Cancel" + > + @@ -194,7 +375,7 @@ export default function Network() { icon={} type="primary" onClick={() => { - getFullState(true); + getNetworkPageState(true); setBridgeModalVisible(true); }} /> @@ -211,6 +392,78 @@ export default function Network() { + + + + + + Enable a watcher on a bridge to force the sibling bridge ports down when one member loses link. + + {(networkState?.bridges.length ?? 0) === 0 ? ( + + No bridges available. + + ) : ( + watcherData.map(({ bridge, watcher }) => { + const members = Object.values(watcher?.members ?? {}); + + return ( + + {bridge.ifname} + + {watcher?.active ? 'watching' : 'disabled'} + + {watcher?.last_error ? error : null} + + } + extra={ + watcher?.active ? ( + + ) : ( + + ) + } + > + + {bridge.members.map((member) => member.ifname).join(', ') || '—'} + + {watcher?.poll_interval_seconds ? `${watcher.poll_interval_seconds}s` : '—'} + + + {watcher?.suppressed_members.length ? watcher.suppressed_members.join(', ') : 'none'} + + {watcher?.last_action ?? watcher?.message ?? '—'} + + {members.length ? ( + row.ifname} + dataSource={members} + columns={watcherMemberColumns} + pagination={false} + size="small" + /> + ) : ( + + {watcher?.active ? 'Waiting for watcher state.' : 'Watcher is disabled.'} + + )} + + ); + }) + )} + + + + + + + { + setWatcherModalVisible(false); + setWatcherBridgeName(undefined); + watcherForm.resetFields(); + }} + onOk={() => watcherForm.submit()} + > +
+ + + + + When enabled, the backend suppresses sibling bridge ports if one member loses link and restores them after recovery. + + +
); } diff --git a/frontend/src/types/network.ts b/frontend/src/types/network.ts index 8ee24eb..febba3a 100644 --- a/frontend/src/types/network.ts +++ b/frontend/src/types/network.ts @@ -46,10 +46,36 @@ export interface BridgeCreateRequest { name: string; interfaces: string[]; } + export interface BridgeRemoveRequest { name: string; } +export interface BridgeLinkStateEnableRequest { + poll_interval_seconds: number; +} + +export interface BridgeMemberLinkStateInfo { + ifname: string; + admin_up?: boolean | null; + carrier_up?: boolean | null; + operstate?: string | null; + link_ready: boolean; + suppressed: boolean; +} + +export interface BridgeLinkStateWatcherStatus { + bridge: string; + active: boolean; + poll_interval_seconds?: number | null; + last_poll_ts?: number | null; + last_error?: string | null; + last_action?: string | null; + suppressed_members: string[]; + members: Record; + message?: string | null; +} + // full-state response shape export interface FullState { interfaces: InterfaceInfo[];