This commit is contained in:
sebseb7
2025-12-23 05:58:03 +01:00
parent 84c47e3357
commit 5f4c4a55d8
22 changed files with 2935 additions and 2468 deletions

14
dist/index.html vendored Normal file
View 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
View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { Component } from 'react';
import { import {
Card, Card,
CardContent, CardContent,
@@ -10,126 +10,132 @@ 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';
}; };
const hasTags = alarm.colorTags && alarm.colorTags.length > 0; render() {
const { alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, readOnly } = this.props;
return ( // Parse trigger/action data to display summary
<Card const trigger = alarm.trigger || {};
sx={{ const action = alarm.action || {};
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
color: '#ebdbb2',
border: '1px solid #504945',
position: 'relative',
transition: 'transform 0.2s',
'&:hover': {
transform: readOnly ? 'none' : 'translateY(-2px)',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
}
}}
>
<CardContent sx={{ pb: '16px !important', display: 'flex', alignItems: 'center', gap: 2 }}>
{/* Drag Handle / Sort indicators */} const hasTags = alarm.colorTags && alarm.colorTags.length > 0;
{!readOnly && (onMoveUp || onMoveDown) && (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<IconButton size="small" onClick={onMoveUp} disabled={!onMoveUp} sx={{ p: 0.5, color: '#a89984' }}>
</IconButton>
<IconButton size="small" onClick={onMoveDown} disabled={!onMoveDown} sx={{ p: 0.5, color: '#a89984' }}>
</IconButton>
</Box>
)}
{/* Enabled Switch */} return (
<Switch <Card
checked={!!alarm.enabled} sx={{
onChange={onToggle} background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
disabled={readOnly} color: '#ebdbb2',
color="success" border: '1px solid #504945',
/> position: 'relative',
transition: 'transform 0.2s',
'&:hover': {
transform: readOnly ? 'none' : 'translateY(-2px)',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
}
}}
>
<CardContent sx={{ pb: '16px !important', display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ flexGrow: 1 }}> {/* Drag Handle / Sort indicators */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}> {!readOnly && (onMoveUp || onMoveDown) && (
<Typography variant="h6" sx={{ fontWeight: 600 }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
{alarm.name} <IconButton size="small" onClick={onMoveUp} disabled={!onMoveUp} sx={{ p: 0.5, color: '#a89984' }}>
</IconButton>
<IconButton size="small" onClick={onMoveDown} disabled={!onMoveDown} sx={{ p: 0.5, color: '#a89984' }}>
</IconButton>
</Box>
)}
{/* Enabled Switch */}
<Switch
checked={!!alarm.enabled}
onChange={onToggle}
disabled={readOnly}
color="success"
/>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{alarm.name}
</Typography>
{/* Tags */}
{hasTags && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{alarm.colorTags.map(tagId => (
<Box
key={tagId}
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: this.getTagColor(tagId),
border: '1px solid #282828'
}}
title={tagId}
/>
))}
</Box>
)}
{action.severity && (
<Chip
label={action.severity.toUpperCase()}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 'bold',
bgcolor: action.severity === 'critical' ? '#fb4934' :
action.severity === 'warning' ? '#fe8019' : '#83a598',
color: '#282828'
}}
/>
)}
</Box>
<Typography variant="body2" color="text.secondary">
{/* Trigger Summary */}
{trigger.scheduledTime ? (
<span> {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')})</span>
) : trigger.timeRange ? (
<span> {trigger.timeRange.start}-{trigger.timeRange.end}</span>
) : trigger.sensors ? (
<span>
📊 {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')}
</span>
) : (
<span>Unknown Trigger</span>
)}
<span style={{ margin: '0 8px', opacity: 0.5 }}></span>
<span>🔔 Telegram: "{action.message || 'Alert'}"</span>
</Typography> </Typography>
{/* Tags */}
{hasTags && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{alarm.colorTags.map(tagId => (
<Box
key={tagId}
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: getTagColor(tagId),
border: '1px solid #282828'
}}
title={tagId}
/>
))}
</Box>
)}
{action.severity && (
<Chip
label={action.severity.toUpperCase()}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 'bold',
bgcolor: action.severity === 'critical' ? '#fb4934' :
action.severity === 'warning' ? '#fe8019' : '#83a598',
color: '#282828'
}}
/>
)}
</Box> </Box>
<Typography variant="body2" color="text.secondary"> {/* Actions */}
{/* Trigger Summary */} {!readOnly && (
{trigger.scheduledTime ? ( <Box sx={{ display: 'flex', gap: 1 }}>
<span> {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')})</span> <IconButton onClick={onEdit} size="small" sx={{ color: '#8ec07c' }}>
) : trigger.timeRange ? (
<span> {trigger.timeRange.start}-{trigger.timeRange.end}</span> </IconButton>
) : trigger.sensors ? ( <IconButton onClick={onDelete} size="small" sx={{ color: '#fb4934' }}>
<span> 🗑
📊 {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')} </IconButton>
</span> </Box>
) : ( )}
<span>Unknown Trigger</span> </CardContent>
)} </Card>
<span style={{ margin: '0 8px', opacity: 0.5 }}></span> );
<span>🔔 Telegram: "{action.message || 'Alert'}"</span> }
</Typography>
</Box>
{/* Actions */}
{!readOnly && (
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton onClick={onEdit} size="small" sx={{ color: '#8ec07c' }}>
</IconButton>
<IconButton onClick={onDelete} size="small" sx={{ color: '#fb4934' }}>
🗑
</IconButton>
</Box>
)}
</CardContent>
</Card>
);
} }
export default AlarmCard;

View File

@@ -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,177 +36,170 @@ const OPERATORS = [
{ value: '==', label: '=' } { value: '==', label: '=' }
]; ];
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) { class SensorCondition extends Component {
return ( render() {
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}> const { condition, sensors, onChange, onRemove, disabled } = this.props;
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Sensor</InputLabel>
<Select
value={condition.sensor || ''}
label="Sensor"
onChange={(e) => {
const newSensor = sensors.find(s => s.id === e.target.value);
onChange({ ...condition, sensor: e.target.value, sensorLabel: newSensor?.label });
}}
disabled={disabled}
>
{sensors.map(s => (
<MenuItem key={s.id} value={s.id}>{s.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 70 }}> return (
<Select <Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
value={condition.operator || '>'} <FormControl size="small" sx={{ minWidth: 180 }}>
onChange={(e) => onChange({ ...condition, operator: e.target.value })} <InputLabel>Sensor</InputLabel>
disabled={disabled} <Select
> value={condition.sensor || ''}
{OPERATORS.map(op => ( label="Sensor"
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem> onChange={(e) => {
))} const newSensor = sensors.find(s => s.id === e.target.value);
</Select> onChange({ ...condition, sensor: e.target.value, sensorLabel: newSensor?.label });
</FormControl> }}
<TextField disabled={disabled}
size="small" >
type="number" {sensors.map(s => (
value={condition.value ?? ''} <MenuItem key={s.id} value={s.id}>{s.label}</MenuItem>
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })} ))}
sx={{ width: 80 }} </Select>
disabled={disabled} </FormControl>
/>
{onRemove && ( <FormControl size="small" sx={{ minWidth: 70 }}>
<IconButton size="small" onClick={onRemove} disabled={disabled}> <Select
value={condition.operator || '>'}
</IconButton> onChange={(e) => onChange({ ...condition, operator: e.target.value })}
)} disabled={disabled}
</Paper> >
); {OPERATORS.map(op => (
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="number"
value={condition.value ?? ''}
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })}
sx={{ width: 80 }}
disabled={disabled}
/>
{onRemove && (
<IconButton size="small" onClick={onRemove} disabled={disabled}>
</IconButton>
)}
</Paper>
);
}
} }
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 || '',
} else { severity: alarm.action?.severity || 'warning'
setTriggerMode('sensor'); });
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
}
const action = alarm.action || {};
setMessage(action.message || '');
setSeverity(action.severity || 'warning');
} else { } else {
setName(''); this.setState({
setSelectedTags([]); name: '',
setTriggerMode('sensor'); selectedTags: [],
setOutputTarget(''); triggerMode: 'sensor',
setOutputState('on'); outputTarget: '',
setUseTimeRange(false); outputState: 'on',
setTimeStart('08:00'); useTimeRange: false,
setTimeEnd('18:00'); timeStart: '08:00',
setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']); timeEnd: '18:00',
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]); timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
setSensorLogic('and'); sensorConditions: [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
setMessage(''); sensorLogic: 'and',
setSeverity('warning'); message: '',
} severity: '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) => {
const updated = [...sensorConditions];
updated[index] = newCondition;
setSensorConditions(updated);
};
const removeSensorCondition = (index) => {
if (sensorConditions.length > 1) {
setSensorConditions(sensorConditions.filter((_, i) => i !== index));
} }
}; };
const handleSave = () => { addSensorCondition = () => {
const { sensors } = this.props;
this.setState(prev => ({
sensorConditions: [...prev.sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]
}));
};
updateSensorCondition = (index, newCondition) => {
this.setState(prev => {
const updated = [...prev.sensorConditions];
updated[index] = newCondition;
return { sensorConditions: updated };
});
};
removeSensorCondition = (index) => {
this.setState(prev => {
if (prev.sensorConditions.length > 1) {
return { sensorConditions: prev.sensorConditions.filter((_, i) => i !== index) };
}
return prev;
});
};
handleSave = () => {
const { onSave, sensors } = this.props;
const { name, selectedTags, triggerMode, outputTarget, outputState,
useTimeRange, timeStart, timeEnd, timeRangeDays,
sensorConditions, sensorLogic, message, severity } = this.state;
const trigger = {}; const trigger = {};
// Always require sensors for an Alarm (otherwise it's just a time-based notification, which is valid too)
// Let's assume user wants to monitor something.
if (triggerMode === 'output') { if (triggerMode === 'output') {
trigger.outputChange = { trigger.outputChange = {
target: outputTarget, target: outputTarget,
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,220 +219,229 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
onSave({ name, trigger, action, colorTags: selectedTags }); onSave({ name, trigger, action, colorTags: selectedTags });
}; };
const isValid = name.trim().length > 0 && render() {
((triggerMode === 'sensor' && sensorConditions.every(c => c.sensor)) || const { open, alarm, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving, i18n: { t } } = this.props;
(triggerMode === 'output' && outputTarget)) && const { name, selectedTags, triggerMode, outputTarget, outputState,
message.trim().length > 0; useTimeRange, timeStart, timeEnd, timeRangeDays,
sensorConditions, sensorLogic, message, severity } = this.state;
return ( const isValid = name.trim().length > 0 &&
<Dialog ((triggerMode === 'sensor' && sensorConditions.every(c => c.sensor)) ||
open={open} (triggerMode === 'output' && outputTarget)) &&
onClose={onClose} message.trim().length > 0;
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
border: '1px solid #504945'
}
}}
>
<DialogTitle>
{alarm ? 'Edit Alarm' : 'Create Alarm'}
</DialogTitle>
<DialogContent dividers> return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}> <Dialog
<TextField open={open}
label="Alarm Name" onClose={onClose}
value={name} maxWidth="md"
onChange={(e) => setName(e.target.value)} fullWidth
fullWidth PaperProps={{
disabled={saving} sx: {
/> background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
border: '1px solid #504945'
}
}}
>
<DialogTitle>
{alarm ? 'Edit Alarm' : 'Create Alarm'}
</DialogTitle>
{/* Tags */} <DialogContent dividers>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
<Typography variant="body2" color="text.secondary">Tags:</Typography> <TextField
{availableColorTags.map(tag => ( label="Alarm Name"
<Box value={name}
key={tag.id} onChange={(e) => this.setState({ name: e.target.value })}
onClick={() => { fullWidth
if (selectedTags.includes(tag.id)) { disabled={saving}
setSelectedTags(selectedTags.filter(t => t !== tag.id)); />
} else {
setSelectedTags([...selectedTags, tag.id]); {/* Tags */}
} <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
}} <Typography variant="body2" color="text.secondary">Tags:</Typography>
sx={{ {availableColorTags.map(tag => (
width: 24, height: 24, borderRadius: '50%', <Box
bgcolor: tag.color, cursor: 'pointer', key={tag.id}
border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent', onClick={() => {
display: 'flex', alignItems: 'center', justifyContent: 'center' if (selectedTags.includes(tag.id)) {
}} this.setState({ selectedTags: selectedTags.filter(t => t !== tag.id) });
} else {
this.setState({ selectedTags: [...selectedTags, tag.id] });
}
}}
sx={{
width: 24, height: 24, borderRadius: '50%',
bgcolor: tag.color, cursor: 'pointer',
border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}
>
{selectedTags.includes(tag.id) && <span style={{ fontSize: '0.7rem' }}></span>}
</Box>
))}
</Box>
{/* TRIGGER */}
<Typography variant="subtitle2" color="text.secondary">TRIGGER CONDITIONS</Typography>
<Divider />
{/* Trigger Mode Selection */}
<FormControl fullWidth>
<InputLabel>Trigger Type</InputLabel>
<Select
value={triggerMode}
label="Trigger Type"
onChange={(e) => this.setState({ triggerMode: e.target.value })}
> >
{selectedTags.includes(tag.id) && <span style={{ fontSize: '0.7rem' }}></span>} <MenuItem value="sensor">📊 Sensor Value Threshold</MenuItem>
</Box> <MenuItem value="output">🔌 Output Turn ON/OFF</MenuItem>
))} </Select>
</Box> </FormControl>
{/* TRIGGER */} {triggerMode === 'output' ? (
<Typography variant="subtitle2" color="text.secondary">TRIGGER CONDITIONS</Typography> <Paper sx={{ p: 2, bgcolor: 'action.selected', display: 'flex', flexDirection: 'column', gap: 2 }}>
<Divider /> <Alert severity="info" icon={false} sx={{ py: 0 }}>
Trigger when a filtered rule changes a device state.
{/* Trigger Mode Selection */} </Alert>
<FormControl fullWidth> <Box sx={{ display: 'flex', gap: 2 }}>
<InputLabel>Trigger Type</InputLabel> <FormControl fullWidth>
<Select <InputLabel>Target Output</InputLabel>
value={triggerMode} <Select
label="Trigger Type" value={outputTarget}
onChange={(e) => setTriggerMode(e.target.value)} label="Target Output"
> onChange={(e) => this.setState({ outputTarget: e.target.value })}
<MenuItem value="sensor">📊 Sensor Value Threshold</MenuItem> >
<MenuItem value="output">🔌 Output Turn ON/OFF</MenuItem> <MenuItem value="any">Any Output</MenuItem>
</Select> {outputs.map(o => (
</FormControl> <MenuItem key={o.id} value={o.id}>{o.label}</MenuItem>
))}
{triggerMode === 'output' ? ( </Select>
<Paper sx={{ p: 2, bgcolor: 'action.selected', display: 'flex', flexDirection: 'column', gap: 2 }}> </FormControl>
<Alert severity="info" icon={false} sx={{ py: 0 }}> <FormControl sx={{ minWidth: 120 }}>
Trigger when a filtered rule changes a device state. <InputLabel>State</InputLabel>
</Alert> <Select
<Box sx={{ display: 'flex', gap: 2 }}> value={outputState}
<FormControl fullWidth> label="State"
<InputLabel>Target Output</InputLabel> onChange={(e) => this.setState({ outputState: e.target.value })}
<Select >
value={outputTarget} <MenuItem value="on">Turns ON</MenuItem>
label="Target Output" <MenuItem value="off">Turns OFF</MenuItem>
onChange={(e) => setOutputTarget(e.target.value)} </Select>
> </FormControl>
<MenuItem value="any">Any Output</MenuItem>
{outputs.map(o => (
<MenuItem key={o.id} value={o.id}>{o.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 120 }}>
<InputLabel>State</InputLabel>
<Select
value={outputState}
label="State"
onChange={(e) => setOutputState(e.target.value)}
>
<MenuItem value="on">Turns ON</MenuItem>
<MenuItem value="off">Turns OFF</MenuItem>
</Select>
</FormControl>
</Box>
</Paper>
) : (
<>
{/* Time Window */}
<Paper sx={{ p: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
<Switch checked={useTimeRange} onChange={(e) => setUseTimeRange(e.target.checked)} disabled={saving} />
}
label="Active Time Window (Optional)"
/>
{useTimeRange && (
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="From" type="time"
value={timeStart} onChange={(e) => setTimeStart(e.target.value)}
InputLabelProps={{ shrink: true }} size="small"
/>
<Typography>to</Typography>
<TextField
label="Until" type="time"
value={timeEnd} onChange={(e) => setTimeEnd(e.target.value)}
InputLabelProps={{ shrink: true }} size="small"
/>
</Box>
)}
</Paper>
{/* Sensors */}
<Paper sx={{ p: 2, bgcolor: 'action.selected' }}>
<Typography gutterBottom fontWeight="bold">📊 Sensor Thresholds</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{sensorConditions.length > 1 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">Logic:</Typography>
<ToggleButtonGroup
value={sensorLogic} exclusive
onChange={(e, v) => v && setSensorLogic(v)} size="small"
>
<ToggleButton value="and">AND</ToggleButton>
<ToggleButton value="or">OR</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{sensorConditions.map((cond, i) => (
<SensorCondition
key={i}
condition={cond}
sensors={sensors}
onChange={(newCond) => updateSensorCondition(i, newCond)}
onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
disabled={saving}
/>
))}
<Button size="small" onClick={addSensorCondition} disabled={saving} sx={{ alignSelf: 'flex-start' }}>
+ Add Condition
</Button>
</Box> </Box>
</Paper> </Paper>
</> ) : (
)} <>
{/* Time Window */}
<Paper sx={{ p: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
<Switch checked={useTimeRange} onChange={(e) => this.setState({ useTimeRange: e.target.checked })} disabled={saving} />
}
label="Active Time Window (Optional)"
/>
{useTimeRange && (
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="From" type="time"
value={timeStart} onChange={(e) => this.setState({ timeStart: e.target.value })}
InputLabelProps={{ shrink: true }} size="small"
/>
<Typography>to</Typography>
<TextField
label="Until" type="time"
value={timeEnd} onChange={(e) => this.setState({ timeEnd: e.target.value })}
InputLabelProps={{ shrink: true }} size="small"
/>
</Box>
)}
</Paper>
{/* ACTION */} {/* Sensors */}
<Typography variant="subtitle2" color="text.secondary">ACTION (Telelegram Notification)</Typography> <Paper sx={{ p: 2, bgcolor: 'action.selected' }}>
<Divider /> <Typography gutterBottom fontWeight="bold">📊 Sensor Thresholds</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{sensorConditions.length > 1 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">Logic:</Typography>
<ToggleButtonGroup
value={sensorLogic} exclusive
onChange={(e, v) => v && this.setState({ sensorLogic: v })} size="small"
>
<ToggleButton value="and">AND</ToggleButton>
<ToggleButton value="or">OR</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
<FormControl fullWidth> {sensorConditions.map((cond, i) => (
<InputLabel>Severity</InputLabel> <SensorCondition
<Select key={i}
value={severity} condition={cond}
label="Severity" sensors={sensors}
onChange={(e) => setSeverity(e.target.value)} onChange={(newCond) => this.updateSensorCondition(i, newCond)}
> onRemove={sensorConditions.length > 1 ? () => this.removeSensorCondition(i) : null}
<MenuItem value="info"> Info</MenuItem> disabled={saving}
<MenuItem value="warning"> Warning</MenuItem> />
<MenuItem value="critical">🔥 Critical</MenuItem> ))}
</Select> <Button size="small" onClick={this.addSensorCondition} disabled={saving} sx={{ alignSelf: 'flex-start' }}>
</FormControl> + Add Condition
</Button>
</Box>
</Paper>
</>
)}
<TextField {/* ACTION */}
label="Notification Message" <Typography variant="subtitle2" color="text.secondary">ACTION (Telelegram Notification)</Typography>
value={message} <Divider />
onChange={(e) => setMessage(e.target.value)}
fullWidth
multiline
rows={2}
placeholder="e.g. Temperature is too high!"
disabled={saving}
/>
<Alert severity="info" icon={false} sx={{ bgcolor: 'rgba(2, 136, 209, 0.1)' }}> <FormControl fullWidth>
This message will be sent to all users who have linked their Telegram ID in their profile. <InputLabel>Severity</InputLabel>
</Alert> <Select
value={severity}
label="Severity"
onChange={(e) => this.setState({ severity: e.target.value })}
>
<MenuItem value="info"> Info</MenuItem>
<MenuItem value="warning"> Warning</MenuItem>
<MenuItem value="critical">🔥 Critical</MenuItem>
</Select>
</FormControl>
</Box> <TextField
</DialogContent> label="Notification Message"
value={message}
onChange={(e) => this.setState({ message: e.target.value })}
fullWidth
multiline
rows={2}
placeholder="e.g. Temperature is too high!"
disabled={saving}
/>
<DialogActions sx={{ px: 3, py: 2 }}> <Alert severity="info" icon={false} sx={{ bgcolor: 'rgba(2, 136, 209, 0.1)' }}>
<Button onClick={onClose} color="inherit" disabled={saving}>Cancel</Button> This message will be sent to all users who have linked their Telegram ID in their profile.
<Button </Alert>
onClick={handleSave}
variant="contained" </Box>
disabled={!isValid || saving} </DialogContent>
sx={{ background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)' }}
> <DialogActions sx={{ px: 3, py: 2 }}>
{saving ? <CircularProgress size={20} /> : (alarm ? 'Save Changes' : 'Create Alarm')} <Button onClick={onClose} color="inherit" disabled={saving}>Cancel</Button>
</Button> <Button
</DialogActions> onClick={this.handleSave}
</Dialog> variant="contained"
); disabled={!isValid || saving}
sx={{ background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)' }}
>
{saving ? <CircularProgress size={20} /> : (alarm ? 'Save Changes' : 'Create Alarm')}
</Button>
</DialogActions>
</Dialog>
);
}
} }
export default withI18n(AlarmEditor);

View File

@@ -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,117 +159,124 @@ 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' });
} }
}; };
const filteredAlarms = filterTag render() {
? alarms.filter(a => (a.colorTags || []).includes(filterTag)) const { auth: { isAdmin }, i18n: { t }, devicesCtx: { getAvailableSensors, getAvailableOutputs } } = this.props;
: alarms; const { alarms, loading, error, editorOpen, editingAlarm, saving, filterTag } = this.state;
if (loading) { const filteredAlarms = filterTag
return <Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}><CircularProgress /></Paper>; ? alarms.filter(a => (a.colorTags || []).includes(filterTag))
} : alarms;
return ( if (loading) {
<Paper return <Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}><CircularProgress /></Paper>;
sx={{ }
mt: 4, ml: 4, mb: 4,
p: 3, return (
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)', // Distinct background? Or same? <Paper
border: '1px solid #504945' sx={{
}} mt: 4, ml: 4, mb: 4,
> p: 3,
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
<Box> border: '1px solid #504945'
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}> }}
🚨 {t('alarms.title') || 'Alarms'} >
</Typography> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="body2" color="text.secondary"> <Box>
{isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'} <Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
</Typography> 🚨 {t('alarms.title') || 'Alarms'}
</Typography>
<Typography variant="body2" color="text.secondary">
{isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'}
</Typography>
</Box>
{isAdmin && (
<Button
variant="contained"
onClick={this.handleAddAlarm}
disabled={saving}
sx={{
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)',
'&:hover': { background: 'linear-gradient(45deg, #e396a5 30%, #ff9029 90%)' }
}}
>
Add Alarm
</Button>
)}
</Box> </Box>
{isAdmin && (
<Button
variant="contained"
onClick={handleAddAlarm}
disabled={saving}
sx={{
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)',
'&:hover': { background: 'linear-gradient(45deg, #e396a5 30%, #ff9029 90%)' }
}}
>
Add Alarm
</Button>
)}
</Box>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<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
label="All" size="small" onClick={() => setFilterTag(null)}
sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
/>
{COLOR_TAGS.map(tag => (
<Chip <Chip
key={tag.id} size="small" onClick={() => setFilterTag(filterTag === tag.id ? null : tag.id)} label="All" size="small" onClick={() => this.setState({ filterTag: null })}
sx={{ sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
bgcolor: filterTag === tag.id ? tag.color : '#504945',
color: filterTag === tag.id ? '#282828' : tag.color,
border: `2px solid ${tag.color}`,
'&:hover': { bgcolor: tag.color, color: '#282828' }
}}
/> />
))} {COLOR_TAGS.map(tag => (
</Box> <Chip
key={tag.id} size="small" onClick={() => this.setState({ filterTag: filterTag === tag.id ? null : tag.id })}
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>} sx={{
bgcolor: filterTag === tag.id ? tag.color : '#504945',
{filteredAlarms.length === 0 ? ( color: filterTag === tag.id ? '#282828' : tag.color,
<Box sx={{ textAlign: 'center', py: 4 }}> border: `2px solid ${tag.color}`,
<Typography color="text.secondary">No alarms found.</Typography> '&:hover': { bgcolor: tag.color, color: '#282828' }
</Box> }}
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{filteredAlarms.map((alarm, idx) => (
<AlarmCard
key={alarm.id}
alarm={alarm}
onEdit={isAdmin ? () => handleEditAlarm(alarm) : null}
onDelete={isAdmin ? () => handleDeleteAlarm(alarm.id) : null}
onToggle={isAdmin ? () => handleToggleAlarm(alarm.id) : null}
onMoveUp={isAdmin && idx > 0 ? () => handleMoveAlarm(alarm.id, 'up') : null}
onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => handleMoveAlarm(alarm.id, 'down') : null}
colorTags={COLOR_TAGS}
readOnly={!isAdmin}
/> />
))} ))}
</Box> </Box>
)}
{isAdmin && ( {error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => this.setState({ error: null })}>{error}</Alert>}
<AlarmEditor
open={editorOpen} {filteredAlarms.length === 0 ? (
alarm={editingAlarm} <Box sx={{ textAlign: 'center', py: 4 }}>
onSave={handleSaveAlarm} <Typography color="text.secondary">No alarms found.</Typography>
onClose={() => { setEditorOpen(false); setEditingAlarm(null); }} </Box>
sensors={availableSensors} ) : (
outputs={availableOutputs} <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
colorTags={COLOR_TAGS} {filteredAlarms.map((alarm, idx) => (
saving={saving} <AlarmCard
/> key={alarm.id}
)} alarm={alarm}
</Paper> onEdit={isAdmin ? () => this.handleEditAlarm(alarm) : null}
); onDelete={isAdmin ? () => this.handleDeleteAlarm(alarm.id) : null}
onToggle={isAdmin ? () => this.handleToggleAlarm(alarm.id) : null}
onMoveUp={isAdmin && idx > 0 ? () => this.handleMoveAlarm(alarm.id, 'up') : null}
onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => this.handleMoveAlarm(alarm.id, 'down') : null}
colorTags={COLOR_TAGS}
readOnly={!isAdmin}
/>
))}
</Box>
)}
{isAdmin && (
<AlarmEditor
open={editorOpen}
alarm={editingAlarm}
onSave={this.handleSaveAlarm}
onClose={() => { this.setState({ editorOpen: false, editingAlarm: null }); }}
sensors={getAvailableSensors()}
outputs={getAvailableOutputs()}
colorTags={COLOR_TAGS}
saving={saving}
/>
)}
</Paper>
);
}
} }
export default withDevices(withAuth(withI18n(AlarmManager)));

View File

@@ -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,107 +47,121 @@ 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
};
}
return ( render() {
<Box sx={{ flexGrow: 1 }}> const { auth: { user, logout, isAuthenticated, isAdmin }, i18n: { t } } = this.props;
<AppBar position="static"> const { showLogin, showProfile } = this.state;
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> return (
{t('app.title')} <Box sx={{ flexGrow: 1 }}>
</Typography> <AppBar position="static">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Toolbar>
<LanguageSwitcher /> <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
{isAuthenticated ? ( {t('app.title')}
<> </Typography>
<Chip <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
label={user.username} <LanguageSwitcher />
onClick={() => setShowProfile(true)} {isAuthenticated ? (
color={isAdmin ? 'secondary' : 'default'} <>
size="small"
sx={{
fontWeight: 600,
cursor: 'pointer',
'&:hover': { opacity: 0.8 },
...(isAdmin && {
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
})
}}
/>
{isAdmin && (
<Chip <Chip
label={t('app.admin')} label={user.username}
onClick={() => this.setState({ showProfile: true })}
color={isAdmin ? 'secondary' : 'default'}
size="small" size="small"
sx={{ sx={{
bgcolor: gruvboxDark.purple, fontWeight: 600,
color: gruvboxDark.bg0, cursor: 'pointer',
fontWeight: 700 '&:hover': { opacity: 0.8 },
...(isAdmin && {
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
})
}} }}
/> />
)} {isAdmin && (
<Chip
label={t('app.admin')}
size="small"
sx={{
bgcolor: gruvboxDark.purple,
color: gruvboxDark.bg0,
fontWeight: 700
}}
/>
)}
<Button
color="inherit"
onClick={logout}
size="small"
>
{t('app.logout')}
</Button>
</>
) : (
<Button <Button
color="inherit" color="inherit"
onClick={logout} onClick={() => this.setState({ showLogin: true })}
variant="outlined"
size="small" size="small"
sx={{ borderColor: gruvboxDark.aqua }}
> >
{t('app.logout')} {t('app.adminLogin')}
</Button> </Button>
</> )}
) : ( </Box>
<Button </Toolbar>
color="inherit" </AppBar>
onClick={() => setShowLogin(true)} <Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
variant="outlined" {/* Dashboard is always visible to everyone */}
size="small" <Dashboard />
sx={{ borderColor: gruvboxDark.aqua }}
>
{t('app.adminLogin')}
</Button>
)}
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
{/* Dashboard is always visible to everyone */}
<Dashboard />
{/* Rule Manager visible to everyone (guests read-only, admins can edit) */} {/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
<RuleManager /> <RuleManager />
{/* Alarm Manager visible to everyone (guests read-only, admins can edit) */} {/* Alarm Manager visible to everyone (guests read-only, admins can edit) */}
<AlarmManager /> <AlarmManager />
</Container> </Container>
{/* 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));
return (
<ThemeProvider theme={darkTheme}> class App extends Component {
<CssBaseline /> render() {
<I18nProvider> return (
<AuthProvider> <ThemeProvider theme={darkTheme}>
<AppContent /> <CssBaseline />
</AuthProvider> <I18nProvider>
</I18nProvider> <AuthProvider>
</ThemeProvider> <DevicesProvider>
); <WrappedAppContent />
</DevicesProvider>
</AuthProvider>
</I18nProvider>
</ThemeProvider>
);
}
} }
export default App; export default App;

View File

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

View File

@@ -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,55 +64,55 @@ export default function ControllerCard({ controllerName, ports, range }) {
} }
}; };
// Initial Fetch & Auto-Refresh render() {
useEffect(() => { const { controllerName, ports, range, i18n: { t } } = this.props;
fetchData(); const { envData, portData } = this.state;
const interval = setInterval(fetchData, 60000);
return () => clearInterval(interval);
}, [controllerName, range]); // Depend on range, controllerName changes rarely
return ( return (
<Card sx={{ mb: 4, borderRadius: 2, boxShadow: 3 }}> <Card sx={{ mb: 4, borderRadius: 2, boxShadow: 3 }}>
<CardHeader <CardHeader
title={controllerName} title={controllerName}
titleTypographyProps={{ variant: 'h5', fontWeight: 'bold', color: 'primary.main' }} titleTypographyProps={{ variant: 'h5', fontWeight: 'bold', color: 'primary.main' }}
sx={{ bgcolor: 'background.paper', borderLeft: '6px solid', borderLeftColor: 'primary.main' }} sx={{ bgcolor: 'background.paper', borderLeft: '6px solid', borderLeftColor: 'primary.main' }}
/> />
<CardContent> <CardContent>
{/* Environment Chart */} {/* Environment Chart */}
<Box sx={{ height: 350, mb: 6 }}> <Box sx={{ height: 350, mb: 6 }}>
<Typography variant="h6" color="text.secondary" gutterBottom> <Typography variant="h6" color="text.secondary" gutterBottom>
{t('controller.environment')} {t('controller.environment')}
</Typography> </Typography>
<EnvChart data={envData} range={range} /> <EnvChart data={envData} range={range} />
</Box> </Box>
<Divider sx={{ mt: 2, mb: 3 }} /> <Divider sx={{ mt: 2, mb: 3 }} />
{/* Port Grid */} {/* Port Grid */}
<Grid container spacing={3}> <Grid container spacing={3}>
{ports.map((port) => { {ports.map((port) => {
const isLight = port.port_name && port.port_name.toLowerCase().includes('light'); const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2'); const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2');
const pData = portData[port.port] || []; const pData = portData[port.port] || [];
return ( return (
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={port.port}> <Grid size={{ xs: 12, md: 6, lg: 4 }} key={port.port}>
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}> <Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
{port.port_name || `${t('controller.port')} ${port.port}`} {port.port_name || `${t('controller.port')} ${port.port}`}
</Typography> </Typography>
<Box sx={{ height: 250 }}> <Box sx={{ height: 250 }}>
<LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} /> <LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} />
</Box> </Box>
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
); );
})} })}
</Grid> </Grid>
</CardContent> </CardContent>
</Card> </Card>
); );
}
} }
export default withI18n(ControllerCard);

View File

@@ -1,92 +1,67 @@
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 () => { setRange = (range) => {
try { this.setState({ range });
// 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'); render() {
if (!res.ok) throw new Error('Failed to fetch devices'); const { i18n: { t }, devicesCtx: { devices, groupedDevices, loading, error } } = this.props;
const { range } = this.state;
const devices = await res.json(); if (loading) return <Typography>{t('dashboard.loading')}</Typography>;
if (error) return <Alert severity="error">{error}</Alert>;
// Group by dev_name return (
const grouped = devices.reduce((acc, dev) => { <Box>
if (!acc[dev.dev_name]) acc[dev.dev_name] = []; <Box display="flex" justifyContent="flex-end" mb={3}>
acc[dev.dev_name].push(dev); <ButtonGroup variant="contained" aria-label="outlined primary button group">
return acc; <Button
}, {}); onClick={() => this.setRange('day')}
color={range === 'day' ? 'primary' : 'inherit'}
>
{t('dashboard.hours24')}
</Button>
<Button
onClick={() => this.setRange('week')}
color={range === 'week' ? 'primary' : 'inherit'}
>
{t('dashboard.days7')}
</Button>
<Button
onClick={() => this.setRange('month')}
color={range === 'month' ? 'primary' : 'inherit'}
>
{t('dashboard.days30')}
</Button>
</ButtonGroup>
</Box>
setGroupedDevices(grouped); {Object.entries(groupedDevices).map(([controllerName, ports]) => (
setAllDevices(devices); <ControllerCard
setLoading(false); key={controllerName}
} catch (err) { controllerName={controllerName}
console.error(err); ports={ports}
setError(err.message); range={range}
setLoading(false); />
} ))}
}, []);
useEffect(() => { <OutputChart range={range} devices={devices} />
fetchDevices();
}, [fetchDevices]);
// Auto-refresh logic (basic rerender trigger could be added here,
// but simpler to let ControllerCard handle data fetching internally based on props)
if (loading) return <Typography>{t('dashboard.loading')}</Typography>;
if (error) return <Alert severity="error">{error}</Alert>;
return (
<Box>
<Box display="flex" justifyContent="flex-end" mb={3}>
<ButtonGroup variant="contained" aria-label="outlined primary button group">
<Button
onClick={() => setRange('day')}
color={range === 'day' ? 'primary' : 'inherit'}
>
{t('dashboard.hours24')}
</Button>
<Button
onClick={() => setRange('week')}
color={range === 'week' ? 'primary' : 'inherit'}
>
{t('dashboard.days7')}
</Button>
<Button
onClick={() => setRange('month')}
color={range === 'month' ? 'primary' : 'inherit'}
>
{t('dashboard.days30')}
</Button>
</ButtonGroup>
</Box> </Box>
);
{Object.entries(groupedDevices).map(([controllerName, ports]) => ( }
<ControllerCard
key={controllerName}
controllerName={controllerName}
ports={ports}
range={range}
/>
))}
<OutputChart range={range} devices={allDevices} />
</Box>
);
} }
export default withDevices(withI18n(Dashboard));

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

View File

@@ -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,65 +33,73 @@ export default function EnvChart({ data, range }) {
} }
}; };
const chartData = { render() {
labels: data.map(d => formatDateLabel(d.timestamp)), const { data } = this.props;
datasets: [
{
label: 'Temperature (°C)',
data: data.map(d => d.temp_c),
borderColor: '#ff6384',
backgroundColor: '#ff6384',
yAxisID: 'y',
tension: 0.4,
pointRadius: 0,
borderWidth: 2
},
{
label: 'Humidity (%)',
data: data.map(d => d.humidity),
borderColor: '#36a2eb',
backgroundColor: '#36a2eb',
yAxisID: 'y1',
tension: 0.4,
pointRadius: 0,
borderWidth: 2
},
],
};
const options = { if (!data || data.length === 0) return null;
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 12
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: 'Temp (°C)' },
suggestedMin: 15,
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: 'Humidity (%)' },
suggestedMin: 30,
suggestedMax: 80,
},
},
};
return <Line data={chartData} options={options} />; const chartData = {
labels: data.map(d => this.formatDateLabel(d.timestamp)),
datasets: [
{
label: 'Temperature (°C)',
data: data.map(d => d.temp_c),
borderColor: '#ff6384',
backgroundColor: '#ff6384',
yAxisID: 'y',
tension: 0.4,
pointRadius: 0,
borderWidth: 2
},
{
label: 'Humidity (%)',
data: data.map(d => d.humidity),
borderColor: '#36a2eb',
backgroundColor: '#36a2eb',
yAxisID: 'y1',
tension: 0.4,
pointRadius: 0,
borderWidth: 2
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 12
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: 'Temp (°C)' },
suggestedMin: 15,
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: 'Humidity (%)' },
suggestedMin: 30,
suggestedMax: 80,
},
},
};
return <Line data={chartData} options={options} />;
}
} }
export default EnvChart;

View File

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

View File

@@ -1,42 +1,46 @@
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 }}>
<Tooltip title="English"> <Tooltip title="English">
<IconButton <IconButton
size="small" size="small"
onClick={() => setLanguage('en')} onClick={() => setLanguage('en')}
sx={{ sx={{
opacity: language === 'en' ? 1 : 0.5, opacity: language === 'en' ? 1 : 0.5,
fontSize: '1.2rem', fontSize: '1.2rem',
'&:hover': { opacity: 1 } '&:hover': { opacity: 1 }
}} }}
> >
{FLAG_EN} {FLAG_EN}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Deutsch"> <Tooltip title="Deutsch">
<IconButton <IconButton
size="small" size="small"
onClick={() => setLanguage('de')} onClick={() => setLanguage('de')}
sx={{ sx={{
opacity: language === 'de' ? 1 : 0.5, opacity: language === 'de' ? 1 : 0.5,
fontSize: '1.2rem', fontSize: '1.2rem',
'&:hover': { opacity: 1 } '&:hover': { opacity: 1 }
}} }}
> >
{FLAG_DE} {FLAG_DE}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
); );
}
} }
export default withI18n(LanguageSwitcher);

View File

@@ -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,49 +33,57 @@ export default function LevelChart({ data, isLight, isCO2, range }) {
} }
}; };
// Determine label and color based on sensor type render() {
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed'); const { data, isLight, isCO2 } = this.props;
const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
const chartData = { if (!data || data.length === 0) return null;
labels: data.map(d => formatDateLabel(d.timestamp)),
datasets: [ // Determine label and color based on sensor type
{ const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
label: levelLabel, const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
data: data.map(d => d.fan_speed),
borderColor: levelColor, const chartData = {
backgroundColor: levelColor, labels: data.map(d => this.formatDateLabel(d.timestamp)),
stepped: !isCO2, // CO2 uses smooth lines datasets: [
tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others {
borderWidth: 2, label: levelLabel,
pointRadius: 0 data: data.map(d => d.fan_speed),
borderColor: levelColor,
backgroundColor: levelColor,
stepped: !isCO2, // CO2 uses smooth lines
tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others
borderWidth: 2,
pointRadius: 0
},
],
};
// CO2 needs different Y-axis scale (ppm range)
const yScale = isCO2
? { suggestedMin: 200, suggestedMax: 900 }
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
}, },
], scales: {
}; x: {
ticks: {
// CO2 needs different Y-axis scale (ppm range) maxRotation: 0,
const yScale = isCO2 autoSkip: true,
? { suggestedMin: 200, suggestedMax: 900 } maxTicksLimit: 8
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } }; }
},
const options = { y: yScale
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 8
}
}, },
y: yScale };
},
};
return <Line data={chartData} options={options} />; return <Line data={chartData} options={options} />;
}
} }
export default LevelChart;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { import {
Dialog, Dialog,
DialogTitle, DialogTitle,
@@ -13,140 +13,152 @@ 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();
}; };
return ( render() {
<Dialog const { open, i18n: { t } } = this.props;
open={open} const { username, password, showPassword, error, loading } = this.state;
onClose={handleClose}
maxWidth="xs"
fullWidth
PaperProps={{
sx: {
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
borderRadius: 3,
border: '1px solid #504945'
}
}}
>
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
<Typography variant="h5" component="div" sx={{ fontWeight: 600 }}>
{t('login.title')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t('login.subtitle')}
</Typography>
</DialogTitle>
<form onSubmit={handleSubmit}> return (
<DialogContent> <Dialog
{error && ( open={open}
<Alert severity="error" sx={{ mb: 2 }}> onClose={this.handleClose}
{error} maxWidth="xs"
</Alert> fullWidth
)} PaperProps={{
sx: {
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
borderRadius: 3,
border: '1px solid #504945'
}
}}
>
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
<Typography variant="h5" component="div" sx={{ fontWeight: 600 }}>
{t('login.title')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t('login.subtitle')}
</Typography>
</DialogTitle>
<TextField <form onSubmit={this.handleSubmit}>
autoFocus <DialogContent>
margin="dense" {error && (
label={t('login.username')} <Alert severity="error" sx={{ mb: 2 }}>
type="text" {error}
fullWidth </Alert>
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label={t('login.password')}
type={showPassword ? 'text' : 'password'}
fullWidth
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
size="small"
>
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>
)
}}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
<Button
type="submit"
variant="contained"
fullWidth
size="large"
disabled={loading || !username || !password}
sx={{
py: 1.5,
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
'&:hover': {
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
}
}}
>
{loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={20} color="inherit" />
<span>{t('login.signingIn')}</span>
</Box>
) : (
t('login.signIn')
)} )}
</Button>
</DialogActions> <TextField
</form> autoFocus
</Dialog> margin="dense"
); label={t('login.username')}
type="text"
fullWidth
variant="outlined"
value={username}
onChange={(e) => this.setState({ username: e.target.value })}
disabled={loading}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label={t('login.password')}
type={showPassword ? 'text' : 'password'}
fullWidth
variant="outlined"
value={password}
onChange={(e) => this.setState({ password: e.target.value })}
disabled={loading}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => this.setState({ showPassword: !showPassword })}
edge="end"
size="small"
>
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>
)
}}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
<Button
type="submit"
variant="contained"
fullWidth
size="large"
disabled={loading || !username || !password}
sx={{
py: 1.5,
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
'&:hover': {
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
}
}}
>
{loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={20} color="inherit" />
<span>{t('login.signingIn')}</span>
</Box>
) : (
t('login.signIn')
)}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
} }
export default withAuth(withI18n(LoginDialog));

View File

@@ -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,186 +23,210 @@ 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: [],
useEffect(() => { loading: true
const fetchData = async () => {
try {
const res = await fetch('api/outputs/history');
const logs = await res.json();
setData(logs);
} catch (err) {
console.error('Failed to fetch output history', err);
} finally {
setLoading(false);
}
}; };
fetchData(); this.interval = null;
}
componentDidMount() {
this.fetchData();
// Poll for updates every minute // Poll for updates every minute
const interval = setInterval(fetchData, 60000); this.interval = setInterval(() => this.fetchData(), 60000);
return () => clearInterval(interval); }
}, []);
if (loading) return <CircularProgress size={20} />; componentDidUpdate(prevProps) {
if (!data || data.length === 0) return null; if (prevProps.range !== this.props.range) {
this.fetchData();
// Group data by "Device:Port"
const groupedData = {};
data.forEach(log => {
const key = `${log.dev_name}:${log.port}`;
if (!groupedData[key]) groupedData[key] = [];
groupedData[key].push(log);
});
// Gruvbox Palette
const gruvboxColors = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019', '#928374'];
// Generate datasets
const datasets = Object.keys(groupedData).map((key, index) => {
const logs = groupedData[key];
const color = gruvboxColors[index % gruvboxColors.length];
// Resolve Label
let label = key;
const isTapo = key.includes('tapo') || key.includes('Plug'); // Simple check
if (devices && devices.length > 0) {
const [dName, pNum] = key.split(':');
const portNum = parseInt(pNum);
const device = devices.find(d => d.dev_name === dName && d.port === portNum);
if (device) {
label = `${device.dev_name} - ${device.port_name}`;
}
} }
}
// Fallback friendly name for Tapo if not found in devices list (which comes from readings) componentWillUnmount() {
// Check if key looks like "tapo-xxx:0" if (this.interval) {
if (label === key && key.startsWith('tapo-')) { clearInterval(this.interval);
// "tapo-001:0" -> "Tapo Plug 001"
const parts2 = key.split(':');
const tapoId = parts2[0].replace('tapo-', '');
label = `Tapo Plug ${tapoId}`;
} }
}
return { fetchData = async () => {
label: label, const { range } = this.props;
data: logs.map(d => ({ try {
x: new Date(d.timestamp).getTime(), const res = await fetch(`api/outputs/history?range=${range || 'day'}`);
y: d.state === 0 ? 0 : (d.level || 10) const logs = await res.json();
})), this.setState({ data: logs, loading: false });
borderColor: color, } catch (err) {
backgroundColor: color, console.error('Failed to fetch output history', err);
stepped: true, this.setState({ loading: false });
pointRadius: 0,
borderWidth: 2,
// Custom property to identify binary devices in tooltip
isBinary: isTapo
};
});
// Create a time axis based on the data range
const allTimestamps = [...new Set(data.map(d => d.timestamp))].sort();
// We need to normalize data to these timestamps for "Line" chart
const chartLabels = allTimestamps.map(ts => {
const date = new Date(ts);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
});
// We need to map *each* dataset's data to align with `chartLabels`.
const alignedDatasets = datasets.map((ds, index) => {
const alignedData = [];
let lastValue = 0; // Default off
const offset = index * 0.15; // Small offset to avoid overlap
// Populate alignedData matching `allTimestamps`
allTimestamps.forEach(ts => {
// Find if we have a log at this specific timestamp
const timeMs = new Date(ts).getTime();
const exactLog = ds.data.find(d => Math.abs(d.x - timeMs) < 1000); // 1s tolerance
if (exactLog) {
lastValue = exactLog.y;
}
// Apply offset to the value for visualization
// If value is 0 (OFF), we might still want to offset it?
// Or only if ON? The user said "levels are on top of each other".
// Even OFF lines might overlap. Let's offset everything.
alignedData.push(lastValue + offset);
});
return {
...ds,
data: alignedData
};
});
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 12
}
},
y: {
type: 'linear',
min: 0,
max: 12, // Increased max to accommodate offsets
ticks: {
stepSize: 1,
callback: (val) => {
const rVal = Math.round(val);
if (rVal === 0) return 'OFF';
if (rVal === 10) return 'MAX/ON';
return rVal;
}
}
}
},
plugins: {
legend: {
position: 'top',
labels: { color: '#ebdbb2' }
},
title: {
display: true,
text: 'Output History (Levels & States)',
color: '#ebdbb2'
},
tooltip: {
callbacks: {
label: (context) => {
// Round down to ignore the offset
const val = Math.floor(context.raw);
const ds = context.dataset;
if (val === 0) return `${ds.label}: OFF`;
// If it's binary (Tapo) or max level, show ON
if (val === 10 || ds.isBinary) return `${ds.label}: ON`;
return `${ds.label}: Level ${val}`;
}
}
}
} }
}; };
return ( render() {
<Paper sx={{ p: 2, mt: 3, bgcolor: '#282828', color: '#ebdbb2' }}> const { devices = [] } = this.props;
<Box sx={{ height: 300 }}> const { data, loading } = this.state;
<Line data={{ labels: chartLabels, datasets: alignedDatasets }} options={options} />
</Box> if (loading) return <CircularProgress size={20} />;
</Paper> if (!data || data.length === 0) return null;
);
// Group data by "Device:Port"
const groupedData = {};
data.forEach(log => {
const key = `${log.dev_name}:${log.port}`;
if (!groupedData[key]) groupedData[key] = [];
groupedData[key].push(log);
});
// Gruvbox Palette
const gruvboxColors = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019', '#928374'];
// Generate datasets
const datasets = Object.keys(groupedData).map((key, index) => {
const logs = groupedData[key];
const color = gruvboxColors[index % gruvboxColors.length];
// Resolve Label
let label = key;
const isTapo = key.includes('tapo') || key.includes('Plug'); // Simple check
if (devices && devices.length > 0) {
const [dName, pNum] = key.split(':');
const portNum = parseInt(pNum);
const device = devices.find(d => d.dev_name === dName && d.port === portNum);
if (device) {
label = `${device.dev_name} - ${device.port_name}`;
}
}
// Fallback friendly name for Tapo if not found in devices list (which comes from readings)
// Check if key looks like "tapo-xxx:0"
if (label === key && key.startsWith('tapo-')) {
// "tapo-001:0" -> "Tapo Plug 001"
const parts2 = key.split(':');
const tapoId = parts2[0].replace('tapo-', '');
label = `Tapo Plug ${tapoId}`;
}
return {
label: label,
data: logs.map(d => ({
x: new Date(d.timestamp).getTime(),
y: d.state === 0 ? 0 : (d.level || 10)
})),
borderColor: color,
backgroundColor: color,
stepped: true,
pointRadius: 0,
borderWidth: 2,
// Custom property to identify binary devices in tooltip
isBinary: isTapo
};
});
// Create a time axis based on the data range
const allTimestamps = [...new Set(data.map(d => d.timestamp))].sort();
// We need to normalize data to these timestamps for "Line" chart
const chartLabels = allTimestamps.map(ts => {
const date = new Date(ts);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
});
// We need to map *each* dataset's data to align with `chartLabels`.
const alignedDatasets = datasets.map((ds, index) => {
const alignedData = [];
let lastValue = 0; // Default off
const offset = index * 0.15; // Small offset to avoid overlap
// Populate alignedData matching `allTimestamps`
allTimestamps.forEach(ts => {
// Find if we have a log at this specific timestamp
const timeMs = new Date(ts).getTime();
const exactLog = ds.data.find(d => Math.abs(d.x - timeMs) < 1000); // 1s tolerance
if (exactLog) {
lastValue = exactLog.y;
}
// Apply offset to the value for visualization
// If value is 0 (OFF), we might still want to offset it?
// Or only if ON? The user said "levels are on top of each other".
// Even OFF lines might overlap. Let's offset everything.
alignedData.push(lastValue + offset);
});
return {
...ds,
data: alignedData
};
});
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 12
}
},
y: {
type: 'linear',
min: 0,
max: 12, // Increased max to accommodate offsets
ticks: {
stepSize: 1,
callback: (val) => {
const rVal = Math.round(val);
if (rVal === 0) return 'OFF';
if (rVal === 10) return 'MAX/ON';
return rVal;
}
}
}
},
plugins: {
legend: {
position: 'top',
labels: { color: '#ebdbb2' }
},
title: {
display: true,
text: 'Output History (Levels & States)',
color: '#ebdbb2'
},
tooltip: {
callbacks: {
label: (context) => {
// Round down to ignore the offset
const val = Math.floor(context.raw);
const ds = context.dataset;
if (val === 0) return `${ds.label}: OFF`;
// If it's binary (Tapo) or max level, show ON
if (val === 10 || ds.isBinary) return `${ds.label}: ON`;
return `${ds.label}: Level ${val}`;
}
}
}
}
};
return (
<Paper sx={{ p: 2, mt: 3, bgcolor: '#282828', color: '#ebdbb2' }}>
<Box sx={{ height: 300 }}>
<Line data={{ labels: chartLabels, datasets: alignedDatasets }} options={options} />
</Box>
</Paper>
);
}
} }
export default withI18n(OutputChart);

View File

@@ -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. componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
const [telegramId, setTelegramId] = useState(''); this.fetchProfile();
const [loading, setLoading] = useState(false); this.setState({ success: false, error: null });
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 () => { fetchProfile = async () => {
setLoading(true); 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,56 +74,63 @@ 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 });
} }
}; };
return ( render() {
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> const { open, onClose, auth: { user } } = this.props;
<DialogTitle>User Profile: {user?.username}</DialogTitle> const { telegramId, loading, saving, error, success } = this.state;
<DialogContent>
<Box sx={{ pt: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
Link your Telegram account to receive alarm notifications.
</Typography>
<Alert severity="info" icon={false}> return (
To get your Telegram ID: <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<ol style={{ margin: '8px 0', paddingLeft: 20 }}> <DialogTitle>User Profile: {user?.username}</DialogTitle>
<li>Search for <b>@TischlereiCtrlBot</b> on Telegram</li> <DialogContent>
<li>Start the bot (`/start`)</li> <Box sx={{ pt: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
<li>It will reply with your ID. Copy it here.</li> <Typography variant="body2" color="text.secondary">
</ol> Link your Telegram account to receive alarm notifications.
</Alert> </Typography>
{loading ? ( <Alert severity="info" icon={false}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}><CircularProgress /></Box> To get your Telegram ID:
) : ( <ol style={{ margin: '8px 0', paddingLeft: 20 }}>
<TextField <li>Search for <b>@TischlereiCtrlBot</b> on Telegram</li>
label="Telegram ID" <li>Start the bot (`/start`)</li>
value={telegramId} <li>It will reply with your ID. Copy it here.</li>
onChange={(e) => setTelegramId(e.target.value)} </ol>
fullWidth </Alert>
placeholder="e.g. 123456789"
helperText="Leave empty to disable notifications"
/>
)}
{error && <Alert severity="error">{error}</Alert>} {loading ? (
{success && <Alert severity="success">Profile updated successfully!</Alert>} <Box sx={{ display: 'flex', justifyContent: 'center' }}><CircularProgress /></Box>
</Box> ) : (
</DialogContent> <TextField
<DialogActions> label="Telegram ID"
<Button onClick={onClose} color="inherit">Close</Button> value={telegramId}
<Button onClick={handleSave} variant="contained" disabled={saving || loading}> onChange={(e) => this.setState({ telegramId: e.target.value })}
{saving ? 'Saving...' : 'Save'} fullWidth
</Button> placeholder="e.g. 123456789"
</DialogActions> helperText="Leave empty to disable notifications"
</Dialog> />
); )}
{error && <Alert severity="error">{error}</Alert>}
{success && <Alert severity="success">Profile updated successfully!</Alert>}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="inherit">Close</Button>
<Button onClick={this.handleSave} variant="contained" disabled={saving || loading}>
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
);
}
} }
export default withAuth(ProfileDialog);

View File

@@ -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,254 +16,263 @@ 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 dayLabels = { const { trigger, t } = this.props;
mon: t('days.mon').charAt(0), const dayLabels = {
tue: t('days.tue').charAt(0), mon: t('days.mon').charAt(0),
wed: t('days.wed').charAt(0), tue: t('days.tue').charAt(0),
thu: t('days.thu').charAt(0), wed: t('days.wed').charAt(0),
fri: t('days.fri').charAt(0), thu: t('days.thu').charAt(0),
sat: t('days.sat').charAt(0), fri: t('days.fri').charAt(0),
sun: t('days.sun').charAt(0) sat: t('days.sat').charAt(0),
}; sun: t('days.sun').charAt(0)
const parts = []; };
const parts = [];
// Scheduled time (trigger at exact time) // Scheduled time (trigger at exact time)
if (trigger.scheduledTime) { if (trigger.scheduledTime) {
const { time, days } = trigger.scheduledTime; const { time, days } = trigger.scheduledTime;
const isEveryDay = days?.length === 7; const isEveryDay = days?.length === 7;
const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d)); const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') : let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join(''); dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
parts.push( parts.push(
<Box key="scheduled" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}> <Box key="scheduled" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="🕐" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} /> <Chip label="🕐" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2"> <Typography variant="body2">
{t('ruleCard.at')} <strong>{time}</strong> ({dayText}) {t('ruleCard.at')} <strong>{time}</strong> ({dayText})
</Typography> </Typography>
</Box>
);
}
// Time range (active during window)
if (trigger.timeRange) {
const { start, end, days } = trigger.timeRange;
const isEveryDay = days?.length === 7;
const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
parts.push(
<Box key="time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2">
{start}{end} ({dayText})
</Typography>
</Box>
);
}
// Sensor conditions
if (trigger.sensors && trigger.sensors.length > 0) {
const logic = trigger.sensorLogic || 'and';
const sensorText = trigger.sensors.map((s, i) => (
<span key={i}>
{i > 0 && <Chip label={logic.toUpperCase()} size="small" sx={{ mx: 0.5, bgcolor: logic === 'and' ? '#8ec07c' : '#fabd2f', color: '#282828', fontSize: '0.65rem' }} />}
<strong>{s.sensorLabel || s.sensor}</strong> {s.operator} {s.value}
</span>
));
parts.push(
<Box key="sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2" component="span">
{sensorText}
</Typography>
</Box>
);
}
// Legacy support for old trigger format
if (trigger.type === 'time' && !trigger.timeRange) {
const days = trigger.days || [];
const isEveryDay = days.length === 7;
const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
parts.push(
<Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }} />
<Typography variant="body2">{t('ruleCard.at')} {trigger.time} ({dayText})</Typography>
</Box>
);
}
if (trigger.type === 'sensor' && !trigger.sensors) {
parts.push(
<Box key="legacy-sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600 }} />
<Typography variant="body2">
{trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value}
</Typography>
</Box>
);
}
if (parts.length === 0) return null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{parts}
</Box>
);
}
function ActionSummary({ action }) {
const { t } = useI18n();
if (action.type === 'toggle') {
// Check if it's a level or binary action
const hasLevel = action.level !== undefined;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip
label={hasLevel ? '🎚️' : (action.state ? '🔛' : '🔴')}
size="small"
sx={{ bgcolor: hasLevel ? '#83a598' : (action.state ? '#b8bb26' : '#fb4934'), color: '#282828', fontWeight: 600, minWidth: 32 }}
/>
<Typography variant="body2">
<strong>{action.targetLabel || action.target}</strong> {hasLevel ? `${t('ruleCard.level')} ${action.level}` : (action.state ? t('ruleCard.on') : t('ruleCard.off'))}
</Typography>
</Box>
);
}
if (action.type === 'keepOn') {
const hasLevel = action.level !== undefined;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2">
<strong>{action.targetLabel || action.target}</strong> {hasLevel ? `${t('ruleCard.level')} ${action.level}` : t('ruleCard.on')} {t('ruleCard.forMinutes', { duration: action.duration })}
</Typography>
</Box>
);
}
return null;
}
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)
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
return (
<Paper
sx={{
p: 2,
opacity: rule.enabled ? 1 : 0.6,
transition: 'opacity 0.2s, transform 0.2s',
border: '1px solid', // Standard border
borderColor: rule.enabled ? '#504945' : '#3c3836',
borderLeft: ruleTags.length > 0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945',
boxShadow: activeInfo ? '0 0 0 2px #b8bb26' : 'none', // Active highlight
'&:hover': {
transform: readOnly ? 'none' : 'translateX(4px)',
borderColor: rule.enabled ? '#8ec07c' : '#504945'
}
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
{rule.name}
{/* ACTIVE INDICATOR */}
{activeInfo && (
<Chip
label={`Active: Lvl ${activeInfo.level}`}
size="small"
sx={{
bgcolor: '#b8bb26',
color: '#282828',
fontWeight: 'bold',
height: 20
}}
/>
)}
</Typography>
{ruleTags.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.25 }}>
{ruleTags.map(tag => (
<Box key={tag.id} sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: tag.color,
flexShrink: 0
}} />
))}
</Box>
)}
{!rule.enabled && (
<Chip label={t('ruleCard.disabled')} size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} />
)}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<TriggerSummary trigger={rule.trigger} />
<ActionSummary action={rule.action} />
</Box>
</Box> </Box>
);
}
{!readOnly && ( // Time range (active during window)
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> if (trigger.timeRange) {
{/* Move buttons */} const { start, end, days } = trigger.timeRange;
<Box sx={{ display: 'flex', flexDirection: 'column' }}> const isEveryDay = days?.length === 7;
<Tooltip title="Move up"> const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
<span> let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
<IconButton dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
onClick={onMoveUp}
parts.push(
<Box key="time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2">
{start}{end} ({dayText})
</Typography>
</Box>
);
}
// Sensor conditions
if (trigger.sensors && trigger.sensors.length > 0) {
const logic = trigger.sensorLogic || 'and';
const sensorText = trigger.sensors.map((s, i) => (
<span key={i}>
{i > 0 && <Chip label={logic.toUpperCase()} size="small" sx={{ mx: 0.5, bgcolor: logic === 'and' ? '#8ec07c' : '#fabd2f', color: '#282828', fontSize: '0.65rem' }} />}
<strong>{s.sensorLabel || s.sensor}</strong> {s.operator} {s.value}
</span>
));
parts.push(
<Box key="sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2" component="span">
{sensorText}
</Typography>
</Box>
);
}
// Legacy support for old trigger format
if (trigger.type === 'time' && !trigger.timeRange) {
const days = trigger.days || [];
const isEveryDay = days.length === 7;
const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
parts.push(
<Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }} />
<Typography variant="body2">{t('ruleCard.at')} {trigger.time} ({dayText})</Typography>
</Box>
);
}
if (trigger.type === 'sensor' && !trigger.sensors) {
parts.push(
<Box key="legacy-sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600 }} />
<Typography variant="body2">
{trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value}
</Typography>
</Box>
);
}
if (parts.length === 0) return null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{parts}
</Box>
);
}
}
class ActionSummary extends Component {
render() {
const { action, t } = this.props;
if (action.type === 'toggle') {
// Check if it's a level or binary action
const hasLevel = action.level !== undefined;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip
label={hasLevel ? '🎚️' : (action.state ? '🔛' : '🔴')}
size="small"
sx={{ bgcolor: hasLevel ? '#83a598' : (action.state ? '#b8bb26' : '#fb4934'), color: '#282828', fontWeight: 600, minWidth: 32 }}
/>
<Typography variant="body2">
<strong>{action.targetLabel || action.target}</strong> {hasLevel ? `${t('ruleCard.level')} ${action.level}` : (action.state ? t('ruleCard.on') : t('ruleCard.off'))}
</Typography>
</Box>
);
}
if (action.type === 'keepOn') {
const hasLevel = action.level !== undefined;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2">
<strong>{action.targetLabel || action.target}</strong> {hasLevel ? `${t('ruleCard.level')} ${action.level}` : t('ruleCard.on')} {t('ruleCard.forMinutes', { duration: action.duration })}
</Typography>
</Box>
);
}
return null;
}
}
class RuleCard extends Component {
render() {
const { rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly, activeInfo, i18n: { t } } = this.props;
// Get list of tag colors for this rule (handle array or backwards-compat single value)
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
return (
<Paper
sx={{
p: 2,
opacity: rule.enabled ? 1 : 0.6,
transition: 'opacity 0.2s, transform 0.2s',
border: '1px solid', // Standard border
borderColor: rule.enabled ? '#504945' : '#3c3836',
borderLeft: ruleTags.length > 0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945',
boxShadow: activeInfo ? '0 0 0 2px #b8bb26' : 'none', // Active highlight
'&:hover': {
transform: readOnly ? 'none' : 'translateX(4px)',
borderColor: rule.enabled ? '#8ec07c' : '#504945'
}
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
{rule.name}
{/* ACTIVE INDICATOR */}
{activeInfo && (
<Chip
label={`Active: Lvl ${activeInfo.level}`}
size="small" size="small"
disabled={!onMoveUp} sx={{
sx={{ p: 0.25 }} bgcolor: '#b8bb26',
> color: '#282828',
fontWeight: 'bold',
</IconButton> height: 20
</span> }}
/>
)}
</Typography>
{ruleTags.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.25 }}>
{ruleTags.map(tag => (
<Box key={tag.id} sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: tag.color,
flexShrink: 0
}} />
))}
</Box>
)}
{!rule.enabled && (
<Chip label={t('ruleCard.disabled')} size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} />
)}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<TriggerSummary trigger={rule.trigger} t={t} />
<ActionSummary action={rule.action} t={t} />
</Box>
</Box>
{!readOnly && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{/* Move buttons */}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Tooltip title="Move up">
<span>
<IconButton
onClick={onMoveUp}
size="small"
disabled={!onMoveUp}
sx={{ p: 0.25 }}
>
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<span>
<IconButton
onClick={onMoveDown}
size="small"
disabled={!onMoveDown}
sx={{ p: 0.25 }}
>
</IconButton>
</span>
</Tooltip>
</Box>
<Tooltip title={rule.enabled ? t('ruleCard.disable') : t('ruleCard.enable')}>
<Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" />
</Tooltip> </Tooltip>
<Tooltip title="Move down"> <Tooltip title={t('ruleCard.edit')}>
<span> <IconButton onClick={onEdit} size="small"><EditIcon /></IconButton>
<IconButton </Tooltip>
onClick={onMoveDown} <Tooltip title={t('ruleCard.delete')}>
size="small" <IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
disabled={!onMoveDown} <DeleteIcon />
sx={{ p: 0.25 }} </IconButton>
>
</IconButton>
</span>
</Tooltip> </Tooltip>
</Box> </Box>
<Tooltip title={rule.enabled ? t('ruleCard.disable') : t('ruleCard.enable')}> )}
<Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" /> </Box>
</Tooltip> </Paper>
<Tooltip title={t('ruleCard.edit')}> );
<IconButton onClick={onEdit} size="small"><EditIcon /></IconButton> }
</Tooltip>
<Tooltip title={t('ruleCard.delete')}>
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
</Paper>
);
} }
export default withI18n(RuleCard);

File diff suppressed because it is too large Load Diff

View File

@@ -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(() => {
fetchRules();
fetchDevices();
fetchActiveRules();
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)
[...availableOutputs.filter(o => o.type === 'plug' || o.type === 'virtual')].forEach(o => {
availableSensors.push({
id: `${o.id}:state`,
label: `${o.label} (State)`,
type: 'output-state'
});
});
const handleAddRule = () => {
setEditingRule(null);
setEditorOpen(true);
}; };
const handleEditRule = (rule) => { getAvailableSensors = () => {
setEditingRule(rule); const { devicesCtx: { getAvailableSensors, getAvailableOutputs } } = this.props;
setEditorOpen(true); const sensors = getAvailableSensors();
const outputs = getAvailableOutputs();
// Add Tapo and virtual channels as sensors (on/off state)
outputs.filter(o => o.type === 'plug' || o.type === 'virtual').forEach(o => {
sensors.push({
id: `${o.id}:state`,
label: `${o.label} (State)`,
type: 'output-state'
});
});
return sensors;
}; };
const handleDeleteRule = async (ruleId) => { handleAddRule = () => {
this.setState({ editingRule: null, editorOpen: true });
};
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,145 +207,150 @@ 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 filteredRules = filterTag const { auth: { isAdmin }, i18n: { t }, devicesCtx: { getAvailableOutputs } } = this.props;
? rules.filter(r => (r.colorTags || []).includes(filterTag)) const { rules, activeRuleIds, loading, error, editorOpen, editingRule, saving, filterTag } = this.state;
: rules;
const filteredRules = filterTag
? rules.filter(r => (r.colorTags || []).includes(filterTag))
: rules;
if (loading) {
return (
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
<CircularProgress size={24} />
<Typography sx={{ mt: 2 }}>{t('rules.loading')}</Typography>
</Paper>
);
}
if (loading) {
return ( return (
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}> <Paper
<CircularProgress size={24} /> sx={{
<Typography sx={{ mt: 2 }}>{t('rules.loading')}</Typography> mt: 4,
</Paper> p: 3,
); background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
} border: '1px solid #504945'
}}
return ( >
<Paper <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
sx={{ <Box>
mt: 4, <Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
p: 3, {t('rules.title')}
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)', </Typography>
border: '1px solid #504945' <Typography variant="body2" color="text.secondary">
}} {isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')}
> </Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> </Box>
<Box> {isAdmin && (
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}> <Button
{t('rules.title')} variant="contained"
</Typography> onClick={this.handleAddRule}
<Typography variant="body2" color="text.secondary"> disabled={saving}
{isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')} sx={{
</Typography> background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
'&:hover': {
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
}
}}
>
{t('rules.addRule')}
</Button>
)}
</Box> </Box>
{isAdmin && (
<Button
variant="contained"
onClick={handleAddRule}
disabled={saving}
sx={{
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
'&:hover': {
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
}
}}
>
{t('rules.addRule')}
</Button>
)}
</Box>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
{/* Color tag filter */} {/* Color tag filter */}
<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
label="All"
size="small"
onClick={() => setFilterTag(null)}
sx={{
bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
color: filterTag === null ? '#282828' : '#ebdbb2'
}}
/>
{COLOR_TAGS.map(tag => (
<Chip <Chip
key={tag.id} label="All"
size="small" size="small"
onClick={() => setFilterTag(filterTag === tag.id ? null : tag.id)} onClick={() => this.setState({ filterTag: null })}
sx={{ sx={{
bgcolor: filterTag === tag.id ? tag.color : '#504945', bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
color: filterTag === tag.id ? '#282828' : tag.color, color: filterTag === null ? '#282828' : '#ebdbb2'
border: `2px solid ${tag.color}`,
'&:hover': { bgcolor: tag.color, color: '#282828' }
}} }}
/> />
))} {COLOR_TAGS.map(tag => (
</Box> <Chip
key={tag.id}
{error && ( size="small"
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> onClick={() => this.setState({ filterTag: filterTag === tag.id ? null : tag.id })}
{error} sx={{
</Alert> bgcolor: filterTag === tag.id ? tag.color : '#504945',
)} color: filterTag === tag.id ? '#282828' : tag.color,
border: `2px solid ${tag.color}`,
{filteredRules.length === 0 ? ( '&:hover': { bgcolor: tag.color, color: '#282828' }
<Box sx={{ textAlign: 'center', py: 4 }}> }}
<Typography color="text.secondary">
{rules.length === 0
? (isAdmin ? t('rules.noRules') + ' ' + t('rules.noRulesAdmin') : t('rules.noRules'))
: 'No rules match the selected filter.'
}
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{filteredRules.map((rule, idx) => (
<RuleCard
key={rule.id}
rule={rule}
onEdit={isAdmin ? () => handleEditRule(rule) : null}
onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
onMoveUp={isAdmin && idx > 0 ? () => handleMoveRule(rule.id, 'up') : null}
onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => handleMoveRule(rule.id, 'down') : null}
colorTags={COLOR_TAGS}
readOnly={!isAdmin}
activeInfo={activeRuleIds.find(r => r.id === rule.id)}
/> />
))} ))}
</Box> </Box>
)}
{isAdmin && ( {error && (
<RuleEditor <Alert severity="error" sx={{ mb: 2 }} onClose={() => this.setState({ error: null })}>
open={editorOpen} {error}
rule={editingRule} </Alert>
onSave={handleSaveRule} )}
onClose={handleCloseEditor}
sensors={availableSensors} {filteredRules.length === 0 ? (
outputs={availableOutputs} <Box sx={{ textAlign: 'center', py: 4 }}>
colorTags={COLOR_TAGS} <Typography color="text.secondary">
saving={saving} {rules.length === 0
/> ? (isAdmin ? t('rules.noRules') + ' ' + t('rules.noRulesAdmin') : t('rules.noRules'))
)} : 'No rules match the selected filter.'
</Paper> }
); </Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{filteredRules.map((rule, idx) => (
<RuleCard
key={rule.id}
rule={rule}
onEdit={isAdmin ? () => this.handleEditRule(rule) : null}
onDelete={isAdmin ? () => this.handleDeleteRule(rule.id) : null}
onToggle={isAdmin ? () => this.handleToggleRule(rule.id) : null}
onMoveUp={isAdmin && idx > 0 ? () => this.handleMoveRule(rule.id, 'up') : null}
onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => this.handleMoveRule(rule.id, 'down') : null}
colorTags={COLOR_TAGS}
readOnly={!isAdmin}
activeInfo={activeRuleIds.find(r => r.id === rule.id)}
/>
))}
</Box>
)}
{isAdmin && (
<RuleEditor
open={editorOpen}
rule={editingRule}
onSave={this.handleSaveRule}
onClose={this.handleCloseEditor}
sensors={this.getAvailableSensors()}
outputs={getAvailableOutputs()}
colorTags={COLOR_TAGS}
saving={saving}
/>
)}
</Paper>
);
}
} }
export default withDevices(withAuth(withI18n(RuleManager)));

9
tapotest.js Normal file
View 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);