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/x-data-grid": "^8.21.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.559.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^3.6.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
@@ -523,6 +526,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -539,6 +543,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -555,6 +560,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -571,6 +577,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -587,6 +594,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -603,6 +611,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -619,6 +628,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -635,6 +645,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -651,6 +662,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -667,6 +679,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -683,6 +696,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -699,6 +713,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -715,6 +730,7 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -731,6 +747,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -747,6 +764,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -763,6 +781,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -779,6 +798,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -795,6 +815,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -811,6 +832,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -827,6 +849,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -843,6 +866,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -859,6 +883,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -875,6 +900,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1575,6 +1601,17 @@
|
||||
"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": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
@@ -1625,6 +1662,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1638,6 +1676,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1651,6 +1690,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1664,6 +1704,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1677,6 +1718,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1690,6 +1732,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1703,6 +1746,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1716,6 +1760,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1729,6 +1774,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1742,6 +1788,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1755,6 +1802,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1768,6 +1816,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1781,6 +1830,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1794,6 +1844,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1807,6 +1858,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1820,6 +1872,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1833,6 +1886,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1846,6 +1900,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1859,6 +1914,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1872,6 +1928,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1885,6 +1942,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1898,6 +1956,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2285,8 +2344,26 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
@@ -2303,6 +2380,7 @@
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -3145,6 +3223,7 @@
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -3548,6 +3627,7 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -3958,6 +4038,12 @@
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -4340,6 +4426,7 @@
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4535,6 +4622,7 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -4654,6 +4742,20 @@
|
||||
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
|
||||
"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": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
@@ -4815,6 +4917,7 @@
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -5202,6 +5305,7 @@
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
|
||||
@@ -17,13 +17,16 @@
|
||||
"@mui/material": "^7.3.6",
|
||||
"@mui/x-data-grid": "^8.21.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.559.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^3.6.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@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 XMetersPage from "./pages/conectores/XMetersPage";
|
||||
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 { updateMyProfile } from "./api/me";
|
||||
|
||||
@@ -46,7 +49,10 @@ export type Page =
|
||||
| "roles"
|
||||
| "sh-meters"
|
||||
| "xmeters"
|
||||
| "tts";
|
||||
| "tts"
|
||||
| "analytics-map"
|
||||
| "analytics-reports"
|
||||
| "analytics-server";
|
||||
|
||||
export default function App() {
|
||||
const [isAuth, setIsAuth] = useState<boolean>(false);
|
||||
@@ -195,6 +201,12 @@ export default function App() {
|
||||
return <XMetersPage />;
|
||||
case "tts":
|
||||
return <TTSPage />;
|
||||
case "analytics-map":
|
||||
return <AnalyticsMapPage />;
|
||||
case "analytics-reports":
|
||||
return <AnalyticsReportsPage />;
|
||||
case "analytics-server":
|
||||
return <AnalyticsServerPage />;
|
||||
case "home":
|
||||
default:
|
||||
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,
|
||||
People,
|
||||
Cable,
|
||||
BarChart,
|
||||
} from "@mui/icons-material";
|
||||
import { Page } from "../../App";
|
||||
import { getCurrentUserRole } from "../../api/auth";
|
||||
@@ -19,6 +20,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
const [systemOpen, setSystemOpen] = useState(true);
|
||||
const [usersOpen, setUsersOpen] = useState(true);
|
||||
const [conectoresOpen, setConectoresOpen] = useState(true);
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(true);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
@@ -223,6 +225,53 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -434,7 +434,10 @@ export default function Home({
|
||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Proyectos</span>
|
||||
</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" />
|
||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Reportes</span>
|
||||
</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 { Radio } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Radio, CheckCircle, Activity, Clock, Zap, RefreshCw, Server, Calendar } from "lucide-react";
|
||||
import { getConnectorStats, type ConnectorStats } from "../../api/analytics";
|
||||
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<Radio className="w-6 h-6 text-blue-600" />
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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="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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
{/* Stats Grid */}
|
||||
<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-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">Estado</span>
|
||||
<Activity className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Radio className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-200 mb-2">
|
||||
Conector SH-METERS
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-zinc-400 max-w-md mx-auto">
|
||||
Configuracion e integracion con medidores SH.
|
||||
Esta seccion esta en desarrollo.
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">Conectado</span>
|
||||
</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">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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,176 @@
|
||||
import { useState } from "react";
|
||||
import { Gauge } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Gauge, CheckCircle, Activity, Clock, Zap, RefreshCw, Server, Calendar } from "lucide-react";
|
||||
import { getConnectorStats, type ConnectorStats } from "../../api/analytics";
|
||||
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<Gauge className="w-6 h-6 text-purple-600" />
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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="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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
{/* Stats Grid */}
|
||||
<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-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">Estado</span>
|
||||
<Activity className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Gauge className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-200 mb-2">
|
||||
Conector XMETERS
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-zinc-400 max-w-md mx-auto">
|
||||
Configuracion e integracion con medidores X.
|
||||
Esta seccion esta en desarrollo.
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">Conectado</span>
|
||||
</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">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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import csvUploadRoutes from './csv-upload.routes';
|
||||
import auditRoutes from './audit.routes';
|
||||
import notificationRoutes from './notification.routes';
|
||||
import testRoutes from './test.routes';
|
||||
import systemRoutes from './system.routes';
|
||||
|
||||
// Create main router
|
||||
const router = Router();
|
||||
@@ -188,4 +189,13 @@ router.use('/notifications', notificationRoutes);
|
||||
*/
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user