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

@@ -4,6 +4,30 @@ All notable changes to PVEmanager will be documented in this file.
---
## [v1.4.0] - 2026-04-06
### 🌐 Networks — SDN Management & Node Interfaces
- **Networks page** — new unified page (`/proxmox/networks`) for managing SDN zones, VNets, subnets, and node-level network interfaces; added to sidebar navigation with dedicated icon
- **Edit SDN Zone** — `PUT /api/servers/{server_id}/sdn/zones/{zone}` — update zone properties (mtu, dns, reversedns, ipam, etc.)
- **Edit SDN VNet** — `PUT /api/servers/{server_id}/sdn/vnets/{vnet}` — update VNet alias, VLAN tag, vlanaware, etc.
- **Delete SDN Subnet** — `DELETE /api/servers/{server_id}/sdn/vnets/{vnet}/subnets/{subnet_cidr}` — delete a subnet; optionally delete linked IPAM network (`?delete_ipam_network=true`)
- **Auto-create IPAM network on subnet creation** — `POST .../subnets` now accepts `create_ipam_network: true`; creates a matching `IPAMNetwork` linked to the server and VNet, or returns the existing one
- **Node network interfaces** — full CRUD for node-level interfaces (bridges, bonds, VLANs, ethernet): list, get, create, update, delete via `proxmox_client.py`
- **Apply / Revert node network config** — `PUT` and `DELETE` on `/nodes/{node}/network` to activate or roll back pending changes
- **Node list endpoint** — `GET /api/servers/{server_id}/nodes` — returns nodes for a Proxmox server (used by Networks page selectors)
### 🧹 Code cleanup
- **SDN module imports cleaned up** — removed unused imports (`ssl`, `asyncio`, `httpx`, `websockets`, `func`, `List`, etc.) from `sdn.py`
- **Networks router** — new `networks.py` module under `api/proxmox/` with HTML page route and node API endpoints; registered in `proxmox/__init__.py`
### 🌍 i18n
- **49 new translation keys** (EN + RU) for Networks page: interface types, bridge/bond/VLAN fields, apply/revert config, IPAM integration labels, edit zone/vnet, subnet deletion, and more
---
## [v1.3.1] - 2026-04-05
### 🔍 VM List — Node Filtering & Responsive Toolbar

Binary file not shown.

View File

@@ -4,7 +4,7 @@
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
[![Version](https://img.shields.io/badge/version-1.3.1-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.4.0-blue?style=flat-square)](CHANGELOG.md)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)
@@ -27,7 +27,9 @@ PVEmanager is a self-hosted web panel that provides a unified interface for mana
- **Bulk operations** — mass start / stop / restart / delete with queue and progress tracking
- **Snapshot management** — create, rollback, delete snapshots with an async queue system
- **OS templates** — deploy VMs from templates; cross-node replication handled automatically
- **SDN support** — manage Software-Defined Networking zones, VNets, and subnets
- **SDN support** — manage Software-Defined Networking zones, VNets, and subnets; edit zones and VNets; auto-create IPAM networks for subnets
- **Node networking** — manage node-level network interfaces (bridges, bonds, VLANs); apply/revert pending config
- **Networks page** — unified UI for SDN and node interface management
### Access & Console
- **VNC console** — in-browser console via noVNC, no client software required

View File

@@ -1 +1 @@
1.3.1
1.4.0

110
WIKI.md
View File

@@ -1,6 +1,6 @@
# 📖 PVEmanager - Documentation
> Complete guide for installation, configuration and usage of PVEmanager v1.3.1
> Complete guide for installation, configuration and usage of PVEmanager v1.4.0
---
@@ -22,7 +22,8 @@
14. [Security (RBAC v2)](#-security)
15. [Localization](#-localization)
16. [Settings](#-settings)
17. [pve CLI Tool](#-pve-cli-tool)
17. [Networks (SDN & Node Interfaces)](#node-network-interfaces)
18. [pve CLI Tool](#-pve-cli-tool)
18. [API Reference](#-api-reference)
19. [Workspaces](#-workspaces)
20. [User → Server Assignment](#-user--server-assignment)
@@ -254,6 +255,23 @@ This section summarizes key API endpoints. All require JWT authentication.
- `POST /api/{server_id}/ha/{vm_type}/{vmid}/add` — add VM to HA
- `DELETE /api/{server_id}/ha/{vm_type}/{vmid}/remove` — remove VM from HA
### SDN (Software Defined Networking)
- `GET /api/servers/{server_id}/sdn/status` — check SDN availability
- `GET /api/servers/{server_id}/sdn/zones` — list zones
- `POST /api/servers/{server_id}/sdn/zones` — create zone
- `PUT /api/servers/{server_id}/sdn/zones/{zone}` — update zone
- `GET /api/servers/{server_id}/sdn/vnets` — list VNets
- `POST /api/servers/{server_id}/sdn/vnets` — create VNet
- `PUT /api/servers/{server_id}/sdn/vnets/{vnet}` — update VNet
- `POST /api/servers/{server_id}/sdn/vnets/{vnet}/subnets` — create subnet (supports `create_ipam_network`)
- `DELETE /api/servers/{server_id}/sdn/vnets/{vnet}/subnets/{cidr}` — delete subnet
- `POST /api/servers/{server_id}/sdn/apply` — apply pending SDN changes
### Node Networks
- `GET /api/servers/{server_id}/nodes` — list nodes for a server
### Misc
- `POST /api/sync-vms` — force immediate VM sync from all Proxmox servers
@@ -1087,6 +1105,13 @@ POST /api/servers/{server_id}/sdn/zones
"type": "simple"
}
# Update zone
PUT /api/servers/{server_id}/sdn/zones/{zone}
{
"mtu": 1500,
"dns": "8.8.8.8"
}
# List VNets
GET /api/servers/{server_id}/sdn/vnets
@@ -1098,18 +1123,91 @@ POST /api/servers/{server_id}/sdn/vnets
"alias": "Production Network"
}
# Create subnet
# Update VNet
PUT /api/servers/{server_id}/sdn/vnets/{vnet}
{
"alias": "Updated Alias",
"tag": 100
}
# Create subnet (with optional IPAM auto-create)
POST /api/servers/{server_id}/sdn/vnets/{vnet}/subnets
{
"subnet": "10.0.0.0/24",
"gateway": "10.0.0.1",
"snat": true
"snat": true,
"create_ipam_network": true
}
# Delete subnet (optionally delete linked IPAM network)
DELETE /api/servers/{server_id}/sdn/vnets/{vnet}/subnets/{subnet_cidr}?delete_ipam_network=true
# Apply changes (required after modifications)
POST /api/servers/{server_id}/sdn/apply
```
### Node Network Interfaces
Manage network interfaces (bridges, bonds, VLANs) directly on Proxmox nodes.
#### Accessing the Networks Page
1. Open **Networks** from the sidebar (requires `proxmox.view` permission)
2. Select a **Server** from the dropdown
3. The page shows two tabs: **SDN** and **Node Interfaces**
#### Node Interface Management
| Action | Description |
|--------|-------------|
| List interfaces | View all interfaces on a selected node |
| Create interface | Create bridge, bond, VLAN, or alias |
| Edit interface | Modify IP, netmask, gateway, ports, etc. |
| Delete interface | Remove interface configuration |
| Apply config | Activate pending changes |
| Revert config | Roll back to running configuration |
> ⚠️ Always **Apply** changes after creating or editing interfaces. Changes are staged until applied.
#### Node Network API Endpoints
```bash
# List nodes for a server
GET /api/servers/{server_id}/nodes
# List interfaces on a node
GET /proxmox/api/servers/{server_id}/nodes/{node}/networks
# Get interface details
GET /proxmox/api/servers/{server_id}/nodes/{node}/networks/{iface}
# Create interface
POST /proxmox/api/servers/{server_id}/nodes/{node}/networks
{
"iface": "vmbr1",
"type": "bridge",
"address": "10.0.0.1",
"netmask": "255.255.255.0",
"bridge_ports": "ens4",
"autostart": true
}
# Update interface
PUT /proxmox/api/servers/{server_id}/nodes/{node}/networks/{iface}
{
"address": "10.0.0.2"
}
# Delete interface
DELETE /proxmox/api/servers/{server_id}/nodes/{node}/networks/{iface}
# Apply pending network config
PUT /proxmox/api/servers/{server_id}/nodes/{node}/networks/apply
# Revert pending changes
DELETE /proxmox/api/servers/{server_id}/nodes/{node}/networks/revert
```
#### Legacy Compatibility
Old permission format (`vms.view`, `proxmox.manage`) is still supported and automatically converted to new format.
@@ -1409,6 +1507,10 @@ curl -H "Authorization: Bearer eyJ..." \
| POST | `/api/{server_id}/ha/{vm_type}/{vmid}/add` | Add VM to HA group |
| DELETE | `/api/{server_id}/ha/{vm_type}/{vmid}/remove` | Remove VM from HA |
| POST | `/api/sync-vms` | Force VM sync from Proxmox |
| GET | `/api/servers/{id}/nodes` | List nodes for a server |
| PUT | `/api/servers/{id}/sdn/zones/{zone}` | Update SDN zone |
| PUT | `/api/servers/{id}/sdn/vnets/{vnet}` | Update SDN VNet |
| DELETE | `/api/servers/{id}/sdn/vnets/{vnet}/subnets/{cidr}` | Delete SDN subnet |
### Swagger Documentation

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