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
+12
View File
@@ -0,0 +1,12 @@
.env
.venv/
__pycache__/
*.py[cod]
node_modules/
dist/
.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+53
View File
@@ -0,0 +1,53 @@
# Prox Visualizer
Dashboard local pour inventorier les VM/LXC Proxmox et reperer les doublons par nom ou par metadata `duplicate_id` quand les notes sont accessibles.
## Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
Le fichier `backend/.env` attend une liste JSON:
```bash
PROXMOX_SERVERS='[
{
"name": "PVE-203",
"url": "https://10.0.3.203:8006",
"token_name": "api-visualizer@pve!visualizer-token",
"token_value": "secret-du-token"
}
]'
```
Avec des droits de lecture limites, le scan tente de lister les VM/LXC. La lecture des descriptions est optionnelle: si Proxmox refuse l'acces aux configs, les machines restent visibles mais `duplicate_id` reste vide.
## Frontend
```bash
cd frontend
npm install
npm run dev
```
Vite sert le dashboard sur `http://localhost:5173` et proxifie `/api` vers `http://127.0.0.1:8000`.
Pour rendre l'application disponible sur le reseau local:
```bash
./start.sh
```
Ouvre ensuite l'URL `Network` affichee par Vite, par exemple `http://10.0.3.x:5173/`.
## Routes utiles
- `GET /`: etat de l'API.
- `GET /api/proxmox/servers`: serveurs configures, sans secrets.
- `GET /api/vms/scan`: inventaire, etat des serveurs et groupes de doublons.
+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
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prox Visualizer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+947
View File
@@ -0,0 +1,947 @@
{
"name": "prox-visualizer-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prox-visualizer-frontend",
"version": "0.1.0",
"dependencies": {
"@vitejs/plugin-react": "^6.0.2",
"lucide-react": "^1.17.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"vite": "^8.0.16"
},
"devDependencies": {}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lucide-react": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.133.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.3",
"@rolldown/binding-darwin-arm64": "1.0.3",
"@rolldown/binding-darwin-x64": "1.0.3",
"@rolldown/binding-freebsd-x64": "1.0.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
"@rolldown/binding-linux-arm64-musl": "1.0.3",
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
"@rolldown/binding-linux-x64-gnu": "1.0.3",
"@rolldown/binding-linux-x64-musl": "1.0.3",
"@rolldown/binding-openharmony-arm64": "1.0.3",
"@rolldown/binding-wasm32-wasi": "1.0.3",
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.3",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "prox-visualizer-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev:lan": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"check": "vite build"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.2",
"vite": "^8.0.16",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^1.17.0"
},
"devDependencies": {}
}
+5
View File
@@ -0,0 +1,5 @@
import Dashboard from "./views/Dashboard.jsx";
export default function App() {
return <Dashboard />;
}
@@ -0,0 +1,74 @@
import DuplicateBadge from "./DuplicateBadge.jsx";
const bytesFormatter = new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 1,
});
function formatBytes(value) {
if (!value) return "-";
const units = ["o", "Ko", "Mo", "Go", "To"];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${bytesFormatter.format(size)} ${units[unitIndex]}`;
}
export default function DashboardTable({ items, duplicateIndex }) {
return (
<section className="panel inventory-panel">
<div className="panel-header">
<h2>Inventaire</h2>
<span>{items.length}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>VMID</th>
<th>Nom</th>
<th>Type</th>
<th>Serveur</th>
<th>Noeud</th>
<th>Etat</th>
<th>Memoire</th>
<th>Disque</th>
<th>Doublon</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const duplicate = duplicateIndex.get(item.id);
return (
<tr key={item.id}>
<td className="mono">{item.vmid}</td>
<td>
<div className="vm-name">
<strong>{item.name}</strong>
{item.tags && <span>{item.tags}</span>}
</div>
</td>
<td>{item.type === "qemu" ? "VM" : "LXC"}</td>
<td>{item.server}</td>
<td>{item.node}</td>
<td>
<span className={`status-pill ${item.status || "unknown"}`}>
{item.status || "-"}
</span>
</td>
<td>{formatBytes(item.maxmem || item.mem)}</td>
<td>{formatBytes(item.maxdisk || item.disk)}</td>
<td>
<DuplicateBadge reason={duplicate?.reason} count={duplicate?.count} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
}
@@ -0,0 +1,12 @@
export default function DuplicateBadge({ reason, count }) {
if (!reason) {
return <span className="badge badge-neutral">Unique</span>;
}
const label = reason === "metadata" ? "Metadata" : "Nom";
return (
<span className={`badge ${reason === "metadata" ? "badge-warn" : "badge-info"}`}>
{label} x{count}
</span>
);
}
@@ -0,0 +1,41 @@
import { AlertTriangle, CheckCircle2, Server } from "lucide-react";
export default function ServerStatus({ servers }) {
return (
<section className="panel">
<div className="panel-header">
<div className="title-row">
<Server size={18} aria-hidden="true" />
<h2>Serveurs</h2>
</div>
</div>
<div className="server-list">
{servers.map((server) => (
<div className="server-row" key={`${server.name}-${server.url}`}>
<div className="server-main">
{server.ok ? (
<CheckCircle2 className="ok" size={18} aria-hidden="true" />
) : (
<AlertTriangle className="warn" size={18} aria-hidden="true" />
)}
<div>
<strong>{server.name}</strong>
<span>{server.url}</span>
</div>
</div>
<span className="metric-value small">{server.vm_count}</span>
</div>
))}
</div>
{servers.some((server) => server.errors?.length) && (
<div className="error-list">
{servers.flatMap((server) =>
(server.errors ?? []).map((error) => (
<p key={`${server.name}-${error}`}>{server.name}: {error}</p>
)),
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from "react";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
export function useFetchVms() {
const [data, setData] = useState({ servers: [], items: [], duplicates: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [lastRefresh, setLastRefresh] = useState(null);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await fetch(`${API_BASE_URL}/api/vms/scan`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
setData({
servers: payload.servers ?? [],
items: payload.items ?? [],
duplicates: payload.duplicates ?? [],
});
setLastRefresh(new Date());
} catch (fetchError) {
setError(fetchError.message || "Erreur de chargement");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
return {
data,
loading,
error,
lastRefresh,
refresh,
};
}
+445
View File
@@ -0,0 +1,445 @@
:root {
color: #17211f;
background: #f5f7f6;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: #f5f7f6;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.72;
}
.app-shell {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 24px 0 40px;
}
.topbar,
.toolbar,
.panel-header,
.title-row,
.server-row,
.server-main,
.duplicate-row {
display: flex;
align-items: center;
}
.topbar {
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.eyebrow {
margin: 0 0 2px;
color: #66736f;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 1.7rem;
line-height: 1.15;
}
h2 {
font-size: 1rem;
}
.icon-button,
.segmented button {
border: 1px solid #cfd8d4;
background: #ffffff;
color: #17211f;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 14px;
border-radius: 8px;
font-weight: 700;
}
.icon-button.primary {
border-color: #1b7566;
background: #1b7566;
color: #ffffff;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.metric-card,
.panel,
.toolbar,
.notice {
border: 1px solid #d9e1de;
border-radius: 8px;
background: #ffffff;
}
.metric-card {
min-height: 84px;
padding: 14px;
}
.metric-card span,
.server-main span,
.duplicate-row span,
.refresh-time,
.vm-name span {
color: #66736f;
}
.metric-card span {
display: block;
margin-bottom: 8px;
font-size: 0.84rem;
}
.metric-card strong {
font-size: 1.7rem;
}
.toolbar {
justify-content: space-between;
gap: 12px;
min-height: 56px;
padding: 8px;
margin-bottom: 14px;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 180px;
height: 40px;
padding: 0 12px;
border: 1px solid #cfd8d4;
border-radius: 8px;
background: #fbfcfc;
}
.search-box input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #17211f;
}
.segmented {
display: grid;
grid-template-columns: repeat(2, minmax(86px, 1fr));
gap: 4px;
padding: 4px;
border: 1px solid #cfd8d4;
border-radius: 8px;
background: #eef3f1;
}
.segmented button {
min-height: 32px;
border-radius: 6px;
font-weight: 700;
}
.segmented button.active {
border-color: #ffffff;
background: #ffffff;
color: #1b7566;
}
.content-grid {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
gap: 14px;
margin-bottom: 14px;
}
.panel {
min-width: 0;
overflow: hidden;
}
.panel-header {
justify-content: space-between;
gap: 12px;
min-height: 50px;
padding: 0 14px;
border-bottom: 1px solid #e6ece9;
}
.title-row {
gap: 8px;
}
.server-list,
.duplicate-list {
display: grid;
}
.server-row,
.duplicate-row {
justify-content: space-between;
gap: 12px;
min-height: 58px;
padding: 10px 14px;
border-bottom: 1px solid #edf1ef;
}
.server-row:last-child,
.duplicate-row:last-child {
border-bottom: 0;
}
.server-main {
gap: 10px;
min-width: 0;
}
.server-main div,
.duplicate-row div {
min-width: 0;
}
.server-main strong,
.server-main span,
.duplicate-row strong,
.duplicate-row span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ok {
color: #18805f;
flex: 0 0 auto;
}
.warn {
color: #b56a00;
flex: 0 0 auto;
}
.error-list,
.notice.error {
color: #8c2f24;
background: #fff7f4;
}
.error-list {
display: grid;
gap: 6px;
padding: 12px 14px;
border-top: 1px solid #f1d5ce;
font-size: 0.9rem;
}
.notice {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
margin-bottom: 14px;
}
.metric-value {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 42px;
min-height: 32px;
padding: 0 8px;
border-radius: 6px;
background: #eef3f1;
font-weight: 800;
}
.metric-value.small {
font-size: 0.9rem;
}
.empty-state {
padding: 20px 14px;
color: #66736f;
}
.inventory-panel {
margin-bottom: 20px;
}
.table-wrap {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
min-width: 960px;
border-collapse: collapse;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid #edf1ef;
text-align: left;
vertical-align: middle;
}
th {
color: #66736f;
background: #fbfcfc;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
td {
font-size: 0.94rem;
}
.mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.vm-name {
display: grid;
gap: 2px;
min-width: 170px;
}
.vm-name strong,
.vm-name span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-pill,
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 800;
}
.status-pill.running {
color: #11664d;
background: #dff5ed;
}
.status-pill.stopped,
.status-pill.unknown {
color: #6d4530;
background: #f5e8dc;
}
.badge-neutral {
color: #5a6662;
background: #eef3f1;
}
.badge-info {
color: #0e5a78;
background: #dff2f8;
}
.badge-warn {
color: #7a4b00;
background: #ffedc2;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 900px) {
.metrics-grid,
.content-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.app-shell {
width: min(100% - 20px, 1440px);
padding-top: 14px;
}
.topbar,
.toolbar {
align-items: stretch;
flex-direction: column;
}
.metrics-grid,
.content-grid {
grid-template-columns: 1fr;
}
.icon-button {
width: 100%;
}
.segmented {
width: 100%;
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
@@ -0,0 +1,138 @@
import { AlertCircle, RefreshCw, Search } from "lucide-react";
import { useMemo, useState } from "react";
import DashboardTable from "../components/DashboardTable.jsx";
import ServerStatus from "../components/ServerStatus.jsx";
import { useFetchVms } from "../hooks/useFetchVms.js";
function buildDuplicateIndex(duplicates) {
const index = new Map();
for (const group of duplicates) {
for (const item of group.items ?? []) {
index.set(item.id, { reason: group.reason, count: group.count });
}
}
return index;
}
export default function Dashboard() {
const { data, loading, error, lastRefresh, refresh } = useFetchVms();
const [query, setQuery] = useState("");
const [mode, setMode] = useState("all");
const duplicateIndex = useMemo(
() => buildDuplicateIndex(data.duplicates),
[data.duplicates],
);
const filteredItems = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return data.items.filter((item) => {
const matchesMode = mode === "all" || duplicateIndex.has(item.id);
const matchesQuery =
!normalizedQuery ||
[item.name, item.server, item.node, item.vmid, item.type, item.status]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedQuery));
return matchesMode && matchesQuery;
});
}, [data.items, duplicateIndex, mode, query]);
const onlineServers = data.servers.filter((server) => server.ok).length;
const duplicateItems = duplicateIndex.size;
return (
<main className="app-shell">
<header className="topbar">
<div>
<p className="eyebrow">Proxmox</p>
<h1>Prox Visualizer</h1>
</div>
<button className="icon-button primary" onClick={refresh} disabled={loading} title="Rafraichir">
<RefreshCw size={18} aria-hidden="true" className={loading ? "spin" : ""} />
<span>Rafraichir</span>
</button>
</header>
<section className="metrics-grid">
<div className="metric-card">
<span>Machines</span>
<strong>{data.items.length}</strong>
</div>
<div className="metric-card">
<span>Doublons</span>
<strong>{data.duplicates.length}</strong>
</div>
<div className="metric-card">
<span>Elements lies</span>
<strong>{duplicateItems}</strong>
</div>
<div className="metric-card">
<span>Serveurs OK</span>
<strong>{onlineServers}/{data.servers.length}</strong>
</div>
</section>
<section className="toolbar">
<div className="search-box">
<Search size={18} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher"
/>
</div>
<div className="segmented">
<button className={mode === "all" ? "active" : ""} onClick={() => setMode("all")}>
Tout
</button>
<button
className={mode === "duplicates" ? "active" : ""}
onClick={() => setMode("duplicates")}
>
Doublons
</button>
</div>
{lastRefresh && (
<span className="refresh-time">
{lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
</span>
)}
</section>
{error && (
<div className="notice error">
<AlertCircle size={18} aria-hidden="true" />
<span>{error}</span>
</div>
)}
<div className="content-grid">
<ServerStatus servers={data.servers} />
<section className="panel">
<div className="panel-header">
<h2>Groupes</h2>
<span>{data.duplicates.length}</span>
</div>
<div className="duplicate-list">
{data.duplicates.length === 0 ? (
<p className="empty-state">Aucun doublon</p>
) : (
data.duplicates.map((group) => (
<div className="duplicate-row" key={group.id}>
<div>
<strong>{group.label}</strong>
<span>{group.reason === "metadata" ? "Metadata" : "Nom identique"}</span>
</div>
<span className="metric-value small">{group.count}</span>
</div>
))
)}
</div>
</section>
</div>
<DashboardTable items={filteredItems} duplicateIndex={duplicateIndex} />
</main>
);
}
@@ -0,0 +1,7 @@
export default {
content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: {
extend: {},
},
plugins: [],
};
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
},
},
});
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
FRONTEND_DIR="$ROOT_DIR/frontend"
API_HOST="${API_HOST:-127.0.0.1}"
API_PORT="${API_PORT:-8000}"
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
pids=()
cleanup() {
for pid in "${pids[@]}"; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
}
trap cleanup INT TERM EXIT
if [ ! -x "$BACKEND_DIR/.venv/bin/uvicorn" ]; then
echo "Backend dependencies missing. Run:"
echo " cd backend && python -m venv .venv && .venv/bin/pip install -r requirements.txt"
exit 1
fi
if [ ! -d "$FRONTEND_DIR/node_modules" ]; then
echo "Frontend dependencies missing. Run:"
echo " cd frontend && npm install"
exit 1
fi
echo "Starting backend on http://$API_HOST:$API_PORT"
(
cd "$BACKEND_DIR"
.venv/bin/uvicorn app.main:app --host "$API_HOST" --port "$API_PORT"
) &
pids+=("$!")
echo "Starting frontend"
(
cd "$FRONTEND_DIR"
npm run dev -- --host "$FRONTEND_HOST"
) &
pids+=("$!")
echo
echo "Prox Visualizer is starting."
echo "Backend: http://$API_HOST:$API_PORT"
echo "Frontend: use the Network URL printed by Vite from another device."
echo "Stop both services with Ctrl+C."
echo
wait