v1.4.0: Networks page — SDN zone/vnet editing, node interfaces CRUD, IPAM auto-create
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
|
||||
|
||||
BIN
PVEmanager_Frontend_Redesign_TZ.docx
Normal file
BIN
PVEmanager_Frontend_Redesign_TZ.docx
Normal file
Binary file not shown.
@@ -4,7 +4,7 @@
|
||||
|
||||
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](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
|
||||
|
||||
110
WIKI.md
110
WIKI.md
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
254
backend/app/api/proxmox/networks.py
Normal file
254
backend/app/api/proxmox/networks.py
Normal 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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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>
|
||||
|
||||
1164
backend/app/templates/networks.html
Normal file
1164
backend/app/templates/networks.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user