Compare commits
10 Commits
096fc2aa72
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec45ee379 | ||
|
|
1b56e2cc42 | ||
|
|
ecaf8ab2a5 | ||
|
|
5f4c4a55d8 | ||
|
|
84c47e3357 | ||
|
|
0ff69b70e4 | ||
|
|
077e033d2e | ||
|
|
739b6fe54f | ||
|
|
5febdf29c8 | ||
|
|
eab4241e6e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
ac_data.db
|
ac_data.db
|
||||||
dashboard_log.txt
|
dashboard_log.txt
|
||||||
|
dist
|
||||||
415
package-lock.json
generated
415
package-lock.json
generated
@@ -31,6 +31,8 @@
|
|||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
|
"telegraf": "^4.16.3",
|
||||||
|
"tp-link-tapo-connect": "^2.0.8",
|
||||||
"webpack": "^5.104.1",
|
"webpack": "^5.104.1",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-dev-middleware": "^7.4.5"
|
"webpack-dev-middleware": "^7.4.5"
|
||||||
@@ -2511,6 +2513,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@network-utils/arp-lookup": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@network-utils/arp-lookup/-/arp-lookup-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-DFrRcGecVuouFW6KMOX4qnCWkCH44er/A5udIPW7j4aiHDyVjQB5SG9/+beeV7066iA5ZasbBzu162F9ofHNmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@@ -2521,6 +2532,12 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@telegraf/types": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -2801,6 +2818,18 @@
|
|||||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -2919,6 +2948,21 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/any-promise": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/assert-plus": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/auto-bind": {
|
"node_modules/auto-bind": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
||||||
@@ -2931,6 +2975,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "0.21.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
|
||||||
|
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-loader": {
|
"node_modules/babel-loader": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz",
|
||||||
@@ -3174,12 +3227,34 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-alloc": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-alloc-unsafe": "^1.1.0",
|
||||||
|
"buffer-fill": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-alloc-unsafe": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-fill": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -3308,6 +3383,12 @@
|
|||||||
"node": ">=6.0"
|
"node": ">=6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cidr-regex": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-4sQNVjJw/I3kHyb9FGSJ/dBT18BP7pf5zongGsEYExNvtTss24hgJxZr0JK1eWlumX/ij4yiR/vYlndRynnkDQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/clean-css": {
|
"node_modules/clean-css": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
||||||
@@ -3499,6 +3580,12 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cosmiconfig": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||||
@@ -4012,6 +4099,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
@@ -4073,6 +4169,15 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/extsprintf": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
|
||||||
|
"engines": [
|
||||||
|
"node >=0.6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -4162,6 +4267,26 @@
|
|||||||
"flat": "cli.js"
|
"flat": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -4240,6 +4365,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-ip-range": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-ip-range/-/get-ip-range-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-n401kTpf57VhbW2UvInXxhw1DgTJSsZf24gpNhJrFU8Cv+Jk/sQ+qukP2x0EF4MUEjDWlDLeIhIY/f80IV0kqg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cidr-regex": "^1.0.7",
|
||||||
|
"ip": "^1.1.5",
|
||||||
|
"ip-address": "^6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-proto": {
|
"node_modules/get-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
@@ -4637,6 +4773,30 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ip": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "6.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-6.4.0.tgz",
|
||||||
|
"integrity": "sha512-c5uxc2WUTuRBVHT/6r4m7HIr/DfV0bF6DvLH3iZGSK8wp8iMwwZSgIq2do0asFf8q9ECug0SE+6+1ACMe4sorA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jsbn": "1.1.0",
|
||||||
|
"lodash.find": "4.6.0",
|
||||||
|
"lodash.max": "4.0.1",
|
||||||
|
"lodash.merge": "4.6.2",
|
||||||
|
"lodash.padstart": "4.6.1",
|
||||||
|
"lodash.repeat": "4.1.0",
|
||||||
|
"sprintf-js": "1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -4750,6 +4910,12 @@
|
|||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsbn": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@@ -4768,6 +4934,12 @@
|
|||||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||||
|
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -4808,6 +4980,21 @@
|
|||||||
"npm": ">=6"
|
"npm": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsprim": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-plus": "1.0.0",
|
||||||
|
"extsprintf": "1.3.0",
|
||||||
|
"json-schema": "0.4.0",
|
||||||
|
"verror": "1.10.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
@@ -4857,6 +5044,20 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/local-devices": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/local-devices/-/local-devices-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-65EPltZW7bPRTmIiL9yuQcu0Om6dYl4dzRgiIYxl/cUO62FBW09ZpIVtpffXU8vryfGXEXxt68oWK/Kru12FEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-ip-range": "^2.1.0",
|
||||||
|
"ip": "^1.1.5",
|
||||||
|
"mz": "^2.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -4884,6 +5085,12 @@
|
|||||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.find": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.find/-/lodash.find-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-yaRZoAV3Xq28F1iafWN1+a0rflOej93l1DQUejs3SZ41h2O9UJBoS9aueGjPDgAl4B6tPC0NuuchLKaDQQ3Isg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -4920,12 +5127,36 @@
|
|||||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.max": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.max/-/lodash.max-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-iykTDTb7PK33HSQmKy34zv+hh4WEu7WonJPXQcgODzUbbtradtNs8RsD/GI7XV++60KaKR1xhW56N4ISqHesfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.merge": {
|
||||||
|
"version": "4.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.once": {
|
"node_modules/lodash.once": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.padstart": {
|
||||||
|
"version": "4.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz",
|
||||||
|
"integrity": "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.repeat": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-eWsgQW89IewS95ZOcr15HHCX6FVDxq3f2PNUIng3fyzsPev9imFQxIYdFZ6crl8L56UR6ZlGDLcEb3RZsCSSqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -4956,6 +5187,16 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/macaddr": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/macaddr/-/macaddr-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-GgNo6LbhjKCQ42DFgB7gUvbNmbccfFxP9WOf2zaA9bnya/5Fe0VpeA664WVbYELqLI4Bxdx9KKLXHwe4xglvIw==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-plus": "^1.0.0",
|
||||||
|
"jsprim": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -5071,6 +5312,15 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mri": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -5086,6 +5336,17 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mz": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"any-promise": "^1.0.0",
|
||||||
|
"object-assign": "^4.0.1",
|
||||||
|
"thenify-all": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -5156,6 +5417,26 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp-build": {
|
"node_modules/node-gyp-build": {
|
||||||
"version": "4.8.4",
|
"version": "4.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
@@ -5272,6 +5553,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-timeout": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/p-try": {
|
"node_modules/p-try": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
@@ -5987,12 +6277,30 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-compare": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-alloc": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sandwich-stream": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -6299,6 +6607,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/stack-utils": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
@@ -6492,6 +6806,28 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/telegraf": {
|
||||||
|
"version": "4.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz",
|
||||||
|
"integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@telegraf/types": "^7.1.0",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"mri": "^1.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"p-timeout": "^4.1.0",
|
||||||
|
"safe-compare": "^1.1.4",
|
||||||
|
"sandwich-stream": "^2.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"telegraf": "lib/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || >=14.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/terser": {
|
"node_modules/terser": {
|
||||||
"version": "5.44.1",
|
"version": "5.44.1",
|
||||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||||
@@ -6550,6 +6886,27 @@
|
|||||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/thenify": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"any-promise": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/thenify-all": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"thenify": ">= 3.1.0 < 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thingies": {
|
"node_modules/thingies": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz",
|
||||||
@@ -6575,6 +6932,25 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tp-link-tapo-connect": {
|
||||||
|
"version": "2.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/tp-link-tapo-connect/-/tp-link-tapo-connect-2.0.8.tgz",
|
||||||
|
"integrity": "sha512-2LcYhx/CEGWKQOMY3Cgb6s7HQ7oAzbgh7Z5BwDqm9D0R0RAa9z/H3F8eWZaBhj5wD69yrTE6b1uh0ib5G3Rv8w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@network-utils/arp-lookup": "^2.1.0",
|
||||||
|
"axios": "^0.21.4",
|
||||||
|
"local-devices": "^4.0.0",
|
||||||
|
"macaddr": "^0.1.1",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tree-dump": {
|
"node_modules/tree-dump": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz",
|
||||||
@@ -6732,6 +7108,15 @@
|
|||||||
"integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==",
|
"integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -6741,6 +7126,20 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/verror": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
|
||||||
|
"engines": [
|
||||||
|
"node >=0.6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-plus": "^1.0.0",
|
||||||
|
"core-util-is": "1.0.2",
|
||||||
|
"extsprintf": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/watchpack": {
|
"node_modules/watchpack": {
|
||||||
"version": "2.4.4",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
|
||||||
@@ -6754,6 +7153,12 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/webpack": {
|
"node_modules/webpack": {
|
||||||
"version": "5.104.1",
|
"version": "5.104.1",
|
||||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
||||||
@@ -6926,6 +7331,16 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
|
"telegraf": "^4.16.3",
|
||||||
|
"tp-link-tapo-connect": "^2.0.8",
|
||||||
"webpack": "^5.104.1",
|
"webpack": "^5.104.1",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-dev-middleware": "^7.4.5"
|
"webpack-dev-middleware": "^7.4.5"
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
const scriptPath = document.currentScript ? document.currentScript.src : window.location.href;
|
|
||||||
const API_BASE = scriptPath.substring(0, scriptPath.lastIndexOf('/') + 1) + 'api/';
|
|
||||||
|
|
||||||
// Store device info globally to simplify reload
|
|
||||||
let filteredDevices = [];
|
|
||||||
// Grouped: { "ControllerName": [ {dev info...}, {dev info...} ] }
|
|
||||||
let groupedDevices = {};
|
|
||||||
let currentRange = 'day';
|
|
||||||
let chartInstances = {};
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
setupControls();
|
|
||||||
await loadDevices();
|
|
||||||
|
|
||||||
// Auto-refresh data every 60 seconds
|
|
||||||
setInterval(loadData, 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupControls() {
|
|
||||||
// Range Buttons
|
|
||||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
|
||||||
e.target.classList.add('active');
|
|
||||||
currentRange = e.target.dataset.range;
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and setup device containers (Grouped by Controller)
|
|
||||||
*/
|
|
||||||
async function loadDevices() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}devices`);
|
|
||||||
const rawDevices = await res.json();
|
|
||||||
const container = document.getElementById('devicesContainer');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
if (rawDevices.length === 0) {
|
|
||||||
container.innerHTML = '<p>No devices found.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by Controller Name
|
|
||||||
groupedDevices = rawDevices.reduce((acc, dev) => {
|
|
||||||
if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
|
|
||||||
acc[dev.dev_name].push(dev);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Create Section per Controller
|
|
||||||
for (const [controllerName, ports] of Object.entries(groupedDevices)) {
|
|
||||||
const safeControllerName = controllerName.replace(/\s+/g, '_');
|
|
||||||
|
|
||||||
const section = document.createElement('div');
|
|
||||||
section.className = 'controller-section';
|
|
||||||
|
|
||||||
// Generate Ports HTML
|
|
||||||
let portsHtml = '';
|
|
||||||
ports.forEach(port => {
|
|
||||||
const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_');
|
|
||||||
|
|
||||||
portsHtml += `
|
|
||||||
<div class="port-card">
|
|
||||||
<h4>${port.port_name || 'Port ' + port.port}</h4>
|
|
||||||
<div class="canvas-container small">
|
|
||||||
<canvas id="level_${safePortId}"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
section.innerHTML = `
|
|
||||||
<div class="controller-header">
|
|
||||||
<h2>${controllerName}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="environment-row">
|
|
||||||
<div class="chart-wrapper wide">
|
|
||||||
<h3>Environment (Temp / Humidity)</h3>
|
|
||||||
<div class="canvas-container">
|
|
||||||
<canvas id="env_${safeControllerName}"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ports-grid">
|
|
||||||
${portsHtml}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
container.appendChild(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger initial data load
|
|
||||||
loadData();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load devices", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch data and render
|
|
||||||
*/
|
|
||||||
async function loadData() {
|
|
||||||
for (const [controllerName, ports] of Object.entries(groupedDevices)) {
|
|
||||||
const safeControllerName = controllerName.replace(/\s+/g, '_');
|
|
||||||
|
|
||||||
// 1. Fetch Environment Data (Use first port as representative source)
|
|
||||||
if (ports.length > 0) {
|
|
||||||
const firstPort = ports[0];
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}history?devName=${encodeURIComponent(controllerName)}&port=${firstPort.port}&range=${currentRange}`);
|
|
||||||
const data = await res.json();
|
|
||||||
renderEnvChart(safeControllerName, data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to load env data for ${controllerName}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch Level Data for EACH port
|
|
||||||
for (const port of ports) {
|
|
||||||
const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_');
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${currentRange}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
|
||||||
renderLevelChart(safePortId, data, isLight);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to load level data for ${controllerName}:${port.port}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateLabel(timestamp) {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
if (currentRange === 'day') {
|
|
||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' +
|
|
||||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEnvChart(safeName, data) {
|
|
||||||
const labels = data.map(d => formatDateLabel(d.timestamp));
|
|
||||||
const ctx = document.getElementById(`env_${safeName}`).getContext('2d');
|
|
||||||
|
|
||||||
updateChart(`env_${safeName}`, ctx, labels, [
|
|
||||||
{
|
|
||||||
label: 'Temperature (°C)',
|
|
||||||
data: data.map(d => d.temp_c),
|
|
||||||
borderColor: '#ff6384',
|
|
||||||
yAxisID: 'y'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Humidity (%)',
|
|
||||||
data: data.map(d => d.humidity),
|
|
||||||
borderColor: '#36a2eb',
|
|
||||||
yAxisID: 'y1'
|
|
||||||
}
|
|
||||||
], {
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'left',
|
|
||||||
suggestedMin: 15,
|
|
||||||
title: { display: true, text: 'Temp (°C)' }
|
|
||||||
},
|
|
||||||
y1: {
|
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'right',
|
|
||||||
grid: { drawOnChartArea: false },
|
|
||||||
suggestedMin: 30,
|
|
||||||
suggestedMax: 80,
|
|
||||||
title: { display: true, text: 'Humidity (%)' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLevelChart(safeId, data, isLight) {
|
|
||||||
const labels = data.map(d => formatDateLabel(d.timestamp));
|
|
||||||
const ctx = document.getElementById(`level_${safeId}`).getContext('2d');
|
|
||||||
const levelLabel = isLight ? 'Brightness' : 'Fan Speed';
|
|
||||||
const levelColor = isLight ? '#ffcd56' : '#9966ff';
|
|
||||||
|
|
||||||
updateChart(`level_${safeId}`, ctx, labels, [{
|
|
||||||
label: levelLabel,
|
|
||||||
data: data.map(d => d.fan_speed),
|
|
||||||
borderColor: levelColor,
|
|
||||||
backgroundColor: levelColor,
|
|
||||||
stepped: true
|
|
||||||
}], {
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
suggestedMin: 0,
|
|
||||||
suggestedMax: 10,
|
|
||||||
ticks: { stepSize: 1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateChart(id, ctx, labels, datasets, extraOptions = {}) {
|
|
||||||
if (chartInstances[id]) {
|
|
||||||
chartInstances[id].destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
interaction: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: { position: 'top' }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
maxRotation: 0,
|
|
||||||
autoSkip: true,
|
|
||||||
maxTicksLimit: 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Merge scales specifically
|
|
||||||
const mergedScales = { ...defaultOptions.scales, ...(extraOptions.scales || {}) };
|
|
||||||
|
|
||||||
// Merge options
|
|
||||||
const options = {
|
|
||||||
...defaultOptions,
|
|
||||||
...extraOptions,
|
|
||||||
scales: mergedScales
|
|
||||||
};
|
|
||||||
|
|
||||||
chartInstances[id] = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: labels,
|
|
||||||
datasets: datasets.map(ds => ({ ...ds, borderWidth: 2, tension: 0.3, pointRadius: 1 }))
|
|
||||||
},
|
|
||||||
options: options
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>AC Inf</title>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<header>
|
|
||||||
<h1>AC Inf</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<!-- Device Select Removed -->
|
|
||||||
|
|
||||||
<div class="time-range">
|
|
||||||
<button class="range-btn active" data-range="day">24 Hours</button>
|
|
||||||
<button class="range-btn" data-range="week">7 Days</button>
|
|
||||||
<button class="range-btn" data-range="month">30 Days</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="devicesContainer" class="devices-container">
|
|
||||||
<!-- Dynamic Content Will Be Loaded Here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="dashboard.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
202
public/style.css
202
public/style.css
@@ -1,202 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg-color: #f4f4f9;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--text-color: #333;
|
|
||||||
--accent-color: #2c974b;
|
|
||||||
--border-color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--card-bg);
|
|
||||||
padding: 15px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-range {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--accent-color);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--accent-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-btn.active,
|
|
||||||
.range-btn:hover {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-wrapper {
|
|
||||||
background: var(--card-bg);
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 300px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Multi-Device Layout */
|
|
||||||
.device-section {
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-header {
|
|
||||||
background: #e9ecef;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-left: 5px solid #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 400px;
|
|
||||||
/* Ensure 2 columns on wide screens */
|
|
||||||
background: white;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-container {
|
|
||||||
position: relative;
|
|
||||||
height: 300px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-divider {
|
|
||||||
border: 0;
|
|
||||||
height: 1px;
|
|
||||||
background: #ddd;
|
|
||||||
margin: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Range Buttons Active State */
|
|
||||||
.range-btn.active {
|
|
||||||
background-color: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grouped Controller Layout */
|
|
||||||
.controller-section {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-header {
|
|
||||||
border-bottom: 2px solid #f0f0f0;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Environment Chart (Full Width) */
|
|
||||||
.environment-row {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-wrapper.wide {
|
|
||||||
width: 100%;
|
|
||||||
min-width: unset;
|
|
||||||
box-shadow: none;
|
|
||||||
/* Inside controller card */
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ports Grid */
|
|
||||||
.ports-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.port-card {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.port-card h4 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-container.small {
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override chart height for small containers */
|
|
||||||
.port-card canvas {
|
|
||||||
height: 200px !important;
|
|
||||||
}
|
|
||||||
141
src/client/AlarmCard.js
Normal file
141
src/client/AlarmCard.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Switch,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Tooltip
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
class AlarmCard extends Component {
|
||||||
|
getTagColor = (tagId) => {
|
||||||
|
const { colorTags } = this.props;
|
||||||
|
const tag = colorTags.find(t => t.id === tagId);
|
||||||
|
return tag ? tag.color : 'transparent';
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, readOnly } = this.props;
|
||||||
|
|
||||||
|
// Parse trigger/action data to display summary
|
||||||
|
const trigger = alarm.trigger || {};
|
||||||
|
const action = alarm.action || {};
|
||||||
|
|
||||||
|
const hasTags = alarm.colorTags && alarm.colorTags.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
|
||||||
|
color: '#ebdbb2',
|
||||||
|
border: '1px solid #504945',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
transform: readOnly ? 'none' : 'translateY(-2px)',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ pb: '16px !important', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
|
||||||
|
{/* Drag Handle / Sort indicators */}
|
||||||
|
{!readOnly && (onMoveUp || onMoveDown) && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<IconButton size="small" onClick={onMoveUp} disabled={!onMoveUp} sx={{ p: 0.5, color: '#a89984' }}>
|
||||||
|
▲
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={onMoveDown} disabled={!onMoveDown} sx={{ p: 0.5, color: '#a89984' }}>
|
||||||
|
▼
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enabled Switch */}
|
||||||
|
<Switch
|
||||||
|
checked={!!alarm.enabled}
|
||||||
|
onChange={onToggle}
|
||||||
|
disabled={readOnly}
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
{alarm.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{hasTags && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
{alarm.colorTags.map(tagId => (
|
||||||
|
<Box
|
||||||
|
key={tagId}
|
||||||
|
sx={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: this.getTagColor(tagId),
|
||||||
|
border: '1px solid #282828'
|
||||||
|
}}
|
||||||
|
title={tagId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action.severity && (
|
||||||
|
<Chip
|
||||||
|
label={action.severity.toUpperCase()}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
bgcolor: action.severity === 'critical' ? '#fb4934' :
|
||||||
|
action.severity === 'warning' ? '#fe8019' : '#83a598',
|
||||||
|
color: '#282828'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{/* Trigger Summary */}
|
||||||
|
{trigger.scheduledTime ? (
|
||||||
|
<span>⏰ {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')})</span>
|
||||||
|
) : trigger.timeRange ? (
|
||||||
|
<span>⏰ {trigger.timeRange.start}-{trigger.timeRange.end}</span>
|
||||||
|
) : trigger.sensors ? (
|
||||||
|
<span>
|
||||||
|
📊 {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Unknown Trigger</span>
|
||||||
|
)}
|
||||||
|
<span style={{ margin: '0 8px', opacity: 0.5 }}>➜</span>
|
||||||
|
<span>🔔 Telegram: "{action.message || 'Alert'}"</span>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{!readOnly && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<IconButton onClick={onEdit} size="small" sx={{ color: '#8ec07c' }}>
|
||||||
|
✎
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={onDelete} size="small" sx={{ color: '#fb4934' }}>
|
||||||
|
🗑
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlarmCard;
|
||||||
447
src/client/AlarmEditor.js
Normal file
447
src/client/AlarmEditor.js
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Chip,
|
||||||
|
Alert
|
||||||
|
} from '@mui/material';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
|
// Reusing some constants/components from RuleEditor logic if possible, but duplicating for isolation as per plan
|
||||||
|
const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
|
|
||||||
|
const OPERATORS = [
|
||||||
|
{ value: '>', label: '>' },
|
||||||
|
{ value: '<', label: '<' },
|
||||||
|
{ value: '>=', label: '≥' },
|
||||||
|
{ value: '<=', label: '≤' },
|
||||||
|
{ value: '==', label: '=' }
|
||||||
|
];
|
||||||
|
|
||||||
|
class SensorCondition extends Component {
|
||||||
|
render() {
|
||||||
|
const { condition, sensors, onChange, onRemove, disabled } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
|
<InputLabel>Sensor</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={condition.sensor || ''}
|
||||||
|
label="Sensor"
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSensor = sensors.find(s => s.id === e.target.value);
|
||||||
|
onChange({ ...condition, sensor: e.target.value, sensorLabel: newSensor?.label });
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{sensors.map(s => (
|
||||||
|
<MenuItem key={s.id} value={s.id}>{s.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" sx={{ minWidth: 70 }}>
|
||||||
|
<Select
|
||||||
|
value={condition.operator || '>'}
|
||||||
|
onChange={(e) => onChange({ ...condition, operator: e.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{OPERATORS.map(op => (
|
||||||
|
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={condition.value ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })}
|
||||||
|
sx={{ width: 80 }}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{onRemove && (
|
||||||
|
<IconButton size="small" onClick={onRemove} disabled={disabled}>
|
||||||
|
❌
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlarmEditor extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
name: '',
|
||||||
|
selectedTags: [],
|
||||||
|
triggerMode: 'sensor',
|
||||||
|
outputTarget: '',
|
||||||
|
outputState: 'on',
|
||||||
|
useTimeRange: false,
|
||||||
|
timeStart: '08:00',
|
||||||
|
timeEnd: '18:00',
|
||||||
|
timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||||
|
sensorConditions: [{ sensor: '', operator: '>', value: 25 }],
|
||||||
|
sensorLogic: 'and',
|
||||||
|
message: '',
|
||||||
|
severity: 'warning'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.open !== prevProps.open || this.props.alarm !== prevProps.alarm) {
|
||||||
|
this.initializeState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.initializeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeState = () => {
|
||||||
|
const { alarm, sensors } = this.props;
|
||||||
|
|
||||||
|
if (alarm) {
|
||||||
|
const tags = alarm.colorTags || (alarm.colorTag ? [alarm.colorTag] : []);
|
||||||
|
const trigger = alarm.trigger || {};
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
name: alarm.name,
|
||||||
|
selectedTags: Array.isArray(tags) ? tags : [],
|
||||||
|
useTimeRange: !!trigger.timeRange,
|
||||||
|
timeStart: trigger.timeRange?.start || '08:00',
|
||||||
|
timeEnd: trigger.timeRange?.end || '18:00',
|
||||||
|
timeRangeDays: trigger.timeRange?.days || [],
|
||||||
|
triggerMode: trigger.outputChange ? 'output' : 'sensor',
|
||||||
|
outputTarget: trigger.outputChange?.target || '',
|
||||||
|
outputState: trigger.outputChange?.state || 'on',
|
||||||
|
sensorConditions: trigger.sensors?.length > 0
|
||||||
|
? trigger.sensors
|
||||||
|
: [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
|
||||||
|
sensorLogic: trigger.sensorLogic || 'and',
|
||||||
|
message: alarm.action?.message || '',
|
||||||
|
severity: alarm.action?.severity || 'warning'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
name: '',
|
||||||
|
selectedTags: [],
|
||||||
|
triggerMode: 'sensor',
|
||||||
|
outputTarget: '',
|
||||||
|
outputState: 'on',
|
||||||
|
useTimeRange: false,
|
||||||
|
timeStart: '08:00',
|
||||||
|
timeEnd: '18:00',
|
||||||
|
timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||||
|
sensorConditions: [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
|
||||||
|
sensorLogic: 'and',
|
||||||
|
message: '',
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addSensorCondition = () => {
|
||||||
|
const { sensors } = this.props;
|
||||||
|
this.setState(prev => ({
|
||||||
|
sensorConditions: [...prev.sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSensorCondition = (index, newCondition) => {
|
||||||
|
this.setState(prev => {
|
||||||
|
const updated = [...prev.sensorConditions];
|
||||||
|
updated[index] = newCondition;
|
||||||
|
return { sensorConditions: updated };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
removeSensorCondition = (index) => {
|
||||||
|
this.setState(prev => {
|
||||||
|
if (prev.sensorConditions.length > 1) {
|
||||||
|
return { sensorConditions: prev.sensorConditions.filter((_, i) => i !== index) };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSave = () => {
|
||||||
|
const { onSave, sensors } = this.props;
|
||||||
|
const { name, selectedTags, triggerMode, outputTarget, outputState,
|
||||||
|
useTimeRange, timeStart, timeEnd, timeRangeDays,
|
||||||
|
sensorConditions, sensorLogic, message, severity } = this.state;
|
||||||
|
|
||||||
|
const trigger = {};
|
||||||
|
|
||||||
|
if (triggerMode === 'output') {
|
||||||
|
trigger.outputChange = {
|
||||||
|
target: outputTarget,
|
||||||
|
state: outputState
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (useTimeRange) {
|
||||||
|
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
|
||||||
|
}
|
||||||
|
trigger.sensors = sensorConditions.map(c => ({
|
||||||
|
...c,
|
||||||
|
sensorLabel: sensors.find(s => s.id === c.sensor)?.label
|
||||||
|
}));
|
||||||
|
trigger.sensorLogic = sensorLogic;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
type: 'telegram',
|
||||||
|
message,
|
||||||
|
severity
|
||||||
|
};
|
||||||
|
|
||||||
|
onSave({ name, trigger, action, colorTags: selectedTags });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, alarm, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving, i18n: { t } } = this.props;
|
||||||
|
const { name, selectedTags, triggerMode, outputTarget, outputState,
|
||||||
|
useTimeRange, timeStart, timeEnd, timeRangeDays,
|
||||||
|
sensorConditions, sensorLogic, message, severity } = this.state;
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0 &&
|
||||||
|
((triggerMode === 'sensor' && sensorConditions.every(c => c.sensor)) ||
|
||||||
|
(triggerMode === 'output' && outputTarget)) &&
|
||||||
|
message.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
|
||||||
|
border: '1px solid #504945'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{alarm ? 'Edit Alarm' : 'Create Alarm'}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Alarm Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => this.setState({ name: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">Tags:</Typography>
|
||||||
|
{availableColorTags.map(tag => (
|
||||||
|
<Box
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedTags.includes(tag.id)) {
|
||||||
|
this.setState({ selectedTags: selectedTags.filter(t => t !== tag.id) });
|
||||||
|
} else {
|
||||||
|
this.setState({ selectedTags: [...selectedTags, tag.id] });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 24, height: 24, borderRadius: '50%',
|
||||||
|
bgcolor: tag.color, cursor: 'pointer',
|
||||||
|
border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedTags.includes(tag.id) && <span style={{ fontSize: '0.7rem' }}>✓</span>}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* TRIGGER */}
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">TRIGGER CONDITIONS</Typography>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Trigger Mode Selection */}
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Trigger Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={triggerMode}
|
||||||
|
label="Trigger Type"
|
||||||
|
onChange={(e) => this.setState({ triggerMode: e.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="sensor">📊 Sensor Value Threshold</MenuItem>
|
||||||
|
<MenuItem value="output">🔌 Output Turn ON/OFF</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{triggerMode === 'output' ? (
|
||||||
|
<Paper sx={{ p: 2, bgcolor: 'action.selected', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Alert severity="info" icon={false} sx={{ py: 0 }}>
|
||||||
|
Trigger when a filtered rule changes a device state.
|
||||||
|
</Alert>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Target Output</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={outputTarget}
|
||||||
|
label="Target Output"
|
||||||
|
onChange={(e) => this.setState({ outputTarget: e.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="any">Any Output</MenuItem>
|
||||||
|
{outputs.map(o => (
|
||||||
|
<MenuItem key={o.id} value={o.id}>{o.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl sx={{ minWidth: 120 }}>
|
||||||
|
<InputLabel>State</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={outputState}
|
||||||
|
label="State"
|
||||||
|
onChange={(e) => this.setState({ outputState: e.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="on">Turns ON</MenuItem>
|
||||||
|
<MenuItem value="off">Turns OFF</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Time Window */}
|
||||||
|
<Paper sx={{ p: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch checked={useTimeRange} onChange={(e) => this.setState({ useTimeRange: e.target.checked })} disabled={saving} />
|
||||||
|
}
|
||||||
|
label="Active Time Window (Optional)"
|
||||||
|
/>
|
||||||
|
{useTimeRange && (
|
||||||
|
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
label="From" type="time"
|
||||||
|
value={timeStart} onChange={(e) => this.setState({ timeStart: e.target.value })}
|
||||||
|
InputLabelProps={{ shrink: true }} size="small"
|
||||||
|
/>
|
||||||
|
<Typography>to</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Until" type="time"
|
||||||
|
value={timeEnd} onChange={(e) => this.setState({ timeEnd: e.target.value })}
|
||||||
|
InputLabelProps={{ shrink: true }} size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Sensors */}
|
||||||
|
<Paper sx={{ p: 2, bgcolor: 'action.selected' }}>
|
||||||
|
<Typography gutterBottom fontWeight="bold">📊 Sensor Thresholds</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{sensorConditions.length > 1 && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2">Logic:</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={sensorLogic} exclusive
|
||||||
|
onChange={(e, v) => v && this.setState({ sensorLogic: v })} size="small"
|
||||||
|
>
|
||||||
|
<ToggleButton value="and">AND</ToggleButton>
|
||||||
|
<ToggleButton value="or">OR</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sensorConditions.map((cond, i) => (
|
||||||
|
<SensorCondition
|
||||||
|
key={i}
|
||||||
|
condition={cond}
|
||||||
|
sensors={sensors}
|
||||||
|
onChange={(newCond) => this.updateSensorCondition(i, newCond)}
|
||||||
|
onRemove={sensorConditions.length > 1 ? () => this.removeSensorCondition(i) : null}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button size="small" onClick={this.addSensorCondition} disabled={saving} sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
+ Add Condition
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ACTION */}
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">ACTION (Telelegram Notification)</Typography>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Severity</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={severity}
|
||||||
|
label="Severity"
|
||||||
|
onChange={(e) => this.setState({ severity: e.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="info">ℹ️ Info</MenuItem>
|
||||||
|
<MenuItem value="warning">⚠️ Warning</MenuItem>
|
||||||
|
<MenuItem value="critical">🔥 Critical</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Notification Message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => this.setState({ message: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
placeholder="e.g. Temperature is too high!"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert severity="info" icon={false} sx={{ bgcolor: 'rgba(2, 136, 209, 0.1)' }}>
|
||||||
|
This message will be sent to all users who have linked their Telegram ID in their profile.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
<Button onClick={onClose} color="inherit" disabled={saving}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!isValid || saving}
|
||||||
|
sx={{ background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)' }}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={20} /> : (alarm ? 'Save Changes' : 'Create Alarm')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(AlarmEditor);
|
||||||
282
src/client/AlarmManager.js
Normal file
282
src/client/AlarmManager.js
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Divider,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AlarmCard from './AlarmCard';
|
||||||
|
import AlarmEditor from './AlarmEditor';
|
||||||
|
import { withAuth } from './AuthContext';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
import { withDevices } from './DevicesContext';
|
||||||
|
|
||||||
|
const COLOR_TAGS = [
|
||||||
|
{ id: 'red', label: 'Red', color: '#fb4934' },
|
||||||
|
{ id: 'orange', label: 'Orange', color: '#fe8019' },
|
||||||
|
{ id: 'yellow', label: 'Yellow', color: '#fabd2f' },
|
||||||
|
{ id: 'green', label: 'Green', color: '#b8bb26' },
|
||||||
|
{ id: 'teal', label: 'Teal', color: '#8ec07c' },
|
||||||
|
{ id: 'blue', label: 'Blue', color: '#83a598' },
|
||||||
|
{ id: 'purple', label: 'Purple', color: '#d3869b' },
|
||||||
|
{ id: 'gray', label: 'Gray', color: '#928374' }
|
||||||
|
];
|
||||||
|
|
||||||
|
class AlarmManager extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
alarms: [],
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
editorOpen: false,
|
||||||
|
editingAlarm: null,
|
||||||
|
saving: false,
|
||||||
|
filterTag: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.fetchAlarms();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthHeaders = () => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAlarms = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/alarms');
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch alarms');
|
||||||
|
const data = await res.json();
|
||||||
|
this.setState({ alarms: data, error: null, loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: err.message, loading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAddAlarm = () => {
|
||||||
|
this.setState({ editingAlarm: null, editorOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleEditAlarm = (alarm) => {
|
||||||
|
this.setState({ editingAlarm: alarm, editorOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDeleteAlarm = async (id) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this alarm?')) return;
|
||||||
|
this.setState({ saving: true });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/alarms/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete alarm');
|
||||||
|
this.setState(prev => ({
|
||||||
|
alarms: prev.alarms.filter(a => a.id !== id),
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: err.message, saving: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleToggleAlarm = async (id) => {
|
||||||
|
const { alarms } = this.state;
|
||||||
|
const alarm = alarms.find(a => a.id === id);
|
||||||
|
if (!alarm) return;
|
||||||
|
this.setState({ saving: true });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/alarms/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ ...alarm, enabled: !alarm.enabled })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update alarm');
|
||||||
|
const updated = await res.json();
|
||||||
|
this.setState(prev => ({
|
||||||
|
alarms: prev.alarms.map(a => a.id === id ? updated : a),
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: err.message, saving: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSaveAlarm = async (alarmData) => {
|
||||||
|
const { editingAlarm } = this.state;
|
||||||
|
this.setState({ saving: true });
|
||||||
|
try {
|
||||||
|
if (editingAlarm) {
|
||||||
|
const res = await fetch(`api/alarms/${editingAlarm.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ ...alarmData, enabled: editingAlarm.enabled })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update alarm');
|
||||||
|
const updated = await res.json();
|
||||||
|
this.setState(prev => ({
|
||||||
|
alarms: prev.alarms.map(a => a.id === editingAlarm.id ? updated : a),
|
||||||
|
editorOpen: false,
|
||||||
|
editingAlarm: null,
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const res = await fetch('api/alarms', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ ...alarmData, enabled: true })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create alarm');
|
||||||
|
const newAlarm = await res.json();
|
||||||
|
this.setState(prev => ({
|
||||||
|
alarms: [...prev.alarms, newAlarm],
|
||||||
|
editorOpen: false,
|
||||||
|
editingAlarm: null,
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: err.message, saving: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMoveAlarm = async (id, direction) => {
|
||||||
|
const { alarms } = this.state;
|
||||||
|
const idx = alarms.findIndex(a => a.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
if (direction === 'up' && idx === 0) return;
|
||||||
|
if (direction === 'down' && idx === alarms.length - 1) return;
|
||||||
|
|
||||||
|
const newAlarms = [...alarms];
|
||||||
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||||
|
[newAlarms[idx], newAlarms[swapIdx]] = [newAlarms[swapIdx], newAlarms[idx]];
|
||||||
|
this.setState({ alarms: newAlarms });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('api/alarms/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ alarmIds: newAlarms.map(a => a.id) })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: 'Failed to save order' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { auth: { isAdmin }, i18n: { t }, devicesCtx: { getAvailableSensors, getAvailableOutputs } } = this.props;
|
||||||
|
const { alarms, loading, error, editorOpen, editingAlarm, saving, filterTag } = this.state;
|
||||||
|
|
||||||
|
const filteredAlarms = filterTag
|
||||||
|
? alarms.filter(a => (a.colorTags || []).includes(filterTag))
|
||||||
|
: alarms;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}><CircularProgress /></Paper>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
mt: 4, ml: 4, mb: 4,
|
||||||
|
p: 3,
|
||||||
|
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
|
||||||
|
border: '1px solid #504945'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
🚨 {t('alarms.title') || 'Alarms'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={this.handleAddAlarm}
|
||||||
|
disabled={saving}
|
||||||
|
sx={{
|
||||||
|
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)',
|
||||||
|
'&:hover': { background: 'linear-gradient(45deg, #e396a5 30%, #ff9029 90%)' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Alarm
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>Filter:</Typography>
|
||||||
|
<Chip
|
||||||
|
label="All" size="small" onClick={() => this.setState({ filterTag: null })}
|
||||||
|
sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
|
||||||
|
/>
|
||||||
|
{COLOR_TAGS.map(tag => (
|
||||||
|
<Chip
|
||||||
|
key={tag.id} size="small" onClick={() => this.setState({ filterTag: filterTag === tag.id ? null : tag.id })}
|
||||||
|
sx={{
|
||||||
|
bgcolor: filterTag === tag.id ? tag.color : '#504945',
|
||||||
|
color: filterTag === tag.id ? '#282828' : tag.color,
|
||||||
|
border: `2px solid ${tag.color}`,
|
||||||
|
'&:hover': { bgcolor: tag.color, color: '#282828' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => this.setState({ error: null })}>{error}</Alert>}
|
||||||
|
|
||||||
|
{filteredAlarms.length === 0 ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Typography color="text.secondary">No alarms found.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{filteredAlarms.map((alarm, idx) => (
|
||||||
|
<AlarmCard
|
||||||
|
key={alarm.id}
|
||||||
|
alarm={alarm}
|
||||||
|
onEdit={isAdmin ? () => this.handleEditAlarm(alarm) : null}
|
||||||
|
onDelete={isAdmin ? () => this.handleDeleteAlarm(alarm.id) : null}
|
||||||
|
onToggle={isAdmin ? () => this.handleToggleAlarm(alarm.id) : null}
|
||||||
|
onMoveUp={isAdmin && idx > 0 ? () => this.handleMoveAlarm(alarm.id, 'up') : null}
|
||||||
|
onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => this.handleMoveAlarm(alarm.id, 'down') : null}
|
||||||
|
colorTags={COLOR_TAGS}
|
||||||
|
readOnly={!isAdmin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<AlarmEditor
|
||||||
|
open={editorOpen}
|
||||||
|
alarm={editingAlarm}
|
||||||
|
onSave={this.handleSaveAlarm}
|
||||||
|
onClose={() => { this.setState({ editorOpen: false, editingAlarm: null }); }}
|
||||||
|
sensors={getAvailableSensors()}
|
||||||
|
outputs={getAvailableOutputs()}
|
||||||
|
colorTags={COLOR_TAGS}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDevices(withAuth(withI18n(AlarmManager)));
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box, Button, Chip } from '@mui/material';
|
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box, Button, Chip } from '@mui/material';
|
||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
import RuleManager from './RuleManager';
|
import RuleManager from './RuleManager';
|
||||||
|
import AlarmManager from './AlarmManager';
|
||||||
import LoginDialog from './LoginDialog';
|
import LoginDialog from './LoginDialog';
|
||||||
import { AuthProvider, useAuth } from './AuthContext';
|
import ProfileDialog from './ProfileDialog';
|
||||||
|
import { AuthProvider, withAuth } from './AuthContext';
|
||||||
|
import { I18nProvider, withI18n } from './I18nContext';
|
||||||
|
import { DevicesProvider } from './DevicesContext';
|
||||||
|
import LanguageSwitcher from './LanguageSwitcher';
|
||||||
|
|
||||||
// Gruvbox Dark color palette
|
// Gruvbox Dark color palette
|
||||||
const gruvboxDark = {
|
const gruvboxDark = {
|
||||||
@@ -42,90 +47,121 @@ const darkTheme = createTheme({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function AppContent() {
|
class AppContent extends Component {
|
||||||
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
|
constructor(props) {
|
||||||
const [showLogin, setShowLogin] = useState(false);
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
showLogin: false,
|
||||||
|
showProfile: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
render() {
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
const { auth: { user, logout, isAuthenticated, isAdmin }, i18n: { t } } = this.props;
|
||||||
<AppBar position="static">
|
const { showLogin, showProfile } = this.state;
|
||||||
<Toolbar>
|
|
||||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
return (
|
||||||
Tischlerei Dashboard
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
</Typography>
|
<AppBar position="static">
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Toolbar>
|
||||||
{isAuthenticated ? (
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
<>
|
{t('app.title')}
|
||||||
<Chip
|
</Typography>
|
||||||
label={user.username}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
color={isAdmin ? 'secondary' : 'default'}
|
<LanguageSwitcher />
|
||||||
size="small"
|
{isAuthenticated ? (
|
||||||
sx={{
|
<>
|
||||||
fontWeight: 600,
|
|
||||||
...(isAdmin && {
|
|
||||||
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isAdmin && (
|
|
||||||
<Chip
|
<Chip
|
||||||
label="ADMIN"
|
label={user.username}
|
||||||
|
onClick={() => this.setState({ showProfile: true })}
|
||||||
|
color={isAdmin ? 'secondary' : 'default'}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: gruvboxDark.purple,
|
fontWeight: 600,
|
||||||
color: gruvboxDark.bg0,
|
cursor: 'pointer',
|
||||||
fontWeight: 700
|
'&:hover': { opacity: 0.8 },
|
||||||
|
...(isAdmin && {
|
||||||
|
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
{isAdmin && (
|
||||||
|
<Chip
|
||||||
|
label={t('app.admin')}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: gruvboxDark.purple,
|
||||||
|
color: gruvboxDark.bg0,
|
||||||
|
fontWeight: 700
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={logout}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('app.logout')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={logout}
|
onClick={() => this.setState({ showLogin: true })}
|
||||||
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ borderColor: gruvboxDark.aqua }}
|
||||||
>
|
>
|
||||||
Logout
|
{t('app.adminLogin')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
) : (
|
</Box>
|
||||||
<Button
|
</Toolbar>
|
||||||
color="inherit"
|
</AppBar>
|
||||||
onClick={() => setShowLogin(true)}
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
variant="outlined"
|
{/* Dashboard is always visible to everyone */}
|
||||||
size="small"
|
<Dashboard />
|
||||||
sx={{ borderColor: gruvboxDark.aqua }}
|
|
||||||
>
|
|
||||||
🔐 Admin Login
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
|
||||||
{/* Dashboard is always visible to everyone */}
|
|
||||||
<Dashboard />
|
|
||||||
|
|
||||||
{/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
|
{/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
|
||||||
<RuleManager />
|
<RuleManager />
|
||||||
</Container>
|
|
||||||
|
|
||||||
{/* Login dialog - shown on demand */}
|
{/* Alarm Manager visible to everyone (guests read-only, admins can edit) */}
|
||||||
<LoginDialog
|
<AlarmManager />
|
||||||
open={showLogin}
|
</Container>
|
||||||
onClose={() => setShowLogin(false)}
|
|
||||||
/>
|
{/* Login dialog - shown on demand */}
|
||||||
</Box>
|
<LoginDialog
|
||||||
);
|
open={showLogin}
|
||||||
|
onClose={() => this.setState({ showLogin: false })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Profile dialog */}
|
||||||
|
<ProfileDialog
|
||||||
|
open={showProfile}
|
||||||
|
onClose={() => this.setState({ showProfile: false })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
const WrappedAppContent = withAuth(withI18n(AppContent));
|
||||||
return (
|
|
||||||
<ThemeProvider theme={darkTheme}>
|
class App extends Component {
|
||||||
<CssBaseline />
|
render() {
|
||||||
<AuthProvider>
|
return (
|
||||||
<AppContent />
|
<ThemeProvider theme={darkTheme}>
|
||||||
</AuthProvider>
|
<CssBaseline />
|
||||||
</ThemeProvider>
|
<I18nProvider>
|
||||||
);
|
<AuthProvider>
|
||||||
|
<DevicesProvider>
|
||||||
|
<WrappedAppContent />
|
||||||
|
</DevicesProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -77,3 +77,11 @@ export function useAuth() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HOC for class components
|
||||||
|
export function withAuth(Component) {
|
||||||
|
return function WrappedComponent(props) {
|
||||||
|
const auth = useAuth();
|
||||||
|
return <Component {...props} auth={auth} />;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { Card, CardHeader, CardContent, Divider, Grid, Box, Typography } from '@mui/material';
|
import { Card, CardHeader, CardContent, Divider, Grid, Box, Typography } from '@mui/material';
|
||||||
import EnvChart from './EnvChart';
|
import EnvChart from './EnvChart';
|
||||||
import LevelChart from './LevelChart';
|
import LevelChart from './LevelChart';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
export default function ControllerCard({ controllerName, ports, range }) {
|
class ControllerCard extends Component {
|
||||||
const [envData, setEnvData] = useState([]);
|
constructor(props) {
|
||||||
const [portData, setPortData] = useState({});
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
envData: [],
|
||||||
|
portData: {}
|
||||||
|
};
|
||||||
|
this.interval = null;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
componentDidMount() {
|
||||||
|
this.fetchData();
|
||||||
|
this.interval = setInterval(() => this.fetchData(), 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.controllerName !== this.props.controllerName ||
|
||||||
|
prevProps.range !== this.props.range ||
|
||||||
|
prevProps.offset !== this.props.offset) {
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData = async () => {
|
||||||
|
const { controllerName, ports, range, offset } = this.props;
|
||||||
try {
|
try {
|
||||||
if (ports.length === 0) return;
|
if (ports.length === 0) return;
|
||||||
|
|
||||||
// Fetch all ports concurrently
|
// Fetch all ports concurrently
|
||||||
const promises = ports.map(port =>
|
const promises = ports.map(port =>
|
||||||
fetch(`api/history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${range}`)
|
fetch(`api/history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${range}&offset=${offset || 0}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => ({ port: port.port, data }))
|
.then(data => ({ port: port.port, data }))
|
||||||
);
|
);
|
||||||
@@ -25,12 +52,12 @@ export default function ControllerCard({ controllerName, ports, range }) {
|
|||||||
newPortData[item.port] = item.data;
|
newPortData[item.port] = item.data;
|
||||||
});
|
});
|
||||||
|
|
||||||
setPortData(newPortData);
|
this.setState({ portData: newPortData });
|
||||||
|
|
||||||
// Use the data from the first port for the Environment Chart
|
// Use the data from the first port for the Environment Chart
|
||||||
// This avoids a redundant network request
|
// This avoids a redundant network request
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
setEnvData(results[0].data);
|
this.setState({ envData: results[0].data });
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -38,55 +65,55 @@ export default function ControllerCard({ controllerName, ports, range }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial Fetch & Auto-Refresh
|
render() {
|
||||||
useEffect(() => {
|
const { controllerName, ports, range, i18n: { t } } = this.props;
|
||||||
fetchData();
|
const { envData, portData } = this.state;
|
||||||
const interval = setInterval(fetchData, 60000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [controllerName, range]); // Depend on range, controllerName changes rarely
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ mb: 4, borderRadius: 2, boxShadow: 3 }}>
|
<Card sx={{ mb: 4, borderRadius: 2, boxShadow: 3 }}>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title={controllerName}
|
title={controllerName}
|
||||||
titleTypographyProps={{ variant: 'h5', fontWeight: 'bold', color: 'primary.main' }}
|
titleTypographyProps={{ variant: 'h5', fontWeight: 'bold', color: 'primary.main' }}
|
||||||
sx={{ bgcolor: 'background.paper', borderLeft: '6px solid', borderLeftColor: 'primary.main' }}
|
sx={{ bgcolor: 'background.paper', borderLeft: '6px solid', borderLeftColor: 'primary.main' }}
|
||||||
/>
|
/>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Environment Chart */}
|
{/* Environment Chart */}
|
||||||
<Box sx={{ height: 350, mb: 6 }}>
|
<Box sx={{ height: 350, mb: 6 }}>
|
||||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
Environment (Temp / Humidity)
|
{t('controller.environment')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<EnvChart data={envData} range={range} />
|
<EnvChart data={envData} range={range} offset={this.props.offset} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ mt: 2, mb: 3 }} />
|
<Divider sx={{ mt: 2, mb: 3 }} />
|
||||||
|
|
||||||
{/* Port Grid */}
|
{/* Port Grid */}
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{ports.map((port) => {
|
{ports.map((port) => {
|
||||||
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
||||||
const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2');
|
const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2');
|
||||||
const pData = portData[port.port] || [];
|
const pData = portData[port.port] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={port.port}>
|
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={port.port}>
|
||||||
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
{port.port_name || `Port ${port.port}`}
|
{port.port_name || `${t('controller.port')} ${port.port}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ height: 250 }}>
|
<Box sx={{ height: 250 }}>
|
||||||
<LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} />
|
<LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} offset={this.props.offset} />
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Grid>
|
</Grid>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withI18n(ControllerCard);
|
||||||
|
|||||||
@@ -1,85 +1,128 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material';
|
import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material';
|
||||||
import ControllerCard from './ControllerCard';
|
import ControllerCard from './ControllerCard';
|
||||||
|
import OutputChart from './OutputChart';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
import { withDevices } from './DevicesContext';
|
||||||
|
|
||||||
export default function Dashboard() {
|
class Dashboard extends Component {
|
||||||
const [groupedDevices, setGroupedDevices] = useState({});
|
constructor(props) {
|
||||||
const [loading, setLoading] = useState(true);
|
super(props);
|
||||||
const [error, setError] = useState(null);
|
this.state = {
|
||||||
const [range, setRange] = useState('day'); // 'day', 'week', 'month'
|
range: 'day', // 'day', 'week', 'month'
|
||||||
|
offset: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const fetchDevices = useCallback(async () => {
|
setRange = (range) => {
|
||||||
try {
|
this.setState({ range, offset: 0 });
|
||||||
// Robust API Base detection
|
};
|
||||||
const baseUrl = window.location.pathname.endsWith('/') ? 'api/' : 'api/';
|
|
||||||
// Actually, since we are serving from root or subpath, relative 'api/' is tricky if URL depth changes.
|
|
||||||
// Better to use a relative path that works from the page root.
|
|
||||||
// If page is /ac-dashboard/, fetch is /ac-dashboard/api/devices.
|
|
||||||
|
|
||||||
const res = await fetch('api/devices');
|
setOffset = (offset) => {
|
||||||
if (!res.ok) throw new Error('Failed to fetch devices');
|
if (offset < 0) offset = 0;
|
||||||
|
this.setState({ offset });
|
||||||
|
};
|
||||||
|
|
||||||
const devices = await res.json();
|
render() {
|
||||||
|
const { i18n: { t }, devicesCtx: { devices, groupedDevices, loading, error } } = this.props;
|
||||||
|
const { range, offset } = this.state;
|
||||||
|
|
||||||
// Group by dev_name
|
const nowMs = Date.now();
|
||||||
const grouped = devices.reduce((acc, dev) => {
|
let startMs, endMs;
|
||||||
if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
|
|
||||||
acc[dev.dev_name].push(dev);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
setGroupedDevices(grouped);
|
if (range === 'today') {
|
||||||
setLoading(false);
|
const d = new Date(nowMs);
|
||||||
} catch (err) {
|
d.setHours(0, 0, 0, 0);
|
||||||
console.error(err);
|
const midnightMs = d.getTime();
|
||||||
setError(err.message);
|
startMs = midnightMs - (offset * 24 * 3600 * 1000);
|
||||||
setLoading(false);
|
endMs = startMs + (24 * 3600 * 1000);
|
||||||
|
} else {
|
||||||
|
const durationSec = (range === 'week' ? 7 * 24 * 3600 : (range === 'month' ? 30 * 24 * 3600 : 24 * 3600));
|
||||||
|
endMs = nowMs - (offset * durationSec * 1000);
|
||||||
|
startMs = endMs - (durationSec * 1000);
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const dateOpts = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
|
||||||
fetchDevices();
|
const dateRangeLabel = `${new Date(startMs).toLocaleString([], dateOpts)} - ${new Date(endMs).toLocaleString([], dateOpts)}`;
|
||||||
}, [fetchDevices]);
|
|
||||||
|
|
||||||
// Auto-refresh logic (basic rerender trigger could be added here,
|
if (loading) return <Typography>{t('dashboard.loading')}</Typography>;
|
||||||
// but simpler to let ControllerCard handle data fetching internally based on props)
|
if (error) return <Alert severity="error">{error}</Alert>;
|
||||||
|
|
||||||
if (loading) return <Typography>Loading devices...</Typography>;
|
return (
|
||||||
if (error) return <Alert severity="error">{error}</Alert>;
|
<Box>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
mb={3}
|
||||||
|
sx={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
backgroundColor: '#1d2021', // Dashboard background color to cover content
|
||||||
|
padding: '10px 20px', // Added horizontal padding
|
||||||
|
borderBottom: '1px solid #3c3836',
|
||||||
|
margin: '-16px -16px 16px -16px' // Negative margin to stretch full width if inside padding
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ color: '#ebdbb2' }}>
|
||||||
|
{dateRangeLabel}
|
||||||
|
</Typography>
|
||||||
|
<ButtonGroup variant="contained" aria-label="outlined primary button group">
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setOffset(this.state.offset + 1)}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setRange('day')}
|
||||||
|
color={range === 'day' ? 'primary' : 'inherit'}
|
||||||
|
>
|
||||||
|
{t('dashboard.hours24')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setRange('today')}
|
||||||
|
color={range === 'today' ? 'primary' : 'inherit'}
|
||||||
|
>
|
||||||
|
1 Tag
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setRange('week')}
|
||||||
|
color={range === 'week' ? 'primary' : 'inherit'}
|
||||||
|
>
|
||||||
|
{t('dashboard.days7')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setRange('month')}
|
||||||
|
color={range === 'month' ? 'primary' : 'inherit'}
|
||||||
|
>
|
||||||
|
{t('dashboard.days30')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setOffset(this.state.offset - 1)}
|
||||||
|
disabled={this.state.offset <= 0}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Box>
|
||||||
|
|
||||||
return (
|
{Object.entries(groupedDevices).map(([controllerName, ports]) => (
|
||||||
<Box>
|
<ControllerCard
|
||||||
<Box display="flex" justifyContent="flex-end" mb={3}>
|
key={controllerName}
|
||||||
<ButtonGroup variant="contained" aria-label="outlined primary button group">
|
controllerName={controllerName}
|
||||||
<Button
|
ports={ports}
|
||||||
onClick={() => setRange('day')}
|
range={range}
|
||||||
color={range === 'day' ? 'primary' : 'inherit'}
|
offset={this.state.offset}
|
||||||
>
|
/>
|
||||||
24 Hours
|
))}
|
||||||
</Button>
|
|
||||||
<Button
|
<OutputChart range={range} offset={this.state.offset} devices={devices} />
|
||||||
onClick={() => setRange('week')}
|
|
||||||
color={range === 'week' ? 'primary' : 'inherit'}
|
|
||||||
>
|
|
||||||
7 Days
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setRange('month')}
|
|
||||||
color={range === 'month' ? 'primary' : 'inherit'}
|
|
||||||
>
|
|
||||||
30 Days
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
);
|
||||||
{Object.entries(groupedDevices).map(([controllerName, ports]) => (
|
}
|
||||||
<ControllerCard
|
|
||||||
key={controllerName}
|
|
||||||
controllerName={controllerName}
|
|
||||||
ports={ports}
|
|
||||||
range={range}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withDevices(withI18n(Dashboard));
|
||||||
|
|||||||
135
src/client/DevicesContext.js
Normal file
135
src/client/DevicesContext.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import React, { Component, createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
const DevicesContext = createContext(null);
|
||||||
|
|
||||||
|
export class DevicesProvider extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
devices: [],
|
||||||
|
groupedDevices: {},
|
||||||
|
loading: true,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.fetchDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDevices = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/devices');
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch devices');
|
||||||
|
|
||||||
|
const devices = await res.json();
|
||||||
|
|
||||||
|
// Group by dev_name
|
||||||
|
const grouped = devices.reduce((acc, dev) => {
|
||||||
|
if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
|
||||||
|
acc[dev.dev_name].push(dev);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
devices,
|
||||||
|
groupedDevices: grouped,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch devices:', err);
|
||||||
|
this.setState({
|
||||||
|
error: err.message,
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build available sensors from devices
|
||||||
|
getAvailableSensors = () => {
|
||||||
|
const { devices } = this.state;
|
||||||
|
const availableSensors = [];
|
||||||
|
const seenDevices = new Set();
|
||||||
|
|
||||||
|
devices.forEach(d => {
|
||||||
|
if (!seenDevices.has(d.dev_name)) {
|
||||||
|
seenDevices.add(d.dev_name);
|
||||||
|
availableSensors.push({
|
||||||
|
id: `${d.dev_name}:temp`,
|
||||||
|
label: `${d.dev_name} - Temperature`,
|
||||||
|
type: 'temperature'
|
||||||
|
});
|
||||||
|
availableSensors.push({
|
||||||
|
id: `${d.dev_name}:humidity`,
|
||||||
|
label: `${d.dev_name} - Humidity`,
|
||||||
|
type: 'humidity'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
availableSensors.push({
|
||||||
|
id: `${d.dev_name}:${d.port}:level`,
|
||||||
|
label: `${d.dev_name} - ${d.port_name} Level`,
|
||||||
|
type: d.port_name.toLowerCase()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return availableSensors;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build available outputs from devices
|
||||||
|
getAvailableOutputs = () => {
|
||||||
|
const { devices } = this.state;
|
||||||
|
return [
|
||||||
|
{ id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
|
||||||
|
{ id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
|
||||||
|
{ id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
|
||||||
|
{ id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
|
||||||
|
{ id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
|
||||||
|
...devices.map(d => ({
|
||||||
|
id: `${d.dev_name}:${d.port}:out`,
|
||||||
|
label: `${d.dev_name} - ${d.port_name}`,
|
||||||
|
type: d.port_name.toLowerCase()
|
||||||
|
})),
|
||||||
|
{ id: 'virtual-1', label: 'Virtual Channel 1', type: 'virtual' },
|
||||||
|
{ id: 'virtual-2', label: 'Virtual Channel 2', type: 'virtual' },
|
||||||
|
{ id: 'virtual-3', label: 'Virtual Channel 3', type: 'virtual' },
|
||||||
|
{ id: 'virtual-4', label: 'Virtual Channel 4', type: 'virtual' }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { devices, groupedDevices, loading, error } = this.state;
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
devices,
|
||||||
|
groupedDevices,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshDevices: this.fetchDevices,
|
||||||
|
getAvailableSensors: this.getAvailableSensors,
|
||||||
|
getAvailableOutputs: this.getAvailableOutputs
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DevicesContext.Provider value={value}>
|
||||||
|
{this.props.children}
|
||||||
|
</DevicesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevices() {
|
||||||
|
const context = useContext(DevicesContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDevices must be used within a DevicesProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HOC for class components
|
||||||
|
export function withDevices(Component) {
|
||||||
|
return function WrappedComponent(props) {
|
||||||
|
const devices = useDevices();
|
||||||
|
return <Component {...props} devicesCtx={devices} />;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -21,10 +21,9 @@ ChartJS.register(
|
|||||||
Legend
|
Legend
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function EnvChart({ data, range }) {
|
class EnvChart extends Component {
|
||||||
if (!data || data.length === 0) return null;
|
formatTime = (timestamp) => {
|
||||||
|
const { range } = this.props;
|
||||||
const formatDateLabel = (timestamp) => {
|
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
if (range === 'day') {
|
if (range === 'day') {
|
||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
@@ -34,65 +33,120 @@ export default function EnvChart({ data, range }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartData = {
|
render() {
|
||||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
const { data } = this.props;
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Temperature (°C)',
|
|
||||||
data: data.map(d => d.temp_c),
|
|
||||||
borderColor: '#ff6384',
|
|
||||||
backgroundColor: '#ff6384',
|
|
||||||
yAxisID: 'y',
|
|
||||||
tension: 0.4,
|
|
||||||
pointRadius: 0,
|
|
||||||
borderWidth: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Humidity (%)',
|
|
||||||
data: data.map(d => d.humidity),
|
|
||||||
borderColor: '#36a2eb',
|
|
||||||
backgroundColor: '#36a2eb',
|
|
||||||
yAxisID: 'y1',
|
|
||||||
tension: 0.4,
|
|
||||||
pointRadius: 0,
|
|
||||||
borderWidth: 2
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const options = {
|
if (!data || !data.temps || data.temps.length === 0) return null;
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
const { start, step, temps, hums } = data;
|
||||||
interaction: {
|
|
||||||
mode: 'index',
|
// Generate points: x (timestamp ms), y (value)
|
||||||
intersect: false,
|
// start is in seconds, step in seconds.
|
||||||
},
|
// We can just map the index.
|
||||||
scales: {
|
const startTimeMs = start * 1000;
|
||||||
x: {
|
const stepMs = step * 1000;
|
||||||
ticks: {
|
|
||||||
maxRotation: 0,
|
const tempPoints = temps.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val }));
|
||||||
autoSkip: true,
|
const humPoints = hums.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val }));
|
||||||
maxTicksLimit: 12
|
|
||||||
|
// Filter out nulls if charts line breaks are desired on gaps
|
||||||
|
// Chart.js handles nulls by breaking the line, which is usually desired.
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Temperature (°C)',
|
||||||
|
data: tempPoints,
|
||||||
|
borderColor: '#ff6384',
|
||||||
|
backgroundColor: '#ff6384',
|
||||||
|
yAxisID: 'y',
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
spanGaps: true // or false if we want breaks
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Humidity (%)',
|
||||||
|
data: humPoints,
|
||||||
|
borderColor: '#36a2eb',
|
||||||
|
backgroundColor: '#36a2eb',
|
||||||
|
yAxisID: 'y1',
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
let startMs, endMs;
|
||||||
|
|
||||||
|
if (this.props.range === 'today') {
|
||||||
|
const d = new Date(nowMs);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
const midnightMs = d.getTime();
|
||||||
|
startMs = midnightMs - ((this.props.offset || 0) * 24 * 3600 * 1000);
|
||||||
|
endMs = startMs + (24 * 3600 * 1000);
|
||||||
|
} else {
|
||||||
|
const durationSec = (this.props.range === 'week' ? 7 * 24 * 3600 : (this.props.range === 'month' ? 30 * 24 * 3600 : 24 * 3600));
|
||||||
|
endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000);
|
||||||
|
startMs = endMs - (durationSec * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
animation: false,
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
min: startMs,
|
||||||
|
max: endMs,
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 12,
|
||||||
|
callback: (value) => this.formatTime(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
title: { display: true, text: 'Temp (°C)' },
|
||||||
|
suggestedMin: 15,
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
title: { display: true, text: 'Humidity (%)' },
|
||||||
|
suggestedMin: 30,
|
||||||
|
suggestedMax: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: (context) => {
|
||||||
|
if (context.length > 0) {
|
||||||
|
return this.formatTime(context[0].parsed.x);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
y: {
|
};
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'left',
|
|
||||||
title: { display: true, text: 'Temp (°C)' },
|
|
||||||
suggestedMin: 15,
|
|
||||||
},
|
|
||||||
y1: {
|
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'right',
|
|
||||||
grid: { drawOnChartArea: false },
|
|
||||||
title: { display: true, text: 'Humidity (%)' },
|
|
||||||
suggestedMin: 30,
|
|
||||||
suggestedMax: 80,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Line data={chartData} options={options} />;
|
return <Line data={chartData} options={options} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default EnvChart;
|
||||||
|
|||||||
90
src/client/I18nContext.js
Normal file
90
src/client/I18nContext.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||||
|
import en from './i18n/en.json';
|
||||||
|
import de from './i18n/de.json';
|
||||||
|
|
||||||
|
const translations = { en, de };
|
||||||
|
|
||||||
|
// Cookie helpers
|
||||||
|
function getCookie(name) {
|
||||||
|
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||||
|
return match ? match[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name, value, days = 365) {
|
||||||
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
|
document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial language from cookie or browser preference
|
||||||
|
function getInitialLanguage() {
|
||||||
|
const cookieLang = getCookie('lang');
|
||||||
|
if (cookieLang && translations[cookieLang]) {
|
||||||
|
return cookieLang;
|
||||||
|
}
|
||||||
|
// Check browser preference
|
||||||
|
const browserLang = navigator.language?.slice(0, 2);
|
||||||
|
if (browserLang === 'de') return 'de';
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext(null);
|
||||||
|
|
||||||
|
export function I18nProvider({ children }) {
|
||||||
|
const [language, setLanguageState] = useState(getInitialLanguage);
|
||||||
|
|
||||||
|
const setLanguage = useCallback((lang) => {
|
||||||
|
if (translations[lang]) {
|
||||||
|
setLanguageState(lang);
|
||||||
|
setCookie('lang', lang);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Translation function with nested key support (e.g., 'app.title')
|
||||||
|
const t = useCallback((key, params = {}) => {
|
||||||
|
const keys = key.split('.');
|
||||||
|
let value = translations[language];
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
value = value[k];
|
||||||
|
} else {
|
||||||
|
return key; // fallback to key if not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') return key;
|
||||||
|
|
||||||
|
// Replace {param} placeholders
|
||||||
|
return value.replace(/\{(\w+)\}/g, (_, param) =>
|
||||||
|
params[param] !== undefined ? params[param] : `{${param}}`
|
||||||
|
);
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
language,
|
||||||
|
setLanguage,
|
||||||
|
t
|
||||||
|
}), [language, setLanguage, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
const context = useContext(I18nContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useI18n must be used within an I18nProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HOC for class components
|
||||||
|
export function withI18n(Component) {
|
||||||
|
return function WrappedComponent(props) {
|
||||||
|
const i18n = useI18n();
|
||||||
|
return <Component {...props} i18n={i18n} />;
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/client/LanguageSwitcher.js
Normal file
46
src/client/LanguageSwitcher.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Box, IconButton, Tooltip } from '@mui/material';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
|
// Flag emojis for language switching
|
||||||
|
const FLAG_DE = '🇩🇪';
|
||||||
|
const FLAG_EN = '🇬🇧';
|
||||||
|
|
||||||
|
class LanguageSwitcher extends Component {
|
||||||
|
render() {
|
||||||
|
const { i18n: { language, setLanguage } } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<Tooltip title="English">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setLanguage('en')}
|
||||||
|
sx={{
|
||||||
|
opacity: language === 'en' ? 1 : 0.5,
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
'&:hover': { opacity: 1 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FLAG_EN}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Deutsch">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setLanguage('de')}
|
||||||
|
sx={{
|
||||||
|
opacity: language === 'de' ? 1 : 0.5,
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
'&:hover': { opacity: 1 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FLAG_DE}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(LanguageSwitcher);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -21,10 +21,9 @@ ChartJS.register(
|
|||||||
Legend
|
Legend
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function LevelChart({ data, isLight, isCO2, range }) {
|
class LevelChart extends Component {
|
||||||
if (!data || data.length === 0) return null;
|
formatTime = (timestamp) => {
|
||||||
|
const { range } = this.props;
|
||||||
const formatDateLabel = (timestamp) => {
|
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
if (range === 'day') {
|
if (range === 'day') {
|
||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
@@ -34,49 +33,95 @@ export default function LevelChart({ data, isLight, isCO2, range }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine label and color based on sensor type
|
render() {
|
||||||
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
|
const { data, isLight, isCO2 } = this.props;
|
||||||
const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
|
|
||||||
|
|
||||||
const chartData = {
|
if (!data || !data.levels || data.levels.length === 0) return null;
|
||||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
|
||||||
datasets: [
|
const { start, step, levels } = data;
|
||||||
{
|
const startTimeMs = start * 1000;
|
||||||
label: levelLabel,
|
const stepMs = step * 1000;
|
||||||
data: data.map(d => d.fan_speed),
|
|
||||||
borderColor: levelColor,
|
const points = levels.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val }));
|
||||||
backgroundColor: levelColor,
|
|
||||||
stepped: !isCO2, // CO2 uses smooth lines
|
// Determine label and color based on sensor type
|
||||||
tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others
|
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
|
||||||
borderWidth: 2,
|
const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
|
||||||
pointRadius: 0
|
|
||||||
|
const chartData = {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: levelLabel,
|
||||||
|
data: points,
|
||||||
|
borderColor: levelColor,
|
||||||
|
backgroundColor: levelColor,
|
||||||
|
stepped: !isCO2, // CO2 uses smooth lines
|
||||||
|
tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// CO2 needs different Y-axis scale (ppm range)
|
||||||
|
const yScale = isCO2
|
||||||
|
? { suggestedMin: 200, suggestedMax: 900 }
|
||||||
|
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
let startMs, endMs;
|
||||||
|
|
||||||
|
if (this.props.range === 'today') {
|
||||||
|
const d = new Date(nowMs);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
const midnightMs = d.getTime();
|
||||||
|
startMs = midnightMs - ((this.props.offset || 0) * 24 * 3600 * 1000);
|
||||||
|
endMs = startMs + (24 * 3600 * 1000);
|
||||||
|
} else {
|
||||||
|
const durationSec = (this.props.range === 'week' ? 7 * 24 * 3600 : (this.props.range === 'month' ? 30 * 24 * 3600 : 24 * 3600));
|
||||||
|
endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000);
|
||||||
|
startMs = endMs - (durationSec * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
animation: false,
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
},
|
},
|
||||||
],
|
scales: {
|
||||||
};
|
x: {
|
||||||
|
type: 'linear',
|
||||||
// CO2 needs different Y-axis scale (ppm range)
|
min: startMs,
|
||||||
const yScale = isCO2
|
max: endMs,
|
||||||
? { suggestedMin: 400, suggestedMax: 2000 }
|
ticks: {
|
||||||
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
|
maxRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
const options = {
|
maxTicksLimit: 8,
|
||||||
responsive: true,
|
callback: (value) => this.formatTime(value)
|
||||||
maintainAspectRatio: false,
|
}
|
||||||
interaction: {
|
},
|
||||||
mode: 'index',
|
y: yScale
|
||||||
intersect: false,
|
},
|
||||||
},
|
plugins: {
|
||||||
scales: {
|
tooltip: {
|
||||||
x: {
|
callbacks: {
|
||||||
ticks: {
|
title: (context) => {
|
||||||
maxRotation: 0,
|
if (context.length > 0) {
|
||||||
autoSkip: true,
|
return this.formatTime(context[0].parsed.x);
|
||||||
maxTicksLimit: 8
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
y: yScale
|
};
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Line data={chartData} options={options} />;
|
return <Line data={chartData} options={options} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default LevelChart;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -13,138 +13,152 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useAuth } from './AuthContext';
|
import { withAuth } from './AuthContext';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
// Simple eye icons using unicode
|
// Simple eye icons using unicode
|
||||||
const VisibilityIcon = () => <span style={{ fontSize: '1.2rem' }}>👁</span>;
|
const VisibilityIcon = () => <span style={{ fontSize: '1.2rem' }}>👁</span>;
|
||||||
const VisibilityOffIcon = () => <span style={{ fontSize: '1.2rem' }}>👁🗨</span>;
|
const VisibilityOffIcon = () => <span style={{ fontSize: '1.2rem' }}>👁🗨</span>;
|
||||||
|
|
||||||
export default function LoginDialog({ open, onClose }) {
|
class LoginDialog extends Component {
|
||||||
const { login } = useAuth();
|
constructor(props) {
|
||||||
const [username, setUsername] = useState('');
|
super(props);
|
||||||
const [password, setPassword] = useState('');
|
this.state = {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
username: '',
|
||||||
const [error, setError] = useState('');
|
password: '',
|
||||||
const [loading, setLoading] = useState(false);
|
showPassword: false,
|
||||||
|
error: '',
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
const { auth: { login }, onClose } = this.props;
|
||||||
setLoading(true);
|
const { username, password } = this.state;
|
||||||
|
|
||||||
|
this.setState({ error: '', loading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
// Success - close dialog and reset form
|
// Success - close dialog and reset form
|
||||||
setUsername('');
|
this.setState({ username: '', password: '' });
|
||||||
setPassword('');
|
|
||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
this.setState({ loading: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
handleClose = () => {
|
||||||
setError('');
|
const { onClose } = this.props;
|
||||||
|
this.setState({ error: '' });
|
||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
render() {
|
||||||
<Dialog
|
const { open, i18n: { t } } = this.props;
|
||||||
open={open}
|
const { username, password, showPassword, error, loading } = this.state;
|
||||||
onClose={handleClose}
|
|
||||||
maxWidth="xs"
|
|
||||||
fullWidth
|
|
||||||
PaperProps={{
|
|
||||||
sx: {
|
|
||||||
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid #504945'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
|
||||||
<Typography variant="h5" component="div" sx={{ fontWeight: 600 }}>
|
|
||||||
🔐 Dashboard Login
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
|
||||||
Tischlerei Automation Control
|
|
||||||
</Typography>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
return (
|
||||||
<DialogContent>
|
<Dialog
|
||||||
{error && (
|
open={open}
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
onClose={this.handleClose}
|
||||||
{error}
|
maxWidth="xs"
|
||||||
</Alert>
|
fullWidth
|
||||||
)}
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid #504945'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
||||||
|
<Typography variant="h5" component="div" sx={{ fontWeight: 600 }}>
|
||||||
|
{t('login.title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{t('login.subtitle')}
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<TextField
|
<form onSubmit={this.handleSubmit}>
|
||||||
autoFocus
|
<DialogContent>
|
||||||
margin="dense"
|
{error && (
|
||||||
label="Username"
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
type="text"
|
{error}
|
||||||
fullWidth
|
</Alert>
|
||||||
variant="outlined"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
margin="dense"
|
|
||||||
label="Password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
edge="end"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
size="large"
|
|
||||||
disabled={loading || !username || !password}
|
|
||||||
sx={{
|
|
||||||
py: 1.5,
|
|
||||||
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
|
|
||||||
'&:hover': {
|
|
||||||
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<CircularProgress size={20} color="inherit" />
|
|
||||||
<span>Signing in...</span>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
'Sign In'
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
<TextField
|
||||||
</form>
|
autoFocus
|
||||||
</Dialog>
|
margin="dense"
|
||||||
);
|
label={t('login.username')}
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => this.setState({ username: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label={t('login.password')}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => this.setState({ password: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => this.setState({ showPassword: !showPassword })}
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
size="large"
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CircularProgress size={20} color="inherit" />
|
||||||
|
<span>{t('login.signingIn')}</span>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
t('login.signIn')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withAuth(withI18n(LoginDialog));
|
||||||
|
|||||||
236
src/client/OutputChart.js
Normal file
236
src/client/OutputChart.js
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
class OutputChart extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
data: [],
|
||||||
|
loading: true
|
||||||
|
};
|
||||||
|
this.interval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.fetchData();
|
||||||
|
// Poll for updates every minute
|
||||||
|
this.interval = setInterval(() => this.fetchData(), 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.range !== this.props.range || prevProps.offset !== this.props.offset) {
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData = async () => {
|
||||||
|
const { range, offset } = this.props;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/outputs/history?range=${range || 'day'}&offset=${offset || 0}`);
|
||||||
|
const logs = await res.json();
|
||||||
|
this.setState({ data: logs, loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch output history', err);
|
||||||
|
this.setState({ loading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
formatTime = (timestamp) => {
|
||||||
|
const { range } = this.props;
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (range === 'day') {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' +
|
||||||
|
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { devices = [], range } = this.props;
|
||||||
|
const { data, loading } = this.state;
|
||||||
|
|
||||||
|
if (loading) return <CircularProgress size={20} />;
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
// Data is now a dictionary: { "Dev:Port": [ [ts, state, level], ... ] }
|
||||||
|
const groupedData = data;
|
||||||
|
|
||||||
|
// Gruvbox Palette
|
||||||
|
const gruvboxColors = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019', '#928374'];
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
let startMs, endMs;
|
||||||
|
|
||||||
|
if (range === 'today') {
|
||||||
|
const d = new Date(nowMs);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
const midnightMs = d.getTime();
|
||||||
|
startMs = midnightMs - ((this.props.offset || 0) * 24 * 3600 * 1000);
|
||||||
|
endMs = startMs + (24 * 3600 * 1000);
|
||||||
|
} else {
|
||||||
|
const durationSec = (range === 'week' ? 7 * 24 * 3600 : (range === 'month' ? 30 * 24 * 3600 : 24 * 3600));
|
||||||
|
endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000);
|
||||||
|
startMs = endMs - (durationSec * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate datasets
|
||||||
|
const datasets = Object.keys(groupedData).map((key, index) => {
|
||||||
|
const logs = groupedData[key];
|
||||||
|
const color = gruvboxColors[index % gruvboxColors.length];
|
||||||
|
const offset = index * 0.15; // Visual offset to prevent overlap
|
||||||
|
|
||||||
|
// Resolve Label
|
||||||
|
let label = key;
|
||||||
|
const isTapo = key.includes('tapo') || key.includes('Plug');
|
||||||
|
|
||||||
|
if (devices && devices.length > 0) {
|
||||||
|
const [dName, pNum] = key.split(':');
|
||||||
|
const portNum = parseInt(pNum);
|
||||||
|
const device = devices.find(d => d.dev_name === dName && d.port === portNum);
|
||||||
|
if (device) {
|
||||||
|
label = `${device.dev_name} - ${device.port_name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label === key && key.startsWith('tapo-')) {
|
||||||
|
const parts2 = key.split(':');
|
||||||
|
const tapoId = parts2[0].replace('tapo-', '');
|
||||||
|
label = `Tapo Plug ${tapoId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map [ts, state, level] -> {x, y}
|
||||||
|
const points = logs.map(d => ({
|
||||||
|
x: d[0], // Already epoch ms
|
||||||
|
y: (d[1] === 0 ? 0 : (d[2] || 10)) + offset
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Extend line to end of window (or "now")
|
||||||
|
// If offset is 0, endMs is now. If offset > 0, endMs is historical.
|
||||||
|
// But visually we want the line to extend to the right edge of the chart.
|
||||||
|
if (points.length > 0) {
|
||||||
|
const lastPoint = points[points.length - 1];
|
||||||
|
points.push({
|
||||||
|
x: endMs, // Extend to end of current view window
|
||||||
|
y: lastPoint.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: label,
|
||||||
|
data: points,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color,
|
||||||
|
stepped: true, // Crucial for sparse data
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
isBinary: isTapo
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
min: startMs,
|
||||||
|
max: endMs,
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 12,
|
||||||
|
callback: (value) => this.formatTime(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
min: 0,
|
||||||
|
max: 12,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1,
|
||||||
|
callback: (val) => {
|
||||||
|
const rVal = Math.round(val);
|
||||||
|
if (rVal === 0) return 'OFF';
|
||||||
|
if (rVal === 10) return 'MAX/ON';
|
||||||
|
return rVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: { color: '#ebdbb2' }
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Output History (Levels & States)',
|
||||||
|
color: '#ebdbb2'
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: (context) => {
|
||||||
|
if (context.length > 0) {
|
||||||
|
return this.formatTime(context[0].parsed.x);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
label: (context) => {
|
||||||
|
// Round down to ignore the offset
|
||||||
|
const val = Math.floor(context.raw.y || context.raw);
|
||||||
|
const ds = context.dataset;
|
||||||
|
|
||||||
|
if (val === 0) return `${ds.label}: OFF`;
|
||||||
|
if (val === 10 || ds.isBinary) return `${ds.label}: ON`;
|
||||||
|
|
||||||
|
return `${ds.label}: Level ${val}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2, mt: 3, bgcolor: '#282828', color: '#ebdbb2' }}>
|
||||||
|
<Box sx={{ height: 300 }}>
|
||||||
|
<Line data={{ datasets }} options={options} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(OutputChart);
|
||||||
136
src/client/ProfileDialog.js
Normal file
136
src/client/ProfileDialog.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Link
|
||||||
|
} from '@mui/material';
|
||||||
|
import { withAuth } from './AuthContext';
|
||||||
|
|
||||||
|
class ProfileDialog extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
telegramId: '',
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
success: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.open && !prevProps.open) {
|
||||||
|
this.fetchProfile();
|
||||||
|
this.setState({ success: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProfile = async () => {
|
||||||
|
this.setState({ loading: true });
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const res = await fetch('api/auth/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.user) {
|
||||||
|
this.setState({ telegramId: data.user.telegramId || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ loading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSave = async () => {
|
||||||
|
const { onClose } = this.props;
|
||||||
|
const { telegramId } = this.state;
|
||||||
|
|
||||||
|
this.setState({ saving: true, error: null, success: false });
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const res = await fetch('api/auth/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ telegramId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ success: true });
|
||||||
|
setTimeout(() => onClose(), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
this.setState({ saving: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, onClose, auth: { user } } = this.props;
|
||||||
|
const { telegramId, loading, saving, error, success } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>User Profile: {user?.username}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ pt: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Link your Telegram account to receive alarm notifications.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Alert severity="info" icon={false}>
|
||||||
|
To get your Telegram ID:
|
||||||
|
<ol style={{ margin: '8px 0', paddingLeft: 20 }}>
|
||||||
|
<li>Search for <b>@TischlereiCtrlBot</b> on Telegram</li>
|
||||||
|
<li>Start the bot (`/start`)</li>
|
||||||
|
<li>It will reply with your ID. Copy it here.</li>
|
||||||
|
</ol>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center' }}><CircularProgress /></Box>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
label="Telegram ID"
|
||||||
|
value={telegramId}
|
||||||
|
onChange={(e) => this.setState({ telegramId: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
placeholder="e.g. 123456789"
|
||||||
|
helperText="Leave empty to disable notifications"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
|
{success && <Alert severity="success">Profile updated successfully!</Alert>}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} color="inherit">Close</Button>
|
||||||
|
<Button onClick={this.handleSave} variant="contained" disabled={saving || loading}>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuth(ProfileDialog);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -8,169 +8,271 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
// Simple icons using unicode/emoji
|
// Simple icons using unicode/emoji
|
||||||
const EditIcon = () => <span style={{ fontSize: '1rem' }}>✏️</span>;
|
const EditIcon = () => <span style={{ fontSize: '1rem' }}>✏️</span>;
|
||||||
const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑️</span>;
|
const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑️</span>;
|
||||||
|
|
||||||
const dayLabels = { mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S' };
|
|
||||||
const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
|
|
||||||
function TriggerSummary({ trigger }) {
|
class TriggerSummary extends Component {
|
||||||
const parts = [];
|
render() {
|
||||||
|
const { trigger, t } = this.props;
|
||||||
|
const dayLabels = {
|
||||||
|
mon: t('days.mon').charAt(0),
|
||||||
|
tue: t('days.tue').charAt(0),
|
||||||
|
wed: t('days.wed').charAt(0),
|
||||||
|
thu: t('days.thu').charAt(0),
|
||||||
|
fri: t('days.fri').charAt(0),
|
||||||
|
sat: t('days.sat').charAt(0),
|
||||||
|
sun: t('days.sun').charAt(0)
|
||||||
|
};
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
// Time range
|
// Scheduled time (trigger at exact time)
|
||||||
if (trigger.timeRange) {
|
if (trigger.scheduledTime) {
|
||||||
const { start, end, days } = trigger.timeRange;
|
const { time, days } = trigger.scheduledTime;
|
||||||
const isEveryDay = days?.length === 7;
|
const isEveryDay = days?.length === 7;
|
||||||
const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
|
const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
|
||||||
let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' :
|
let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
|
||||||
dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
|
dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
|
||||||
|
|
||||||
parts.push(
|
parts.push(
|
||||||
<Box key="time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
<Box key="scheduled" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
<Chip label="🕐" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
{start}–{end} ({dayText})
|
{t('ruleCard.at')} <strong>{time}</strong> ({dayText})
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sensor conditions
|
|
||||||
if (trigger.sensors && trigger.sensors.length > 0) {
|
|
||||||
const logic = trigger.sensorLogic || 'and';
|
|
||||||
const sensorText = trigger.sensors.map((s, i) => (
|
|
||||||
<span key={i}>
|
|
||||||
{i > 0 && <Chip label={logic.toUpperCase()} size="small" sx={{ mx: 0.5, bgcolor: logic === 'and' ? '#8ec07c' : '#fabd2f', color: '#282828', fontSize: '0.65rem' }} />}
|
|
||||||
<strong>{s.sensorLabel || s.sensor}</strong> {s.operator} {s.value}
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
|
|
||||||
parts.push(
|
|
||||||
<Box key="sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
|
||||||
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
|
||||||
<Typography variant="body2" component="span">
|
|
||||||
{sensorText}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy support for old trigger format
|
|
||||||
if (trigger.type === 'time' && !trigger.timeRange) {
|
|
||||||
const days = trigger.days || [];
|
|
||||||
const isEveryDay = days.length === 7;
|
|
||||||
const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
|
|
||||||
let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' :
|
|
||||||
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
|
|
||||||
|
|
||||||
parts.push(
|
|
||||||
<Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }} />
|
|
||||||
<Typography variant="body2">At {trigger.time} ({dayText})</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trigger.type === 'sensor' && !trigger.sensors) {
|
|
||||||
parts.push(
|
|
||||||
<Box key="legacy-sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600 }} />
|
|
||||||
<Typography variant="body2">
|
|
||||||
{trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{parts}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionSummary({ action }) {
|
|
||||||
if (action.type === 'toggle') {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<Chip
|
|
||||||
label={action.state ? '🔛' : '🔴'}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: action.state ? '#b8bb26' : '#fb4934', color: '#282828', fontWeight: 600, minWidth: 32 }}
|
|
||||||
/>
|
|
||||||
<Typography variant="body2">
|
|
||||||
→ <strong>{action.targetLabel || action.target}</strong> {action.state ? 'ON' : 'OFF'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === 'keepOn') {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
|
||||||
<Typography variant="body2">
|
|
||||||
→ <strong>{action.targetLabel || action.target}</strong> ON for {action.duration}m
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly }) {
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
opacity: rule.enabled ? 1 : 0.6,
|
|
||||||
transition: 'opacity 0.2s, transform 0.2s',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: rule.enabled ? '#504945' : '#3c3836',
|
|
||||||
'&:hover': {
|
|
||||||
transform: readOnly ? 'none' : 'translateX(4px)',
|
|
||||||
borderColor: rule.enabled ? '#8ec07c' : '#504945'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
|
||||||
{rule.name}
|
|
||||||
</Typography>
|
|
||||||
{!rule.enabled && (
|
|
||||||
<Chip label="Disabled" size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
<TriggerSummary trigger={rule.trigger} />
|
|
||||||
<ActionSummary action={rule.action} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{!readOnly && (
|
// Time range (active during window)
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
if (trigger.timeRange) {
|
||||||
<Tooltip title={rule.enabled ? 'Disable' : 'Enable'}>
|
const { start, end, days } = trigger.timeRange;
|
||||||
<Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" />
|
const isEveryDay = days?.length === 7;
|
||||||
</Tooltip>
|
const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
|
||||||
<Tooltip title="Edit">
|
let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
|
||||||
<IconButton onClick={onEdit} size="small"><EditIcon /></IconButton>
|
dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Delete">
|
parts.push(
|
||||||
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
|
<Box key="time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
<DeleteIcon />
|
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
||||||
</IconButton>
|
<Typography variant="body2">
|
||||||
</Tooltip>
|
{start}–{end} ({dayText})
|
||||||
</Box>
|
</Typography>
|
||||||
)}
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sensor conditions
|
||||||
|
if (trigger.sensors && trigger.sensors.length > 0) {
|
||||||
|
const logic = trigger.sensorLogic || 'and';
|
||||||
|
const sensorText = trigger.sensors.map((s, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && <Chip label={logic.toUpperCase()} size="small" sx={{ mx: 0.5, bgcolor: logic === 'and' ? '#8ec07c' : '#fabd2f', color: '#282828', fontSize: '0.65rem' }} />}
|
||||||
|
<strong>{s.sensorLabel || s.sensor}</strong> {s.operator} {s.value}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
<Box key="sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
||||||
|
<Typography variant="body2" component="span">
|
||||||
|
{sensorText}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy support for old trigger format
|
||||||
|
if (trigger.type === 'time' && !trigger.timeRange) {
|
||||||
|
const days = trigger.days || [];
|
||||||
|
const isEveryDay = days.length === 7;
|
||||||
|
const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
|
||||||
|
let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
|
||||||
|
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
<Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }} />
|
||||||
|
<Typography variant="body2">{t('ruleCard.at')} {trigger.time} ({dayText})</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.type === 'sensor' && !trigger.sensors) {
|
||||||
|
parts.push(
|
||||||
|
<Box key="legacy-sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600 }} />
|
||||||
|
<Typography variant="body2">
|
||||||
|
{trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{parts}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ActionSummary extends Component {
|
||||||
|
render() {
|
||||||
|
const { action, t } = this.props;
|
||||||
|
|
||||||
|
if (action.type === 'toggle') {
|
||||||
|
// Check if it's a level or binary action
|
||||||
|
const hasLevel = action.level !== undefined;
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={hasLevel ? '🎚️' : (action.state ? '🔛' : '🔴')}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: hasLevel ? '#83a598' : (action.state ? '#b8bb26' : '#fb4934'), color: '#282828', fontWeight: 600, minWidth: 32 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">
|
||||||
|
→ <strong>{action.targetLabel || action.target}</strong> {hasLevel ? `${t('ruleCard.level')} ${action.level}` : (action.state ? t('ruleCard.on') : t('ruleCard.off'))}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === 'keepOn') {
|
||||||
|
const hasLevel = action.level !== undefined;
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
||||||
|
<Typography variant="body2">
|
||||||
|
→ <strong>{action.targetLabel || action.target}</strong> {hasLevel ? `${t('ruleCard.level')} ${action.level}` : t('ruleCard.on')} {t('ruleCard.forMinutes', { duration: action.duration })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RuleCard extends Component {
|
||||||
|
render() {
|
||||||
|
const { rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly, activeInfo, i18n: { t } } = this.props;
|
||||||
|
|
||||||
|
// Get list of tag colors for this rule (handle array or backwards-compat single value)
|
||||||
|
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
||||||
|
const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
opacity: rule.enabled ? 1 : 0.6,
|
||||||
|
transition: 'opacity 0.2s, transform 0.2s',
|
||||||
|
border: '1px solid', // Standard border
|
||||||
|
borderColor: rule.enabled ? '#504945' : '#3c3836',
|
||||||
|
borderLeft: ruleTags.length > 0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945',
|
||||||
|
boxShadow: activeInfo ? '0 0 0 2px #b8bb26' : 'none', // Active highlight
|
||||||
|
'&:hover': {
|
||||||
|
transform: readOnly ? 'none' : 'translateX(4px)',
|
||||||
|
borderColor: rule.enabled ? '#8ec07c' : '#504945'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{rule.name}
|
||||||
|
{/* ACTIVE INDICATOR */}
|
||||||
|
{activeInfo && (
|
||||||
|
<Chip
|
||||||
|
label={`Active: Lvl ${activeInfo.level}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#b8bb26',
|
||||||
|
color: '#282828',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
height: 20
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{ruleTags.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.25 }}>
|
||||||
|
{ruleTags.map(tag => (
|
||||||
|
<Box key={tag.id} sx={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: tag.color,
|
||||||
|
flexShrink: 0
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!rule.enabled && (
|
||||||
|
<Chip label={t('ruleCard.disabled')} size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
<TriggerSummary trigger={rule.trigger} t={t} />
|
||||||
|
<ActionSummary action={rule.action} t={t} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{/* Move buttons */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Tooltip title="Move up">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={onMoveUp}
|
||||||
|
size="small"
|
||||||
|
disabled={!onMoveUp}
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Move down">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={onMoveDown}
|
||||||
|
size="small"
|
||||||
|
disabled={!onMoveDown}
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title={rule.enabled ? t('ruleCard.disable') : t('ruleCard.enable')}>
|
||||||
|
<Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('ruleCard.edit')}>
|
||||||
|
<IconButton onClick={onEdit} size="small"><EditIcon /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('ruleCard.delete')}>
|
||||||
|
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(RuleCard);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -6,290 +6,351 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Divider,
|
Divider,
|
||||||
Alert,
|
Alert,
|
||||||
CircularProgress
|
CircularProgress,
|
||||||
|
Chip,
|
||||||
|
IconButton
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import RuleCard from './RuleCard';
|
import RuleCard from './RuleCard';
|
||||||
import RuleEditor from './RuleEditor';
|
import RuleEditor from './RuleEditor';
|
||||||
import { useAuth } from './AuthContext';
|
import { withAuth } from './AuthContext';
|
||||||
|
import { withI18n } from './I18nContext';
|
||||||
|
import { withDevices } from './DevicesContext';
|
||||||
|
|
||||||
export default function RuleManager() {
|
// 8 color tags
|
||||||
const { isAdmin } = useAuth();
|
const COLOR_TAGS = [
|
||||||
const [rules, setRules] = useState([]);
|
{ id: 'red', label: 'Red', color: '#fb4934' },
|
||||||
const [loading, setLoading] = useState(true);
|
{ id: 'orange', label: 'Orange', color: '#fe8019' },
|
||||||
const [error, setError] = useState(null);
|
{ id: 'yellow', label: 'Yellow', color: '#fabd2f' },
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
{ id: 'green', label: 'Green', color: '#b8bb26' },
|
||||||
const [editingRule, setEditingRule] = useState(null);
|
{ id: 'teal', label: 'Teal', color: '#8ec07c' },
|
||||||
const [devices, setDevices] = useState([]);
|
{ id: 'blue', label: 'Blue', color: '#83a598' },
|
||||||
const [saving, setSaving] = useState(false);
|
{ id: 'purple', label: 'Purple', color: '#d3869b' },
|
||||||
|
{ id: 'gray', label: 'Gray', color: '#928374' }
|
||||||
|
];
|
||||||
|
|
||||||
// Get auth token from localStorage
|
class RuleManager extends Component {
|
||||||
const getAuthHeaders = useCallback(() => {
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
rules: [],
|
||||||
|
activeRuleIds: [],
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
editorOpen: false,
|
||||||
|
editingRule: null,
|
||||||
|
saving: false,
|
||||||
|
filterTag: null
|
||||||
|
};
|
||||||
|
this.activeRulesInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.fetchRules();
|
||||||
|
this.fetchActiveRules();
|
||||||
|
this.activeRulesInterval = setInterval(() => this.fetchActiveRules(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.activeRulesInterval) {
|
||||||
|
clearInterval(this.activeRulesInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthHeaders = () => {
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
};
|
};
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// Fetch rules from server (public endpoint)
|
fetchRules = async () => {
|
||||||
const fetchRules = useCallback(async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('api/rules');
|
const res = await fetch('api/rules');
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error('Failed to fetch rules');
|
||||||
throw new Error('Failed to fetch rules');
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setRules(data);
|
this.setState({ rules: data, error: null, loading: false });
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message, loading: false });
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// Fetch devices for sensor/output selection
|
fetchActiveRules = async () => {
|
||||||
const fetchDevices = useCallback(async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('api/devices');
|
const res = await fetch('api/rules/active');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setDevices(data);
|
this.setState({ activeRuleIds: data });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch devices:', err);
|
console.error('Failed to fetch active rules:', err);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
getAvailableSensors = () => {
|
||||||
fetchRules();
|
const { devicesCtx: { getAvailableSensors, getAvailableOutputs } } = this.props;
|
||||||
fetchDevices();
|
const sensors = getAvailableSensors();
|
||||||
}, [fetchRules, fetchDevices]);
|
const outputs = getAvailableOutputs();
|
||||||
|
|
||||||
// Build available sensors
|
// Add Tapo and virtual channels as sensors (on/off state)
|
||||||
// - Environment sensors (Temp, Humidity) are per DEVICE
|
outputs.filter(o => o.type === 'plug' || o.type === 'virtual').forEach(o => {
|
||||||
// - Port values (Fan Speed, Brightness, CO2, etc.) are per PORT
|
sensors.push({
|
||||||
const availableSensors = [];
|
id: `${o.id}:state`,
|
||||||
const seenDevices = new Set();
|
label: `${o.label} (State)`,
|
||||||
|
type: 'output-state'
|
||||||
devices.forEach(d => {
|
|
||||||
// Add environment sensors once per device
|
|
||||||
if (!seenDevices.has(d.dev_name)) {
|
|
||||||
seenDevices.add(d.dev_name);
|
|
||||||
availableSensors.push({
|
|
||||||
id: `${d.dev_name}:temp`,
|
|
||||||
label: `${d.dev_name} - Temperature`,
|
|
||||||
type: 'temperature'
|
|
||||||
});
|
});
|
||||||
availableSensors.push({
|
|
||||||
id: `${d.dev_name}:humidity`,
|
|
||||||
label: `${d.dev_name} - Humidity`,
|
|
||||||
type: 'humidity'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add each port as a sensor (Fan Speed, Brightness, CO2, etc.)
|
|
||||||
availableSensors.push({
|
|
||||||
id: `${d.dev_name}:${d.port}:level`,
|
|
||||||
label: `${d.dev_name} - ${d.port_name} Level`,
|
|
||||||
type: d.port_name.toLowerCase()
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Build available outputs: Tapo plugs + device fans/lights
|
return sensors;
|
||||||
const availableOutputs = [
|
|
||||||
{ id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
|
|
||||||
{ id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
|
|
||||||
{ id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
|
|
||||||
{ id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
|
|
||||||
{ id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
|
|
||||||
...devices
|
|
||||||
.filter(d => d.port_name === 'Fan')
|
|
||||||
.map(d => ({
|
|
||||||
id: `${d.dev_name}:fan:${d.port}`,
|
|
||||||
label: `${d.dev_name} - Fan`,
|
|
||||||
type: 'fan'
|
|
||||||
})),
|
|
||||||
...devices
|
|
||||||
.filter(d => d.port_name === 'Light')
|
|
||||||
.map(d => ({
|
|
||||||
id: `${d.dev_name}:light:${d.port}`,
|
|
||||||
label: `${d.dev_name} - Light`,
|
|
||||||
type: 'light'
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleAddRule = () => {
|
|
||||||
setEditingRule(null);
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditRule = (rule) => {
|
handleAddRule = () => {
|
||||||
setEditingRule(rule);
|
this.setState({ editingRule: null, editorOpen: true });
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRule = async (ruleId) => {
|
handleEditRule = (rule) => {
|
||||||
if (!confirm('Delete this rule?')) return;
|
this.setState({ editingRule: rule, editorOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
setSaving(true);
|
handleDeleteRule = async (ruleId) => {
|
||||||
|
const { i18n: { t } } = this.props;
|
||||||
|
if (!confirm(t('rules.deleteConfirm'))) return;
|
||||||
|
|
||||||
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`api/rules/${ruleId}`, {
|
const res = await fetch(`api/rules/${ruleId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getAuthHeaders()
|
headers: this.getAuthHeaders()
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to delete rule');
|
if (!res.ok) throw new Error('Failed to delete rule');
|
||||||
setRules(rules.filter(r => r.id !== ruleId));
|
this.setState(prev => ({
|
||||||
|
rules: prev.rules.filter(r => r.id !== ruleId),
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message, saving: false });
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleRule = async (ruleId) => {
|
handleToggleRule = async (ruleId) => {
|
||||||
|
const { rules } = this.state;
|
||||||
const rule = rules.find(r => r.id === ruleId);
|
const rule = rules.find(r => r.id === ruleId);
|
||||||
if (!rule) return;
|
if (!rule) return;
|
||||||
|
|
||||||
setSaving(true);
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`api/rules/${ruleId}`, {
|
const res = await fetch(`api/rules/${ruleId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
body: JSON.stringify({ ...rule, enabled: !rule.enabled })
|
body: JSON.stringify({ ...rule, enabled: !rule.enabled })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to update rule');
|
if (!res.ok) throw new Error('Failed to update rule');
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
setRules(rules.map(r => r.id === ruleId ? updated : r));
|
this.setState(prev => ({
|
||||||
|
rules: prev.rules.map(r => r.id === ruleId ? updated : r),
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message, saving: false });
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveRule = async (ruleData) => {
|
handleSaveRule = async (ruleData) => {
|
||||||
setSaving(true);
|
const { editingRule } = this.state;
|
||||||
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
if (editingRule) {
|
if (editingRule) {
|
||||||
// Update existing rule
|
|
||||||
const res = await fetch(`api/rules/${editingRule.id}`, {
|
const res = await fetch(`api/rules/${editingRule.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
body: JSON.stringify(ruleData)
|
body: JSON.stringify({ ...ruleData, enabled: editingRule.enabled })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to update rule');
|
if (!res.ok) throw new Error('Failed to update rule');
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
setRules(rules.map(r => r.id === editingRule.id ? updated : r));
|
this.setState(prev => ({
|
||||||
|
rules: prev.rules.map(r => r.id === editingRule.id ? updated : r),
|
||||||
|
editorOpen: false,
|
||||||
|
editingRule: null,
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
// Create new rule
|
|
||||||
const res = await fetch('api/rules', {
|
const res = await fetch('api/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
body: JSON.stringify({ ...ruleData, enabled: true })
|
body: JSON.stringify({ ...ruleData, enabled: true })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to create rule');
|
if (!res.ok) throw new Error('Failed to create rule');
|
||||||
const newRule = await res.json();
|
const newRule = await res.json();
|
||||||
setRules([...rules, newRule]);
|
this.setState(prev => ({
|
||||||
|
rules: [...prev.rules, newRule],
|
||||||
|
editorOpen: false,
|
||||||
|
editingRule: null,
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
setEditorOpen(false);
|
|
||||||
setEditingRule(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message, saving: false });
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseEditor = () => {
|
handleCloseEditor = () => {
|
||||||
setEditorOpen(false);
|
this.setState({ editorOpen: false, editingRule: null });
|
||||||
setEditingRule(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
handleMoveRule = async (ruleId, direction) => {
|
||||||
|
const { rules } = this.state;
|
||||||
|
const idx = rules.findIndex(r => r.id === ruleId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
if (direction === 'up' && idx === 0) return;
|
||||||
|
if (direction === 'down' && idx === rules.length - 1) return;
|
||||||
|
|
||||||
|
const newRules = [...rules];
|
||||||
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||||
|
[newRules[idx], newRules[swapIdx]] = [newRules[swapIdx], newRules[idx]];
|
||||||
|
this.setState({ rules: newRules });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ruleIds = newRules.map(r => r.id);
|
||||||
|
await fetch('api/rules/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ ruleIds })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: 'Failed to save order' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { auth: { isAdmin }, i18n: { t }, devicesCtx: { getAvailableOutputs } } = this.props;
|
||||||
|
const { rules, activeRuleIds, loading, error, editorOpen, editingRule, saving, filterTag } = this.state;
|
||||||
|
|
||||||
|
const filteredRules = filterTag
|
||||||
|
? rules.filter(r => (r.colorTags || []).includes(filterTag))
|
||||||
|
: rules;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<Typography sx={{ mt: 2 }}>{t('rules.loading')}</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
|
<Paper
|
||||||
<CircularProgress size={24} />
|
sx={{
|
||||||
<Typography sx={{ mt: 2 }}>Loading rules...</Typography>
|
mt: 4,
|
||||||
</Paper>
|
p: 3,
|
||||||
);
|
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
|
||||||
}
|
border: '1px solid #504945'
|
||||||
|
}}
|
||||||
return (
|
>
|
||||||
<Paper
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
sx={{
|
<Box>
|
||||||
mt: 4,
|
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
p: 3,
|
{t('rules.title')}
|
||||||
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
|
</Typography>
|
||||||
border: '1px solid #504945'
|
<Typography variant="body2" color="text.secondary">
|
||||||
}}
|
{isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')}
|
||||||
>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
</Box>
|
||||||
<Box>
|
{isAdmin && (
|
||||||
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Button
|
||||||
⚙️ Automation Rules
|
variant="contained"
|
||||||
</Typography>
|
onClick={this.handleAddRule}
|
||||||
<Typography variant="body2" color="text.secondary">
|
disabled={saving}
|
||||||
{isAdmin ? 'Configure triggers and actions for home automation' : 'View automation rules (read-only)'}
|
sx={{
|
||||||
</Typography>
|
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('rules.addRule')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
<Divider sx={{ mb: 2 }} />
|
||||||
variant="contained"
|
|
||||||
onClick={handleAddRule}
|
{/* Color tag filter */}
|
||||||
disabled={saving}
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>Filter:</Typography>
|
||||||
|
<Chip
|
||||||
|
label="All"
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.setState({ filterTag: null })}
|
||||||
sx={{
|
sx={{
|
||||||
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
|
||||||
'&:hover': {
|
color: filterTag === null ? '#282828' : '#ebdbb2'
|
||||||
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
+ Add Rule
|
{COLOR_TAGS.map(tag => (
|
||||||
</Button>
|
<Chip
|
||||||
)}
|
key={tag.id}
|
||||||
</Box>
|
size="small"
|
||||||
|
onClick={() => this.setState({ filterTag: filterTag === tag.id ? null : tag.id })}
|
||||||
<Divider sx={{ mb: 3 }} />
|
sx={{
|
||||||
|
bgcolor: filterTag === tag.id ? tag.color : '#504945',
|
||||||
{error && (
|
color: filterTag === tag.id ? '#282828' : tag.color,
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
border: `2px solid ${tag.color}`,
|
||||||
{error}
|
'&:hover': { bgcolor: tag.color, color: '#282828' }
|
||||||
</Alert>
|
}}
|
||||||
)}
|
|
||||||
|
|
||||||
{rules.length === 0 ? (
|
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
|
||||||
<Typography color="text.secondary">
|
|
||||||
No rules configured. {isAdmin && 'Click "Add Rule" to create one.'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
{rules.map(rule => (
|
|
||||||
<RuleCard
|
|
||||||
key={rule.id}
|
|
||||||
rule={rule}
|
|
||||||
onEdit={isAdmin ? () => handleEditRule(rule) : null}
|
|
||||||
onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
|
|
||||||
onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
|
|
||||||
readOnly={!isAdmin}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
{error && (
|
||||||
<RuleEditor
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => this.setState({ error: null })}>
|
||||||
open={editorOpen}
|
{error}
|
||||||
rule={editingRule}
|
</Alert>
|
||||||
onSave={handleSaveRule}
|
)}
|
||||||
onClose={handleCloseEditor}
|
|
||||||
sensors={availableSensors}
|
{filteredRules.length === 0 ? (
|
||||||
outputs={availableOutputs}
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
saving={saving}
|
<Typography color="text.secondary">
|
||||||
/>
|
{rules.length === 0
|
||||||
)}
|
? (isAdmin ? t('rules.noRules') + ' ' + t('rules.noRulesAdmin') : t('rules.noRules'))
|
||||||
</Paper>
|
: 'No rules match the selected filter.'
|
||||||
);
|
}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{filteredRules.map((rule, idx) => (
|
||||||
|
<RuleCard
|
||||||
|
key={rule.id}
|
||||||
|
rule={rule}
|
||||||
|
onEdit={isAdmin ? () => this.handleEditRule(rule) : null}
|
||||||
|
onDelete={isAdmin ? () => this.handleDeleteRule(rule.id) : null}
|
||||||
|
onToggle={isAdmin ? () => this.handleToggleRule(rule.id) : null}
|
||||||
|
onMoveUp={isAdmin && idx > 0 ? () => this.handleMoveRule(rule.id, 'up') : null}
|
||||||
|
onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => this.handleMoveRule(rule.id, 'down') : null}
|
||||||
|
colorTags={COLOR_TAGS}
|
||||||
|
readOnly={!isAdmin}
|
||||||
|
activeInfo={activeRuleIds.find(r => r.id === rule.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<RuleEditor
|
||||||
|
open={editorOpen}
|
||||||
|
rule={editingRule}
|
||||||
|
onSave={this.handleSaveRule}
|
||||||
|
onClose={this.handleCloseEditor}
|
||||||
|
sensors={this.getAvailableSensors()}
|
||||||
|
outputs={getAvailableOutputs()}
|
||||||
|
colorTags={COLOR_TAGS}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withDevices(withAuth(withI18n(RuleManager)));
|
||||||
|
|||||||
97
src/client/i18n/de.json
Normal file
97
src/client/i18n/de.json
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "Tischlerei Dashboard",
|
||||||
|
"adminLogin": "🔐 Admin Anmeldung",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"admin": "ADMIN"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"loading": "Geräte werden geladen...",
|
||||||
|
"hours24": "24 Stunden",
|
||||||
|
"days7": "7 Tage",
|
||||||
|
"days30": "30 Tage"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "🔐 Dashboard Anmeldung",
|
||||||
|
"subtitle": "Tischlerei Automatisierungssteuerung",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"signingIn": "Anmeldung läuft..."
|
||||||
|
},
|
||||||
|
"controller": {
|
||||||
|
"environment": "Umgebung (Temp. / Luftfeuchtigkeit)",
|
||||||
|
"port": "Anschluss"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"title": "⚙️ Automatisierungsregeln (Simulation)",
|
||||||
|
"adminDescription": "Trigger und Aktionen für die Hausautomatisierung konfigurieren",
|
||||||
|
"guestDescription": "Automatisierungsregeln ansehen (nur Lesen)",
|
||||||
|
"addRule": "+ Regel hinzufügen",
|
||||||
|
"loading": "Regeln werden geladen...",
|
||||||
|
"noRules": "Keine Regeln konfiguriert.",
|
||||||
|
"noRulesAdmin": "Klicken Sie auf \"Regel hinzufügen\" um eine zu erstellen.",
|
||||||
|
"deleteConfirm": "Diese Regel löschen?"
|
||||||
|
},
|
||||||
|
"ruleCard": {
|
||||||
|
"disabled": "Deaktiviert",
|
||||||
|
"enable": "Aktivieren",
|
||||||
|
"disable": "Deaktivieren",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"daily": "täglich",
|
||||||
|
"weekdays": "werktags",
|
||||||
|
"at": "Um",
|
||||||
|
"level": "Stufe",
|
||||||
|
"on": "AN",
|
||||||
|
"off": "AUS",
|
||||||
|
"forMinutes": "für {duration}m",
|
||||||
|
"isOn": "ist AN",
|
||||||
|
"isOff": "ist AUS"
|
||||||
|
},
|
||||||
|
"ruleEditor": {
|
||||||
|
"editTitle": "✏️ Regel bearbeiten",
|
||||||
|
"createTitle": "➕ Neue Regel erstellen",
|
||||||
|
"ruleName": "Regelname",
|
||||||
|
"ruleNamePlaceholder": "z.B. Tagsüber Hohe Luftfeuchtigkeit Lüfter",
|
||||||
|
"triggersSection": "AUSLÖSER (Wann aktivieren)",
|
||||||
|
"actionSection": "AKTION (Was tun)",
|
||||||
|
"scheduledTime": "🕐 Geplante Zeit (zu exakter Zeit auslösen)",
|
||||||
|
"triggerAt": "Auslösen um",
|
||||||
|
"timeRange": "⏰ Zeitbereich (aktiv während Fenster)",
|
||||||
|
"from": "Von",
|
||||||
|
"to": "bis",
|
||||||
|
"until": "Bis",
|
||||||
|
"days": "Tage",
|
||||||
|
"sensorConditions": "📊 Sensorbedingungen",
|
||||||
|
"noSensors": "(keine Sensoren verfügbar)",
|
||||||
|
"combineWith": "Bedingungen verknüpfen mit:",
|
||||||
|
"addCondition": "+ Bedingung hinzufügen",
|
||||||
|
"sensor": "Sensor",
|
||||||
|
"actionType": "Aktionstyp",
|
||||||
|
"toggleOnOff": "🔛 Ein/Aus schalten",
|
||||||
|
"keepOnMinutes": "⏱️ Für X Minuten eingeschaltet lassen",
|
||||||
|
"targetOutput": "Zielausgang",
|
||||||
|
"turnOn": "Einschalten",
|
||||||
|
"turnOff": "Ausschalten",
|
||||||
|
"setLevel": "Stufe setzen:",
|
||||||
|
"duration": "Dauer:",
|
||||||
|
"minutes": "Minuten",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"saveChanges": "Änderungen speichern",
|
||||||
|
"createRule": "Regel erstellen",
|
||||||
|
"saving": "Speichern..."
|
||||||
|
},
|
||||||
|
"days": {
|
||||||
|
"mon": "Mo",
|
||||||
|
"tue": "Di",
|
||||||
|
"wed": "Mi",
|
||||||
|
"thu": "Do",
|
||||||
|
"fri": "Fr",
|
||||||
|
"sat": "Sa",
|
||||||
|
"sun": "So"
|
||||||
|
},
|
||||||
|
"alarms": {
|
||||||
|
"title": "Alarme"
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/client/i18n/en.json
Normal file
97
src/client/i18n/en.json
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "Tischlerei Dashboard",
|
||||||
|
"adminLogin": "🔐 Admin Login",
|
||||||
|
"logout": "Logout",
|
||||||
|
"admin": "ADMIN"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"loading": "Loading devices...",
|
||||||
|
"hours24": "24 Hours",
|
||||||
|
"days7": "7 Days",
|
||||||
|
"days30": "30 Days"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "🔐 Dashboard Login",
|
||||||
|
"subtitle": "Tischlerei Automation Control",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signingIn": "Signing in..."
|
||||||
|
},
|
||||||
|
"controller": {
|
||||||
|
"environment": "Environment (Temp / Humidity)",
|
||||||
|
"port": "Port"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"title": "⚙️ Automation Rules (simulated)",
|
||||||
|
"adminDescription": "Configure triggers and actions for home automation",
|
||||||
|
"guestDescription": "View automation rules (read-only)",
|
||||||
|
"addRule": "+ Add Rule",
|
||||||
|
"loading": "Loading rules...",
|
||||||
|
"noRules": "No rules configured.",
|
||||||
|
"noRulesAdmin": "Click \"Add Rule\" to create one.",
|
||||||
|
"deleteConfirm": "Delete this rule?"
|
||||||
|
},
|
||||||
|
"ruleCard": {
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"daily": "daily",
|
||||||
|
"weekdays": "weekdays",
|
||||||
|
"at": "At",
|
||||||
|
"level": "Level",
|
||||||
|
"on": "ON",
|
||||||
|
"off": "OFF",
|
||||||
|
"forMinutes": "for {duration}m",
|
||||||
|
"isOn": "is ON",
|
||||||
|
"isOff": "is OFF"
|
||||||
|
},
|
||||||
|
"ruleEditor": {
|
||||||
|
"editTitle": "✏️ Edit Rule",
|
||||||
|
"createTitle": "➕ Create New Rule",
|
||||||
|
"ruleName": "Rule Name",
|
||||||
|
"ruleNamePlaceholder": "e.g., Daytime High Humidity Fan",
|
||||||
|
"triggersSection": "TRIGGERS (When to activate)",
|
||||||
|
"actionSection": "ACTION (What to do)",
|
||||||
|
"scheduledTime": "🕐 Scheduled Time (trigger at exact time)",
|
||||||
|
"triggerAt": "Trigger At",
|
||||||
|
"timeRange": "⏰ Time Range (active during window)",
|
||||||
|
"from": "From",
|
||||||
|
"to": "to",
|
||||||
|
"until": "Until",
|
||||||
|
"days": "Days",
|
||||||
|
"sensorConditions": "📊 Sensor Conditions",
|
||||||
|
"noSensors": "(no sensors available)",
|
||||||
|
"combineWith": "Combine conditions with:",
|
||||||
|
"addCondition": "+ Add Condition",
|
||||||
|
"sensor": "Sensor",
|
||||||
|
"actionType": "Action Type",
|
||||||
|
"toggleOnOff": "🔛 Toggle On/Off",
|
||||||
|
"keepOnMinutes": "⏱️ Keep On for X Minutes",
|
||||||
|
"targetOutput": "Target Output",
|
||||||
|
"turnOn": "Turn ON",
|
||||||
|
"turnOff": "Turn OFF",
|
||||||
|
"setLevel": "Set Level:",
|
||||||
|
"duration": "Duration:",
|
||||||
|
"minutes": "minutes",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saveChanges": "Save Changes",
|
||||||
|
"createRule": "Create Rule",
|
||||||
|
"saving": "Saving..."
|
||||||
|
},
|
||||||
|
"days": {
|
||||||
|
"mon": "Mon",
|
||||||
|
"tue": "Tue",
|
||||||
|
"wed": "Wed",
|
||||||
|
"thu": "Thu",
|
||||||
|
"fri": "Fri",
|
||||||
|
"sat": "Sat",
|
||||||
|
"sun": "Sun"
|
||||||
|
},
|
||||||
|
"alarms": {
|
||||||
|
"title": "Alarms"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Tischlerei Dashboard</title>
|
<title>Tischlerei Dashboard</title>
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
9
tapotest.js
Normal file
9
tapotest.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { cloudLogin, loginDevice } from 'tp-link-tapo-connect';
|
||||||
|
|
||||||
|
const cloudApi = await cloudLogin('torosaw@gmail.com', 'HazeWolf99');
|
||||||
|
|
||||||
|
const devices = await cloudApi.listDevices();
|
||||||
|
console.log(devices);
|
||||||
|
|
||||||
|
//const device = await loginDevice('torosaw@gmail.com', 'HazeWolf99', devices[0]);
|
||||||
|
//console.log(device);
|
||||||
108
verify_api.js
Normal file
108
verify_api.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
const OPTIONS = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: 3905,
|
||||||
|
path: '/api/history?devName=Test&port=1&range=day',
|
||||||
|
method: 'GET'
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(OPTIONS, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Status Code:', res.statusCode);
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
console.log('Response Keys:', Object.keys(json));
|
||||||
|
if (json.start !== undefined && Array.isArray(json.temps)) {
|
||||||
|
console.log('SUCCESS: API returned compressed structure.');
|
||||||
|
console.log('Step Size:', json.step);
|
||||||
|
console.log('Temps Length:', json.temps.length);
|
||||||
|
} else {
|
||||||
|
console.log('FAILURE: API returned unexpected structure.', json);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse JSON:', e);
|
||||||
|
console.log('Raw Data:', data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error('Request error (server might not be running):', e.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
|
||||||
|
const OUTPUT_OPTIONS = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: 3905,
|
||||||
|
path: '/api/outputs/history?range=day',
|
||||||
|
method: 'GET'
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqOut = http.request(OUTPUT_OPTIONS, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Output History Status Code:', res.statusCode);
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
const keys = Object.keys(json);
|
||||||
|
console.log('Output Keys:', keys);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
const firstKey = keys[0];
|
||||||
|
const entries = json[firstKey];
|
||||||
|
if (Array.isArray(entries) && entries.length > 0 && Array.isArray(entries[0])) {
|
||||||
|
console.log('SUCCESS: Output API returned compressed dictionary.');
|
||||||
|
console.log('Sample Entry:', entries[0]);
|
||||||
|
console.log(`Entries count for ${firstKey}: ${entries.length}`);
|
||||||
|
} else {
|
||||||
|
console.log('FAILURE: Output API format incorrect.', entries[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('WARNING: Output API returned empty object (no history).');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse Output JSON:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
reqOut.on('error', (e) => {
|
||||||
|
console.error('Output Request error:', e.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
reqOut.end();
|
||||||
|
|
||||||
|
// Test offset
|
||||||
|
const OFFSET_OPTIONS = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: 3905,
|
||||||
|
path: '/api/history?devName=Tent&port=1&range=day&offset=1',
|
||||||
|
method: 'GET'
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqOffset = http.request(OFFSET_OPTIONS, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Offset History Status Code:', res.statusCode);
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (json.start && json.start > 0) {
|
||||||
|
const startDate = new Date(json.start * 1000);
|
||||||
|
console.log(`Offset 1 Start Time: ${json.start} (${startDate.toISOString()})`);
|
||||||
|
// Check if it's roughly 48h ago (since offset 1 day means window start is -48h)
|
||||||
|
// vs offset 0 start is -24h.
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const hoursDiff = (now - json.start) / 3600;
|
||||||
|
console.log(`Hours difference from now: ${hoursDiff.toFixed(1)}h (Should be ~48h)`);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
reqOffset.end();
|
||||||
@@ -11,7 +11,7 @@ export default {
|
|||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, 'dist'),
|
path: path.resolve(__dirname, 'dist'),
|
||||||
filename: 'bundle.[contenthash].js',
|
filename: 'bundle.[contenthash].js',
|
||||||
publicPath: '/ac/',
|
publicPath: '/',
|
||||||
clean: true // Clean dist folder on rebuild
|
clean: true // Clean dist folder on rebuild
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
|
|||||||
Reference in New Issue
Block a user