diff --git a/backend/src/api/network_api.py b/backend/src/api/network_api.py index 8aa3d39..2ecb632 100644 --- a/backend/src/api/network_api.py +++ b/backend/src/api/network_api.py @@ -2,6 +2,8 @@ import asyncio import logging +import os +import subprocess from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect @@ -16,6 +18,8 @@ from src.utilities.interface_bridge_helpers import get_bridge_ports_once, read_i router = APIRouter() logger = logging.getLogger("network_router") +_ETHTOOL_BIN = "/usr/sbin/ethtool" if os.path.exists("/usr/sbin/ethtool") else "ethtool" +DEFAULT_INTERFACE_MTU = 1500 ip: IPRoute | None = None ndb: NDB | None = None @@ -106,6 +110,30 @@ class BridgeLinkStateEnableRequest(BaseModel): ) +class InterfaceResetDefaultsRequest(BaseModel): + """Payload for resetting one or more interfaces to baseline settings.""" + + interfaces: List[str] = Field(..., min_length=1, description="Interface names to reset.") + + +class InterfaceResetDefaultsResult(BaseModel): + """Outcome for one interface reset attempt.""" + + interface: str = Field(..., description="Interface name.") + mtu: Optional[int] = Field(None, description="Resulting MTU after reset.") + ethernet_profile: Optional[Dict[str, Any]] = Field( + None, + description="Resulting speed/duplex/autoneg profile when available.", + ) + message: str = Field(..., description="Human-readable reset result.") + + +class InterfaceResetDefaultsResponse(BaseModel): + """Batch reset response for one or more interfaces.""" + + results: List[InterfaceResetDefaultsResult] = Field(default_factory=list) + + class BridgeMemberLinkStateInfo(BaseModel): """Current link-state snapshot for one bridge member.""" @@ -282,6 +310,38 @@ def publish_network_state_update(reason: str) -> None: logger.exception("Failed to publish network state update") +def _reset_interface_defaults(ifname: str, ip_route: IPRoute) -> InterfaceResetDefaultsResult: + """Reset one interface to a conservative baseline configuration.""" + idx = iface_index(ifname, ip_route) + messages: list[str] = [] + + ip_route.link("set", index=idx, state="down") + ip_route.link("set", index=idx, mtu=DEFAULT_INTERFACE_MTU) + + try: + subprocess.run( + [_ETHTOOL_BIN, "-s", ifname, "autoneg", "on"], + capture_output=True, + text=True, + check=True, + ) + messages.append("autoneg on") + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + logger.debug("Failed to reset ethtool defaults on %s: %s", ifname, exc) + messages.append("autoneg unchanged") + + ip_route.link("set", index=idx, state="up") + messages.append(f"mtu {DEFAULT_INTERFACE_MTU}") + messages.append("admin up") + + return InterfaceResetDefaultsResult( + interface=ifname, + mtu=DEFAULT_INTERFACE_MTU, + ethernet_profile=read_interface_ethernet_profile(ifname), + message=", ".join(messages), + ) + + @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.""" @@ -422,6 +482,18 @@ def full_state(ip: IPRoute = Depends(get_iproute)) -> FullStateResponse: return _build_full_state_response(ip) +@router.post("/interfaces/reset-defaults", response_model=InterfaceResetDefaultsResponse) +def reset_interfaces_to_defaults( + req: InterfaceResetDefaultsRequest, + ip: IPRoute = Depends(get_iproute), +) -> InterfaceResetDefaultsResponse: + """Reset listed interfaces to baseline MTU/autoneg/up settings.""" + unique_ifaces = list(dict.fromkeys(req.interfaces)) + results = [_reset_interface_defaults(ifname, ip) for ifname in unique_ifaces] + publish_network_state_update("interfaces_reset_defaults") + return InterfaceResetDefaultsResponse(results=results) + + @router.post("/bridge/create") def create_bridge(req: BridgeCreateRequest, ip: IPRoute = Depends(get_iproute)) -> dict: """Create a bridge and attach listed interfaces.""" diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 3f260b5..3db4438 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -8,6 +8,8 @@ import { BridgeInfo, BridgeRemoveRequest, FullState, + InterfaceResetDefaultsRequest, + InterfaceResetDefaultsResponse, InterfaceInfo, RouteInfo, } from '../types/network'; @@ -82,6 +84,13 @@ export const fetchFullState = async (): Promise => { return res.data; }; +export const resetInterfaceDefaults = async ( + req: InterfaceResetDefaultsRequest, +): Promise => { + const res = await api.post('/network/interfaces/reset-defaults', req); + return res.data; +}; + export const getNetworkStateWebSocketUrl = (): string => { const url = new URL(BASE); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; diff --git a/frontend/src/pages/Network.tsx b/frontend/src/pages/Network.tsx index e1c9aba..db4e8f5 100644 --- a/frontend/src/pages/Network.tsx +++ b/frontend/src/pages/Network.tsx @@ -27,6 +27,7 @@ import { fetchFullState, getNetworkStateWebSocketUrl, removeBridge, + resetInterfaceDefaults, } from '../api/apiClient'; import type { EthernetProfile, @@ -34,6 +35,7 @@ import type { BridgeLinkStateWatcherStatus, FullState, InterfaceInfo, + InterfaceResetDefaultsResponse, } from '../types/network'; const { Title, Paragraph } = Typography; @@ -47,6 +49,7 @@ export default function Network() { const [watcherStatuses, setWatcherStatuses] = useState>({}); const [watcherBridgeName, setWatcherBridgeName] = useState(); const [watcherBusyBridge, setWatcherBusyBridge] = useState(); + const [resetBusyInterface, setResetBusyInterface] = useState(); const getNetworkPageState = (silent = false) => { fetchFullState() @@ -227,8 +230,24 @@ export default function Network() { — ), }, + { + title: 'Actions', + key: 'actions', + width: 160, + render: (_: unknown, record: InterfaceInfo) => ( + handleResetInterface(record.name)} + okText="Reset" + cancelText="Cancel" + > + + + ), + }, ], - [], + [resetBusyInterface], ); const bridgeColumns: ColumnsType = useMemo( @@ -392,6 +411,31 @@ export default function Network() { }); } + function handleResetInterface(ifname: string) { + setResetBusyInterface(ifname); + resetInterfaceDefaults({ interfaces: [ifname] }) + .then((response: InterfaceResetDefaultsResponse) => { + const result = response.results[0]; + notification.success({ + message: 'Success', + description: result?.message + ? `${ifname}: ${result.message}` + : `${ifname} reset to defaults`, + }); + getNetworkPageState(true); + }) + .catch((error) => { + console.error(error); + notification.error({ + message: 'Error', + description: (error as Error).message ?? `Failed to reset ${ifname}`, + }); + }) + .finally(() => { + setResetBusyInterface(undefined); + }); + } + return (
diff --git a/frontend/src/types/network.ts b/frontend/src/types/network.ts index 2c83440..8ff9547 100644 --- a/frontend/src/types/network.ts +++ b/frontend/src/types/network.ts @@ -63,6 +63,21 @@ export interface BridgeLinkStateEnableRequest { recovery_holdoff_seconds: number; } +export interface InterfaceResetDefaultsRequest { + interfaces: string[]; +} + +export interface InterfaceResetDefaultsResult { + interface: string; + mtu?: number | null; + ethernet_profile?: EthernetProfile | null; + message: string; +} + +export interface InterfaceResetDefaultsResponse { + results: InterfaceResetDefaultsResult[]; +} + export interface BridgeMemberLinkStateInfo { ifname: string; admin_up?: boolean | null;