Build Proxmox visualizer app

This commit is contained in:
2026-06-07 09:30:11 +02:00
commit fa08851041
33 changed files with 2402 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Configuration de l'API FastAPI
API_ENV=development
API_PORT=8000
FRONTEND_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
FRONTEND_ORIGIN_REGEX=^https?://(localhost|127\.0\.0\.1|10\.0\.[0-3]\.[0-9]{1,3})(:[0-9]+)?$
PROXMOX_VERIFY_SSL=false
PROXMOX_TIMEOUT=15
# Liste des serveurs Proxmox au format JSON
# Format: [{"name": "Cluster-A", "url": "https://10.0.0.1:8006", "token_name": "api-visualizer@pve!visualizer", "token_value": "uuid-du-token"}]
PROXMOX_SERVERS='[]'
@@ -0,0 +1,19 @@
from fastapi import APIRouter
from app.core.config import settings
from app.models.schemas import ServerInfo
from app.services.scanner import parse_server_configs
router = APIRouter(prefix="/proxmox", tags=["proxmox"])
@router.get("/servers", response_model=list[ServerInfo])
async def list_servers() -> list[ServerInfo]:
servers, _ = parse_server_configs(settings.parsed_servers)
return [
ServerInfo(
name=server.name,
url=str(server.url).rstrip("/"),
)
for server in servers
]
@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.core.config import settings
from app.models.schemas import ScanResponse
from app.services.scanner import scan_servers
router = APIRouter(prefix="/vms", tags=["vms"])
@router.get("/scan", response_model=ScanResponse)
async def scan_vms() -> ScanResponse:
return await scan_servers(settings.parsed_servers)
@@ -0,0 +1,7 @@
from fastapi import APIRouter
from app.api.endpoints import proxmox, vms
api_router = APIRouter(prefix="/api")
api_router.include_router(proxmox.router)
api_router.include_router(vms.router)
@@ -0,0 +1,40 @@
import json
from pathlib import Path
from typing import List, Dict, Any
from pydantic_settings import BaseSettings
BACKEND_DIR = Path(__file__).resolve().parents[2]
class Settings(BaseSettings):
API_ENV: str = "development"
API_PORT: int = 8000
PROXMOX_SERVERS: str = "[]"
PROXMOX_VERIFY_SSL: bool = False
PROXMOX_TIMEOUT: float = 15.0
FRONTEND_ORIGINS: str = "http://localhost:5173,http://127.0.0.1:5173"
FRONTEND_ORIGIN_REGEX: str = (
r"^https?://(localhost|127\.0\.0\.1|10\.0\.[0-3]\.[0-9]{1,3})(:[0-9]+)?$"
)
@property
def parsed_servers(self) -> List[Dict[str, Any]]:
try:
servers = json.loads(self.PROXMOX_SERVERS)
except json.JSONDecodeError:
return []
if not isinstance(servers, list):
return []
return [server for server in servers if isinstance(server, dict)]
@property
def frontend_origins(self) -> List[str]:
return [
origin.strip()
for origin in self.FRONTEND_ORIGINS.split(",")
if origin.strip()
]
class Config:
env_file = str(BACKEND_DIR / ".env")
settings = Settings()
+30
View File
@@ -0,0 +1,30 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import settings
app = FastAPI(
title="Proxmox Duplicate Visualizer API",
description="Backend de scan et de détection de doublons pour Proxmox VE",
version="1.0.0"
)
# Configuration CORS pour le Frontend
app.add_middleware(
CORSMiddleware,
allow_origins=settings.frontend_origins,
allow_origin_regex=settings.FRONTEND_ORIGIN_REGEX,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/")
async def root():
return {
"status": "online",
"message": "Proxmox Visualizer API is running",
"configured_servers": len(settings.parsed_servers)
}
@@ -0,0 +1,58 @@
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field, HttpUrl
class ProxmoxServerConfig(BaseModel):
name: str
url: HttpUrl
token_name: str
token_value: str = Field(repr=False)
class ServerInfo(BaseModel):
name: str
url: str
configured: bool = True
class ServerScanStatus(BaseModel):
name: str
url: str
ok: bool
vm_count: int = 0
errors: List[str] = Field(default_factory=list)
class VmRecord(BaseModel):
id: str
server: str
node: str
vmid: int
type: Literal["qemu", "lxc"]
name: str
status: Optional[str] = None
cpu: Optional[float] = None
mem: Optional[int] = None
maxmem: Optional[int] = None
disk: Optional[int] = None
maxdisk: Optional[int] = None
uptime: Optional[int] = None
tags: Optional[str] = None
duplicate_id: Optional[int] = None
description_available: bool = False
raw: Dict[str, Any] = Field(default_factory=dict)
class DuplicateGroup(BaseModel):
id: str
label: str
reason: Literal["name", "metadata"]
count: int
items: List[VmRecord]
class ScanResponse(BaseModel):
servers: List[ServerScanStatus]
items: List[VmRecord]
duplicates: List[DuplicateGroup]
@@ -0,0 +1,33 @@
import re
from typing import Optional
MARKER_START = "--- [prox-visualizer-metadata] ---"
def extract_duplicate_id(description: Optional[str]) -> Optional[int]:
"""Extrait l'ID de doublon caché dans la note Proxmox via Regex."""
if not description:
return None
# Cherche la clé duplicate_id=chiffres
match = re.search(r'duplicate_id=(\d+)', description)
if match:
return int(match.group(1))
return None
def inject_duplicate_id(description: Optional[str], duplicate_id: int) -> str:
"""
Insère ou met à jour l'ID de doublon à la fin de la note existante,
sans effacer les notes déjà écrites par l'utilisateur.
"""
clean_desc = ""
if description:
# Si un ancien bloc de métadonnées existe, on le retire pour repartir sur du propre
if MARKER_START in description:
clean_desc = description.split(MARKER_START)[0].strip()
else:
clean_desc = description.strip()
# Génération du nouveau bloc de métadonnées
metadata_block = f"\n\n{MARKER_START}\nduplicate_id={duplicate_id}"
return f"{clean_desc}{metadata_block}".strip()
@@ -0,0 +1,163 @@
from typing import Any, Dict, List, Optional
import httpx
from app.core.config import settings
from app.models.schemas import ProxmoxServerConfig, ServerScanStatus, VmRecord
from app.services.metadata import extract_duplicate_id
class ProxmoxClient:
def __init__(self, server: ProxmoxServerConfig):
self.server = server
self.base_url = str(server.url).rstrip("/")
self.errors: List[str] = []
async def __aenter__(self) -> "ProxmoxClient":
self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api2/json",
headers={
"Authorization": (
f"PVEAPIToken={self.server.token_name}={self.server.token_value}"
)
},
timeout=settings.PROXMOX_TIMEOUT,
verify=settings.PROXMOX_VERIFY_SSL,
)
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.client.aclose()
async def scan(self) -> tuple[ServerScanStatus, List[VmRecord]]:
items = await self._scan_nodes()
if not items:
fallback_items = await self._scan_cluster_resources()
items.extend(fallback_items)
if not items and await self._has_no_effective_permissions():
self.errors.append(
"permissions effectives vides pour ce token; Proxmox filtre probablement les VM/LXC"
)
status = ServerScanStatus(
name=self.server.name,
url=self.base_url,
ok=not self.errors or bool(items),
vm_count=len(items),
errors=self.errors,
)
return status, items
async def _has_no_effective_permissions(self) -> bool:
permissions = await self._get_data("/access/permissions?path=/", optional=True)
if not isinstance(permissions, dict):
return False
if not permissions:
return True
return all(not value for value in permissions.values())
async def _get_data(self, path: str, *, optional: bool = False) -> Optional[Any]:
try:
response = await self.client.get(path)
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as exc:
message = self._format_http_error(path, exc)
if optional and exc.response.status_code in {401, 403, 404}:
return None
self.errors.append(message)
return None
except httpx.HTTPError as exc:
self.errors.append(f"{path}: {exc}")
return None
except ValueError:
self.errors.append(f"{path}: réponse JSON invalide")
return None
return payload.get("data")
def _format_http_error(self, path: str, exc: httpx.HTTPStatusError) -> str:
detail = exc.response.text.strip()
if len(detail) > 160:
detail = f"{detail[:157]}..."
return f"{path}: HTTP {exc.response.status_code} {detail}".strip()
async def _scan_nodes(self) -> List[VmRecord]:
nodes = await self._get_data("/nodes")
if not isinstance(nodes, list):
return []
items: List[VmRecord] = []
for node in nodes:
node_name = node.get("node")
if not node_name:
continue
items.extend(await self._scan_node_vms(node_name, "qemu"))
items.extend(await self._scan_node_vms(node_name, "lxc"))
return items
async def _scan_node_vms(self, node: str, vm_type: str) -> List[VmRecord]:
path = f"/nodes/{node}/{vm_type}"
records = await self._get_data(path)
if not isinstance(records, list):
return []
items = []
for record in records:
vmid = record.get("vmid")
if vmid is None:
continue
config = await self._get_data(
f"{path}/{vmid}/config",
optional=True,
)
items.append(self._to_vm_record(node, vm_type, record, config))
return items
async def _scan_cluster_resources(self) -> List[VmRecord]:
resources = await self._get_data("/cluster/resources?type=vm")
if not isinstance(resources, list):
return []
items = []
for resource in resources:
vm_type = resource.get("type")
if vm_type not in {"qemu", "lxc"}:
continue
node = resource.get("node") or "unknown"
items.append(self._to_vm_record(node, vm_type, resource, None))
return items
def _to_vm_record(
self,
node: str,
vm_type: str,
record: Dict[str, Any],
config: Optional[Dict[str, Any]],
) -> VmRecord:
vmid = int(record["vmid"])
description = None
tags = record.get("tags")
if isinstance(config, dict):
description = config.get("description")
tags = config.get("tags", tags)
return VmRecord(
id=f"{self.server.name}:{node}:{vm_type}:{vmid}",
server=self.server.name,
node=node,
vmid=vmid,
type=vm_type,
name=record.get("name") or record.get("hostname") or f"{vm_type}-{vmid}",
status=record.get("status"),
cpu=record.get("cpu"),
mem=record.get("mem"),
maxmem=record.get("maxmem"),
disk=record.get("disk"),
maxdisk=record.get("maxdisk"),
uptime=record.get("uptime"),
tags=tags,
duplicate_id=extract_duplicate_id(description),
description_available=description is not None,
raw=record,
)
@@ -0,0 +1,128 @@
import asyncio
import re
from collections import defaultdict
from typing import Dict, Iterable, List, Tuple
from pydantic import ValidationError
from app.models.schemas import (
DuplicateGroup,
ProxmoxServerConfig,
ScanResponse,
ServerScanStatus,
VmRecord,
)
from app.services.proxmox_api import ProxmoxClient
def parse_server_configs(raw_servers: Iterable[dict]) -> Tuple[List[ProxmoxServerConfig], List[str]]:
servers: List[ProxmoxServerConfig] = []
errors: List[str] = []
for index, raw_server in enumerate(raw_servers, start=1):
try:
servers.append(ProxmoxServerConfig.model_validate(raw_server))
except ValidationError as exc:
errors.append(f"serveur #{index}: {exc.errors()[0]['msg']}")
return servers, errors
async def scan_servers(raw_servers: Iterable[dict]) -> ScanResponse:
servers, config_errors = parse_server_configs(raw_servers)
if not servers:
return ScanResponse(
servers=[
ServerScanStatus(
name="configuration",
url="",
ok=False,
errors=config_errors or ["aucun serveur Proxmox configure"],
)
],
items=[],
duplicates=[],
)
results = await asyncio.gather(*[_scan_one(server) for server in servers])
statuses: List[ServerScanStatus] = []
items: List[VmRecord] = []
if config_errors:
statuses.append(
ServerScanStatus(
name="configuration",
url="",
ok=False,
errors=config_errors,
)
)
for status, server_items in results:
statuses.append(status)
items.extend(server_items)
return ScanResponse(
servers=statuses,
items=sorted(items, key=lambda vm: (vm.server, vm.node, vm.vmid)),
duplicates=find_duplicates(items),
)
async def _scan_one(server: ProxmoxServerConfig) -> tuple[ServerScanStatus, List[VmRecord]]:
async with ProxmoxClient(server) as client:
return await client.scan()
def find_duplicates(items: List[VmRecord]) -> List[DuplicateGroup]:
groups: List[DuplicateGroup] = []
seen_item_sets = set()
by_metadata: Dict[int, List[VmRecord]] = defaultdict(list)
for item in items:
if item.duplicate_id is not None:
by_metadata[item.duplicate_id].append(item)
for duplicate_id, members in sorted(by_metadata.items()):
if len(members) < 2:
continue
groups.append(
DuplicateGroup(
id=f"metadata:{duplicate_id}",
label=f"duplicate_id={duplicate_id}",
reason="metadata",
count=len(members),
items=members,
)
)
seen_item_sets.add(frozenset(item.id for item in members))
by_name: Dict[str, List[VmRecord]] = defaultdict(list)
for item in items:
normalized = normalize_name(item.name)
if normalized:
by_name[normalized].append(item)
for normalized, members in sorted(by_name.items()):
if len(members) < 2:
continue
item_set = frozenset(item.id for item in members)
if item_set in seen_item_sets:
continue
groups.append(
DuplicateGroup(
id=f"name:{normalized}",
label=members[0].name,
reason="name",
count=len(members),
items=members,
)
)
return groups
def normalize_name(name: str) -> str:
normalized = name.strip().lower()
normalized = re.sub(r"\s+", " ", normalized)
return normalized
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.34,<1.0
pydantic>=2.12,<3.0
pydantic-settings>=2.8,<3.0
httpx>=0.27,<1.0
python-dotenv>=1.0,<2.0