feat: IPAM improvements - server/node subnet binding, theme color fixes

- IPAM link-allocations: filter networks by proxmox_server_id and proxmox_node
  to prevent cross-server/cross-node IP assignment
- IPAM link-allocations: add manual server+network selection modal before linking
- IPAM add-network forms: add Proxmox server and node selectors with dynamic
  cluster-info loading (ipam_dashboard.html, ipam_networks.html)
- models.py / schemas.py: add proxmox_node field to IPAMNetwork
- migrations: migration #16 adds proxmox_node column to ipam_networks table
- Theme fixes: replace all hardcoded rgba/color:#fff/color:white with CSS vars
  across base.html, virtual_machines.html, instance_detail.html,
  os_templates.html, proxmox_vms.html, ipam_network_detail.html,
  ipam_allocations.html
- i18n: add proxmox_node, node selector, link-allocations modal keys (ru/en)
- Version bump: 1.0.4 -> 1.0.5
This commit is contained in:
markmorado
2026-03-08 14:17:54 +01:00
parent 4bbc02982a
commit 91dbf0b81c
15 changed files with 542 additions and 119 deletions

View File

@@ -1 +1 @@
1.0.4 1.0.5

View File

@@ -4,7 +4,9 @@ IP Address Management endpoints for networks, pools, and allocations.
""" """
import logging import logging
import ipaddress as ipaddress_module
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -12,6 +14,7 @@ from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, ProxmoxServer, User, VMInstance from ..models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, ProxmoxServer, User, VMInstance
from ..proxmox_client import ProxmoxClient
from ..schemas import ( from ..schemas import (
IPAMNetworkCreate, IPAMNetworkUpdate, IPAMNetworkResponse, IPAMNetworkStats, IPAMNetworkCreate, IPAMNetworkUpdate, IPAMNetworkResponse, IPAMNetworkStats,
IPAMPoolCreate, IPAMPoolUpdate, IPAMPoolResponse, IPAMPoolCreate, IPAMPoolUpdate, IPAMPoolResponse,
@@ -814,97 +817,247 @@ async def cleanup_orphan_allocations(
} }
def _build_proxmox_client(server: ProxmoxServer) -> ProxmoxClient:
"""Build ProxmoxClient from ProxmoxServer model."""
host = server.ip_address or server.hostname
if server.port and server.port != 8006:
host = f"{host}:{server.port}"
if server.use_password and server.password:
return ProxmoxClient(host=host, user=server.api_user, password=server.password, verify_ssl=server.verify_ssl)
return ProxmoxClient(host=host, user=server.api_user, token_name=server.api_token_name, token_value=server.api_token_value, verify_ssl=server.verify_ssl)
def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
"""Extract first IPv4 that belongs to any IPAM network from interface list."""
for iface in interfaces:
for ip_info in iface.get('ips', []):
if ip_info.get('type') != 'ipv4':
continue
addr = ip_info.get('address', '')
try:
ip_obj = ipaddress_module.ip_address(addr)
except ValueError:
continue
if ip_obj.is_loopback or ip_obj.is_link_local:
continue
for net, net_obj in parsed_networks:
if ip_obj in net_obj:
return addr, net
return None, None
@router.get("/api/unlinked") @router.get("/api/unlinked")
def get_unlinked_allocations( def get_unlinked_allocations(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage")) current_user: User = Depends(PermissionChecker("ipam.manage"))
): ):
""" """
Get IPAM allocations without proxmox_vmid that can be linked to existing VMs by name. Return VM instances that have no IPAM allocation linked to them (by vmid+server_id).
""" """
unlinked = [] unlinked = []
# Allocations without proxmox_vmid # All live VMs
allocations = db.query(IPAMAllocation).filter( vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all()
IPAMAllocation.proxmox_vmid.is_(None),
IPAMAllocation.status.in_(['allocated', 'reserved']) # Pre-load all linked (proxmox_server_id, proxmox_vmid) pairs for fast lookup
).all() linked_pairs = set(
(a.proxmox_server_id, a.proxmox_vmid)
for alloc in allocations: for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter(
# Try to find matching VM by name IPAMAllocation.proxmox_vmid.isnot(None)
vm = db.query(VMInstance).filter( ).all()
VMInstance.name.ilike(alloc.resource_name), )
VMInstance.deleted_at.is_(None)
).first() servers_map = {s.id: s for s in db.query(ProxmoxServer).all()}
for vm in vms:
if (vm.server_id, vm.vmid) in linked_pairs:
continue
server = servers_map.get(vm.server_id)
unlinked.append({ unlinked.append({
'id': alloc.id, 'id': None,
'ip_address': alloc.ip_address, 'ip_address': vm.ip_address or '?',
'resource_name': alloc.resource_name, 'resource_name': vm.name,
'resource_type': alloc.resource_type, 'resource_type': vm.vm_type,
'can_link': vm is not None, 'can_link': True,
'suggested_vmid': vm.vmid if vm else None, 'suggested_vmid': vm.vmid,
'suggested_server_id': vm.server_id if vm else None, 'suggested_server_id': vm.server_id,
'suggested_server_name': None # Will be filled below 'suggested_server_name': server.name if server else None
}) })
if vm:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == vm.server_id).first()
unlinked[-1]['suggested_server_name'] = server.name if server else None
return { return {
'unlinked': unlinked, 'unlinked': unlinked,
'count': len(unlinked), 'count': len(unlinked),
'linkable_count': sum(1 for u in unlinked if u['can_link']) 'linkable_count': len(unlinked)
} }
class LinkAllocationsRequest(BaseModel):
server_id: Optional[int] = None # If set, only process this server's VMs
network_id: Optional[int] = None # If set, only match against this IPAM network
@router.post("/api/link-allocations") @router.post("/api/link-allocations")
def link_allocations_to_vms( def link_allocations_to_vms(
body: LinkAllocationsRequest = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage")) current_user: User = Depends(PermissionChecker("ipam.manage"))
): ):
""" """
Link IPAM allocations to real VMs by matching resource_name. Scan Proxmox servers, get VM IP addresses via QEMU guest agent / LXC config,
and create/update IPAM allocations for each VM whose IP belongs to a known IPAM network.
When body.server_id is given: only process that server.
When body.network_id is given: only match against that specific network.
In auto mode (no params): each VM is matched only against networks whose
proxmox_server_id equals the VM's server (or networks without a server binding).
""" """
if body is None:
body = LinkAllocationsRequest()
linked = [] linked = []
not_found = [] not_found = []
# Get unlinked allocations service = IPAMService(db)
allocations = db.query(IPAMAllocation).filter( synced_by = current_user.username if hasattr(current_user, 'username') else 'system'
IPAMAllocation.proxmox_vmid.is_(None),
IPAMAllocation.status.in_(['allocated', 'reserved']) # Parse all active IPAM networks once (or just the requested one)
).all() net_query = db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True)
if body.network_id:
for alloc in allocations: net_query = net_query.filter(IPAMNetwork.id == body.network_id)
# Find VM by name (case-insensitive) networks = net_query.all()
vm = db.query(VMInstance).filter(
VMInstance.name.ilike(alloc.resource_name), parsed_networks = []
VMInstance.deleted_at.is_(None) for net in networks:
).first() try:
parsed_networks.append((net, ipaddress_module.ip_network(net.network, strict=False)))
if vm: except ValueError:
# Update allocation with VM info pass
alloc.proxmox_vmid = vm.vmid
alloc.proxmox_server_id = vm.server_id if not parsed_networks:
alloc.resource_id = vm.vmid return {'linked': [], 'linked_count': 0, 'not_found': [], 'not_found_count': 0,
'message': 'No active IPAM networks configured'}
linked.append({
'ip_address': alloc.ip_address, # Pre-build set of already-linked (server_id, vmid) pairs
'resource_name': alloc.resource_name, linked_pairs = set(
'vmid': vm.vmid, (a.proxmox_server_id, a.proxmox_vmid)
'server_id': vm.server_id for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter(
}) IPAMAllocation.proxmox_vmid.isnot(None)
logger.info(f"Linked IP {alloc.ip_address} to VM {vm.vmid} on server {vm.server_id}") ).all()
)
# All live VMs cached in DB (optionally filtered by server)
vm_query = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None))
if body.server_id:
vm_query = vm_query.filter(VMInstance.server_id == body.server_id)
vms = vm_query.all()
# Group VMs by server_id for efficient client reuse
vms_by_server: dict = {}
for vm in vms:
vms_by_server.setdefault(vm.server_id, []).append(vm)
for server_id, server_vms in vms_by_server.items():
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
continue
# Determine which networks apply for this server.
# If a specific network_id was requested, only use that (already filtered above).
# Otherwise restrict to networks that belong to this server OR are "global" (no server binding).
if body.network_id:
server_networks = parsed_networks # already filtered to the one network
else: else:
not_found.append({ server_networks = [
'ip_address': alloc.ip_address, (n, p) for n, p in parsed_networks
'resource_name': alloc.resource_name if n.proxmox_server_id == server_id or n.proxmox_server_id is None
}) ]
if not server_networks:
logger.info(f"[IPAM link] Server {server.name} has no matching IPAM networks, skipping")
continue
try:
client = _build_proxmox_client(server)
if not client.is_connected():
logger.warning(f"[IPAM link] Server {server.name} not reachable, skipping")
for vm in server_vms:
if (server_id, vm.vmid) not in linked_pairs:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'server_unreachable'})
continue
except Exception as e:
logger.error(f"[IPAM link] Cannot build client for server {server.name}: {e}")
continue
for vm in server_vms:
# Skip already linked
if (server_id, vm.vmid) in linked_pairs:
continue
# For this VM, further filter networks by proxmox_node if set
# (e.g. network tagged to pve1 should not match VMs on pve2)
vm_networks = [
(n, p) for n, p in server_networks
if not n.proxmox_node or n.proxmox_node == vm.node
]
if not vm_networks:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'no_network_for_node'})
continue
# Get interfaces from Proxmox
try:
if vm.vm_type == 'qemu':
interfaces = client.get_vm_interfaces(vm.node, vm.vmid)
else:
interfaces = client.get_container_interfaces(vm.node, vm.vmid)
except Exception as e:
logger.debug(f"[IPAM link] Cannot get interfaces for {vm.name}/{vm.vmid}: {e}")
interfaces = []
# Find first IPv4 in one of this VM's applicable IPAM networks
ip_found, matching_network = _extract_primary_ipv4(interfaces, vm_networks)
if not ip_found:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'no_ip_in_ipam_network'})
continue
alloc, error = service.sync_from_proxmox_vm(
network_id=matching_network.id,
proxmox_server_id=server_id,
vmid=vm.vmid,
vm_name=vm.name,
vm_type=vm.vm_type,
ip_address=ip_found,
node=vm.node,
synced_by=synced_by
)
if alloc:
# Update the cached ip_address in vm_instances as well
vm.ip_address = ip_found
linked_pairs.add((server_id, vm.vmid)) # avoid double-linking
linked.append({
'ip_address': ip_found,
'resource_name': vm.name,
'vmid': vm.vmid,
'server_id': server_id
})
logger.info(f"[IPAM link] Linked {ip_found} -> VM {vm.vmid} ({vm.name}) on {server.name}")
else:
not_found.append({'ip_address': ip_found, 'resource_name': vm.name,
'reason': error or 'sync_error'})
logger.warning(f"[IPAM link] Failed {ip_found} for {vm.name}: {error}")
if linked: if linked:
db.commit() try:
db.commit()
except Exception as e:
db.rollback()
logger.error(f"[IPAM link] Commit failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
return { return {
'linked': linked, 'linked': linked,
'linked_count': len(linked), 'linked_count': len(linked),

View File

@@ -1710,6 +1710,26 @@ class I18nService:
"ru": "Proxmox Bridge", "ru": "Proxmox Bridge",
"en": "Proxmox Bridge" "en": "Proxmox Bridge"
}, },
"proxmox_node": {
"ru": "Нода Proxmox",
"en": "Proxmox Node"
},
"proxmox_node_hint": {
"ru": "Нода внутри кластера, к которой привязана сеть. Привязка будет учтена при поиске IP.",
"en": "Cluster node the network is associated with. Used for IP matching."
},
"all_nodes": {
"ru": "— Все ноды —",
"en": "— All nodes —"
},
"select_node": {
"ru": "— Выберите ноду —",
"en": "— Select node —"
},
"loading_nodes": {
"ru": "Загрузка нод...",
"en": "Loading nodes..."
},
"proxmox_server": { "proxmox_server": {
"ru": "Proxmox сервер", "ru": "Proxmox сервер",
"en": "Proxmox Server" "en": "Proxmox Server"
@@ -3606,6 +3626,26 @@ class I18nService:
"ru": "Привязать все несвязанные адреса к соответствующим VM по имени?", "ru": "Привязать все несвязанные адреса к соответствующим VM по имени?",
"en": "Link all unlinked allocations to matching VMs by name?" "en": "Link all unlinked allocations to matching VMs by name?"
}, },
"link_allocations_title": {
"ru": "Привязать IP к VM",
"en": "Link IPs to VMs"
},
"link_scope_hint": {
"ru": "Выберите сервер (ноду) и подсеть. Будут привязаны только ВМ выбранного сервера к выбранной подсети.",
"en": "Select a server (node) and subnet. Only VMs from the selected server will be linked to the selected subnet."
},
"select_server_for_link": {
"ru": "— Выберите сервер —",
"en": "— Select server —"
},
"select_network_first_server": {
"ru": "— Сначала выберите сервер —",
"en": "— Select server first —"
},
"no_networks_for_server": {
"ru": "Нет сетей для этого сервера",
"en": "No networks for this server"
},
"cleaning": { "cleaning": {
"ru": "Очистка", "ru": "Очистка",
"en": "Cleaning" "en": "Cleaning"

View File

@@ -512,6 +512,7 @@ class IPAMNetwork(Base):
# Привязка к Proxmox # Привязка к Proxmox
proxmox_server_id = Column(Integer, nullable=True, index=True) # FK to ProxmoxServer proxmox_server_id = Column(Integer, nullable=True, index=True) # FK to ProxmoxServer
proxmox_node = Column(String(100), nullable=True) # "pve1", "pve2" etc.
proxmox_bridge = Column(String(20), nullable=True) # "vmbr0" proxmox_bridge = Column(String(20), nullable=True) # "vmbr0"
# Метаданные # Метаданные

View File

@@ -359,6 +359,7 @@ class IPAMNetworkBase(BaseModel):
dns_secondary: Optional[str] = Field(None, description="Secondary DNS server") dns_secondary: Optional[str] = Field(None, description="Secondary DNS server")
dns_domain: Optional[str] = Field(None, max_length=255, description="DNS domain") dns_domain: Optional[str] = Field(None, max_length=255, description="DNS domain")
proxmox_server_id: Optional[int] = Field(None, description="Associated Proxmox server ID") proxmox_server_id: Optional[int] = Field(None, description="Associated Proxmox server ID")
proxmox_node: Optional[str] = Field(None, max_length=100, description="Proxmox node name (e.g., pve1)")
proxmox_bridge: Optional[str] = Field(None, max_length=20, description="Proxmox bridge (e.g., vmbr0)") proxmox_bridge: Optional[str] = Field(None, max_length=20, description="Proxmox bridge (e.g., vmbr0)")
is_active: bool = Field(default=True, description="Is network active") is_active: bool = Field(default=True, description="Is network active")
@@ -400,6 +401,7 @@ class IPAMNetworkUpdate(BaseModel):
dns_secondary: Optional[str] = None dns_secondary: Optional[str] = None
dns_domain: Optional[str] = Field(None, max_length=255) dns_domain: Optional[str] = Field(None, max_length=255)
proxmox_server_id: Optional[int] = None proxmox_server_id: Optional[int] = None
proxmox_node: Optional[str] = Field(None, max_length=100)
proxmox_bridge: Optional[str] = Field(None, max_length=20) proxmox_bridge: Optional[str] = Field(None, max_length=20)
is_active: Optional[bool] = None is_active: Optional[bool] = None

View File

@@ -1083,16 +1083,16 @@ body.update-in-progress .layout {
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.style.cssText = ` toast.style.cssText = `
position: fixed; top: 20px; right: 20px; min-width: 300px; max-width: 400px; position: fixed; top: 20px; right: 20px; min-width: 300px; max-width: 400px;
padding: 16px 20px; background: rgba(30, 30, 40, 0.95); border: 1px solid rgba(255,255,255,0.1); padding: 16px 20px; background: var(--bg-surface); border: 1px solid var(--border-default);
border-left: 4px solid ${borderColor}; border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); border-left: 4px solid ${borderColor}; border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.5);
z-index: 99999; animation: toastSlide 0.3s ease-out; z-index: 99999; animation: toastSlide 0.3s ease-out;
`; `;
toast.innerHTML = ` toast.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span style="font-weight: 600; color: #fff;">${title}</span> <span style="font-weight: 600; color: var(--text-primary);">${title}</span>
<button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: #888; cursor: pointer; font-size: 18px;">&times;</button> <button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 18px;">&times;</button>
</div> </div>
<div style="color: #aaa; font-size: 14px;">${msg || ''}</div> <div style="color: var(--text-secondary); font-size: 14px;">${msg || ''}</div>
`; `;
document.body.appendChild(toast); document.body.appendChild(toast);

View File

@@ -30,7 +30,7 @@
<div> <div>
<h2 id="instance-name" style="margin: 0; color: var(--text-primary);">{{ t('loading') }}</h2> <h2 id="instance-name" style="margin: 0; color: var(--text-primary);">{{ t('loading') }}</h2>
<div style="display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem;">
<span id="instance-type-badge" style="padding: 0.25rem 0.75rem; border-radius: 6px; background: var(--primary); color: white; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.5px;">QEMU</span> <span id="instance-type-badge" style="padding: 0.25rem 0.75rem; border-radius: 6px; background: var(--primary); color: var(--text-on-primary); font-size: 0.8rem; font-weight: 700; letter-spacing: 0.5px;">QEMU</span>
</div> </div>
<div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.35rem;">ID: {{ vmid }} | Node: {{ node }} | {{ server.name }}</div> <div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.35rem;">ID: {{ vmid }} | Node: {{ node }} | {{ server.name }}</div>
</div> </div>
@@ -79,7 +79,7 @@
<i class="fa-solid fa-compact-disc"></i> {{ t('reinstall') }} <i class="fa-solid fa-compact-disc"></i> {{ t('reinstall') }}
</button> </button>
<!-- HA Button --> <!-- HA Button -->
<button id="btn-ha" onclick="toggleHA()" class="btn" style="background: var(--purple); color: white; display: none;" title="{{ t('high_availability') or 'High Availability' }}" data-permission="vms.ha"> <button id="btn-ha" onclick="toggleHA()" class="btn" style="background: var(--purple); color: var(--text-on-primary); display: none;" title="{{ t('high_availability') or 'High Availability' }}" data-permission="vms.ha">
<span id="btn-ha-icon"><i class="fa-solid fa-shield"></i></span> <span id="btn-ha-text">HA</span> <span id="btn-ha-icon"><i class="fa-solid fa-shield"></i></span> <span id="btn-ha-text">HA</span>
</button> </button>
<button onclick="deleteInstance()" class="btn btn-danger" data-permission="vms.delete"> <button onclick="deleteInstance()" class="btn btn-danger" data-permission="vms.delete">
@@ -1218,7 +1218,7 @@ function showNotification(message, type = 'info') {
bottom: 2rem; bottom: 2rem;
right: 2rem; right: 2rem;
background: ${colors[type]}; background: ${colors[type]};
color: white; color: var(--text-on-primary);
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-radius: 8px; border-radius: 8px;
font-weight: 500; font-weight: 500;

View File

@@ -239,8 +239,8 @@ function renderAllocations(allocations) {
</td> </td>
<td style="padding: 0.75rem;"> <td style="padding: 0.75rem;">
${a.status === 'reserved' ? ${a.status === 'reserved' ?
`<span class="badge badge-info" style="color: #fff;"><i class="fa-solid fa-lock"></i> ${t('reserved')}</span>` : `<span class="badge badge-info"><i class="fa-solid fa-lock"></i> ${t('reserved')}</span>` :
`<span class="badge badge-success" style="color: #fff;">✓ ${t('allocated')}</span>`} `<span class="badge badge-success">✓ ${t('allocated')}</span>`}
</td> </td>
<td style="padding: 0.75rem; color: var(--text-muted); font-size: 0.875rem;">${a.created_at ? new Date(a.created_at).toLocaleDateString('ru-RU') : '-'}</td> <td style="padding: 0.75rem; color: var(--text-muted); font-size: 0.875rem;">${a.created_at ? new Date(a.created_at).toLocaleDateString('ru-RU') : '-'}</td>
<td style="padding: 0.75rem; text-align: right;"> <td style="padding: 0.75rem; text-align: right;">

View File

@@ -121,11 +121,19 @@
<div class="form-group"> <div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_server') }}</label> <label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_server') }}</label>
<select id="networkServer" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);"> <select id="networkServer" onchange="onNetworkServerChange()" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option> <option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>
</select> </select>
<small style="color: var(--text-muted);">{{ t('network_server_hint') }}</small> <small style="color: var(--text-muted);">{{ t('network_server_hint') }}</small>
</div> </div>
<div class="form-group" id="networkNodeRow" style="display: none;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_node') }}</label>
<select id="networkNode" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('all_nodes') }}</option>
</select>
<small style="color: var(--text-muted);">{{ t('proxmox_node_hint') }}</small>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group"> <div class="form-group">
@@ -304,6 +312,37 @@ async function loadServersForIPAM() {
} }
} }
async function onNetworkServerChange() {
const serverId = document.getElementById('networkServer').value;
const nodeRow = document.getElementById('networkNodeRow');
const nodeSelect = document.getElementById('networkNode');
if (!serverId) {
nodeRow.style.display = 'none';
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
return;
}
nodeRow.style.display = 'block';
nodeSelect.innerHTML = `<option value="">{{ t('loading_nodes') }}</option>`;
try {
const resp = await fetchWithAuth(`/proxmox/api/servers/${serverId}/cluster-info`);
const info = resp.ok ? await resp.json() : {};
const nodes = info.nodes || [];
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
nodes.forEach(n => {
nodeSelect.innerHTML += `<option value="${n}">${n}</option>`;
});
// If single-node server, pre-select it
if (nodes.length === 1) {
nodeSelect.value = nodes[0];
}
} catch(e) {
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
}
}
function showAddNetworkModal() { function showAddNetworkModal() {
document.getElementById('addNetworkModal').style.display = 'flex'; document.getElementById('addNetworkModal').style.display = 'flex';
loadServersForIPAM(); loadServersForIPAM();
@@ -312,12 +351,14 @@ function showAddNetworkModal() {
function closeAddNetworkModal() { function closeAddNetworkModal() {
document.getElementById('addNetworkModal').style.display = 'none'; document.getElementById('addNetworkModal').style.display = 'none';
document.getElementById('addNetworkForm').reset(); document.getElementById('addNetworkForm').reset();
document.getElementById('networkNodeRow').style.display = 'none';
} }
async function createNetwork(event) { async function createNetwork(event) {
event.preventDefault(); event.preventDefault();
const serverId = document.getElementById('networkServer').value; const serverId = document.getElementById('networkServer').value;
const nodeVal = document.getElementById('networkNode').value;
const data = { const data = {
name: document.getElementById('networkName').value, name: document.getElementById('networkName').value,
@@ -326,6 +367,7 @@ async function createNetwork(event) {
vlan_id: document.getElementById('networkVLAN').value ? parseInt(document.getElementById('networkVLAN').value) : null, vlan_id: document.getElementById('networkVLAN').value ? parseInt(document.getElementById('networkVLAN').value) : null,
proxmox_bridge: document.getElementById('networkBridge').value || null, proxmox_bridge: document.getElementById('networkBridge').value || null,
proxmox_server_id: serverId ? parseInt(serverId) : null, proxmox_server_id: serverId ? parseInt(serverId) : null,
proxmox_node: nodeVal || null,
dns_primary: document.getElementById('networkDNS1').value || null, dns_primary: document.getElementById('networkDNS1').value || null,
dns_secondary: document.getElementById('networkDNS2').value || null, dns_secondary: document.getElementById('networkDNS2').value || null,
dns_domain: document.getElementById('networkDomain').value || null, dns_domain: document.getElementById('networkDomain').value || null,
@@ -502,23 +544,93 @@ async function cleanupOrphans() {
} }
async function linkAllocations() { async function linkAllocations() {
const confirmed = await showDeleteConfirm( await showLinkModal();
'{{ t("link_to_vms") }}', }
'{{ t("confirm_link_allocations") }}'
); // ---- Link Allocations Modal ----
if (!confirmed) return; let _linkServers = [];
const btn = document.querySelector('#maintenanceModal .btn-primary'); async function showLinkModal() {
btn.disabled = true; document.getElementById('linkAllocationsModal').style.display = 'flex';
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t("linking") }}...'; const serverSel = document.getElementById('linkServerSelect');
const networkSel = document.getElementById('linkNetworkSelect');
serverSel.innerHTML = `<option value="">{{ t('loading_ellipsis') }}</option>`;
networkSel.innerHTML = `<option value="">{{ t('select_network_first_server') }}</option>`;
networkSel.disabled = true;
document.getElementById('submitLinkBtn').disabled = true;
try { try {
const response = await fetchWithAuth('/ipam/api/link-allocations', { method: 'POST' }); const resp = await fetchWithAuth('/proxmox/api/servers');
_linkServers = resp.ok ? await resp.json() : [];
} catch(e) { _linkServers = []; }
serverSel.innerHTML = `<option value="">{{ t('select_server_for_link') }}</option>`;
_linkServers.forEach(s => {
serverSel.innerHTML += `<option value="${s.id}">${s.name} (${s.ip_address})</option>`;
});
}
function closeLinkModal() {
document.getElementById('linkAllocationsModal').style.display = 'none';
}
async function onLinkServerChange() {
const serverId = document.getElementById('linkServerSelect').value;
const networkSel = document.getElementById('linkNetworkSelect');
const submitBtn = document.getElementById('submitLinkBtn');
networkSel.disabled = true;
submitBtn.disabled = true;
if (!serverId) {
networkSel.innerHTML = `<option value="">{{ t('select_network_first_server') }}</option>`;
return;
}
networkSel.innerHTML = `<option value="">{{ t('loading_ellipsis') }}</option>`;
try {
const resp = await fetchWithAuth(`/ipam/api/networks?proxmox_server_id=${serverId}`);
const data = resp.ok ? await resp.json() : [];
if (!data.length) {
networkSel.innerHTML = `<option value="">{{ t('no_networks_for_server') }}</option>`;
return;
}
networkSel.innerHTML = `<option value="">{{ t('select_network') }}</option>`;
data.forEach(n => {
networkSel.innerHTML += `<option value="${n.id}">${n.name} (${n.network})</option>`;
});
networkSel.disabled = false;
} catch(e) {
networkSel.innerHTML = `<option value="">{{ t('error') }}</option>`;
}
}
function onLinkNetworkChange() {
const networkSel = document.getElementById('linkNetworkSelect');
document.getElementById('submitLinkBtn').disabled = !networkSel.value;
}
async function submitLinkAllocations() {
const serverId = document.getElementById('linkServerSelect').value;
const networkId = document.getElementById('linkNetworkSelect').value;
if (!serverId || !networkId) return;
const submitBtn = document.getElementById('submitLinkBtn');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t("linking") }}...';
try {
const response = await fetchWithAuth('/ipam/api/link-allocations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_id: parseInt(serverId), network_id: parseInt(networkId) })
});
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
closeLinkModal();
showToast('success', '{{ t("success") }}', `{{ t("linked") }}: ${data.linked_count}, {{ t("not_found") }}: ${data.not_found_count}`); showToast('success', '{{ t("success") }}', `{{ t("linked") }}: ${data.linked_count}, {{ t("not_found") }}: ${data.not_found_count}`);
loadMaintenanceData(); loadMaintenanceData();
loadData(); // Refresh main data loadData();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast('error', '{{ t("error") }}', error.detail || '{{ t("linking_failed") }}'); showToast('error', '{{ t("error") }}', error.detail || '{{ t("linking_failed") }}');
@@ -527,8 +639,8 @@ async function linkAllocations() {
console.error('Error linking allocations:', e); console.error('Error linking allocations:', e);
showToast('error', '{{ t("error") }}', '{{ t("linking_failed") }}'); showToast('error', '{{ t("error") }}', '{{ t("linking_failed") }}');
} finally { } finally {
btn.disabled = false; submitBtn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-link"></i> {{ t("link_to_vms") }}'; submitBtn.innerHTML = '<i class="fa-solid fa-link"></i> {{ t("link_to_vms") }}';
} }
} }
@@ -538,6 +650,37 @@ window.addEventListener('DOMContentLoaded', () => {
}); });
</script> </script>
<!-- Link Allocations Modal -->
<div id="linkAllocationsModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 3000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 480px; width: 95%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-link"></i> {{ t('link_allocations_title') }}</h3>
<button onclick="closeLinkModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1.25rem;">{{ t('link_scope_hint') }}</p>
<div style="display: grid; gap: 1rem; margin-bottom: 1.5rem;">
<div>
<label style="display: block; margin-bottom: 0.4rem; font-size: 0.85rem; color: var(--text-secondary);">{{ t('proxmox_server') }} *</label>
<select id="linkServerSelect" onchange="onLinkServerChange()" style="width: 100%; padding: 0.65rem 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('select_server_for_link') }}</option>
</select>
</div>
<div>
<label style="display: block; margin-bottom: 0.4rem; font-size: 0.85rem; color: var(--text-secondary);">{{ t('network') }} *</label>
<select id="linkNetworkSelect" onchange="onLinkNetworkChange()" disabled style="width: 100%; padding: 0.65rem 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('select_network_first_server') }}</option>
</select>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 0.75rem;">
<button onclick="closeLinkModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
<button onclick="submitLinkAllocations()" id="submitLinkBtn" disabled class="btn btn-primary">
<i class="fa-solid fa-link"></i> {{ t('link_to_vms') }}
</button>
</div>
</div>
</div>
<!-- Maintenance Modal --> <!-- Maintenance Modal -->
<div id="maintenanceModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;"> <div id="maintenanceModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 800px; width: 95%; max-height: 90vh; overflow-y: auto;"> <div class="card" style="max-width: 800px; width: 95%; max-height: 90vh; overflow-y: auto;">

View File

@@ -337,11 +337,11 @@ async function loadIPMap() {
function getIPColor(status) { function getIPColor(status) {
switch(status) { switch(status) {
case 'available': return 'rgba(34, 197, 94, 0.2)'; case 'available': return 'var(--success-light)';
case 'allocated': return 'rgba(59, 130, 246, 0.3)'; case 'allocated': return 'var(--info-light)';
case 'reserved': return 'rgba(245, 158, 11, 0.3)'; case 'reserved': return 'var(--warning-light)';
case 'gateway': return 'rgba(139, 92, 246, 0.3)'; case 'gateway': return 'var(--purple-light)';
case 'conflict': return 'rgba(239, 68, 68, 0.3)'; case 'conflict': return 'var(--danger-light)';
default: return 'var(--bg-tertiary)'; default: return 'var(--bg-tertiary)';
} }
} }
@@ -392,7 +392,7 @@ async function loadAllocations() {
<td style="padding: 0.5rem; color: var(--text-secondary);">${alloc.resource_type || '-'}</td> <td style="padding: 0.5rem; color: var(--text-secondary);">${alloc.resource_type || '-'}</td>
<td style="padding: 0.5rem; color: var(--text-secondary);">${alloc.hostname || '-'}</td> <td style="padding: 0.5rem; color: var(--text-secondary);">${alloc.hostname || '-'}</td>
<td style="padding: 0.5rem;"> <td style="padding: 0.5rem;">
<span style="padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; background: ${getStatusColor(alloc.status)}; color: #fff;"> <span style="padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; background: ${getStatusColor(alloc.status)}; color: ${getStatusTextColor(alloc.status)}; font-weight: 600;">
${alloc.status} ${alloc.status}
</span> </span>
</td> </td>
@@ -412,12 +412,20 @@ async function loadAllocations() {
function getStatusColor(status) { function getStatusColor(status) {
switch(status) { switch(status) {
case 'allocated': return 'rgba(59, 130, 246, 0.3)'; case 'allocated': return 'var(--info-light)';
case 'reserved': return 'rgba(245, 158, 11, 0.3)'; case 'reserved': return 'var(--warning-light)';
case 'conflict': return 'rgba(239, 68, 68, 0.3)'; case 'conflict': return 'var(--danger-light)';
default: return 'var(--bg-tertiary)'; default: return 'var(--bg-tertiary)';
} }
} }
function getStatusTextColor(status) {
switch(status) {
case 'allocated': return 'var(--info)';
case 'reserved': return 'var(--warning)';
case 'conflict': return 'var(--danger)';
default: return 'var(--text-secondary)';
}
}
// Modal functions // Modal functions
function showAllocateModal() { function showAllocateModal() {
@@ -591,7 +599,7 @@ async function showIPDetail(ip) {
<div style="font-family: monospace; color: var(--text-primary);">${alloc.ip_address}</div> <div style="font-family: monospace; color: var(--text-primary);">${alloc.ip_address}</div>
<div style="color: var(--text-muted);">${t('status')}:</div> <div style="color: var(--text-muted);">${t('status')}:</div>
<div><span style="padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.875rem; background: ${getStatusColor(alloc.status)}; color: #fff;">${alloc.status}</span></div> <div><span style="padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.875rem; background: ${getStatusColor(alloc.status)}; color: ${getStatusTextColor(alloc.status)}; font-weight: 600;">${alloc.status}</span></div>
<div style="color: var(--text-muted);">${t('resource_type')}:</div> <div style="color: var(--text-muted);">${t('resource_type')}:</div>
<div style="color: var(--text-primary);">${alloc.resource_type || '-'}</div> <div style="color: var(--text-primary);">${alloc.resource_type || '-'}</div>

View File

@@ -63,10 +63,21 @@
<input type="text" id="networkBridge" placeholder="vmbr0" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);"> <input type="text" id="networkBridge" placeholder="vmbr0" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label> <label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_server') }}</label>
<textarea id="networkDescription" rows="2" placeholder="{{ t('description_placeholder') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea> <select id="networkServer" onchange="onNetworkServerChange()" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>
</select>
<small style="color: var(--text-muted);">{{ t('network_server_hint') }}</small>
</div>
<div class="form-group" id="networkNodeRow" style="display: none;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_node') }}</label>
<select id="networkNode" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('all_nodes') }}</option>
</select>
<small style="color: var(--text-muted);">{{ t('proxmox_node_hint') }}</small>
</div> </div>
</div> </div>
@@ -158,11 +169,52 @@ function getUtilizationColor(percent) {
function showAddNetworkModal() { function showAddNetworkModal() {
document.getElementById('addNetworkModal').style.display = 'flex'; document.getElementById('addNetworkModal').style.display = 'flex';
loadServersForIPAM();
} }
function closeAddNetworkModal() { function closeAddNetworkModal() {
document.getElementById('addNetworkModal').style.display = 'none'; document.getElementById('addNetworkModal').style.display = 'none';
document.getElementById('addNetworkForm').reset(); document.getElementById('addNetworkForm').reset();
document.getElementById('networkNodeRow').style.display = 'none';
}
let ipamServers = [];
async function loadServersForIPAM() {
try {
const response = await fetchWithAuth('/proxmox/api/servers');
if (response.ok) {
ipamServers = await response.json();
const select = document.getElementById('networkServer');
select.innerHTML = `<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>`;
ipamServers.forEach(s => {
select.innerHTML += `<option value="${s.id}">${s.name} (${s.ip_address})</option>`;
});
}
} catch(e) { console.error('Error loading servers:', e); }
}
async function onNetworkServerChange() {
const serverId = document.getElementById('networkServer').value;
const nodeRow = document.getElementById('networkNodeRow');
const nodeSelect = document.getElementById('networkNode');
if (!serverId) {
nodeRow.style.display = 'none';
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
return;
}
nodeRow.style.display = 'block';
nodeSelect.innerHTML = `<option value="">{{ t('loading_nodes') }}</option>`;
try {
const resp = await fetchWithAuth(`/proxmox/api/servers/${serverId}/cluster-info`);
const info = resp.ok ? await resp.json() : {};
const nodes = info.nodes || [];
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
nodes.forEach(n => { nodeSelect.innerHTML += `<option value="${n}">${n}</option>`; });
if (nodes.length === 1) nodeSelect.value = nodes[0];
} catch(e) {
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
}
} }
async function createNetwork(event) { async function createNetwork(event) {
@@ -174,6 +226,8 @@ async function createNetwork(event) {
gateway: document.getElementById('networkGateway').value || null, gateway: document.getElementById('networkGateway').value || null,
vlan_id: document.getElementById('networkVLAN').value ? parseInt(document.getElementById('networkVLAN').value) : null, vlan_id: document.getElementById('networkVLAN').value ? parseInt(document.getElementById('networkVLAN').value) : null,
proxmox_bridge: document.getElementById('networkBridge').value || null, proxmox_bridge: document.getElementById('networkBridge').value || null,
proxmox_server_id: document.getElementById('networkServer').value ? parseInt(document.getElementById('networkServer').value) : null,
proxmox_node: document.getElementById('networkNode').value || null,
description: document.getElementById('networkDescription').value || null description: document.getElementById('networkDescription').value || null
}; };

View File

@@ -150,7 +150,7 @@
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> <div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeModal('autoImportModal')" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button> <button type="button" onclick="closeModal('autoImportModal')" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
<button type="button" onclick="previewAutoImport()" id="previewImportBtn" class="btn" style="background: var(--info); color: white;"><i class="fa-solid fa-eye"></i> {{ t('preview') }}</button> <button type="button" onclick="previewAutoImport()" id="previewImportBtn" class="btn" style="background: var(--info); color: var(--text-on-info);"><i class="fa-solid fa-eye"></i> {{ t('preview') }}</button>
<button type="button" onclick="executeAutoImport()" id="executeImportBtn" class="btn btn-primary" style="display: none;"><i class="fa-solid fa-rocket"></i> {{ t('import') }}</button> <button type="button" onclick="executeAutoImport()" id="executeImportBtn" class="btn btn-primary" style="display: none;"><i class="fa-solid fa-rocket"></i> {{ t('import') }}</button>
</div> </div>
</div> </div>
@@ -702,7 +702,7 @@ function renderTemplates(templates) {
<span style="color: var(--text-muted); margin-left: 0.5rem;">→</span> <span style="color: var(--text-muted); margin-left: 0.5rem;">→</span>
<span style="color: var(--info); font-weight: 500; margin-left: 0.5rem;"><i class="fa-solid fa-cube"></i> ${group.node}</span> <span style="color: var(--info); font-weight: 500; margin-left: 0.5rem;"><i class="fa-solid fa-cube"></i> ${group.node}</span>
</div> </div>
<span class="badge" style="background: var(--primary); color: white; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem;"> <span class="badge" style="background: var(--primary); color: var(--text-on-primary); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem;">
${group.templates.length} ${i18n.templates_count} ${group.templates.length} ${i18n.templates_count}
</span> </span>
</div> </div>
@@ -942,7 +942,7 @@ async function executeAutoImport() {
html += `<div style="margin-bottom: 0.5rem;"> html += `<div style="margin-bottom: 0.5rem;">
<span style="font-weight: bold;">${getGroupIcon(groupName)} ${groupName}:</span>`; <span style="font-weight: bold;">${getGroupIcon(groupName)} ${groupName}:</span>`;
templates.forEach(tpl => { templates.forEach(tpl => {
html += ` <span style="background: var(--success); color: white; padding: 0.125rem 0.5rem; border-radius: 1rem; font-size: 0.75rem; margin-left: 0.25rem;">${tpl.name} (#${tpl.sort_order})</span>`; html += ` <span style="background: var(--success); color: var(--text-on-success); padding: 0.125rem 0.5rem; border-radius: 1rem; font-size: 0.75rem; margin-left: 0.25rem;">${tpl.name} (#${tpl.sort_order})</span>`;
}); });
html += '</div>'; html += '</div>';
} }

View File

@@ -67,13 +67,13 @@
</td> </td>
<td style="text-align: right;"> <td style="text-align: right;">
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> <div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button onclick="testConnection({{ server.id }})" class="btn btn-sm" style="background: var(--success); color: white;" title="{{ t('test') }}"> <button onclick="testConnection({{ server.id }})" class="btn btn-sm" style="background: var(--success); color: var(--text-on-success);" title="{{ t('test') }}">
<i class="fa-solid fa-plug"></i> {{ t('test') }} <i class="fa-solid fa-plug"></i> {{ t('test') }}
</button> </button>
<button onclick="window.location.href='/proxmox/server/{{ server.id }}'" class="btn btn-sm" style="background: var(--info); color: white;" title="{{ t('details') }}"> <button onclick="window.location.href='/proxmox/server/{{ server.id }}'" class="btn btn-sm" style="background: var(--info); color: var(--text-on-info);" title="{{ t('details') }}">
<i class="fa-solid fa-chart-simple"></i> {{ t('details') }} <i class="fa-solid fa-chart-simple"></i> {{ t('details') }}
</button> </button>
<button onclick="editServer({{ server.id }})" class="btn btn-sm" style="background: var(--warning); color: white;" title="{{ t('edit') }}" data-permission="proxmox.servers.edit"> <button onclick="editServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm" style="background: var(--warning); color: var(--text-on-warning);" title="{{ t('edit') }}" data-permission="proxmox.servers.edit">
<i class="fa-solid fa-pen"></i> <i class="fa-solid fa-pen"></i>
</button> </button>
<button onclick="deleteServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm btn-delete" title="{{ t('delete') }}" data-permission="proxmox.servers.delete"> <button onclick="deleteServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm btn-delete" title="{{ t('delete') }}" data-permission="proxmox.servers.delete">
@@ -160,17 +160,17 @@
} }
.status-badge.status-online { .status-badge.status-online {
background: rgba(34, 197, 94, 0.15); background: var(--success-light);
color: var(--success); color: var(--success);
} }
.status-badge.status-offline { .status-badge.status-offline {
background: rgba(239, 68, 68, 0.15); background: var(--danger-light);
color: var(--danger); color: var(--danger);
} }
.status-badge.status-loading { .status-badge.status-loading {
background: rgba(156, 163, 175, 0.15); background: var(--bg-surface-hover);
color: var(--text-muted); color: var(--text-muted);
} }
</style> </style>

View File

@@ -259,12 +259,12 @@
.vm-type-badge.qemu { .vm-type-badge.qemu {
background: var(--info); background: var(--info);
color: white; color: var(--text-on-info);
} }
.vm-type-badge.lxc { .vm-type-badge.lxc {
background: var(--purple); background: var(--purple);
color: white; color: var(--text-on-primary);
} }
.vm-cluster-link { .vm-cluster-link {
@@ -438,7 +438,7 @@
gap: 1rem; gap: 1rem;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--primary); background: var(--primary);
color: white; color: var(--text-on-primary);
animation: slideDown 0.2s ease; animation: slideDown 0.2s ease;
} }
@@ -463,7 +463,7 @@
border: 1px solid rgba(255,255,255,0.3); border: 1px solid rgba(255,255,255,0.3);
border-radius: 6px; border-radius: 6px;
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
color: white; color: var(--text-on-primary);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
transition: all 0.15s ease; transition: all 0.15s ease;
@@ -486,7 +486,7 @@
.bulk-actions-bar .cancel-btn { .bulk-actions-bar .cancel-btn {
background: none; background: none;
border: none; border: none;
color: white; color: var(--text-on-primary);
cursor: pointer; cursor: pointer;
padding: 0.25rem; padding: 0.25rem;
opacity: 0.8; opacity: 0.8;
@@ -2830,7 +2830,7 @@ async function loadCreateLXCForm() {
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);"> <div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<button type="button" onclick="closeCreateLXCModal()" class="btn btn-secondary">{{ t('cancel') }}</button> <button type="button" onclick="closeCreateLXCModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
<button type="submit" id="createLXCBtn" class="btn" style="background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); color: white;"><i class="fa-solid fa-cube"></i> {{ t('create') }}</button> <button type="submit" id="createLXCBtn" class="btn" style="background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); color: var(--text-on-primary);"><i class="fa-solid fa-cube"></i> {{ t('create') }}</button>
</div> </div>
</form> </form>
`; `;
@@ -3635,7 +3635,7 @@ window.addEventListener('beforeunload', () => {
.badge-info { .badge-info {
background: var(--info); background: var(--info);
color: white; color: var(--text-on-info);
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
@@ -3782,7 +3782,7 @@ window.addEventListener('beforeunload', () => {
.snapshot-queue-indicator .badge { .snapshot-queue-indicator .badge {
background: var(--primary); background: var(--primary);
color: white; color: var(--text-on-primary);
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
border-radius: 10px; border-radius: 10px;
font-size: 0.75rem; font-size: 0.75rem;

View File

@@ -1035,6 +1035,20 @@ def migrate_backup_jobs(conn):
logger.info("✓ backup_jobs table created") logger.info("✓ backup_jobs table created")
# ==================== Migration 16: IPAM Network Node ====================
def migrate_ipam_network_node(conn):
"""Add proxmox_node column to ipam_networks table"""
if not table_exists(conn, 'ipam_networks'):
logger.info("Table ipam_networks does not exist, skipping")
return
if add_column_if_not_exists(conn, 'ipam_networks', 'proxmox_node', 'VARCHAR(100)'):
logger.info("✓ Added proxmox_node column to ipam_networks")
else:
logger.info("✓ ipam_networks.proxmox_node already exists")
def run_all_migrations(engine, db_session=None): def run_all_migrations(engine, db_session=None):
""" """
Run all migrations in order. Run all migrations in order.
@@ -1169,6 +1183,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"Backup jobs migration: {e}") logger.warning(f"Backup jobs migration: {e}")
conn.rollback() conn.rollback()
# Migration 16: IPAM Network Node
try:
migrate_ipam_network_node(conn)
conn.commit()
except Exception as e:
logger.warning(f"IPAM network node migration: {e}")
conn.rollback()
logger.info("=" * 50) logger.info("=" * 50)
logger.info("✓ All migrations completed successfully") logger.info("✓ All migrations completed successfully")
logger.info("=" * 50) logger.info("=" * 50)