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:
@@ -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/ {
|
||||
|
||||
703
frontend/package-lock.json
generated
703
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 (
|
||||
<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}>
|
||||
<div className="h-full">
|
||||
{topology.data ? (
|
||||
<NetworkGraph nodes={topology.data.nodes} />
|
||||
) : (
|
||||
<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>
|
||||
{enabledViews.map((v) => (
|
||||
<div key={v.id} className="h-full">
|
||||
{viewContent[v.id]}
|
||||
</div>
|
||||
))}
|
||||
</ViewRotator>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -76,12 +76,20 @@ export function useCalendar(refreshMs: number = 300_000) {
|
||||
export function useDisplayConfig() {
|
||||
const [config, setConfig] = useState<DisplayConfig | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { WSMessage } from "../types";
|
||||
export function useWebSocket(onMessage: (msg: WSMessage) => void) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout>>(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();
|
||||
|
||||
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 "./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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
{isAdmin ? <AdminApp /> : <App />}
|
||||
</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;
|
||||
transition: string;
|
||||
theme: string;
|
||||
active_views?: string[];
|
||||
}
|
||||
|
||||
export interface WSMessage {
|
||||
type: string;
|
||||
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