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:
Exteban08
2026-02-03 21:07:00 +00:00
parent 6487e9105e
commit 9f1ab4115e
13 changed files with 1864 additions and 50 deletions

104
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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}`);
}

View File

@@ -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>

View File

@@ -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>

View 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: '&copy; <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)}`
: "—"}
</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>
);
}

View 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")}`,
"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")}`,
"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>
);
}

View 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>
);
}

View 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='&copy; <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>
);
}

View File

@@ -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="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg"> <div className="flex items-center gap-3">
<Radio className="w-6 h-6 text-blue-600" /> <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Radio className="w-6 h-6 text-blue-600" />
</div>
<div>
<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 LORA</p>
</div>
</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> <div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">SH-METERS</h1> <p className="font-semibold text-green-800 dark:text-green-300">Conexion Activa</p>
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores SH</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>
</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> </p>
</div> </div>
)} </div>
</div> </div>
</div> </div>
); );

View File

@@ -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="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg"> <div className="flex items-center gap-3">
<Gauge className="w-6 h-6 text-purple-600" /> <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<Gauge className="w-6 h-6 text-purple-600" />
</div>
<div>
<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 Grandes Consumidores</p>
</div>
</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> <div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">XMETERS</h1> <p className="font-semibold text-green-800 dark:text-green-300">Conexion Activa</p>
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores X</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>
</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> </p>
</div> </div>
)} </div>
</div> </div>
</div> </div>
); );

View File

@@ -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;