diff --git a/backend/main.py b/backend/main.py index cbef52d..ca3cdbc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -85,13 +85,15 @@ app.add_middleware( 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(tasks.router) app.include_router(calendar.router) app.include_router(services.router) app.include_router(ws.router) +app.include_router(admin.router) @app.get("/api/health") diff --git a/backend/modules/config_manager.py b/backend/modules/config_manager.py index e18650f..d809583 100644 --- a/backend/modules/config_manager.py +++ b/backend/modules/config_manager.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Any @@ -18,6 +19,12 @@ class ConfigManager: with open(p) as f: 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]: if not self._settings: self._settings = self._load_yaml(self._settings_path) @@ -26,7 +33,7 @@ class ConfigManager: def get_nodes(self) -> list[dict[str, Any]]: if not self._services: 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: for node in self.get_nodes(): @@ -39,6 +46,21 @@ class ConfigManager: self._services = self._load_yaml(self._services_path) 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: self._settings = {} self._services = {} diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..1da66b7 --- /dev/null +++ b/backend/routers/admin.py @@ -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} diff --git a/backend/routers/network.py b/backend/routers/network.py index 690e8b2..d57c8ad 100644 --- a/backend/routers/network.py +++ b/backend/routers/network.py @@ -1,3 +1,5 @@ +import asyncio + from fastapi import APIRouter, Depends from modules.config_manager import ConfigManager 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): try: - scan_data = scanner.scan() + scan_data = await asyncio.to_thread(scanner.scan) discovered = scanner.parse_scan_results(scan_data) nodes = scanner.merge_with_config(discovered, configured_nodes) except Exception: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d732931..abbc8da 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -3,8 +3,15 @@ server { root /usr/share/nginx/html; index index.html; + # SPA fallback: all paths serve index.html with no-cache location / { 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/ { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 485ac48..d1d08ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@types/d3": "^7.4.3", - "d3": "^7.9.0", "framer-motion": "^12.34.0", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", @@ -1377,239 +1376,12 @@ "@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": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "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": { "version": "19.2.14", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "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": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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": { "version": "4.4.3", "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": { "version": "2.1.2", "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==", "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": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2717,10 +2096,41 @@ "node": ">=0.10.0" } }, - "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "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": { "version": "4.57.1", @@ -2766,16 +2176,6 @@ "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": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2790,6 +2190,11 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 50ce482..23aafcd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "dependencies": { "framer-motion": "^12.34.0", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.0" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 061a8b7..f3c7892 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { Header } from "./components/Layout/Header"; import { ViewRotator } from "./components/Layout/ViewRotator"; import { NetworkGraph } from "./components/Topology/NetworkGraph"; @@ -14,7 +14,11 @@ import { useWebSocket } from "./hooks/useWebSocket"; import { useRotation } from "./hooks/useRotation"; 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 }) { return ( @@ -28,10 +32,16 @@ function LoadingScreen({ label }: { label: string }) { } function App() { - const config = useDisplayConfig(); + const { config, refetch: refetchConfig } = useDisplayConfig(); 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 tasks = useTasks(); const calendar = useCalendar(); @@ -43,40 +53,48 @@ function App() { } else if (msg.type === "odoo_refresh") { tasks.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); const connected = !topology.error && !tasks.error && !calendar.error; + const currentView = enabledViews[activeView]; + + const viewContent: Record = { + topology: topology.data ? ( + + ) : ( + + ), + projects: tasks.data ? ( + + ) : ( + + ), + calendar: calendar.data ? ( + + ) : ( + + ), + }; return (
-
+
-
- {topology.data ? ( - - ) : ( - - )} -
-
- {tasks.data ? ( - - ) : ( - - )} -
-
- {calendar.data ? ( - - ) : ( - - )} -
+ {enabledViews.map((v) => ( +
+ {viewContent[v.id]} +
+ ))}
); diff --git a/frontend/src/hooks/useOdooData.ts b/frontend/src/hooks/useOdooData.ts index 22aee28..72fe66f 100644 --- a/frontend/src/hooks/useOdooData.ts +++ b/frontend/src/hooks/useOdooData.ts @@ -76,12 +76,20 @@ export function useCalendar(refreshMs: number = 300_000) { export function useDisplayConfig() { const [config, setConfig] = useState(null); - useEffect(() => { - fetch("/api/services/config") - .then((r) => r.json()) - .then((d) => setConfig(d.display)) - .catch(() => {}); + const refetch = useCallback(async () => { + try { + const res = await fetch("/api/services/config"); + if (!res.ok) return; + const d = await res.json(); + setConfig(d.display); + } catch { + // ignore + } }, []); - return config; + useEffect(() => { + refetch(); + }, [refetch]); + + return { config, refetch }; } diff --git a/frontend/src/hooks/useRotation.ts b/frontend/src/hooks/useRotation.ts index fb0f4f3..7dc0fd3 100644 --- a/frontend/src/hooks/useRotation.ts +++ b/frontend/src/hooks/useRotation.ts @@ -4,6 +4,11 @@ export function useRotation(totalViews: number, intervalMs: number = 30_000) { const [activeView, setActiveView] = useState(0); useEffect(() => { + setActiveView(0); + }, [totalViews]); + + useEffect(() => { + if (totalViews <= 1) return; const id = setInterval(() => { setActiveView((prev) => (prev + 1) % totalViews); }, intervalMs); diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index d2578a9..27d3406 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -4,6 +4,8 @@ import type { WSMessage } from "../types"; export function useWebSocket(onMessage: (msg: WSMessage) => void) { const wsRef = useRef(null); const reconnectTimeout = useRef>(undefined); + const onMessageRef = useRef(onMessage); + onMessageRef.current = onMessage; const connect = useCallback(() => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -12,7 +14,7 @@ export function useWebSocket(onMessage: (msg: WSMessage) => void) { ws.onmessage = (event) => { try { const msg: WSMessage = JSON.parse(event.data); - onMessage(msg); + onMessageRef.current(msg); } catch { // ignore malformed messages } @@ -24,7 +26,7 @@ export function useWebSocket(onMessage: (msg: WSMessage) => void) { ws.onerror = () => ws.close(); wsRef.current = ws; - }, [onMessage]); + }, []); useEffect(() => { connect(); diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts new file mode 100644 index 0000000..4f4746d --- /dev/null +++ b/frontend/src/lib/adminApi.ts @@ -0,0 +1,67 @@ +import type { + AdminSettings, + DisplayConfig, + OdooConfig, + RefreshConfig, + NodeConfig, +} from "../types"; + +const BASE = "/api/admin"; + +async function json(res: Response): Promise { + 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(r)); + +export const updateDisplay = (values: Partial) => + fetch(`${BASE}/settings/display`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }).then((r) => json(r)); + +export const updateOdoo = (values: Partial) => + fetch(`${BASE}/settings/odoo`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }).then((r) => json(r)); + +export const updateRefresh = (values: Partial) => + fetch(`${BASE}/settings/refresh`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }).then((r) => json(r)); + +// ── Nodes ────────────────────────────────────────────────── + +export const getNodes = () => + fetch(`${BASE}/nodes`).then((r) => json(r)); + +export const addNode = (node: NodeConfig) => + fetch(`${BASE}/nodes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(node), + }).then((r) => json(r)); + +export const replaceNodes = (nodes: NodeConfig[]) => + fetch(`${BASE}/nodes`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(nodes), + }).then((r) => json(r)); + +export const deleteNode = (ip: string) => + fetch(`${BASE}/nodes/${encodeURIComponent(ip)}`, { + method: "DELETE", + }).then((r) => json<{ deleted: string }>(r)); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 12fa35b..71462d0 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,16 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; 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( - + {isAdmin ? : } ); diff --git a/frontend/src/pages/admin/AdminApp.tsx b/frontend/src/pages/admin/AdminApp.tsx new file mode 100644 index 0000000..b27711d --- /dev/null +++ b/frontend/src/pages/admin/AdminApp.tsx @@ -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 React.ReactNode> = { + overview: () => , + nodes: () => , + odoo: () => , + display: () => , + refresh: () => , +}; + +export function AdminApp() { + const [activeTab, setActiveTab] = useState("overview"); + + const Content = TAB_CONTENT[activeTab]; + + return ( +
+ {/* Sidebar */} + + + {/* Content */} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx new file mode 100644 index 0000000..00dd4b9 --- /dev/null +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -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 ( + + `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} + + ); +} + +export function AdminLayout() { + return ( +
+ {/* Sidebar */} + + + {/* Content */} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/admin/DashboardOverview.tsx b/frontend/src/pages/admin/DashboardOverview.tsx new file mode 100644 index 0000000..faf7aaf --- /dev/null +++ b/frontend/src/pages/admin/DashboardOverview.tsx @@ -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 ( +
+

{label}

+

{value}

+
+ ); +} + +export function DashboardOverview() { + const [stats, setStats] = useState({ + nodeCount: 0, + proxmoxCount: 0, + vmCount: 0, + settings: null, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

Resumen del Dashboard

+ + {error && ( +
+ Error: {error} +
+ )} + + {loading &&

Cargando datos...

} + +
+ + + + +
+ + {s && ( +
+
+

Odoo

+
+
Servidor
+
{s.odoo.url}
+
Base de datos
+
{s.odoo.database}
+
Usuario
+
{s.odoo.username}
+
+
+
+

Intervalos

+
+
Odoo
+
{s.refresh.odoo_minutes} min
+
Red
+
{s.refresh.network_minutes} min
+
Ping
+
{s.refresh.ping_seconds} seg
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/admin/DisplaySettingsPage.tsx b/frontend/src/pages/admin/DisplaySettingsPage.tsx new file mode 100644 index 0000000..379cd57 --- /dev/null +++ b/frontend/src/pages/admin/DisplaySettingsPage.tsx @@ -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(["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 ( +
+

Configuracion de Pantalla

+
+ + + + + + +
+ Vistas activas +
+ {ALL_VIEWS.map((v) => ( + + ))} +
+
+ + + {activeViews.length === 0 && ( +

Debes seleccionar al menos una vista.

+ )} + {status === "error" && ( +

Error al guardar.

+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/NodeFormModal.tsx b/frontend/src/pages/admin/NodeFormModal.tsx new file mode 100644 index 0000000..8ffd2a9 --- /dev/null +++ b/frontend/src/pages/admin/NodeFormModal.tsx @@ -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(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 ( +
+
e.stopPropagation()} + > +

+ {initial ? "Editar Nodo" : "Agregar Nodo"} +

+
+ + + + +
+ + +
+ + + +
+ + +
+ + + + + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/admin/NodesPage.tsx b/frontend/src/pages/admin/NodesPage.tsx new file mode 100644 index 0000000..7c7cc44 --- /dev/null +++ b/frontend/src/pages/admin/NodesPage.tsx @@ -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([]); + const [editNode, setEditNode] = useState(); + const [showModal, setShowModal] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(null); + const [error, setError] = useState(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 ( +
+
+

Nodos de Red

+ +
+ + {error && ( +
+ Error: {error} +
+ )} + +
+ + + + + + + + + + + + + {nodes.map((node) => ( + + + + + + + + + ))} + +
NombreIPTipoParentIconoAcciones
{node.name}{node.ip} + {node.type && ( + + {node.type} + + )} + {node.parent ?? "—"}{node.icon} +
+ + {confirmDelete === node.ip ? ( +
+ + +
+ ) : ( + + )} +
+
+ {nodes.length === 0 && ( +

No hay nodos configurados.

+ )} +
+ + {showModal && ( + setShowModal(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/admin/OdooSettingsPage.tsx b/frontend/src/pages/admin/OdooSettingsPage.tsx new file mode 100644 index 0000000..49fd241 --- /dev/null +++ b/frontend/src/pages/admin/OdooSettingsPage.tsx @@ -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({ + 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") => ( + + ); + + return ( +
+

Configuracion Odoo

+
+ {field("URL del servidor", "url")} + {field("Base de datos", "database")} + {field("Usuario", "username")} + {field("Contrasena", "password", "password")} + + + {status === "error" && ( +

Error al guardar. Revisa la conexion.

+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/RefreshSettingsPage.tsx b/frontend/src/pages/admin/RefreshSettingsPage.tsx new file mode 100644 index 0000000..2edff6b --- /dev/null +++ b/frontend/src/pages/admin/RefreshSettingsPage.tsx @@ -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 ( +
+

Intervalos de Actualizacion

+
+ + + + + + + + {status === "error" && ( +

Error al guardar.

+ )} +
+
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5e9b4ec..7311e68 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -62,9 +62,44 @@ export interface DisplayConfig { rotation_interval_seconds: number; transition: string; theme: string; + active_views?: string[]; } export interface WSMessage { type: string; data: Record; } + +// ── 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; +}