Build Proxmox visualizer app
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Executable
+56
@@ -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
|
||||
Reference in New Issue
Block a user