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:
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
# Метаданные
|
# Метаданные
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;">×</button>
|
<button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 18px;">×</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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
|||||||
@@ -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;">×</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;">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user