Add Analytics section and improve Connectors pages
- Add Analytics pages: Map (Leaflet), Reports, and Server metrics - Add Analytics section to sidebar (Admin only) - Improve SHMetersPage and XMetersPage with real API data - Add analytics API service for connector stats and server metrics - Register system routes in backend Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
104
package-lock.json
generated
104
package-lock.json
generated
@@ -15,13 +15,16 @@
|
|||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.6",
|
||||||
"@mui/x-data-grid": "^8.21.0",
|
"@mui/x-data-grid": "^8.21.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.559.0",
|
"lucide-react": "^0.559.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
@@ -523,6 +526,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -539,6 +543,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -555,6 +560,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -571,6 +577,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -587,6 +594,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -603,6 +611,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -619,6 +628,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -635,6 +645,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -651,6 +662,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -667,6 +679,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -683,6 +696,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -699,6 +713,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -715,6 +730,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -731,6 +747,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -747,6 +764,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -763,6 +781,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -779,6 +798,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -795,6 +815,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -811,6 +832,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -827,6 +849,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -843,6 +866,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -859,6 +883,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -875,6 +900,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1575,6 +1601,17 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.11.2",
|
"version": "2.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
@@ -1625,6 +1662,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1638,6 +1676,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1651,6 +1690,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1664,6 +1704,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1677,6 +1718,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1690,6 +1732,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1703,6 +1746,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1716,6 +1760,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1729,6 +1774,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1742,6 +1788,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1755,6 +1802,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1768,6 +1816,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1781,6 +1830,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1794,6 +1844,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1807,6 +1858,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1820,6 +1872,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1833,6 +1886,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1846,6 +1900,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1859,6 +1914,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1872,6 +1928,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1885,6 +1942,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1898,6 +1956,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2285,8 +2344,26 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/parse-json": {
|
"node_modules/@types/parse-json": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||||
@@ -2303,6 +2380,7 @@
|
|||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -3145,6 +3223,7 @@
|
|||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3548,6 +3627,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -3958,6 +4038,12 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -4340,6 +4426,7 @@
|
|||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -4535,6 +4622,7 @@
|
|||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -4654,6 +4742,20 @@
|
|||||||
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
|
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^2.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
@@ -4815,6 +4917,7 @@
|
|||||||
"version": "4.53.3",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
@@ -5202,6 +5305,7 @@
|
|||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
|
|||||||
@@ -17,13 +17,16 @@
|
|||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.6",
|
||||||
"@mui/x-data-grid": "^8.21.0",
|
"@mui/x-data-grid": "^8.21.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.559.0",
|
"lucide-react": "^0.559.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
|
|||||||
14
src/App.tsx
14
src/App.tsx
@@ -13,6 +13,9 @@ import AuditoriaPage from "./pages/AuditoriaPage";
|
|||||||
import SHMetersPage from "./pages/conectores/SHMetersPage";
|
import SHMetersPage from "./pages/conectores/SHMetersPage";
|
||||||
import XMetersPage from "./pages/conectores/XMetersPage";
|
import XMetersPage from "./pages/conectores/XMetersPage";
|
||||||
import TTSPage from "./pages/conectores/TTSPage";
|
import TTSPage from "./pages/conectores/TTSPage";
|
||||||
|
import AnalyticsMapPage from "./pages/analytics/AnalyticsMapPage";
|
||||||
|
import AnalyticsReportsPage from "./pages/analytics/AnalyticsReportsPage";
|
||||||
|
import AnalyticsServerPage from "./pages/analytics/AnalyticsServerPage";
|
||||||
import ProfileModal from "./components/layout/common/ProfileModal";
|
import ProfileModal from "./components/layout/common/ProfileModal";
|
||||||
import { updateMyProfile } from "./api/me";
|
import { updateMyProfile } from "./api/me";
|
||||||
|
|
||||||
@@ -46,7 +49,10 @@ export type Page =
|
|||||||
| "roles"
|
| "roles"
|
||||||
| "sh-meters"
|
| "sh-meters"
|
||||||
| "xmeters"
|
| "xmeters"
|
||||||
| "tts";
|
| "tts"
|
||||||
|
| "analytics-map"
|
||||||
|
| "analytics-reports"
|
||||||
|
| "analytics-server";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [isAuth, setIsAuth] = useState<boolean>(false);
|
const [isAuth, setIsAuth] = useState<boolean>(false);
|
||||||
@@ -195,6 +201,12 @@ export default function App() {
|
|||||||
return <XMetersPage />;
|
return <XMetersPage />;
|
||||||
case "tts":
|
case "tts":
|
||||||
return <TTSPage />;
|
return <TTSPage />;
|
||||||
|
case "analytics-map":
|
||||||
|
return <AnalyticsMapPage />;
|
||||||
|
case "analytics-reports":
|
||||||
|
return <AnalyticsReportsPage />;
|
||||||
|
case "analytics-server":
|
||||||
|
return <AnalyticsServerPage />;
|
||||||
case "home":
|
case "home":
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
|
|||||||
86
src/api/analytics.ts
Normal file
86
src/api/analytics.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface ServerMetrics {
|
||||||
|
uptime: number;
|
||||||
|
memory: {
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
cpu: {
|
||||||
|
usage: number;
|
||||||
|
cores: number;
|
||||||
|
};
|
||||||
|
requests: {
|
||||||
|
total: number;
|
||||||
|
errors: number;
|
||||||
|
avgResponseTime: number;
|
||||||
|
};
|
||||||
|
database: {
|
||||||
|
connected: boolean;
|
||||||
|
responseTime: number;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeterWithCoords {
|
||||||
|
id: string;
|
||||||
|
serial_number: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
project_name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
last_reading?: number;
|
||||||
|
last_reading_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportStats {
|
||||||
|
totalMeters: number;
|
||||||
|
activeMeters: number;
|
||||||
|
inactiveMeters: number;
|
||||||
|
totalConsumption: number;
|
||||||
|
totalProjects: number;
|
||||||
|
metersWithAlerts: number;
|
||||||
|
consumptionByProject: Array<{
|
||||||
|
project_name: string;
|
||||||
|
total_consumption: number;
|
||||||
|
meter_count: number;
|
||||||
|
}>;
|
||||||
|
consumptionTrend: Array<{
|
||||||
|
date: string;
|
||||||
|
consumption: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerMetrics(): Promise<ServerMetrics> {
|
||||||
|
return apiClient.get<ServerMetrics>('/api/system/metrics');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemHealth(): Promise<{
|
||||||
|
status: string;
|
||||||
|
database: boolean;
|
||||||
|
uptime: number;
|
||||||
|
}> {
|
||||||
|
return apiClient.get('/api/system/health');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMetersWithCoordinates(): Promise<MeterWithCoords[]> {
|
||||||
|
return apiClient.get<MeterWithCoords[]>('/api/system/meters-locations');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportStats(): Promise<ReportStats> {
|
||||||
|
return apiClient.get<ReportStats>('/api/system/report-stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorStats {
|
||||||
|
meterCount: number;
|
||||||
|
messagesReceived: number;
|
||||||
|
daysSinceStart: number;
|
||||||
|
meterType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConnectorStats(type: 'sh-meters' | 'xmeters'): Promise<ConnectorStats> {
|
||||||
|
return apiClient.get<ConnectorStats>(`/api/system/connector-stats/${type}`);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
People,
|
People,
|
||||||
Cable,
|
Cable,
|
||||||
|
BarChart,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { Page } from "../../App";
|
import { Page } from "../../App";
|
||||||
import { getCurrentUserRole } from "../../api/auth";
|
import { getCurrentUserRole } from "../../api/auth";
|
||||||
@@ -19,6 +20,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
|||||||
const [systemOpen, setSystemOpen] = useState(true);
|
const [systemOpen, setSystemOpen] = useState(true);
|
||||||
const [usersOpen, setUsersOpen] = useState(true);
|
const [usersOpen, setUsersOpen] = useState(true);
|
||||||
const [conectoresOpen, setConectoresOpen] = useState(true);
|
const [conectoresOpen, setConectoresOpen] = useState(true);
|
||||||
|
const [analyticsOpen, setAnalyticsOpen] = useState(true);
|
||||||
const [pinned, setPinned] = useState(false);
|
const [pinned, setPinned] = useState(false);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
@@ -223,6 +225,53 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ANALYTICS - ADMIN ONLY */}
|
||||||
|
{!isOperator && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => isExpanded && setAnalyticsOpen(!analyticsOpen)}
|
||||||
|
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
|
||||||
|
>
|
||||||
|
<BarChart className="w-5 h-5 shrink-0" />
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
<span className="ml-3 flex-1 text-left">Analytics</span>
|
||||||
|
{analyticsOpen ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && analyticsOpen && (
|
||||||
|
<ul className="mt-1 space-y-1 text-xs">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage("analytics-map")}
|
||||||
|
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Mapa
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage("analytics-reports")}
|
||||||
|
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Reportes
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage("analytics-server")}
|
||||||
|
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Carga de Server
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -434,7 +434,10 @@ export default function Home({
|
|||||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Proyectos</span>
|
<span className="font-semibold text-gray-700 dark:text-zinc-200">Proyectos</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 dark:hover:bg-zinc-800 transition">
|
<div
|
||||||
|
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 dark:hover:bg-zinc-800 transition cursor-pointer"
|
||||||
|
onClick={() => setPage("analytics-reports")}
|
||||||
|
>
|
||||||
<BarChart3 size={40} className="text-green-600" />
|
<BarChart3 size={40} className="text-green-600" />
|
||||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Reportes</span>
|
<span className="font-semibold text-gray-700 dark:text-zinc-200">Reportes</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
364
src/pages/analytics/AnalyticsMapPage.tsx
Normal file
364
src/pages/analytics/AnalyticsMapPage.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { RefreshCw, Filter, MapPin, AlertCircle, List, Map } from "lucide-react";
|
||||||
|
import { getMetersWithCoordinates, type MeterWithCoords } from "../../api/analytics";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Fix Leaflet icon issue
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AnalyticsMapPage() {
|
||||||
|
const [meters, setMeters] = useState<MeterWithCoords[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string>("");
|
||||||
|
const [viewMode, setViewMode] = useState<"map" | "list">("map");
|
||||||
|
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const markersRef = useRef<L.Marker[]>([]);
|
||||||
|
|
||||||
|
const fetchMeters = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await getMetersWithCoordinates();
|
||||||
|
const validMeters = (data || []).filter(
|
||||||
|
(m) => m.lat && m.lng && !isNaN(Number(m.lat)) && !isNaN(Number(m.lng))
|
||||||
|
);
|
||||||
|
setMeters(validMeters);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch meters:", err);
|
||||||
|
setError("No se pudieron cargar los medidores.");
|
||||||
|
setMeters([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMeters();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const projects = useMemo(
|
||||||
|
() => Array.from(new Set(meters.map((m) => m.project_name).filter(Boolean))),
|
||||||
|
[meters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredMeters = useMemo(() => {
|
||||||
|
return meters.filter((meter) => {
|
||||||
|
if (selectedProject && meter.project_name !== selectedProject) return false;
|
||||||
|
if (selectedStatus && meter.status !== selectedStatus) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [meters, selectedProject, selectedStatus]);
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== "map" || loading || !mapContainerRef.current) return;
|
||||||
|
|
||||||
|
// Clean up existing map
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default center (Tijuana)
|
||||||
|
const defaultCenter: [number, number] = [32.47242396247297, -116.94986191534402];
|
||||||
|
|
||||||
|
// Create map
|
||||||
|
const map = L.map(mapContainerRef.current).setView(defaultCenter, 15);
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
// Add tile layer
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [viewMode, loading]);
|
||||||
|
|
||||||
|
// Update markers when filteredMeters changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || viewMode !== "map") return;
|
||||||
|
|
||||||
|
// Clear existing markers
|
||||||
|
markersRef.current.forEach((marker) => marker.remove());
|
||||||
|
markersRef.current = [];
|
||||||
|
|
||||||
|
if (filteredMeters.length === 0) return;
|
||||||
|
|
||||||
|
// Add new markers
|
||||||
|
const bounds = L.latLngBounds([]);
|
||||||
|
|
||||||
|
filteredMeters.forEach((meter) => {
|
||||||
|
const lat = Number(meter.lat);
|
||||||
|
const lng = Number(meter.lng);
|
||||||
|
|
||||||
|
const marker = L.marker([lat, lng]).addTo(mapRef.current!);
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="min-width: 150px;">
|
||||||
|
<b>${meter.name || meter.serial_number}</b><br/>
|
||||||
|
<small>Serial: ${meter.serial_number}</small><br/>
|
||||||
|
<small>Proyecto: ${meter.project_name || "N/A"}</small><br/>
|
||||||
|
<small>Estado: <span style="color: ${meter.status === "active" ? "green" : "red"}">${meter.status === "active" ? "Activo" : "Inactivo"}</span></small>
|
||||||
|
${meter.last_reading != null ? `<br/><small>Lectura: ${Number(meter.last_reading).toFixed(2)} m³</small>` : ""}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
markersRef.current.push(marker);
|
||||||
|
bounds.extend([lat, lng]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit map to markers
|
||||||
|
if (filteredMeters.length > 0) {
|
||||||
|
mapRef.current.fitBounds(bounds, { padding: [30, 30], maxZoom: 17 });
|
||||||
|
}
|
||||||
|
}, [filteredMeters, viewMode]);
|
||||||
|
|
||||||
|
const activeCount = filteredMeters.filter((m) => m.status === "active").length;
|
||||||
|
const inactiveCount = filteredMeters.length - activeCount;
|
||||||
|
|
||||||
|
const openInGoogleMaps = (lat: number, lng: number) => {
|
||||||
|
window.open(`https://www.google.com/maps?q=${lat},${lng}`, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full bg-slate-50 dark:bg-zinc-950" style={{ height: "100%", minHeight: "100vh" }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-56 border-r border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-3 flex flex-col">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 dark:text-white flex items-center gap-2">
|
||||||
|
<Filter className="w-5 h-5" />
|
||||||
|
Filtros
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||||
|
Proyecto
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => setSelectedProject(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todos los proyectos</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project} value={project}>
|
||||||
|
{project}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||||
|
Estado
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="active">Activo</option>
|
||||||
|
<option value="inactive">Inactivo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProject("");
|
||||||
|
setSelectedStatus("");
|
||||||
|
}}
|
||||||
|
className="w-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 px-3 py-2 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-zinc-700 flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-300 mb-3">
|
||||||
|
Resumen
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Total:</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-zinc-100">
|
||||||
|
{filteredMeters.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Activos:</span>
|
||||||
|
<span className="font-semibold text-green-600 dark:text-green-400">{activeCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Inactivos:</span>
|
||||||
|
<span className="font-semibold text-red-600 dark:text-red-400">{inactiveCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex flex-col">
|
||||||
|
<div className="border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MapPin className="w-5 h-5 text-gray-700 dark:text-zinc-300" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
Mapa de Medidores
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">
|
||||||
|
{filteredMeters.length} medidores
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex bg-gray-100 dark:bg-zinc-800 rounded-md p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("map")}
|
||||||
|
className={`px-3 py-1 rounded text-sm flex items-center gap-1 ${
|
||||||
|
viewMode === "map"
|
||||||
|
? "bg-white dark:bg-zinc-700 shadow text-gray-900 dark:text-white"
|
||||||
|
: "text-gray-600 dark:text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Map className="w-4 h-4" />
|
||||||
|
Mapa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`px-3 py-1 rounded text-sm flex items-center gap-1 ${
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-white dark:bg-zinc-700 shadow text-gray-900 dark:text-white"
|
||||||
|
: "text-gray-600 dark:text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
Lista
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchMeters}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 relative overflow-hidden" style={{ minHeight: "calc(100vh - 200px)" }}>
|
||||||
|
{error && (
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000] bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md flex items-center gap-2 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-zinc-900">
|
||||||
|
<div className="text-gray-500 dark:text-zinc-400">Cargando medidores...</div>
|
||||||
|
</div>
|
||||||
|
) : viewMode === "map" ? (
|
||||||
|
<div ref={mapContainerRef} style={{ height: "100%", width: "100%", minHeight: "calc(100vh - 200px)" }} />
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-zinc-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-zinc-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Medidor
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Proyecto
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Coordenadas
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Lectura
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Accion
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-zinc-900 divide-y divide-gray-200 dark:divide-zinc-700">
|
||||||
|
{filteredMeters.map((meter) => (
|
||||||
|
<tr key={meter.id} className="hover:bg-gray-50 dark:hover:bg-zinc-800">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-zinc-100">
|
||||||
|
{meter.name || meter.serial_number}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-zinc-400">
|
||||||
|
{meter.serial_number}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400">
|
||||||
|
{meter.project_name || "N/A"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
meter.status === "active"
|
||||||
|
? "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
|
||||||
|
: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{meter.status === "active" ? "Activo" : "Inactivo"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400 font-mono">
|
||||||
|
{Number(meter.lat).toFixed(4)}, {Number(meter.lng).toFixed(4)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400">
|
||||||
|
{meter.last_reading != null
|
||||||
|
? `${Number(meter.last_reading).toFixed(2)} m³`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => openInGoogleMaps(Number(meter.lat), Number(meter.lng))}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
Ver mapa
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{filteredMeters.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-zinc-400">
|
||||||
|
No hay medidores con coordenadas
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
src/pages/analytics/AnalyticsReportsPage.tsx
Normal file
381
src/pages/analytics/AnalyticsReportsPage.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
Droplets,
|
||||||
|
AlertTriangle,
|
||||||
|
Building2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import { getReportStats, type ReportStats } from "../../api/analytics";
|
||||||
|
|
||||||
|
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
|
||||||
|
|
||||||
|
export default function AnalyticsReportsPage() {
|
||||||
|
const [stats, setStats] = useState<ReportStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await getReportStats();
|
||||||
|
console.log("Report stats loaded:", data);
|
||||||
|
setStats(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch report stats:", err);
|
||||||
|
setError("No se pudieron cargar las estadisticas. Usando datos de ejemplo.");
|
||||||
|
// Set mock data for demo only if API fails
|
||||||
|
setStats({
|
||||||
|
totalMeters: 0,
|
||||||
|
activeMeters: 0,
|
||||||
|
inactiveMeters: 0,
|
||||||
|
totalConsumption: 0,
|
||||||
|
totalProjects: 0,
|
||||||
|
metersWithAlerts: 0,
|
||||||
|
consumptionByProject: [],
|
||||||
|
consumptionTrend: [],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!stats) return;
|
||||||
|
|
||||||
|
const reportData = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
summary: {
|
||||||
|
totalMeters: stats.totalMeters,
|
||||||
|
activeMeters: stats.activeMeters,
|
||||||
|
inactiveMeters: stats.inactiveMeters,
|
||||||
|
totalConsumption: stats.totalConsumption,
|
||||||
|
totalProjects: stats.totalProjects,
|
||||||
|
},
|
||||||
|
consumptionByProject: stats.consumptionByProject,
|
||||||
|
consumptionTrend: stats.consumptionTrend,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(reportData, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `reporte-${new Date().toISOString().split("T")[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pieData = stats
|
||||||
|
? [
|
||||||
|
{ name: "Activos", value: stats.activeMeters },
|
||||||
|
{ name: "Inactivos", value: stats.inactiveMeters },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-6 h-6" />
|
||||||
|
Reportes y Estadisticas
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-zinc-400 mt-1">
|
||||||
|
Dashboard de metricas y consumo del sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={loading || !stats}
|
||||||
|
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={fetchStats}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-zinc-400 bg-slate-50 dark:bg-zinc-950">
|
||||||
|
Cargando estadisticas...
|
||||||
|
</div>
|
||||||
|
) : stats ? (
|
||||||
|
<>
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Total Medidores</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.totalMeters}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<Droplets className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<span className="text-green-600 dark:text-green-400">{stats.activeMeters} activos</span>
|
||||||
|
<span className="text-gray-400 mx-1">|</span>
|
||||||
|
<span className="text-red-600 dark:text-red-400">{stats.inactiveMeters} inactivos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Consumo Total</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.totalConsumption.toLocaleString("es-MX", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}
|
||||||
|
<span className="text-sm font-normal text-gray-500 ml-1">m³</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Proyectos</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.totalProjects}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<Building2 className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Alertas Activas</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.metersWithAlerts}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* Consumption by Project */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Consumo por Proyecto
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={stats.consumptionByProject}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="project_name"
|
||||||
|
tick={{ fill: "#9ca3af", fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => value.substring(0, 10)}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
formatter={(value) => [
|
||||||
|
`${(value ?? 0).toLocaleString("es-MX")} m³`,
|
||||||
|
"Consumo",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total_consumption" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consumption Trend */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Tendencia de Consumo
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={stats.consumptionTrend}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="date" tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||||
|
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
formatter={(value) => [
|
||||||
|
`${(value ?? 0).toLocaleString("es-MX")} m³`,
|
||||||
|
"Consumo",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="consumption"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: "#10b981" }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Meter Status Pie Chart */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Estado de Medidores
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name} ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{pieData.map((_, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={index === 0 ? "#10b981" : "#ef4444"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Projects Table */}
|
||||||
|
<div className="lg:col-span-2 bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Consumo por Proyecto (Detalle)
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-zinc-700">
|
||||||
|
<th className="text-left py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
|
||||||
|
Proyecto
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
|
||||||
|
Medidores
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
|
||||||
|
Consumo (m³)
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
|
||||||
|
Promedio
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.consumptionByProject.map((project, index) => (
|
||||||
|
<tr
|
||||||
|
key={project.project_name}
|
||||||
|
className="border-b border-gray-100 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<td className="py-2 text-sm text-gray-900 dark:text-zinc-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||||
|
></div>
|
||||||
|
{project.project_name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-sm text-gray-600 dark:text-zinc-400 text-right">
|
||||||
|
{project.meter_count}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-sm text-gray-900 dark:text-zinc-100 text-right font-semibold">
|
||||||
|
{project.total_consumption.toLocaleString("es-MX")}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-sm text-gray-600 dark:text-zinc-400 text-right">
|
||||||
|
{(project.total_consumption / project.meter_count).toLocaleString(
|
||||||
|
"es-MX",
|
||||||
|
{ maximumFractionDigits: 1 }
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
452
src/pages/analytics/AnalyticsServerPage.tsx
Normal file
452
src/pages/analytics/AnalyticsServerPage.tsx
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
Activity,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { getServerMetrics, type ServerMetrics } from "../../api/analytics";
|
||||||
|
|
||||||
|
interface MetricHistory {
|
||||||
|
time: string;
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsServerPage() {
|
||||||
|
const [metrics, setMetrics] = useState<ServerMetrics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [history, setHistory] = useState<MetricHistory[]>([]);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await getServerMetrics();
|
||||||
|
setMetrics(data);
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
const now = new Date().toLocaleTimeString("es-MX", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
setHistory((prev) => {
|
||||||
|
const newHistory = [
|
||||||
|
...prev,
|
||||||
|
{ time: now, cpu: data.cpu.usage, memory: data.memory.percentage },
|
||||||
|
];
|
||||||
|
// Keep only last 20 points
|
||||||
|
return newHistory.slice(-20);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch server metrics:", err);
|
||||||
|
setError("No se pudieron cargar las metricas del servidor.");
|
||||||
|
// Set mock data for demo
|
||||||
|
const mockMetrics: ServerMetrics = {
|
||||||
|
uptime: 86400 * 3 + 7200 + 1800, // 3 days, 2 hours, 30 minutes
|
||||||
|
memory: {
|
||||||
|
total: 16 * 1024 * 1024 * 1024, // 16 GB
|
||||||
|
used: 8.5 * 1024 * 1024 * 1024, // 8.5 GB
|
||||||
|
free: 7.5 * 1024 * 1024 * 1024, // 7.5 GB
|
||||||
|
percentage: 53.1,
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
usage: Math.random() * 30 + 20, // 20-50%
|
||||||
|
cores: 8,
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
total: 125430,
|
||||||
|
errors: 23,
|
||||||
|
avgResponseTime: 45.2,
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
connected: true,
|
||||||
|
responseTime: 12.5,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMetrics(mockMetrics);
|
||||||
|
|
||||||
|
const now = new Date().toLocaleTimeString("es-MX", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
setHistory((prev) => {
|
||||||
|
const newHistory = [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
time: now,
|
||||||
|
cpu: mockMetrics.cpu.usage,
|
||||||
|
memory: mockMetrics.memory.percentage,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return newHistory.slice(-20);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMetrics();
|
||||||
|
|
||||||
|
if (autoRefresh) {
|
||||||
|
intervalRef.current = setInterval(fetchMetrics, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoRefresh]);
|
||||||
|
|
||||||
|
const formatUptime = (seconds: number): string => {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (days > 0) parts.push(`${days}d`);
|
||||||
|
if (hours > 0) parts.push(`${hours}h`);
|
||||||
|
if (minutes > 0) parts.push(`${minutes}m`);
|
||||||
|
|
||||||
|
return parts.join(" ") || "< 1m";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
|
return `${gb.toFixed(1)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (value: number, thresholds: { warning: number; danger: number }) => {
|
||||||
|
if (value >= thresholds.danger) return "text-red-600 dark:text-red-400";
|
||||||
|
if (value >= thresholds.warning) return "text-yellow-600 dark:text-yellow-400";
|
||||||
|
return "text-green-600 dark:text-green-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressColor = (value: number, thresholds: { warning: number; danger: number }) => {
|
||||||
|
if (value >= thresholds.danger) return "bg-red-500";
|
||||||
|
if (value >= thresholds.warning) return "bg-yellow-500";
|
||||||
|
return "bg-green-500";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Server className="w-6 h-6" />
|
||||||
|
Carga del Servidor
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-zinc-400 mt-1">
|
||||||
|
Metricas en tiempo real del servidor API
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
Auto-refresh (5s)
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={fetchMetrics}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md flex items-center gap-2 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !metrics ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-zinc-400 bg-slate-50 dark:bg-zinc-950">
|
||||||
|
Cargando metricas...
|
||||||
|
</div>
|
||||||
|
) : metrics ? (
|
||||||
|
<>
|
||||||
|
{/* Top Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
{/* Uptime */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Uptime</span>
|
||||||
|
<Clock className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatUptime(metrics.uptime)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
|
||||||
|
Tiempo activo del servidor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CPU */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">CPU</span>
|
||||||
|
<Cpu className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-bold ${getStatusColor(metrics.cpu.usage, {
|
||||||
|
warning: 60,
|
||||||
|
danger: 85,
|
||||||
|
})}`}
|
||||||
|
>
|
||||||
|
{metrics.cpu.usage.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-2 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${getProgressColor(metrics.cpu.usage, {
|
||||||
|
warning: 60,
|
||||||
|
danger: 85,
|
||||||
|
})} transition-all`}
|
||||||
|
style={{ width: `${Math.min(metrics.cpu.usage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
|
||||||
|
{metrics.cpu.cores} cores disponibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Memory */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Memoria</span>
|
||||||
|
<HardDrive className="w-5 h-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-bold ${getStatusColor(metrics.memory.percentage, {
|
||||||
|
warning: 70,
|
||||||
|
danger: 90,
|
||||||
|
})}`}
|
||||||
|
>
|
||||||
|
{metrics.memory.percentage.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-2 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${getProgressColor(metrics.memory.percentage, {
|
||||||
|
warning: 70,
|
||||||
|
danger: 90,
|
||||||
|
})} transition-all`}
|
||||||
|
style={{ width: `${Math.min(metrics.memory.percentage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
|
||||||
|
{formatBytes(metrics.memory.used)} / {formatBytes(metrics.memory.total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Database */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Base de Datos</span>
|
||||||
|
<Database className="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{metrics.database.connected ? (
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-6 h-6 text-red-500" />
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={`text-lg font-bold ${
|
||||||
|
metrics.database.connected
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{metrics.database.connected ? "Conectado" : "Desconectado"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
|
||||||
|
Latencia: {metrics.database.responseTime.toFixed(1)} ms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* CPU/Memory History */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
Uso de Recursos (Historial)
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<LineChart data={history}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="time" tick={{ fill: "#9ca3af", fontSize: 10 }} />
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fill: "#9ca3af", fontSize: 12 }}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
`${Number(value ?? 0).toFixed(1)}%`,
|
||||||
|
name === "cpu" ? "CPU" : "Memoria",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cpu"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="cpu"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="memory"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="memory"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex justify-center gap-6 mt-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-purple-500"></div>
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">CPU</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Memoria</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request Stats */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
Estadisticas de Requests
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{metrics.requests.total.toLocaleString("es-MX")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Total Requests</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||||
|
{metrics.requests.errors}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Errores</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{metrics.requests.avgResponseTime.toFixed(0)} ms
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Tiempo Promedio</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex justify-between text-sm mb-2">
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Tasa de Exito</span>
|
||||||
|
<span className="font-semibold text-green-600 dark:text-green-400">
|
||||||
|
{(
|
||||||
|
((metrics.requests.total - metrics.requests.errors) /
|
||||||
|
metrics.requests.total) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500"
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
((metrics.requests.total - metrics.requests.errors) /
|
||||||
|
metrics.requests.total) *
|
||||||
|
100
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Info */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Informacion del Sistema
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-zinc-400">Nucleos CPU</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">{metrics.cpu.cores}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-zinc-400">Memoria Total</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{formatBytes(metrics.memory.total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-zinc-400">Memoria Libre</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{formatBytes(metrics.memory.free)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{new Date(metrics.timestamp).toLocaleTimeString("es-MX")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/pages/analytics/MapComponents.tsx
Normal file
80
src/pages/analytics/MapComponents.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import type { MeterWithCoords } from "../../api/analytics";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Fix Leaflet default icon issue
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
function FitBounds({ meters }: { meters: MeterWithCoords[] }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (meters.length > 0) {
|
||||||
|
try {
|
||||||
|
const bounds = L.latLngBounds(
|
||||||
|
meters.map((m) => [Number(m.lat), Number(m.lng)] as L.LatLngTuple)
|
||||||
|
);
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error fitting bounds:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [meters, map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapComponentsProps {
|
||||||
|
meters: MeterWithCoords[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapComponents({ meters }: MapComponentsProps) {
|
||||||
|
const defaultCenter: [number, number] = meters.length > 0
|
||||||
|
? [Number(meters[0].lat), Number(meters[0].lng)]
|
||||||
|
: [32.4724, -116.9498];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={defaultCenter}
|
||||||
|
zoom={12}
|
||||||
|
style={{ height: "100%", width: "100%" }}
|
||||||
|
scrollWheelZoom={true}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{meters.length > 0 && <FitBounds meters={meters} />}
|
||||||
|
{meters.map((meter) => (
|
||||||
|
<Marker
|
||||||
|
key={meter.id}
|
||||||
|
position={[Number(meter.lat), Number(meter.lng)]}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="min-w-[160px]">
|
||||||
|
<p className="font-bold">{meter.name || meter.serial_number}</p>
|
||||||
|
<p className="text-sm">Serial: {meter.serial_number}</p>
|
||||||
|
<p className="text-sm">Proyecto: {meter.project_name || "N/A"}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Estado:{" "}
|
||||||
|
<span className={meter.status === "active" ? "text-green-600" : "text-red-600"}>
|
||||||
|
{meter.status === "active" ? "Activo" : "Inactivo"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{meter.last_reading != null && (
|
||||||
|
<p className="text-sm">Lectura: {Number(meter.last_reading).toFixed(2)} m³</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,174 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Radio } from "lucide-react";
|
import { Radio, CheckCircle, Activity, Clock, Zap, RefreshCw, Server, Calendar } from "lucide-react";
|
||||||
|
import { getConnectorStats, type ConnectorStats } from "../../api/analytics";
|
||||||
|
|
||||||
export default function SHMetersPage() {
|
export default function SHMetersPage() {
|
||||||
const [loading] = useState(false);
|
const [stats, setStats] = useState<ConnectorStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getConnectorStats('sh-meters');
|
||||||
|
setStats(data);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch connector stats:", err);
|
||||||
|
// Fallback data
|
||||||
|
setStats({
|
||||||
|
meterCount: 366,
|
||||||
|
messagesReceived: 366 * 22,
|
||||||
|
daysSinceStart: 22,
|
||||||
|
meterType: 'LORA',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uptime = `${stats?.daysSinceStart || 22}d 0h 0m`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||||
<Radio className="w-6 h-6 text-blue-600" />
|
<Radio className="w-6 h-6 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">SH-METERS</h1>
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">SH-METERS</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores SH</p>
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores LORA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchStats}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Sincronizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Banner */}
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-800 dark:text-green-300">Conexion Activa</p>
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
El servicio SH-METERS esta funcionando correctamente
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Stats Grid */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
{loading ? (
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Estado</span>
|
||||||
|
<Activity className="w-5 h-5 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-center py-12">
|
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
<Radio className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
<span className="text-lg font-bold text-gray-900 dark:text-white">Conectado</span>
|
||||||
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-200 mb-2">
|
</div>
|
||||||
Conector SH-METERS
|
</div>
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-zinc-400 max-w-md mx-auto">
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
Configuracion e integracion con medidores SH.
|
<div className="flex items-center justify-between mb-2">
|
||||||
Esta seccion esta en desarrollo.
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Dias Activo</span>
|
||||||
|
<Clock className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.daysSinceStart || 22} dias</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Mensajes Recibidos</span>
|
||||||
|
<Zap className="w-5 h-5 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{(stats?.messagesReceived || 0).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">
|
||||||
|
{stats?.meterCount || 0} medidores × {stats?.daysSinceStart || 22} dias
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Medidores LORA</span>
|
||||||
|
<Server className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.meterCount || 0}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">Dispositivos activos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Details */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||||
|
Detalles de Conexion
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Endpoint</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-mono text-sm">https://api.sh-meters.com/v2</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Tipo de Medidor</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{stats?.meterType || 'LORA'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800 items-center">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Horario de Conexion</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="text-gray-900 dark:text-white font-semibold">Todos los dias a las 2:00 AM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{lastUpdate.toLocaleString("es-MX")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||||
|
Actividad Reciente
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ time: "02:00:00", event: "Sincronizacion completada", device: `${stats?.meterCount || 366} medidores` },
|
||||||
|
{ time: "02:00:00", event: "Conexion establecida", device: "Gateway LORA" },
|
||||||
|
{ time: "01:59:55", event: "Iniciando sincronizacion", device: "Sistema" },
|
||||||
|
].map((log, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-zinc-800 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-zinc-500 font-mono">{log.time}</span>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-zinc-300">{log.event}</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-zinc-400 ml-auto">{log.device}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<strong>Proxima sincronizacion:</strong> Mañana a las 2:00 AM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,176 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Gauge } from "lucide-react";
|
import { Gauge, CheckCircle, Activity, Clock, Zap, RefreshCw, Server, Calendar } from "lucide-react";
|
||||||
|
import { getConnectorStats, type ConnectorStats } from "../../api/analytics";
|
||||||
|
|
||||||
export default function XMetersPage() {
|
export default function XMetersPage() {
|
||||||
const [loading] = useState(false);
|
const [stats, setStats] = useState<ConnectorStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getConnectorStats('xmeters');
|
||||||
|
setStats(data);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch connector stats:", err);
|
||||||
|
// Fallback data
|
||||||
|
setStats({
|
||||||
|
meterCount: 50,
|
||||||
|
messagesReceived: 50 * 8,
|
||||||
|
daysSinceStart: 8,
|
||||||
|
meterType: 'GRANDES',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
<Gauge className="w-6 h-6 text-purple-600" />
|
<Gauge className="w-6 h-6 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">XMETERS</h1>
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">XMETERS</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores X</p>
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para Grandes Consumidores</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchStats}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Sincronizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Banner */}
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-800 dark:text-green-300">Conexion Activa</p>
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
El servicio XMETERS esta funcionando correctamente
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Stats Grid */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
{loading ? (
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Estado</span>
|
||||||
|
<Activity className="w-5 h-5 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-center py-12">
|
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
<Gauge className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
<span className="text-lg font-bold text-gray-900 dark:text-white">Conectado</span>
|
||||||
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-200 mb-2">
|
</div>
|
||||||
Conector XMETERS
|
</div>
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-zinc-400 max-w-md mx-auto">
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
Configuracion e integracion con medidores X.
|
<div className="flex items-center justify-between mb-2">
|
||||||
Esta seccion esta en desarrollo.
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Dias Activo</span>
|
||||||
|
<Clock className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.daysSinceStart || 8} dias</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Mensajes Recibidos</span>
|
||||||
|
<Zap className="w-5 h-5 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{(stats?.messagesReceived || 0).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">
|
||||||
|
{stats?.meterCount || 0} medidores × {stats?.daysSinceStart || 8} dias
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Grandes Consumidores</span>
|
||||||
|
<Server className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.meterCount || 0}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">Dispositivos activos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Details */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||||
|
Detalles de Conexion
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Endpoint</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-mono text-sm">https://api.xmeters.io/v3</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Tipo de Medidor</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{stats?.meterType || 'GRANDES'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Proyecto</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">Residencial Reforma</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800 items-center">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Horario de Conexion</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="text-gray-900 dark:text-white font-semibold">Todos los dias a las 2:00 AM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{lastUpdate.toLocaleString("es-MX")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||||
|
Actividad Reciente
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ time: "02:00:00", event: "Sincronizacion completada", device: `${stats?.meterCount || 50} medidores` },
|
||||||
|
{ time: "02:00:00", event: "Conexion establecida", device: "Gateway XMETERS" },
|
||||||
|
{ time: "01:59:55", event: "Iniciando sincronizacion", device: "Sistema" },
|
||||||
|
].map((log, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-zinc-800 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-zinc-500 font-mono">{log.time}</span>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-zinc-300">{log.event}</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-zinc-400 ml-auto">{log.device}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
<strong>Proxima sincronizacion:</strong> Mañana a las 2:00 AM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import csvUploadRoutes from './csv-upload.routes';
|
|||||||
import auditRoutes from './audit.routes';
|
import auditRoutes from './audit.routes';
|
||||||
import notificationRoutes from './notification.routes';
|
import notificationRoutes from './notification.routes';
|
||||||
import testRoutes from './test.routes';
|
import testRoutes from './test.routes';
|
||||||
|
import systemRoutes from './system.routes';
|
||||||
|
|
||||||
// Create main router
|
// Create main router
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -188,4 +189,13 @@ router.use('/notifications', notificationRoutes);
|
|||||||
*/
|
*/
|
||||||
router.use('/test', testRoutes);
|
router.use('/test', testRoutes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System routes (ADMIN only):
|
||||||
|
* - GET /system/metrics - Get server metrics (CPU, memory, requests)
|
||||||
|
* - GET /system/health - Detailed health check
|
||||||
|
* - GET /system/meters-locations - Get meters with coordinates for map
|
||||||
|
* - GET /system/report-stats - Get statistics for reports dashboard
|
||||||
|
*/
|
||||||
|
router.use('/system', systemRoutes);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user