feat: Enhanced Pulse analytics with historical charts and improved sensor cards
This commit is contained in:
parent
c39abe5696
commit
7cb7843ceb
4 changed files with 890 additions and 312 deletions
376
frontend/package-lock.json
generated
376
frontend/package-lock.json
generated
|
|
@ -33,6 +33,7 @@
|
|||
"react-i18next": "^16.4.1",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "0.165.0",
|
||||
|
|
@ -2613,6 +2614,32 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
|
@ -2935,6 +2962,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "9.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
|
||||
|
|
@ -3104,6 +3143,69 @@
|
|||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/draco3d": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
|
||||
|
|
@ -3170,7 +3272,7 @@
|
|||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
|
|
@ -3213,6 +3315,12 @@
|
|||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
|
|
@ -4377,6 +4485,127 @@
|
|||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
|
|
@ -4435,6 +4664,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
|
||||
|
|
@ -4723,6 +4958,16 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
|
|
@ -4993,6 +5238,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
|
||||
|
|
@ -5803,6 +6054,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
|
|
@ -7501,6 +7761,29 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
|
@ -7656,6 +7939,46 @@
|
|||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
||||
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
|
|
@ -7670,6 +7993,21 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
|
@ -7722,6 +8060,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
|
|
@ -8427,6 +8771,12 @@
|
|||
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
|
@ -8649,7 +8999,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
@ -8822,6 +9172,28 @@
|
|||
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"react-i18next": "^16.4.1",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "0.165.0",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
|
||||
import { motion } from 'framer-motion';
|
||||
import { LucideIcon, Thermometer, Droplets, Wind, CloudFog, Sun, Activity } from 'lucide-react';
|
||||
import { Thermometer, Droplets, Wind, CloudFog, Activity, ChevronRight, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AreaChart, Area, ResponsiveContainer, ReferenceLine } from 'recharts';
|
||||
|
||||
interface PulseSensorCardProps {
|
||||
reading: {
|
||||
|
|
@ -16,12 +16,18 @@ interface PulseSensorCardProps {
|
|||
};
|
||||
history?: {
|
||||
temperature: number[];
|
||||
// we can add other metrics history later
|
||||
humidity?: number[];
|
||||
vpd?: number[];
|
||||
};
|
||||
thresholds?: {
|
||||
temperature?: { min: number; max: number };
|
||||
humidity?: { min: number; max: number };
|
||||
vpd?: { min: number; max: number };
|
||||
};
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function PulseSensorCard({ reading, history, onClick }: PulseSensorCardProps) {
|
||||
export function PulseSensorCard({ reading, history, thresholds, onClick }: PulseSensorCardProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Determine offline status (older than 15 mins)
|
||||
|
|
@ -30,127 +36,185 @@ export function PulseSensorCard({ reading, history, onClick }: PulseSensorCardPr
|
|||
const diffMinutes = (now.getTime() - readingTime.getTime()) / 1000 / 60;
|
||||
const isOffline = diffMinutes > 15;
|
||||
|
||||
// Determine status color based on VPD (gold standard for crop health)
|
||||
const getStatusColor = (vpd: number) => {
|
||||
if (vpd < 0.8 || vpd > 1.2) return 'text-amber-500'; // Warning
|
||||
if (vpd < 0.4 || vpd > 1.6) return 'text-rose-500'; // Critical
|
||||
return 'text-emerald-500'; // Good
|
||||
// Get trend for temperature
|
||||
const getTrend = (data?: number[]) => {
|
||||
if (!data || data.length < 3) return 'stable';
|
||||
const recent = data.slice(-3);
|
||||
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
|
||||
const first = recent[0];
|
||||
if (avg > first + 0.5) return 'up';
|
||||
if (avg < first - 0.5) return 'down';
|
||||
return 'stable';
|
||||
};
|
||||
|
||||
// If offline, use neutral/error color for metrics
|
||||
const statusColor = isOffline ? 'text-slate-400 dark:text-slate-500' : getStatusColor(reading.vpd);
|
||||
const badgeColor = isOffline
|
||||
? 'text-rose-500 bg-rose-500/10 border-rose-500/20'
|
||||
: cn("bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700", statusColor);
|
||||
const tempTrend = getTrend(history?.temperature);
|
||||
|
||||
// Simple Sparkline
|
||||
const Sparkline = ({ data, color = "#10b981", width = 120, height = 40 }: { data: number[], color?: string, width?: number, height?: number }) => {
|
||||
if (!data || data.length < 2) return null;
|
||||
// Determine if values are in warning range
|
||||
const isTempWarning = thresholds?.temperature
|
||||
? reading.temperature > thresholds.temperature.max || reading.temperature < thresholds.temperature.min
|
||||
: reading.temperature > 82 || reading.temperature < 65;
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
const isVpdWarning = thresholds?.vpd
|
||||
? reading.vpd > thresholds.vpd.max || reading.vpd < thresholds.vpd.min
|
||||
: reading.vpd > 1.2 || reading.vpd < 0.8;
|
||||
|
||||
const points = data.map((val, i) => {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - ((val - min) / range) * height;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible opacity-50">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
// Convert history to chart data
|
||||
const chartData = history?.temperature?.map((temp, i) => ({
|
||||
value: temp,
|
||||
index: i
|
||||
})) || [];
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) onClick();
|
||||
else navigate(`/pulse`); // Default to pulse dashboard
|
||||
else navigate(`/pulse`);
|
||||
};
|
||||
|
||||
const TrendIcon = tempTrend === 'up' ? TrendingUp : tempTrend === 'down' ? TrendingDown : Minus;
|
||||
const trendColor = tempTrend === 'up' ? 'text-red-400' : tempTrend === 'down' ? 'text-blue-400' : 'text-slate-400';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ y: -2 }}
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
onClick={handleClick}
|
||||
className="group relative overflow-hidden bg-white dark:bg-[#0C0C0C] border border-[var(--color-border-subtle)]/60 p-5 rounded-2xl transition-all hover:shadow-2xl hover:shadow-indigo-500/5 cursor-pointer"
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-2xl cursor-pointer transition-all",
|
||||
"bg-gradient-to-br from-slate-800/80 to-slate-900/80",
|
||||
"border border-slate-700/50 hover:border-emerald-500/50",
|
||||
"shadow-xl hover:shadow-2xl hover:shadow-emerald-500/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className={cn(
|
||||
"p-2.5 rounded-xl ring-1",
|
||||
isOffline
|
||||
? "bg-slate-100 text-slate-400 ring-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:ring-slate-700"
|
||||
: "bg-emerald-500/10 text-emerald-500 ring-emerald-500/20"
|
||||
)}>
|
||||
<Activity size={18} />
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className={cn(
|
||||
"p-3 rounded-xl",
|
||||
isOffline
|
||||
? "bg-slate-700 text-slate-400"
|
||||
: isTempWarning || isVpdWarning
|
||||
? "bg-amber-500/20 text-amber-400"
|
||||
: "bg-emerald-500/20 text-emerald-400"
|
||||
)}>
|
||||
<Activity size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white tracking-wide">
|
||||
{reading.deviceName}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">Pulse Grow Sensor</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest leading-none mb-1">
|
||||
{reading.deviceName}
|
||||
</h3>
|
||||
<p className="text-[10px] text-[var(--color-text-tertiary)] opacity-60">Pulse Grow</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("text-[10px] font-bold px-2 py-0.5 rounded-full border", badgeColor)}>
|
||||
{isOffline ? 'OFFLINE' : 'LIVE'}
|
||||
<div className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-bold px-2.5 py-1 rounded-full border",
|
||||
isOffline
|
||||
? "text-red-400 bg-red-500/10 border-red-500/30"
|
||||
: "text-emerald-400 bg-emerald-500/10 border-emerald-500/30"
|
||||
)}>
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full", isOffline ? "bg-red-400" : "bg-emerald-400 animate-pulse")} />
|
||||
{isOffline ? 'OFFLINE' : 'LIVE'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
|
||||
<Thermometer size={12} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Temp</span>
|
||||
</div>
|
||||
<p className={cn("text-2xl font-bold transition-colors", isOffline ? "text-slate-400" : "text-[var(--color-text-primary)]")}>
|
||||
{reading.temperature.toFixed(1)}°
|
||||
</p>
|
||||
{/* Sparkline Chart */}
|
||||
{chartData.length > 2 && !isOffline && (
|
||||
<div className="h-16 px-2 -mb-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${reading.deviceId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={isTempWarning ? "#f59e0b" : "#10b981"} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={isTempWarning ? "#f59e0b" : "#10b981"} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={isTempWarning ? "#f59e0b" : "#10b981"}
|
||||
fill={`url(#gradient-${reading.deviceId})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
|
||||
<Droplets size={12} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">RH</span>
|
||||
</div>
|
||||
<p className={cn("text-2xl font-bold transition-colors", isOffline ? "text-slate-400" : "text-[var(--color-text-primary)]")}>
|
||||
{reading.humidity.toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
|
||||
<CloudFog size={12} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">VPD</span>
|
||||
</div>
|
||||
<p className={cn("text-2xl font-bold transition-colors", statusColor)}>
|
||||
{reading.vpd.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
{history && history.temperature && !isOffline && (
|
||||
<div className="mb-1">
|
||||
<Sparkline data={history.temperature} color={reading.temperature > 80 ? '#f43f5e' : '#10b981'} />
|
||||
{/* Metrics Grid */}
|
||||
<div className="p-5 pt-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Temperature */}
|
||||
<div className={cn(
|
||||
"p-3 rounded-xl transition-colors",
|
||||
isTempWarning ? "bg-amber-500/10" : "bg-slate-800/50"
|
||||
)}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1.5 text-slate-400">
|
||||
<Thermometer size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Temp</span>
|
||||
</div>
|
||||
<TrendIcon size={12} className={trendColor} />
|
||||
</div>
|
||||
)}
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
isOffline ? "text-slate-500" : isTempWarning ? "text-amber-400" : "text-white"
|
||||
)}>
|
||||
{reading.temperature.toFixed(1)}°
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isOffline && (
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-medium text-rose-500">Last Updated</p>
|
||||
<p className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{readingTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
{/* Humidity */}
|
||||
<div className="p-3 rounded-xl bg-slate-800/50">
|
||||
<div className="flex items-center gap-1.5 mb-1 text-slate-400">
|
||||
<Droplets size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">RH</span>
|
||||
</div>
|
||||
)}
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
isOffline ? "text-slate-500" : "text-blue-400"
|
||||
)}>
|
||||
{reading.humidity.toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* VPD */}
|
||||
<div className={cn(
|
||||
"p-3 rounded-xl transition-colors",
|
||||
isVpdWarning ? "bg-purple-500/10" : "bg-slate-800/50"
|
||||
)}>
|
||||
<div className="flex items-center gap-1.5 mb-1 text-slate-400">
|
||||
<CloudFog size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">VPD</span>
|
||||
</div>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
isOffline ? "text-slate-500" : isVpdWarning ? "text-purple-400" : "text-emerald-400"
|
||||
)}>
|
||||
{reading.vpd.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dewpoint Row */}
|
||||
<div className="mt-3 flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<Wind size={12} />
|
||||
<span className="text-xs">Dewpoint: <span className="text-slate-300">{reading.dewpoint.toFixed(1)}°F</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-slate-500 group-hover:text-emerald-400 transition-colors">
|
||||
<span className="text-xs font-medium">View Details</span>
|
||||
<ChevronRight size={14} className="group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp Footer */}
|
||||
<div className="px-5 pb-4">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t border-slate-700/50">
|
||||
<span>Last reading</span>
|
||||
<span className={isOffline ? "text-red-400" : ""}>
|
||||
{readingTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Activity, Thermometer, Droplets, Wind, Sun, Wifi, WifiOff, RefreshCw, Bell } from 'lucide-react';
|
||||
import { Activity, Thermometer, Droplets, Wind, Wifi, WifiOff, RefreshCw, Bell, TrendingUp, Clock, ChevronRight } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart, ReferenceLine } from 'recharts';
|
||||
import { useNotifications } from '../hooks/useNotifications';
|
||||
import { motion } from 'framer-motion';
|
||||
import api from '../lib/api';
|
||||
|
||||
interface PulseDevice {
|
||||
|
|
@ -28,15 +30,25 @@ interface PulseStatus {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
interface HistoryPoint {
|
||||
timestamp: string;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
vpd: number;
|
||||
}
|
||||
|
||||
export default function PulseTestPage() {
|
||||
const { connected: wsConnected, alerts, unreadCount } = useNotifications();
|
||||
|
||||
const [status, setStatus] = useState<PulseStatus | null>(null);
|
||||
const [devices, setDevices] = useState<PulseDevice[]>([]);
|
||||
const [readings, setReadings] = useState<PulseReading[]>([]);
|
||||
const [history, setHistory] = useState<HistoryPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [selectedDevice, setSelectedDevice] = useState<string | null>(null);
|
||||
const [historyHours, setHistoryHours] = useState(6);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -55,6 +67,11 @@ export default function PulseTestPage() {
|
|||
// Fetch readings
|
||||
const readingsRes = await api.get('/pulse/readings');
|
||||
setReadings(readingsRes.data.readings || []);
|
||||
|
||||
// Set first device as selected if none selected
|
||||
if (!selectedDevice && readingsRes.data.readings?.length > 0) {
|
||||
setSelectedDevice(readingsRes.data.readings[0].deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
setLastUpdate(new Date());
|
||||
|
|
@ -65,240 +82,364 @@ export default function PulseTestPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!selectedDevice) return;
|
||||
|
||||
try {
|
||||
const res = await api.get(`/pulse/devices/${selectedDevice}/history?hours=${historyHours}`);
|
||||
const points = (res.data.readings || []).map((r: any) => ({
|
||||
timestamp: new Date(r.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
temperature: r.temperature,
|
||||
humidity: r.humidity,
|
||||
vpd: r.vpd
|
||||
}));
|
||||
setHistory(points);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch history:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [selectedDevice, historyHours]);
|
||||
|
||||
const getStatusColor = (connected: boolean) =>
|
||||
connected ? 'text-green-500' : 'text-red-500';
|
||||
|
||||
const getVpdColor = (vpd: number) => {
|
||||
if (vpd < 0.8) return 'text-blue-500';
|
||||
if (vpd > 1.2) return 'text-red-500';
|
||||
if (vpd > 1.2) return 'text-amber-500';
|
||||
if (vpd > 1.6) return 'text-red-500';
|
||||
return 'text-green-500';
|
||||
};
|
||||
|
||||
const getTempColor = (temp: number) => {
|
||||
if (temp < 65) return 'text-blue-500';
|
||||
if (temp > 82) return 'text-red-500';
|
||||
if (temp > 82) return 'text-amber-500';
|
||||
if (temp > 90) return 'text-red-500';
|
||||
return 'text-green-500';
|
||||
};
|
||||
|
||||
const currentReading = readings.find(r => r.deviceId === selectedDevice) || readings[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||
Pulse Sensor Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
Live environment monitoring from Pulse Grow
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* WebSocket Status */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-tertiary)]">
|
||||
{wsConnected ? (
|
||||
<Wifi className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{wsConnected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-500">
|
||||
<Bell className="w-3 h-3" />
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-lg bg-[var(--color-bg-tertiary)] hover:bg-[var(--color-primary)] hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Status Card */}
|
||||
<div className="p-4 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${status?.connected ? 'bg-green-500/10' : 'bg-red-500/10'}`}>
|
||||
<Activity className={`w-5 h-5 ${getStatusColor(status?.connected || false)}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--color-text-primary)]">
|
||||
Pulse API Connection
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{status?.connected
|
||||
? `Connected • ${status.deviceCount} device(s)`
|
||||
: status?.error || 'Not connected'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{lastUpdate && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
Last update: {lastUpdate.toLocaleTimeString()}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 shadow-lg shadow-emerald-500/25">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
Pulse Sensor Analytics
|
||||
</h1>
|
||||
<p className="text-slate-400 ml-14 mt-1">
|
||||
Real-time environmental monitoring with historical trends
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sensor Readings Grid */}
|
||||
{readings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{readings.map((reading, index) => (
|
||||
<div
|
||||
key={reading.deviceId || index}
|
||||
className="p-5 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)] space-y-4"
|
||||
>
|
||||
{/* Device Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-[var(--color-text-primary)]">
|
||||
{reading.deviceName || `Device ${reading.deviceId}`}
|
||||
</h3>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Temperature */}
|
||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Thermometer className={`w-4 h-4 ${getTempColor(reading.temperature)}`} />
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">Temp</span>
|
||||
</div>
|
||||
<p className={`text-xl font-bold ${getTempColor(reading.temperature)}`}>
|
||||
{reading.temperature.toFixed(1)}°F
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Humidity */}
|
||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Droplets className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">Humidity</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-blue-500">
|
||||
{reading.humidity.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* VPD */}
|
||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Wind className={`w-4 h-4 ${getVpdColor(reading.vpd)}`} />
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">VPD</span>
|
||||
</div>
|
||||
<p className={`text-xl font-bold ${getVpdColor(reading.vpd)}`}>
|
||||
{reading.vpd.toFixed(2)} kPa
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dewpoint */}
|
||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Droplets className="w-4 h-4 text-cyan-500" />
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">Dewpoint</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-cyan-500">
|
||||
{reading.dewpoint.toFixed(1)}°F
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Light (if available) */}
|
||||
{reading.light !== undefined && (
|
||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Sun className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">Light</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-yellow-500">
|
||||
{reading.light.toFixed(0)} lux
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CO2 (if available) */}
|
||||
{reading.co2 !== undefined && (
|
||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Wind className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">CO2</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-purple-500">
|
||||
{reading.co2} ppm
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] text-right">
|
||||
{new Date(reading.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* WebSocket Status */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 border border-slate-700">
|
||||
{wsConnected ? (
|
||||
<Wifi className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-slate-300">
|
||||
{wsConnected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-500">
|
||||
<Bell className="w-3 h-3" />
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && readings.length === 0 && status?.connected && (
|
||||
<div className="p-8 text-center rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
|
||||
<Activity className="w-12 h-12 mx-auto mb-4 text-[var(--color-text-tertiary)]" />
|
||||
<h3 className="font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
No Sensor Readings
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
Waiting for data from Pulse devices...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Alerts */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="p-4 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
|
||||
<h3 className="font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
|
||||
<Bell className="w-4 h-4" />
|
||||
Recent Alerts ({alerts.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{alerts.slice(0, 5).map((alert, index) => (
|
||||
<div
|
||||
key={alert.id || index}
|
||||
className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm"
|
||||
>
|
||||
<p className="font-medium text-red-500">{alert.sensorName}</p>
|
||||
<p className="text-[var(--color-text-tertiary)]">
|
||||
{alert.type}: {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)})
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
{new Date(alert.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="p-2.5 rounded-xl bg-slate-800/50 border border-slate-700 hover:bg-slate-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-slate-300 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Live Readings Panel */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">Live Sensors</h2>
|
||||
|
||||
{readings.map((reading) => (
|
||||
<motion.div
|
||||
key={reading.deviceId}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() => setSelectedDevice(reading.deviceId)}
|
||||
className={`p-4 rounded-xl cursor-pointer transition-all ${selectedDevice === reading.deviceId
|
||||
? 'bg-emerald-500/20 border-2 border-emerald-500/50'
|
||||
: 'bg-slate-800/50 border border-slate-700/50 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-white text-sm">{reading.deviceName}</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-xs text-green-400 font-medium">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Temp</p>
|
||||
<p className={`text-lg font-bold ${getTempColor(reading.temperature)}`}>
|
||||
{reading.temperature.toFixed(1)}°
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-0.5">RH</p>
|
||||
<p className="text-lg font-bold text-blue-400">
|
||||
{reading.humidity.toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-0.5">VPD</p>
|
||||
<p className={`text-lg font-bold ${getVpdColor(reading.vpd)}`}>
|
||||
{reading.vpd.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{readings.length === 0 && !loading && (
|
||||
<div className="p-6 text-center rounded-xl bg-slate-800/50 border border-slate-700/50">
|
||||
<Activity className="w-8 h-8 mx-auto mb-2 text-slate-500" />
|
||||
<p className="text-slate-400 text-sm">No sensors connected</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Charts Section */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{/* Current Stats */}
|
||||
{currentReading && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-5 rounded-2xl bg-gradient-to-br from-red-500/20 to-orange-500/10 border border-red-500/20"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Thermometer className="w-5 h-5 text-red-400" />
|
||||
<span className="text-sm text-slate-400">Temperature</span>
|
||||
</div>
|
||||
<p className={`text-4xl font-bold ${getTempColor(currentReading.temperature)}`}>
|
||||
{currentReading.temperature.toFixed(1)}°F
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="p-5 rounded-2xl bg-gradient-to-br from-blue-500/20 to-cyan-500/10 border border-blue-500/20"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Droplets className="w-5 h-5 text-blue-400" />
|
||||
<span className="text-sm text-slate-400">Humidity</span>
|
||||
</div>
|
||||
<p className="text-4xl font-bold text-blue-400">
|
||||
{currentReading.humidity.toFixed(0)}%
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="p-5 rounded-2xl bg-gradient-to-br from-purple-500/20 to-pink-500/10 border border-purple-500/20"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wind className="w-5 h-5 text-purple-400" />
|
||||
<span className="text-sm text-slate-400">VPD</span>
|
||||
</div>
|
||||
<p className={`text-4xl font-bold ${getVpdColor(currentReading.vpd)}`}>
|
||||
{currentReading.vpd.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">kPa</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="p-5 rounded-2xl bg-gradient-to-br from-cyan-500/20 to-teal-500/10 border border-cyan-500/20"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Droplets className="w-5 h-5 text-cyan-400" />
|
||||
<span className="text-sm text-slate-400">Dewpoint</span>
|
||||
</div>
|
||||
<p className="text-4xl font-bold text-cyan-400">
|
||||
{currentReading.dewpoint.toFixed(1)}°F
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Range Selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-emerald-400" />
|
||||
Historical Trends
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-slate-400" />
|
||||
<select
|
||||
value={historyHours}
|
||||
onChange={(e) => setHistoryHours(Number(e.target.value))}
|
||||
className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
<option value={1}>Last hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={12}>Last 12 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
<option value={48}>Last 48 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature Chart */}
|
||||
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Thermometer className="w-4 h-4 text-red-400" />
|
||||
Temperature History
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="tempGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} domain={['auto', 'auto']} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#94a3b8' }}
|
||||
/>
|
||||
<ReferenceLine y={82} stroke="#f59e0b" strokeDasharray="5 5" />
|
||||
<ReferenceLine y={65} stroke="#3b82f6" strokeDasharray="5 5" />
|
||||
<Area type="monotone" dataKey="temperature" stroke="#ef4444" fill="url(#tempGradient)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Humidity & VPD Charts */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Droplets className="w-4 h-4 text-blue-400" />
|
||||
Humidity History
|
||||
</h3>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="humidityGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={10} />
|
||||
<YAxis stroke="#64748b" fontSize={10} domain={[0, 100]} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="humidity" stroke="#3b82f6" fill="url(#humidityGradient)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Wind className="w-4 h-4 text-purple-400" />
|
||||
VPD History
|
||||
</h3>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="vpdGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={10} />
|
||||
<YAxis stroke="#64748b" fontSize={10} domain={[0, 2]} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
/>
|
||||
<ReferenceLine y={1.2} stroke="#f59e0b" strokeDasharray="3 3" />
|
||||
<ReferenceLine y={0.8} stroke="#3b82f6" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="vpd" stroke="#a855f7" fill="url(#vpdGradient)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Alerts */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
|
||||
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-amber-400" />
|
||||
Recent Alerts ({alerts.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{alerts.slice(0, 6).map((alert, index) => (
|
||||
<div
|
||||
key={alert.id || index}
|
||||
className="p-4 rounded-xl bg-red-500/10 border border-red-500/20"
|
||||
>
|
||||
<p className="font-medium text-red-400 text-sm">{alert.sensorName}</p>
|
||||
<p className="text-slate-400 text-sm">
|
||||
{alert.type}: {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)})
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
{new Date(alert.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue