commit fa08851041b79e393d11a931d8b1c25bb57b7e4a Author: Gilles Soulier Date: Sun Jun 7 09:30:11 2026 +0200 Build Proxmox visualizer app diff --git a/prox-visualizer/.gitignore b/prox-visualizer/.gitignore new file mode 100644 index 0000000..42506fb --- /dev/null +++ b/prox-visualizer/.gitignore @@ -0,0 +1,12 @@ +.env +.venv/ +__pycache__/ +*.py[cod] + +node_modules/ +dist/ +.vite/ + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/prox-visualizer/README.md b/prox-visualizer/README.md new file mode 100644 index 0000000..b195834 --- /dev/null +++ b/prox-visualizer/README.md @@ -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. diff --git a/prox-visualizer/backend/.env.example b/prox-visualizer/backend/.env.example new file mode 100644 index 0000000..1aa27c4 --- /dev/null +++ b/prox-visualizer/backend/.env.example @@ -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='[]' diff --git a/prox-visualizer/backend/app/__init__.py b/prox-visualizer/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prox-visualizer/backend/app/api/__init__.py b/prox-visualizer/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prox-visualizer/backend/app/api/endpoints/__init__.py b/prox-visualizer/backend/app/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prox-visualizer/backend/app/api/endpoints/proxmox.py b/prox-visualizer/backend/app/api/endpoints/proxmox.py new file mode 100644 index 0000000..acaa5c4 --- /dev/null +++ b/prox-visualizer/backend/app/api/endpoints/proxmox.py @@ -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 + ] diff --git a/prox-visualizer/backend/app/api/endpoints/vms.py b/prox-visualizer/backend/app/api/endpoints/vms.py new file mode 100644 index 0000000..c2984cf --- /dev/null +++ b/prox-visualizer/backend/app/api/endpoints/vms.py @@ -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) diff --git a/prox-visualizer/backend/app/api/router.py b/prox-visualizer/backend/app/api/router.py new file mode 100644 index 0000000..d8fc536 --- /dev/null +++ b/prox-visualizer/backend/app/api/router.py @@ -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) diff --git a/prox-visualizer/backend/app/core/__init__.py b/prox-visualizer/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prox-visualizer/backend/app/core/config.py b/prox-visualizer/backend/app/core/config.py new file mode 100644 index 0000000..320c47c --- /dev/null +++ b/prox-visualizer/backend/app/core/config.py @@ -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() diff --git a/prox-visualizer/backend/app/main.py b/prox-visualizer/backend/app/main.py new file mode 100644 index 0000000..c36fb83 --- /dev/null +++ b/prox-visualizer/backend/app/main.py @@ -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) + } diff --git a/prox-visualizer/backend/app/models/__init__.py b/prox-visualizer/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prox-visualizer/backend/app/models/schemas.py b/prox-visualizer/backend/app/models/schemas.py new file mode 100644 index 0000000..8a6713f --- /dev/null +++ b/prox-visualizer/backend/app/models/schemas.py @@ -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] diff --git a/prox-visualizer/backend/app/services/__init__.py b/prox-visualizer/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prox-visualizer/backend/app/services/metadata.py b/prox-visualizer/backend/app/services/metadata.py new file mode 100644 index 0000000..b814056 --- /dev/null +++ b/prox-visualizer/backend/app/services/metadata.py @@ -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() diff --git a/prox-visualizer/backend/app/services/proxmox_api.py b/prox-visualizer/backend/app/services/proxmox_api.py new file mode 100644 index 0000000..dd6e954 --- /dev/null +++ b/prox-visualizer/backend/app/services/proxmox_api.py @@ -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, + ) diff --git a/prox-visualizer/backend/app/services/scanner.py b/prox-visualizer/backend/app/services/scanner.py new file mode 100644 index 0000000..008f0d3 --- /dev/null +++ b/prox-visualizer/backend/app/services/scanner.py @@ -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 diff --git a/prox-visualizer/backend/requirements.txt b/prox-visualizer/backend/requirements.txt new file mode 100644 index 0000000..2257a75 --- /dev/null +++ b/prox-visualizer/backend/requirements.txt @@ -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 diff --git a/prox-visualizer/frontend/index.html b/prox-visualizer/frontend/index.html new file mode 100644 index 0000000..0a98d2e --- /dev/null +++ b/prox-visualizer/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Prox Visualizer + + +
+ + + diff --git a/prox-visualizer/frontend/package-lock.json b/prox-visualizer/frontend/package-lock.json new file mode 100644 index 0000000..6b5a1fb --- /dev/null +++ b/prox-visualizer/frontend/package-lock.json @@ -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 + } + } + } + } +} diff --git a/prox-visualizer/frontend/package.json b/prox-visualizer/frontend/package.json new file mode 100644 index 0000000..f66bd30 --- /dev/null +++ b/prox-visualizer/frontend/package.json @@ -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": {} +} diff --git a/prox-visualizer/frontend/src/App.jsx b/prox-visualizer/frontend/src/App.jsx new file mode 100644 index 0000000..5b30370 --- /dev/null +++ b/prox-visualizer/frontend/src/App.jsx @@ -0,0 +1,5 @@ +import Dashboard from "./views/Dashboard.jsx"; + +export default function App() { + return ; +} diff --git a/prox-visualizer/frontend/src/components/DashboardTable.jsx b/prox-visualizer/frontend/src/components/DashboardTable.jsx new file mode 100644 index 0000000..9f97579 --- /dev/null +++ b/prox-visualizer/frontend/src/components/DashboardTable.jsx @@ -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 ( +
+
+

Inventaire

+ {items.length} +
+
+ + + + + + + + + + + + + + + + {items.map((item) => { + const duplicate = duplicateIndex.get(item.id); + return ( + + + + + + + + + + + + ); + })} + +
VMIDNomTypeServeurNoeudEtatMemoireDisqueDoublon
{item.vmid} +
+ {item.name} + {item.tags && {item.tags}} +
+
{item.type === "qemu" ? "VM" : "LXC"}{item.server}{item.node} + + {item.status || "-"} + + {formatBytes(item.maxmem || item.mem)}{formatBytes(item.maxdisk || item.disk)} + +
+
+
+ ); +} diff --git a/prox-visualizer/frontend/src/components/DuplicateBadge.jsx b/prox-visualizer/frontend/src/components/DuplicateBadge.jsx new file mode 100644 index 0000000..c635a2f --- /dev/null +++ b/prox-visualizer/frontend/src/components/DuplicateBadge.jsx @@ -0,0 +1,12 @@ +export default function DuplicateBadge({ reason, count }) { + if (!reason) { + return Unique; + } + + const label = reason === "metadata" ? "Metadata" : "Nom"; + return ( + + {label} x{count} + + ); +} diff --git a/prox-visualizer/frontend/src/components/ServerStatus.jsx b/prox-visualizer/frontend/src/components/ServerStatus.jsx new file mode 100644 index 0000000..214f71e --- /dev/null +++ b/prox-visualizer/frontend/src/components/ServerStatus.jsx @@ -0,0 +1,41 @@ +import { AlertTriangle, CheckCircle2, Server } from "lucide-react"; + +export default function ServerStatus({ servers }) { + return ( +
+
+
+
+
+
+ {servers.map((server) => ( +
+
+ {server.ok ? ( +
+ {server.vm_count} +
+ ))} +
+ {servers.some((server) => server.errors?.length) && ( +
+ {servers.flatMap((server) => + (server.errors ?? []).map((error) => ( +

{server.name}: {error}

+ )), + )} +
+ )} +
+ ); +} diff --git a/prox-visualizer/frontend/src/hooks/useFetchVms.js b/prox-visualizer/frontend/src/hooks/useFetchVms.js new file mode 100644 index 0000000..8b10cb9 --- /dev/null +++ b/prox-visualizer/frontend/src/hooks/useFetchVms.js @@ -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, + }; +} diff --git a/prox-visualizer/frontend/src/index.css b/prox-visualizer/frontend/src/index.css new file mode 100644 index 0000000..9cae200 --- /dev/null +++ b/prox-visualizer/frontend/src/index.css @@ -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%; + } +} diff --git a/prox-visualizer/frontend/src/main.jsx b/prox-visualizer/frontend/src/main.jsx new file mode 100644 index 0000000..097953c --- /dev/null +++ b/prox-visualizer/frontend/src/main.jsx @@ -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( + + + , +); diff --git a/prox-visualizer/frontend/src/views/Dashboard.jsx b/prox-visualizer/frontend/src/views/Dashboard.jsx new file mode 100644 index 0000000..e3d6068 --- /dev/null +++ b/prox-visualizer/frontend/src/views/Dashboard.jsx @@ -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 ( +
+
+
+

Proxmox

+

Prox Visualizer

+
+ +
+ +
+
+ Machines + {data.items.length} +
+
+ Doublons + {data.duplicates.length} +
+
+ Elements lies + {duplicateItems} +
+
+ Serveurs OK + {onlineServers}/{data.servers.length} +
+
+ +
+
+
+
+ + +
+ {lastRefresh && ( + + {lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} + + )} +
+ + {error && ( +
+
+ )} + +
+ +
+
+

Groupes

+ {data.duplicates.length} +
+
+ {data.duplicates.length === 0 ? ( +

Aucun doublon

+ ) : ( + data.duplicates.map((group) => ( +
+
+ {group.label} + {group.reason === "metadata" ? "Metadata" : "Nom identique"} +
+ {group.count} +
+ )) + )} +
+
+
+ + +
+ ); +} diff --git a/prox-visualizer/frontend/tailwind.config.js b/prox-visualizer/frontend/tailwind.config.js new file mode 100644 index 0000000..2737d3e --- /dev/null +++ b/prox-visualizer/frontend/tailwind.config.js @@ -0,0 +1,7 @@ +export default { + content: ["./index.html", "./src/**/*.{js,jsx}"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/prox-visualizer/frontend/vite.config.js b/prox-visualizer/frontend/vite.config.js new file mode 100644 index 0000000..fed7b00 --- /dev/null +++ b/prox-visualizer/frontend/vite.config.js @@ -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, + }, + }, + }, +}); diff --git a/prox-visualizer/start.sh b/prox-visualizer/start.sh new file mode 100755 index 0000000..8b4a590 --- /dev/null +++ b/prox-visualizer/start.sh @@ -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