feat: add admin panel at /admin for full dashboard configuration
- Backend: ConfigManager write methods (atomic YAML save), admin CRUD router for settings (display/odoo/refresh) and nodes, WS broadcast on config changes, fix nmap scan blocking event loop with to_thread - Frontend: admin UI with tab navigation, overview dashboard, node CRUD table with modal form, Odoo/display/refresh settings pages, typed API wrappers, active views filtering, config_changed WS handler - Infra: nginx no-cache headers for HTML, cache-forever for hashed assets - Fixes: WebSocket reconnect loop (ref pattern), rotation index OOB when views shrink, mutable node list cache Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -85,13 +85,15 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
from routers import network, tasks, calendar, services, ws
|
from routers import network, tasks, calendar, services, ws, admin
|
||||||
|
|
||||||
|
admin.init(app_config)
|
||||||
app.include_router(network.router)
|
app.include_router(network.router)
|
||||||
app.include_router(tasks.router)
|
app.include_router(tasks.router)
|
||||||
app.include_router(calendar.router)
|
app.include_router(calendar.router)
|
||||||
app.include_router(services.router)
|
app.include_router(services.router)
|
||||||
app.include_router(ws.router)
|
app.include_router(ws.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ class ConfigManager:
|
|||||||
with open(p) as f:
|
with open(p) as f:
|
||||||
return yaml.safe_load(f) or {}
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
def _save_yaml(self, path: str, data: dict[str, Any]) -> None:
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
def get_settings(self) -> dict[str, Any]:
|
def get_settings(self) -> dict[str, Any]:
|
||||||
if not self._settings:
|
if not self._settings:
|
||||||
self._settings = self._load_yaml(self._settings_path)
|
self._settings = self._load_yaml(self._settings_path)
|
||||||
@@ -26,7 +33,7 @@ class ConfigManager:
|
|||||||
def get_nodes(self) -> list[dict[str, Any]]:
|
def get_nodes(self) -> list[dict[str, Any]]:
|
||||||
if not self._services:
|
if not self._services:
|
||||||
self._services = self._load_yaml(self._services_path)
|
self._services = self._load_yaml(self._services_path)
|
||||||
return self._services.get("nodes", [])
|
return list(self._services.get("nodes", []))
|
||||||
|
|
||||||
def get_node_by_ip(self, ip: str) -> dict[str, Any] | None:
|
def get_node_by_ip(self, ip: str) -> dict[str, Any] | None:
|
||||||
for node in self.get_nodes():
|
for node in self.get_nodes():
|
||||||
@@ -39,6 +46,21 @@ class ConfigManager:
|
|||||||
self._services = self._load_yaml(self._services_path)
|
self._services = self._load_yaml(self._services_path)
|
||||||
return self._services.get("network_scan", {})
|
return self._services.get("network_scan", {})
|
||||||
|
|
||||||
|
def update_settings(self, section: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
settings = self._load_yaml(self._settings_path)
|
||||||
|
if section not in settings:
|
||||||
|
settings[section] = {}
|
||||||
|
settings[section].update(values)
|
||||||
|
self._save_yaml(self._settings_path, settings)
|
||||||
|
self._settings = {}
|
||||||
|
return settings[section]
|
||||||
|
|
||||||
|
def save_nodes(self, nodes: list[dict[str, Any]]) -> None:
|
||||||
|
services = self._load_yaml(self._services_path)
|
||||||
|
services["nodes"] = nodes
|
||||||
|
self._save_yaml(self._services_path, services)
|
||||||
|
self._services = {}
|
||||||
|
|
||||||
def reload(self) -> None:
|
def reload(self) -> None:
|
||||||
self._settings = {}
|
self._settings = {}
|
||||||
self._services = {}
|
self._services = {}
|
||||||
|
|||||||
121
backend/routers/admin.py
Normal file
121
backend/routers/admin.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from modules.config_manager import ConfigManager
|
||||||
|
from routers.ws import broadcast
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
# Will be set from main.py
|
||||||
|
config: ConfigManager = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def init(cfg: ConfigManager) -> None:
|
||||||
|
global config
|
||||||
|
config = cfg
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic models ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class DisplaySettings(BaseModel):
|
||||||
|
resolution: str | None = None
|
||||||
|
rotation_interval_seconds: int | None = None
|
||||||
|
transition: str | None = None
|
||||||
|
theme: str | None = None
|
||||||
|
active_views: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OdooSettings(BaseModel):
|
||||||
|
url: str | None = None
|
||||||
|
database: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
exclude_company_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshSettings(BaseModel):
|
||||||
|
odoo_minutes: int | None = None
|
||||||
|
network_minutes: int | None = None
|
||||||
|
ping_seconds: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NodeModel(BaseModel):
|
||||||
|
name: str
|
||||||
|
ip: str
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
public_url: str | None = None
|
||||||
|
icon: str = "device"
|
||||||
|
type: str | None = None
|
||||||
|
parent: str | None = None
|
||||||
|
connections: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Settings endpoints ──────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
async def get_settings():
|
||||||
|
return config.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings/display")
|
||||||
|
async def update_display(body: DisplaySettings):
|
||||||
|
values = body.model_dump(exclude_none=True)
|
||||||
|
result = config.update_settings("display", values)
|
||||||
|
await broadcast("config_changed", {"section": "display"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings/odoo")
|
||||||
|
async def update_odoo(body: OdooSettings):
|
||||||
|
values = body.model_dump(exclude_none=True)
|
||||||
|
result = config.update_settings("odoo", values)
|
||||||
|
await broadcast("config_changed", {"section": "odoo"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings/refresh")
|
||||||
|
async def update_refresh(body: RefreshSettings):
|
||||||
|
values = body.model_dump(exclude_none=True)
|
||||||
|
result = config.update_settings("refresh", values)
|
||||||
|
await broadcast("config_changed", {"section": "refresh"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Node endpoints ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/nodes")
|
||||||
|
async def get_nodes():
|
||||||
|
return config.get_nodes()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/nodes", status_code=201)
|
||||||
|
async def add_node(body: NodeModel):
|
||||||
|
nodes = config.get_nodes()
|
||||||
|
for n in nodes:
|
||||||
|
if n["ip"] == body.ip:
|
||||||
|
raise HTTPException(400, f"Node with IP {body.ip} already exists")
|
||||||
|
nodes.append(body.model_dump(exclude_none=True))
|
||||||
|
config.save_nodes(nodes)
|
||||||
|
await broadcast("config_changed", {"section": "nodes"})
|
||||||
|
return body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/nodes")
|
||||||
|
async def replace_nodes(body: list[NodeModel]):
|
||||||
|
nodes = [n.model_dump(exclude_none=True) for n in body]
|
||||||
|
config.save_nodes(nodes)
|
||||||
|
await broadcast("config_changed", {"section": "nodes"})
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/nodes/{ip}")
|
||||||
|
async def delete_node(ip: str):
|
||||||
|
nodes = config.get_nodes()
|
||||||
|
original_len = len(nodes)
|
||||||
|
nodes = [n for n in nodes if n.get("ip") != ip]
|
||||||
|
if len(nodes) == original_len:
|
||||||
|
raise HTTPException(404, f"Node with IP {ip} not found")
|
||||||
|
config.save_nodes(nodes)
|
||||||
|
await broadcast("config_changed", {"section": "nodes"})
|
||||||
|
return {"deleted": ip}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from modules.config_manager import ConfigManager
|
from modules.config_manager import ConfigManager
|
||||||
from modules.network_scanner import NetworkScanner
|
from modules.network_scanner import NetworkScanner
|
||||||
@@ -19,7 +21,7 @@ async def get_topology(config: ConfigManager = Depends(get_config)):
|
|||||||
|
|
||||||
if scan_config.get("enabled", False):
|
if scan_config.get("enabled", False):
|
||||||
try:
|
try:
|
||||||
scan_data = scanner.scan()
|
scan_data = await asyncio.to_thread(scanner.scan)
|
||||||
discovered = scanner.parse_scan_results(scan_data)
|
discovered = scanner.parse_scan_results(scan_data)
|
||||||
nodes = scanner.merge_with_config(discovered, configured_nodes)
|
nodes = scanner.merge_with_config(discovered, configured_nodes)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback: all paths serve index.html with no-cache
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hashed static assets: cache forever (filename changes on rebuild)
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
|||||||
703
frontend/package-lock.json
generated
703
frontend/package-lock.json
generated
@@ -8,11 +8,10 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3": "^7.4.3",
|
|
||||||
"d3": "^7.9.0",
|
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
@@ -1377,239 +1376,12 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3": {
|
|
||||||
"version": "7.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
|
||||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-array": "*",
|
|
||||||
"@types/d3-axis": "*",
|
|
||||||
"@types/d3-brush": "*",
|
|
||||||
"@types/d3-chord": "*",
|
|
||||||
"@types/d3-color": "*",
|
|
||||||
"@types/d3-contour": "*",
|
|
||||||
"@types/d3-delaunay": "*",
|
|
||||||
"@types/d3-dispatch": "*",
|
|
||||||
"@types/d3-drag": "*",
|
|
||||||
"@types/d3-dsv": "*",
|
|
||||||
"@types/d3-ease": "*",
|
|
||||||
"@types/d3-fetch": "*",
|
|
||||||
"@types/d3-force": "*",
|
|
||||||
"@types/d3-format": "*",
|
|
||||||
"@types/d3-geo": "*",
|
|
||||||
"@types/d3-hierarchy": "*",
|
|
||||||
"@types/d3-interpolate": "*",
|
|
||||||
"@types/d3-path": "*",
|
|
||||||
"@types/d3-polygon": "*",
|
|
||||||
"@types/d3-quadtree": "*",
|
|
||||||
"@types/d3-random": "*",
|
|
||||||
"@types/d3-scale": "*",
|
|
||||||
"@types/d3-scale-chromatic": "*",
|
|
||||||
"@types/d3-selection": "*",
|
|
||||||
"@types/d3-shape": "*",
|
|
||||||
"@types/d3-time": "*",
|
|
||||||
"@types/d3-time-format": "*",
|
|
||||||
"@types/d3-timer": "*",
|
|
||||||
"@types/d3-transition": "*",
|
|
||||||
"@types/d3-zoom": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-array": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-axis": {
|
|
||||||
"version": "3.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
|
||||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-selection": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-brush": {
|
|
||||||
"version": "3.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
|
||||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-selection": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-chord": {
|
|
||||||
"version": "3.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
|
||||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-color": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-contour": {
|
|
||||||
"version": "3.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
|
||||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-array": "*",
|
|
||||||
"@types/geojson": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-delaunay": {
|
|
||||||
"version": "6.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
|
||||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-dispatch": {
|
|
||||||
"version": "3.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
|
||||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-drag": {
|
|
||||||
"version": "3.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
|
||||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-selection": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-dsv": {
|
|
||||||
"version": "3.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
|
||||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-ease": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-fetch": {
|
|
||||||
"version": "3.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
|
||||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-dsv": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-force": {
|
|
||||||
"version": "3.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
|
||||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-format": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-geo": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/geojson": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-hierarchy": {
|
|
||||||
"version": "3.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
|
||||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-interpolate": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-color": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-path": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-polygon": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-quadtree": {
|
|
||||||
"version": "3.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
|
||||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-random": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-scale": {
|
|
||||||
"version": "4.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
|
||||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-time": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-scale-chromatic": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-selection": {
|
|
||||||
"version": "3.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
|
||||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-shape": {
|
|
||||||
"version": "3.1.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
|
||||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-path": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-time": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-time-format": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-timer": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-transition": {
|
|
||||||
"version": "3.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
|
||||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-selection": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-zoom": {
|
|
||||||
"version": "3.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
|
||||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-interpolate": "*",
|
|
||||||
"@types/d3-selection": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/geojson": {
|
|
||||||
"version": "7946.0.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -1710,396 +1482,30 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
|
||||||
"version": "7.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
|
||||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/d3": {
|
|
||||||
"version": "7.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
|
||||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "3",
|
|
||||||
"d3-axis": "3",
|
|
||||||
"d3-brush": "3",
|
|
||||||
"d3-chord": "3",
|
|
||||||
"d3-color": "3",
|
|
||||||
"d3-contour": "4",
|
|
||||||
"d3-delaunay": "6",
|
|
||||||
"d3-dispatch": "3",
|
|
||||||
"d3-drag": "3",
|
|
||||||
"d3-dsv": "3",
|
|
||||||
"d3-ease": "3",
|
|
||||||
"d3-fetch": "3",
|
|
||||||
"d3-force": "3",
|
|
||||||
"d3-format": "3",
|
|
||||||
"d3-geo": "3",
|
|
||||||
"d3-hierarchy": "3",
|
|
||||||
"d3-interpolate": "3",
|
|
||||||
"d3-path": "3",
|
|
||||||
"d3-polygon": "3",
|
|
||||||
"d3-quadtree": "3",
|
|
||||||
"d3-random": "3",
|
|
||||||
"d3-scale": "4",
|
|
||||||
"d3-scale-chromatic": "3",
|
|
||||||
"d3-selection": "3",
|
|
||||||
"d3-shape": "3",
|
|
||||||
"d3-time": "3",
|
|
||||||
"d3-time-format": "4",
|
|
||||||
"d3-timer": "3",
|
|
||||||
"d3-transition": "3",
|
|
||||||
"d3-zoom": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-array": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
|
||||||
"dependencies": {
|
|
||||||
"internmap": "1 - 2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-axis": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-brush": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-drag": "2 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-selection": "3",
|
|
||||||
"d3-transition": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-chord": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-color": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-contour": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "^3.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-delaunay": {
|
|
||||||
"version": "6.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
|
||||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
|
||||||
"dependencies": {
|
|
||||||
"delaunator": "5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dispatch": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-drag": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-selection": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dsv": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"commander": "7",
|
|
||||||
"iconv-lite": "0.6",
|
|
||||||
"rw": "1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"csv2json": "bin/dsv2json.js",
|
|
||||||
"csv2tsv": "bin/dsv2dsv.js",
|
|
||||||
"dsv2dsv": "bin/dsv2dsv.js",
|
|
||||||
"dsv2json": "bin/dsv2json.js",
|
|
||||||
"json2csv": "bin/json2dsv.js",
|
|
||||||
"json2dsv": "bin/json2dsv.js",
|
|
||||||
"json2tsv": "bin/json2dsv.js",
|
|
||||||
"tsv2csv": "bin/dsv2dsv.js",
|
|
||||||
"tsv2json": "bin/dsv2json.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-ease": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-fetch": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dsv": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-force": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-quadtree": "1 - 3",
|
|
||||||
"d3-timer": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-format": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-geo": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.5.0 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-hierarchy": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-interpolate": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-path": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-polygon": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-quadtree": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-random": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.10.0 - 3",
|
|
||||||
"d3-format": "1 - 3",
|
|
||||||
"d3-interpolate": "1.2.0 - 3",
|
|
||||||
"d3-time": "2.1.1 - 3",
|
|
||||||
"d3-time-format": "2 - 4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale-chromatic": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3",
|
|
||||||
"d3-interpolate": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-selection": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time-format": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-time": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-timer": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-transition": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3",
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-ease": "1 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-timer": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"d3-selection": "2 - 3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-zoom": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-drag": "2 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-selection": "2 - 3",
|
|
||||||
"d3-transition": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2117,14 +1523,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/delaunator": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
|
||||||
"dependencies": {
|
|
||||||
"robust-predicates": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -2275,25 +1673,6 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/internmap": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -2717,10 +2096,41 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/robust-predicates": {
|
"node_modules/react-router": {
|
||||||
"version": "3.0.2",
|
"version": "7.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="
|
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
||||||
|
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.1",
|
"version": "4.57.1",
|
||||||
@@ -2766,16 +2176,6 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rw": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
|
||||||
},
|
|
||||||
"node_modules/safer-buffer": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
|
||||||
},
|
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2790,6 +2190,11 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Header } from "./components/Layout/Header";
|
import { Header } from "./components/Layout/Header";
|
||||||
import { ViewRotator } from "./components/Layout/ViewRotator";
|
import { ViewRotator } from "./components/Layout/ViewRotator";
|
||||||
import { NetworkGraph } from "./components/Topology/NetworkGraph";
|
import { NetworkGraph } from "./components/Topology/NetworkGraph";
|
||||||
@@ -14,7 +14,11 @@ import { useWebSocket } from "./hooks/useWebSocket";
|
|||||||
import { useRotation } from "./hooks/useRotation";
|
import { useRotation } from "./hooks/useRotation";
|
||||||
import type { WSMessage } from "./types";
|
import type { WSMessage } from "./types";
|
||||||
|
|
||||||
const VIEW_NAMES = ["Topología de Red", "Proyectos", "Calendario"];
|
const ALL_VIEWS = [
|
||||||
|
{ id: "topology", label: "Topologia de Red" },
|
||||||
|
{ id: "projects", label: "Proyectos" },
|
||||||
|
{ id: "calendar", label: "Calendario" },
|
||||||
|
];
|
||||||
|
|
||||||
function LoadingScreen({ label }: { label: string }) {
|
function LoadingScreen({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -28,10 +32,16 @@ function LoadingScreen({ label }: { label: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const config = useDisplayConfig();
|
const { config, refetch: refetchConfig } = useDisplayConfig();
|
||||||
const intervalMs = (config?.rotation_interval_seconds ?? 30) * 1000;
|
const intervalMs = (config?.rotation_interval_seconds ?? 30) * 1000;
|
||||||
|
|
||||||
const activeView = useRotation(3, intervalMs);
|
const enabledViews = useMemo(() => {
|
||||||
|
const active = config?.active_views;
|
||||||
|
if (!active || active.length === 0) return ALL_VIEWS;
|
||||||
|
return ALL_VIEWS.filter((v) => active.includes(v.id));
|
||||||
|
}, [config?.active_views]);
|
||||||
|
|
||||||
|
const activeView = useRotation(enabledViews.length, intervalMs);
|
||||||
const topology = useTopology();
|
const topology = useTopology();
|
||||||
const tasks = useTasks();
|
const tasks = useTasks();
|
||||||
const calendar = useCalendar();
|
const calendar = useCalendar();
|
||||||
@@ -43,40 +53,48 @@ function App() {
|
|||||||
} else if (msg.type === "odoo_refresh") {
|
} else if (msg.type === "odoo_refresh") {
|
||||||
tasks.refetch();
|
tasks.refetch();
|
||||||
calendar.refetch();
|
calendar.refetch();
|
||||||
|
} else if (msg.type === "config_changed") {
|
||||||
|
refetchConfig();
|
||||||
|
topology.refetch();
|
||||||
|
tasks.refetch();
|
||||||
|
calendar.refetch();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[topology, tasks, calendar]
|
[topology, tasks, calendar, refetchConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
useWebSocket(handleWsMessage);
|
useWebSocket(handleWsMessage);
|
||||||
|
|
||||||
const connected = !topology.error && !tasks.error && !calendar.error;
|
const connected = !topology.error && !tasks.error && !calendar.error;
|
||||||
|
const currentView = enabledViews[activeView];
|
||||||
|
|
||||||
|
const viewContent: Record<string, React.ReactNode> = {
|
||||||
|
topology: topology.data ? (
|
||||||
|
<NetworkGraph nodes={topology.data.nodes} />
|
||||||
|
) : (
|
||||||
|
<LoadingScreen label="Cargando topologia..." />
|
||||||
|
),
|
||||||
|
projects: tasks.data ? (
|
||||||
|
<KanbanBoard projects={tasks.data.projects} />
|
||||||
|
) : (
|
||||||
|
<LoadingScreen label="Cargando proyectos..." />
|
||||||
|
),
|
||||||
|
calendar: calendar.data ? (
|
||||||
|
<CalendarView events={calendar.data.events} />
|
||||||
|
) : (
|
||||||
|
<LoadingScreen label="Cargando calendario..." />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-screen h-screen">
|
<div className="flex flex-col w-screen h-screen">
|
||||||
<Header viewName={VIEW_NAMES[activeView]} connected={connected} />
|
<Header viewName={currentView?.label ?? ""} connected={connected} />
|
||||||
<ViewRotator activeView={activeView}>
|
<ViewRotator activeView={activeView}>
|
||||||
<div className="h-full">
|
{enabledViews.map((v) => (
|
||||||
{topology.data ? (
|
<div key={v.id} className="h-full">
|
||||||
<NetworkGraph nodes={topology.data.nodes} />
|
{viewContent[v.id]}
|
||||||
) : (
|
</div>
|
||||||
<LoadingScreen label="Cargando topología..." />
|
))}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="h-full">
|
|
||||||
{tasks.data ? (
|
|
||||||
<KanbanBoard projects={tasks.data.projects} />
|
|
||||||
) : (
|
|
||||||
<LoadingScreen label="Cargando proyectos..." />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="h-full">
|
|
||||||
{calendar.data ? (
|
|
||||||
<CalendarView events={calendar.data.events} />
|
|
||||||
) : (
|
|
||||||
<LoadingScreen label="Cargando calendario..." />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ViewRotator>
|
</ViewRotator>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,12 +76,20 @@ export function useCalendar(refreshMs: number = 300_000) {
|
|||||||
export function useDisplayConfig() {
|
export function useDisplayConfig() {
|
||||||
const [config, setConfig] = useState<DisplayConfig | null>(null);
|
const [config, setConfig] = useState<DisplayConfig | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const refetch = useCallback(async () => {
|
||||||
fetch("/api/services/config")
|
try {
|
||||||
.then((r) => r.json())
|
const res = await fetch("/api/services/config");
|
||||||
.then((d) => setConfig(d.display))
|
if (!res.ok) return;
|
||||||
.catch(() => {});
|
const d = await res.json();
|
||||||
|
setConfig(d.display);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return config;
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
return { config, refetch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export function useRotation(totalViews: number, intervalMs: number = 30_000) {
|
|||||||
const [activeView, setActiveView] = useState(0);
|
const [activeView, setActiveView] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setActiveView(0);
|
||||||
|
}, [totalViews]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (totalViews <= 1) return;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
setActiveView((prev) => (prev + 1) % totalViews);
|
setActiveView((prev) => (prev + 1) % totalViews);
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { WSMessage } from "../types";
|
|||||||
export function useWebSocket(onMessage: (msg: WSMessage) => void) {
|
export function useWebSocket(onMessage: (msg: WSMessage) => void) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const reconnectTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
@@ -12,7 +14,7 @@ export function useWebSocket(onMessage: (msg: WSMessage) => void) {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg: WSMessage = JSON.parse(event.data);
|
const msg: WSMessage = JSON.parse(event.data);
|
||||||
onMessage(msg);
|
onMessageRef.current(msg);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed messages
|
// ignore malformed messages
|
||||||
}
|
}
|
||||||
@@ -24,7 +26,7 @@ export function useWebSocket(onMessage: (msg: WSMessage) => void) {
|
|||||||
|
|
||||||
ws.onerror = () => ws.close();
|
ws.onerror = () => ws.close();
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
}, [onMessage]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
|
|||||||
67
frontend/src/lib/adminApi.ts
Normal file
67
frontend/src/lib/adminApi.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type {
|
||||||
|
AdminSettings,
|
||||||
|
DisplayConfig,
|
||||||
|
OdooConfig,
|
||||||
|
RefreshConfig,
|
||||||
|
NodeConfig,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const BASE = "/api/admin";
|
||||||
|
|
||||||
|
async function json<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getSettings = () =>
|
||||||
|
fetch(`${BASE}/settings`).then((r) => json<AdminSettings>(r));
|
||||||
|
|
||||||
|
export const updateDisplay = (values: Partial<DisplayConfig>) =>
|
||||||
|
fetch(`${BASE}/settings/display`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
}).then((r) => json<DisplayConfig>(r));
|
||||||
|
|
||||||
|
export const updateOdoo = (values: Partial<OdooConfig>) =>
|
||||||
|
fetch(`${BASE}/settings/odoo`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
}).then((r) => json<OdooConfig>(r));
|
||||||
|
|
||||||
|
export const updateRefresh = (values: Partial<RefreshConfig>) =>
|
||||||
|
fetch(`${BASE}/settings/refresh`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
}).then((r) => json<RefreshConfig>(r));
|
||||||
|
|
||||||
|
// ── Nodes ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getNodes = () =>
|
||||||
|
fetch(`${BASE}/nodes`).then((r) => json<NodeConfig[]>(r));
|
||||||
|
|
||||||
|
export const addNode = (node: NodeConfig) =>
|
||||||
|
fetch(`${BASE}/nodes`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(node),
|
||||||
|
}).then((r) => json<NodeConfig>(r));
|
||||||
|
|
||||||
|
export const replaceNodes = (nodes: NodeConfig[]) =>
|
||||||
|
fetch(`${BASE}/nodes`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(nodes),
|
||||||
|
}).then((r) => json<NodeConfig[]>(r));
|
||||||
|
|
||||||
|
export const deleteNode = (ip: string) =>
|
||||||
|
fetch(`${BASE}/nodes/${encodeURIComponent(ip)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}).then((r) => json<{ deleted: string }>(r));
|
||||||
@@ -2,9 +2,16 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { AdminApp } from "./pages/admin/AdminApp";
|
||||||
|
|
||||||
|
const isAdmin = window.location.pathname.startsWith("/admin");
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
document.title = "Admin - TV Dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
{isAdmin ? <AdminApp /> : <App />}
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
70
frontend/src/pages/admin/AdminApp.tsx
Normal file
70
frontend/src/pages/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { DashboardOverview } from "./DashboardOverview";
|
||||||
|
import { NodesPage } from "./NodesPage";
|
||||||
|
import { OdooSettingsPage } from "./OdooSettingsPage";
|
||||||
|
import { DisplaySettingsPage } from "./DisplaySettingsPage";
|
||||||
|
import { RefreshSettingsPage } from "./RefreshSettingsPage";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: "overview", label: "Resumen" },
|
||||||
|
{ id: "nodes", label: "Nodos" },
|
||||||
|
{ id: "odoo", label: "Odoo" },
|
||||||
|
{ id: "display", label: "Pantalla" },
|
||||||
|
{ id: "refresh", label: "Intervalos" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabId = (typeof TABS)[number]["id"];
|
||||||
|
|
||||||
|
const TAB_CONTENT: Record<TabId, () => React.ReactNode> = {
|
||||||
|
overview: () => <DashboardOverview />,
|
||||||
|
nodes: () => <NodesPage />,
|
||||||
|
odoo: () => <OdooSettingsPage />,
|
||||||
|
display: () => <DisplaySettingsPage />,
|
||||||
|
refresh: () => <RefreshSettingsPage />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminApp() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>("overview");
|
||||||
|
|
||||||
|
const Content = TAB_CONTENT[activeTab];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-56 shrink-0 bg-bg-secondary border-r border-border flex flex-col">
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<h1 className="text-lg font-bold text-text-primary">Admin Panel</h1>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">TV Dashboard</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-3 flex flex-col gap-1">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`block w-full text-left px-4 py-2.5 rounded-lg text-sm transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? "bg-accent/15 text-accent font-medium"
|
||||||
|
: "text-text-secondary hover:text-text-primary hover:bg-bg-card-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="p-3 border-t border-border">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="block px-4 py-2.5 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-card-hover transition-colors"
|
||||||
|
>
|
||||||
|
← Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
<Content />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/pages/admin/AdminLayout.tsx
Normal file
59
frontend/src/pages/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: "/admin", label: "Resumen", end: true },
|
||||||
|
{ to: "/admin/nodes", label: "Nodos" },
|
||||||
|
{ to: "/admin/odoo", label: "Odoo" },
|
||||||
|
{ to: "/admin/display", label: "Pantalla" },
|
||||||
|
{ to: "/admin/refresh", label: "Intervalos" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function SidebarLink({ to, label, end }: { to: string; label: string; end?: boolean }) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block px-4 py-2.5 rounded-lg text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-accent/15 text-accent font-medium"
|
||||||
|
: "text-text-secondary hover:text-text-primary hover:bg-bg-card-hover"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-56 shrink-0 bg-bg-secondary border-r border-border flex flex-col">
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<h1 className="text-lg font-bold text-text-primary">Admin Panel</h1>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">TV Dashboard</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-3 flex flex-col gap-1">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<SidebarLink key={item.to} {...item} />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="p-3 border-t border-border">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="block px-4 py-2.5 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-card-hover transition-colors"
|
||||||
|
>
|
||||||
|
← Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
frontend/src/pages/admin/DashboardOverview.tsx
Normal file
94
frontend/src/pages/admin/DashboardOverview.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getSettings, getNodes } from "../../lib/adminApi";
|
||||||
|
import type { AdminSettings } from "../../types";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
nodeCount: number;
|
||||||
|
proxmoxCount: number;
|
||||||
|
vmCount: number;
|
||||||
|
settings: AdminSettings | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card border border-border rounded-xl p-5">
|
||||||
|
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-text-primary">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardOverview() {
|
||||||
|
const [stats, setStats] = useState<Stats>({
|
||||||
|
nodeCount: 0,
|
||||||
|
proxmoxCount: 0,
|
||||||
|
vmCount: 0,
|
||||||
|
settings: null,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([getSettings(), getNodes()])
|
||||||
|
.then(([settings, nodes]) => {
|
||||||
|
setStats({
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
proxmoxCount: nodes.filter((n) => n.type === "proxmox").length,
|
||||||
|
vmCount: nodes.filter((n) => n.type === "vm" || n.type === "ct").length,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => setError(String(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const s = stats.settings;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold mb-6">Resumen del Dashboard</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 rounded-lg bg-danger/15 border border-danger/30 text-danger text-sm">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <p className="text-text-muted mb-4">Cargando datos...</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<Card label="Total Nodos" value={stats.nodeCount} />
|
||||||
|
<Card label="Servidores Proxmox" value={stats.proxmoxCount} />
|
||||||
|
<Card label="VMs / CTs" value={stats.vmCount} />
|
||||||
|
<Card label="Rotacion" value={s ? `${s.display.rotation_interval_seconds}s` : "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{s && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-bg-card border border-border rounded-xl p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-text-secondary mb-3">Odoo</h3>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||||
|
<dt className="text-text-muted">Servidor</dt>
|
||||||
|
<dd className="text-text-primary">{s.odoo.url}</dd>
|
||||||
|
<dt className="text-text-muted">Base de datos</dt>
|
||||||
|
<dd className="text-text-primary">{s.odoo.database}</dd>
|
||||||
|
<dt className="text-text-muted">Usuario</dt>
|
||||||
|
<dd className="text-text-primary">{s.odoo.username}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="bg-bg-card border border-border rounded-xl p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-text-secondary mb-3">Intervalos</h3>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||||
|
<dt className="text-text-muted">Odoo</dt>
|
||||||
|
<dd className="text-text-primary">{s.refresh.odoo_minutes} min</dd>
|
||||||
|
<dt className="text-text-muted">Red</dt>
|
||||||
|
<dd className="text-text-primary">{s.refresh.network_minutes} min</dd>
|
||||||
|
<dt className="text-text-muted">Ping</dt>
|
||||||
|
<dd className="text-text-primary">{s.refresh.ping_seconds} seg</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
frontend/src/pages/admin/DisplaySettingsPage.tsx
Normal file
123
frontend/src/pages/admin/DisplaySettingsPage.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getSettings, updateDisplay } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
const ALL_VIEWS = [
|
||||||
|
{ id: "topology", label: "Topologia de Red" },
|
||||||
|
{ id: "projects", label: "Proyectos" },
|
||||||
|
{ id: "calendar", label: "Calendario" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DisplaySettingsPage() {
|
||||||
|
const [rotation, setRotation] = useState(120);
|
||||||
|
const [transition, setTransition] = useState("fade");
|
||||||
|
const [theme, setTheme] = useState("dark");
|
||||||
|
const [activeViews, setActiveViews] = useState<string[]>(["topology", "projects", "calendar"]);
|
||||||
|
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSettings().then((s) => {
|
||||||
|
const d = s.display;
|
||||||
|
setRotation(d.rotation_interval_seconds);
|
||||||
|
setTransition(d.transition);
|
||||||
|
setTheme(d.theme);
|
||||||
|
if (d.active_views) setActiveViews(d.active_views);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleView = (id: string) => {
|
||||||
|
setActiveViews((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus("saving");
|
||||||
|
try {
|
||||||
|
await updateDisplay({
|
||||||
|
rotation_interval_seconds: rotation,
|
||||||
|
transition,
|
||||||
|
theme,
|
||||||
|
active_views: activeViews,
|
||||||
|
});
|
||||||
|
setStatus("saved");
|
||||||
|
setTimeout(() => setStatus("idle"), 2000);
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<h2 className="text-xl font-bold mb-6">Configuracion de Pantalla</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">Intervalo de rotacion (segundos)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
value={rotation}
|
||||||
|
onChange={(e) => setRotation(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">Transicion</span>
|
||||||
|
<select
|
||||||
|
value={transition}
|
||||||
|
onChange={(e) => setTransition(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="fade">Fade</option>
|
||||||
|
<option value="slide">Slide</option>
|
||||||
|
<option value="none">Ninguna</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">Tema</span>
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => setTheme(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="dark">Oscuro</option>
|
||||||
|
<option value="light">Claro</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend className="text-sm text-text-secondary mb-2">Vistas activas</legend>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{ALL_VIEWS.map((v) => (
|
||||||
|
<label key={v.id} className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeViews.includes(v.id)}
|
||||||
|
onChange={() => toggleView(v.id)}
|
||||||
|
className="w-4 h-4 accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-primary">{v.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === "saving" || activeViews.length === 0}
|
||||||
|
className="self-start px-6 py-2.5 rounded-lg bg-accent text-white font-medium hover:bg-accent/80 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{status === "saving" ? "Guardando..." : status === "saved" ? "Guardado!" : "Guardar"}
|
||||||
|
</button>
|
||||||
|
{activeViews.length === 0 && (
|
||||||
|
<p className="text-warning text-sm">Debes seleccionar al menos una vista.</p>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-danger text-sm">Error al guardar.</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
frontend/src/pages/admin/NodeFormModal.tsx
Normal file
176
frontend/src/pages/admin/NodeFormModal.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { NodeConfig } from "../../types";
|
||||||
|
|
||||||
|
const ICON_OPTIONS = ["router", "firewall", "switch", "server", "nas", "device", "ap", "printer", "phone", "pc"];
|
||||||
|
const TYPE_OPTIONS = ["", "proxmox", "vm", "ct"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initial?: NodeConfig;
|
||||||
|
allNodes: NodeConfig[];
|
||||||
|
onSave: (node: NodeConfig) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: NodeConfig = {
|
||||||
|
name: "",
|
||||||
|
ip: "",
|
||||||
|
icon: "device",
|
||||||
|
connections: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NodeFormModal({ initial, allNodes, onSave, onClose }: Props) {
|
||||||
|
const [form, setForm] = useState<NodeConfig>(initial ?? EMPTY);
|
||||||
|
const [connectionsText, setConnectionsText] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) {
|
||||||
|
setForm(initial);
|
||||||
|
setConnectionsText(initial.connections.join(", "));
|
||||||
|
}
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const connections = connectionsText
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
onSave({ ...form, connections });
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (key: keyof NodeConfig, value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value || undefined }));
|
||||||
|
|
||||||
|
const parentOptions = allNodes.filter((n) => n.ip !== form.ip).map((n) => n.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-bg-secondary border border-border rounded-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold mb-4">
|
||||||
|
{initial ? "Editar Nodo" : "Agregar Nodo"}
|
||||||
|
</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">Nombre *</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">IP *</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.ip}
|
||||||
|
onChange={(e) => set("ip", e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">Usuario</span>
|
||||||
|
<input
|
||||||
|
value={form.username ?? ""}
|
||||||
|
onChange={(e) => set("username", e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">Contrasena</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password ?? ""}
|
||||||
|
onChange={(e) => set("password", e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">URL publica</span>
|
||||||
|
<input
|
||||||
|
value={form.public_url ?? ""}
|
||||||
|
onChange={(e) => set("public_url", e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">Icono</span>
|
||||||
|
<select
|
||||||
|
value={form.icon}
|
||||||
|
onChange={(e) => set("icon", e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{ICON_OPTIONS.map((i) => (
|
||||||
|
<option key={i} value={i}>{i}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">Tipo</span>
|
||||||
|
<select
|
||||||
|
value={form.type ?? ""}
|
||||||
|
onChange={(e) => set("type", e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Ninguno</option>
|
||||||
|
{TYPE_OPTIONS.filter(Boolean).map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">Parent</span>
|
||||||
|
<select
|
||||||
|
value={form.parent ?? ""}
|
||||||
|
onChange={(e) => set("parent", e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Ninguno</option>
|
||||||
|
{parentOptions.map((name) => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-secondary">Conexiones (nombres separados por coma)</span>
|
||||||
|
<input
|
||||||
|
value={connectionsText}
|
||||||
|
onChange={(e) => setConnectionsText(e.target.value)}
|
||||||
|
placeholder="Switch Cisco, Firewall OPNsense"
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-medium hover:bg-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
{initial ? "Guardar" : "Agregar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
frontend/src/pages/admin/NodesPage.tsx
Normal file
147
frontend/src/pages/admin/NodesPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { getNodes, addNode, replaceNodes, deleteNode } from "../../lib/adminApi";
|
||||||
|
import { NodeFormModal } from "./NodeFormModal";
|
||||||
|
import type { NodeConfig } from "../../types";
|
||||||
|
|
||||||
|
export function NodesPage() {
|
||||||
|
const [nodes, setNodes] = useState<NodeConfig[]>([]);
|
||||||
|
const [editNode, setEditNode] = useState<NodeConfig | undefined>();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
getNodes()
|
||||||
|
.then(setNodes)
|
||||||
|
.catch((err) => setError(String(err)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditNode(undefined);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (node: NodeConfig) => {
|
||||||
|
setEditNode(node);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (node: NodeConfig) => {
|
||||||
|
if (editNode) {
|
||||||
|
// Edit: replace the node in the list
|
||||||
|
const updated = nodes.map((n) =>
|
||||||
|
n.ip === editNode.ip ? node : n
|
||||||
|
);
|
||||||
|
await replaceNodes(updated);
|
||||||
|
} else {
|
||||||
|
await addNode(node);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (ip: string) => {
|
||||||
|
await deleteNode(ip);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">Nodos de Red</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="px-4 py-2 rounded-lg bg-accent text-white text-sm font-medium hover:bg-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
+ Agregar Nodo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 rounded-lg bg-danger/15 border border-danger/30 text-danger text-sm">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-secondary text-text-secondary text-left">
|
||||||
|
<th className="px-4 py-3 font-medium">Nombre</th>
|
||||||
|
<th className="px-4 py-3 font-medium">IP</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Tipo</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Parent</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Icono</th>
|
||||||
|
<th className="px-4 py-3 font-medium w-32">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<tr key={node.ip + node.name} className="border-t border-border hover:bg-bg-card-hover transition-colors">
|
||||||
|
<td className="px-4 py-3 text-text-primary font-medium">{node.name}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary font-mono text-xs">{node.ip}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{node.type && (
|
||||||
|
<span className="px-2 py-0.5 rounded text-xs bg-accent/15 text-accent">
|
||||||
|
{node.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{node.parent ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{node.icon}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(node)}
|
||||||
|
className="px-2.5 py-1 rounded text-xs bg-bg-card hover:bg-bg-card-hover border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
{confirmDelete === node.ip ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(node.ip)}
|
||||||
|
className="px-2.5 py-1 rounded text-xs bg-danger/15 text-danger hover:bg-danger/25 transition-colors"
|
||||||
|
>
|
||||||
|
Confirmar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="px-2.5 py-1 rounded text-xs text-text-muted hover:text-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(node.ip)}
|
||||||
|
className="px-2.5 py-1 rounded text-xs text-danger/70 hover:text-danger hover:bg-danger/10 transition-colors"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{nodes.length === 0 && (
|
||||||
|
<p className="text-center py-8 text-text-muted">No hay nodos configurados.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<NodeFormModal
|
||||||
|
initial={editNode}
|
||||||
|
allNodes={nodes}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/pages/admin/OdooSettingsPage.tsx
Normal file
83
frontend/src/pages/admin/OdooSettingsPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getSettings, updateOdoo } from "../../lib/adminApi";
|
||||||
|
import type { OdooConfig } from "../../types";
|
||||||
|
|
||||||
|
export function OdooSettingsPage() {
|
||||||
|
const [form, setForm] = useState<OdooConfig>({
|
||||||
|
url: "",
|
||||||
|
database: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
exclude_company_ids: [],
|
||||||
|
});
|
||||||
|
const [excludeText, setExcludeText] = useState("");
|
||||||
|
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSettings().then((s) => {
|
||||||
|
const odoo = s.odoo;
|
||||||
|
setForm(odoo);
|
||||||
|
setExcludeText(odoo.exclude_company_ids?.join(", ") ?? "");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus("saving");
|
||||||
|
try {
|
||||||
|
const ids = excludeText
|
||||||
|
.split(",")
|
||||||
|
.map((s) => parseInt(s.trim()))
|
||||||
|
.filter((n) => !isNaN(n));
|
||||||
|
await updateOdoo({ ...form, exclude_company_ids: ids });
|
||||||
|
setStatus("saved");
|
||||||
|
setTimeout(() => setStatus("idle"), 2000);
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = (label: string, key: keyof OdooConfig, type = "text") => (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">{label}</span>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={form[key] as string}
|
||||||
|
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<h2 className="text-xl font-bold mb-6">Configuracion Odoo</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{field("URL del servidor", "url")}
|
||||||
|
{field("Base de datos", "database")}
|
||||||
|
{field("Usuario", "username")}
|
||||||
|
{field("Contrasena", "password", "password")}
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">Excluir Company IDs (separados por coma)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={excludeText}
|
||||||
|
onChange={(e) => setExcludeText(e.target.value)}
|
||||||
|
placeholder="2, 3"
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === "saving"}
|
||||||
|
className="self-start px-6 py-2.5 rounded-lg bg-accent text-white font-medium hover:bg-accent/80 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{status === "saving" ? "Guardando..." : status === "saved" ? "Guardado!" : "Guardar"}
|
||||||
|
</button>
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-danger text-sm">Error al guardar. Revisa la conexion.</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/pages/admin/RefreshSettingsPage.tsx
Normal file
85
frontend/src/pages/admin/RefreshSettingsPage.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getSettings, updateRefresh } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
export function RefreshSettingsPage() {
|
||||||
|
const [odooMin, setOdooMin] = useState(5);
|
||||||
|
const [networkMin, setNetworkMin] = useState(10);
|
||||||
|
const [pingSec, setPingSec] = useState(60);
|
||||||
|
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSettings().then((s) => {
|
||||||
|
const r = s.refresh;
|
||||||
|
setOdooMin(r.odoo_minutes);
|
||||||
|
setNetworkMin(r.network_minutes);
|
||||||
|
setPingSec(r.ping_seconds);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus("saving");
|
||||||
|
try {
|
||||||
|
await updateRefresh({
|
||||||
|
odoo_minutes: odooMin,
|
||||||
|
network_minutes: networkMin,
|
||||||
|
ping_seconds: pingSec,
|
||||||
|
});
|
||||||
|
setStatus("saved");
|
||||||
|
setTimeout(() => setStatus("idle"), 2000);
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<h2 className="text-xl font-bold mb-6">Intervalos de Actualizacion</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">Actualizacion Odoo (minutos)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={odooMin}
|
||||||
|
onChange={(e) => setOdooMin(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">Escaneo de red (minutos)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={networkMin}
|
||||||
|
onChange={(e) => setNetworkMin(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">Ping (segundos)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
value={pingSec}
|
||||||
|
onChange={(e) => setPingSec(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg bg-bg-primary border border-border text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === "saving"}
|
||||||
|
className="self-start px-6 py-2.5 rounded-lg bg-accent text-white font-medium hover:bg-accent/80 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{status === "saving" ? "Guardando..." : status === "saved" ? "Guardado!" : "Guardar"}
|
||||||
|
</button>
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-danger text-sm">Error al guardar.</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,9 +62,44 @@ export interface DisplayConfig {
|
|||||||
rotation_interval_seconds: number;
|
rotation_interval_seconds: number;
|
||||||
transition: string;
|
transition: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
active_views?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
type: string;
|
type: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Admin types ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OdooConfig {
|
||||||
|
url: string;
|
||||||
|
database: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
exclude_company_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshConfig {
|
||||||
|
odoo_minutes: number;
|
||||||
|
network_minutes: number;
|
||||||
|
ping_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeConfig {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
public_url?: string;
|
||||||
|
icon: string;
|
||||||
|
type?: string;
|
||||||
|
parent?: string;
|
||||||
|
connections: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSettings {
|
||||||
|
display: DisplayConfig;
|
||||||
|
odoo: OdooConfig;
|
||||||
|
refresh: RefreshConfig;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user