u
This commit is contained in:
14
dist/index.html
vendored
Normal file
14
dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tischlerei Dashboard</title>
|
||||||
|
<script defer src="/ac/bundle.51c89fd2f2d5281a592b.js"></script></head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
265
package-lock.json
generated
265
package-lock.json
generated
@@ -32,6 +32,7 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"telegraf": "^4.16.3",
|
"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"
|
||||||
@@ -2512,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",
|
||||||
@@ -2938,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",
|
||||||
@@ -2950,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",
|
||||||
@@ -3349,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",
|
||||||
@@ -3540,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",
|
||||||
@@ -4123,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",
|
||||||
@@ -4212,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",
|
||||||
@@ -4290,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",
|
||||||
@@ -4687,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",
|
||||||
@@ -4800,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",
|
||||||
@@ -4818,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",
|
||||||
@@ -4858,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",
|
||||||
@@ -4907,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",
|
||||||
@@ -4934,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",
|
||||||
@@ -4970,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",
|
||||||
@@ -5006,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",
|
||||||
@@ -5145,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",
|
||||||
@@ -6405,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",
|
||||||
@@ -6678,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",
|
||||||
@@ -6703,6 +6932,19 @@
|
|||||||
"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": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
@@ -6866,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",
|
||||||
@@ -6875,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",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"telegraf": "^4.16.3",
|
"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,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -10,17 +10,20 @@ import {
|
|||||||
Tooltip
|
Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
export default function AlarmCard({ alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags, readOnly }) {
|
class AlarmCard extends Component {
|
||||||
// Parse trigger/action data to display summary
|
getTagColor = (tagId) => {
|
||||||
const trigger = alarm.trigger || {};
|
const { colorTags } = this.props;
|
||||||
const action = alarm.action || {};
|
|
||||||
|
|
||||||
// Get color for tag
|
|
||||||
const getTagColor = (tagId) => {
|
|
||||||
const tag = colorTags.find(t => t.id === tagId);
|
const tag = colorTags.find(t => t.id === tagId);
|
||||||
return tag ? tag.color : 'transparent';
|
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;
|
const hasTags = alarm.colorTags && alarm.colorTags.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +78,7 @@ export default function AlarmCard({ alarm, onEdit, onDelete, onToggle, onMoveUp,
|
|||||||
width: 12,
|
width: 12,
|
||||||
height: 12,
|
height: 12,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: getTagColor(tagId),
|
bgcolor: this.getTagColor(tagId),
|
||||||
border: '1px solid #282828'
|
border: '1px solid #282828'
|
||||||
}}
|
}}
|
||||||
title={tagId}
|
title={tagId}
|
||||||
@@ -133,3 +136,6 @@ export default function AlarmCard({ alarm, onEdit, onDelete, onToggle, onMoveUp,
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlarmCard;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Alert
|
Alert
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useI18n } from './I18nContext';
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
// Reusing some constants/components from RuleEditor logic if possible, but duplicating for isolation as per plan
|
// 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 DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
@@ -36,7 +36,10 @@ const OPERATORS = [
|
|||||||
{ value: '==', label: '=' }
|
{ value: '==', label: '=' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
class SensorCondition extends Component {
|
||||||
|
render() {
|
||||||
|
const { condition, sensors, onChange, onRemove, disabled } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
|
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
@@ -84,121 +87,112 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
|
class AlarmEditor extends Component {
|
||||||
const { t } = useI18n();
|
constructor(props) {
|
||||||
const [name, setName] = useState('');
|
super(props);
|
||||||
const [selectedTags, setSelectedTags] = useState([]);
|
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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger Mode: 'sensor' or 'output'
|
componentDidUpdate(prevProps) {
|
||||||
const [triggerMode, setTriggerMode] = useState('sensor');
|
if (this.props.open !== prevProps.open || this.props.alarm !== prevProps.alarm) {
|
||||||
|
this.initializeState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Output Change Config
|
componentDidMount() {
|
||||||
const [outputTarget, setOutputTarget] = useState('');
|
this.initializeState();
|
||||||
const [outputState, setOutputState] = useState('on');
|
}
|
||||||
|
|
||||||
// Scheduled time (not commonly used for alarms, but keeping parity with rules engine if needed)
|
initializeState = () => {
|
||||||
// Actually, alarms are usually condition-based (Value > X). Time-based alarms remind you to do something?
|
const { alarm, sensors } = this.props;
|
||||||
// Let's keep it simple: SENSORS ONLY for now described in plan ("triggers (based on sensors, time, etc.)")
|
|
||||||
// I'll keep the UI structure but maybe default to Sensors.
|
|
||||||
|
|
||||||
// Simplification: Alarms usually monitor state.
|
|
||||||
// "Time Range" is valid (only alarm between 8am-8pm).
|
|
||||||
// "Scheduled Time" (Alarm at 8am) is basically a Reminder.
|
|
||||||
|
|
||||||
// I will include: Time Range (Active Window) and Sensor Conditions.
|
|
||||||
// I'll omit "Scheduled Time" as a trigger for now unless requested, to reduce complexity,
|
|
||||||
// as "Alarm at 8am" is just an event. The user asked for "similar to alarms".
|
|
||||||
|
|
||||||
const [useTimeRange, setUseTimeRange] = useState(false);
|
|
||||||
const [timeStart, setTimeStart] = useState('08:00');
|
|
||||||
const [timeEnd, setTimeEnd] = useState('18:00');
|
|
||||||
const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
|
||||||
|
|
||||||
const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
|
|
||||||
const [sensorLogic, setSensorLogic] = useState('and');
|
|
||||||
|
|
||||||
// Action State (Telegram)
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [severity, setSeverity] = useState('warning');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (alarm) {
|
if (alarm) {
|
||||||
setName(alarm.name);
|
|
||||||
const tags = alarm.colorTags || (alarm.colorTag ? [alarm.colorTag] : []);
|
const tags = alarm.colorTags || (alarm.colorTag ? [alarm.colorTag] : []);
|
||||||
setSelectedTags(Array.isArray(tags) ? tags : []);
|
|
||||||
|
|
||||||
const trigger = alarm.trigger || {};
|
const trigger = alarm.trigger || {};
|
||||||
|
|
||||||
setUseTimeRange(!!trigger.timeRange);
|
this.setState({
|
||||||
if (trigger.timeRange) {
|
name: alarm.name,
|
||||||
setTimeStart(trigger.timeRange.start || '08:00');
|
selectedTags: Array.isArray(tags) ? tags : [],
|
||||||
setTimeEnd(trigger.timeRange.end || '18:00');
|
useTimeRange: !!trigger.timeRange,
|
||||||
setTimeRangeDays(trigger.timeRange.days || []);
|
timeStart: trigger.timeRange?.start || '08:00',
|
||||||
}
|
timeEnd: trigger.timeRange?.end || '18:00',
|
||||||
|
timeRangeDays: trigger.timeRange?.days || [],
|
||||||
if (trigger.outputChange) {
|
triggerMode: trigger.outputChange ? 'output' : 'sensor',
|
||||||
setTriggerMode('output');
|
outputTarget: trigger.outputChange?.target || '',
|
||||||
setOutputTarget(trigger.outputChange.target || '');
|
outputState: trigger.outputChange?.state || 'on',
|
||||||
setOutputState(trigger.outputChange.state || 'on');
|
sensorConditions: trigger.sensors?.length > 0
|
||||||
} else if (trigger.sensors && trigger.sensors.length > 0) {
|
? trigger.sensors
|
||||||
setTriggerMode('sensor');
|
: [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
|
||||||
setSensorConditions(trigger.sensors);
|
sensorLogic: trigger.sensorLogic || 'and',
|
||||||
setSensorLogic(trigger.sensorLogic || 'and');
|
message: alarm.action?.message || '',
|
||||||
|
severity: alarm.action?.severity || 'warning'
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setTriggerMode('sensor');
|
this.setState({
|
||||||
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = alarm.action || {};
|
|
||||||
setMessage(action.message || '');
|
|
||||||
setSeverity(action.severity || 'warning');
|
|
||||||
|
|
||||||
} else {
|
|
||||||
setName('');
|
|
||||||
setSelectedTags([]);
|
|
||||||
setTriggerMode('sensor');
|
|
||||||
setOutputTarget('');
|
|
||||||
setOutputState('on');
|
|
||||||
setUseTimeRange(false);
|
|
||||||
setTimeStart('08:00');
|
|
||||||
setTimeEnd('18:00');
|
|
||||||
setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
|
||||||
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
|
||||||
setSensorLogic('and');
|
|
||||||
setMessage('');
|
|
||||||
setSeverity('warning');
|
|
||||||
}
|
|
||||||
}, [alarm, open, sensors]);
|
|
||||||
|
|
||||||
// Default sensor init
|
|
||||||
useEffect(() => {
|
|
||||||
if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
|
|
||||||
setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
|
|
||||||
}
|
|
||||||
}, [sensors, sensorConditions]);
|
|
||||||
|
|
||||||
const addSensorCondition = () => {
|
|
||||||
setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSensorCondition = (index, newCondition) => {
|
addSensorCondition = () => {
|
||||||
const updated = [...sensorConditions];
|
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;
|
updated[index] = newCondition;
|
||||||
setSensorConditions(updated);
|
return { sensorConditions: updated };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSensorCondition = (index) => {
|
removeSensorCondition = (index) => {
|
||||||
if (sensorConditions.length > 1) {
|
this.setState(prev => {
|
||||||
setSensorConditions(sensorConditions.filter((_, i) => i !== index));
|
if (prev.sensorConditions.length > 1) {
|
||||||
|
return { sensorConditions: prev.sensorConditions.filter((_, i) => i !== index) };
|
||||||
}
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
handleSave = () => {
|
||||||
const trigger = {};
|
const { onSave, sensors } = this.props;
|
||||||
|
const { name, selectedTags, triggerMode, outputTarget, outputState,
|
||||||
|
useTimeRange, timeStart, timeEnd, timeRangeDays,
|
||||||
|
sensorConditions, sensorLogic, message, severity } = this.state;
|
||||||
|
|
||||||
// Always require sensors for an Alarm (otherwise it's just a time-based notification, which is valid too)
|
const trigger = {};
|
||||||
// Let's assume user wants to monitor something.
|
|
||||||
|
|
||||||
if (triggerMode === 'output') {
|
if (triggerMode === 'output') {
|
||||||
trigger.outputChange = {
|
trigger.outputChange = {
|
||||||
@@ -206,7 +200,6 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
state: outputState
|
state: outputState
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Sensor Mode
|
|
||||||
if (useTimeRange) {
|
if (useTimeRange) {
|
||||||
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
|
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
|
||||||
}
|
}
|
||||||
@@ -226,6 +219,12 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
onSave({ name, trigger, action, colorTags: selectedTags });
|
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 &&
|
const isValid = name.trim().length > 0 &&
|
||||||
((triggerMode === 'sensor' && sensorConditions.every(c => c.sensor)) ||
|
((triggerMode === 'sensor' && sensorConditions.every(c => c.sensor)) ||
|
||||||
(triggerMode === 'output' && outputTarget)) &&
|
(triggerMode === 'output' && outputTarget)) &&
|
||||||
@@ -253,7 +252,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<TextField
|
<TextField
|
||||||
label="Alarm Name"
|
label="Alarm Name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => this.setState({ name: e.target.value })}
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
@@ -266,9 +265,9 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
key={tag.id}
|
key={tag.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedTags.includes(tag.id)) {
|
if (selectedTags.includes(tag.id)) {
|
||||||
setSelectedTags(selectedTags.filter(t => t !== tag.id));
|
this.setState({ selectedTags: selectedTags.filter(t => t !== tag.id) });
|
||||||
} else {
|
} else {
|
||||||
setSelectedTags([...selectedTags, tag.id]);
|
this.setState({ selectedTags: [...selectedTags, tag.id] });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -293,7 +292,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<Select
|
<Select
|
||||||
value={triggerMode}
|
value={triggerMode}
|
||||||
label="Trigger Type"
|
label="Trigger Type"
|
||||||
onChange={(e) => setTriggerMode(e.target.value)}
|
onChange={(e) => this.setState({ triggerMode: e.target.value })}
|
||||||
>
|
>
|
||||||
<MenuItem value="sensor">📊 Sensor Value Threshold</MenuItem>
|
<MenuItem value="sensor">📊 Sensor Value Threshold</MenuItem>
|
||||||
<MenuItem value="output">🔌 Output Turn ON/OFF</MenuItem>
|
<MenuItem value="output">🔌 Output Turn ON/OFF</MenuItem>
|
||||||
@@ -311,7 +310,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<Select
|
<Select
|
||||||
value={outputTarget}
|
value={outputTarget}
|
||||||
label="Target Output"
|
label="Target Output"
|
||||||
onChange={(e) => setOutputTarget(e.target.value)}
|
onChange={(e) => this.setState({ outputTarget: e.target.value })}
|
||||||
>
|
>
|
||||||
<MenuItem value="any">Any Output</MenuItem>
|
<MenuItem value="any">Any Output</MenuItem>
|
||||||
{outputs.map(o => (
|
{outputs.map(o => (
|
||||||
@@ -324,7 +323,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<Select
|
<Select
|
||||||
value={outputState}
|
value={outputState}
|
||||||
label="State"
|
label="State"
|
||||||
onChange={(e) => setOutputState(e.target.value)}
|
onChange={(e) => this.setState({ outputState: e.target.value })}
|
||||||
>
|
>
|
||||||
<MenuItem value="on">Turns ON</MenuItem>
|
<MenuItem value="on">Turns ON</MenuItem>
|
||||||
<MenuItem value="off">Turns OFF</MenuItem>
|
<MenuItem value="off">Turns OFF</MenuItem>
|
||||||
@@ -338,7 +337,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<Paper sx={{ p: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
<Paper sx={{ p: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch checked={useTimeRange} onChange={(e) => setUseTimeRange(e.target.checked)} disabled={saving} />
|
<Switch checked={useTimeRange} onChange={(e) => this.setState({ useTimeRange: e.target.checked })} disabled={saving} />
|
||||||
}
|
}
|
||||||
label="Active Time Window (Optional)"
|
label="Active Time Window (Optional)"
|
||||||
/>
|
/>
|
||||||
@@ -346,13 +345,13 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
|
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="From" type="time"
|
label="From" type="time"
|
||||||
value={timeStart} onChange={(e) => setTimeStart(e.target.value)}
|
value={timeStart} onChange={(e) => this.setState({ timeStart: e.target.value })}
|
||||||
InputLabelProps={{ shrink: true }} size="small"
|
InputLabelProps={{ shrink: true }} size="small"
|
||||||
/>
|
/>
|
||||||
<Typography>to</Typography>
|
<Typography>to</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
label="Until" type="time"
|
label="Until" type="time"
|
||||||
value={timeEnd} onChange={(e) => setTimeEnd(e.target.value)}
|
value={timeEnd} onChange={(e) => this.setState({ timeEnd: e.target.value })}
|
||||||
InputLabelProps={{ shrink: true }} size="small"
|
InputLabelProps={{ shrink: true }} size="small"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -368,7 +367,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<Typography variant="body2">Logic:</Typography>
|
<Typography variant="body2">Logic:</Typography>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={sensorLogic} exclusive
|
value={sensorLogic} exclusive
|
||||||
onChange={(e, v) => v && setSensorLogic(v)} size="small"
|
onChange={(e, v) => v && this.setState({ sensorLogic: v })} size="small"
|
||||||
>
|
>
|
||||||
<ToggleButton value="and">AND</ToggleButton>
|
<ToggleButton value="and">AND</ToggleButton>
|
||||||
<ToggleButton value="or">OR</ToggleButton>
|
<ToggleButton value="or">OR</ToggleButton>
|
||||||
@@ -381,12 +380,12 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
key={i}
|
key={i}
|
||||||
condition={cond}
|
condition={cond}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
onChange={(newCond) => updateSensorCondition(i, newCond)}
|
onChange={(newCond) => this.updateSensorCondition(i, newCond)}
|
||||||
onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
|
onRemove={sensorConditions.length > 1 ? () => this.removeSensorCondition(i) : null}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Button size="small" onClick={addSensorCondition} disabled={saving} sx={{ alignSelf: 'flex-start' }}>
|
<Button size="small" onClick={this.addSensorCondition} disabled={saving} sx={{ alignSelf: 'flex-start' }}>
|
||||||
+ Add Condition
|
+ Add Condition
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -403,7 +402,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<Select
|
<Select
|
||||||
value={severity}
|
value={severity}
|
||||||
label="Severity"
|
label="Severity"
|
||||||
onChange={(e) => setSeverity(e.target.value)}
|
onChange={(e) => this.setState({ severity: e.target.value })}
|
||||||
>
|
>
|
||||||
<MenuItem value="info">ℹ️ Info</MenuItem>
|
<MenuItem value="info">ℹ️ Info</MenuItem>
|
||||||
<MenuItem value="warning">⚠️ Warning</MenuItem>
|
<MenuItem value="warning">⚠️ Warning</MenuItem>
|
||||||
@@ -414,7 +413,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<TextField
|
<TextField
|
||||||
label="Notification Message"
|
label="Notification Message"
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => this.setState({ message: e.target.value })}
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -432,7 +431,7 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
<Button onClick={onClose} color="inherit" disabled={saving}>Cancel</Button>
|
<Button onClick={onClose} color="inherit" disabled={saving}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={this.handleSave}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!isValid || saving}
|
disabled={!isValid || saving}
|
||||||
sx={{ background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)' }}
|
sx={{ background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)' }}
|
||||||
@@ -443,3 +442,6 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(AlarmEditor);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import AlarmCard from './AlarmCard';
|
import AlarmCard from './AlarmCard';
|
||||||
import AlarmEditor from './AlarmEditor';
|
import AlarmEditor from './AlarmEditor';
|
||||||
import { useAuth } from './AuthContext';
|
import { withAuth } from './AuthContext';
|
||||||
import { useI18n } from './I18nContext';
|
import { withI18n } from './I18nContext';
|
||||||
|
import { withDevices } from './DevicesContext';
|
||||||
|
|
||||||
const COLOR_TAGS = [
|
const COLOR_TAGS = [
|
||||||
{ id: 'red', label: 'Red', color: '#fb4934' },
|
{ id: 'red', label: 'Red', color: '#fb4934' },
|
||||||
@@ -25,162 +26,131 @@ const COLOR_TAGS = [
|
|||||||
{ id: 'gray', label: 'Gray', color: '#928374' }
|
{ id: 'gray', label: 'Gray', color: '#928374' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AlarmManager() {
|
class AlarmManager extends Component {
|
||||||
const { isAdmin } = useAuth();
|
constructor(props) {
|
||||||
const { t } = useI18n();
|
super(props);
|
||||||
const [alarms, setAlarms] = useState([]);
|
this.state = {
|
||||||
const [loading, setLoading] = useState(true);
|
alarms: [],
|
||||||
const [error, setError] = useState(null);
|
loading: true,
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
error: null,
|
||||||
const [editingAlarm, setEditingAlarm] = useState(null);
|
editorOpen: false,
|
||||||
const [devices, setDevices] = useState([]);
|
editingAlarm: null,
|
||||||
const [saving, setSaving] = useState(false);
|
saving: false,
|
||||||
const [filterTag, setFilterTag] = useState(null);
|
filterTag: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const getAuthHeaders = useCallback(() => {
|
componentDidMount() {
|
||||||
|
this.fetchAlarms();
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`
|
||||||
};
|
};
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const fetchAlarms = useCallback(async () => {
|
fetchAlarms = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('api/alarms');
|
const res = await fetch('api/alarms');
|
||||||
if (!res.ok) throw new Error('Failed to fetch alarms');
|
if (!res.ok) throw new Error('Failed to fetch alarms');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setAlarms(data);
|
this.setState({ alarms: data, error: null, loading: false });
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message, loading: false });
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchDevices = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('api/devices');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setDevices(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch devices:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAlarms();
|
|
||||||
fetchDevices();
|
|
||||||
}, [fetchAlarms, fetchDevices]);
|
|
||||||
|
|
||||||
// Build available sensors (same usage as RuleManager)
|
|
||||||
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: 'temp' });
|
|
||||||
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: 'level'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build available outputs
|
|
||||||
const availableOutputs = [];
|
|
||||||
devices.forEach(d => {
|
|
||||||
availableOutputs.push({
|
|
||||||
id: `${d.dev_name}:${d.port}:out`,
|
|
||||||
label: `${d.dev_name} - ${d.port_name} (Output)`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAddAlarm = () => {
|
|
||||||
setEditingAlarm(null);
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditAlarm = (alarm) => {
|
handleAddAlarm = () => {
|
||||||
setEditingAlarm(alarm);
|
this.setState({ editingAlarm: null, editorOpen: true });
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAlarm = async (id) => {
|
handleEditAlarm = (alarm) => {
|
||||||
|
this.setState({ editingAlarm: alarm, editorOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDeleteAlarm = async (id) => {
|
||||||
if (!confirm('Are you sure you want to delete this alarm?')) return;
|
if (!confirm('Are you sure you want to delete this alarm?')) return;
|
||||||
setSaving(true);
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`api/alarms/${id}`, {
|
const res = await fetch(`api/alarms/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getAuthHeaders()
|
headers: this.getAuthHeaders()
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to delete alarm');
|
if (!res.ok) throw new Error('Failed to delete alarm');
|
||||||
setAlarms(alarms.filter(a => a.id !== id));
|
this.setState(prev => ({
|
||||||
|
alarms: prev.alarms.filter(a => a.id !== id),
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message, saving: false });
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleAlarm = async (id) => {
|
handleToggleAlarm = async (id) => {
|
||||||
|
const { alarms } = this.state;
|
||||||
const alarm = alarms.find(a => a.id === id);
|
const alarm = alarms.find(a => a.id === id);
|
||||||
if (!alarm) return;
|
if (!alarm) return;
|
||||||
setSaving(true);
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`api/alarms/${id}`, {
|
const res = await fetch(`api/alarms/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
body: JSON.stringify({ ...alarm, enabled: !alarm.enabled })
|
body: JSON.stringify({ ...alarm, enabled: !alarm.enabled })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to update alarm');
|
if (!res.ok) throw new Error('Failed to update alarm');
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
setAlarms(alarms.map(a => a.id === id ? updated : a));
|
this.setState(prev => ({
|
||||||
|
alarms: prev.alarms.map(a => a.id === id ? updated : a),
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message, saving: false });
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAlarm = async (alarmData) => {
|
handleSaveAlarm = async (alarmData) => {
|
||||||
setSaving(true);
|
const { editingAlarm } = this.state;
|
||||||
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
if (editingAlarm) {
|
if (editingAlarm) {
|
||||||
const res = await fetch(`api/alarms/${editingAlarm.id}`, {
|
const res = await fetch(`api/alarms/${editingAlarm.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
body: JSON.stringify({ ...alarmData, enabled: editingAlarm.enabled })
|
body: JSON.stringify({ ...alarmData, enabled: editingAlarm.enabled })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to update alarm');
|
if (!res.ok) throw new Error('Failed to update alarm');
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
setAlarms(alarms.map(a => a.id === editingAlarm.id ? updated : a));
|
this.setState(prev => ({
|
||||||
|
alarms: prev.alarms.map(a => a.id === editingAlarm.id ? updated : a),
|
||||||
|
editorOpen: false,
|
||||||
|
editingAlarm: null,
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('api/alarms', {
|
const res = await fetch('api/alarms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
body: JSON.stringify({ ...alarmData, enabled: true })
|
body: JSON.stringify({ ...alarmData, enabled: true })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to create alarm');
|
if (!res.ok) throw new Error('Failed to create alarm');
|
||||||
const newAlarm = await res.json();
|
const newAlarm = await res.json();
|
||||||
setAlarms([...alarms, newAlarm]);
|
this.setState(prev => ({
|
||||||
|
alarms: [...prev.alarms, newAlarm],
|
||||||
|
editorOpen: false,
|
||||||
|
editingAlarm: null,
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
setEditorOpen(false);
|
|
||||||
setEditingAlarm(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message, saving: false });
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveAlarm = async (id, direction) => {
|
handleMoveAlarm = async (id, direction) => {
|
||||||
|
const { alarms } = this.state;
|
||||||
const idx = alarms.findIndex(a => a.id === id);
|
const idx = alarms.findIndex(a => a.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
if (direction === 'up' && idx === 0) return;
|
if (direction === 'up' && idx === 0) return;
|
||||||
@@ -189,19 +159,23 @@ export default function AlarmManager() {
|
|||||||
const newAlarms = [...alarms];
|
const newAlarms = [...alarms];
|
||||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||||
[newAlarms[idx], newAlarms[swapIdx]] = [newAlarms[swapIdx], newAlarms[idx]];
|
[newAlarms[idx], newAlarms[swapIdx]] = [newAlarms[swapIdx], newAlarms[idx]];
|
||||||
setAlarms(newAlarms);
|
this.setState({ alarms: newAlarms });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch('api/alarms/reorder', {
|
await fetch('api/alarms/reorder', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
body: JSON.stringify({ alarmIds: newAlarms.map(a => a.id) })
|
body: JSON.stringify({ alarmIds: newAlarms.map(a => a.id) })
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to save order');
|
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
|
const filteredAlarms = filterTag
|
||||||
? alarms.filter(a => (a.colorTags || []).includes(filterTag))
|
? alarms.filter(a => (a.colorTags || []).includes(filterTag))
|
||||||
: alarms;
|
: alarms;
|
||||||
@@ -215,7 +189,7 @@ export default function AlarmManager() {
|
|||||||
sx={{
|
sx={{
|
||||||
mt: 4, ml: 4, mb: 4,
|
mt: 4, ml: 4, mb: 4,
|
||||||
p: 3,
|
p: 3,
|
||||||
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)', // Distinct background? Or same?
|
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
|
||||||
border: '1px solid #504945'
|
border: '1px solid #504945'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -231,7 +205,7 @@ export default function AlarmManager() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleAddAlarm}
|
onClick={this.handleAddAlarm}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
sx={{
|
sx={{
|
||||||
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)',
|
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)',
|
||||||
@@ -248,12 +222,12 @@ export default function AlarmManager() {
|
|||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>Filter:</Typography>
|
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>Filter:</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
label="All" size="small" onClick={() => setFilterTag(null)}
|
label="All" size="small" onClick={() => this.setState({ filterTag: null })}
|
||||||
sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
|
sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
|
||||||
/>
|
/>
|
||||||
{COLOR_TAGS.map(tag => (
|
{COLOR_TAGS.map(tag => (
|
||||||
<Chip
|
<Chip
|
||||||
key={tag.id} size="small" onClick={() => setFilterTag(filterTag === tag.id ? null : tag.id)}
|
key={tag.id} size="small" onClick={() => this.setState({ filterTag: filterTag === tag.id ? null : tag.id })}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: filterTag === tag.id ? tag.color : '#504945',
|
bgcolor: filterTag === tag.id ? tag.color : '#504945',
|
||||||
color: filterTag === tag.id ? '#282828' : tag.color,
|
color: filterTag === tag.id ? '#282828' : tag.color,
|
||||||
@@ -264,7 +238,7 @@ export default function AlarmManager() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => this.setState({ error: null })}>{error}</Alert>}
|
||||||
|
|
||||||
{filteredAlarms.length === 0 ? (
|
{filteredAlarms.length === 0 ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
@@ -276,11 +250,11 @@ export default function AlarmManager() {
|
|||||||
<AlarmCard
|
<AlarmCard
|
||||||
key={alarm.id}
|
key={alarm.id}
|
||||||
alarm={alarm}
|
alarm={alarm}
|
||||||
onEdit={isAdmin ? () => handleEditAlarm(alarm) : null}
|
onEdit={isAdmin ? () => this.handleEditAlarm(alarm) : null}
|
||||||
onDelete={isAdmin ? () => handleDeleteAlarm(alarm.id) : null}
|
onDelete={isAdmin ? () => this.handleDeleteAlarm(alarm.id) : null}
|
||||||
onToggle={isAdmin ? () => handleToggleAlarm(alarm.id) : null}
|
onToggle={isAdmin ? () => this.handleToggleAlarm(alarm.id) : null}
|
||||||
onMoveUp={isAdmin && idx > 0 ? () => handleMoveAlarm(alarm.id, 'up') : null}
|
onMoveUp={isAdmin && idx > 0 ? () => this.handleMoveAlarm(alarm.id, 'up') : null}
|
||||||
onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => handleMoveAlarm(alarm.id, 'down') : null}
|
onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => this.handleMoveAlarm(alarm.id, 'down') : null}
|
||||||
colorTags={COLOR_TAGS}
|
colorTags={COLOR_TAGS}
|
||||||
readOnly={!isAdmin}
|
readOnly={!isAdmin}
|
||||||
/>
|
/>
|
||||||
@@ -292,10 +266,10 @@ export default function AlarmManager() {
|
|||||||
<AlarmEditor
|
<AlarmEditor
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
alarm={editingAlarm}
|
alarm={editingAlarm}
|
||||||
onSave={handleSaveAlarm}
|
onSave={this.handleSaveAlarm}
|
||||||
onClose={() => { setEditorOpen(false); setEditingAlarm(null); }}
|
onClose={() => { this.setState({ editorOpen: false, editingAlarm: null }); }}
|
||||||
sensors={availableSensors}
|
sensors={getAvailableSensors()}
|
||||||
outputs={availableOutputs}
|
outputs={getAvailableOutputs()}
|
||||||
colorTags={COLOR_TAGS}
|
colorTags={COLOR_TAGS}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
/>
|
/>
|
||||||
@@ -303,3 +277,6 @@ export default function AlarmManager() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDevices(withAuth(withI18n(AlarmManager)));
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
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 AlarmManager from './AlarmManager';
|
||||||
import LoginDialog from './LoginDialog';
|
import LoginDialog from './LoginDialog';
|
||||||
import ProfileDialog from './ProfileDialog';
|
import ProfileDialog from './ProfileDialog';
|
||||||
import { AuthProvider, useAuth } from './AuthContext';
|
import { AuthProvider, withAuth } from './AuthContext';
|
||||||
import { I18nProvider, useI18n } from './I18nContext';
|
import { I18nProvider, withI18n } from './I18nContext';
|
||||||
|
import { DevicesProvider } from './DevicesContext';
|
||||||
import LanguageSwitcher from './LanguageSwitcher';
|
import LanguageSwitcher from './LanguageSwitcher';
|
||||||
|
|
||||||
// Gruvbox Dark color palette
|
// Gruvbox Dark color palette
|
||||||
@@ -46,11 +47,18 @@ const darkTheme = createTheme({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function AppContent() {
|
class AppContent extends Component {
|
||||||
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
|
constructor(props) {
|
||||||
const { t } = useI18n();
|
super(props);
|
||||||
const [showLogin, setShowLogin] = useState(false);
|
this.state = {
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
showLogin: false,
|
||||||
|
showProfile: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { auth: { user, logout, isAuthenticated, isAdmin }, i18n: { t } } = this.props;
|
||||||
|
const { showLogin, showProfile } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
@@ -65,7 +73,7 @@ function AppContent() {
|
|||||||
<>
|
<>
|
||||||
<Chip
|
<Chip
|
||||||
label={user.username}
|
label={user.username}
|
||||||
onClick={() => setShowProfile(true)}
|
onClick={() => this.setState({ showProfile: true })}
|
||||||
color={isAdmin ? 'secondary' : 'default'}
|
color={isAdmin ? 'secondary' : 'default'}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -99,7 +107,7 @@ function AppContent() {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => setShowLogin(true)}
|
onClick={() => this.setState({ showLogin: true })}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ borderColor: gruvboxDark.aqua }}
|
sx={{ borderColor: gruvboxDark.aqua }}
|
||||||
@@ -124,29 +132,36 @@ function AppContent() {
|
|||||||
{/* Login dialog - shown on demand */}
|
{/* Login dialog - shown on demand */}
|
||||||
<LoginDialog
|
<LoginDialog
|
||||||
open={showLogin}
|
open={showLogin}
|
||||||
onClose={() => setShowLogin(false)}
|
onClose={() => this.setState({ showLogin: false })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Profile dialog */}
|
{/* Profile dialog */}
|
||||||
<ProfileDialog
|
<ProfileDialog
|
||||||
open={showProfile}
|
open={showProfile}
|
||||||
onClose={() => setShowProfile(false)}
|
onClose={() => this.setState({ showProfile: false })}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
const WrappedAppContent = withAuth(withI18n(AppContent));
|
||||||
|
|
||||||
|
class App extends Component {
|
||||||
|
render() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={darkTheme}>
|
<ThemeProvider theme={darkTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppContent />
|
<DevicesProvider>
|
||||||
|
<WrappedAppContent />
|
||||||
|
</DevicesProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</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,15 +1,39 @@
|
|||||||
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 { useI18n } from './I18nContext';
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
export default function ControllerCard({ controllerName, ports, range }) {
|
class ControllerCard extends Component {
|
||||||
const { t } = useI18n();
|
constructor(props) {
|
||||||
const [envData, setEnvData] = useState([]);
|
super(props);
|
||||||
const [portData, setPortData] = useState({});
|
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) {
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData = async () => {
|
||||||
|
const { controllerName, ports, range } = this.props;
|
||||||
try {
|
try {
|
||||||
if (ports.length === 0) return;
|
if (ports.length === 0) return;
|
||||||
|
|
||||||
@@ -27,12 +51,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) {
|
||||||
@@ -40,12 +64,9 @@ 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 }}>
|
||||||
@@ -92,3 +113,6 @@ export default function ControllerCard({ controllerName, ports, range }) {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(ControllerCard);
|
||||||
|
|||||||
@@ -1,53 +1,25 @@
|
|||||||
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 OutputChart from './OutputChart';
|
||||||
import { useI18n } from './I18nContext';
|
import { withI18n } from './I18nContext';
|
||||||
|
import { withDevices } from './DevicesContext';
|
||||||
|
|
||||||
export default function Dashboard() {
|
class Dashboard extends Component {
|
||||||
const { t } = useI18n();
|
constructor(props) {
|
||||||
const [groupedDevices, setGroupedDevices] = useState({});
|
super(props);
|
||||||
const [allDevices, setAllDevices] = useState([]);
|
this.state = {
|
||||||
const [loading, setLoading] = useState(true);
|
range: 'day' // 'day', 'week', 'month'
|
||||||
const [error, setError] = useState(null);
|
};
|
||||||
const [range, setRange] = useState('day'); // 'day', 'week', 'month'
|
|
||||||
|
|
||||||
const fetchDevices = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
// 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');
|
|
||||||
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;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
setGroupedDevices(grouped);
|
|
||||||
setAllDevices(devices);
|
|
||||||
setLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err.message);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
setRange = (range) => {
|
||||||
fetchDevices();
|
this.setState({ range });
|
||||||
}, [fetchDevices]);
|
};
|
||||||
|
|
||||||
// Auto-refresh logic (basic rerender trigger could be added here,
|
render() {
|
||||||
// but simpler to let ControllerCard handle data fetching internally based on props)
|
const { i18n: { t }, devicesCtx: { devices, groupedDevices, loading, error } } = this.props;
|
||||||
|
const { range } = this.state;
|
||||||
|
|
||||||
if (loading) return <Typography>{t('dashboard.loading')}</Typography>;
|
if (loading) return <Typography>{t('dashboard.loading')}</Typography>;
|
||||||
if (error) return <Alert severity="error">{error}</Alert>;
|
if (error) return <Alert severity="error">{error}</Alert>;
|
||||||
@@ -57,19 +29,19 @@ export default function Dashboard() {
|
|||||||
<Box display="flex" justifyContent="flex-end" mb={3}>
|
<Box display="flex" justifyContent="flex-end" mb={3}>
|
||||||
<ButtonGroup variant="contained" aria-label="outlined primary button group">
|
<ButtonGroup variant="contained" aria-label="outlined primary button group">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setRange('day')}
|
onClick={() => this.setRange('day')}
|
||||||
color={range === 'day' ? 'primary' : 'inherit'}
|
color={range === 'day' ? 'primary' : 'inherit'}
|
||||||
>
|
>
|
||||||
{t('dashboard.hours24')}
|
{t('dashboard.hours24')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setRange('week')}
|
onClick={() => this.setRange('week')}
|
||||||
color={range === 'week' ? 'primary' : 'inherit'}
|
color={range === 'week' ? 'primary' : 'inherit'}
|
||||||
>
|
>
|
||||||
{t('dashboard.days7')}
|
{t('dashboard.days7')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setRange('month')}
|
onClick={() => this.setRange('month')}
|
||||||
color={range === 'month' ? 'primary' : 'inherit'}
|
color={range === 'month' ? 'primary' : 'inherit'}
|
||||||
>
|
>
|
||||||
{t('dashboard.days30')}
|
{t('dashboard.days30')}
|
||||||
@@ -86,7 +58,10 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<OutputChart range={range} devices={allDevices} />
|
<OutputChart range={range} devices={devices} />
|
||||||
</Box>
|
</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;
|
formatDateLabel = (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,8 +33,13 @@ export default function EnvChart({ data, range }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { data } = this.props;
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
labels: data.map(d => this.formatDateLabel(d.timestamp)),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Temperature (°C)',
|
label: 'Temperature (°C)',
|
||||||
@@ -96,3 +100,6 @@ export default function EnvChart({ data, range }) {
|
|||||||
|
|
||||||
return <Line data={chartData} options={options} />;
|
return <Line data={chartData} options={options} />;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnvChart;
|
||||||
|
|||||||
@@ -80,3 +80,11 @@ export function useI18n() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HOC for class components
|
||||||
|
export function withI18n(Component) {
|
||||||
|
return function WrappedComponent(props) {
|
||||||
|
const i18n = useI18n();
|
||||||
|
return <Component {...props} i18n={i18n} />;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react';
|
import React, { Component } from 'react';
|
||||||
import { Box, IconButton, Tooltip } from '@mui/material';
|
import { Box, IconButton, Tooltip } from '@mui/material';
|
||||||
import { useI18n } from './I18nContext';
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
// Flag emojis for language switching
|
// Flag emojis for language switching
|
||||||
const FLAG_DE = '🇩🇪';
|
const FLAG_DE = '🇩🇪';
|
||||||
const FLAG_EN = '🇬🇧';
|
const FLAG_EN = '🇬🇧';
|
||||||
|
|
||||||
export default function LanguageSwitcher() {
|
class LanguageSwitcher extends Component {
|
||||||
const { language, setLanguage } = useI18n();
|
render() {
|
||||||
|
const { i18n: { language, setLanguage } } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
@@ -40,3 +41,6 @@ export default function LanguageSwitcher() {
|
|||||||
</Box>
|
</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;
|
formatDateLabel = (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,12 +33,17 @@ export default function LevelChart({ data, isLight, isCO2, range }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { data, isLight, isCO2 } = this.props;
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
// Determine label and color based on sensor type
|
// Determine label and color based on sensor type
|
||||||
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
|
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
|
||||||
const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
|
const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
|
||||||
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
labels: data.map(d => this.formatDateLabel(d.timestamp)),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: levelLabel,
|
label: levelLabel,
|
||||||
@@ -80,3 +84,6 @@ export default function LevelChart({ data, isLight, isCO2, range }) {
|
|||||||
|
|
||||||
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,49 +13,58 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useAuth } from './AuthContext';
|
import { withAuth } from './AuthContext';
|
||||||
import { useI18n } from './I18nContext';
|
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 { t } = useI18n();
|
super(props);
|
||||||
const [username, setUsername] = useState('');
|
this.state = {
|
||||||
const [password, setPassword] = useState('');
|
username: '',
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
password: '',
|
||||||
const [error, setError] = useState('');
|
showPassword: false,
|
||||||
const [loading, setLoading] = useState(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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, i18n: { t } } = this.props;
|
||||||
|
const { username, password, showPassword, error, loading } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={this.handleClose}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{
|
PaperProps={{
|
||||||
@@ -75,7 +84,7 @@ export default function LoginDialog({ open, onClose }) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={this.handleSubmit}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
@@ -91,7 +100,7 @@ export default function LoginDialog({ open, onClose }) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => this.setState({ username: e.target.value })}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
/>
|
/>
|
||||||
@@ -103,13 +112,13 @@ export default function LoginDialog({ open, onClose }) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => this.setState({ password: e.target.value })}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => this.setState({ showPassword: !showPassword })}
|
||||||
edge="end"
|
edge="end"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
@@ -150,3 +159,6 @@ export default function LoginDialog({ open, onClose }) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuth(withI18n(LoginDialog));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||||
import { useI18n } from './I18nContext';
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -23,28 +23,49 @@ ChartJS.register(
|
|||||||
Legend
|
Legend
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function OutputChart({ range, devices = [] }) {
|
class OutputChart extends Component {
|
||||||
const { t } = useI18n();
|
constructor(props) {
|
||||||
const [data, setData] = useState([]);
|
super(props);
|
||||||
const [loading, setLoading] = useState(true);
|
this.state = {
|
||||||
|
data: [],
|
||||||
|
loading: true
|
||||||
|
};
|
||||||
|
this.interval = null;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
componentDidMount() {
|
||||||
const fetchData = async () => {
|
this.fetchData();
|
||||||
|
// Poll for updates every minute
|
||||||
|
this.interval = setInterval(() => this.fetchData(), 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.range !== this.props.range) {
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData = async () => {
|
||||||
|
const { range } = this.props;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('api/outputs/history');
|
const res = await fetch(`api/outputs/history?range=${range || 'day'}`);
|
||||||
const logs = await res.json();
|
const logs = await res.json();
|
||||||
setData(logs);
|
this.setState({ data: logs, loading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch output history', err);
|
console.error('Failed to fetch output history', err);
|
||||||
} finally {
|
this.setState({ loading: false });
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
|
||||||
// Poll for updates every minute
|
render() {
|
||||||
const interval = setInterval(fetchData, 60000);
|
const { devices = [] } = this.props;
|
||||||
return () => clearInterval(interval);
|
const { data, loading } = this.state;
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) return <CircularProgress size={20} />;
|
if (loading) return <CircularProgress size={20} />;
|
||||||
if (!data || data.length === 0) return null;
|
if (!data || data.length === 0) return null;
|
||||||
@@ -206,3 +227,6 @@ export default function OutputChart({ range, devices = [] }) {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(OutputChart);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -12,35 +12,29 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Link
|
Link
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useAuth } from './AuthContext';
|
import { withAuth } from './AuthContext';
|
||||||
|
|
||||||
export default function ProfileDialog({ open, onClose }) {
|
class ProfileDialog extends Component {
|
||||||
const { user, login } = useAuth(); // We need a way to refresh user data or update context.
|
constructor(props) {
|
||||||
// Actually, AuthContext might not expose a "refreshUser" method.
|
super(props);
|
||||||
// For now we will update the local state and rely on next page load/auth check to refresh global state,
|
this.state = {
|
||||||
// OR we should ideally update the user object in AuthContext.
|
telegramId: '',
|
||||||
// Checking AuthContext... it has `user` state. It sets user on login/checkAuth.
|
loading: false,
|
||||||
// `checkAuth` is not exposed in `useAuth` return typically?
|
saving: false,
|
||||||
// Let's assume we can just modify the user locally or ignore it, as long as the server has it.
|
error: null,
|
||||||
|
success: false
|
||||||
// Better: Fetch the latest profile data when opening the dialog.
|
};
|
||||||
|
|
||||||
const [telegramId, setTelegramId] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
fetchProfile();
|
|
||||||
setSuccess(false);
|
|
||||||
setError(null);
|
|
||||||
}
|
}
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const fetchProfile = async () => {
|
componentDidUpdate(prevProps) {
|
||||||
setLoading(true);
|
if (this.props.open && !prevProps.open) {
|
||||||
|
this.fetchProfile();
|
||||||
|
this.setState({ success: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProfile = async () => {
|
||||||
|
this.setState({ loading: true });
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
const res = await fetch('api/auth/me', {
|
const res = await fetch('api/auth/me', {
|
||||||
@@ -49,20 +43,21 @@ export default function ProfileDialog({ open, onClose }) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
setTelegramId(data.user.telegramId || '');
|
this.setState({ telegramId: data.user.telegramId || '' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
this.setState({ loading: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
handleSave = async () => {
|
||||||
setSaving(true);
|
const { onClose } = this.props;
|
||||||
setError(null);
|
const { telegramId } = this.state;
|
||||||
setSuccess(false);
|
|
||||||
|
this.setState({ saving: true, error: null, success: false });
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
const res = await fetch('api/auth/profile', {
|
const res = await fetch('api/auth/profile', {
|
||||||
@@ -79,15 +74,19 @@ export default function ProfileDialog({ open, onClose }) {
|
|||||||
throw new Error(errData.error || 'Failed to update profile');
|
throw new Error(errData.error || 'Failed to update profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess(true);
|
this.setState({ success: true });
|
||||||
setTimeout(() => onClose(), 1500);
|
setTimeout(() => onClose(), 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
this.setState({ error: err.message });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
this.setState({ saving: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, onClose, auth: { user } } = this.props;
|
||||||
|
const { telegramId, loading, saving, error, success } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>User Profile: {user?.username}</DialogTitle>
|
<DialogTitle>User Profile: {user?.username}</DialogTitle>
|
||||||
@@ -112,7 +111,7 @@ export default function ProfileDialog({ open, onClose }) {
|
|||||||
<TextField
|
<TextField
|
||||||
label="Telegram ID"
|
label="Telegram ID"
|
||||||
value={telegramId}
|
value={telegramId}
|
||||||
onChange={(e) => setTelegramId(e.target.value)}
|
onChange={(e) => this.setState({ telegramId: e.target.value })}
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder="e.g. 123456789"
|
placeholder="e.g. 123456789"
|
||||||
helperText="Leave empty to disable notifications"
|
helperText="Leave empty to disable notifications"
|
||||||
@@ -125,10 +124,13 @@ export default function ProfileDialog({ open, onClose }) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} color="inherit">Close</Button>
|
<Button onClick={onClose} color="inherit">Close</Button>
|
||||||
<Button onClick={handleSave} variant="contained" disabled={saving || loading}>
|
<Button onClick={this.handleSave} variant="contained" disabled={saving || loading}>
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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,7 +8,7 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useI18n } from './I18nContext';
|
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>;
|
||||||
@@ -16,8 +16,9 @@ const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑️</span>;
|
|||||||
|
|
||||||
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 { t } = useI18n();
|
render() {
|
||||||
|
const { trigger, t } = this.props;
|
||||||
const dayLabels = {
|
const dayLabels = {
|
||||||
mon: t('days.mon').charAt(0),
|
mon: t('days.mon').charAt(0),
|
||||||
tue: t('days.tue').charAt(0),
|
tue: t('days.tue').charAt(0),
|
||||||
@@ -120,9 +121,11 @@ function TriggerSummary({ trigger }) {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ActionSummary({ action }) {
|
class ActionSummary extends Component {
|
||||||
const { t } = useI18n();
|
render() {
|
||||||
|
const { action, t } = this.props;
|
||||||
|
|
||||||
if (action.type === 'toggle') {
|
if (action.type === 'toggle') {
|
||||||
// Check if it's a level or binary action
|
// Check if it's a level or binary action
|
||||||
@@ -155,9 +158,12 @@ function ActionSummary({ action }) {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RuleCard extends Component {
|
||||||
|
render() {
|
||||||
|
const { rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly, activeInfo, i18n: { t } } = this.props;
|
||||||
|
|
||||||
export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly, activeInfo }) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
// Get list of tag colors for this rule (handle array or backwards-compat single value)
|
// Get list of tag colors for this rule (handle array or backwards-compat single value)
|
||||||
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
||||||
const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
|
const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
|
||||||
@@ -216,8 +222,8 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, o
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
<TriggerSummary trigger={rule.trigger} />
|
<TriggerSummary trigger={rule.trigger} t={t} />
|
||||||
<ActionSummary action={rule.action} />
|
<ActionSummary action={rule.action} t={t} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -267,3 +273,6 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, o
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(RuleCard);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Chip
|
Chip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useI18n } from './I18nContext';
|
import { withI18n } from './I18nContext';
|
||||||
|
|
||||||
const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
|
|
||||||
@@ -36,12 +36,9 @@ const OPERATORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Single sensor condition component
|
// Single sensor condition component
|
||||||
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
class SensorCondition extends Component {
|
||||||
const selectedSensor = sensors.find(s => s.id === condition.sensor);
|
handleSensorChange = (newSensorId) => {
|
||||||
const isStateSensor = selectedSensor?.type === 'output-state';
|
const { sensors, condition, onChange } = this.props;
|
||||||
|
|
||||||
// When sensor changes, reset operator to appropriate default
|
|
||||||
const handleSensorChange = (newSensorId) => {
|
|
||||||
const newSensor = sensors.find(s => s.id === newSensorId);
|
const newSensor = sensors.find(s => s.id === newSensorId);
|
||||||
const newIsState = newSensor?.type === 'output-state';
|
const newIsState = newSensor?.type === 'output-state';
|
||||||
onChange({
|
onChange({
|
||||||
@@ -52,6 +49,11 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { condition, sensors, onChange, onRemove, disabled } = this.props;
|
||||||
|
const selectedSensor = sensors.find(s => s.id === condition.sensor);
|
||||||
|
const isStateSensor = selectedSensor?.type === 'output-state';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
|
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
@@ -59,7 +61,7 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
|||||||
<Select
|
<Select
|
||||||
value={condition.sensor || ''}
|
value={condition.sensor || ''}
|
||||||
label="Sensor"
|
label="Sensor"
|
||||||
onChange={(e) => handleSensorChange(e.target.value)}
|
onChange={(e) => this.handleSensorChange(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{sensors.map(s => (
|
{sensors.map(s => (
|
||||||
@@ -113,135 +115,130 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
|
class RuleEditor extends Component {
|
||||||
const { t } = useI18n();
|
constructor(props) {
|
||||||
const [name, setName] = useState('');
|
super(props);
|
||||||
const [selectedTags, setSelectedTags] = useState([]); // array of tag ids
|
this.state = {
|
||||||
|
name: '',
|
||||||
|
selectedTags: [],
|
||||||
|
useScheduledTime: false,
|
||||||
|
scheduledTime: '08:00',
|
||||||
|
scheduledDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||||
|
useTimeRange: false,
|
||||||
|
timeStart: '08:00',
|
||||||
|
timeEnd: '18:00',
|
||||||
|
timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||||
|
useSensors: false,
|
||||||
|
sensorConditions: [{ sensor: '', operator: '>', value: 25 }],
|
||||||
|
sensorLogic: 'and',
|
||||||
|
actionType: 'toggle',
|
||||||
|
target: '',
|
||||||
|
toggleState: true,
|
||||||
|
outputLevel: 5,
|
||||||
|
duration: 15
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Scheduled time state (trigger at specific time)
|
componentDidUpdate(prevProps) {
|
||||||
const [useScheduledTime, setUseScheduledTime] = useState(false);
|
if (this.props.open !== prevProps.open || this.props.rule !== prevProps.rule) {
|
||||||
const [scheduledTime, setScheduledTime] = useState('08:00');
|
this.initializeState();
|
||||||
const [scheduledDays, setScheduledDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Time range state (active during window)
|
componentDidMount() {
|
||||||
const [useTimeRange, setUseTimeRange] = useState(false);
|
this.initializeState();
|
||||||
const [timeStart, setTimeStart] = useState('08:00');
|
}
|
||||||
const [timeEnd, setTimeEnd] = useState('18:00');
|
|
||||||
const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
|
||||||
|
|
||||||
// Sensor conditions state
|
initializeState = () => {
|
||||||
const [useSensors, setUseSensors] = useState(false);
|
const { rule, sensors, outputs } = this.props;
|
||||||
const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
|
|
||||||
const [sensorLogic, setSensorLogic] = useState('and'); // 'and' or 'or'
|
|
||||||
|
|
||||||
// Action state
|
|
||||||
const [actionType, setActionType] = useState('toggle');
|
|
||||||
const [target, setTarget] = useState('');
|
|
||||||
const [toggleState, setToggleState] = useState(true);
|
|
||||||
const [outputLevel, setOutputLevel] = useState(5); // 1-10 for port outputs
|
|
||||||
const [duration, setDuration] = useState(15);
|
|
||||||
|
|
||||||
// Check if target is a binary (on/off) output or level (1-10) output
|
|
||||||
const selectedOutput = outputs.find(o => o.id === target);
|
|
||||||
const isBinaryOutput = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
|
|
||||||
|
|
||||||
// Reset form when rule changes or dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (rule) {
|
if (rule) {
|
||||||
setName(rule.name);
|
|
||||||
// colorTags can be array or single value for backwards compat
|
|
||||||
const tags = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
const tags = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
||||||
setSelectedTags(Array.isArray(tags) ? tags : []);
|
|
||||||
|
|
||||||
// Parse trigger
|
|
||||||
const trigger = rule.trigger || {};
|
const trigger = rule.trigger || {};
|
||||||
|
|
||||||
// Scheduled time
|
this.setState({
|
||||||
setUseScheduledTime(!!trigger.scheduledTime);
|
name: rule.name,
|
||||||
if (trigger.scheduledTime) {
|
selectedTags: Array.isArray(tags) ? tags : [],
|
||||||
setScheduledTime(trigger.scheduledTime.time || '08:00');
|
useScheduledTime: !!trigger.scheduledTime,
|
||||||
setScheduledDays(trigger.scheduledTime.days || []);
|
scheduledTime: trigger.scheduledTime?.time || '08:00',
|
||||||
}
|
scheduledDays: trigger.scheduledTime?.days || [],
|
||||||
|
useTimeRange: !!trigger.timeRange,
|
||||||
// Time range
|
timeStart: trigger.timeRange?.start || '08:00',
|
||||||
setUseTimeRange(!!trigger.timeRange);
|
timeEnd: trigger.timeRange?.end || '18:00',
|
||||||
if (trigger.timeRange) {
|
timeRangeDays: trigger.timeRange?.days || [],
|
||||||
setTimeStart(trigger.timeRange.start || '08:00');
|
useSensors: !!trigger.sensors && trigger.sensors.length > 0,
|
||||||
setTimeEnd(trigger.timeRange.end || '18:00');
|
sensorConditions: trigger.sensors?.length > 0 ? trigger.sensors : [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
|
||||||
setTimeRangeDays(trigger.timeRange.days || []);
|
sensorLogic: trigger.sensorLogic || 'and',
|
||||||
}
|
actionType: rule.action?.type || 'toggle',
|
||||||
|
target: rule.action?.target || '',
|
||||||
setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
|
toggleState: rule.action?.state ?? true,
|
||||||
if (trigger.sensors && trigger.sensors.length > 0) {
|
outputLevel: rule.action?.level ?? 5,
|
||||||
setSensorConditions(trigger.sensors);
|
duration: rule.action?.duration || 15
|
||||||
setSensorLogic(trigger.sensorLogic || 'and');
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Parse action
|
|
||||||
setActionType(rule.action?.type || 'toggle');
|
|
||||||
setTarget(rule.action?.target || '');
|
|
||||||
if (rule.action?.type === 'toggle') {
|
|
||||||
setToggleState(rule.action?.state ?? true);
|
|
||||||
setOutputLevel(rule.action?.level ?? 5);
|
|
||||||
} else {
|
} else {
|
||||||
setDuration(rule.action?.duration || 15);
|
this.setState({
|
||||||
|
name: '',
|
||||||
|
selectedTags: [],
|
||||||
|
useScheduledTime: true,
|
||||||
|
scheduledTime: '08:00',
|
||||||
|
scheduledDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||||
|
useTimeRange: false,
|
||||||
|
timeStart: '08:00',
|
||||||
|
timeEnd: '18:00',
|
||||||
|
timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||||
|
useSensors: false,
|
||||||
|
sensorConditions: [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
|
||||||
|
sensorLogic: 'and',
|
||||||
|
actionType: 'toggle',
|
||||||
|
target: outputs[0]?.id || '',
|
||||||
|
toggleState: true,
|
||||||
|
outputLevel: 5,
|
||||||
|
duration: 15
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Reset to defaults
|
|
||||||
setName('');
|
|
||||||
setSelectedTags([]);
|
|
||||||
setUseScheduledTime(true);
|
|
||||||
setScheduledTime('08:00');
|
|
||||||
setScheduledDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
|
||||||
setUseTimeRange(false);
|
|
||||||
setTimeStart('08:00');
|
|
||||||
setTimeEnd('18:00');
|
|
||||||
setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
|
||||||
setUseSensors(false);
|
|
||||||
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
|
||||||
setSensorLogic('and');
|
|
||||||
setActionType('toggle');
|
|
||||||
setTarget(outputs[0]?.id || '');
|
|
||||||
setToggleState(true);
|
|
||||||
setOutputLevel(5);
|
|
||||||
setDuration(15);
|
|
||||||
}
|
|
||||||
}, [rule, open, sensors, outputs]);
|
|
||||||
|
|
||||||
// Set default sensor/output when lists load
|
|
||||||
useEffect(() => {
|
|
||||||
if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
|
|
||||||
setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
|
|
||||||
}
|
|
||||||
if (!target && outputs.length > 0) setTarget(outputs[0].id);
|
|
||||||
}, [sensors, outputs, sensorConditions, target]);
|
|
||||||
|
|
||||||
const handleScheduledDaysChange = (event, newDays) => {
|
|
||||||
if (newDays.length > 0) setScheduledDays(newDays);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeRangeDaysChange = (event, newDays) => {
|
handleScheduledDaysChange = (event, newDays) => {
|
||||||
if (newDays.length > 0) setTimeRangeDays(newDays);
|
if (newDays.length > 0) this.setState({ scheduledDays: newDays });
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSensorCondition = () => {
|
handleTimeRangeDaysChange = (event, newDays) => {
|
||||||
setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
if (newDays.length > 0) this.setState({ timeRangeDays: newDays });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSensorCondition = (index, newCondition) => {
|
addSensorCondition = () => {
|
||||||
const updated = [...sensorConditions];
|
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;
|
updated[index] = newCondition;
|
||||||
setSensorConditions(updated);
|
return { sensorConditions: updated };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSensorCondition = (index) => {
|
removeSensorCondition = (index) => {
|
||||||
if (sensorConditions.length > 1) {
|
this.setState(prev => {
|
||||||
setSensorConditions(sensorConditions.filter((_, i) => i !== index));
|
if (prev.sensorConditions.length > 1) {
|
||||||
|
return { sensorConditions: prev.sensorConditions.filter((_, i) => i !== index) };
|
||||||
}
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
handleSave = () => {
|
||||||
|
const { onSave, sensors, outputs } = this.props;
|
||||||
|
const { name, selectedTags, useScheduledTime, scheduledTime, scheduledDays,
|
||||||
|
useTimeRange, timeStart, timeEnd, timeRangeDays,
|
||||||
|
useSensors, sensorConditions, sensorLogic,
|
||||||
|
actionType, target, toggleState, outputLevel, duration } = this.state;
|
||||||
|
|
||||||
const selectedOutput = outputs.find(o => o.id === target);
|
const selectedOutput = outputs.find(o => o.id === target);
|
||||||
|
|
||||||
// Build trigger object
|
// Build trigger object
|
||||||
@@ -285,6 +282,16 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
onSave(ruleData);
|
onSave(ruleData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, rule, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving, i18n: { t } } = this.props;
|
||||||
|
const { name, selectedTags, useScheduledTime, scheduledTime, scheduledDays,
|
||||||
|
useTimeRange, timeStart, timeEnd, timeRangeDays,
|
||||||
|
useSensors, sensorConditions, sensorLogic,
|
||||||
|
actionType, target, toggleState, outputLevel, duration } = this.state;
|
||||||
|
|
||||||
|
const selectedOutput = outputs.find(o => o.id === target);
|
||||||
|
const isBinaryOutput = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
|
||||||
|
|
||||||
const isValid = name.trim().length > 0 &&
|
const isValid = name.trim().length > 0 &&
|
||||||
(useScheduledTime || useTimeRange || useSensors) &&
|
(useScheduledTime || useTimeRange || useSensors) &&
|
||||||
(!useScheduledTime || scheduledDays.length > 0) &&
|
(!useScheduledTime || scheduledDays.length > 0) &&
|
||||||
@@ -315,7 +322,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
<TextField
|
<TextField
|
||||||
label={t('ruleEditor.ruleName')}
|
label={t('ruleEditor.ruleName')}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => this.setState({ name: e.target.value })}
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder={t('ruleEditor.ruleNamePlaceholder')}
|
placeholder={t('ruleEditor.ruleNamePlaceholder')}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -325,7 +332,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Typography variant="body2" color="text.secondary">Tags:</Typography>
|
<Typography variant="body2" color="text.secondary">Tags:</Typography>
|
||||||
<Box
|
<Box
|
||||||
onClick={() => setSelectedTags([])}
|
onClick={() => this.setState({ selectedTags: [] })}
|
||||||
sx={{
|
sx={{
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -347,9 +354,9 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
key={tag.id}
|
key={tag.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedTags.includes(tag.id)) {
|
if (selectedTags.includes(tag.id)) {
|
||||||
setSelectedTags(selectedTags.filter(t => t !== tag.id));
|
this.setState({ selectedTags: selectedTags.filter(t => t !== tag.id) });
|
||||||
} else {
|
} else {
|
||||||
setSelectedTags([...selectedTags, tag.id]);
|
this.setState({ selectedTags: [...selectedTags, tag.id] });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -377,13 +384,13 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
{/* Scheduled Time Trigger (fires at exact time) */}
|
{/* Scheduled Time Trigger */}
|
||||||
<Paper sx={{ p: 2, mb: 2, bgcolor: useScheduledTime ? 'action.selected' : 'background.default' }}>
|
<Paper sx={{ p: 2, mb: 2, bgcolor: useScheduledTime ? 'action.selected' : 'background.default' }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={useScheduledTime}
|
checked={useScheduledTime}
|
||||||
onChange={(e) => setUseScheduledTime(e.target.checked)}
|
onChange={(e) => this.setState({ useScheduledTime: e.target.checked })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -396,7 +403,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
label={t('ruleEditor.triggerAt')}
|
label={t('ruleEditor.triggerAt')}
|
||||||
type="time"
|
type="time"
|
||||||
value={scheduledTime}
|
value={scheduledTime}
|
||||||
onChange={(e) => setScheduledTime(e.target.value)}
|
onChange={(e) => this.setState({ scheduledTime: e.target.value })}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ width: 150 }}
|
sx={{ width: 150 }}
|
||||||
@@ -408,7 +415,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
</Typography>
|
</Typography>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={scheduledDays}
|
value={scheduledDays}
|
||||||
onChange={handleScheduledDaysChange}
|
onChange={this.handleScheduledDaysChange}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
@@ -433,13 +440,13 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Time Range Trigger (active within window) */}
|
{/* Time Range Trigger */}
|
||||||
<Paper sx={{ p: 2, mb: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
<Paper sx={{ p: 2, mb: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={useTimeRange}
|
checked={useTimeRange}
|
||||||
onChange={(e) => setUseTimeRange(e.target.checked)}
|
onChange={(e) => this.setState({ useTimeRange: e.target.checked })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -453,7 +460,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
label="From"
|
label="From"
|
||||||
type="time"
|
type="time"
|
||||||
value={timeStart}
|
value={timeStart}
|
||||||
onChange={(e) => setTimeStart(e.target.value)}
|
onChange={(e) => this.setState({ timeStart: e.target.value })}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -463,7 +470,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
label="Until"
|
label="Until"
|
||||||
type="time"
|
type="time"
|
||||||
value={timeEnd}
|
value={timeEnd}
|
||||||
onChange={(e) => setTimeEnd(e.target.value)}
|
onChange={(e) => this.setState({ timeEnd: e.target.value })}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -475,7 +482,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
</Typography>
|
</Typography>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={timeRangeDays}
|
value={timeRangeDays}
|
||||||
onChange={handleTimeRangeDaysChange}
|
onChange={this.handleTimeRangeDaysChange}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
@@ -506,7 +513,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={useSensors}
|
checked={useSensors}
|
||||||
onChange={(e) => setUseSensors(e.target.checked)}
|
onChange={(e) => this.setState({ useSensors: e.target.checked })}
|
||||||
disabled={saving || sensors.length === 0}
|
disabled={saving || sensors.length === 0}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -525,7 +532,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={sensorLogic}
|
value={sensorLogic}
|
||||||
exclusive
|
exclusive
|
||||||
onChange={(e, v) => v && setSensorLogic(v)}
|
onChange={(e, v) => v && this.setState({ sensorLogic: v })}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
@@ -556,8 +563,8 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
<SensorCondition
|
<SensorCondition
|
||||||
condition={cond}
|
condition={cond}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
onChange={(newCond) => updateSensorCondition(i, newCond)}
|
onChange={(newCond) => this.updateSensorCondition(i, newCond)}
|
||||||
onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
|
onRemove={sensorConditions.length > 1 ? () => this.removeSensorCondition(i) : null}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -565,7 +572,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={addSensorCondition}
|
onClick={this.addSensorCondition}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
sx={{ alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
@@ -588,7 +595,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
<Select
|
<Select
|
||||||
value={actionType}
|
value={actionType}
|
||||||
label="Action Type"
|
label="Action Type"
|
||||||
onChange={(e) => setActionType(e.target.value)}
|
onChange={(e) => this.setState({ actionType: e.target.value })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
<MenuItem value="toggle">🔛 Toggle On/Off</MenuItem>
|
<MenuItem value="toggle">🔛 Toggle On/Off</MenuItem>
|
||||||
@@ -601,7 +608,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
<Select
|
<Select
|
||||||
value={target}
|
value={target}
|
||||||
label="Target Output"
|
label="Target Output"
|
||||||
onChange={(e) => setTarget(e.target.value)}
|
onChange={(e) => this.setState({ target: e.target.value })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{outputs.map(o => (
|
{outputs.map(o => (
|
||||||
@@ -614,12 +621,11 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
{actionType === 'toggle' && (
|
{actionType === 'toggle' && (
|
||||||
<Box>
|
<Box>
|
||||||
{isBinaryOutput ? (
|
{isBinaryOutput ? (
|
||||||
// Binary output: On/Off switch
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={toggleState}
|
checked={toggleState}
|
||||||
onChange={(e) => setToggleState(e.target.checked)}
|
onChange={(e) => this.setState({ toggleState: e.target.checked })}
|
||||||
color="primary"
|
color="primary"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
@@ -627,14 +633,13 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
label={toggleState ? 'Turn ON' : 'Turn OFF'}
|
label={toggleState ? 'Turn ON' : 'Turn OFF'}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Level output: 1-10 slider
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Set Level: {outputLevel}
|
Set Level: {outputLevel}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Slider
|
<Slider
|
||||||
value={outputLevel}
|
value={outputLevel}
|
||||||
onChange={(e, val) => setOutputLevel(val)}
|
onChange={(e, val) => this.setState({ outputLevel: val })}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -655,14 +660,13 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
{actionType === 'keepOn' && (
|
{actionType === 'keepOn' && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{!isBinaryOutput && (
|
{!isBinaryOutput && (
|
||||||
// Level for port outputs
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Set Level: {outputLevel}
|
Set Level: {outputLevel}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Slider
|
<Slider
|
||||||
value={outputLevel}
|
value={outputLevel}
|
||||||
onChange={(e, val) => setOutputLevel(val)}
|
onChange={(e, val) => this.setState({ outputLevel: val })}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -682,7 +686,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Slider
|
<Slider
|
||||||
value={duration}
|
value={duration}
|
||||||
onChange={(e, val) => setDuration(val)}
|
onChange={(e, val) => this.setState({ duration: val })}
|
||||||
min={1}
|
min={1}
|
||||||
max={120}
|
max={120}
|
||||||
marks={[
|
marks={[
|
||||||
@@ -706,7 +710,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
{t('ruleEditor.cancel')}
|
{t('ruleEditor.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={this.handleSave}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!isValid || saving}
|
disabled={!isValid || saving}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -729,3 +733,6 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(RuleEditor);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
} 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 { useI18n } from './I18nContext';
|
import { withI18n } from './I18nContext';
|
||||||
|
import { withDevices } from './DevicesContext';
|
||||||
|
|
||||||
// 8 color tags
|
// 8 color tags
|
||||||
const COLOR_TAGS = [
|
const COLOR_TAGS = [
|
||||||
@@ -27,227 +28,177 @@ const COLOR_TAGS = [
|
|||||||
{ id: 'gray', label: 'Gray', color: '#928374' }
|
{ id: 'gray', label: 'Gray', color: '#928374' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function RuleManager() {
|
class RuleManager extends Component {
|
||||||
const { isAdmin } = useAuth();
|
constructor(props) {
|
||||||
const { t } = useI18n();
|
super(props);
|
||||||
const [rules, setRules] = useState([]);
|
this.state = {
|
||||||
const [activeRuleIds, setActiveRuleIds] = useState([]);
|
rules: [],
|
||||||
const [loading, setLoading] = useState(true);
|
activeRuleIds: [],
|
||||||
const [error, setError] = useState(null);
|
loading: true,
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
error: null,
|
||||||
const [editingRule, setEditingRule] = useState(null);
|
editorOpen: false,
|
||||||
const [devices, setDevices] = useState([]);
|
editingRule: null,
|
||||||
const [saving, setSaving] = useState(false);
|
saving: false,
|
||||||
const [filterTag, setFilterTag] = useState(null); // null = show all
|
filterTag: null
|
||||||
|
};
|
||||||
|
this.activeRulesInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Get auth token from localStorage
|
componentDidMount() {
|
||||||
const getAuthHeaders = useCallback(() => {
|
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);
|
|
||||||
}
|
}
|
||||||
}, [setRules, setLoading, setError]);
|
};
|
||||||
|
|
||||||
// Fetch devices for sensor/output selection
|
fetchActiveRules = async () => {
|
||||||
const fetchDevices = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('api/devices');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setDevices(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch devices:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchActiveRules = useCallback(async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('api/rules/active');
|
const res = await fetch('api/rules/active');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setActiveRuleIds(data); // "ids" is now a list of objects {id, level, state}
|
this.setState({ activeRuleIds: data });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch active rules:', err);
|
console.error('Failed to fetch active rules:', err);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
getAvailableSensors = () => {
|
||||||
fetchRules();
|
const { devicesCtx: { getAvailableSensors, getAvailableOutputs } } = this.props;
|
||||||
fetchDevices();
|
const sensors = getAvailableSensors();
|
||||||
fetchActiveRules();
|
const outputs = getAvailableOutputs();
|
||||||
const interval = setInterval(fetchActiveRules, 5000); // 5s poll
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchRules, fetchDevices, fetchActiveRules]);
|
|
||||||
|
|
||||||
// Build available sensors
|
|
||||||
// - Environment sensors (Temp, Humidity) are per DEVICE
|
|
||||||
// - Port values (Fan Speed, Brightness, CO2, etc.) are per PORT
|
|
||||||
const availableSensors = [];
|
|
||||||
const seenDevices = new Set();
|
|
||||||
|
|
||||||
devices.forEach(d => {
|
|
||||||
// Add environment sensors once per device (temp, humidity)
|
|
||||||
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 0-10, Brightness 0-10, 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 + ALL device ports + 4 virtual channels
|
|
||||||
const availableOutputs = [
|
|
||||||
// Tapo smart plugs
|
|
||||||
{ 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' },
|
|
||||||
// All device ports as outputs
|
|
||||||
...devices.map(d => ({
|
|
||||||
id: `${d.dev_name}:${d.port}:out`,
|
|
||||||
label: `${d.dev_name} - ${d.port_name}`,
|
|
||||||
type: d.port_name.toLowerCase()
|
|
||||||
})),
|
|
||||||
// 4 virtual channels
|
|
||||||
{ 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' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add Tapo and virtual channels as sensors (on/off state)
|
// Add Tapo and virtual channels as sensors (on/off state)
|
||||||
[...availableOutputs.filter(o => o.type === 'plug' || o.type === 'virtual')].forEach(o => {
|
outputs.filter(o => o.type === 'plug' || o.type === 'virtual').forEach(o => {
|
||||||
availableSensors.push({
|
sensors.push({
|
||||||
id: `${o.id}:state`,
|
id: `${o.id}:state`,
|
||||||
label: `${o.label} (State)`,
|
label: `${o.label} (State)`,
|
||||||
type: 'output-state'
|
type: 'output-state'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAddRule = () => {
|
return sensors;
|
||||||
setEditingRule(null);
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditRule = (rule) => {
|
handleAddRule = () => {
|
||||||
setEditingRule(rule);
|
this.setState({ editingRule: null, editorOpen: true });
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRule = async (ruleId) => {
|
handleEditRule = (rule) => {
|
||||||
|
this.setState({ editingRule: rule, editorOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDeleteRule = async (ruleId) => {
|
||||||
|
const { i18n: { t } } = this.props;
|
||||||
if (!confirm(t('rules.deleteConfirm'))) return;
|
if (!confirm(t('rules.deleteConfirm'))) return;
|
||||||
|
|
||||||
setSaving(true);
|
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 - preserve enabled state
|
|
||||||
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, enabled: editingRule.enabled })
|
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Move rule up or down
|
handleMoveRule = async (ruleId, direction) => {
|
||||||
const handleMoveRule = async (ruleId, direction) => {
|
const { rules } = this.state;
|
||||||
const idx = rules.findIndex(r => r.id === ruleId);
|
const idx = rules.findIndex(r => r.id === ruleId);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
if (direction === 'up' && idx === 0) return;
|
if (direction === 'up' && idx === 0) return;
|
||||||
@@ -256,22 +207,24 @@ export default function RuleManager() {
|
|||||||
const newRules = [...rules];
|
const newRules = [...rules];
|
||||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||||
[newRules[idx], newRules[swapIdx]] = [newRules[swapIdx], newRules[idx]];
|
[newRules[idx], newRules[swapIdx]] = [newRules[swapIdx], newRules[idx]];
|
||||||
setRules(newRules);
|
this.setState({ rules: newRules });
|
||||||
|
|
||||||
// Save new order to server
|
|
||||||
try {
|
try {
|
||||||
const ruleIds = newRules.map(r => r.id);
|
const ruleIds = newRules.map(r => r.id);
|
||||||
await fetch('api/rules/reorder', {
|
await fetch('api/rules/reorder', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
body: JSON.stringify({ ruleIds })
|
body: JSON.stringify({ ruleIds })
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to save order');
|
this.setState({ error: 'Failed to save order' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter rules by color tag
|
render() {
|
||||||
|
const { auth: { isAdmin }, i18n: { t }, devicesCtx: { getAvailableOutputs } } = this.props;
|
||||||
|
const { rules, activeRuleIds, loading, error, editorOpen, editingRule, saving, filterTag } = this.state;
|
||||||
|
|
||||||
const filteredRules = filterTag
|
const filteredRules = filterTag
|
||||||
? rules.filter(r => (r.colorTags || []).includes(filterTag))
|
? rules.filter(r => (r.colorTags || []).includes(filterTag))
|
||||||
: rules;
|
: rules;
|
||||||
@@ -306,7 +259,7 @@ export default function RuleManager() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleAddRule}
|
onClick={this.handleAddRule}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
sx={{
|
sx={{
|
||||||
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
||||||
@@ -328,7 +281,7 @@ export default function RuleManager() {
|
|||||||
<Chip
|
<Chip
|
||||||
label="All"
|
label="All"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setFilterTag(null)}
|
onClick={() => this.setState({ filterTag: null })}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
|
bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
|
||||||
color: filterTag === null ? '#282828' : '#ebdbb2'
|
color: filterTag === null ? '#282828' : '#ebdbb2'
|
||||||
@@ -338,7 +291,7 @@ export default function RuleManager() {
|
|||||||
<Chip
|
<Chip
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setFilterTag(filterTag === tag.id ? null : tag.id)}
|
onClick={() => this.setState({ filterTag: filterTag === tag.id ? null : tag.id })}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: filterTag === tag.id ? tag.color : '#504945',
|
bgcolor: filterTag === tag.id ? tag.color : '#504945',
|
||||||
color: filterTag === tag.id ? '#282828' : tag.color,
|
color: filterTag === tag.id ? '#282828' : tag.color,
|
||||||
@@ -350,7 +303,7 @@ export default function RuleManager() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => this.setState({ error: null })}>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -370,11 +323,11 @@ export default function RuleManager() {
|
|||||||
<RuleCard
|
<RuleCard
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
rule={rule}
|
rule={rule}
|
||||||
onEdit={isAdmin ? () => handleEditRule(rule) : null}
|
onEdit={isAdmin ? () => this.handleEditRule(rule) : null}
|
||||||
onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
|
onDelete={isAdmin ? () => this.handleDeleteRule(rule.id) : null}
|
||||||
onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
|
onToggle={isAdmin ? () => this.handleToggleRule(rule.id) : null}
|
||||||
onMoveUp={isAdmin && idx > 0 ? () => handleMoveRule(rule.id, 'up') : null}
|
onMoveUp={isAdmin && idx > 0 ? () => this.handleMoveRule(rule.id, 'up') : null}
|
||||||
onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => handleMoveRule(rule.id, 'down') : null}
|
onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => this.handleMoveRule(rule.id, 'down') : null}
|
||||||
colorTags={COLOR_TAGS}
|
colorTags={COLOR_TAGS}
|
||||||
readOnly={!isAdmin}
|
readOnly={!isAdmin}
|
||||||
activeInfo={activeRuleIds.find(r => r.id === rule.id)}
|
activeInfo={activeRuleIds.find(r => r.id === rule.id)}
|
||||||
@@ -387,10 +340,10 @@ export default function RuleManager() {
|
|||||||
<RuleEditor
|
<RuleEditor
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
rule={editingRule}
|
rule={editingRule}
|
||||||
onSave={handleSaveRule}
|
onSave={this.handleSaveRule}
|
||||||
onClose={handleCloseEditor}
|
onClose={this.handleCloseEditor}
|
||||||
sensors={availableSensors}
|
sensors={this.getAvailableSensors()}
|
||||||
outputs={availableOutputs}
|
outputs={getAvailableOutputs()}
|
||||||
colorTags={COLOR_TAGS}
|
colorTags={COLOR_TAGS}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
/>
|
/>
|
||||||
@@ -398,3 +351,6 @@ export default function RuleManager() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDevices(withAuth(withI18n(RuleManager)));
|
||||||
|
|||||||
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);
|
||||||
Reference in New Issue
Block a user