v1.4.0: Networks page — SDN zone/vnet editing, node interfaces CRUD, IPAM auto-create

This commit is contained in:
markmorado
2026-04-06 21:13:38 +02:00
parent 6a0564cea2
commit 4546a798d0
13 changed files with 2002 additions and 35 deletions

View File

@@ -6,6 +6,7 @@ from .vms import router as vms_router
from .tasks import router as tasks_router
from .backup import router as backup_router
from .cluster import router as cluster_router
from .networks import router as networks_router
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
router = APIRouter()
@@ -16,3 +17,4 @@ router.include_router(vms_router)
router.include_router(tasks_router)
router.include_router(backup_router)
router.include_router(cluster_router)
router.include_router(networks_router)

View File

@@ -0,0 +1,254 @@
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from loguru import logger
from ...db import get_db
from ...models import ProxmoxServer, User, IPAMNetwork
from ...auth import PermissionChecker
from ...template_helpers import add_i18n_context
from ._helpers import _get_proxmox_client, templates
router = APIRouter()
# ==================== HTML Page ====================
@router.get("/networks", response_class=HTMLResponse, include_in_schema=False)
def networks_page(request: Request, db: Session = Depends(get_db)):
"""Страница управления сетями PVE (SDN + интерфейсы нод)"""
from ...i18n import t
lang = request.cookies.get("language", "en")
proxmox_servers = db.query(ProxmoxServer).all()
context = {
"request": request,
"proxmox_servers": proxmox_servers,
"page_title": t("nav_networks", lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("networks.html", context)
# ==================== Node list ====================
@router.get("/api/servers/{server_id}/nodes")
def get_server_nodes(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""List nodes for a Proxmox server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
nodes = client.get_nodes()
return JSONResponse(content={"nodes": nodes})
except Exception as e:
logger.error(f"Error getting nodes for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== Node Network Interfaces ====================
@router.get("/api/servers/{server_id}/nodes/{node}/networks")
def list_node_networks(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""List all network interfaces on a node, with IPAM linkage info."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
ifaces = client.get_node_networks(node)
# Fetch all IPAM networks linked to this server once — O(1) round-trips
ipam_nets = (
db.query(IPAMNetwork)
.filter(IPAMNetwork.proxmox_server_id == server_id)
.all()
)
# Build lookup: bridge_name → ipam_network
bridge_to_ipam = {n.proxmox_bridge: n for n in ipam_nets if n.proxmox_bridge}
enriched = []
for iface in ifaces:
iface_name = iface.get("iface") or iface.get("name", "")
ipam_net = bridge_to_ipam.get(iface_name)
record = dict(iface)
if ipam_net:
record["ipam_network_id"] = ipam_net.id
record["ipam_cidr"] = ipam_net.network
record["ipam_name"] = ipam_net.name
enriched.append(record)
return JSONResponse(content={"node": node, "interfaces": enriched})
except Exception as e:
logger.error(f"Error listing node {node} networks for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/nodes/{node}/networks")
async def create_node_network(
server_id: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Create a new network interface on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
iface_type = data.pop("type", None)
if not iface_type:
raise HTTPException(status_code=400, detail="Interface type is required")
iface_name = data.get("iface", "")
if not iface_name:
raise HTTPException(status_code=400, detail="Interface name (iface) is required")
try:
client = _get_proxmox_client(server)
result = client.create_node_network(node, iface_type, **data)
if result.get("success"):
logger.info(
f"User {current_user.username} created {iface_type} interface "
f"{iface_name} on {server.name}/{node}"
)
return JSONResponse(content=result)
raise HTTPException(status_code=400, detail=result.get("error", "Failed to create interface"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating node interface: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/servers/{server_id}/nodes/{node}/networks/{iface}")
async def update_node_network(
server_id: int,
node: str,
iface: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Update a network interface on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
try:
client = _get_proxmox_client(server)
result = client.update_node_network(node, iface, **data)
if result.get("success"):
logger.info(
f"User {current_user.username} updated interface {iface} "
f"on {server.name}/{node}"
)
return JSONResponse(content=result)
raise HTTPException(status_code=400, detail=result.get("error", "Failed to update interface"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating node interface {iface}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/servers/{server_id}/nodes/{node}/networks/{iface}")
def delete_node_network(
server_id: int,
node: str,
iface: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Delete a network interface from a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.delete_node_network(node, iface)
if result.get("success"):
logger.info(
f"User {current_user.username} deleted interface {iface} "
f"from {server.name}/{node}"
)
return JSONResponse(content=result)
raise HTTPException(status_code=400, detail=result.get("error", "Failed to delete interface"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting node interface {iface}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/nodes/{node}/networks/apply")
def apply_node_network(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Apply pending network configuration changes on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.apply_node_network_config(node)
if result.get("success"):
logger.info(
f"User {current_user.username} applied network config "
f"on {server.name}/{node}"
)
return JSONResponse(content=result)
raise HTTPException(status_code=400, detail=result.get("error", "Failed to apply network config"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error applying node network config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/nodes/{node}/networks/revert")
def revert_node_network(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Revert pending network configuration changes on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.revert_node_network_config(node)
if result.get("success"):
logger.info(
f"User {current_user.username} reverted network config "
f"on {server.name}/{node}"
)
return JSONResponse(content=result)
raise HTTPException(status_code=400, detail=result.get("error", "Failed to revert network config"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error reverting node network config: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,27 +1,14 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from loguru import logger
from typing import List
import ssl
import asyncio
import httpx
import websockets
from typing import Optional
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...models import ProxmoxServer, User, IPAMNetwork
from ...auth import PermissionChecker
from ...logging_service import LoggingService
from ...template_helpers import add_i18n_context
from ...ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
get_next_vmid, archive_and_delete_snapshots,
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
templates)
from ._helpers import _get_proxmox_client
router = APIRouter()
@@ -271,24 +258,51 @@ async def create_sdn_subnet(
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
subnet = data.get('subnet')
gateway = data.get('gateway')
snat = data.get('snat', False)
dnszoneprefix = data.get('dnszoneprefix')
create_ipam_network = data.get('create_ipam_network', False)
if not subnet:
raise HTTPException(status_code=400, detail="Subnet CIDR is required")
try:
client = _get_proxmox_client(server)
result = client.create_sdn_subnet(vnet, subnet, gateway=gateway, snat=snat, dnszoneprefix=dnszoneprefix)
if result.get('success'):
logger.info(f"User {current_user.username} created subnet {subnet} in vnet {vnet}")
return JSONResponse(content=result)
response = dict(result)
# Optionally create a matching IPAMNetwork
if create_ipam_network:
existing = db.query(IPAMNetwork).filter(IPAMNetwork.network == subnet).first()
if existing:
response['ipam_network_id'] = existing.id
response['ipam_already_existed'] = True
else:
ipam_net = IPAMNetwork(
name=f"{vnet} — {subnet}",
network=subnet,
gateway=gateway or None,
proxmox_server_id=server_id,
proxmox_bridge=vnet,
is_active=True,
)
db.add(ipam_net)
db.commit()
db.refresh(ipam_net)
response['ipam_network_id'] = ipam_net.id
response['ipam_created'] = True
logger.info(
f"User {current_user.username} auto-created IPAM network "
f"{subnet} (id={ipam_net.id}) linked to vnet {vnet}"
)
return JSONResponse(content=response)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create subnet'))
except HTTPException:
@@ -324,3 +338,109 @@ def apply_sdn_changes(
except Exception as e:
logger.error(f"Error applying SDN changes: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/servers/{server_id}/sdn/zones/{zone}")
async def update_sdn_zone(
server_id: int,
zone: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Update an existing SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
try:
client = _get_proxmox_client(server)
result = client.update_sdn_zone(zone, **data)
if result.get('success'):
logger.info(f"User {current_user.username} updated SDN zone: {zone}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to update zone'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating SDN zone {zone}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/servers/{server_id}/sdn/vnets/{vnet}")
async def update_sdn_vnet(
server_id: int,
vnet: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Update an existing SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
try:
client = _get_proxmox_client(server)
result = client.update_sdn_vnet(vnet, **data)
if result.get('success'):
logger.info(f"User {current_user.username} updated SDN vnet: {vnet}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to update vnet'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating SDN vnet {vnet}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/servers/{server_id}/sdn/vnets/{vnet}/subnets/{subnet_cidr:path}")
def delete_sdn_subnet(
server_id: int,
vnet: str,
subnet_cidr: str,
delete_ipam_network: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Delete a subnet from a VNet. subnet_cidr uses '-' instead of '/' (e.g. 10.0.0.0-24)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
# Accept both dash-encoded and slash CIDR
subnet_slash = subnet_cidr.replace("-", "/") if "-" in subnet_cidr else subnet_cidr
try:
client = _get_proxmox_client(server)
result = client.delete_sdn_subnet(vnet, subnet_slash)
if result.get('success'):
logger.info(f"User {current_user.username} deleted subnet {subnet_slash} from vnet {vnet}")
response = dict(result)
if delete_ipam_network:
ipam_net = db.query(IPAMNetwork).filter(
IPAMNetwork.network == subnet_slash,
IPAMNetwork.proxmox_server_id == server_id,
).first()
if ipam_net:
db.delete(ipam_net)
db.commit()
response['ipam_network_deleted'] = True
logger.info(
f"User {current_user.username} deleted IPAM network "
f"{subnet_slash} (id={ipam_net.id})"
)
return JSONResponse(content=response)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to delete subnet'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting SDN subnet {subnet_slash}: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1186,5 +1186,52 @@
"send": "Send",
"server_updated_successfully": "Server updated successfully",
"update_check_disabled": "Update check disabled",
"virtual_machine": "virtual machine"
"virtual_machine": "virtual machine",
"nav_networks": "Networks",
"networks_management_desc": "Manage SDN zones, VNets, subnets and node network interfaces",
"node_networks": "Node Interfaces",
"network_interfaces": "Network Interfaces",
"iface_type": "Type",
"iface_bridge": "Bridge",
"iface_bond": "Bond",
"iface_vlan": "VLAN",
"iface_eth": "Ethernet",
"bridge_ports": "Bridge Ports",
"bridge_ports_hint": "Space-separated list of physical interfaces, e.g. ens3",
"bond_slaves": "Slaves",
"bond_slaves_hint": "Space-separated list of slave interfaces, e.g. ens3 ens4",
"bond_mode": "Bond Mode",
"autostart": "Autostart",
"active": "Active",
"apply_network_config": "Apply Network Config",
"apply_network_config_confirm": "Apply pending network changes to this node? This will activate the new configuration.",
"revert_network_config": "Revert Network Config",
"revert_network_config_confirm": "Revert all pending network changes on this node? Unsaved changes will be lost.",
"pending_network_changes": "Pending Changes",
"create_interface": "Create Interface",
"edit_interface": "Edit Interface",
"edit_zone": "Edit Zone",
"edit_vnet": "Edit VNet",
"delete_subnet": "Delete Subnet",
"sdn_zone_evpn": "EVPN",
"sdn_zone_name_hint": "Up to 8 characters, letters and digits only",
"create_ipam_network_for_subnet": "Also create an IPAM network for this subnet",
"create_ipam_hint": "Creates a linked IPAM network entry that tracks IP allocations for this subnet",
"ipam_network_created": "IPAM network created.",
"ipam_network_existed": "IPAM network already existed, linked.",
"ipam_network_will_not_be_deleted": "The linked IPAM network (if any) will not be deleted.",
"synced_with_ipam": "IPAM",
"vlan_aware": "VLAN Aware",
"vlan_id": "VLAN ID",
"vlan_raw_device": "Raw Device",
"vxlan_peer": "Peer Addresses",
"vxlan_peer_hint": "Space-separated list of remote peer IP addresses",
"zone": "Zone",
"alias": "Alias",
"subnet": "Subnet",
"netmask": "Netmask",
"select_node": "Select Node",
"iface_bridge_name_hint": "e.g. vmbr0, vmbr1",
"iface_bond_name_hint": "e.g. bond0, bond1",
"iface_vlan_name_hint": "e.g. ens3.100"
}

View File

@@ -1186,5 +1186,52 @@
"send": "Отправить",
"server_updated_successfully": "Сервер успешно обновлён",
"update_check_disabled": "Проверка обновлений отключена",
"virtual_machine": "виртуальная машина"
"virtual_machine": "виртуальная машина",
"nav_networks": "Сети",
"networks_management_desc": "Управление SDN-зонами, VNet, подсетями и сетевыми интерфейсами узлов",
"node_networks": "Интерфейсы узлов",
"network_interfaces": "Сетевые интерфейсы",
"iface_type": "Тип",
"iface_bridge": "Бридж",
"iface_bond": "Бонд",
"iface_vlan": "VLAN",
"iface_eth": "Ethernet",
"bridge_ports": "Порты бриджа",
"bridge_ports_hint": "Список физических интерфейсов через пробел, например ens3",
"bond_slaves": "Slave-интерфейсы",
"bond_slaves_hint": "Список slave-интерфейсов через пробел, например ens3 ens4",
"bond_mode": "Режим бонда",
"autostart": "Автозапуск",
"active": "Активен",
"apply_network_config": "Применить конфигурацию",
"apply_network_config_confirm": "Применить ожидающие изменения сети на этом узле? Новая конфигурация будет активирована.",
"revert_network_config": "Откатить конфигурацию",
"revert_network_config_confirm": "Откатить все ожидающие изменения сети на этом узле? Несохранённые изменения будут потеряны.",
"pending_network_changes": "Ожидающие изменения",
"create_interface": "Создать интерфейс",
"edit_interface": "Редактировать интерфейс",
"edit_zone": "Редактировать зону",
"edit_vnet": "Редактировать VNet",
"delete_subnet": "Удалить подсеть",
"sdn_zone_evpn": "EVPN",
"sdn_zone_name_hint": "До 8 символов, только буквы и цифры",
"create_ipam_network_for_subnet": "Также создать сеть IPAM для этой подсети",
"create_ipam_hint": "Создаёт связанную запись IPAM для отслеживания IP-адресов в этой подсети",
"ipam_network_created": "Сеть IPAM создана.",
"ipam_network_existed": "Сеть IPAM уже существовала, привязана.",
"ipam_network_will_not_be_deleted": "Связанная сеть IPAM (при наличии) удалена не будет.",
"synced_with_ipam": "IPAM",
"vlan_aware": "VLAN Aware",
"vlan_id": "VLAN ID",
"vlan_raw_device": "Raw-устройство",
"vxlan_peer": "Адреса пиров",
"vxlan_peer_hint": "Список IP-адресов удалённых пиров через пробел",
"zone": "Зона",
"alias": "Псевдоним",
"subnet": "Подсеть",
"netmask": "Маска",
"select_node": "Выберите узел",
"iface_bridge_name_hint": "например vmbr0, vmbr1",
"iface_bond_name_hint": "например bond0, bond1",
"iface_vlan_name_hint": "например ens3.100"
}

View File

@@ -2840,7 +2840,208 @@ class ProxmoxClient:
except Exception as e:
logger.error(f"Error getting SDN pending changes: {e}")
return []
def update_sdn_zone(self, zone: str, **kwargs) -> Dict:
"""
Update an existing SDN zone.
Args:
zone: Zone name to update
**kwargs: Fields to update (mtu, dns, reversedns, ipam, etc.)
Returns:
Result dict with success status
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.sdn.zones(zone).put(**kwargs)
logger.info(f"Updated SDN zone: {zone}")
return {"success": True, "zone": zone}
except Exception as e:
logger.error(f"Error updating SDN zone {zone}: {e}")
return {"success": False, "error": str(e)}
def update_sdn_vnet(self, vnet: str, **kwargs) -> Dict:
"""
Update an existing SDN VNet.
Args:
vnet: VNet name to update
**kwargs: Fields to update (alias, tag, vlanaware, etc.)
Returns:
Result dict with success status
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.sdn.vnets(vnet).put(**kwargs)
logger.info(f"Updated SDN vnet: {vnet}")
return {"success": True, "vnet": vnet}
except Exception as e:
logger.error(f"Error updating SDN vnet {vnet}: {e}")
return {"success": False, "error": str(e)}
# ==================== Node Network Interfaces ====================
def get_node_networks(self, node: str) -> List[Dict]:
"""
Get all network interfaces configured on a node.
Args:
node: Node name
Returns:
List of network interface objects
"""
if not self.proxmox:
return []
try:
ifaces = self.proxmox.nodes(node).network.get()
return ifaces if isinstance(ifaces, list) else []
except Exception as e:
logger.error(f"Error getting node {node} networks: {e}")
return []
def get_node_network(self, node: str, iface: str) -> Optional[Dict]:
"""
Get details of a specific network interface on a node.
Args:
node: Node name
iface: Interface name (e.g., vmbr0, bond0)
Returns:
Interface configuration dict or None
"""
if not self.proxmox:
return None
try:
return self.proxmox.nodes(node).network(iface).get()
except Exception as e:
logger.error(f"Error getting node {node} interface {iface}: {e}")
return None
def create_node_network(self, node: str, iface_type: str, **kwargs) -> Dict:
"""
Create a new network interface on a node.
Args:
node: Node name
iface_type: Type of interface ('bridge', 'bond', 'vlan', 'eth')
**kwargs: Interface parameters (iface, address, netmask, gateway,
bridge_ports, bond_slaves, bond_mode, vlan-id,
vlan-raw-device, autostart, comments, etc.)
Returns:
Result dict with success status
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"type": iface_type}
params.update(kwargs)
self.proxmox.nodes(node).network.post(**params)
iface_name = kwargs.get("iface", "")
logger.info(f"Created {iface_type} interface {iface_name} on node {node}")
return {"success": True, "iface": iface_name}
except Exception as e:
logger.error(f"Error creating {iface_type} interface on node {node}: {e}")
return {"success": False, "error": str(e)}
def update_node_network(self, node: str, iface: str, **kwargs) -> Dict:
"""
Update a network interface on a node.
Args:
node: Node name
iface: Interface name to update
**kwargs: Fields to update
Returns:
Result dict with success status
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).network(iface).put(**kwargs)
logger.info(f"Updated interface {iface} on node {node}")
return {"success": True, "iface": iface}
except Exception as e:
logger.error(f"Error updating interface {iface} on node {node}: {e}")
return {"success": False, "error": str(e)}
def delete_node_network(self, node: str, iface: str) -> Dict:
"""
Delete a network interface from a node.
Args:
node: Node name
iface: Interface name to delete
Returns:
Result dict with success status
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).network(iface).delete()
logger.info(f"Deleted interface {iface} from node {node}")
return {"success": True}
except Exception as e:
logger.error(f"Error deleting interface {iface} from node {node}: {e}")
return {"success": False, "error": str(e)}
def apply_node_network_config(self, node: str) -> Dict:
"""
Apply pending network configuration changes on a node.
Args:
node: Node name
Returns:
Result dict with success status
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
result = self.proxmox.nodes(node).network.put()
logger.info(f"Applied network config on node {node}")
return {"success": True, "result": result}
except Exception as e:
logger.error(f"Error applying network config on node {node}: {e}")
return {"success": False, "error": str(e)}
def revert_node_network_config(self, node: str) -> Dict:
"""
Revert pending network configuration changes on a node (restore from running config).
Args:
node: Node name
Returns:
Result dict with success status
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).network.delete()
logger.info(f"Reverted network config on node {node}")
return {"success": True}
except Exception as e:
logger.error(f"Error reverting network config on node {node}: {e}")
return {"success": False, "error": str(e)}
# ==================== Snapshots ====================
def get_vm_snapshots(self, node: str, vmid: int) -> List[Dict]:

View File

@@ -750,6 +750,10 @@ body.update-in-progress .layout {
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
<span>{{ t('nav_ipam') }}</span>
</a></li>
<li data-nav-permission="proxmox.view"><a href="/proxmox/networks" class="{{ 'active' if request.path.startswith('/proxmox/networks') else '' }}" title="{{ t('nav_networks') }}">
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<span>{{ t('nav_networks') }}</span>
</a></li>
<li data-nav-permission="docker.view"><a href="/containers" class="{{ 'active' if request.path.startswith('/containers') else '' }}" title="{{ t('nav_docker') }}">
<i class="fa-brands fa-docker" style="font-size: 1.25rem; width: 24px; text-align: center;"></i>
<span>{{ t('nav_docker') }}</span>

File diff suppressed because it is too large Load Diff