Compare commits
63 Commits
3efed93cbb
...
feat-tools
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce879aa44 | ||
|
|
ee478e52ef | ||
|
|
924c2b5367 | ||
|
|
69012029ad | ||
|
|
5433529ffd | ||
|
|
e43c330594 | ||
|
|
974cbe93cd | ||
|
|
c8668920a6 | ||
|
|
9e6bb26a2c | ||
|
|
ba0dbe6baf | ||
|
|
b6c2a7e904 | ||
|
|
ef62bb5da0 | ||
|
|
981f498eb7 | ||
|
|
5912c00a82 | ||
|
|
edd7226966 | ||
|
|
c0be21a718 | ||
|
|
370f911063 | ||
|
|
b4182c5c94 | ||
|
|
3094c06467 | ||
|
|
c9402de2e4 | ||
|
|
bf66845528 | ||
|
|
137c6131c3 | ||
|
|
a34048ae6b | ||
|
|
81e6e1960d | ||
|
|
59f7135b61 | ||
|
|
d17c78f42a | ||
|
|
ea4af13840 | ||
|
|
a19c6988f4 | ||
|
|
2bbc5e0320 | ||
|
|
b6b1ed7a2b | ||
|
|
a79ab2af38 | ||
|
|
d79c12b6e9 | ||
|
|
c478f5c13c | ||
|
|
c597d4a414 | ||
|
|
82310a521f | ||
|
|
73675ddfff | ||
|
|
40dd392696 | ||
|
|
32de4b1441 | ||
|
|
8fe514ed14 | ||
|
|
8b5937fa19 | ||
|
|
ccd6143253 | ||
|
|
acb6b39dcf | ||
|
|
1044837080 | ||
|
|
0ae64d0c5b | ||
|
|
3032dbcc96 | ||
|
|
e13bbe3d02 | ||
|
|
063fccfaea | ||
|
|
e003770187 | ||
|
|
1ef9160361 | ||
|
|
dcf1c47328 | ||
|
|
7d61d6361c | ||
|
|
1f161270ef | ||
|
|
c3bc6dabc0 | ||
|
|
14c29b1434 | ||
|
|
473eb9015f | ||
|
|
f785fbedca | ||
|
|
e65f2ba3a0 | ||
|
|
98eefcacdc | ||
|
|
a07abbfeea | ||
|
|
07673586c2 | ||
|
|
dd19b33f35 | ||
|
|
3cd5a84cc1 | ||
|
|
0f511c2cf9 |
@@ -0,0 +1,4 @@
|
|||||||
|
DOMAIN=
|
||||||
|
|
||||||
|
#production= mebboat.it
|
||||||
|
#development= localhost
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,3 +17,7 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
**/tsconfig.tsbuildinfo
|
**/tsconfig.tsbuildinfo
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
.claude/
|
||||||
203
api/package-lock.json
generated
203
api/package-lock.json
generated
@@ -10,12 +10,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.35.0",
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||||
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.20.0"
|
"pg": "^8.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -40,6 +42,15 @@
|
|||||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpack/msgpack": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -53,6 +64,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/async": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
@@ -113,6 +130,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -160,6 +194,51 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 0.8"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^2.2.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/concat-stream/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/concat-stream/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||||
@@ -219,6 +298,12 @@
|
|||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -653,6 +738,12 @@
|
|||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/jsonwebtoken": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
@@ -811,6 +902,15 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minio": {
|
"node_modules/minio": {
|
||||||
"version": "8.0.7",
|
"version": "8.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
|
||||||
@@ -865,12 +965,86 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mkdirp": {
|
||||||
|
"version": "0.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "1.4.5-lts.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||||
|
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||||
|
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.0.0",
|
||||||
|
"concat-stream": "^1.5.2",
|
||||||
|
"mkdirp": "^0.5.4",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"type-is": "^1.6.4",
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
@@ -880,6 +1054,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -1075,6 +1258,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -1414,6 +1603,14 @@
|
|||||||
"stream-chain": "^2.2.5"
|
"stream-chain": "^2.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strict-uri-encode": {
|
"node_modules/strict-uri-encode": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||||
@@ -1476,6 +1673,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -11,12 +11,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.35.0",
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||||
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.20.0"
|
"pg": "^8.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const parser = require('cookie-parser');
|
const parser = require('cookie-parser');
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
|
const { requireAuth } = require('./middlewares/auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT;
|
const PORT = process.env.PORT;
|
||||||
@@ -12,6 +13,21 @@ const vState = process.env.VERSION_STATE;
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(parser());
|
app.use(parser());
|
||||||
|
|
||||||
|
// CORS per permettere chiamate cross-origin dalla console
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
// Accetta origini nella whitelist, oppure tutte in dev
|
||||||
|
if (allowed.length === 0 || allowed.includes(origin)) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key');
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.redirect('/health');
|
res.redirect('/health');
|
||||||
});
|
});
|
||||||
@@ -23,6 +39,8 @@ app.get('/health', async (req, res) => {
|
|||||||
|
|
||||||
const allOk = Object.values(postgres).every(s => s === 'connected') && influx && minio;
|
const allOk = Object.values(postgres).every(s => s === 'connected') && influx && minio;
|
||||||
|
|
||||||
|
console.log("Health check results:", { postgres, influx: influx ? 'connected' : 'disconnected', minio: minio ? 'connected' : 'disconnected' });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: allOk ? "ok" : "degraded",
|
status: allOk ? "ok" : "degraded",
|
||||||
service: "api",
|
service: "api",
|
||||||
@@ -39,33 +57,14 @@ app.get('/health', async (req, res) => {
|
|||||||
const paramsSensorRoutes = require('./routes/params.sensor');
|
const paramsSensorRoutes = require('./routes/params.sensor');
|
||||||
app.use('/params/sensor', paramsSensorRoutes);
|
app.use('/params/sensor', paramsSensorRoutes);
|
||||||
|
|
||||||
// Middleware di autenticazione per le API
|
const kioskSensorRoutes = require('./routes/kiosk.sensor');
|
||||||
app.use((req, res, next) => {
|
app.use('/kiosk/sensor', kioskSensorRoutes);
|
||||||
if (req.path === '/health' || req.path === '/') return next();
|
|
||||||
|
|
||||||
// 1. Service-to-service: x-api-key header
|
const kioskPublicRoutes = require('./routes/kiosk.public');
|
||||||
const apiKey = req.headers['x-api-key'];
|
app.use('/kiosk', kioskPublicRoutes);
|
||||||
if (apiKey && apiKey === process.env.INTERNAL_API_KEY) {
|
|
||||||
req.internal = true;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. User auth: cookie o Authorization header
|
// Middleware di autenticazione per tutte le API protette
|
||||||
const token = req.cookies?.auth_token
|
app.use(requireAuth);
|
||||||
|| (req.headers.authorization?.startsWith('Bearer ') && req.headers.authorization.slice(7));
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({ error: 'Unauthorized: Nessun token di autenticazione fornito' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
||||||
req.user = payload;
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(401).json({ error: 'Unauthorized: Token non valido o scaduto' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataRoutes = require('./routes/data');
|
const dataRoutes = require('./routes/data');
|
||||||
app.use('/data', dataRoutes);
|
app.use('/data', dataRoutes);
|
||||||
@@ -76,7 +75,33 @@ app.use('/storage', storageRoutes)
|
|||||||
const paramsRoutes = require('./routes/params')
|
const paramsRoutes = require('./routes/params')
|
||||||
app.use('/params', paramsRoutes)
|
app.use('/params', paramsRoutes)
|
||||||
|
|
||||||
// Avvio del server
|
const settingsRoutes = require('./routes/settings')
|
||||||
|
app.use('/settings', settingsRoutes)
|
||||||
|
|
||||||
|
const sessionsRoutes = require('./routes/sessions')
|
||||||
|
app.use('/sessions', sessionsRoutes)
|
||||||
|
|
||||||
|
const docsRoutes = require('./routes/docs')
|
||||||
|
app.use('/docs', docsRoutes)
|
||||||
|
|
||||||
|
const marineDatasetsRoutes = require('./routes/marine.datasets')
|
||||||
|
app.use('/marine/datasets', marineDatasetsRoutes)
|
||||||
|
|
||||||
|
const jobsRoutes = require('./routes/jobs')
|
||||||
|
app.use('/jobs', jobsRoutes)
|
||||||
|
|
||||||
|
const queueRoutes = require('./routes/queue')
|
||||||
|
app.use('/queue', queueRoutes)
|
||||||
|
|
||||||
|
const pageconnectionsRoutes = require('./routes/pageconnections')
|
||||||
|
app.use('/pageconnections', pageconnectionsRoutes)
|
||||||
|
|
||||||
|
const kioskRoutes = require('./routes/kiosk')
|
||||||
|
app.use('/kiosk', kioskRoutes)
|
||||||
|
|
||||||
|
const rulesRoutes = require('./routes/rules')
|
||||||
|
app.use('/rules', rulesRoutes)
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Started on port ${PORT}`);
|
console.log(`Started on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
70
api/src/middlewares/auth.js
Normal file
70
api/src/middlewares/auth.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Middleware di autenticazione per API REST.
|
||||||
|
* Supporta tre modalità:
|
||||||
|
* - x-api-key (service-to-service, INTERNAL_API_KEY)
|
||||||
|
* - cookie auth_token (utenti loggati dal browser, SSO via .mebboat.it)
|
||||||
|
* - Authorization: Bearer <jwt>
|
||||||
|
*
|
||||||
|
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET e verificato localmente.
|
||||||
|
* Il cookie è condiviso tra i sottodomini grazie a domain=.mebboat.it
|
||||||
|
*/
|
||||||
|
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET;
|
||||||
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
|
||||||
|
function extractToken(req) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
return (req.cookies && req.cookies.auth_token) || bearer || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
if (!token || typeof token !== 'string' || token.length > 2048) return null;
|
||||||
|
try {
|
||||||
|
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
|
||||||
|
return {
|
||||||
|
user_id: p.sub,
|
||||||
|
username: p.username,
|
||||||
|
session_id: p.session_id,
|
||||||
|
iat: p.iat,
|
||||||
|
exp: p.exp
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accetta utente loggato (cookie/bearer) o chiamata interna (x-api-key).
|
||||||
|
* Imposta req.user con i dati dell'utente, oppure req.internal = true.
|
||||||
|
*/
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
// 1. Service-to-service
|
||||||
|
const apiKey = req.headers['x-api-key'];
|
||||||
|
if (apiKey && INTERNAL_KEY && apiKey === INTERNAL_KEY) {
|
||||||
|
req.internal = true;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. User auth (cookie o Bearer)
|
||||||
|
const user = verifyToken(extractToken(req));
|
||||||
|
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solo service-to-service (x-api-key).
|
||||||
|
*/
|
||||||
|
function requireInternal(req, res, next) {
|
||||||
|
const apiKey = req.headers['x-api-key'];
|
||||||
|
if (!INTERNAL_KEY || !apiKey || apiKey !== INTERNAL_KEY) {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
req.internal = true;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuth, requireInternal, verifyToken, extractToken };
|
||||||
71
api/src/migrations/001_ml_datasets.sql
Normal file
71
api/src/migrations/001_ml_datasets.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- -- Database: ml
|
||||||
|
-- -- Eseguire con: psql -U meb -d ml -f 001_ml_datasets.sql
|
||||||
|
-- --
|
||||||
|
-- -- Tabella dei metadati dei dataset salvati su MinIO.
|
||||||
|
-- -- Ogni riga è associata ad un file nel bucket "ml.datasets" (o altri bucket future)
|
||||||
|
-- -- tramite minio_key (= nome oggetto in MinIO, che è anche il suo "id" nativo).
|
||||||
|
|
||||||
|
-- CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- CREATE TABLE IF NOT EXISTS datasets (
|
||||||
|
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- -- Storage MinIO
|
||||||
|
-- minio_key TEXT NOT NULL UNIQUE, -- es. "2026-04-22_currents_med.csv"
|
||||||
|
-- bucket TEXT NOT NULL DEFAULT 'ml.datasets',
|
||||||
|
|
||||||
|
-- -- Identità dataset
|
||||||
|
-- nome TEXT NOT NULL,
|
||||||
|
-- description TEXT,
|
||||||
|
-- tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
-- type TEXT NOT NULL DEFAULT 'copernicus', -- copernicus | custom | imported
|
||||||
|
-- format TEXT NOT NULL, -- csv | json | netcdf
|
||||||
|
-- notes TEXT,
|
||||||
|
|
||||||
|
-- -- Provenienza / audit
|
||||||
|
-- created_by TEXT NOT NULL, -- username
|
||||||
|
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
-- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
-- last_used_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- -- Misure del file
|
||||||
|
-- size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
-- row_count BIGINT, -- numero righe (se noto)
|
||||||
|
-- columns TEXT[], -- nomi colonne finali (dopo rename)
|
||||||
|
|
||||||
|
-- -- Specifico Copernicus (nullable per altri type)
|
||||||
|
-- copernicus_dataset_id TEXT,
|
||||||
|
-- variables TEXT[], -- variabili richieste
|
||||||
|
-- variable_renames JSONB, -- {original: custom}
|
||||||
|
-- bbox JSONB, -- [min_lon, min_lat, max_lon, max_lat]
|
||||||
|
-- start_date DATE,
|
||||||
|
-- end_date DATE,
|
||||||
|
|
||||||
|
-- -- Estensibile per type futuri senza migration
|
||||||
|
-- params JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- -- Versioning semplice
|
||||||
|
-- version INT NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- CONSTRAINT datasets_format_ok CHECK (format IN ('csv','json','netcdf')),
|
||||||
|
-- CONSTRAINT datasets_type_ok CHECK (type IN ('copernicus','custom','imported'))
|
||||||
|
-- );
|
||||||
|
|
||||||
|
-- CREATE INDEX IF NOT EXISTS idx_datasets_created_by ON datasets(created_by);
|
||||||
|
-- CREATE INDEX IF NOT EXISTS idx_datasets_type ON datasets(type);
|
||||||
|
-- CREATE INDEX IF NOT EXISTS idx_datasets_tags ON datasets USING gin(tags);
|
||||||
|
-- CREATE INDEX IF NOT EXISTS idx_datasets_created_at ON datasets(created_at DESC);
|
||||||
|
-- CREATE INDEX IF NOT EXISTS idx_datasets_minio_key ON datasets(minio_key);
|
||||||
|
|
||||||
|
-- -- Trigger per aggiornare updated_at automaticamente
|
||||||
|
-- CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
|
||||||
|
-- BEGIN
|
||||||
|
-- NEW.updated_at = NOW();
|
||||||
|
-- RETURN NEW;
|
||||||
|
-- END;
|
||||||
|
-- $$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- DROP TRIGGER IF EXISTS trg_datasets_updated_at ON datasets;
|
||||||
|
-- CREATE TRIGGER trg_datasets_updated_at
|
||||||
|
-- BEFORE UPDATE ON datasets
|
||||||
|
-- FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
5
api/src/migrations/002_ml_datasets_bucket_default.sql
Normal file
5
api/src/migrations/002_ml_datasets_bucket_default.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Database: ml
|
||||||
|
-- DEPRECATED: la colonna `bucket` e' stata rimossa dalla tabella `datasets`.
|
||||||
|
-- Il bucket e' ora fisso a 'ml.datasets' lato applicazione (vedi
|
||||||
|
-- api/src/routes/marine.datasets.js e ml/routers/datasets.py).
|
||||||
|
-- Questo file e' lasciato vuoto per non rompere lo storico delle migration.
|
||||||
35
api/src/migrations/003_ml_models.sql
Normal file
35
api/src/migrations/003_ml_models.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- Database: ml
|
||||||
|
-- Registro modelli ML: ogni riga punta a una repo Gitea con codice del modello.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS models (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL, -- 'xgboost'|'lstm'|'sklearn'|...
|
||||||
|
gitea_repo TEXT NOT NULL, -- "owner/repo"
|
||||||
|
default_branch TEXT NOT NULL DEFAULT 'main',
|
||||||
|
spec JSONB, -- copia cached di model.yml @ tip del default_branch
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_models_created_by ON models(created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_models_type ON models(type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS model_notes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_model_notes_model ON model_notes(model_id, created_at DESC);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_models_updated_at ON models;
|
||||||
|
CREATE TRIGGER trg_models_updated_at
|
||||||
|
BEFORE UPDATE ON models
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- La colonna `bucket` su datasets e' stata rimossa: il bucket e' fisso a
|
||||||
|
-- 'ml.datasets' (vedi codice). Lasciato come no-op per coerenza storica.
|
||||||
26
api/src/migrations/004_ml_trainings.sql
Normal file
26
api/src/migrations/004_ml_trainings.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Database: ml
|
||||||
|
-- Storico training di modelli. Le time-series (cpu/mem/loss) vivono su InfluxDB;
|
||||||
|
-- qui salviamo solo anagrafica + risultati finali e riepilogo risorse.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trainings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
patch TEXT NOT NULL, -- git commit sha (short o full)
|
||||||
|
dataset_id UUID NOT NULL,
|
||||||
|
started_by TEXT NOT NULL,
|
||||||
|
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
duration_ms BIGINT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued', -- queued|running|succeeded|failed|cancelled
|
||||||
|
artifacts_prefix TEXT, -- es. "models/<id>/<version>/<patch>"
|
||||||
|
results JSONB, -- final metrics + plots (arrays puri)
|
||||||
|
resource_summary JSONB, -- {cpu_peak,cpu_avg,mem_peak_mb,mem_avg_mb,samples}
|
||||||
|
error TEXT,
|
||||||
|
CONSTRAINT trainings_status_ok CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
|
||||||
|
UNIQUE(model_id, version, patch)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trainings_model ON trainings(model_id, started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trainings_status ON trainings(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trainings_user ON trainings(started_by);
|
||||||
17
api/src/migrations/005_ml_tests.sql
Normal file
17
api/src/migrations/005_ml_tests.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Database: ml
|
||||||
|
-- Sessioni di test: una sessione contiene 1..N run (set di input → output).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
training_id UUID NOT NULL REFERENCES trainings(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
runs JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||||
|
-- ogni elemento: {inputs, outputs, duration_ms, cpu_peak, mem_peak_mb, ts}
|
||||||
|
model_size_bytes BIGINT,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tests_training ON tests(training_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tests_user ON tests(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tests_started ON tests(started_at DESC);
|
||||||
27
api/src/migrations/006_jobs.sql
Normal file
27
api/src/migrations/006_jobs.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- Database: ml
|
||||||
|
-- Tabella jobs: ciclo di vita di un lavoro asincrono (training oggi, domani altro).
|
||||||
|
-- L'api-service espone /jobs /queue /pageconnections per coordinare accessi e coda.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type TEXT NOT NULL, -- 'train' | 'test' | ...
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',-- queued|running|succeeded|failed|cancelled
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||||
|
result JSONB,
|
||||||
|
error TEXT,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT jobs_status_ok CHECK (status IN ('queued','running','succeeded','failed','cancelled'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at DESC);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||||
|
CREATE TRIGGER trg_jobs_updated_at
|
||||||
|
BEFORE UPDATE ON jobs
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
79
api/src/migrations/007_kiosktemplates.sql
Normal file
79
api/src/migrations/007_kiosktemplates.sql
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Kiosk schema (DB: sensors)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- SOLO struttura: PK, FK, NOT NULL, sequence, indice.
|
||||||
|
-- Tutta la logica (id char(8), updated_at, defaults, CHECK,
|
||||||
|
-- "un solo active") e' gestita lato applicazione (Node/JS).
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- Templates
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS kiosktemplates (
|
||||||
|
id char(8) PRIMARY KEY,
|
||||||
|
name varchar(50) NOT NULL,
|
||||||
|
tags text[],
|
||||||
|
active boolean,
|
||||||
|
archived boolean,
|
||||||
|
created_at timestamp,
|
||||||
|
updated_at timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Idempotente: se la tabella esiste gia', garantisci PK + NOT NULL critici.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'kiosktemplates'::regclass AND contype = 'p'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE kiosktemplates ADD PRIMARY KEY (id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE kiosktemplates ALTER COLUMN name SET NOT NULL;
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- Elements (un template -> N elementi)
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS kioskelements (
|
||||||
|
id bigint PRIMARY KEY,
|
||||||
|
template_id char(8) NOT NULL,
|
||||||
|
font integer,
|
||||||
|
label varchar(100),
|
||||||
|
x integer,
|
||||||
|
y integer,
|
||||||
|
width integer,
|
||||||
|
height integer,
|
||||||
|
color varchar(20)
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'kioskelements'::regclass AND contype = 'p'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE kioskelements ADD PRIMARY KEY (id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE kioskelements ALTER COLUMN template_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- Auto-increment per kioskelements.id (l'app fa INSERT senza specificare id).
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS kioskelements_id_seq OWNED BY kioskelements.id;
|
||||||
|
SELECT setval(
|
||||||
|
'kioskelements_id_seq',
|
||||||
|
COALESCE((SELECT MAX(id) FROM kioskelements), 0) + 1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
ALTER TABLE kioskelements ALTER COLUMN id SET DEFAULT nextval('kioskelements_id_seq');
|
||||||
|
|
||||||
|
-- Foreign Key con CASCADE.
|
||||||
|
ALTER TABLE kioskelements DROP CONSTRAINT IF EXISTS fk_kioskelements_template;
|
||||||
|
ALTER TABLE kioskelements
|
||||||
|
ADD CONSTRAINT fk_kioskelements_template
|
||||||
|
FOREIGN KEY (template_id)
|
||||||
|
REFERENCES kiosktemplates(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS kioskelements_template_idx
|
||||||
|
ON kioskelements (template_id);
|
||||||
124
api/src/migrations/008_rulesets.sql
Normal file
124
api/src/migrations/008_rulesets.sql
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Rulesets schema (DB: rules)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Un ruleset e' una collezione versionata di "items" (paths
|
||||||
|
-- Signal K per i logs, codici openmeteo per i forecasts, ...).
|
||||||
|
-- Modello:
|
||||||
|
-- * 5 tipi fissi: logs | forecast_current | forecast_hourly
|
||||||
|
-- | marine_current | marine_hourly
|
||||||
|
-- * Un solo ruleset puo' essere "active" per ciascun tipo.
|
||||||
|
-- * Le versioni sono triple di interi 1..100 (major.build.patch).
|
||||||
|
-- * Gli items sono JSONB per massima flessibilita'.
|
||||||
|
-- * Ogni item ha un "ref" stabile scelto dall'utente: e' la
|
||||||
|
-- chiave logica che garantisce continuita' su InfluxDB anche
|
||||||
|
-- se il path del sensore cambia.
|
||||||
|
-- * Le deployments tracciano quale ruleset-version e' stato
|
||||||
|
-- pushato ad ogni sensore.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- RULESETS
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS rulesets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type TEXT NOT NULL
|
||||||
|
CHECK (type IN ('logs','forecast_current','forecast_hourly','marine_current','marine_hourly')),
|
||||||
|
version_major SMALLINT NOT NULL DEFAULT 1 CHECK (version_major BETWEEN 1 AND 100),
|
||||||
|
version_build SMALLINT NOT NULL DEFAULT 0 CHECK (version_build BETWEEN 0 AND 100),
|
||||||
|
version_patch SMALLINT NOT NULL DEFAULT 0 CHECK (version_patch BETWEEN 0 AND 100),
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
-- items: [{ ref, path, enabled, meta: {...} }, ...]
|
||||||
|
-- ref: identificatore logico stabile (chiave su Influx)
|
||||||
|
-- path: SK path (logs) | codice openmeteo (forecast/marine)
|
||||||
|
-- meta: { name, unit, measurement, sk_path, group_name, category, ... }
|
||||||
|
items JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
archived BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE (type, version_major, version_build, version_patch)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- solo UN ruleset active per tipo (archiviati esclusi)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS rulesets_one_active_per_type
|
||||||
|
ON rulesets (type)
|
||||||
|
WHERE active = true AND archived = false;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS rulesets_type_idx ON rulesets (type);
|
||||||
|
CREATE INDEX IF NOT EXISTS rulesets_active_idx ON rulesets (type) WHERE active = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS rulesets_archived_idx ON rulesets (archived);
|
||||||
|
CREATE INDEX IF NOT EXISTS rulesets_items_gin_idx ON rulesets USING GIN (items);
|
||||||
|
|
||||||
|
-- Validazione items: array di oggetti con almeno ref+path
|
||||||
|
CREATE OR REPLACE FUNCTION rulesets_validate_items() RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
refs TEXT[];
|
||||||
|
BEGIN
|
||||||
|
IF jsonb_typeof(NEW.items) <> 'array' THEN
|
||||||
|
RAISE EXCEPTION 'items must be a JSON array';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- tutti gli item devono avere ref non vuoto e path (anche vuoto ammesso)
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM jsonb_array_elements(NEW.items) it
|
||||||
|
WHERE jsonb_typeof(it) <> 'object'
|
||||||
|
OR NULLIF(it->>'ref','') IS NULL
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'every item must be an object with a non-empty "ref"';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- unicita' ref all'interno dello stesso ruleset
|
||||||
|
SELECT array_agg(it->>'ref') INTO refs
|
||||||
|
FROM jsonb_array_elements(NEW.items) it;
|
||||||
|
IF (SELECT count(DISTINCT x) FROM unnest(refs) x) <> COALESCE(array_length(refs,1),0) THEN
|
||||||
|
RAISE EXCEPTION 'item refs must be unique within the ruleset';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
NEW.updated_at := NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS rulesets_validate_trigger ON rulesets;
|
||||||
|
CREATE TRIGGER rulesets_validate_trigger
|
||||||
|
BEFORE INSERT OR UPDATE ON rulesets
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION rulesets_validate_items();
|
||||||
|
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- DEPLOYMENTS
|
||||||
|
-- Traccia quale ruleset-version e' stato pushato ad ogni
|
||||||
|
-- sensore (per tipo). Un solo ruleset per (sensor,type).
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS ruleset_deployments (
|
||||||
|
sensor_name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
ruleset_id UUID NOT NULL REFERENCES rulesets(id) ON DELETE CASCADE,
|
||||||
|
deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
acked_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (sensor_name, type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ruleset_deployments_ruleset_idx
|
||||||
|
ON ruleset_deployments (ruleset_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- AUDIT LOG (opzionale ma utile)
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS ruleset_changes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
ruleset_id UUID,
|
||||||
|
type TEXT,
|
||||||
|
action TEXT NOT NULL, -- created | updated | activated | archived | deleted | deployed
|
||||||
|
user_id TEXT,
|
||||||
|
payload JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ruleset_changes_ruleset_idx ON ruleset_changes (ruleset_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ruleset_changes_created_idx ON ruleset_changes (created_at DESC);
|
||||||
80
api/src/routes/docs.js
Normal file
80
api/src/routes/docs.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Gestione file Markdown su MinIO nel bucket "documentation".
|
||||||
|
*
|
||||||
|
* GET /docs → lista file (name, size, lastModified)
|
||||||
|
* GET /docs/:name → contenuto markdown raw
|
||||||
|
* POST /docs → crea nuovo documento body {name, content}
|
||||||
|
* PUT /docs/:name → sovrascrive contenuto body {content}
|
||||||
|
* DELETE /docs/:name → elimina
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { listObjects, readText, writeText, removeObject } = require('../storage/minio');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const BUCKET = 'documentation';
|
||||||
|
|
||||||
|
const sanitize = (name) => {
|
||||||
|
// Solo caratteri safe per oggetti MinIO; forza estensione .md
|
||||||
|
const clean = String(name || '').trim().replace(/[^a-zA-Z0-9 _\-./]/g, '').replace(/\.+/g, '.');
|
||||||
|
if (!clean) return null;
|
||||||
|
return clean.endsWith('.md') ? clean : `${clean}.md`;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const files = await listObjects(BUCKET);
|
||||||
|
res.json(files.filter(f => f.name.endsWith('.md')));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:name', async (req, res) => {
|
||||||
|
const name = sanitize(req.params.name);
|
||||||
|
if (!name) return res.status(400).json({ error: 'invalid name' });
|
||||||
|
try {
|
||||||
|
const content = await readText(BUCKET, name);
|
||||||
|
res.type('text/markdown').send(content);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'NoSuchKey') return res.status(404).json({ error: 'not found' });
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const name = sanitize(req.body?.name);
|
||||||
|
const content = req.body?.content ?? '';
|
||||||
|
if (!name) return res.status(400).json({ error: 'name required' });
|
||||||
|
try {
|
||||||
|
const r = await writeText(BUCKET, name, content);
|
||||||
|
res.status(201).json(r);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:name', async (req, res) => {
|
||||||
|
const name = sanitize(req.params.name);
|
||||||
|
if (!name) return res.status(400).json({ error: 'invalid name' });
|
||||||
|
const content = req.body?.content ?? '';
|
||||||
|
try {
|
||||||
|
const r = await writeText(BUCKET, name, content);
|
||||||
|
res.json(r);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:name', async (req, res) => {
|
||||||
|
const name = sanitize(req.params.name);
|
||||||
|
if (!name) return res.status(400).json({ error: 'invalid name' });
|
||||||
|
try {
|
||||||
|
await removeObject(BUCKET, name);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
120
api/src/routes/jobs.js
Normal file
120
api/src/routes/jobs.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* /jobs — ciclo di vita dei job asincroni (es. training).
|
||||||
|
*
|
||||||
|
* Tabella: jobs (db "ml") — vedi migrations/006_jobs.sql
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { query } = require('../storage/postgres');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// In assenza dei trigger/funzioni DB (`gen_random_uuid` default,
|
||||||
|
// `set_updated_at` trigger, `jobs_status_ok` CHECK) gestiamo tutto qui.
|
||||||
|
const VALID_STATUSES = ['queued', 'running', 'succeeded', 'failed', 'cancelled'];
|
||||||
|
function genUUID() { return crypto.randomUUID(); }
|
||||||
|
|
||||||
|
function rowToJob(r) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
status: r.status,
|
||||||
|
payload: r.payload,
|
||||||
|
result: r.result,
|
||||||
|
error: r.error,
|
||||||
|
created_by: r.created_by,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
started_at: r.started_at,
|
||||||
|
finished_at: r.finished_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, created_by, payload } = req.body || {};
|
||||||
|
if (!type) return res.status(400).json({ error: 'type required' });
|
||||||
|
const newId = genUUID();
|
||||||
|
const r = await query(
|
||||||
|
`INSERT INTO jobs (id, type, created_by, payload, status, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb, 'queued', NOW(), NOW()) RETURNING *`,
|
||||||
|
[newId, type, created_by || req.user?.username || 'unknown', JSON.stringify(payload || {})],
|
||||||
|
'ml'
|
||||||
|
);
|
||||||
|
res.status(201).json(rowToJob(r.rows[0]));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filters = [];
|
||||||
|
const params = [];
|
||||||
|
if (req.query.type) { params.push(req.query.type); filters.push(`type = $${params.length}`); }
|
||||||
|
if (req.query.status) { params.push(req.query.status); filters.push(`status = $${params.length}`); }
|
||||||
|
if (req.query.user === 'me' && req.user?.username) {
|
||||||
|
params.push(req.user.username);
|
||||||
|
filters.push(`created_by = $${params.length}`);
|
||||||
|
}
|
||||||
|
const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
|
||||||
|
params.push(limit);
|
||||||
|
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
const r = await query(
|
||||||
|
`SELECT * FROM jobs ${where} ORDER BY created_at DESC LIMIT $${params.length}`,
|
||||||
|
params, 'ml'
|
||||||
|
);
|
||||||
|
res.json(r.rows.map(rowToJob));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query('SELECT * FROM jobs WHERE id = $1', [req.params.id], 'ml');
|
||||||
|
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(rowToJob(r.rows[0]));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const allowed = ['status', 'payload', 'result', 'error', 'started_at', 'finished_at'];
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
// CHECK status sostituito da whitelist applicativa
|
||||||
|
if ('status' in req.body && !VALID_STATUSES.includes(req.body.status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `invalid status, must be one of: ${VALID_STATUSES.join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of allowed) {
|
||||||
|
if (k in req.body) {
|
||||||
|
params.push(k === 'payload' || k === 'result' ? JSON.stringify(req.body[k]) : req.body[k]);
|
||||||
|
const cast = (k === 'payload' || k === 'result') ? '::jsonb' : '';
|
||||||
|
sets.push(`${k} = $${params.length}${cast}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sets.length) return res.status(400).json({ error: 'no fields' });
|
||||||
|
|
||||||
|
// Trigger trg_jobs_updated_at non presente: lo facciamo manualmente.
|
||||||
|
sets.push('updated_at = NOW()');
|
||||||
|
|
||||||
|
params.push(req.params.id);
|
||||||
|
const r = await query(
|
||||||
|
`UPDATE jobs SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||||
|
params, 'ml'
|
||||||
|
);
|
||||||
|
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(rowToJob(r.rows[0]));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
443
api/src/routes/kiosk.js
Normal file
443
api/src/routes/kiosk.js
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* Kiosk templates API
|
||||||
|
* Base: /kiosk
|
||||||
|
*
|
||||||
|
* Schema reale (DB `sensors`):
|
||||||
|
* kiosktemplates(id char(8) PK, name varchar(50) NOT NULL,
|
||||||
|
* tags text[], active bool, archived bool,
|
||||||
|
* created_at timestamp, updated_at timestamp)
|
||||||
|
* kioskelements(id bigint PK auto-seq, template_id char(8) NOT NULL FK CASCADE,
|
||||||
|
* font, label varchar(100), x, y, width, height, color varchar(20))
|
||||||
|
*
|
||||||
|
* NOTA: il DB contiene SOLO i constraint strutturali (PK, FK, NOT NULL).
|
||||||
|
* Tutta la logica (id char(8), updated_at, "un solo active per tabella",
|
||||||
|
* validazione, CHECK su x/y/w/h, prevenzione active+archived) e' gestita qui.
|
||||||
|
*
|
||||||
|
* Le route restituiscono il template arricchito con `elements`:
|
||||||
|
* { id, name, tags, active, archived, created_at, updated_at, elements: [...] }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { query, getClient } = require('../storage/postgres');
|
||||||
|
|
||||||
|
const DB = 'kiosk'; // pool puntato al DB `sensors` (vedi postgres.js)
|
||||||
|
|
||||||
|
// Sostituisce il default DB `gen_short_id8()` (funzione SQL non presente nel DB).
|
||||||
|
function genShortId8() {
|
||||||
|
return crypto.randomBytes(4).toString('hex'); // 8 char hex [0-9a-f]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ELEMENT_FIELDS = ['font', 'label', 'x', 'y', 'width', 'height', 'color'];
|
||||||
|
|
||||||
|
function sanitizeElement(e) {
|
||||||
|
const out = {};
|
||||||
|
if (e.font !== undefined) out.font = parseInt(e.font, 10);
|
||||||
|
if (e.label !== undefined) out.label = String(e.label).slice(0, 100);
|
||||||
|
if (e.x !== undefined) out.x = parseInt(e.x, 10);
|
||||||
|
if (e.y !== undefined) out.y = parseInt(e.y, 10);
|
||||||
|
if (e.width !== undefined) out.width = parseInt(e.width, 10);
|
||||||
|
if (e.height !== undefined) out.height = parseInt(e.height, 10);
|
||||||
|
if (e.color !== undefined) out.color = String(e.color).slice(0, 20);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateElementForInsert(e) {
|
||||||
|
const errs = [];
|
||||||
|
if (typeof e.x !== 'number' || e.x < 0) errs.push('x must be >= 0');
|
||||||
|
if (typeof e.y !== 'number' || e.y < 0) errs.push('y must be >= 0');
|
||||||
|
if (typeof e.width !== 'number' || e.width <= 0) errs.push('width must be > 0');
|
||||||
|
if (typeof e.height !== 'number' || e.height <= 0) errs.push('height must be > 0');
|
||||||
|
return errs.length ? errs.join(', ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserisce N elementi per un template in batch.
|
||||||
|
* Usa il client passato (per transazione).
|
||||||
|
*/
|
||||||
|
async function insertElements(client, templateId, elements) {
|
||||||
|
if (!Array.isArray(elements) || !elements.length) return [];
|
||||||
|
const rows = [];
|
||||||
|
for (const raw of elements) {
|
||||||
|
const e = sanitizeElement(raw);
|
||||||
|
const err = validateElementForInsert({ x:0, y:0, width:1, height:1, ...e });
|
||||||
|
if (err) throw new Error(`element invalid: ${err}`);
|
||||||
|
const r = await client.query(
|
||||||
|
`INSERT INTO kioskelements (template_id, font, label, x, y, width, height, color)
|
||||||
|
VALUES ($1, COALESCE($2,16), COALESCE($3,''), COALESCE($4,0), COALESCE($5,0),
|
||||||
|
COALESCE($6,1), COALESCE($7,1), COALESCE($8,'#1e293b'))
|
||||||
|
RETURNING *`,
|
||||||
|
[templateId, e.font, e.label, e.x, e.y, e.width, e.height, e.color]
|
||||||
|
);
|
||||||
|
rows.push(r.rows[0]);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ritorna un template con la lista elements aggregata.
|
||||||
|
*/
|
||||||
|
async function fetchTemplateWithElements(idOrCondition, value) {
|
||||||
|
const where = idOrCondition === 'active'
|
||||||
|
? 't.active = true AND t.archived = false'
|
||||||
|
: 't.id = $1';
|
||||||
|
const params = idOrCondition === 'active' ? [] : [value];
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT t.*,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT json_agg(e.* ORDER BY e.id)
|
||||||
|
FROM kioskelements e WHERE e.template_id = t.id),
|
||||||
|
'[]'::json
|
||||||
|
) AS elements
|
||||||
|
FROM kiosktemplates t
|
||||||
|
WHERE ${where}
|
||||||
|
LIMIT 1`;
|
||||||
|
const r = await query(sql, params, DB);
|
||||||
|
return r.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// READS
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// GET /kiosk/template/active → template attivo (con elements)
|
||||||
|
router.get('/template/active', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tpl = await fetchTemplateWithElements('active');
|
||||||
|
if (!tpl) return res.status(404).json({ error: 'no active template' });
|
||||||
|
res.json(tpl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK] active error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /kiosk/templates → lista (senza elements)
|
||||||
|
router.get('/templates', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`SELECT t.id, t.name, t.tags, t.active, t.archived,
|
||||||
|
t.created_at, t.updated_at,
|
||||||
|
(SELECT COUNT(*)::int FROM kioskelements e WHERE e.template_id = t.id) AS elements_count
|
||||||
|
FROM kiosktemplates t
|
||||||
|
ORDER BY t.updated_at DESC`,
|
||||||
|
[], DB
|
||||||
|
);
|
||||||
|
res.json(r.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK] list error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /kiosk/templates/:id → dettaglio con elements
|
||||||
|
router.get('/templates/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tpl = await fetchTemplateWithElements('id', req.params.id);
|
||||||
|
if (!tpl) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(tpl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK] get error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// WRITES
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// POST /kiosk/templates → crea template + elements (transazionale)
|
||||||
|
router.post('/templates', async (req, res) => {
|
||||||
|
const { name, tags, elements } = req.body || {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'name required' });
|
||||||
|
}
|
||||||
|
const tagsArr = Array.isArray(tags) ? tags.map(String) : [];
|
||||||
|
const els = Array.isArray(elements) ? elements : [];
|
||||||
|
|
||||||
|
const client = await getClient(DB);
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Genera id in app (no default DB). Retry se collisione (estremamente rara).
|
||||||
|
let tpl = null;
|
||||||
|
for (let attempt = 0; attempt < 5 && !tpl; attempt++) {
|
||||||
|
const id = genShortId8();
|
||||||
|
try {
|
||||||
|
const t = await client.query(
|
||||||
|
`INSERT INTO kiosktemplates (id, name, tags, active, archived, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, false, false, NOW(), NOW()) RETURNING *`,
|
||||||
|
[id, name.slice(0, 50), tagsArr]
|
||||||
|
);
|
||||||
|
tpl = t.rows[0];
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== '23505') throw e; // PK conflict → retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!tpl) throw new Error('id generation failed');
|
||||||
|
|
||||||
|
const insertedEls = await insertElements(client, tpl.id, els);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.status(201).json({ ...tpl, elements: insertedEls });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('[KIOSK] create error:', err.message);
|
||||||
|
res.status(400).json({ error: err.message || 'internal error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /kiosk/templates/:id → patch metadata. Se `elements` viene passato,
|
||||||
|
// sostituisce TUTTI gli elements (delete + insert in transazione).
|
||||||
|
router.put('/templates/:id', async (req, res) => {
|
||||||
|
const { name, tags, elements } = req.body || {};
|
||||||
|
const fields = [], values = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
if (name !== undefined) { fields.push(`name = $${i++}`); values.push(String(name).slice(0, 50)); }
|
||||||
|
if (tags !== undefined) {
|
||||||
|
if (!Array.isArray(tags)) return res.status(400).json({ error: 'tags must be array' });
|
||||||
|
fields.push(`tags = $${i++}`); values.push(tags.map(String));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fields.length && elements === undefined) {
|
||||||
|
return res.status(400).json({ error: 'no fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient(DB);
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
let tpl;
|
||||||
|
if (fields.length) {
|
||||||
|
// Trigger set_updated_at non presente: lo facciamo manualmente.
|
||||||
|
fields.push('updated_at = NOW()');
|
||||||
|
values.push(req.params.id);
|
||||||
|
const r = await client.query(
|
||||||
|
`UPDATE kiosktemplates SET ${fields.join(', ')}
|
||||||
|
WHERE id = $${i} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
tpl = r.rows[0];
|
||||||
|
} else {
|
||||||
|
const r = await client.query(`SELECT * FROM kiosktemplates WHERE id = $1`, [req.params.id]);
|
||||||
|
if (!r.rows[0]) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
tpl = r.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let elsRows;
|
||||||
|
if (Array.isArray(elements)) {
|
||||||
|
await client.query(`DELETE FROM kioskelements WHERE template_id = $1`, [tpl.id]);
|
||||||
|
elsRows = await insertElements(client, tpl.id, elements);
|
||||||
|
} else {
|
||||||
|
const r = await client.query(
|
||||||
|
`SELECT * FROM kioskelements WHERE template_id = $1 ORDER BY id`,
|
||||||
|
[tpl.id]
|
||||||
|
);
|
||||||
|
elsRows = r.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ ...tpl, elements: elsRows });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('[KIOSK] update error:', err.message);
|
||||||
|
res.status(400).json({ error: err.message || 'internal error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /kiosk/templates/:id/activate → attiva (disattiva tutti gli altri)
|
||||||
|
router.post('/templates/:id/activate', async (req, res) => {
|
||||||
|
const client = await getClient(DB);
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// pre-check: archived non puo' diventare active
|
||||||
|
const cur = await client.query(
|
||||||
|
`SELECT archived FROM kiosktemplates WHERE id = $1`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (!cur.rows[0]) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
if (cur.rows[0].archived) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(409).json({ error: 'cannot activate archived template' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE kiosktemplates SET active = false, updated_at = NOW()
|
||||||
|
WHERE active = true AND id <> $1`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
const r = await client.query(
|
||||||
|
`UPDATE kiosktemplates SET active = true, updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const tpl = r.rows[0];
|
||||||
|
|
||||||
|
// notifica realtime (best-effort)
|
||||||
|
const RT = process.env.REALTIME_URL || 'http://meb-realtime:3000';
|
||||||
|
const KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
if (KEY) {
|
||||||
|
fetch(`${RT}/kiosk/notify-active`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
|
||||||
|
body: JSON.stringify({ template: tpl })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(tpl);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('[KIOSK] activate error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /kiosk/templates/:id/archive → toggle archived (e disattiva)
|
||||||
|
router.patch('/templates/:id/archive', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cur = await query(
|
||||||
|
`SELECT archived FROM kiosktemplates WHERE id = $1`,
|
||||||
|
[req.params.id], DB
|
||||||
|
);
|
||||||
|
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
const willArchive = !cur.rows[0].archived;
|
||||||
|
const r = await query(
|
||||||
|
`UPDATE kiosktemplates
|
||||||
|
SET archived = $1,
|
||||||
|
active = CASE WHEN $1 = true THEN false ELSE active END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2 RETURNING *`,
|
||||||
|
[willArchive, req.params.id], DB
|
||||||
|
);
|
||||||
|
res.json(r.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK] archive error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /kiosk/templates/:id
|
||||||
|
// FK CASCADE non e' garantita lato DB: cancelliamo manualmente in transazione
|
||||||
|
// prima gli elements e poi il template.
|
||||||
|
router.delete('/templates/:id', async (req, res) => {
|
||||||
|
const client = await getClient(DB);
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const check = await client.query(
|
||||||
|
`SELECT active FROM kiosktemplates WHERE id = $1`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (!check.rows[0]) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
if (check.rows[0].active) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(409).json({ error: 'cannot delete active template' });
|
||||||
|
}
|
||||||
|
await client.query(`DELETE FROM kioskelements WHERE template_id = $1`, [req.params.id]);
|
||||||
|
await client.query(`DELETE FROM kiosktemplates WHERE id = $1`, [req.params.id]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ deleted: true });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('[KIOSK] delete error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// ELEMENTS — CRUD granulare (utile per editor live)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// POST /kiosk/templates/:id/elements → aggiunge un singolo element
|
||||||
|
router.post('/templates/:id/elements', async (req, res) => {
|
||||||
|
const client = await getClient(DB);
|
||||||
|
try {
|
||||||
|
const tpl = await client.query(
|
||||||
|
`SELECT id FROM kiosktemplates WHERE id = $1`, [req.params.id]
|
||||||
|
);
|
||||||
|
if (!tpl.rows[0]) return res.status(404).json({ error: 'template not found' });
|
||||||
|
|
||||||
|
const [created] = await insertElements(client, req.params.id, [req.body || {}]);
|
||||||
|
// bumpa updated_at del template
|
||||||
|
await client.query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id]);
|
||||||
|
res.status(201).json(created);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK] add element error:', err.message);
|
||||||
|
res.status(400).json({ error: err.message || 'internal error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /kiosk/templates/:id/elements/:elementId → patch element
|
||||||
|
router.put('/templates/:id/elements/:elementId', async (req, res) => {
|
||||||
|
const e = sanitizeElement(req.body || {});
|
||||||
|
const fields = [], values = [];
|
||||||
|
let i = 1;
|
||||||
|
for (const k of ELEMENT_FIELDS) {
|
||||||
|
if (e[k] !== undefined) { fields.push(`${k} = $${i++}`); values.push(e[k]); }
|
||||||
|
}
|
||||||
|
if (!fields.length) return res.status(400).json({ error: 'no fields' });
|
||||||
|
|
||||||
|
values.push(req.params.elementId, req.params.id);
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`UPDATE kioskelements SET ${fields.join(', ')}
|
||||||
|
WHERE id = $${i++} AND template_id = $${i} RETURNING *`,
|
||||||
|
values, DB
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
await query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id], DB);
|
||||||
|
res.json(r.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK] update element error:', err.message);
|
||||||
|
res.status(400).json({ error: err.message || 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /kiosk/templates/:id/elements/:elementId
|
||||||
|
router.delete('/templates/:id/elements/:elementId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`DELETE FROM kioskelements
|
||||||
|
WHERE id = $1 AND template_id = $2 RETURNING id`,
|
||||||
|
[req.params.elementId, req.params.id], DB
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
await query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id], DB);
|
||||||
|
res.json({ deleted: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK] delete element error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
49
api/src/routes/kiosk.public.js
Normal file
49
api/src/routes/kiosk.public.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const { query } = require('../storage/postgres');
|
||||||
|
|
||||||
|
// Endpoint pubblici (usati dal plugin kiosk sulla barca). Solo read.
|
||||||
|
// Restituisce template + array `elements`.
|
||||||
|
|
||||||
|
const DB = 'kiosk';
|
||||||
|
|
||||||
|
const SELECT_WITH_ELEMENTS = `
|
||||||
|
SELECT t.*,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT json_agg(e.* ORDER BY e.id)
|
||||||
|
FROM kioskelements e WHERE e.template_id = t.id),
|
||||||
|
'[]'::json
|
||||||
|
) AS elements
|
||||||
|
FROM kiosktemplates t
|
||||||
|
`;
|
||||||
|
|
||||||
|
router.get('/template/active', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`${SELECT_WITH_ELEMENTS}
|
||||||
|
WHERE t.active = true AND t.archived = false
|
||||||
|
LIMIT 1`,
|
||||||
|
[], DB
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'no active template' });
|
||||||
|
res.json(r.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK/PUB] active error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/templates/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`${SELECT_WITH_ELEMENTS} WHERE t.id = $1 LIMIT 1`,
|
||||||
|
[req.params.id], DB
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(r.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK/PUB] get error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
67
api/src/routes/kiosk.sensor.js
Normal file
67
api/src/routes/kiosk.sensor.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { query } = require('../storage/postgres');
|
||||||
|
|
||||||
|
const DB = 'kiosk';
|
||||||
|
|
||||||
|
function hash(code) { return crypto.createHash('sha256').update(code).digest('hex'); }
|
||||||
|
|
||||||
|
async function authSensor(req, res, next) {
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
'SELECT id, active FROM sensors WHERE code_hash = $1',
|
||||||
|
[hash(req.params.sensorCode)], 'sensors'
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(401).json({ error: 'invalid sensor code' });
|
||||||
|
if (!r.rows[0].active) return res.status(403).json({ error: 'sensor inactive' });
|
||||||
|
req.sensorId = r.rows[0].id;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK/SENSOR] auth error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_WITH_ELEMENTS = `
|
||||||
|
SELECT t.*,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT json_agg(e.* ORDER BY e.id)
|
||||||
|
FROM kioskelements e WHERE e.template_id = t.id),
|
||||||
|
'[]'::json
|
||||||
|
) AS elements
|
||||||
|
FROM kiosktemplates t
|
||||||
|
`;
|
||||||
|
|
||||||
|
// GET /kiosk/sensor/:sensorCode/template/active
|
||||||
|
router.get('/:sensorCode/template/active', authSensor, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`${SELECT_WITH_ELEMENTS}
|
||||||
|
WHERE t.active = true AND t.archived = false
|
||||||
|
LIMIT 1`,
|
||||||
|
[], DB
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'no active template' });
|
||||||
|
res.json(r.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK/SENSOR] active error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /kiosk/sensor/:sensorCode/templates/:id
|
||||||
|
router.get('/:sensorCode/templates/:id', authSensor, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`${SELECT_WITH_ELEMENTS} WHERE t.id = $1 LIMIT 1`,
|
||||||
|
[req.params.id], DB
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(r.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KIOSK/SENSOR] get error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
232
api/src/routes/marine.datasets.js
Normal file
232
api/src/routes/marine.datasets.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* /marine/datasets — CRUD sulla tabella `datasets` del database `ml`.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* POST /marine/datasets/upload (multipart: file + metadata JSON)
|
||||||
|
* GET /marine/datasets (query: ?tags=a,b&type=copernicus&mine=1)
|
||||||
|
* GET /marine/datasets/:id (metadata)
|
||||||
|
* GET /marine/datasets/:id/download (presigned URL 1h)
|
||||||
|
* GET /marine/datasets/:id/raw (stream diretto)
|
||||||
|
* PATCH /marine/datasets/:id (aggiorna nome/tags/notes)
|
||||||
|
* DELETE /marine/datasets/:id (rimuove da MinIO + DB)
|
||||||
|
*
|
||||||
|
* I file vivono SEMPRE nel bucket MinIO "ml.datasets". La colonna `file_key` salva
|
||||||
|
* il nome dell'oggetto (basta `${uuid}.${ext}`, senza prefissi).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
|
||||||
|
const { query } = require('../storage/postgres');
|
||||||
|
const { bucketExists, upload, download, removeObject, getFileStream } = require('../storage/minio');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const upload_mw = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
// Bucket MinIO fisso per tutti i dataset.
|
||||||
|
const BUCKET = 'ml.datasets';
|
||||||
|
|
||||||
|
const parseTags = (s) => (s ? String(s).split(',').map(x => x.trim()).filter(Boolean) : []);
|
||||||
|
|
||||||
|
function rowToDataset(r) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
file_key: r.file_key,
|
||||||
|
nome: r.nome,
|
||||||
|
description: r.description,
|
||||||
|
tags: r.tags,
|
||||||
|
type: r.type,
|
||||||
|
format: r.format,
|
||||||
|
notes: r.notes,
|
||||||
|
created_by: r.created_by,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
size_bytes: Number(r.size_bytes),
|
||||||
|
row_count: r.row_count != null ? Number(r.row_count) : null,
|
||||||
|
columns: r.columns,
|
||||||
|
copernicus_id: r.copernicus_id,
|
||||||
|
variables: r.variables,
|
||||||
|
variable_renames: r.variable_renames,
|
||||||
|
bbox: r.bbox,
|
||||||
|
start_date: r.start_date,
|
||||||
|
end_date: r.end_date,
|
||||||
|
params: r.params,
|
||||||
|
version: r.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LIST ─────────────────────────────────────────────────────────────────
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filters = [];
|
||||||
|
const params = [];
|
||||||
|
if (req.query.type) {
|
||||||
|
params.push(req.query.type);
|
||||||
|
filters.push(`type = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (req.query.tags) {
|
||||||
|
const tags = parseTags(req.query.tags);
|
||||||
|
if (tags.length) {
|
||||||
|
params.push(tags);
|
||||||
|
filters.push(`tags && $${params.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.query.mine === '1' && req.user?.username) {
|
||||||
|
params.push(req.user.username);
|
||||||
|
filters.push(`created_by = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (req.query.search) {
|
||||||
|
params.push(`%${req.query.search}%`);
|
||||||
|
filters.push(`(nome ILIKE $${params.length} OR description ILIKE $${params.length})`);
|
||||||
|
}
|
||||||
|
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
const r = await query(
|
||||||
|
`SELECT * FROM datasets ${where} ORDER BY created_at DESC LIMIT 500`,
|
||||||
|
params, 'ml'
|
||||||
|
);
|
||||||
|
res.json({ count: r.rows.length, datasets: r.rows.map(rowToDataset) });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[marine/datasets list]', e);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── UPLOAD (usato dal servizio copernicus) ───────────────────────────────
|
||||||
|
router.post('/upload', upload_mw.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'file required (multipart field "file")' });
|
||||||
|
|
||||||
|
let meta = {};
|
||||||
|
try { meta = JSON.parse(req.body.metadata || '{}'); } catch { /* ignore */ }
|
||||||
|
|
||||||
|
const fmt = (meta.type && ['csv', 'json', 'netcdf'].includes(meta.type)) ? meta.type : 'csv';
|
||||||
|
const id = randomUUID();
|
||||||
|
const ext = fmt === 'netcdf' ? 'nc' : fmt;
|
||||||
|
const fileKey = `${id}.${ext}`;
|
||||||
|
|
||||||
|
await bucketExists(BUCKET);
|
||||||
|
await upload(BUCKET, fileKey, req.file.buffer, req.file.size, req.file.mimetype || 'application/octet-stream');
|
||||||
|
|
||||||
|
const createdBy = req.user?.username || meta.created_by || 'unknown';
|
||||||
|
const insert = await query(
|
||||||
|
`INSERT INTO datasets (
|
||||||
|
id, file_key, nome, description, tags, type, format, notes,
|
||||||
|
created_by, size_bytes, copernicus_id, variables, variable_renames,
|
||||||
|
bbox, start_date, end_date, params
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
fileKey,
|
||||||
|
meta.nome || req.file.originalname || fileKey,
|
||||||
|
meta.description || null,
|
||||||
|
Array.isArray(meta.tags) ? meta.tags : [],
|
||||||
|
'copernicus',
|
||||||
|
fmt,
|
||||||
|
meta.notes || null,
|
||||||
|
createdBy,
|
||||||
|
req.file.size,
|
||||||
|
meta.copernicus_id || meta.copernicus_dataset_id || null,
|
||||||
|
Array.isArray(meta.variables) ? meta.variables : null,
|
||||||
|
meta.variable_renames ? JSON.stringify(meta.variable_renames) : null,
|
||||||
|
meta.bbox ? JSON.stringify(meta.bbox) : null,
|
||||||
|
meta.start_date || null,
|
||||||
|
meta.end_date || null,
|
||||||
|
JSON.stringify(meta.params || {}),
|
||||||
|
],
|
||||||
|
'ml'
|
||||||
|
);
|
||||||
|
res.status(201).json(rowToDataset(insert.rows[0]));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[marine/datasets upload]', e);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DETAIL ───────────────────────────────────────────────────────────────
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(`SELECT * FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||||
|
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(rowToDataset(r.rows[0]));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DOWNLOAD presigned ───────────────────────────────────────────────────
|
||||||
|
router.get('/:id/download', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(`SELECT file_key FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||||
|
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
|
const { file_key } = r.rows[0];
|
||||||
|
const url = await download(BUCKET, file_key, 3600);
|
||||||
|
res.json({ url, expires_in: 3600 });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── STREAM raw (per download diretto dal browser) ────────────────────────
|
||||||
|
router.get('/:id/raw', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(`SELECT file_key, nome, format FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||||
|
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
|
const { file_key, nome, format } = r.rows[0];
|
||||||
|
const mime = format === 'json' ? 'application/json' : format === 'csv' ? 'text/csv' : 'application/octet-stream';
|
||||||
|
const ext = format === 'netcdf' ? 'nc' : format;
|
||||||
|
res.setHeader('Content-Type', mime);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(nome)}.${ext}"`);
|
||||||
|
const stream = await getFileStream(BUCKET, file_key);
|
||||||
|
stream.on('error', (err) => { console.error(err); res.end(); });
|
||||||
|
stream.pipe(res);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PATCH metadata ───────────────────────────────────────────────────────
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const allowed = ['nome', 'description', 'tags', 'notes'];
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
for (const k of allowed) {
|
||||||
|
if (k in req.body) {
|
||||||
|
params.push(req.body[k]);
|
||||||
|
sets.push(`${k} = $${params.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
|
||||||
|
|
||||||
|
// Trigger updated_at non presente nel DB: lo aggiorniamo manualmente.
|
||||||
|
sets.push('updated_at = NOW()');
|
||||||
|
|
||||||
|
params.push(req.params.id);
|
||||||
|
const r = await query(
|
||||||
|
`UPDATE datasets SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||||
|
params, 'ml'
|
||||||
|
);
|
||||||
|
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(rowToDataset(r.rows[0]));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE ───────────────────────────────────────────────────────────────
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(`SELECT file_key FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||||
|
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
|
const { file_key } = r.rows[0];
|
||||||
|
try { await removeObject(BUCKET, file_key); } catch (e) { console.warn('[minio remove]', e.message); }
|
||||||
|
await query(`DELETE FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
100
api/src/routes/pageconnections.js
Normal file
100
api/src/routes/pageconnections.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* /pageconnections — registro sessioni di pagina attive, con heartbeat.
|
||||||
|
*
|
||||||
|
* Storage Redis:
|
||||||
|
* pageconn:{page} ZSET score=lastPing, member=session_id
|
||||||
|
* pageconn:meta:{session_id} HASH {page, user_id, created_at}
|
||||||
|
*
|
||||||
|
* Limiti:
|
||||||
|
* page = "test" → max 2 session_id attive (entro TTL heartbeat). Altrimenti 429.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const redis = new Redis({
|
||||||
|
host: process.env.REDIS_HOST || 'meb-redis',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
const HEARTBEAT_TTL_SEC = 30;
|
||||||
|
const LIMITS = { test: 2 };
|
||||||
|
|
||||||
|
function nowSec() { return Math.floor(Date.now() / 1000); }
|
||||||
|
|
||||||
|
async function activeMembers(page) {
|
||||||
|
const min = nowSec() - HEARTBEAT_TTL_SEC;
|
||||||
|
// rimuovi stale
|
||||||
|
await redis.zremrangebyscore(`pageconn:${page}`, '-inf', `(${min}`);
|
||||||
|
return redis.zrange(`pageconn:${page}`, 0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page, session_id, user_id } = req.body || {};
|
||||||
|
if (!page || !session_id) return res.status(400).json({ error: 'page and session_id required' });
|
||||||
|
|
||||||
|
const active = await activeMembers(page);
|
||||||
|
const limit = LIMITS[page];
|
||||||
|
if (limit && !active.includes(session_id) && active.length >= limit) {
|
||||||
|
return res.status(429).json({ error: 'slot full', active: active.length, limit });
|
||||||
|
}
|
||||||
|
const ts = nowSec();
|
||||||
|
await redis.zadd(`pageconn:${page}`, ts, session_id);
|
||||||
|
await redis.hset(`pageconn:meta:${session_id}`, {
|
||||||
|
page,
|
||||||
|
user_id: user_id || req.user?.username || 'unknown',
|
||||||
|
created_at: String(ts),
|
||||||
|
});
|
||||||
|
await redis.expire(`pageconn:meta:${session_id}`, HEARTBEAT_TTL_SEC * 4);
|
||||||
|
res.status(201).json({ session_id, page, active: (await activeMembers(page)).length, limit: limit || null });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:sid/ping', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sid = req.params.sid;
|
||||||
|
const meta = await redis.hgetall(`pageconn:meta:${sid}`);
|
||||||
|
if (!meta || !meta.page) return res.status(404).json({ error: 'session not found' });
|
||||||
|
const ts = nowSec();
|
||||||
|
await redis.zadd(`pageconn:${meta.page}`, ts, sid);
|
||||||
|
await redis.expire(`pageconn:meta:${sid}`, HEARTBEAT_TTL_SEC * 4);
|
||||||
|
res.json({ ok: true, ts });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:sid', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sid = req.params.sid;
|
||||||
|
const meta = await redis.hgetall(`pageconn:meta:${sid}`);
|
||||||
|
if (meta && meta.page) {
|
||||||
|
await redis.zrem(`pageconn:${meta.page}`, sid);
|
||||||
|
}
|
||||||
|
await redis.del(`pageconn:meta:${sid}`);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:page', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const members = await activeMembers(req.params.page);
|
||||||
|
res.json({
|
||||||
|
page: req.params.page,
|
||||||
|
active: members.length,
|
||||||
|
limit: LIMITS[req.params.page] || null,
|
||||||
|
sessions: members,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -2,27 +2,22 @@ const router = require('express').Router();
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { query } = require('../storage/postgres');
|
const { query } = require('../storage/postgres');
|
||||||
|
|
||||||
const sets = ['forecasts', 'sensors'];
|
const sets = ['forecasts', 'sensors', 'marine'];
|
||||||
|
|
||||||
function hashSensorCode(code) {
|
function hashSensorCode(code) {
|
||||||
return crypto.createHash('sha256').update(code).digest('hex');
|
return crypto.createHash('sha256').update(code).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /params/sensor/:sensorCode/active?set=sensors
|
* Middleware: valida sensor code e verifica che il sensore sia attivo.
|
||||||
* Autenticazione tramite SENSOR_CODE (stesso meccanismo di realtime)
|
* Salva sensor.id in req.sensorId.
|
||||||
*/
|
*/
|
||||||
router.get('/:sensorCode/active', async (req, res) => {
|
async function authenticateSensor(req, res, next) {
|
||||||
const { sensorCode } = req.params;
|
const { sensorCode } = req.params;
|
||||||
const { set } = req.query;
|
|
||||||
|
|
||||||
if (!set || !sets.includes(set))
|
|
||||||
return res.status(400).json({ error: 'SET parameter invalid' });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hashed = hashSensorCode(sensorCode);
|
const hashed = hashSensorCode(sensorCode);
|
||||||
const sensor = await query(
|
const sensor = await query(
|
||||||
'SELECT id, is_active FROM sensors WHERE code_hash = $1',
|
'SELECT id, active FROM sensors WHERE code_hash = $1',
|
||||||
[hashed],
|
[hashed],
|
||||||
'sensors'
|
'sensors'
|
||||||
);
|
);
|
||||||
@@ -30,11 +25,29 @@ router.get('/:sensorCode/active', async (req, res) => {
|
|||||||
if (!sensor.rows[0]) {
|
if (!sensor.rows[0]) {
|
||||||
return res.status(401).json({ error: 'Sensor code not valid' });
|
return res.status(401).json({ error: 'Sensor code not valid' });
|
||||||
}
|
}
|
||||||
|
if (!sensor.rows[0].active) {
|
||||||
if (!sensor.rows[0].is_active) {
|
|
||||||
return res.status(403).json({ error: 'Sensor is not active' });
|
return res.status(403).json({ error: 'Sensor is not active' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.sensorId = sensor.rows[0].id;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PARAMS/SENSOR] Auth error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /params/sensor/:sensorCode/active?set=sensors
|
||||||
|
* Ritorna il set di parametri attivo (forecasts, sensors, marine)
|
||||||
|
*/
|
||||||
|
router.get('/:sensorCode/active', authenticateSensor, async (req, res) => {
|
||||||
|
const { set } = req.query;
|
||||||
|
|
||||||
|
if (!set || !sets.includes(set))
|
||||||
|
return res.status(400).json({ error: 'SET parameter invalid' });
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT * FROM ${set} WHERE active = true LIMIT 1`,
|
`SELECT * FROM ${set} WHERE active = true LIMIT 1`,
|
||||||
[],
|
[],
|
||||||
|
|||||||
35
api/src/routes/queue.js
Normal file
35
api/src/routes/queue.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* /queue — stato delle code (solo read). La coda vera è gestita in Redis
|
||||||
|
* dai servizi esecutori (ml-service per `train`). Qui aggreghiamo lo stato
|
||||||
|
* leggendo la tabella `jobs`.
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const { query } = require('../storage/postgres');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const type = req.query.type || 'train';
|
||||||
|
const r = await query(
|
||||||
|
`SELECT id, type, status, created_by, created_at, started_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE type = $1 AND status IN ('queued','running')
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
[type], 'ml'
|
||||||
|
);
|
||||||
|
const queued = r.rows.filter(x => x.status === 'queued');
|
||||||
|
const running = r.rows.filter(x => x.status === 'running');
|
||||||
|
res.json({
|
||||||
|
type,
|
||||||
|
queued_count: queued.length,
|
||||||
|
running_count: running.length,
|
||||||
|
queued,
|
||||||
|
running,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
609
api/src/routes/rules.js
Normal file
609
api/src/routes/rules.js
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
/**
|
||||||
|
* Rulesets API
|
||||||
|
* Base: /rules
|
||||||
|
*
|
||||||
|
* Tipi supportati: logs | forecast_current | forecast_hourly | marine_current | marine_hourly
|
||||||
|
*
|
||||||
|
* Un ruleset ha:
|
||||||
|
* - version {major, build, patch} (interi 1..100, unici per tipo)
|
||||||
|
* - description, tags[]
|
||||||
|
* - items JSONB: array di { ref, path, enabled, meta }
|
||||||
|
* ref: identificatore STABILE scelto dall'utente (chiave logica, usata come tag Influx)
|
||||||
|
* path: SK path (logs) o codice openmeteo (forecast/marine)
|
||||||
|
* meta: libero (unit, measurement, sk_path, name, group_name, category, ...)
|
||||||
|
* - active (un solo attivo per tipo), archived
|
||||||
|
*
|
||||||
|
* Deploy: POST /rules/:type/:id/deploy { sensors: [name,...] }
|
||||||
|
* -> salva in ruleset_deployments
|
||||||
|
* -> notifica il servizio realtime (HTTP interno) che fara' il push WS al plugin
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { query, getClient } = require('../storage/postgres');
|
||||||
|
|
||||||
|
const DB = 'rules';
|
||||||
|
const VALID_TYPES = ['logs', 'forecast_current', 'forecast_hourly', 'marine_current', 'marine_hourly'];
|
||||||
|
|
||||||
|
// Sostituisce il default DB `gen_random_uuid()` (estensione pgcrypto non presente).
|
||||||
|
function genUUID() {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidType(t) { return VALID_TYPES.includes(t); }
|
||||||
|
|
||||||
|
function parseVersion(body) {
|
||||||
|
// accetta sia { version_major, version_build, version_patch } che { version: "1.0.0" }
|
||||||
|
let M = body?.version_major, B = body?.version_build, P = body?.version_patch;
|
||||||
|
if (M === undefined && typeof body?.version === 'string') {
|
||||||
|
const m = body.version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (m) { M = +m[1]; B = +m[2]; P = +m[3]; }
|
||||||
|
}
|
||||||
|
const toInt = v => (v === undefined || v === null || v === '') ? null : parseInt(v, 10);
|
||||||
|
M = toInt(M); B = toInt(B); P = toInt(P);
|
||||||
|
return { M, B, P };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validVersionPart(n, min = 0) {
|
||||||
|
return Number.isInteger(n) && n >= min && n <= 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateItems(items) {
|
||||||
|
if (!Array.isArray(items)) return 'items must be an array';
|
||||||
|
const refs = new Set();
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it || typeof it !== 'object') return 'each item must be an object';
|
||||||
|
if (!it.ref || typeof it.ref !== 'string') return 'each item needs a non-empty "ref"';
|
||||||
|
if (refs.has(it.ref)) return `duplicate ref "${it.ref}"`;
|
||||||
|
refs.add(it.ref);
|
||||||
|
if (it.path !== undefined && typeof it.path !== 'string') return `item "${it.ref}" has invalid path`;
|
||||||
|
if (it.enabled !== undefined && typeof it.enabled !== 'boolean') return `item "${it.ref}" enabled must be boolean`;
|
||||||
|
if (it.meta !== undefined && (typeof it.meta !== 'object' || it.meta === null || Array.isArray(it.meta))) {
|
||||||
|
return `item "${it.ref}" meta must be an object`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItems(items) {
|
||||||
|
return (items || []).map(it => ({
|
||||||
|
ref: String(it.ref),
|
||||||
|
path: it.path != null ? String(it.path) : '',
|
||||||
|
enabled: it.enabled === undefined ? true : !!it.enabled,
|
||||||
|
meta: it.meta && typeof it.meta === 'object' ? it.meta : {}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToRuleset(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
version: {
|
||||||
|
major: row.version_major,
|
||||||
|
build: row.version_build,
|
||||||
|
patch: row.version_patch,
|
||||||
|
str: `${row.version_major}.${row.version_build}.${row.version_patch}`
|
||||||
|
},
|
||||||
|
description: row.description,
|
||||||
|
tags: row.tags || [],
|
||||||
|
items: row.items || [],
|
||||||
|
active: row.active,
|
||||||
|
archived: row.archived,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logChange(rulesetId, type, action, userId, payload) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO ruleset_changes (ruleset_id, type, action, user_id, payload)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[rulesetId, type, action, userId || null, payload ? JSON.stringify(payload) : null],
|
||||||
|
DB
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RULES] audit log error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
SENSORS (deve stare PRIMA delle route /:type/:id per via del routing Express)
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
// GET /rules/-/sensors → lista sensori disponibili (dal DB sensors)
|
||||||
|
router.get('/-/sensors', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(`SELECT name, created_at FROM sensors ORDER BY name`, [], 'sensors');
|
||||||
|
res.json(r.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] sensors list error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
READS
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
// GET /rules → { logs:[], forecast_current:[], ... } (lista completa, senza items per leggerezza)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`SELECT id, type, version_major, version_build, version_patch,
|
||||||
|
description, tags, active, archived, created_at, updated_at,
|
||||||
|
jsonb_array_length(items) AS items_count
|
||||||
|
FROM rulesets
|
||||||
|
ORDER BY type, version_major DESC, version_build DESC, version_patch DESC`,
|
||||||
|
[], DB
|
||||||
|
);
|
||||||
|
const grouped = Object.fromEntries(VALID_TYPES.map(t => [t, []]));
|
||||||
|
for (const row of r.rows) {
|
||||||
|
grouped[row.type].push({
|
||||||
|
...rowToRuleset(row),
|
||||||
|
items: undefined,
|
||||||
|
items_count: row.items_count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json(grouped);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] list error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /rules/:type → lista versioni del tipo
|
||||||
|
router.get('/:type', async (req, res) => {
|
||||||
|
const { type } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`SELECT * FROM rulesets WHERE type = $1
|
||||||
|
ORDER BY version_major DESC, version_build DESC, version_patch DESC`,
|
||||||
|
[type], DB
|
||||||
|
);
|
||||||
|
res.json(r.rows.map(rowToRuleset));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] list type error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /rules/:type/:id
|
||||||
|
router.get('/:type/:id', async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const r = await query(`SELECT * FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(rowToRuleset(r.rows[0]));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] get error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /rules/:type/active → ruleset attivo
|
||||||
|
router.get('/:type/-/active', async (req, res) => {
|
||||||
|
const { type } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`SELECT * FROM rulesets WHERE type = $1 AND active = true AND archived = false LIMIT 1`,
|
||||||
|
[type], DB
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'no active ruleset' });
|
||||||
|
res.json(rowToRuleset(r.rows[0]));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] active error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
WRITES
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
// POST /rules/:type
|
||||||
|
router.post('/:type', async (req, res) => {
|
||||||
|
const { type } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
|
||||||
|
let { M, B, P } = parseVersion(req.body);
|
||||||
|
if (M === null) M = 1;
|
||||||
|
if (B === null) B = 0;
|
||||||
|
if (P === null) P = 0;
|
||||||
|
if (!validVersionPart(M, 1) || !validVersionPart(B) || !validVersionPart(P)) {
|
||||||
|
return res.status(400).json({ error: 'version parts must be integers (major 1..100, build/patch 0..100)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = typeof req.body?.description === 'string' ? req.body.description : '';
|
||||||
|
const tags = Array.isArray(req.body?.tags) ? req.body.tags.map(String) : [];
|
||||||
|
const items = normalizeItems(req.body?.items);
|
||||||
|
const itemsErr = validateItems(items);
|
||||||
|
if (itemsErr) return res.status(400).json({ error: itemsErr });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newId = genUUID();
|
||||||
|
const r = await query(
|
||||||
|
`INSERT INTO rulesets (id, type, version_major, version_build, version_patch,
|
||||||
|
description, tags, items, active, archived,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,false,false,NOW(),NOW()) RETURNING *`,
|
||||||
|
[newId, type, M, B, P, description, tags, JSON.stringify(items)],
|
||||||
|
DB
|
||||||
|
);
|
||||||
|
const rs = rowToRuleset(r.rows[0]);
|
||||||
|
logChange(rs.id, type, 'created', req.user?.user_id, { version: rs.version.str });
|
||||||
|
res.status(201).json(rs);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'version already exists for this type' });
|
||||||
|
}
|
||||||
|
console.error('[RULES] create error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message || 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /rules/:type/:id → update campi (version, description, tags, items)
|
||||||
|
router.put('/:type/:id', async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
if (req.body?.description !== undefined) {
|
||||||
|
fields.push(`description = $${i++}`); values.push(String(req.body.description));
|
||||||
|
}
|
||||||
|
if (req.body?.tags !== undefined) {
|
||||||
|
if (!Array.isArray(req.body.tags)) return res.status(400).json({ error: 'tags must be array' });
|
||||||
|
fields.push(`tags = $${i++}`); values.push(req.body.tags.map(String));
|
||||||
|
}
|
||||||
|
if (req.body?.items !== undefined) {
|
||||||
|
const items = normalizeItems(req.body.items);
|
||||||
|
const itemsErr = validateItems(items);
|
||||||
|
if (itemsErr) return res.status(400).json({ error: itemsErr });
|
||||||
|
fields.push(`items = $${i++}`); values.push(JSON.stringify(items));
|
||||||
|
}
|
||||||
|
if (req.body?.version_major !== undefined || req.body?.version_build !== undefined ||
|
||||||
|
req.body?.version_patch !== undefined || req.body?.version !== undefined) {
|
||||||
|
const { M, B, P } = parseVersion(req.body);
|
||||||
|
if (!validVersionPart(M, 1) || !validVersionPart(B) || !validVersionPart(P)) {
|
||||||
|
return res.status(400).json({ error: 'version parts must be integers (major 1..100, build/patch 0..100)' });
|
||||||
|
}
|
||||||
|
fields.push(`version_major = $${i++}`); values.push(M);
|
||||||
|
fields.push(`version_build = $${i++}`); values.push(B);
|
||||||
|
fields.push(`version_patch = $${i++}`); values.push(P);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fields.length) return res.status(400).json({ error: 'no fields to update' });
|
||||||
|
|
||||||
|
// Trigger set_updated_at non presente: lo facciamo manualmente.
|
||||||
|
fields.push('updated_at = NOW()');
|
||||||
|
values.push(id, type);
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`UPDATE rulesets SET ${fields.join(', ')}
|
||||||
|
WHERE id = $${i++} AND type = $${i}
|
||||||
|
RETURNING *`,
|
||||||
|
values, DB
|
||||||
|
);
|
||||||
|
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
const rs = rowToRuleset(r.rows[0]);
|
||||||
|
logChange(rs.id, type, 'updated', req.user?.user_id, null);
|
||||||
|
res.json(rs);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'version already exists for this type' });
|
||||||
|
}
|
||||||
|
console.error('[RULES] update error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message || 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /rules/:type/:id/active → toggle (deattiva le altre dello stesso tipo)
|
||||||
|
router.patch('/:type/:id/active', async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
|
||||||
|
const client = await getClient(DB);
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const cur = await client.query(
|
||||||
|
`SELECT active, archived FROM rulesets WHERE id = $1 AND type = $2`,
|
||||||
|
[id, type]
|
||||||
|
);
|
||||||
|
if (!cur.rows[0]) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); }
|
||||||
|
if (cur.rows[0].archived && !cur.rows[0].active) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(409).json({ error: 'cannot activate an archived ruleset' });
|
||||||
|
}
|
||||||
|
const willActivate = !cur.rows[0].active;
|
||||||
|
if (willActivate) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE rulesets SET active = false, updated_at = NOW()
|
||||||
|
WHERE type = $1 AND active = true`,
|
||||||
|
[type]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const r = await client.query(
|
||||||
|
`UPDATE rulesets SET active = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2 RETURNING *`,
|
||||||
|
[willActivate, id]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
logChange(id, type, willActivate ? 'activated' : 'deactivated', req.user?.user_id, null);
|
||||||
|
res.json({ active: r.rows[0].active, ruleset: rowToRuleset(r.rows[0]) });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('[RULES] active error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /rules/:type/:id/archive → toggle
|
||||||
|
router.patch('/:type/:id/archive', async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const cur = await query(`SELECT archived, active FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||||
|
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
const willArchive = !cur.rows[0].archived;
|
||||||
|
// archiviare implica disattivare
|
||||||
|
const r = await query(
|
||||||
|
`UPDATE rulesets SET archived = $1,
|
||||||
|
active = CASE WHEN $1 = true THEN false ELSE active END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2 RETURNING *`,
|
||||||
|
[willArchive, id], DB
|
||||||
|
);
|
||||||
|
logChange(id, type, willArchive ? 'archived' : 'unarchived', req.user?.user_id, null);
|
||||||
|
res.json({ archived: r.rows[0].archived, ruleset: rowToRuleset(r.rows[0]) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] archive error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /rules/:type/:id
|
||||||
|
// FK CASCADE non e' garantita lato DB: cancelliamo manualmente in transazione
|
||||||
|
// prima i deployments e poi il ruleset. ruleset_changes (audit) viene preservato
|
||||||
|
// volutamente — annulliamo solo ruleset_id se necessario.
|
||||||
|
router.delete('/:type/:id', async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
const client = await getClient(DB);
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const cur = await client.query(
|
||||||
|
`SELECT active FROM rulesets WHERE id = $1 AND type = $2`,
|
||||||
|
[id, type]
|
||||||
|
);
|
||||||
|
if (!cur.rows[0]) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
if (cur.rows[0].active) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(409).json({ error: 'cannot delete active ruleset' });
|
||||||
|
}
|
||||||
|
await client.query(`DELETE FROM ruleset_deployments WHERE ruleset_id = $1`, [id]);
|
||||||
|
await client.query(`DELETE FROM rulesets WHERE id = $1`, [id]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
logChange(id, type, 'deleted', req.user?.user_id, null);
|
||||||
|
res.json({ deleted: true });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('[RULES] delete error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
ITEMS: helper endpoints (comodi per la UI)
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
// POST /rules/:type/:id/items → aggiungi item
|
||||||
|
router.post('/:type/:id/items', async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||||
|
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
const items = cur.rows[0].items || [];
|
||||||
|
const newItem = normalizeItems([req.body || {}])[0];
|
||||||
|
if (!newItem.ref) return res.status(400).json({ error: 'ref required' });
|
||||||
|
if (items.some(it => it.ref === newItem.ref)) {
|
||||||
|
return res.status(409).json({ error: `ref "${newItem.ref}" already exists` });
|
||||||
|
}
|
||||||
|
items.push(newItem);
|
||||||
|
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
|
||||||
|
logChange(id, type, 'item_added', req.user?.user_id, { ref: newItem.ref });
|
||||||
|
res.status(201).json(newItem);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] add item error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message || 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /rules/:type/:id/items/:ref → patch item (per ref)
|
||||||
|
router.put('/:type/:id/items/:ref', async (req, res) => {
|
||||||
|
const { type, id, ref } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||||
|
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
const items = cur.rows[0].items || [];
|
||||||
|
const idx = items.findIndex(it => it.ref === ref);
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'item not found' });
|
||||||
|
|
||||||
|
const body = req.body || {};
|
||||||
|
const newRef = body.ref !== undefined ? String(body.ref) : items[idx].ref;
|
||||||
|
if (newRef !== ref && items.some(it => it.ref === newRef)) {
|
||||||
|
return res.status(409).json({ error: `ref "${newRef}" already exists` });
|
||||||
|
}
|
||||||
|
items[idx] = {
|
||||||
|
ref: newRef,
|
||||||
|
path: body.path !== undefined ? String(body.path) : items[idx].path,
|
||||||
|
enabled: body.enabled !== undefined ? !!body.enabled : items[idx].enabled,
|
||||||
|
meta: body.meta !== undefined
|
||||||
|
? (body.meta && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : {})
|
||||||
|
: { ...(items[idx].meta || {}), ...Object.fromEntries(
|
||||||
|
Object.entries(body).filter(([k]) => !['ref','path','enabled','meta'].includes(k))
|
||||||
|
) }
|
||||||
|
};
|
||||||
|
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
|
||||||
|
logChange(id, type, 'item_updated', req.user?.user_id, { ref: newRef });
|
||||||
|
res.json(items[idx]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] update item error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message || 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /rules/:type/:id/items/:ref/toggle
|
||||||
|
router.patch('/:type/:id/items/:ref/toggle', async (req, res) => {
|
||||||
|
const { type, id, ref } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||||
|
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
const items = cur.rows[0].items || [];
|
||||||
|
const idx = items.findIndex(it => it.ref === ref);
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'item not found' });
|
||||||
|
items[idx].enabled = !items[idx].enabled;
|
||||||
|
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
|
||||||
|
res.json({ enabled: items[idx].enabled });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] toggle item error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /rules/:type/:id/items/:ref
|
||||||
|
router.delete('/:type/:id/items/:ref', async (req, res) => {
|
||||||
|
const { type, id, ref } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||||
|
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
const items = (cur.rows[0].items || []).filter(it => it.ref !== ref);
|
||||||
|
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
|
||||||
|
res.json({ deleted: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] delete item error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
SENSORS & DEPLOYMENT
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
// GET /rules/:type/:id/deployments → sensori su cui e' deployato
|
||||||
|
router.get('/:type/:id/deployments', async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
try {
|
||||||
|
const r = await query(
|
||||||
|
`SELECT sensor_name, deployed_at, acked_at
|
||||||
|
FROM ruleset_deployments
|
||||||
|
WHERE ruleset_id = $1 AND type = $2
|
||||||
|
ORDER BY deployed_at DESC`,
|
||||||
|
[id, type], DB
|
||||||
|
);
|
||||||
|
res.json(r.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] deployments error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /rules/:type/:id/deploy { sensors: [name, ...] }
|
||||||
|
// Registra il deploy e notifica il servizio realtime, che fara' il push WS al plugin.
|
||||||
|
router.post('/:type/:id/deploy', async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||||
|
const sensors = Array.isArray(req.body?.sensors) ? req.body.sensors.map(String).filter(Boolean) : [];
|
||||||
|
if (!sensors.length) return res.status(400).json({ error: 'sensors array required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rs = await query(`SELECT * FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||||
|
if (!rs.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (rs.rows[0].archived) return res.status(409).json({ error: 'cannot deploy archived ruleset' });
|
||||||
|
|
||||||
|
const ruleset = rowToRuleset(rs.rows[0]);
|
||||||
|
|
||||||
|
// upsert deployments
|
||||||
|
for (const name of sensors) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO ruleset_deployments (sensor_name, type, ruleset_id, deployed_at, acked_at)
|
||||||
|
VALUES ($1, $2, $3, NOW(), NULL)
|
||||||
|
ON CONFLICT (sensor_name, type) DO UPDATE
|
||||||
|
SET ruleset_id = EXCLUDED.ruleset_id,
|
||||||
|
deployed_at = NOW(),
|
||||||
|
acked_at = NULL`,
|
||||||
|
[name, type, id], DB
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifica realtime (best-effort)
|
||||||
|
const RT = process.env.REALTIME_URL || 'http://meb-realtime:3000';
|
||||||
|
const KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
const results = { pushed: [], offline: [], errors: [] };
|
||||||
|
if (KEY) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${RT}/rules/push`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
|
||||||
|
body: JSON.stringify({ sensors, type, ruleset })
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
Object.assign(results, j);
|
||||||
|
} else {
|
||||||
|
results.errors.push(`realtime HTTP ${r.status}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
results.errors.push(`realtime unreachable: ${e.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.errors.push('INTERNAL_API_KEY missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
logChange(id, type, 'deployed', req.user?.user_id, { sensors, results });
|
||||||
|
res.json({ deployed: sensors, ...results });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] deploy error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message || 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /rules/:type/:id/ack { sensor } → chiamato dal servizio realtime quando il plugin conferma
|
||||||
|
router.post('/:type/:id/ack', async (req, res) => {
|
||||||
|
if (!req.internal) return res.status(403).json({ error: 'forbidden' });
|
||||||
|
const { type, id } = req.params;
|
||||||
|
const sensor = req.body?.sensor;
|
||||||
|
if (!isValidType(type) || !sensor) return res.status(400).json({ error: 'bad request' });
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`UPDATE ruleset_deployments
|
||||||
|
SET acked_at = NOW()
|
||||||
|
WHERE sensor_name = $1 AND type = $2 AND ruleset_id = $3`,
|
||||||
|
[String(sensor), type, id], DB
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RULES] ack error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
107
api/src/routes/sessions.js
Normal file
107
api/src/routes/sessions.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const { query: dbQuery } = require('../storage/postgres');
|
||||||
|
const { listInfluxSessions, querySessionHistory, exportSessionCSV } = require('../storage/influx');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/history
|
||||||
|
* Fonte primaria: InfluxDB (tag sensor + session sul measurement "logs").
|
||||||
|
* Arricchisce con i metadati opzionali da PostgreSQL (sessiondataref): nome, tags, descrizione.
|
||||||
|
*/
|
||||||
|
router.get('/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessions = await listInfluxSessions();
|
||||||
|
|
||||||
|
let pgMap = {};
|
||||||
|
try {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT * FROM sessiondataref`,
|
||||||
|
[],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
result.rows.forEach(r => { pgMap[r.session_id] = r; });
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const enriched = sessions.map(s => ({
|
||||||
|
session_id: s.session,
|
||||||
|
sensor_name: s.sensor,
|
||||||
|
startTime: s.startTime,
|
||||||
|
endTime: s.endTime,
|
||||||
|
// campi opzionali da PostgreSQL
|
||||||
|
name: pgMap[s.session]?.name || null,
|
||||||
|
description: pgMap[s.session]?.description || null,
|
||||||
|
tags: pgMap[s.session]?.tags || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(enriched);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] history error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/:sensorId/data?session=sXXXX&from=ISO&to=ISO
|
||||||
|
* Restituisce i dati storici di una sessione come JSON (righe pivotate da InfluxDB).
|
||||||
|
*/
|
||||||
|
router.get('/:sensorId/data', async (req, res) => {
|
||||||
|
const { sensorId } = req.params;
|
||||||
|
const { session, from, to } = req.query;
|
||||||
|
|
||||||
|
if (!session) return res.status(400).json({ error: 'session param required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let since = from || null;
|
||||||
|
if (!since) {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
|
||||||
|
[session],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
since = result.rows[0]?.created_at?.toISOString() || '-30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = to ? new Date(parseInt(to)).toISOString() : null;
|
||||||
|
const rows = await querySessionHistory(sensorId, session, since, until);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] data error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/:sensorId/csv?session=sXXXX&from=ms&to=ms
|
||||||
|
* Esporta i dati di una sessione come CSV (supporta intervallo opzionale).
|
||||||
|
*/
|
||||||
|
router.get('/:sensorId/csv', async (req, res) => {
|
||||||
|
const { sensorId } = req.params;
|
||||||
|
const { session, from, to } = req.query;
|
||||||
|
|
||||||
|
if (!session) return res.status(400).json({ error: 'session param required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let since = from ? new Date(parseInt(from)).toISOString() : null;
|
||||||
|
if (!since) {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
|
||||||
|
[session],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
since = result.rows[0]?.created_at?.toISOString() || '-30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = to ? new Date(parseInt(to)).toISOString() : null;
|
||||||
|
const csv = await exportSessionCSV(sensorId, session, since, until);
|
||||||
|
|
||||||
|
if (!csv) return res.status(404).json({ error: 'No data found for this session' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="session_${session}_${sensorId}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] csv error:', err.message);
|
||||||
|
res.status(500).json({ error: 'CSV export failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
9
api/src/routes/settings.js
Normal file
9
api/src/routes/settings.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// api.mebboat.it/settings
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -10,6 +10,7 @@ const client = new InfluxDB({ url, token })
|
|||||||
const write = client.getWriteApi(org, boatTelemetry);
|
const write = client.getWriteApi(org, boatTelemetry);
|
||||||
const querying = client.getQueryApi(org);
|
const querying = client.getQueryApi(org);
|
||||||
|
|
||||||
|
console.log("InfluxDB client initialized with config:", { url, org, token });
|
||||||
|
|
||||||
async function append(measurement, sensor, data) {
|
async function append(measurement, sensor, data) {
|
||||||
const point = new Point(measurement)
|
const point = new Point(measurement)
|
||||||
@@ -59,19 +60,134 @@ async function query(bucket, relativeTime, measurement, sensor, field) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sorgente di verità per i logs di sessione: stesso bucket usato da
|
||||||
|
// realtime/store/influx.js. Sovrascrivibile via env per ambiente.
|
||||||
|
const sessionBucket = process.env.INFLX_BUCKET_LOGS || process.env.INFLX_BUCKET || 'logs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query storica per una sessione di registrazione.
|
||||||
|
* @param {string} sensor - nome sensore
|
||||||
|
* @param {string} session - session_id (tag InfluxDB)
|
||||||
|
* @param {string} since - ISO timestamp o duration (es. "-30d")
|
||||||
|
* @param {string|null} until - ISO timestamp fine (opzionale)
|
||||||
|
* @returns {Promise<Array<Object>>}
|
||||||
|
*/
|
||||||
|
async function querySessionHistory(sensor, session, since, until = null) {
|
||||||
|
const rangeStr = until ? `start: ${since}, stop: ${until}` : `start: ${since}`;
|
||||||
|
const fluxQuery = `
|
||||||
|
from(bucket: "${sessionBucket}")
|
||||||
|
|> range(${rangeStr})
|
||||||
|
|> filter(fn: (r) => r._measurement == "logs")
|
||||||
|
|> filter(fn: (r) => r.sensor == "${sensor}")
|
||||||
|
|> filter(fn: (r) => r.session == "${session}")
|
||||||
|
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||||
|
|> sort(columns: ["_time"])
|
||||||
|
`;
|
||||||
|
const rows = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
querying.queryRows(fluxQuery, {
|
||||||
|
next(row, tableMeta) { rows.push(tableMeta.toObject(row)); },
|
||||||
|
error: reject,
|
||||||
|
complete() { resolve(rows); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esporta i dati di una sessione come stringa CSV.
|
||||||
|
* @param {string} sensor
|
||||||
|
* @param {string} session
|
||||||
|
* @param {string} since
|
||||||
|
* @param {string|null} until
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function exportSessionCSV(sensor, session, since, until = null) {
|
||||||
|
const rows = await querySessionHistory(sensor, session, since, until);
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
|
||||||
|
const fieldNames = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (!metaKeys.has(key) && key !== '_time') fieldNames.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fields = Array.from(fieldNames).sort();
|
||||||
|
const header = ['timestamp', ...fields].join(',');
|
||||||
|
const csvRows = rows.map(row => {
|
||||||
|
const values = fields.map(f => { const v = row[f]; return (v == null) ? '' : v; });
|
||||||
|
return [row._time || '', ...values].join(',');
|
||||||
|
});
|
||||||
|
return header + '\n' + csvRows.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility interna: esegue una Flux query e restituisce le righe come array di oggetti.
|
||||||
|
*/
|
||||||
|
function runFlux(fluxQuery) {
|
||||||
|
const rows = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
querying.queryRows(fluxQuery, {
|
||||||
|
next(row, tableMeta) { rows.push(tableMeta.toObject(row)); },
|
||||||
|
error: reject,
|
||||||
|
complete() { resolve(rows); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenca tutte le sessioni presenti in InfluxDB, con primo e ultimo timestamp.
|
||||||
|
* Sorgente di verità: tag sensor + session sul measurement "logs".
|
||||||
|
* @param {string} [lookback='-5y'] - range di ricerca (es. '-365d', '-5y')
|
||||||
|
* @returns {Promise<Array<{session, sensor, startTime, endTime}>>}
|
||||||
|
*/
|
||||||
|
async function listInfluxSessions(lookback = '-5y') {
|
||||||
|
const base = `
|
||||||
|
from(bucket: "${sessionBucket}")
|
||||||
|
|> range(start: ${lookback})
|
||||||
|
|> filter(fn: (r) => r._measurement == "logs")
|
||||||
|
|> group(columns: ["sensor", "session"])
|
||||||
|
`;
|
||||||
|
const [firstRows, lastRows] = await Promise.all([
|
||||||
|
runFlux(base + '|> first() |> keep(columns: ["_time", "sensor", "session"])'),
|
||||||
|
runFlux(base + '|> last() |> keep(columns: ["_time", "sensor", "session"])'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const map = {};
|
||||||
|
firstRows.forEach(r => {
|
||||||
|
if (!r.session) return;
|
||||||
|
map[r.session] = { session: r.session, sensor: r.sensor, startTime: r._time };
|
||||||
|
});
|
||||||
|
lastRows.forEach(r => {
|
||||||
|
if (map[r.session]) map[r.session].endTime = r._time;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(map).sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
|
||||||
|
}
|
||||||
|
|
||||||
async function checkInflux() {
|
async function checkInflux() {
|
||||||
try {
|
try {
|
||||||
await querying.rows(`from(bucket: "boat") |> range(start: -1s) |> limit(n:1)`).next();
|
const result = await querying.collectRows(
|
||||||
return true;
|
`from(bucket: "boat") |> range(start: -1s) |> limit(n:1)`
|
||||||
|
);
|
||||||
|
console.log('InfluxDB: OK');
|
||||||
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
console.error('InfluxDB check failed:', {
|
||||||
|
message: error.message,
|
||||||
|
statusCode: error.statusCode ?? 'N/A',
|
||||||
|
body: error.body ?? 'N/A'
|
||||||
|
});
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
write:append,
|
write: append,
|
||||||
writeBatch,
|
writeBatch,
|
||||||
query,
|
query,
|
||||||
|
listInfluxSessions,
|
||||||
|
querySessionHistory,
|
||||||
|
exportSessionCSV,
|
||||||
checkInflux
|
checkInflux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ const client = new Minio.Client({
|
|||||||
secretKey: process.env.MINIO_SECRET_KEY
|
secretKey: process.env.MINIO_SECRET_KEY
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Unified ML bucket: tutti gli oggetti del dominio ML vivono nel bucket
|
||||||
|
// indicato da MINIO_BUCKET (default "ml"), con prefissi logici:
|
||||||
|
// datasets/<uuid>.<ext>
|
||||||
|
// models/<model_id>/<version>/<patch>/...
|
||||||
|
// trainings/<training_id>/logs.jsonl
|
||||||
|
const ML_BUCKET = process.env.MINIO_BUCKET || 'ml';
|
||||||
|
|
||||||
|
|
||||||
// Buckets
|
// Buckets
|
||||||
|
|
||||||
@@ -132,7 +139,51 @@ async function checkMinio() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legge un oggetto e restituisce il suo contenuto come stringa UTF-8 (comodo per file testo/markdown).
|
||||||
|
*/
|
||||||
|
async function readText(bucket, objectName) {
|
||||||
|
const stream = await client.getObject(bucket, objectName);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
stream.on('data', (c) => chunks.push(c));
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrive una stringa come oggetto (auto-create del bucket).
|
||||||
|
*/
|
||||||
|
async function writeText(bucket, objectName, content, contentType = 'text/markdown; charset=utf-8') {
|
||||||
|
await bucketExists(bucket);
|
||||||
|
const buf = Buffer.from(content, 'utf8');
|
||||||
|
await client.putObject(bucket, objectName, buf, buf.length, { 'Content-Type': contentType });
|
||||||
|
return { bucket, objectName, size: buf.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenca oggetti di un bucket in forma compatta (name, size, lastModified).
|
||||||
|
*/
|
||||||
|
async function listObjects(bucket) {
|
||||||
|
await bucketExists(bucket);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const out = [];
|
||||||
|
const stream = client.listObjects(bucket, '', true);
|
||||||
|
stream.on('data', (o) => out.push({
|
||||||
|
name: o.name,
|
||||||
|
size: o.size,
|
||||||
|
lastModified: o.lastModified,
|
||||||
|
etag: o.etag,
|
||||||
|
}));
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('end', () => resolve(out));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
client,
|
||||||
|
ML_BUCKET,
|
||||||
bucketExists,
|
bucketExists,
|
||||||
getBuckets,
|
getBuckets,
|
||||||
getBucket,
|
getBucket,
|
||||||
@@ -142,5 +193,8 @@ module.exports = {
|
|||||||
upload,
|
upload,
|
||||||
download,
|
download,
|
||||||
getFileStream,
|
getFileStream,
|
||||||
checkMinio
|
checkMinio,
|
||||||
|
readText,
|
||||||
|
writeText,
|
||||||
|
listObjects,
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
user: process.env.POSTGRES_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.POSTGRES_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
host: process.env.POSTGRES_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: process.env.POSTGRES_PORT,
|
port: process.env.DB_PORT,
|
||||||
max: 10,
|
max: 10,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000
|
connectionTimeoutMillis: 5000
|
||||||
}
|
}
|
||||||
|
|
||||||
const pools = {
|
const pools = {
|
||||||
users: new Pool({ ...config, database: process.env.DATA_DB }),
|
data: new Pool({ ...config, database: process.env.DATA_DB }),
|
||||||
references: new Pool({ ...config, database: process.env.REFERENCES_DB }),
|
users: new Pool({ ...config, database: process.env.USERS_DB }),
|
||||||
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' })
|
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }),
|
||||||
|
rules: new Pool({ ...config, database: process.env.RULES_DB || 'rules' }),
|
||||||
|
ml: new Pool({ ...config, database: process.env.ML_DB || 'ml' }),
|
||||||
|
references: new Pool({ ...config, database: process.env.REFERENCES_DB || 'references' }),
|
||||||
|
// Le tabelle kiosktemplates / kioskelements vivono nel DB `sensors`.
|
||||||
|
// KIOSK_DB resta override-abile per environment legacy.
|
||||||
|
kiosk: new Pool({ ...config, database: process.env.KIOSK_DB || 'sensors' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(pools).forEach(([name, pool]) => {
|
Object.entries(pools).forEach(([name, pool]) => {
|
||||||
@@ -30,6 +36,7 @@ Object.entries(pools).forEach(([name, pool]) => {
|
|||||||
async function getClient(db) {
|
async function getClient(db) {
|
||||||
const pool = pools[db];
|
const pool = pools[db];
|
||||||
if (!pool) throw new Error(`Database pool type ${db} does not exist`);
|
if (!pool) throw new Error(`Database pool type ${db} does not exist`);
|
||||||
|
console.log(`Acquiring client for ${db} database... with config:`, config);
|
||||||
return await pool.connect();
|
return await pool.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,13 +78,20 @@ async function remove(table, condition, params, type = 'users') {
|
|||||||
return await query(sql, params, type);
|
return await query(sql, params, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initKioskSchema rimosso: lo schema kiosktemplates/kioskelements vive nel DB
|
||||||
|
// `sensors` ed e' gestito dalla migration 007_kiosktemplates.sql. L'auto-create
|
||||||
|
// qui creava un schema legacy divergente (UUID + JSONB content) sul DB sbagliato.
|
||||||
|
|
||||||
async function checkPostgres() {
|
async function checkPostgres() {
|
||||||
const status = {};
|
const status = {};
|
||||||
|
console.log("Checking PostgreSQL connections with config:", config);
|
||||||
for (const [name, pool] of Object.entries(pools)) {
|
for (const [name, pool] of Object.entries(pools)) {
|
||||||
try {
|
try {
|
||||||
await pool.query('SELECT NOW()');
|
await pool.query('SELECT NOW()');
|
||||||
|
console.log(`PostgreSQL connection check successful for ${name}`);
|
||||||
status[name] = 'connected';
|
status[name] = 'connected';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(`PostgreSQL connection check failed for ${name}:`, error.message);
|
||||||
status[name] = 'disconnected';
|
status[name] = 'disconnected';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
auth/.env.example
Normal file
26
auth/.env.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
DB_USER=
|
||||||
|
DB_HOST=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_PORT=
|
||||||
|
|
||||||
|
USERS_DB=
|
||||||
|
|
||||||
|
REDIS_HOST=
|
||||||
|
REDIS_PORT=
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# In locale: lasciare vuoto (il cookie su localhost funziona su tutte le porte)
|
||||||
|
# In produzione: .mebboat.it (condiviso tra auth.mebboat.it, console.mebboat.it, api.mebboat.it)
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
COOKIE_NAME=
|
||||||
|
|
||||||
|
PORT=3006
|
||||||
|
|
||||||
|
JWT_SECRET=
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
CONSOLE_URL=
|
||||||
|
|
||||||
|
VERSION=1.4.0
|
||||||
|
VERSION_BUILD=1.0
|
||||||
|
VERSION_STATE=pre-release
|
||||||
@@ -1,120 +1,166 @@
|
|||||||
const query = require('../storage/database').query;
|
|
||||||
const track = require('../tools/tracking')
|
|
||||||
const { v4: uuid } = require('uuid');
|
const { v4: uuid } = require('uuid');
|
||||||
const security = require('../tools/security')
|
const { query } = require('../storage/database');
|
||||||
|
const security = require('../tools/security');
|
||||||
|
const tracking = require('../tools/tracking');
|
||||||
|
|
||||||
|
// ─── ERRORI CUSTOM ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AuthError extends Error {
|
||||||
|
constructor(code, message) {
|
||||||
|
super(message || code);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── REGISTRAZIONE ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Registra un nuovo utente
|
|
||||||
*/
|
|
||||||
async function register(username, password) {
|
async function register(username, password) {
|
||||||
const userExists = await query('SELECT id FROM users WHERE username = $1', [username]);
|
const exists = await query('SELECT id FROM users WHERE username = $1', [username]);
|
||||||
|
if (exists.rows.length) throw new AuthError('USER_EXISTS', 'Username già in uso');
|
||||||
|
|
||||||
if (userExists.rows.length > 0) {
|
const hash = await security.hashPassword(password);
|
||||||
throw new Error('User already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = security.hashPassword(password);
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
await query(
|
||||||
await query('INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', [id, username, hashedPassword]);
|
'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)',
|
||||||
|
[id, username, hash]
|
||||||
return {
|
);
|
||||||
success: true,
|
return { id, username };
|
||||||
user: {
|
|
||||||
id,
|
|
||||||
username
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── LOGIN ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Esegue il login di un utente
|
|
||||||
*/
|
|
||||||
async function login(username, password) {
|
async function login(username, password) {
|
||||||
const result = await query('SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', [username]);
|
const { rows } = await query(
|
||||||
if (result.rows.length === 0) {
|
'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1',
|
||||||
throw new Error('No user matched')
|
[username]
|
||||||
}
|
);
|
||||||
|
if (!rows.length) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
|
||||||
|
|
||||||
const user = result.rows[0];
|
const user = rows[0];
|
||||||
const isValid = await security.verifyPassword(password, user.password_hash);
|
if (!user.is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
|
||||||
|
|
||||||
if (!isValid) {
|
const ok = await security.verifyPassword(password, user.password_hash);
|
||||||
throw new Error('Password mismatch')
|
if (!ok) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return { id: user.id, username: user.username, created_at: user.created_at };
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
created: user.created_at
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── SESSIONI ───────────────────────────────────────────────────────
|
||||||
* Esegue il logout di un utente
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
async function logout(sessionID) {
|
|
||||||
if (!sessionID) {
|
|
||||||
throw new Error('no sessio id passed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query('UPDATE sessions SET is_revoked = TRUE WHERE id = $1', [sessionID]);
|
async function createSession(userId, userAgent, ip) {
|
||||||
return result.rowCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea una nuova sessione per un utente che ha appaena eseguito il login
|
|
||||||
*/
|
|
||||||
async function newSession(userId, userAgent, ip) {
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
const sessionCode = security.generateSessionCode();
|
const code = security.sessionCode();
|
||||||
const metadata = track.getBasicMetadata(userAgent);
|
const meta = tracking.extract(userAgent);
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO sessions (id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type)
|
`INSERT INTO sessions
|
||||||
|
(id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
[id, userId, sessionCode, '', ip, userAgent, metadata.browser, metadata.os, metadata.device_type]
|
[id, userId, code, '', ip, userAgent, meta.browser, meta.os, meta.device_type]
|
||||||
|
);
|
||||||
|
return { id, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSession(sessionId) {
|
||||||
|
if (!sessionId || typeof sessionId !== 'string') {
|
||||||
|
throw new AuthError('INVALID_SESSION', 'Sessione non valida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT s.id, s.is_revoked, u.is_active
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
[sessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { id, sessionCode };
|
if (!rows.length) throw new AuthError('INVALID_SESSION', 'Sessione non trovata');
|
||||||
|
if (rows[0].is_revoked) throw new AuthError('SESSION_REVOKED', 'Sessione revocata');
|
||||||
|
if (!rows[0].is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
|
||||||
|
|
||||||
|
// Aggiorna last_active in modo non bloccante
|
||||||
|
query('UPDATE sessions SET last_active = NOW() WHERE id = $1', [sessionId]).catch(() => {});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function revokeSession(sessionId, userId) {
|
||||||
* Valida una sessione
|
if (userId) {
|
||||||
*/
|
const r = await query(
|
||||||
async function validateSession(token) {
|
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE',
|
||||||
const parsed = security.parseSessionToken(token);
|
[sessionId, userId]
|
||||||
|
);
|
||||||
if (!parsed) {
|
return r.rowCount > 0;
|
||||||
throw new Error('Invalid token format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { code, username } = parsed;
|
|
||||||
|
|
||||||
const result = await query('SELECT s.id as session_id, s.user_id, u.username, u.is_active, u.created_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_code = $1 AND s.is_revoked = FALSE', [code]);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
throw new Error('Session not found or revoked')
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = result.rows[0];
|
|
||||||
|
|
||||||
if (session.username !== username) {
|
|
||||||
throw new Error('Session user mismatch');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.is_active) {
|
|
||||||
throw new Error('Session is not active');
|
|
||||||
}
|
}
|
||||||
|
const r = await query(
|
||||||
|
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND is_revoked = FALSE',
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
return r.rowCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listSessions(userId) {
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT id, ip_address, browser, os, device_type,
|
||||||
|
location_country, location_city, created_at, last_active, is_revoked
|
||||||
|
FROM sessions
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY last_active DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LOOKUP UTENTE ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getUserById(userId) {
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT id, username, is_active, created_at, telegram_id FROM users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllUsers() {
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT id, username, is_active, created_at, telegram_id FROM users'
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUsersToNotify() {
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL'
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUsername(userId, newUsername) {
|
||||||
|
const r = await query(
|
||||||
|
'UPDATE users SET username = $1 WHERE id = $2 RETURNING username',
|
||||||
|
[newUsername, userId]
|
||||||
|
);
|
||||||
|
return r.rowCount > 0 ? r.rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTelegram(userId, telegramId) {
|
||||||
|
await query(
|
||||||
|
'UPDATE users SET telegram_id = $1 WHERE id = $2',
|
||||||
|
[telegramId, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
AuthError,
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
logout,
|
createSession,
|
||||||
newSession,
|
validateSession,
|
||||||
validateSession
|
revokeSession,
|
||||||
}
|
listSessions,
|
||||||
|
getUserById,
|
||||||
|
getAllUsers,
|
||||||
|
getUsersToNotify,
|
||||||
|
updateUsername,
|
||||||
|
updateTelegram
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
const query = require('../storage/database').query;
|
|
||||||
const { parseSessionToken } = require('../tools/security');
|
|
||||||
|
|
||||||
async function getSessions(username) {
|
|
||||||
const result = await query('SELECT s.id, s.session_code, s.browser, s.os, s.device_type, s.created_at, s.last_active, s.is_revoked FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE ORDER BY s.last_active DESC', [username]);
|
|
||||||
|
|
||||||
return result.rows.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
code: s.session_code,
|
|
||||||
browser: s.browser,
|
|
||||||
os: s.os,
|
|
||||||
deviceType: s.device_type,
|
|
||||||
createdAt: s.created_at?.toLocaleDateString('it-IT', {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
||||||
}),
|
|
||||||
lastActive: s.last_active?.toLocaleDateString('it-IT', {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
||||||
}),
|
|
||||||
isRevoked: s.is_revoked,
|
|
||||||
isCurrent: false
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getCurrentSessionID(token) {
|
|
||||||
const parsed = parseSessionToken(token);
|
|
||||||
if (!parsed) {
|
|
||||||
throw new Error('Invalid token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query('SELECT id FROM sessions WHERE session_code = $1', [parsed.code]);
|
|
||||||
return result.rows[0]?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revoke(id, username) {
|
|
||||||
const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.id = $1 AND s.user_id = u.id AND u.username = $2', [id, username]);
|
|
||||||
return result.rowCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeOthers(username, current) {
|
|
||||||
const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.user_id = u.id AND u.username = $1 AND s.id != $2 AND s.is_revoked = FALSE', [username, current]);
|
|
||||||
return result.rowCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCount(username) {
|
|
||||||
const result = await query('SELECT COUNT(*) as count FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE', [username]);
|
|
||||||
return parseInt(result.rows[0].count, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getSessions,
|
|
||||||
getCurrentSessionID,
|
|
||||||
revoke,
|
|
||||||
revokeOthers,
|
|
||||||
getCount
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -12,15 +12,80 @@ const version = process.env.VERSION;
|
|||||||
const vBuild = process.env.VERSION_BUILD;
|
const vBuild = process.env.VERSION_BUILD;
|
||||||
const vState = process.env.VERSION_STATE;
|
const vState = process.env.VERSION_STATE;
|
||||||
|
|
||||||
app.use(express.json());
|
// ─── SICUREZZA GLOBALE ──────────────────────────────────────────────
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
// Trust proxy (necessario dietro Nginx/Traefik per avere il vero IP del client)
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// Rate Limiter in-memory (protezione DoS base, senza dipendenze esterne)
|
||||||
|
const rateLimitStore = new Map();
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minuto
|
||||||
|
const RATE_LIMIT_MAX = 100; // max richieste per finestra
|
||||||
|
const RATE_LIMIT_AUTH_MAX = 10; // max tentativi login/register per finestra
|
||||||
|
|
||||||
|
// Pulizia periodica dello store
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of rateLimitStore) {
|
||||||
|
if (now - entry.start > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, RATE_LIMIT_WINDOW_MS);
|
||||||
|
|
||||||
|
function createRateLimiter(maxRequests) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const key = `${req.ip}:${maxRequests}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitStore.get(key);
|
||||||
|
|
||||||
|
if (!entry || now - entry.start > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
rateLimitStore.set(key, { count: 1, start: now });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
if (entry.count > maxRequests) {
|
||||||
|
res.set('Retry-After', Math.ceil((RATE_LIMIT_WINDOW_MS - (now - entry.start)) / 1000));
|
||||||
|
return res.status(429).json({ error: 'Troppe richieste, riprova più tardi' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalRateLimit = createRateLimiter(RATE_LIMIT_MAX);
|
||||||
|
const authRateLimit = createRateLimiter(RATE_LIMIT_AUTH_MAX);
|
||||||
|
|
||||||
|
// Security headers (equivalente leggero di Helmet, senza dipendenze)
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
|
res.setHeader('X-XSS-Protection', '0');
|
||||||
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||||
|
);
|
||||||
|
res.removeHeader('X-Powered-By');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit globale
|
||||||
|
app.use(globalRateLimit);
|
||||||
|
|
||||||
|
// Limiti dimensione body per prevenire payload eccessivi
|
||||||
|
app.use(express.json({ limit: '16kb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '16kb' }));
|
||||||
app.use(parser());
|
app.use(parser());
|
||||||
|
|
||||||
// Static files
|
// ─── STATIC FILES ───────────────────────────────────────────────────
|
||||||
const staticFolder = path.join(__dirname, 'static');
|
const staticFolder = path.join(__dirname, 'static');
|
||||||
app.use('/static', express.static(staticFolder));
|
app.use('/static', express.static(staticFolder));
|
||||||
|
app.use('/api/static', express.static(staticFolder));
|
||||||
|
|
||||||
// Nunjucks templates
|
// ─── NUNJUCKS TEMPLATES ─────────────────────────────────────────────
|
||||||
const templatesFolder = path.join(__dirname, 'templates');
|
const templatesFolder = path.join(__dirname, 'templates');
|
||||||
nunjucks.configure(templatesFolder, {
|
nunjucks.configure(templatesFolder, {
|
||||||
autoescape: true,
|
autoescape: true,
|
||||||
@@ -31,27 +96,49 @@ nunjucks.configure(templatesFolder, {
|
|||||||
app.set('views', templatesFolder);
|
app.set('views', templatesFolder);
|
||||||
app.set('view engine', 'html');
|
app.set('view engine', 'html');
|
||||||
|
|
||||||
// Routes
|
// ─── ROUTES ─────────────────────────────────────────────────────────
|
||||||
const authRoutes = require('./routes/auth');
|
|
||||||
app.use('/', authRoutes);
|
|
||||||
|
|
||||||
|
// Views (pagine HTML)
|
||||||
|
app.use('/', require('./routes/views/auth'));
|
||||||
|
app.use('/sessions', require('./routes/views/sessions'));
|
||||||
|
app.use('/user', require('./routes/views/user'));
|
||||||
|
|
||||||
|
// API - Auth (con rate limit più stretto su login/register)
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
app.use('/api/auth/login', authRateLimit);
|
||||||
|
app.use('/api/auth/register', authRateLimit);
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
|
// API - Risorse
|
||||||
|
app.use('/api/users', require('./routes/users'));
|
||||||
|
app.use('/api/sessions', require('./routes/sessions'));
|
||||||
|
|
||||||
|
// ─── HEALTH CHECK ───────────────────────────────────────────────────
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbConnected = await database.checkPostgres();
|
const dbConnected = await database.checkPostgres();
|
||||||
const redisHelper = require('./storage/redis');
|
|
||||||
const redisConnected = await redisHelper.checkRedis();
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: dbConnected && redisConnected ? "ok" : "degraded",
|
status: dbConnected ? "ok" : "degraded",
|
||||||
service: "auth",
|
service: "auth",
|
||||||
database: dbConnected ? "connected" : "disconnected",
|
database: dbConnected ? "connected" : "disconnected",
|
||||||
redis: redisConnected ? "connected" : "disconnected",
|
|
||||||
version: version,
|
version: version,
|
||||||
build_number: vBuild,
|
build_number: vBuild,
|
||||||
version_state: vState
|
version_state: vState
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Startup
|
// ─── 404 HANDLER ────────────────────────────────────────────────────
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: 'Risorsa non trovata' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ERROR HANDLER GLOBALE ──────────────────────────────────────────
|
||||||
|
app.use((err, req, res, _next) => {
|
||||||
|
console.error('[ERROR]', err.message, '| code:', err.code);
|
||||||
|
res.status(500).json({ error: 'Errore interno del server' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── STARTUP ────────────────────────────────────────────────────────
|
||||||
async function start() {
|
async function start() {
|
||||||
await database.initDb();
|
await database.initDb();
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
|||||||
32
auth/src/middlewares/internal.security.js
Normal file
32
auth/src/middlewares/internal.security.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const API_KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: autentica chiamate service-to-service tramite header x-internal-api-key.
|
||||||
|
* Usa timing-safe comparison per prevenire timing attacks.
|
||||||
|
*/
|
||||||
|
module.exports = function internalAuth(req, res, next) {
|
||||||
|
if (!API_KEY) {
|
||||||
|
console.error('[SECURITY] INTERNAL_API_KEY mancante, blocco tutte le richieste interne.');
|
||||||
|
return res.status(503).json({ error: 'service_not_configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = req.headers['x-internal-api-key'];
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const a = Buffer.from(token, 'utf8');
|
||||||
|
const b = Buffer.from(API_KEY, 'utf8');
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.internal = true;
|
||||||
|
next();
|
||||||
|
};
|
||||||
37
auth/src/middlewares/user.security.js
Normal file
37
auth/src/middlewares/user.security.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const jwt = require('../tools/jwt');
|
||||||
|
const { validateSession } = require('../core/auth.core');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: richiede un utente autenticato valido.
|
||||||
|
* Legge il token dal cookie `auth_token` o dall'header `Authorization: Bearer <token>`.
|
||||||
|
*
|
||||||
|
* Se la richiesta accetta HTML → redirect a /login con redirect-back URL.
|
||||||
|
* Altrimenti → 401 JSON.
|
||||||
|
*/
|
||||||
|
module.exports = async function userAuth(req, res, next) {
|
||||||
|
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
|
||||||
|
|
||||||
|
const unauthorized = (reason) => {
|
||||||
|
if (req.accepts('html') && !req.xhr) {
|
||||||
|
const r = encodeURIComponent(req.originalUrl);
|
||||||
|
return res.redirect(`/login?redirect=${r}`);
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: reason || 'unauthorized' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token || typeof token !== 'string' || token.length > 2048) {
|
||||||
|
return unauthorized('missing_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = jwt.verify(token);
|
||||||
|
if (!v.valid) return unauthorized(`token_${v.reason}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await validateSession(v.payload.session_id);
|
||||||
|
} catch (err) {
|
||||||
|
return unauthorized(err.code || 'session_invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = v.payload;
|
||||||
|
next();
|
||||||
|
};
|
||||||
@@ -2,96 +2,177 @@ const router = require('express').Router();
|
|||||||
const auth = require('../core/auth.core');
|
const auth = require('../core/auth.core');
|
||||||
const jwt = require('../tools/jwt');
|
const jwt = require('../tools/jwt');
|
||||||
|
|
||||||
const version = process.env.VERSION;
|
|
||||||
const vBuild = process.env.VERSION_BUILD;
|
|
||||||
const vState = process.env.VERSION_STATE;
|
|
||||||
|
|
||||||
const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004';
|
const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004';
|
||||||
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
|
||||||
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
router.get('/health', (req, res) => {
|
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
|
||||||
res.json({
|
const MIN_PASSWORD = 8;
|
||||||
status: 'ok',
|
const MAX_PASSWORD = 128;
|
||||||
service: 'auth',
|
const TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 giorni
|
||||||
version: version,
|
|
||||||
build_number: vBuild,
|
/**
|
||||||
version_state: vState
|
* Opzioni cookie condivise per auth_token.
|
||||||
});
|
* Domain = `.mebboat.it` in produzione → SSO cross-subdomain
|
||||||
});
|
* (console.mebboat.it, ml.mebboat.it, api.mebboat.it, ecc.)
|
||||||
|
*/
|
||||||
|
function authCookieOptions(withMaxAge = true) {
|
||||||
|
const opts = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: IS_PROD,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/'
|
||||||
|
};
|
||||||
|
if (withMaxAge) opts.maxAge = TOKEN_MAX_AGE_MS;
|
||||||
|
if (COOKIE_DOMAIN) opts.domain = COOKIE_DOMAIN;
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un redirect URL per prevenire open-redirect.
|
||||||
|
* Accetta solo lo stesso dominio di CONSOLE_URL (o sottodomini di COOKIE_DOMAIN).
|
||||||
|
*/
|
||||||
|
function resolveSafeRedirect(redirect) {
|
||||||
|
if (!redirect || typeof redirect !== 'string') return CONSOLE_URL;
|
||||||
|
try {
|
||||||
|
const target = new URL(redirect);
|
||||||
|
const console_ = new URL(CONSOLE_URL);
|
||||||
|
|
||||||
|
const sameHost = target.hostname === console_.hostname;
|
||||||
|
const sameApex = COOKIE_DOMAIN
|
||||||
|
? target.hostname.endsWith(COOKIE_DOMAIN.replace(/^\./, ''))
|
||||||
|
: false;
|
||||||
|
const notApi = !target.pathname.startsWith('/api/');
|
||||||
|
|
||||||
|
if ((sameHost || sameApex) && notApi) return redirect;
|
||||||
|
} catch {
|
||||||
|
// URL invalido / relativo: fallback
|
||||||
|
}
|
||||||
|
return CONSOLE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /register ────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/register', async (req, res) => {
|
router.post('/register', async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body || {};
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
|
||||||
return res.status(400).json({ error: 'Username e password richiesti' });
|
return res.status(400).json({ success: false, error: 'username_and_password_required' });
|
||||||
|
}
|
||||||
|
if (!USERNAME_REGEX.test(username)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'invalid_username' });
|
||||||
|
}
|
||||||
|
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) {
|
||||||
|
return res.status(400).json({ success: false, error: 'invalid_password_length' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.register(username, password);
|
const user = await auth.register(username, password);
|
||||||
res.status(201).end();
|
return res.status(201).json({ success: true, user });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] Register failed:', err.message);
|
if (err.code === 'USER_EXISTS') {
|
||||||
const status = err.message === 'User already exists' ? 409 : 500;
|
return res.status(409).json({ success: false, error: 'user_exists' });
|
||||||
res.status(status).json({ error: err.message });
|
}
|
||||||
|
console.error('[AUTH] register:', err.message);
|
||||||
|
return res.status(500).json({ success: false, error: 'internal' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/login', (req, res) => {
|
// ─── POST /login ───────────────────────────────────────────────────
|
||||||
const redirect = req.query.redirect || '';
|
|
||||||
res.render('loginpage', { error: null, redirect });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { username, password, redirect } = req.body;
|
const { username, password, redirect, _csrf } = req.body || {};
|
||||||
|
|
||||||
|
// Verifica CSRF token
|
||||||
|
const csrfCookie = req.cookies && req.cookies._csrf;
|
||||||
|
if (!_csrf || !csrfCookie || _csrf !== csrfCookie) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false, error: 'csrf', message: 'Richiesta non valida, riprova'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username || !password || typeof username !== 'string' || typeof password !== 'string'
|
||||||
|
|| username.length > 50 || password.length > MAX_PASSWORD) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRedirect = resolveSafeRedirect(redirect);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await auth.login(username, password);
|
const user = await auth.login(username, password);
|
||||||
const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip);
|
const session = await auth.createSession(user.id, req.headers['user-agent'], req.ip);
|
||||||
const token = jwt.generateToken(user, session.id);
|
const token = jwt.sign(user, session.id);
|
||||||
|
|
||||||
const cookieOptions = {
|
// Imposta il cookie auth_token (condiviso tra sottodomini se COOKIE_DOMAIN è impostato)
|
||||||
httpOnly: true,
|
res.cookie('auth_token', token, authCookieOptions(true));
|
||||||
secure: process.env.NODE_ENV === 'production',
|
// Rimuove il cookie CSRF
|
||||||
sameSite: 'lax',
|
res.clearCookie('_csrf', { httpOnly: true, sameSite: 'strict', path: '/' });
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 giorni
|
|
||||||
};
|
|
||||||
|
|
||||||
if (COOKIE_DOMAIN) {
|
return res.status(200).json({
|
||||||
cookieOptions.domain = COOKIE_DOMAIN;
|
success: true,
|
||||||
}
|
redirect_url: safeRedirect,
|
||||||
|
message: 'Login effettuato'
|
||||||
res.cookie('auth_token', token, cookieOptions);
|
});
|
||||||
|
|
||||||
// Redirect alla pagina da cui l'utente e' arrivato, o alla console
|
|
||||||
const destination = redirect || CONSOLE_URL;
|
|
||||||
res.redirect(destination);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] Login failed:', err.message, err.stack);
|
if (err.code === 'INVALID_CREDENTIALS') {
|
||||||
res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
|
return res.status(401).json({
|
||||||
|
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err.code === 'ACCOUNT_INACTIVE') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false, error: 'account_inactive', message: 'Account disattivato'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error('[AUTH] login:', err.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false, error: 'internal', message: 'Errore interno'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── POST /logout ──────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/logout', async (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
const token = req.cookies && req.cookies.auth_token;
|
const token = req.cookies && req.cookies.auth_token;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
const v = jwt.verify(token);
|
||||||
|
if (v.valid) {
|
||||||
try {
|
try {
|
||||||
const verified = jwt.verifyToken(token);
|
await auth.revokeSession(v.payload.session_id);
|
||||||
if (verified.valid) {
|
|
||||||
await auth.logout(verified.payload.session_id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] Logout error:', err.message);
|
console.error('[AUTH] logout revoke:', err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearOptions = { httpOnly: true, sameSite: 'lax' };
|
res.clearCookie('auth_token', authCookieOptions(false));
|
||||||
if (COOKIE_DOMAIN) {
|
|
||||||
clearOptions.domain = COOKIE_DOMAIN;
|
// Form HTML tradizionale → redirect, altrimenti JSON
|
||||||
|
if (req.accepts('html') && !req.xhr && !req.headers['content-type']?.includes('json')) {
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
return res.status(200).json({ success: true, redirect_url: '/login' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /verify (introspection per altri servizi) ─────────────────
|
||||||
|
|
||||||
|
router.get('/verify', async (req, res) => {
|
||||||
|
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
|
||||||
|
if (!token) return res.status(401).json({ valid: false, error: 'no_token' });
|
||||||
|
|
||||||
|
const v = jwt.verify(token);
|
||||||
|
if (!v.valid) return res.status(401).json({ valid: false, error: `token_${v.reason}` });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.validateSession(v.payload.session_id);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ valid: false, error: err.code || 'session_invalid' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.clearCookie('auth_token', clearOptions);
|
return res.status(200).json({ valid: true, user: v.payload });
|
||||||
res.redirect('/login');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const auth = require('../core/auth.core');
|
||||||
|
const userAuth = require('../middlewares/user.security');
|
||||||
|
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
// Tutte le route richiedono autenticazione utente
|
||||||
|
router.use(userAuth);
|
||||||
|
|
||||||
|
// GET / — Lista sessioni dell'utente corrente
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await auth.listSessions(req.user.user_id);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SESSIONS] list:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /:sessionId — Revoca una sessione specifica
|
||||||
|
router.delete('/:sessionId', async (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
if (!UUID_REGEX.test(sessionId)) {
|
||||||
|
return res.status(400).json({ error: 'invalid_session_id' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const revoked = await auth.revokeSession(sessionId, req.user.user_id);
|
||||||
|
if (!revoked) return res.status(404).json({ error: 'session_not_found' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SESSIONS] revoke:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,2 +1,77 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
|
const auth = require('../core/auth.core');
|
||||||
|
const userAuth = require('../middlewares/user.security');
|
||||||
|
const internalAuth = require('../middlewares/internal.security');
|
||||||
|
|
||||||
|
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
|
||||||
|
const TELEGRAM_REGEX = /^[0-9]{5,15}$/;
|
||||||
|
|
||||||
|
// ─── SERVICE-TO-SERVICE (x-internal-api-key) ────────────────────────
|
||||||
|
|
||||||
|
router.get('/', internalAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = await auth.getAllUsers();
|
||||||
|
res.json(users);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[USERS] list:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/tonotify', internalAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = await auth.getUsersToNotify();
|
||||||
|
res.json(users);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[USERS] tonotify:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── USER AUTH (cookie/JWT) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
router.use(userAuth);
|
||||||
|
|
||||||
|
router.get('/me', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await auth.getUserById(req.user.user_id);
|
||||||
|
if (!user) return res.status(404).json({ error: 'user_not_found' });
|
||||||
|
res.json(user);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[USERS] me:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/me/username', async (req, res) => {
|
||||||
|
const newUsername = req.body?.newUsername || req.query.newUsername;
|
||||||
|
if (!newUsername || typeof newUsername !== 'string' || !USERNAME_REGEX.test(newUsername)) {
|
||||||
|
return res.status(400).json({ error: 'invalid_username' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await auth.updateUsername(req.user.user_id, newUsername);
|
||||||
|
if (!updated) return res.status(404).json({ error: 'user_not_found' });
|
||||||
|
res.json({ success: true, username: updated.username });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'username_taken' });
|
||||||
|
console.error('[USERS] update username:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/me/telegram', async (req, res) => {
|
||||||
|
const telegramId = req.body?.telegramId || req.query.telegramId;
|
||||||
|
if (!telegramId || typeof telegramId !== 'string' || !TELEGRAM_REGEX.test(telegramId)) {
|
||||||
|
return res.status(400).json({ error: 'invalid_telegram_id' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await auth.updateTelegram(req.user.user_id, telegramId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'telegram_taken' });
|
||||||
|
console.error('[USERS] update telegram:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
28
auth/src/routes/views/auth.js
Normal file
28
auth/src/routes/views/auth.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const { csrfToken } = require('../../tools/security');
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
invalid_credentials: 'Credenziali non valide',
|
||||||
|
csrf: 'Richiesta non valida, riprova',
|
||||||
|
account_inactive: 'Account disattivato',
|
||||||
|
session_expired: 'Sessione scaduta, effettua nuovamente il login'
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get('/login', (req, res) => {
|
||||||
|
const redirect = typeof req.query.redirect === 'string' ? req.query.redirect : '';
|
||||||
|
const errorKey = req.query.error;
|
||||||
|
const error = ERROR_MESSAGES[errorKey] || null;
|
||||||
|
|
||||||
|
const token = csrfToken();
|
||||||
|
res.cookie('_csrf', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 30 * 60 * 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
res.render('loginpage', { error, redirect, csrf_token: token });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
10
auth/src/routes/views/sessions.js
Normal file
10
auth/src/routes/views/sessions.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const userAuth = require('../../middlewares/user.security');
|
||||||
|
|
||||||
|
router.use(userAuth);
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.render('sessions', { user: req.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
10
auth/src/routes/views/user.js
Normal file
10
auth/src/routes/views/user.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const userAuth = require('../../middlewares/user.security');
|
||||||
|
|
||||||
|
router.use(userAuth);
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.render('user', { user: req.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
BIN
auth/src/static/font/sans-flex.ttf
Normal file
BIN
auth/src/static/font/sans-flex.ttf
Normal file
Binary file not shown.
@@ -15,7 +15,8 @@
|
|||||||
--header-border: #e2e8f0;
|
--header-border: #e2e8f0;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
--shadow-md:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
--radius-md: 8px;
|
--radius-md: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
}
|
}
|
||||||
@@ -26,21 +27,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Normal';
|
font-family: "Normal";
|
||||||
src: url('../font/Quicksand-VariableFont_wght.ttf');
|
src: url("../font/sans-flex.ttf");
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Bold';
|
font-family: "Bold";
|
||||||
src: url('../font/Quicksand-VariableFont_wght.ttf');
|
src: url("../font/sans-flex.ttf");
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: "Normal", Arial;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ button {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'Bold', inherit;
|
font-family: "Bold", inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
|
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -79,13 +80,11 @@ button.prominent:hover {
|
|||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
button.prominent:active {
|
button.prominent:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* INFO PANEL */
|
/* INFO PANEL */
|
||||||
|
|
||||||
.info-panel {
|
.info-panel {
|
||||||
@@ -111,9 +110,6 @@ button.prominent:active {
|
|||||||
transition: transform 0.12s ease;
|
transition: transform 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* GRID & CARD ITEMS */
|
/* GRID & CARD ITEMS */
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -132,7 +128,9 @@ button.prominent:active {
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
transition:
|
||||||
|
transform 0.12s ease,
|
||||||
|
box-shadow 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h3 {
|
.card h3 {
|
||||||
@@ -158,10 +156,6 @@ button.prominent:active {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* HEADER */
|
/* HEADER */
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -179,7 +173,6 @@ button.prominent:active {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
|||||||
@@ -5,58 +5,46 @@ const config = {
|
|||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: process.env.DB_PORT,
|
port: process.env.DB_PORT,
|
||||||
|
database: process.env.USERS_DB || process.env.DB_NAME,
|
||||||
max: 10,
|
max: 10,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000
|
connectionTimeoutMillis: 5000
|
||||||
}
|
};
|
||||||
|
|
||||||
const pool = new Pool({ ...config, database: process.env.DB_NAME });
|
const pool = new Pool(config);
|
||||||
|
|
||||||
pool.on('error', (err) => {
|
pool.on('error', (err) => {
|
||||||
console.error('Error in database', err);
|
console.error('[DB] Pool error:', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a query with parameters
|
|
||||||
* @param {string} text - SQL query
|
|
||||||
* @param {Array} params - Query parameters
|
|
||||||
* @returns {Promise<Object>} Query result
|
|
||||||
*/
|
|
||||||
async function query(text, params) {
|
async function query(text, params) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
try {
|
||||||
const result = await pool.query(text, params);
|
const result = await pool.query(text, params);
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
if (duration > 100) {
|
if (duration > 100) {
|
||||||
console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80));
|
console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB] Query failed:', err.message, '| code:', err.code);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a client from pool for transactions
|
|
||||||
* @returns {Promise<Object>} Pool client
|
|
||||||
*/
|
|
||||||
async function getClient() {
|
async function getClient() {
|
||||||
return await pool.connect();
|
return await pool.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize database and ensure tables exist
|
|
||||||
*/
|
|
||||||
async function initDb() {
|
async function initDb() {
|
||||||
// Test connection
|
|
||||||
await pool.query('SELECT NOW()');
|
await pool.query('SELECT NOW()');
|
||||||
// Ensure pgcrypto extension (provides gen_random_uuid)
|
|
||||||
// Note: creating extensions requires proper DB permissions (usually superuser in PG)
|
|
||||||
try {
|
try {
|
||||||
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
|
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[DB] Could not create pgcrypto extension (may require superuser):', err.message);
|
console.warn('[DB] Could not create pgcrypto extension:', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure tables exist (UUID default generated by DB)
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -76,7 +64,7 @@ async function initDb() {
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
session_code VARCHAR(64) NOT NULL,
|
session_code VARCHAR(64) NOT NULL,
|
||||||
encoded_username TEXT NOT NULL,
|
encoded_username TEXT NOT NULL DEFAULT '',
|
||||||
ip_address INET,
|
ip_address INET,
|
||||||
user_agent TEXT,
|
user_agent TEXT,
|
||||||
browser VARCHAR(100),
|
browser VARCHAR(100),
|
||||||
@@ -89,9 +77,6 @@ async function initDb() {
|
|||||||
is_revoked BOOLEAN DEFAULT FALSE
|
is_revoked BOOLEAN DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Altera colonna in base al nuovo standard token 32 byte - 64 url chars
|
|
||||||
ALTER TABLE sessions ALTER COLUMN session_code TYPE VARCHAR(64);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code);
|
CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
`);
|
`);
|
||||||
@@ -101,7 +86,7 @@ async function checkPostgres() {
|
|||||||
try {
|
try {
|
||||||
await pool.query('SELECT NOW()');
|
await pool.query('SELECT NOW()');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const Redis = require('ioredis');
|
|||||||
const redis = new Redis({
|
const redis = new Redis({
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT),
|
port: parseInt(process.env.REDIS_PORT),
|
||||||
|
password: process.env.REDIS_PASSWORD,
|
||||||
maxRetriesPerRequest: 3,
|
maxRetriesPerRequest: 3,
|
||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
retryStrategy(times) {
|
retryStrategy(times) {
|
||||||
|
|||||||
@@ -1,39 +1,108 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" href="../static/style/style.css">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="../static/style/login.css" </head>
|
<title>Login — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/style/login.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
|
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<p class="error">{{ error }}</p>
|
<p class="error" id="errorMessage">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form action="/login" method="post">
|
<form id="loginForm">
|
||||||
<input type="hidden" name="redirect" value="{{ redirect }}">
|
<input type="hidden" id="redirect" name="redirect" value="{{ redirect }}">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" id="username" name="username" required>
|
<input type="text" id="username" name="username" required autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" name="password" required>
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Login</button>
|
<button type="submit" class="prominent" id="submitBtn">Login</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Accesso in corso...';
|
||||||
|
if (errorMessage) errorMessage.style.display = 'none';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.get('username'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
redirect: formData.get('redirect'),
|
||||||
|
_csrf: formData.get('_csrf')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (response.ok && data.success && data.redirect_url) {
|
||||||
|
window.location.href = data.redirect_url;
|
||||||
|
} else {
|
||||||
|
const errorMsg = data.message || 'Errore durante il login';
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.textContent = errorMsg;
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Login';
|
||||||
|
|
||||||
|
// Ricarica la pagina per ottenere un nuovo CSRF token
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = 'Errore di connessione. Riprova più tardi.';
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.textContent = errorMsg;
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Login';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sessioni — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<style>
|
||||||
|
main { padding: 24px 30px; }
|
||||||
|
main h2 { font-size: 1.1rem; margin-bottom: 20px; color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.session-card .info h3 { font-size: 0.95rem; margin-bottom: 4px; }
|
||||||
|
.session-card .info p { font-size: 0.8rem; color: var(--text-secondary); margin: 2px 0; }
|
||||||
|
.session-card button { font-size: 0.8rem; padding: 6px 14px; color: #dc2626; border-color: #fca5a5; }
|
||||||
|
.session-card button:hover { background-color: #fef2f2; border-color: #dc2626; color: #dc2626; }
|
||||||
|
#loading { color: var(--text-tertiary); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Console MEB</h1>
|
||||||
|
<div class="profile">
|
||||||
|
<p>{{ user.username }}</p>
|
||||||
|
<form action="/api/auth/logout" method="post">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2>Sessioni attive</h2>
|
||||||
|
<div id="sessions-container">
|
||||||
|
<p id="loading">Caricamento...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(str || ''));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return 'N/D';
|
||||||
|
return new Date(iso).toLocaleString('it-IT', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sessions');
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error('Network error');
|
||||||
|
|
||||||
|
const sessions = await res.json();
|
||||||
|
const container = document.getElementById('sessions-container');
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--text-tertiary)">Nessuna sessione attiva.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = sessions.map(s => `
|
||||||
|
<div class="session-card" id="session-${escapeHtml(s.id)}">
|
||||||
|
<div class="info">
|
||||||
|
<h3>${escapeHtml(s.browser || 'Browser sconosciuto')} su ${escapeHtml(s.os || 'OS sconosciuto')}</h3>
|
||||||
|
<p>${escapeHtml(s.device_type || '')}${s.ip_address ? ' — ' + escapeHtml(s.ip_address) : ''}</p>
|
||||||
|
<p>Ultima attività: ${formatDate(s.last_active)}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="revokeSession('${escapeHtml(s.id)}')">Revoca</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch {
|
||||||
|
document.getElementById('sessions-container').innerHTML =
|
||||||
|
'<p style="color:#dc2626">Errore nel caricamento delle sessioni.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSession(id) {
|
||||||
|
if (!confirm('Revocare questa sessione?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const el = document.getElementById('session-' + id);
|
||||||
|
if (el) el.remove();
|
||||||
|
} catch {
|
||||||
|
alert('Errore durante la revoca della sessione.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSessions();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Profilo — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<style>
|
||||||
|
main { padding: 24px 30px; max-width: 600px; }
|
||||||
|
main h2 { font-size: 1.1rem; margin-bottom: 20px; color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.field { margin-bottom: 20px; }
|
||||||
|
.field label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.field p { font-size: 0.95rem; padding: 10px 14px; border: 1px solid var(--header-border); border-radius: var(--radius-md); }
|
||||||
|
.field-empty { color: var(--text-tertiary); font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Console MEB</h1>
|
||||||
|
<div class="profile">
|
||||||
|
<p id="username-label">{{ user.username }}</p>
|
||||||
|
<form action="/api/auth/logout" method="post">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2>Profilo utente</h2>
|
||||||
|
<div id="user-info">
|
||||||
|
<p style="color:var(--text-tertiary)">Caricamento...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(str || ''));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return 'N/D';
|
||||||
|
return new Date(iso).toLocaleDateString('it-IT', {
|
||||||
|
day: 'numeric', month: 'long', year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUser() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/me');
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
|
const user = await res.json();
|
||||||
|
document.getElementById('username-label').textContent = user.username;
|
||||||
|
document.getElementById('user-info').innerHTML = `
|
||||||
|
<div class="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<p>${escapeHtml(user.username)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Account creato il</label>
|
||||||
|
<p>${formatDate(user.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Telegram ID</label>
|
||||||
|
<p>${user.telegram_id ? escapeHtml(user.telegram_id) : '<span class="field-empty">Non configurato</span>'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Sessioni</label>
|
||||||
|
<p><a href="/sessions">Gestisci sessioni →</a></p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch {
|
||||||
|
document.getElementById('user-info').innerHTML =
|
||||||
|
'<p style="color:#dc2626">Errore nel caricamento del profilo.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUser();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1,70 +1,52 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
const secret = process.env.JWT_SECRET;
|
const SECRET = process.env.JWT_SECRET;
|
||||||
const expires_in = process.env.JWT_EXPIRES_IN;
|
const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Genera un JWT Token a partire dall'utente e crea una nuova sessione
|
* Firma un JWT per l'utente e la sessione.
|
||||||
*
|
* Payload: { sub: userId, username, session_id }
|
||||||
* Uso dell'algoritmo HS256 per firmare il token con JWT_SECRET
|
|
||||||
*
|
|
||||||
* @param {Object} user - Utente
|
|
||||||
* @param {string} sessionID - ID della sessione
|
|
||||||
* @returns {string} - JWT Token
|
|
||||||
*/
|
*/
|
||||||
function generateToken(user, sessionID) {
|
function sign(user, sessionId) {
|
||||||
const payload = {
|
return jwt.sign(
|
||||||
sub: user.id,
|
{ sub: user.id, username: user.username, session_id: sessionId },
|
||||||
username: user.username,
|
SECRET,
|
||||||
session_id: sessionID,
|
{ algorithm: 'HS256', expiresIn: EXPIRES_IN }
|
||||||
iat: Math.floor(Date.now() / 1000)
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return jwt.sign(payload, secret, { expiresIn: expires_in, algorithm: 'HS256' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica e decodifica il token
|
* Verifica e decodifica un token.
|
||||||
* @param {string} token - JWT Token
|
* @returns {{ valid: boolean, payload?: Object, reason?: string }}
|
||||||
* @returns {{valid: boolean, payload?: Object, error?: string, reason?: string}} - Il risultato della verifica. Se fallisce restituisce errore e motivo, altrimenti restituisce una conferma e il payload completo
|
|
||||||
*/
|
*/
|
||||||
function verifyToken(token) {
|
function verify(token) {
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, secret, {
|
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
|
||||||
algorithms: ['HS256']
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
payload: {
|
payload: {
|
||||||
user_id: payload.sub,
|
user_id: p.sub,
|
||||||
username: payload.username,
|
username: p.username,
|
||||||
session_id: payload.session_id,
|
session_id: p.session_id,
|
||||||
iat: payload.iat,
|
iat: p.iat,
|
||||||
exp: payload.exp
|
exp: p.exp
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
||||||
const reason = err.name === 'TokenExpiredError' ? 'expired' : 'invalid';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: err.message,
|
reason: err.name === 'TokenExpiredError' ? 'expired' : 'invalid'
|
||||||
reason: `token ${reason}`
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(header) {
|
/**
|
||||||
if (!header) return null;
|
* Estrae il token da un header Authorization: Bearer <token>.
|
||||||
|
*/
|
||||||
const parts = header.split(' ');
|
function bearer(header) {
|
||||||
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
|
if (!header || typeof header !== 'string') return null;
|
||||||
return parts[1];
|
const [scheme, token] = header.split(' ');
|
||||||
}
|
return scheme && scheme.toLowerCase() === 'bearer' && token ? token : null;
|
||||||
|
|
||||||
//TODO: valutare se modificare in return null per accettare solo metodo Bearer Authorization Token,
|
|
||||||
return header;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateToken, verifyToken, getToken };
|
module.exports = { sign, verify, bearer };
|
||||||
|
|||||||
@@ -1,54 +1,22 @@
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const saltRounds = 12;
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
/**
|
async function hashPassword(password) {
|
||||||
* Genera un hash di una password
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
* @param {string} password - Password da hashare
|
|
||||||
* @returns {string} - Hash della password
|
|
||||||
*/
|
|
||||||
function hashPassword(password) {
|
|
||||||
return bcrypt.hashSync(password, saltRounds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function verifyPassword(password, hash) {
|
||||||
* Verifica una password
|
return bcrypt.compare(password, hash);
|
||||||
* @param {string} password - Password da verificare
|
|
||||||
* @param {string} hash - Hash della password
|
|
||||||
* @returns {boolean} - True se la password è corretta, false altrimenti
|
|
||||||
*/
|
|
||||||
function verifyPassword(password, hash) {
|
|
||||||
return bcrypt.compareSync(password, hash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function sessionCode() {
|
||||||
* Create a session token from code and username
|
|
||||||
* Format: XXXXXXXX-base64_username
|
|
||||||
* @param {string} sessionCode
|
|
||||||
* @param {string} username
|
|
||||||
* @returns {string} Session token
|
|
||||||
*/
|
|
||||||
function generateSessionCode() {
|
|
||||||
return crypto.randomBytes(32).toString('base64url');
|
return crypto.randomBytes(32).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function csrfToken() {
|
||||||
* Parse a session token
|
return crypto.randomBytes(32).toString('hex');
|
||||||
* @param {string} token
|
|
||||||
* @returns {string|null} The session token itself if valid
|
|
||||||
*/
|
|
||||||
function parseSessionToken(token) {
|
|
||||||
if (!token || typeof token !== 'string' || token.length < 32 || token.length > 64) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = { hashPassword, verifyPassword, sessionCode, csrfToken };
|
||||||
hashPassword,
|
|
||||||
verifyPassword,
|
|
||||||
generateSessionCode,
|
|
||||||
parseSessionToken
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
//TODO: Verfica se serve davvero prendere le info come ip e browser
|
const { UAParser } = require('ua-parser-js');
|
||||||
|
|
||||||
const { UAParser: parser } = require('ua-parser-js');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estrae le informazioni base su browser, sistema operativo e dispositivo per identificare meglio la sessione dell'utente
|
* Estrae browser/os/device dal user-agent per identificare meglio la sessione.
|
||||||
* @param {*} userAgent
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
function getBasicMetadata(userAgent) {
|
function extract(userAgent) {
|
||||||
const parsed = parser(userAgent);
|
const r = new UAParser(userAgent || '').getResult();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
browser: parsed.browser.name,
|
browser: r.browser.name || null,
|
||||||
os: parsed.os.name,
|
os: r.os.name || null,
|
||||||
device_type: parsed.device.type,
|
device_type: r.device.type || 'desktop'
|
||||||
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getBasicMetadata
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports = { extract };
|
||||||
|
|||||||
14
console/.env.example
Normal file
14
console/.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
PORT=3004
|
||||||
|
|
||||||
|
VERSION=1.3.0
|
||||||
|
VERSION_BUILD=1.0
|
||||||
|
VERSION_STATE=pre-release
|
||||||
|
|
||||||
|
REALTIME_URL=
|
||||||
|
REALTIME_WS_URL=
|
||||||
|
|
||||||
|
API_URL=
|
||||||
|
|
||||||
|
JWT_SECRET=
|
||||||
|
AUTH_LOGIN_URL=
|
||||||
|
COOKIE_DOMAIN=
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const nunjucks = require('nunjucks');
|
const nunjucks = require('nunjucks');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
|
|
||||||
const parser = require('cookie-parser');
|
const parser = require('cookie-parser');
|
||||||
|
|
||||||
|
const { requireAuthHtml } = require('./middlewares/auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT;
|
const PORT = process.env.PORT;
|
||||||
|
|
||||||
@@ -47,39 +47,9 @@ const renderPage = (page, extra = {}) => (req, res) => {
|
|||||||
res.render(page, {current_path: req.path, ...extra})
|
res.render(page, {current_path: req.path, ...extra})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware di autenticazione per le pagine
|
// Middleware di autenticazione per tutte le pagine protette
|
||||||
app.use((req, res, next) => {
|
// Le route /health e /static sono già gestite sopra
|
||||||
if (req.path === '/health' || req.path.startsWith('/static')) {
|
app.use(requireAuthHtml);
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const authBase = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
|
|
||||||
|
|
||||||
// Costruisci l'URL di redirect-back: protocollo + host + path originale
|
|
||||||
const proto = req.protocol;
|
|
||||||
const host = req.get('host');
|
|
||||||
const redirectBack = `${proto}://${host}${req.originalUrl}`;
|
|
||||||
const loginUrl = `${authBase}?redirect=${encodeURIComponent(redirectBack)}`;
|
|
||||||
|
|
||||||
const token = req.cookies && req.cookies.auth_token;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
||||||
req.user = payload;
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
const clearOptions = { httpOnly: true, sameSite: 'lax' };
|
|
||||||
if (process.env.COOKIE_DOMAIN) {
|
|
||||||
clearOptions.domain = process.env.COOKIE_DOMAIN;
|
|
||||||
}
|
|
||||||
res.clearCookie('auth_token', clearOptions);
|
|
||||||
return res.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/dashboard', renderPage('dashboard'));
|
app.get('/dashboard', renderPage('dashboard'));
|
||||||
app.get('/live', renderPage('live', {
|
app.get('/live', renderPage('live', {
|
||||||
@@ -87,6 +57,39 @@ app.get('/live', renderPage('live', {
|
|||||||
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get('/rulesets', renderPage('rulesets', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003'
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/sessions', renderPage('sessions', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||||
|
mapboxToken: process.env.MAPBOX_TOKEN || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/kioskedit', renderPage('kioskedit'));
|
||||||
|
|
||||||
|
app.get('/kiosklive', renderPage('kiosklive', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||||
|
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/forecasts', renderPage('forecasts', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||||
|
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/documentation', renderPage('documentation', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003'
|
||||||
|
}));
|
||||||
|
// retro-compatibilità: il link della dashboard punta ancora a /documentations
|
||||||
|
app.get('/documentations', (req, res) => res.redirect(301, '/documentation'));
|
||||||
|
|
||||||
|
app.get('/marine', renderPage('marine', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||||
|
marineUrl: process.env.MARINE_URL || (process.env.API_URL || 'http://localhost:3003') + '/marine'
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Started on port ${PORT}`);
|
console.log(`Started on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
74
console/src/middlewares/auth.js
Normal file
74
console/src/middlewares/auth.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Middleware di autenticazione condiviso per la console.
|
||||||
|
* Usa il JWT in cookie `auth_token` (condiviso tra i sottodomini via COOKIE_DOMAIN = .mebboat.it)
|
||||||
|
* oppure il header `Authorization: Bearer <token>`.
|
||||||
|
*
|
||||||
|
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET; questo servizio lo verifica
|
||||||
|
* localmente usando lo stesso secret. Nessuna chiamata di rete richiesta.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET;
|
||||||
|
const AUTH_LOGIN_URL = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
|
||||||
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
|
||||||
|
|
||||||
|
function extractToken(req) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
return (req.cookies && req.cookies.auth_token) || bearer || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
if (!token || typeof token !== 'string' || token.length > 2048) return null;
|
||||||
|
try {
|
||||||
|
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
|
||||||
|
return {
|
||||||
|
user_id: p.sub,
|
||||||
|
username: p.username,
|
||||||
|
session_id: p.session_id,
|
||||||
|
iat: p.iat,
|
||||||
|
exp: p.exp
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthCookie(res) {
|
||||||
|
const opts = { httpOnly: true, sameSite: 'lax', path: '/' };
|
||||||
|
if (COOKIE_DOMAIN) opts.domain = COOKIE_DOMAIN;
|
||||||
|
res.clearCookie('auth_token', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginRedirectUrl(req) {
|
||||||
|
const back = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
||||||
|
return `${AUTH_LOGIN_URL}?redirect=${encodeURIComponent(back)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagine HTML: su fallimento redirige all'auth service (SSO).
|
||||||
|
* Il redirect-back URL viene costruito automaticamente dalla richiesta corrente.
|
||||||
|
*/
|
||||||
|
function requireAuthHtml(req, res, next) {
|
||||||
|
const token = extractToken(req);
|
||||||
|
const user = verifyToken(token);
|
||||||
|
if (!user) {
|
||||||
|
if (token) clearAuthCookie(res);
|
||||||
|
return res.redirect(loginRedirectUrl(req));
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API JSON: su fallimento risponde 401.
|
||||||
|
*/
|
||||||
|
function requireAuthApi(req, res, next) {
|
||||||
|
const user = verifyToken(extractToken(req));
|
||||||
|
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuthHtml, requireAuthApi, clearAuthCookie, verifyToken, extractToken };
|
||||||
File diff suppressed because one or more lines are too long
230
console/src/pages/documentation.html
Normal file
230
console/src/pages/documentation.html
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Documentazione — MEB Console</title>
|
||||||
|
<link rel="stylesheet" href="../static/styles/style.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/highlight.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.doc-layout { display: grid; grid-template-columns: 280px 1fr; gap: 0; height: calc(100vh - 80px); }
|
||||||
|
.doc-sidebar { background: rgba(0,0,0,.15); border-right: 1px solid rgba(255,255,255,.05); padding: 1rem; overflow-y: auto; }
|
||||||
|
.doc-sidebar h3 { margin: 0 0 .75rem; font-size: .85rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
|
||||||
|
.doc-new { width: 100%; padding: .5rem; background: #3498db; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; margin-bottom: .75rem; }
|
||||||
|
.doc-new:hover { background: #2980b9; }
|
||||||
|
.doc-list { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.doc-list li { padding: .5rem .6rem; cursor: pointer; border-radius: 6px; font-size: .88rem; display: flex; justify-content: space-between; align-items: center; gap: .4rem; }
|
||||||
|
.doc-list li:hover { background: rgba(255,255,255,.05); }
|
||||||
|
.doc-list li.active { background: rgba(52,152,219,.15); color: #5dade2; }
|
||||||
|
.doc-list li .del { opacity: 0; border: none; background: transparent; color: #e74c3c; cursor: pointer; font-size: 1.1rem; padding: 0 .3rem; }
|
||||||
|
.doc-list li:hover .del { opacity: .7; }
|
||||||
|
.doc-list li .del:hover { opacity: 1; }
|
||||||
|
.doc-main { display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.doc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: .75rem 1.5rem; border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||||
|
.doc-toolbar .name { font-weight: 600; font-size: 1rem; }
|
||||||
|
.doc-toolbar .actions { display: flex; gap: .5rem; align-items: center; }
|
||||||
|
.toggle { display: flex; background: rgba(255,255,255,.05); border-radius: 6px; padding: 2px; }
|
||||||
|
.toggle button { border: none; background: transparent; padding: .4rem .75rem; cursor: pointer; color: inherit; border-radius: 4px; display: flex; align-items: center; gap: .35rem; font-size: .85rem; }
|
||||||
|
.toggle button.active { background: #3498db; color: #fff; }
|
||||||
|
.btn-save { background: #27ae60; color: #fff; border: none; padding: .5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: .85rem; }
|
||||||
|
.btn-save:hover { background: #229954; }
|
||||||
|
.btn-save:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.doc-body { flex: 1; overflow: auto; }
|
||||||
|
.doc-viewer { padding: 2rem; max-width: 860px; margin: 0 auto; line-height: 1.7; }
|
||||||
|
.doc-viewer h1 { border-bottom: 1px solid rgba(255,255,255,.1); padding-bottom: .3rem; }
|
||||||
|
.doc-viewer h2 { border-bottom: 1px solid rgba(255,255,255,.05); padding-bottom: .2rem; }
|
||||||
|
.doc-viewer code { background: rgba(255,255,255,.08); padding: .15em .4em; border-radius: 3px; font-size: .9em; }
|
||||||
|
.doc-viewer pre { background: #0d1117; border-radius: 6px; padding: 1rem; overflow: auto; }
|
||||||
|
.doc-viewer pre code { background: transparent; padding: 0; }
|
||||||
|
.doc-viewer blockquote { border-left: 3px solid #3498db; margin: 1rem 0; padding: .2rem .5rem .2rem 1rem; background: rgba(52,152,219,.05); opacity: .85; }
|
||||||
|
.doc-viewer table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
|
||||||
|
.doc-viewer th, .doc-viewer td { border: 1px solid rgba(255,255,255,.1); padding: .4rem .7rem; }
|
||||||
|
.doc-viewer th { background: rgba(255,255,255,.03); }
|
||||||
|
.doc-viewer a { color: #5dade2; }
|
||||||
|
.doc-editor { width: 100%; height: 100%; border: none; padding: 1.5rem 2rem; background: #0d1117; color: #e6edf3; font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: .9rem; line-height: 1.55; resize: none; outline: none; }
|
||||||
|
.doc-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; opacity: .5; }
|
||||||
|
.doc-empty .icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||||
|
.doc-toast { position: fixed; bottom: 1.5rem; right: 1.5rem; background: #27ae60; color: #fff; padding: .75rem 1.2rem; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.3); opacity: 0; transform: translateY(10px); transition: all .25s; }
|
||||||
|
.doc-toast.show { opacity: 1; transform: translateY(0); }
|
||||||
|
.doc-toast.err { background: #e74c3c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="contnent" style="padding:0;">
|
||||||
|
<div class="header" style="padding: .5rem 1.5rem;">
|
||||||
|
<h1 style="font-size:1.2rem;">Documentazione</h1>
|
||||||
|
<div class="profile">
|
||||||
|
<a href="/dashboard">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doc-layout">
|
||||||
|
<aside class="doc-sidebar">
|
||||||
|
<h3>File Markdown</h3>
|
||||||
|
<button class="doc-new" id="btnNew">+ Nuovo documento</button>
|
||||||
|
<ul class="doc-list" id="docList">
|
||||||
|
<li style="opacity:.6; cursor:default;">Carico…</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="doc-main">
|
||||||
|
<div class="doc-toolbar">
|
||||||
|
<div class="name" id="currentName">Nessun documento selezionato</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="toggle" id="modeToggle">
|
||||||
|
<button data-mode="view" class="active" title="Visualizza">👁️ <span>Visualizza</span></button>
|
||||||
|
<button data-mode="edit" title="Modifica">✏️ <span>Modifica</span></button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-save" id="btnSave" disabled>💾 Salva</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-body">
|
||||||
|
<div id="viewerWrap" class="doc-viewer">
|
||||||
|
<div class="doc-empty">
|
||||||
|
<div class="icon">📄</div>
|
||||||
|
<div>Seleziona un documento dalla sidebar, o creane uno nuovo.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="editor" class="doc-editor" style="display:none;" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="doc-toast"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = "{{ apiUrl }}";
|
||||||
|
marked.setOptions({ breaks: true, gfm: true, highlight: (code, lang) => {
|
||||||
|
try { return hljs.highlight(code, { language: lang || 'plaintext' }).value; }
|
||||||
|
catch { return code; }
|
||||||
|
}});
|
||||||
|
|
||||||
|
let currentName = null;
|
||||||
|
let originalContent = '';
|
||||||
|
let mode = 'view';
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function toast(msg, kind) {
|
||||||
|
const t = $('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = 'doc-toast show' + (kind === 'err' ? ' err' : '');
|
||||||
|
setTimeout(() => { t.className = 'doc-toast'; }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(`${API}${path}`, { credentials: 'include', ...opts });
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text().catch(() => 'errore');
|
||||||
|
throw new Error(`${res.status}: ${msg}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
try {
|
||||||
|
const res = await api('/docs');
|
||||||
|
const files = await res.json();
|
||||||
|
const list = $('docList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!files.length) {
|
||||||
|
list.innerHTML = '<li style="opacity:.5; cursor:default;">Nessun documento. Creane uno.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (const f of files) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.dataset.name = f.name;
|
||||||
|
li.innerHTML = `<span>${f.name}</span><button class="del" title="Elimina">×</button>`;
|
||||||
|
li.querySelector('span').addEventListener('click', () => openDoc(f.name));
|
||||||
|
li.addEventListener('click', (e) => { if (e.target.tagName !== 'BUTTON') openDoc(f.name); });
|
||||||
|
li.querySelector('.del').addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm(`Eliminare "${f.name}"?`)) return;
|
||||||
|
try { await api(`/docs/${encodeURIComponent(f.name)}`, { method: 'DELETE' }); await loadList(); if (currentName === f.name) resetView(); toast('Eliminato'); }
|
||||||
|
catch (err) { toast(err.message, 'err'); }
|
||||||
|
});
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
} catch (e) { toast('Errore caricamento lista: ' + e.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
currentName = null;
|
||||||
|
originalContent = '';
|
||||||
|
$('currentName').textContent = 'Nessun documento selezionato';
|
||||||
|
$('btnSave').disabled = true;
|
||||||
|
$('viewerWrap').innerHTML = '<div class="doc-empty"><div class="icon">📄</div><div>Seleziona un documento.</div></div>';
|
||||||
|
$('editor').value = '';
|
||||||
|
setMode('view');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDoc(name) {
|
||||||
|
try {
|
||||||
|
const res = await api(`/docs/${encodeURIComponent(name)}`);
|
||||||
|
const content = await res.text();
|
||||||
|
currentName = name;
|
||||||
|
originalContent = content;
|
||||||
|
$('currentName').textContent = name;
|
||||||
|
$('editor').value = content;
|
||||||
|
render(content);
|
||||||
|
document.querySelectorAll('#docList li').forEach(li => li.classList.toggle('active', li.dataset.name === name));
|
||||||
|
$('btnSave').disabled = true;
|
||||||
|
setMode('view');
|
||||||
|
} catch (e) { toast(e.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(md) {
|
||||||
|
$('viewerWrap').innerHTML = marked.parse(md || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(m) {
|
||||||
|
mode = m;
|
||||||
|
document.querySelectorAll('#modeToggle button').forEach(b => b.classList.toggle('active', b.dataset.mode === m));
|
||||||
|
$('viewerWrap').style.display = m === 'view' ? '' : 'none';
|
||||||
|
$('editor').style.display = m === 'edit' ? 'block' : 'none';
|
||||||
|
if (m === 'view') render($('editor').value);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#modeToggle button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||||||
|
|
||||||
|
$('editor').addEventListener('input', () => {
|
||||||
|
$('btnSave').disabled = ($('editor').value === originalContent) || !currentName;
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btnSave').addEventListener('click', async () => {
|
||||||
|
if (!currentName) return;
|
||||||
|
try {
|
||||||
|
await api(`/docs/${encodeURIComponent(currentName)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: $('editor').value })
|
||||||
|
});
|
||||||
|
originalContent = $('editor').value;
|
||||||
|
$('btnSave').disabled = true;
|
||||||
|
toast('Salvato ✓');
|
||||||
|
render(originalContent);
|
||||||
|
} catch (e) { toast(e.message, 'err'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btnNew').addEventListener('click', async () => {
|
||||||
|
const name = prompt('Nome del nuovo documento (es. "guida-utente"):');
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await api('/docs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, content: `# ${name}\n\nScrivi qui…\n` })
|
||||||
|
});
|
||||||
|
await loadList();
|
||||||
|
await openDoc(name.endsWith('.md') ? name : name + '.md');
|
||||||
|
setMode('edit');
|
||||||
|
} catch (e) { toast(e.message, 'err'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
loadList();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
300
console/src/pages/forecasts.html
Normal file
300
console/src/pages/forecasts.html
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Previsioni — MEB Console</title>
|
||||||
|
<link rel="stylesheet" href="../static/styles/style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.fc-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: .75rem; margin: 1rem 0; }
|
||||||
|
.fc-card { background: rgba(255,255,255,.04); border-radius: 10px; padding: 1rem; border: 1px solid rgba(255,255,255,.06); }
|
||||||
|
.fc-card h4 { margin: 0 0 .35rem; font-size: .78rem; opacity: .65; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.fc-card .val { font-size: 1.8rem; font-weight: 600; line-height: 1; }
|
||||||
|
.fc-card .unit { font-size: .9rem; opacity: .55; margin-left: .3rem; }
|
||||||
|
.fc-card .sub { margin-top: .4rem; font-size: .8rem; opacity: .7; }
|
||||||
|
.fc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||||
|
.fc-panel { background: rgba(255,255,255,.04); border-radius: 10px; padding: 1rem; border: 1px solid rgba(255,255,255,.06); }
|
||||||
|
.fc-panel h3 { margin: 0 0 .75rem; font-size: 1rem; }
|
||||||
|
canvas { max-height: 260px; }
|
||||||
|
.fc-status { display: inline-flex; align-items: center; gap: .4rem; font-size: .8rem; opacity: .8; }
|
||||||
|
.fc-status .dot { width: 8px; height: 8px; border-radius: 50%; background: #888; }
|
||||||
|
.fc-status.live .dot { background: #2ecc71; box-shadow: 0 0 8px #2ecc71; animation: pulse 2s infinite; }
|
||||||
|
.fc-status.stale .dot { background: #e67e22; }
|
||||||
|
@keyframes pulse { 50% { opacity: .4; } }
|
||||||
|
.windrose { display: flex; align-items: center; justify-content: center; height: 220px; position: relative; }
|
||||||
|
.windrose svg { width: 200px; height: 200px; }
|
||||||
|
.compass-label { position: absolute; font-size: .7rem; opacity: .6; }
|
||||||
|
.compass-label.n { top: 6px; left: 50%; transform: translateX(-50%); }
|
||||||
|
.compass-label.s { bottom: 6px; left: 50%; transform: translateX(-50%); }
|
||||||
|
.compass-label.e { right: 6px; top: 50%; transform: translateY(-50%); }
|
||||||
|
.compass-label.w { left: 6px; top: 50%; transform: translateY(-50%); }
|
||||||
|
.range-selector { display: flex; gap: .35rem; align-items: center; }
|
||||||
|
.range-selector button { padding: .3rem .75rem; border: 1px solid rgba(255,255,255,.15); background: transparent; color: inherit; border-radius: 6px; cursor: pointer; font-size: .8rem; }
|
||||||
|
.range-selector button.active { background: #3498db; border-color: transparent; color: #fff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="contnent">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Previsioni meteo-marine</h1>
|
||||||
|
<div class="profile">
|
||||||
|
<span id="fcStatus" class="fc-status"><span class="dot"></span><span id="fcStatusText">In attesa…</span></span>
|
||||||
|
<a href="/dashboard">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fc-summary">
|
||||||
|
<div class="fc-card">
|
||||||
|
<h4>Temperatura</h4>
|
||||||
|
<div><span class="val" id="sTemp">--</span><span class="unit">°C</span></div>
|
||||||
|
<div class="sub">Umidità: <span id="sHum">--</span>%</div>
|
||||||
|
</div>
|
||||||
|
<div class="fc-card">
|
||||||
|
<h4>Vento</h4>
|
||||||
|
<div><span class="val" id="sWind">--</span><span class="unit">m/s</span></div>
|
||||||
|
<div class="sub">Raffiche: <span id="sGust">--</span> m/s · Dir: <span id="sWindDir">--</span>°</div>
|
||||||
|
</div>
|
||||||
|
<div class="fc-card">
|
||||||
|
<h4>Pressione</h4>
|
||||||
|
<div><span class="val" id="sPressure">--</span><span class="unit">hPa</span></div>
|
||||||
|
<div class="sub">Nuvole: <span id="sCloud">--</span>% · Prob. pioggia: <span id="sRainProb">--</span>%</div>
|
||||||
|
</div>
|
||||||
|
<div class="fc-card">
|
||||||
|
<h4>Onde</h4>
|
||||||
|
<div><span class="val" id="sWaveH">--</span><span class="unit">m</span></div>
|
||||||
|
<div class="sub">Periodo: <span id="sWaveP">--</span>s · Dir: <span id="sWaveDir">--</span>°</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="range-selector">
|
||||||
|
<span style="opacity:.6; margin-right:.5rem; font-size:.85rem;">Intervallo storico:</span>
|
||||||
|
<button data-range="1h">1h</button>
|
||||||
|
<button data-range="6h" class="active">6h</button>
|
||||||
|
<button data-range="24h">24h</button>
|
||||||
|
<button data-range="7d">7g</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fc-grid">
|
||||||
|
<div class="fc-panel">
|
||||||
|
<h3>Temperatura & Umidità</h3>
|
||||||
|
<canvas id="chartTemp"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="fc-panel">
|
||||||
|
<h3>Vento (velocità + raffiche)</h3>
|
||||||
|
<canvas id="chartWind"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="fc-panel">
|
||||||
|
<h3>Pressione & Copertura</h3>
|
||||||
|
<canvas id="chartPressure"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="fc-panel">
|
||||||
|
<h3>Precipitazioni</h3>
|
||||||
|
<canvas id="chartRain"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="fc-panel">
|
||||||
|
<h3>Onde — altezza & periodo</h3>
|
||||||
|
<canvas id="chartWaves"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="fc-panel">
|
||||||
|
<h3>Direzione (corrente)</h3>
|
||||||
|
<div class="windrose">
|
||||||
|
<span class="compass-label n">N</span><span class="compass-label s">S</span>
|
||||||
|
<span class="compass-label e">E</span><span class="compass-label w">W</span>
|
||||||
|
<svg viewBox="-100 -100 200 200">
|
||||||
|
<circle cx="0" cy="0" r="90" fill="none" stroke="rgba(255,255,255,.12)"/>
|
||||||
|
<circle cx="0" cy="0" r="60" fill="none" stroke="rgba(255,255,255,.08)"/>
|
||||||
|
<circle cx="0" cy="0" r="30" fill="none" stroke="rgba(255,255,255,.05)"/>
|
||||||
|
<line x1="0" y1="-90" x2="0" y2="90" stroke="rgba(255,255,255,.08)"/>
|
||||||
|
<line x1="-90" y1="0" x2="90" y2="0" stroke="rgba(255,255,255,.08)"/>
|
||||||
|
<g id="windArrow" transform="rotate(0)">
|
||||||
|
<polygon points="0,-70 -10,-40 0,-50 10,-40" fill="#3498db"/>
|
||||||
|
</g>
|
||||||
|
<g id="waveArrow" transform="rotate(0)">
|
||||||
|
<polygon points="0,-55 -6,-35 0,-42 6,-35" fill="#e67e22" opacity=".8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center; font-size:.75rem; opacity:.6;">
|
||||||
|
<span style="color:#3498db;">■</span> Vento <span style="color:#e67e22;">■</span> Onde
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="opacity:.5; font-size:.75rem; margin-top:1rem;">
|
||||||
|
Fonte: plugin SignalK → Open-Meteo (current ogni 5 min, hourly ogni 60 min) + dati marini.
|
||||||
|
Live via WebSocket; storico da InfluxDB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_URL = "{{ apiUrl }}";
|
||||||
|
const WS_URL = "{{ realtimeWsUrl }}";
|
||||||
|
|
||||||
|
const MEASUREMENTS = {
|
||||||
|
temperature: 'meb.forecasts.temperature',
|
||||||
|
humidity: 'meb.forecast.humidity',
|
||||||
|
pressure: 'meb.forecast.pressure',
|
||||||
|
precipitation: 'meb.forecast.precipitation',
|
||||||
|
cloudCover: 'meb.forecast.cloudCover',
|
||||||
|
windSpeed: 'meb.forecast.wind.speed',
|
||||||
|
windDirection: 'meb.forecast.wind.direction',
|
||||||
|
windGusts: 'meb.forecast.wind.gusts',
|
||||||
|
waveHeight: 'meb.waves.height',
|
||||||
|
waveDirection: 'meb.waves.direction',
|
||||||
|
wavePeriod: 'meb.waves.period',
|
||||||
|
wavePeakPeriod: 'meb.waves.peakPeriod',
|
||||||
|
currentVelocity: 'meb.waves.currentVelocity',
|
||||||
|
currentDirection: 'meb.waves.currentDirection',
|
||||||
|
};
|
||||||
|
|
||||||
|
const current = {};
|
||||||
|
const series = {};
|
||||||
|
const MAX_POINTS = 500;
|
||||||
|
for (const k of Object.keys(MEASUREMENTS)) series[k] = [];
|
||||||
|
|
||||||
|
function pushPoint(key, ts, value) {
|
||||||
|
if (value == null || Number.isNaN(value)) return;
|
||||||
|
current[key] = value;
|
||||||
|
const arr = series[key];
|
||||||
|
arr.push({ x: ts, y: value });
|
||||||
|
if (arr.length > MAX_POINTS) arr.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v, d = 1) => v == null ? '--' : Number(v).toFixed(d);
|
||||||
|
|
||||||
|
function refreshSummary() {
|
||||||
|
document.getElementById('sTemp').textContent = fmt(current.temperature);
|
||||||
|
document.getElementById('sHum').textContent = fmt(current.humidity, 0);
|
||||||
|
document.getElementById('sWind').textContent = fmt(current.windSpeed);
|
||||||
|
document.getElementById('sGust').textContent = fmt(current.windGusts);
|
||||||
|
document.getElementById('sWindDir').textContent = fmt(current.windDirection, 0);
|
||||||
|
document.getElementById('sPressure').textContent = fmt(current.pressure, 0);
|
||||||
|
document.getElementById('sCloud').textContent = fmt(current.cloudCover, 0);
|
||||||
|
document.getElementById('sWaveH').textContent = fmt(current.waveHeight, 2);
|
||||||
|
document.getElementById('sWaveP').textContent = fmt(current.wavePeriod, 1);
|
||||||
|
document.getElementById('sWaveDir').textContent = fmt(current.waveDirection, 0);
|
||||||
|
|
||||||
|
if (current.windDirection != null)
|
||||||
|
document.getElementById('windArrow').setAttribute('transform', `rotate(${current.windDirection})`);
|
||||||
|
if (current.waveDirection != null)
|
||||||
|
document.getElementById('waveArrow').setAttribute('transform', `rotate(${current.waveDirection})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xScale = {
|
||||||
|
type: 'linear',
|
||||||
|
ticks: { color: '#888', maxTicksLimit: 6, callback: (v) => {
|
||||||
|
const d = new Date(v);
|
||||||
|
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
|
||||||
|
}},
|
||||||
|
grid: { color: 'rgba(255,255,255,.05)' }
|
||||||
|
};
|
||||||
|
const commonOpts = {
|
||||||
|
animation: false,
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: { x: xScale, y: { ticks: { color: '#888' }, grid: { color: 'rgba(255,255,255,.05)' } } },
|
||||||
|
plugins: { legend: { labels: { color: '#bbb', boxWidth: 12 } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const mkChart = (id, datasets) => new Chart(document.getElementById(id), {
|
||||||
|
type: 'line', data: { datasets }, options: commonOpts
|
||||||
|
});
|
||||||
|
|
||||||
|
const charts = {
|
||||||
|
temp: mkChart('chartTemp', [
|
||||||
|
{ label: 'Temperatura (°C)', data: series.temperature, borderColor: '#e74c3c', backgroundColor: 'rgba(231,76,60,.15)', tension: .3, fill: true },
|
||||||
|
{ label: 'Umidità (%)', data: series.humidity, borderColor: '#3498db', tension: .3 }
|
||||||
|
]),
|
||||||
|
wind: mkChart('chartWind', [
|
||||||
|
{ label: 'Velocità (m/s)', data: series.windSpeed, borderColor: '#1abc9c', tension: .3 },
|
||||||
|
{ label: 'Raffiche (m/s)', data: series.windGusts, borderColor: '#9b59b6', borderDash: [4,4], tension: .3 }
|
||||||
|
]),
|
||||||
|
pressure: mkChart('chartPressure', [
|
||||||
|
{ label: 'Pressione (hPa)', data: series.pressure, borderColor: '#f39c12', tension: .3 },
|
||||||
|
{ label: 'Nuvole (%)', data: series.cloudCover, borderColor: '#95a5a6', tension: .3 }
|
||||||
|
]),
|
||||||
|
rain: mkChart('chartRain', [
|
||||||
|
{ label: 'Precipitazioni (mm)', data: series.precipitation, borderColor: '#2980b9', backgroundColor: 'rgba(41,128,185,.3)', fill: true, tension: .2 }
|
||||||
|
]),
|
||||||
|
waves: mkChart('chartWaves', [
|
||||||
|
{ label: 'Altezza (m)', data: series.waveHeight, borderColor: '#e67e22', tension: .3 },
|
||||||
|
{ label: 'Periodo (s)', data: series.wavePeriod, borderColor: '#16a085', tension: .3 }
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let redrawPending = false;
|
||||||
|
function scheduleRedraw() {
|
||||||
|
if (redrawPending) return;
|
||||||
|
redrawPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
redrawPending = false;
|
||||||
|
refreshSummary();
|
||||||
|
for (const c of Object.values(charts)) c.update('none');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(cls, text) {
|
||||||
|
document.getElementById('fcStatus').className = 'fc-status ' + cls;
|
||||||
|
document.getElementById('fcStatusText').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory(range = '6h') {
|
||||||
|
setStatus('', 'Carico storico…');
|
||||||
|
const measurements = Object.values(MEASUREMENTS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/data/history?range=${range}&measurements=${encodeURIComponent(measurements.join(','))}`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const payload = await res.json();
|
||||||
|
for (const [key, mName] of Object.entries(MEASUREMENTS)) {
|
||||||
|
const rows = payload[mName] || payload[key];
|
||||||
|
if (!Array.isArray(rows)) continue;
|
||||||
|
series[key].length = 0;
|
||||||
|
for (const row of rows.slice(-MAX_POINTS)) {
|
||||||
|
series[key].push({ x: new Date(row.ts).getTime(), y: Number(row.value) });
|
||||||
|
}
|
||||||
|
if (rows.length) current[key] = Number(rows[rows.length - 1].value);
|
||||||
|
}
|
||||||
|
scheduleRedraw();
|
||||||
|
setStatus('live', 'Storico caricato');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[history]', e.message);
|
||||||
|
setStatus('stale', 'Storico non disponibile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws, reconnectTimer;
|
||||||
|
function connect() {
|
||||||
|
try { ws = new WebSocket(`${WS_URL}/live`); }
|
||||||
|
catch { return setStatus('stale', 'Errore WS'); }
|
||||||
|
ws.onopen = () => setStatus('live', 'Live');
|
||||||
|
ws.onclose = () => { setStatus('stale', 'Disconnesso, riprovo…'); clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, 4000); };
|
||||||
|
ws.onerror = () => setStatus('stale', 'Errore WS');
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
const ts = new Date(msg.timestamp || Date.now()).getTime();
|
||||||
|
const key = Object.entries(MEASUREMENTS).find(([, v]) => v === msg.measurement)?.[0];
|
||||||
|
if (!key) return;
|
||||||
|
const value = msg.fields?.value ?? msg.value;
|
||||||
|
pushPoint(key, ts, Number(value));
|
||||||
|
scheduleRedraw();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.range-selector button').forEach(b => {
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.range-selector button').forEach(x => x.classList.remove('active'));
|
||||||
|
b.classList.add('active');
|
||||||
|
loadHistory(b.dataset.range);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadHistory('6h');
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
560
console/src/pages/kioskedit.html
Normal file
560
console/src/pages/kioskedit.html
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Kiosk Dashboard</title>
|
||||||
|
|
||||||
|
<script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script>
|
||||||
|
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
|
||||||
|
<link rel="stylesheet" href="../static/styles/style.css">
|
||||||
|
<link rel="stylesheet" href="../static/styles/kiosk.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- Main Grid Canvas -->
|
||||||
|
<div id="canvas" class="canvas">
|
||||||
|
<div id="emptyState" class="empty-state">Trascina o aggiungi una card per iniziare</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UI Feedback & Overlays -->
|
||||||
|
<div id="tooltip" class="tooltip"></div>
|
||||||
|
<div id="unitBadge" class="unit-badge">1u = 0px</div>
|
||||||
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
|
<div id="modalOverlay" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modalTitle">Configurazione</h2>
|
||||||
|
<textarea id="importArea" placeholder="JSON..."></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="modalCancel">Annulla</button>
|
||||||
|
<button id="modalApply" class="primary">Applica</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<p id="cardCount">0 cards</p>
|
||||||
|
|
||||||
|
<button id="editBtn">Edit</button>
|
||||||
|
<button id="addCardBtn" title="Aggiungi Widget">+</button>
|
||||||
|
<button id="addMapBtn" title="Aggiungi Mappa">Map</button>
|
||||||
|
|
||||||
|
<button id="importBtn">Import</button>
|
||||||
|
<button id="exportBtn">Export</button>
|
||||||
|
|
||||||
|
<button id="clearBtn">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="canvas.js"></script>
|
||||||
|
<script src="core.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const COLS = 24, ROWS = 18;
|
||||||
|
const SNAP = 0.5;
|
||||||
|
const SNAP_MAG = 0.3;
|
||||||
|
const MIN_GW = 2, MIN_GH = 1.5;
|
||||||
|
const MAX_GW = 20, MAX_GH = 16;
|
||||||
|
const DEF_GW = 6, DEF_GH = 5;
|
||||||
|
|
||||||
|
const canvasEl = document.getElementById('canvas');
|
||||||
|
const tooltipEl = document.getElementById('tooltip');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const cardCountEl = document.getElementById('cardCount');
|
||||||
|
const unitBadge = document.getElementById('unitBadge');
|
||||||
|
const modalOvl = document.getElementById('modalOverlay');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const importArea = document.getElementById('importArea');
|
||||||
|
const modalApply = document.getElementById('modalApply');
|
||||||
|
const toastEl = document.getElementById('toast');
|
||||||
|
|
||||||
|
let cards = [], cardIdCounter = 0, selectedCard = null, zCounter = 1;
|
||||||
|
let snapGuidesH = [], snapGuidesV = [];
|
||||||
|
let editMode = false;
|
||||||
|
|
||||||
|
const uw = () => canvasEl.clientWidth / COLS;
|
||||||
|
const uh = () => canvasEl.clientHeight / ROWS;
|
||||||
|
const gSnap = v => Math.round(v / SNAP) * SNAP;
|
||||||
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
|
|
||||||
|
function screenToGrid(cx, cy) {
|
||||||
|
const r = canvasEl.getBoundingClientRect();
|
||||||
|
return { gx: (cx - r.left) / uw(), gy: (cy - r.top) / uh() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(c) {
|
||||||
|
const u = uw(), h = uh();
|
||||||
|
c.el.style.left = (c.gx * u) + 'px';
|
||||||
|
c.el.style.top = (c.gy * h) + 'px';
|
||||||
|
c.el.style.width = (c.gw * u) + 'px';
|
||||||
|
c.el.style.height = (c.gh * h) + 'px';
|
||||||
|
|
||||||
|
if (editMode) c.el.classList.add('editable');
|
||||||
|
else c.el.classList.remove('editable', 'selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
cards.forEach(renderCard);
|
||||||
|
unitBadge.textContent = `1u = ${Math.round(uw())}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
const n = cards.length;
|
||||||
|
cardCountEl.textContent = `${n} card${n !== 1 ? 's' : ''}`;
|
||||||
|
emptyState.classList.toggle('hidden', n > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive re-render
|
||||||
|
let rafId = null;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(renderAll);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
let toastT = null;
|
||||||
|
function toast(msg) {
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
clearTimeout(toastT);
|
||||||
|
toastT = setTimeout(() => toastEl.classList.remove('show'), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guides
|
||||||
|
function ensureGuides() {
|
||||||
|
if (snapGuidesH.length) return;
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
let g = document.createElement('div'); g.className = 'guide snap-guide horizontal'; canvasEl.appendChild(g); snapGuidesH.push(g);
|
||||||
|
g = document.createElement('div'); g.className = 'guide snap-guide vertical'; canvasEl.appendChild(g); snapGuidesV.push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideGuides() {
|
||||||
|
snapGuidesH.forEach(g => g.classList.remove('visible')); snapGuidesV.forEach(g => g.classList.remove('visible'));
|
||||||
|
}
|
||||||
|
function showGuide(type, gp, idx = 0) {
|
||||||
|
if (type === 'h' && idx < snapGuidesH.length) { snapGuidesH[idx].style.top = (gp * uh()) + 'px'; snapGuidesH[idx].classList.add('visible'); }
|
||||||
|
else if (type === 'v' && idx < snapGuidesV.length) { snapGuidesV[idx].style.left = (gp * uw()) + 'px'; snapGuidesV[idx].classList.add('visible'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magnetic snap
|
||||||
|
function magSnap(el, gx, gy, gw, gh) {
|
||||||
|
let sx = gx, sy = gy, gH = [], gV = [];
|
||||||
|
const others = cards.filter(c => c.el !== el);
|
||||||
|
|
||||||
|
let bH = SNAP_MAG + 1;
|
||||||
|
for (const o of others)
|
||||||
|
for (const [f, t] of [[gy, o.gy], [gy, o.gy + o.gh], [gy + gh, o.gy], [gy + gh, o.gy + o.gh]]) {
|
||||||
|
const d = Math.abs(f - t);
|
||||||
|
if (d < bH) { bH = d; sy = gy + (t - f); gH = [t]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let bV = SNAP_MAG + 1;
|
||||||
|
for (const o of others)
|
||||||
|
for (const [f, t] of [[gx, o.gx], [gx, o.gx + o.gw], [gx + gw, o.gx], [gx + gw, o.gx + o.gw]]) {
|
||||||
|
const d = Math.abs(f - t);
|
||||||
|
if (d < bV) { bV = d; sx = gx + (t - f); gV = [t]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bH > SNAP_MAG) sy = gSnap(gy);
|
||||||
|
if (bV > SNAP_MAG) sx = gSnap(gx);
|
||||||
|
return { gx: sx, gy: sy, guidesH: gH, guidesV: gV };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal K data handling
|
||||||
|
function updateData(path, value) {
|
||||||
|
cards.filter(c => c.path === path).forEach(c => {
|
||||||
|
const body = c.el.querySelector('.card-body');
|
||||||
|
if (body) {
|
||||||
|
let displayVal = value;
|
||||||
|
if (path.includes('speed')) displayVal = (value * 1.94384).toFixed(1) + ' kn';
|
||||||
|
else if (path.includes('depth')) displayVal = value.toFixed(1) + ' m';
|
||||||
|
body.textContent = displayVal;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.updateKioskData = updateData;
|
||||||
|
|
||||||
|
// Create card
|
||||||
|
function createCard(gx, gy, gw = DEF_GW, gh = DEF_GH, forceId = null, gz = null, type = 'widget', path = null) {
|
||||||
|
const id = forceId || (++cardIdCounter);
|
||||||
|
if (forceId && forceId >= cardIdCounter) cardIdCounter = forceId;
|
||||||
|
if (!forceId && id > cardIdCounter) cardIdCounter = id;
|
||||||
|
|
||||||
|
const skPaths = window.skPaths || [];
|
||||||
|
const finalPath = path || (type === 'widget' && skPaths.length ? skPaths[(id - 1) % skPaths.length] : null);
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'card spawning' + (editMode ? ' editable' : '');
|
||||||
|
el.dataset.id = id;
|
||||||
|
el.dataset.type = type;
|
||||||
|
const z = gz || (++zCounter);
|
||||||
|
el.style.zIndex = z;
|
||||||
|
if (gz && gz >= zCounter) zCounter = gz;
|
||||||
|
|
||||||
|
let headerHtml = `<span class="card-label">${type === 'map' ? 'Mappa' : (finalPath ? finalPath.split('.').pop() : 'Widget')}</span>`;
|
||||||
|
|
||||||
|
// Suggerimento menu path se widget
|
||||||
|
if (type === 'widget') {
|
||||||
|
let menuHtml = `<div class="path-menu">`;
|
||||||
|
skPaths.forEach(p => {
|
||||||
|
menuHtml += `<div class="path-option" data-path="${p}">${p.split('.').pop()}</div>`;
|
||||||
|
});
|
||||||
|
menuHtml += `</div>`;
|
||||||
|
headerHtml = `<div class="label-wrapper">${headerHtml}${menuHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card-header">
|
||||||
|
${headerHtml}
|
||||||
|
<button class="card-close" title="Rimuovi">ELIMINA</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body"></div>
|
||||||
|
<div class="rh corner nw"></div><div class="rh corner ne"></div>
|
||||||
|
<div class="rh corner se"></div><div class="rh corner sw"></div>
|
||||||
|
<div class="rh edge n"></div><div class="rh edge s"></div>
|
||||||
|
<div class="rh edge e"></div><div class="rh edge w"></div>`;
|
||||||
|
|
||||||
|
canvasEl.appendChild(el);
|
||||||
|
const c = { id, el, gx, gy, gw, gh, type, path: finalPath };
|
||||||
|
cards.push(c);
|
||||||
|
renderCard(c);
|
||||||
|
|
||||||
|
if (type === 'map') {
|
||||||
|
const mapDiv = document.createElement('div');
|
||||||
|
mapDiv.id = `map-container-${id}`;
|
||||||
|
mapDiv.className = 'card-map-canvas';
|
||||||
|
el.querySelector('.card-body').appendChild(mapDiv);
|
||||||
|
if (window.initMapInstance) window.initMapInstance(mapDiv.id);
|
||||||
|
} else {
|
||||||
|
updateBody(c);
|
||||||
|
// Listener per il cambio path
|
||||||
|
el.querySelectorAll('.path-option').forEach(opt => {
|
||||||
|
opt.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
c.path = opt.dataset.path;
|
||||||
|
el.querySelector('.card-label').textContent = c.path.split('.').pop();
|
||||||
|
toast(`Path aggiornato: ${c.path}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('animationend', () => el.classList.remove('spawning'), { once: true });
|
||||||
|
el.querySelector('.card-close').addEventListener('click', ev => { ev.stopPropagation(); removeCard(c); });
|
||||||
|
el.addEventListener('mousedown', () => { if (editMode) selectCard(c); });
|
||||||
|
|
||||||
|
setupDrag(c);
|
||||||
|
setupResize(c);
|
||||||
|
updateCount();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCard(c) {
|
||||||
|
c.el.classList.add('removing');
|
||||||
|
c.el.addEventListener('animationend', () => {
|
||||||
|
c.el.remove();
|
||||||
|
cards = cards.filter(x => x.id !== c.id);
|
||||||
|
if (selectedCard === c) selectedCard = null;
|
||||||
|
updateCount();
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCard(c) {
|
||||||
|
if (selectedCard?.el) selectedCard.el.classList.remove('selected');
|
||||||
|
selectedCard = c; c.el.classList.add('selected'); c.el.style.zIndex = ++zCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBody(c) {
|
||||||
|
if (c.type === 'map') {
|
||||||
|
if (window.resizeMapInstance) window.resizeMapInstance();
|
||||||
|
} else {
|
||||||
|
const b = c.el.querySelector('.card-body');
|
||||||
|
if (b && !b.textContent.trim()) {
|
||||||
|
b.textContent = `${c.gw.toFixed(1)} × ${c.gh.toFixed(1)} u`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag
|
||||||
|
function setupDrag(c) {
|
||||||
|
c.el.addEventListener('mousedown', e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
if (e.target.classList.contains('rh') || e.target.classList.contains('card-close')) return;
|
||||||
|
e.preventDefault(); ensureGuides(); c.el.classList.add('dragging');
|
||||||
|
const start = screenToGrid(e.clientX, e.clientY);
|
||||||
|
const oGx = c.gx, oGy = c.gy;
|
||||||
|
|
||||||
|
const onMove = ev => {
|
||||||
|
const now = screenToGrid(ev.clientX, ev.clientY);
|
||||||
|
let nx = oGx + (now.gx - start.gx), ny = oGy + (now.gy - start.gy);
|
||||||
|
nx = clamp(nx, 0, COLS - c.gw); ny = clamp(ny, 0, ROWS - c.gh);
|
||||||
|
const s = magSnap(c.el, nx, ny, c.gw, c.gh);
|
||||||
|
c.gx = clamp(s.gx, 0, COLS - c.gw); c.gy = clamp(s.gy, 0, ROWS - c.gh);
|
||||||
|
hideGuides();
|
||||||
|
s.guidesH.forEach((p, i) => showGuide('h', p, i));
|
||||||
|
s.guidesV.forEach((p, i) => showGuide('v', p, i));
|
||||||
|
renderCard(c);
|
||||||
|
tooltipEl.textContent = `${c.gx.toFixed(1)}, ${c.gy.toFixed(1)}`;
|
||||||
|
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
|
||||||
|
tooltipEl.classList.add('visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
c.el.classList.remove('dragging'); hideGuides(); tooltipEl.classList.remove('visible');
|
||||||
|
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
|
||||||
|
if (c.type === 'map' && window.resizeMapInstance) window.resizeMapInstance();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize
|
||||||
|
function setupResize(c) {
|
||||||
|
c.el.querySelectorAll('.rh').forEach(h => {
|
||||||
|
h.addEventListener('mousedown', e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
e.preventDefault(); e.stopPropagation(); ensureGuides();
|
||||||
|
c.el.classList.add('resizing'); selectCard(c);
|
||||||
|
const start = screenToGrid(e.clientX, e.clientY);
|
||||||
|
const oGx = c.gx, oGy = c.gy, oGw = c.gw, oGh = c.gh;
|
||||||
|
const isN = h.classList.contains('nw') || h.classList.contains('ne') || h.classList.contains('n');
|
||||||
|
const isS = h.classList.contains('sw') || h.classList.contains('se') || h.classList.contains('s');
|
||||||
|
const isW = h.classList.contains('nw') || h.classList.contains('sw') || h.classList.contains('w');
|
||||||
|
const isE = h.classList.contains('ne') || h.classList.contains('se') || h.classList.contains('e');
|
||||||
|
|
||||||
|
const onMove = ev => {
|
||||||
|
const now = screenToGrid(ev.clientX, ev.clientY);
|
||||||
|
const dx = now.gx - start.gx, dy = now.gy - start.gy;
|
||||||
|
let nw = oGw, nh = oGh, nx = oGx, ny = oGy;
|
||||||
|
if (isE) nw = oGw + dx; if (isS) nh = oGh + dy;
|
||||||
|
if (isW) { nw = oGw - dx; nx = oGx + dx; }
|
||||||
|
if (isN) { nh = oGh - dy; ny = oGy + dy; }
|
||||||
|
nw = gSnap(clamp(nw, MIN_GW, MAX_GW)); nh = gSnap(clamp(nh, MIN_GH, MAX_GH));
|
||||||
|
if (isW) nx = oGx + oGw - nw; if (isN) ny = oGy + oGh - nh;
|
||||||
|
nx = gSnap(clamp(nx, 0, COLS - nw)); ny = gSnap(clamp(ny, 0, ROWS - nh));
|
||||||
|
c.gx = nx; c.gy = ny; c.gw = nw; c.gh = nh;
|
||||||
|
renderCard(c); updateBody(c);
|
||||||
|
tooltipEl.textContent = `${nw.toFixed(1)} × ${nh.toFixed(1)} u`;
|
||||||
|
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
|
||||||
|
tooltipEl.classList.add('visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
c.el.classList.remove('resizing'); tooltipEl.classList.remove('visible'); hideGuides();
|
||||||
|
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// EXPORT / IMPORT
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
function exportConfig() {
|
||||||
|
return JSON.stringify({
|
||||||
|
canvas: { cols: COLS, rows: ROWS },
|
||||||
|
cards: cards.map(c => ({
|
||||||
|
id: c.id, type: c.type,
|
||||||
|
dimensions: {
|
||||||
|
x: Math.round(c.gx * 100) / 100, y: Math.round(c.gy * 100) / 100,
|
||||||
|
z: parseInt(c.el.style.zIndex) || 1,
|
||||||
|
width: Math.round(c.gw * 100) / 100, height: Math.round(c.gh * 100) / 100
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importConfig(json) {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(json); } catch { toast('JSON non valido'); return false; }
|
||||||
|
if (!data.cards || !Array.isArray(data.cards)) { toast('Formato non valido: serve "cards" array'); return false; }
|
||||||
|
|
||||||
|
cards.forEach(c => c.el.remove());
|
||||||
|
cards = []; selectedCard = null; cardIdCounter = 0; zCounter = 1;
|
||||||
|
|
||||||
|
for (const entry of data.cards) {
|
||||||
|
const d = entry.dimensions || {};
|
||||||
|
createCard(
|
||||||
|
clamp(d.x ?? 0, 0, COLS - MIN_GW), clamp(d.y ?? 0, 0, ROWS - MIN_GH),
|
||||||
|
clamp(d.width ?? DEF_GW, MIN_GW, MAX_GW), clamp(d.height ?? DEF_GH, MIN_GH, MAX_GH),
|
||||||
|
entry.id ?? null, d.z ?? 1, entry.type ?? 'widget'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updateCount(); renderAll();
|
||||||
|
toast(`Importate ${data.cards.length} card`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toolbar ─────────────────────────────────────────
|
||||||
|
document.getElementById('editBtn').addEventListener('click', (e) => {
|
||||||
|
editMode = !editMode;
|
||||||
|
e.target.classList.toggle('primary', editMode);
|
||||||
|
canvasEl.classList.toggle('edit-active', editMode);
|
||||||
|
document.body.classList.toggle('edit-mode', editMode);
|
||||||
|
renderAll();
|
||||||
|
toast(editMode ? 'Edit Mode Attiva' : 'Edit Mode Disattiva');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('addCardBtn').addEventListener('click', () => {
|
||||||
|
const off = (cards.length % 8) * SNAP * 2;
|
||||||
|
createCard(gSnap(1 + off), gSnap(1 + off));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('addMapBtn').addEventListener('click', () => {
|
||||||
|
createCard(gSnap(4), gSnap(4), 10, 8, null, null, 'map');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exportBtn').addEventListener('click', () => {
|
||||||
|
const json = exportConfig();
|
||||||
|
modalTitle.textContent = 'Esporta configurazione JSON';
|
||||||
|
importArea.value = json; importArea.readOnly = true;
|
||||||
|
modalApply.textContent = 'Copia';
|
||||||
|
modalOvl.classList.add('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('importBtn').addEventListener('click', () => {
|
||||||
|
modalTitle.textContent = 'Importa configurazione JSON';
|
||||||
|
importArea.value = ''; importArea.readOnly = false;
|
||||||
|
modalApply.textContent = 'Applica';
|
||||||
|
modalOvl.classList.add('open');
|
||||||
|
setTimeout(() => importArea.focus(), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modalCancel').addEventListener('click', () => modalOvl.classList.remove('open'));
|
||||||
|
|
||||||
|
modalApply.addEventListener('click', () => {
|
||||||
|
if (modalTitle.textContent.includes('Esporta')) {
|
||||||
|
navigator.clipboard.writeText(importArea.value).then(() => toast('Copiato')).catch(() => toast('Errore copia'));
|
||||||
|
modalOvl.classList.remove('open');
|
||||||
|
} else {
|
||||||
|
if (importConfig(importArea.value)) modalOvl.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modalOvl.addEventListener('click', e => { if (e.target === modalOvl) modalOvl.classList.remove('open'); });
|
||||||
|
|
||||||
|
document.getElementById('clearBtn').addEventListener('click', () => {
|
||||||
|
[...cards].forEach((c, i) => setTimeout(() => removeCard(c), i * 40));
|
||||||
|
});
|
||||||
|
|
||||||
|
canvasEl.addEventListener('mousedown', e => {
|
||||||
|
if (e.target === canvasEl && selectedCard) { selectedCard.el.classList.remove('selected'); selectedCard = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') { modalOvl.classList.remove('open'); return; }
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedCard) {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
removeCard(selectedCard);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCount(); renderAll();
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
"navigation.speedOverGround",
|
||||||
|
"environment.depth.belowTransducer",
|
||||||
|
]
|
||||||
|
window.skPaths = paths;
|
||||||
|
|
||||||
|
mapboxgl.accessToken = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
|
||||||
|
|
||||||
|
let map = null;
|
||||||
|
let boatMark = null;
|
||||||
|
let followBoat = true;
|
||||||
|
|
||||||
|
window.initMapInstance = (containerId) => {
|
||||||
|
map = new mapboxgl.Map({
|
||||||
|
container: containerId,
|
||||||
|
style: {
|
||||||
|
"version": 8,
|
||||||
|
"sources": {
|
||||||
|
"osm": { "type": "raster", "tiles": ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"], "tileSize": 256 },
|
||||||
|
"openseamap": { "type": "raster", "tiles": ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"], "tileSize": 256 }
|
||||||
|
},
|
||||||
|
"layers": [
|
||||||
|
{ "id": "osm-layer", "type": "raster", "source": "osm", "minzoom": 0, "maxzoom": 22 },
|
||||||
|
{ "id": "openseamap-layer", "type": "raster", "source": "openseamap", "minzoom": 0, "maxzoom": 18 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('dragstart', () => {
|
||||||
|
followBoat = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
boatMark = new mapboxgl.Marker({ color: 'red' })
|
||||||
|
.setLngLat([9, 9])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
// Area Protetta mock
|
||||||
|
map.addSource('area-protetta', {
|
||||||
|
'type': 'geojson',
|
||||||
|
'data': {
|
||||||
|
'type': 'Feature',
|
||||||
|
'geometry': {
|
||||||
|
'type': 'Polygon',
|
||||||
|
'coordinates': [[[9.05, 45.05], [9.15, 45.05], [9.15, 45.15], [9.05, 45.15], [9.05, 45.05]]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
'id': 'area-layer',
|
||||||
|
'type': 'fill',
|
||||||
|
'source': 'area-protetta',
|
||||||
|
'paint': { 'fill-color': '#0080ff', 'fill-opacity': 0.3 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resizeMapInstance = () => {
|
||||||
|
if (map) map.resize();
|
||||||
|
};
|
||||||
|
|
||||||
|
function movePosition(lng, lat) {
|
||||||
|
if (!followBoat || !map) return;
|
||||||
|
map.flyTo({ center: [lng, lat], zoom: 14, speed: 1.2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = window.location.host;
|
||||||
|
const connection = `ws://${host}/signalk/v1/stream?subscribe=all`;
|
||||||
|
const ws = new WebSocket(connection);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.updates) {
|
||||||
|
msg.updates.forEach(update => {
|
||||||
|
if (update.values) {
|
||||||
|
update.values.forEach(v => {
|
||||||
|
// Aggiorna le card nel dashboard tramite canvas.js
|
||||||
|
if (window.updateKioskData) {
|
||||||
|
window.updateKioskData(v.path, v.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.path === "navigation.position" && boatMark) {
|
||||||
|
const lng = v.value.longitude;
|
||||||
|
const lat = v.value.latitude;
|
||||||
|
boatMark.setLngLat([lng, lat]);
|
||||||
|
movePosition(lng, lat);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => console.error("Errore WebSocket:", err);
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log("WebSocket chiuso. Riconnessione tra 5s...");
|
||||||
|
setTimeout(() => location.reload(), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// style: 'mapbox://styles/sesee3/cmn9767jg003l01qsbpmace1t/draft'
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
</html>
|
||||||
331
console/src/pages/kiosklive.html
Normal file
331
console/src/pages/kiosklive.html
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Kiosk Live</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/styles/kiosk.css">
|
||||||
|
<style>
|
||||||
|
body { margin:0; display:flex; flex-direction:column; height:100vh; background:#0b1220; color:#fff; font-family:sans-serif; }
|
||||||
|
.topbar { display:flex; align-items:center; gap:12px; padding:8px 14px; background:#111827; border-bottom:1px solid #1f2937; }
|
||||||
|
.status { display:flex; align-items:center; gap:6px; font-size:13px; }
|
||||||
|
.dot { width:10px; height:10px; border-radius:50%; background:#6b7280; }
|
||||||
|
.dot.on { background:#10b981; } .dot.off { background:#ef4444; }
|
||||||
|
.topbar select, .topbar button, .topbar input { background:#1f2937; color:#fff; border:1px solid #374151; padding:6px 10px; border-radius:6px; font-size:13px; }
|
||||||
|
.topbar button { cursor:pointer; } .topbar button:hover { background:#374151; }
|
||||||
|
.topbar .spacer { flex:1; }
|
||||||
|
.main { flex:1; display:flex; min-height:0; }
|
||||||
|
.stage { flex:1; position:relative; background:#0b1220; overflow:hidden; }
|
||||||
|
.canvas { position:absolute; inset:0; }
|
||||||
|
.box { position:absolute; border-radius:8px; padding:10px; overflow:hidden; cursor:pointer; border:2px solid transparent; display:flex; flex-direction:column; }
|
||||||
|
.box:hover { border-color:#38bdf8; }
|
||||||
|
.box.selected { border-color:#f59e0b; }
|
||||||
|
.box .bt { font-size:12px; opacity:.7; text-transform:uppercase; letter-spacing:.05em; }
|
||||||
|
.box .bv { flex:1; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:2.5vw; }
|
||||||
|
.side { width:320px; background:#111827; border-left:1px solid #1f2937; padding:14px; overflow:auto; }
|
||||||
|
.side h3 { margin:0 0 10px; font-size:14px; }
|
||||||
|
.side label { display:block; font-size:12px; margin:8px 0 3px; opacity:.7; }
|
||||||
|
.side input, .side select { width:100%; box-sizing:border-box; background:#1f2937; color:#fff; border:1px solid #374151; padding:6px 8px; border-radius:4px; font-size:13px; }
|
||||||
|
.side .row { display:flex; gap:6px; }
|
||||||
|
.side .row > * { flex:1; }
|
||||||
|
.side .actions { display:flex; gap:6px; margin-top:14px; }
|
||||||
|
.side button { flex:1; padding:8px; background:#2563eb; color:#fff; border:0; border-radius:6px; cursor:pointer; }
|
||||||
|
.side button.danger { background:#dc2626; }
|
||||||
|
.modal { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; z-index:1000; }
|
||||||
|
.modal.open { display:flex; }
|
||||||
|
.modal .card { background:#111827; padding:20px; border-radius:8px; max-width:560px; width:90%; max-height:80vh; overflow:auto; }
|
||||||
|
.tlist { display:flex; flex-direction:column; gap:6px; margin:10px 0; }
|
||||||
|
.tlist .t { padding:10px; background:#1f2937; border-radius:6px; cursor:pointer; display:flex; justify-content:space-between; }
|
||||||
|
.tlist .t.active { border:1px solid #10b981; }
|
||||||
|
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#1f2937; padding:8px 14px; border-radius:6px; opacity:0; transition:opacity .2s; }
|
||||||
|
.toast.show { opacity:1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="status"><span id="dot" class="dot"></span><span id="statusTxt">connecting…</span></div>
|
||||||
|
<label>Sensor: <input id="sensorInput" placeholder="sensor name" value=""></label>
|
||||||
|
<button id="connectBtn">Connect</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="loadBtn">Load template…</button>
|
||||||
|
<button id="persistBtn">Save as new template</button>
|
||||||
|
<button id="reloadBtn">Reload kiosk</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="stage"><div id="canvas" class="canvas"></div></div>
|
||||||
|
<div class="side">
|
||||||
|
<h3 id="sideTitle">No box selected</h3>
|
||||||
|
<div id="form" style="display:none;">
|
||||||
|
<label>Title</label><input id="fTitle">
|
||||||
|
<label>SignalK path</label><input id="fPath">
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Unit</label><input id="fUnit"></div>
|
||||||
|
<div><label>Decimals</label><input id="fDec" type="number" min="0" max="6"></div>
|
||||||
|
</div>
|
||||||
|
<label>Multiplier</label><input id="fMul" type="number" step="any">
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Color</label><input id="fColor" type="color"></div>
|
||||||
|
<div><label>Text</label><input id="fText" type="color"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>X</label><input id="fX" type="number" step="0.5"></div>
|
||||||
|
<div><label>Y</label><input id="fY" type="number" step="0.5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>W</label><input id="fW" type="number" step="0.5"></div>
|
||||||
|
<div><label>H</label><input id="fH" type="number" step="0.5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="saveBox">Apply</button>
|
||||||
|
<button id="delBox" class="danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="empty" style="opacity:.6;font-size:13px;">Click a box to edit it. Changes apply live to the kiosk.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal" class="modal">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Templates</h3>
|
||||||
|
<div id="tlist" class="tlist"></div>
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:6px;">
|
||||||
|
<button id="modalClose">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_URL = "{{ apiUrl }}";
|
||||||
|
const WS_URL = "{{ realtimeWsUrl }}";
|
||||||
|
|
||||||
|
const canvasEl = document.getElementById('canvas');
|
||||||
|
const dotEl = document.getElementById('dot');
|
||||||
|
const stEl = document.getElementById('statusTxt');
|
||||||
|
const toastEl = document.getElementById('toast');
|
||||||
|
const COLS = 24, ROWS = 18;
|
||||||
|
|
||||||
|
let template = null, boxes = [], selected = null, ws = null, cmdSeq = 0;
|
||||||
|
let sensorName = localStorage.getItem('kiosk_sensor') || '';
|
||||||
|
document.getElementById('sensorInput').value = sensorName;
|
||||||
|
|
||||||
|
function toast(m){ toastEl.textContent = m; toastEl.classList.add('show'); setTimeout(()=>toastEl.classList.remove('show'), 1800); }
|
||||||
|
function nextCmd(){ return 'c' + (++cmdSeq); }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
canvasEl.innerHTML = '';
|
||||||
|
if (!template) return;
|
||||||
|
const W = canvasEl.clientWidth, H = canvasEl.clientHeight;
|
||||||
|
const uw = W/COLS, uh = H/ROWS;
|
||||||
|
for (const b of boxes) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'box' + (selected?.id === b.id ? ' selected':'');
|
||||||
|
el.style.left = (b.x*uw)+'px'; el.style.top=(b.y*uh)+'px';
|
||||||
|
el.style.width=(b.w*uw)+'px'; el.style.height=(b.h*uh)+'px';
|
||||||
|
el.style.background = b.color || '#1e293b';
|
||||||
|
el.style.color = b.textColor || '#fff';
|
||||||
|
el.innerHTML = `<div class="bt">${b.title||b.path||''}</div><div class="bv">${b.path||''}${b.unit?' '+b.unit:''}</div>`;
|
||||||
|
el.onclick = () => selectBox(b);
|
||||||
|
canvasEl.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', render);
|
||||||
|
|
||||||
|
function selectBox(b) {
|
||||||
|
selected = b;
|
||||||
|
document.getElementById('sideTitle').textContent = 'Box: ' + (b.title||b.id);
|
||||||
|
document.getElementById('form').style.display = 'block';
|
||||||
|
document.getElementById('empty').style.display = 'none';
|
||||||
|
for (const [id, key] of [['fTitle','title'],['fPath','path'],['fUnit','unit'],['fDec','decimals'],['fMul','multiplier'],['fColor','color'],['fText','textColor'],['fX','x'],['fY','y'],['fW','w'],['fH','h']]) {
|
||||||
|
document.getElementById(id).value = b[key] ?? '';
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('saveBox').onclick = () => {
|
||||||
|
if (!selected) return;
|
||||||
|
const patch = {
|
||||||
|
title: document.getElementById('fTitle').value,
|
||||||
|
path: document.getElementById('fPath').value,
|
||||||
|
unit: document.getElementById('fUnit').value,
|
||||||
|
decimals: +document.getElementById('fDec').value || 0,
|
||||||
|
multiplier: +document.getElementById('fMul').value || 1,
|
||||||
|
color: document.getElementById('fColor').value,
|
||||||
|
textColor: document.getElementById('fText').value,
|
||||||
|
x: +document.getElementById('fX').value,
|
||||||
|
y: +document.getElementById('fY').value,
|
||||||
|
w: +document.getElementById('fW').value,
|
||||||
|
h: +document.getElementById('fH').value,
|
||||||
|
};
|
||||||
|
Object.assign(selected, patch);
|
||||||
|
render();
|
||||||
|
sendCmd({ t:'patch_box', boxId: selected.id, patch });
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('delBox').onclick = () => {
|
||||||
|
if (!selected) return;
|
||||||
|
const id = selected.id;
|
||||||
|
boxes = boxes.filter(b => b.id !== id);
|
||||||
|
selected = null;
|
||||||
|
document.getElementById('form').style.display = 'none';
|
||||||
|
document.getElementById('empty').style.display = '';
|
||||||
|
render();
|
||||||
|
sendCmd({ t:'remove_box', boxId: id });
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendCmd(obj) {
|
||||||
|
if (!ws || ws.readyState !== 1) { toast('not connected'); return; }
|
||||||
|
const cmdId = nextCmd();
|
||||||
|
ws.send(JSON.stringify({ ...obj, cmdId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchActive() {
|
||||||
|
const r = await fetch(`${API_URL}/kiosk/template/active`, { credentials:'include' });
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function fetchTemplate(id) {
|
||||||
|
const r = await fetch(`${API_URL}/kiosk/templates/${id}`, { credentials:'include' });
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function fetchList() {
|
||||||
|
const r = await fetch(`${API_URL}/kiosk/templates`, { credentials:'include' });
|
||||||
|
return r.ok ? r.json() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping fra il modello DB (kioskelements: label,x,y,width,height,color,font)
|
||||||
|
// e il modello UI interno "box" (id,title,x,y,w,h,color,font + campi runtime
|
||||||
|
// non persistiti come path/unit/decimals/multiplier/textColor che servono solo
|
||||||
|
// per il rendering live sul device).
|
||||||
|
function elementToBox(e) {
|
||||||
|
return {
|
||||||
|
id: String(e.id), // bigint dal DB → stringa per uso UI
|
||||||
|
title: e.label || '',
|
||||||
|
label: e.label || '',
|
||||||
|
x: e.x, y: e.y,
|
||||||
|
w: e.width, h: e.height,
|
||||||
|
color: e.color || '#1e293b',
|
||||||
|
font: e.font ?? 16,
|
||||||
|
// campi runtime-only (non persistiti)
|
||||||
|
path: e.sk_path || '',
|
||||||
|
unit: e.unit || '',
|
||||||
|
decimals: e.decimals ?? 1,
|
||||||
|
multiplier: e.multiplier ?? 1,
|
||||||
|
textColor: '#ffffff'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function boxToElement(b) {
|
||||||
|
// Solo i campi che esistono come colonne in kioskelements
|
||||||
|
return {
|
||||||
|
label: (b.title || b.label || '').slice(0, 100),
|
||||||
|
x: Math.max(0, Math.round(b.x || 0)),
|
||||||
|
y: Math.max(0, Math.round(b.y || 0)),
|
||||||
|
width: Math.max(1, Math.round(b.w || 1)),
|
||||||
|
height: Math.max(1, Math.round(b.h || 1)),
|
||||||
|
color: b.color || '#1e293b',
|
||||||
|
font: parseInt(b.font, 10) || 16
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplate(tpl) {
|
||||||
|
template = tpl;
|
||||||
|
// Backward-compat: accetta sia il nuovo `elements` che il legacy `content.boxes`.
|
||||||
|
const els = Array.isArray(tpl.elements) ? tpl.elements
|
||||||
|
: (tpl.content?.boxes || []);
|
||||||
|
boxes = els.map(e => (e.label !== undefined || e.width !== undefined)
|
||||||
|
? elementToBox(e)
|
||||||
|
: { ...e });
|
||||||
|
selected = null;
|
||||||
|
document.getElementById('form').style.display='none';
|
||||||
|
document.getElementById('empty').style.display='';
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
sensorName = document.getElementById('sensorInput').value.trim();
|
||||||
|
if (!sensorName) { toast('sensor name required'); return; }
|
||||||
|
localStorage.setItem('kiosk_sensor', sensorName);
|
||||||
|
if (ws) { try { ws.close(); } catch {} }
|
||||||
|
ws = new WebSocket(`${WS_URL}/kiosk?role=controller&sensor=${encodeURIComponent(sensorName)}`);
|
||||||
|
stEl.textContent = 'connecting…'; dotEl.className = 'dot';
|
||||||
|
ws.onopen = () => { stEl.textContent = 'connected'; };
|
||||||
|
ws.onclose = () => { dotEl.className='dot off'; stEl.textContent='disconnected'; };
|
||||||
|
ws.onerror = () => { stEl.textContent = 'error'; };
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||||
|
if (m.t === 'kiosk_status') {
|
||||||
|
dotEl.className = 'dot ' + (m.online ? 'on':'off');
|
||||||
|
stEl.textContent = m.online ? `online (tpl ${m.templateId||'?'})` : 'kiosk offline';
|
||||||
|
} else if (m.t === 'ack') {
|
||||||
|
if (!m.ok) toast('cmd failed: ' + (m.err||''));
|
||||||
|
} else if (m.t === 'active_template_changed') {
|
||||||
|
fetchTemplate(m.templateId).then(t => t && loadTemplate(t));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('connectBtn').onclick = connect;
|
||||||
|
|
||||||
|
document.getElementById('loadBtn').onclick = async () => {
|
||||||
|
const list = await fetchList();
|
||||||
|
const wrap = document.getElementById('tlist');
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
for (const t of list) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 't' + (t.active?' active':'');
|
||||||
|
row.innerHTML = `<span>${t.name} ${t.active?'★':''}</span><span><button data-act data-id="${t.id}">Activate & send</button> <button data-prev data-id="${t.id}">Preview</button></span>`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
wrap.querySelectorAll('button[data-act]').forEach(b => b.onclick = async () => {
|
||||||
|
const id = b.dataset.id;
|
||||||
|
const r = await fetch(`${API_URL}/kiosk/templates/${id}/activate`, { method:'POST', credentials:'include' });
|
||||||
|
if (r.ok) { toast('activated'); document.getElementById('modal').classList.remove('open'); sendCmd({ t:'load_template', templateId: id }); const tpl = await fetchTemplate(id); if (tpl) loadTemplate(tpl); }
|
||||||
|
else toast('activate failed');
|
||||||
|
});
|
||||||
|
wrap.querySelectorAll('button[data-prev]').forEach(b => b.onclick = async () => {
|
||||||
|
const tpl = await fetchTemplate(b.dataset.id);
|
||||||
|
if (tpl) {
|
||||||
|
loadTemplate(tpl);
|
||||||
|
// Costruisci payload runtime per il device (boxes ricostruite).
|
||||||
|
const content = { grid:{cols:COLS, rows:ROWS}, boxes: boxes.map(x => ({...x})) };
|
||||||
|
sendCmd({ t:'apply_inline', content });
|
||||||
|
document.getElementById('modal').classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('modal').classList.add('open');
|
||||||
|
};
|
||||||
|
document.getElementById('modalClose').onclick = () => document.getElementById('modal').classList.remove('open');
|
||||||
|
|
||||||
|
document.getElementById('persistBtn').onclick = async () => {
|
||||||
|
if (!template) return toast('no template loaded');
|
||||||
|
const name = prompt('New template name:', (template.name || 'Template') + ' (edited)');
|
||||||
|
if (!name) return;
|
||||||
|
const body = {
|
||||||
|
name: String(name).slice(0, 50),
|
||||||
|
tags: template.tags || [],
|
||||||
|
elements: boxes.map(boxToElement)
|
||||||
|
};
|
||||||
|
const r = await fetch(`${API_URL}/kiosk/templates`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (r.ok) toast('saved'); else toast('save failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('reloadBtn').onclick = () => sendCmd({ t:'reload' });
|
||||||
|
|
||||||
|
// boot
|
||||||
|
(async () => {
|
||||||
|
const tpl = await fetchActive();
|
||||||
|
if (tpl) loadTemplate(tpl);
|
||||||
|
if (sensorName) connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -28,6 +28,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Label Popup -->
|
||||||
|
<div class="session-label-overlay" id="sessionLabelOverlay" style="display:none">
|
||||||
|
<div class="session-popup">
|
||||||
|
<h2>Nome Sessione</h2>
|
||||||
|
<p class="popup-subtitle">I nuovi dati verranno taggati con questo nome</p>
|
||||||
|
<input type="text" id="sessionLabelInput" placeholder="es. Traversata Sardegna" />
|
||||||
|
<p style="font-size:0.8rem;color:#94a3b8;margin:8px 0;">Attuale: <span id="currentSessionLabel">—</span></p>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button id="saveSessionLabelBtn">Salva</button>
|
||||||
|
<button id="cancelSessionLabelBtn" style="background:#334155;">Annulla</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="content" id="mainContent" style="display: none;">
|
<div class="content" id="mainContent" style="display: none;">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@@ -154,6 +168,10 @@
|
|||||||
|
|
||||||
<div class="bar-sep"></div>
|
<div class="bar-sep"></div>
|
||||||
|
|
||||||
|
<button id="sessionLabelBtn" title="Sessione di registrazione">Sessione</button>
|
||||||
|
|
||||||
|
<div class="bar-sep"></div>
|
||||||
|
|
||||||
<button id="downloadBtn" title="Scarica CSV">Scarica</button>
|
<button id="downloadBtn" title="Scarica CSV">Scarica</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -240,31 +258,46 @@ function getColorForField(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FIELD_DEFS = {
|
const FIELD_DEFS = {
|
||||||
temp: { name: 'Temperatura', unit: '°C', category: 'weather' },
|
// Meteo (da openmeteo → SignalK → logs)
|
||||||
hum: { name: 'Umidita', unit: '%', category: 'weather' },
|
'meb.forecasts.temperature': { name: 'Temperatura', unit: '°C', category: 'weather' },
|
||||||
pres: { name: 'Pressione', unit: 'hPa', category: 'weather' },
|
'meb.forecast.wind.speed': { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
|
||||||
wSpd: { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
|
'meb.forecast.wind.direction': { name: 'Direzione Vento', unit: '°', category: 'weather' },
|
||||||
wDir: { name: 'Direzione Vento', unit: '°', category: 'weather' },
|
'meb.forecast.wind.gusts': { name: 'Raffiche', unit: 'km/h', category: 'weather' },
|
||||||
gust: { name: 'Raffiche', unit: 'km/h', category: 'weather' },
|
'meb.forecast.humidity': { name: 'Umidita', unit: '%', category: 'weather' },
|
||||||
rain: { name: 'Pioggia', unit: 'mm', category: 'weather' },
|
'meb.forecast.pressure': { name: 'Pressione', unit: 'hPa', category: 'weather' },
|
||||||
prec: { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
|
'meb.forecast.precipitation': { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
|
||||||
lat: { name: 'Latitudine', unit: '°', category: 'navigation' },
|
'meb.forecast.rain': { name: 'Pioggia', unit: 'mm', category: 'weather' },
|
||||||
lon: { name: 'Longitudine', unit: '°', category: 'navigation' },
|
'meb.forecast.cloudCover': { name: 'Copertura Nuvole', unit: '%', category: 'weather' },
|
||||||
hdg: { name: 'Heading', unit: '°', category: 'navigation' },
|
'meb.forecast.precipitationProbability': { name: 'Prob. Precipitazioni', unit: '%', category: 'weather' },
|
||||||
sog: { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
|
// Marine
|
||||||
cog: { name: 'Rotta COG', unit: '°', category: 'navigation' },
|
'meb.waves.height': { name: 'Altezza Onde', unit: 'm', category: 'weather' },
|
||||||
depth: { name: 'Profondita', unit: 'm', category: 'navigation' },
|
'meb.waves.direction': { name: 'Direzione Onde', unit: '°', category: 'weather' },
|
||||||
engTemp: { name: 'Temp. Motore', unit: '°C', category: 'engine' },
|
'meb.waves.period': { name: 'Periodo Onde', unit: 's', category: 'weather' },
|
||||||
wvH: { name: 'Altezza Onde', unit: 'm', category: 'weather' },
|
'meb.waves.peakPeriod': { name: 'Periodo Picco', unit: 's', category: 'weather' },
|
||||||
wvP: { name: 'Periodo Onde', unit: 's', category: 'weather' },
|
'meb.waves.currentVelocity': { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
|
||||||
wvD: { name: 'Direzione Onde', unit: '°', category: 'weather' },
|
'meb.waves.currentDirection': { name: 'Dir. Corrente', unit: '°', category: 'weather' },
|
||||||
curD: { name: 'Dir. Corrente', unit: '°', category: 'weather' },
|
// Navigazione
|
||||||
curV: { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
|
'navigation.position.latitude': { name: 'Latitudine', unit: '°', category: 'navigation' },
|
||||||
fTemp: { name: 'Prev. Temperatura', unit: '°C', category: 'weather' },
|
'navigation.position.longitude': { name: 'Longitudine', unit: '°', category: 'navigation' },
|
||||||
fWSpd: { name: 'Prev. Vento', unit: 'km/h', category: 'weather' }
|
'navigation.headingTrue': { name: 'Heading', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.speedOverGround': { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
|
||||||
|
'navigation.courseOverGroundTrue': { name: 'Rotta COG', unit: '°', category: 'navigation' },
|
||||||
|
// Elettrica
|
||||||
|
'electrical.batteries.service.Voltage': { name: 'Batteria Serv. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.service.current': { name: 'Batteria Serv. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.service.stateOfCharge': { name: 'Batteria Serv. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.Voltage': { name: 'Batteria Traz. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.current': { name: 'Batteria Traz. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.stateOfCharge': { name: 'Batteria Traz. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.temperature': { name: 'Batteria Traz. Temp', unit: '°C', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.power': { name: 'Batteria Traz. W', unit: 'W', category: 'engine' },
|
||||||
|
// Motore
|
||||||
|
'propulsion.0.revolutions': { name: 'Giri Motore', unit: 'RPM', category: 'engine' },
|
||||||
|
// Sistema
|
||||||
|
'system.uptime': { name: 'Uptime', unit: 's', category: 'engine' }
|
||||||
};
|
};
|
||||||
const MEASUREMENT_CATEGORY = { weather: 'weather', navigation: 'navigation', engine: 'engine' };
|
const MEASUREMENT_CATEGORY = { weather: 'weather', navigation: 'navigation', logs: 'navigation', engine: 'engine' };
|
||||||
const ALWAYS_FILL_BOTTOM_FIELDS = ['lat', 'lon'];
|
const ALWAYS_FILL_BOTTOM_FIELDS = ['navigation.position.latitude', 'navigation.position.longitude'];
|
||||||
|
|
||||||
async function loadSessions() {
|
async function loadSessions() {
|
||||||
document.getElementById('sessionList').innerHTML = '<div class="session-loading">Caricamento...</div>';
|
document.getElementById('sessionList').innerHTML = '<div class="session-loading">Caricamento...</div>';
|
||||||
@@ -283,22 +316,27 @@ async function loadSessions() {
|
|||||||
const meta = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta;
|
const meta = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta;
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'session-item';
|
item.className = 'session-item';
|
||||||
const connTime = meta.connectedAt ? new Date(meta.connectedAt * 1000).toLocaleTimeString('it-IT') : '—';
|
const connTime = meta.connectedAt ? new Date(meta.connectedAt).toLocaleTimeString('it-IT') : '—';
|
||||||
item.innerHTML = `<div class="session-item-info"><strong>${meta.name || sId}</strong><span class="session-item-id">${sId}</span></div><div class="session-item-meta"><span class="session-item-time">Connesso: ${connTime}</span><div class="session-item-dot"></div></div>`;
|
const sessId = meta.session || '—';
|
||||||
|
item.innerHTML = `<div class="session-item-info"><strong>${meta.name || sId}</strong><span class="session-item-id">${sessId}</span></div><div class="session-item-meta"><span class="session-item-time">Connesso: ${connTime}</span><div class="session-item-dot"></div></div>`;
|
||||||
item.onclick = () => selectSession(sId, meta);
|
item.onclick = () => selectSession(sId, meta);
|
||||||
document.getElementById('sessionList').appendChild(item);
|
document.getElementById('sessionList').appendChild(item);
|
||||||
}
|
}
|
||||||
} catch (err) { }
|
} catch (err) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentSessionId = null; // InfluxDB session tag (es. s1234)
|
||||||
|
|
||||||
function selectSession(sId, meta) {
|
function selectSession(sId, meta) {
|
||||||
currentSensorId = sId;
|
currentSensorId = sId;
|
||||||
sessionStartTime = meta.connectedAt ? meta.connectedAt * 1000 : Date.now();
|
currentSessionId = meta.session || null;
|
||||||
|
sessionStartTime = meta.connectedAt ? new Date(meta.connectedAt).getTime() : Date.now();
|
||||||
document.getElementById('sessionOverlay').style.display = 'none';
|
document.getElementById('sessionOverlay').style.display = 'none';
|
||||||
document.getElementById('mainContent').style.display = '';
|
document.getElementById('mainContent').style.display = '';
|
||||||
document.getElementById('bottomBar').style.display = '';
|
document.getElementById('bottomBar').style.display = '';
|
||||||
document.getElementById('sensorName').textContent = meta.name || sId;
|
document.getElementById('sensorName').textContent = meta.name || sId;
|
||||||
document.getElementById('sessionInfoTitle').textContent = `Sensore: ${meta.name || sId}`;
|
document.getElementById('sessionInfoTitle').textContent = `Sensore: ${meta.name || sId}`;
|
||||||
|
document.getElementById('currentSessionLabel').textContent = currentSessionId || sId;
|
||||||
liveData = {};
|
liveData = {};
|
||||||
Object.values(miniCharts).forEach(c => c.destroy());
|
Object.values(miniCharts).forEach(c => c.destroy());
|
||||||
miniCharts = {};
|
miniCharts = {};
|
||||||
@@ -370,7 +408,11 @@ function handleSensorData(msg) {
|
|||||||
if (redrawExpChart) updateExpandedChart();
|
if (redrawExpChart) updateExpandedChart();
|
||||||
if (redrawCompChart) updateCompChart();
|
if (redrawCompChart) updateCompChart();
|
||||||
|
|
||||||
if (measurement === 'logs' && fields.lat && fields.lon) updateMap(fields.lat, fields.lon, fields.hdg, fields.wDir, fields.wvD);
|
const lat = fields['navigation.position.latitude'];
|
||||||
|
const lon = fields['navigation.position.longitude'];
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
updateMap(lat, lon, fields['navigation.headingTrue'], fields['meb.forecast.wind.direction'], fields['meb.waves.direction']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHybCard(key, def, val) {
|
function createHybCard(key, def, val) {
|
||||||
@@ -662,7 +704,8 @@ document.getElementById('downloadBtn').onclick = async () => {
|
|||||||
btn.textContent = '...';
|
btn.textContent = '...';
|
||||||
|
|
||||||
await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/flush`, { method: 'POST' });
|
await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/flush`, { method: 'POST' });
|
||||||
const csvUrl = `${REALTIME_URL}/sessions/${currentSensorId}/csv?from=${sessionStartTime}`;
|
const sessionParam = currentSessionId ? `&session=${currentSessionId}` : '';
|
||||||
|
const csvUrl = `${REALTIME_URL}/sessions/${currentSensorId}/csv?from=${sessionStartTime}${sessionParam}`;
|
||||||
const res = await fetch(csvUrl);
|
const res = await fetch(csvUrl);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
|
|
||||||
@@ -704,4 +747,33 @@ function showToast(msg) {
|
|||||||
}, 4500);
|
}, 4500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Session Label Popup ---
|
||||||
|
document.getElementById('sessionLabelBtn').onclick = () => {
|
||||||
|
document.getElementById('sessionLabelInput').value = '';
|
||||||
|
document.getElementById('sessionLabelOverlay').style.display = 'flex';
|
||||||
|
};
|
||||||
|
document.getElementById('cancelSessionLabelBtn').onclick = () => {
|
||||||
|
document.getElementById('sessionLabelOverlay').style.display = 'none';
|
||||||
|
};
|
||||||
|
document.getElementById('saveSessionLabelBtn').onclick = async () => {
|
||||||
|
const label = document.getElementById('sessionLabelInput').value.trim();
|
||||||
|
if (!label || !currentSensorId || !currentSessionId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/details`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session: currentSessionId, name: label })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('currentSessionLabel').textContent = label;
|
||||||
|
showToast(`Sessione rinominata: ${label}`);
|
||||||
|
} else {
|
||||||
|
showToast('Errore nel salvataggio');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Errore di connessione');
|
||||||
|
}
|
||||||
|
document.getElementById('sessionLabelOverlay').style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
301
console/src/pages/marine.html
Normal file
301
console/src/pages/marine.html
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Copernicus Marine — MEB Console</title>
|
||||||
|
<link rel="stylesheet" href="../static/styles/style.css">
|
||||||
|
<style>
|
||||||
|
.m-tabs { display: flex; gap: .5rem; padding: 0 1.5rem; border-bottom: 1px solid rgba(255,255,255,.06); }
|
||||||
|
.m-tabs button { padding: .6rem 1.2rem; background: transparent; border: none; color: inherit; cursor: pointer; border-bottom: 2px solid transparent; opacity: .6; }
|
||||||
|
.m-tabs button.active { opacity: 1; border-bottom-color: #3498db; color: #5dade2; }
|
||||||
|
.m-panel { padding: 1.5rem; }
|
||||||
|
.m-search { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
||||||
|
.m-search input { flex: 1; padding: .6rem .9rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 6px; font-size: .9rem; }
|
||||||
|
.m-search button { padding: .6rem 1.2rem; background: #3498db; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
||||||
|
.m-results { display: grid; gap: .75rem; }
|
||||||
|
.m-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 8px; padding: 1rem; }
|
||||||
|
.m-card .title { font-weight: 600; margin-bottom: .35rem; }
|
||||||
|
.m-card .id { font-size: .75rem; opacity: .55; font-family: monospace; margin-bottom: .5rem; }
|
||||||
|
.m-card .desc { font-size: .85rem; opacity: .8; margin-bottom: .5rem; }
|
||||||
|
.m-card .meta { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .75rem; }
|
||||||
|
.m-card .chip { background: rgba(52,152,219,.15); color: #5dade2; padding: .15rem .5rem; border-radius: 10px; }
|
||||||
|
.m-card .actions { margin-top: .75rem; display: flex; gap: .5rem; }
|
||||||
|
.m-card .actions button { padding: .4rem .9rem; font-size: .8rem; background: rgba(255,255,255,.08); color: inherit; border: 1px solid rgba(255,255,255,.15); border-radius: 5px; cursor: pointer; }
|
||||||
|
.m-card .actions button.primary { background: #3498db; color: #fff; border-color: transparent; }
|
||||||
|
.m-pagination { display: flex; gap: .5rem; justify-content: center; margin-top: 1rem; align-items: center; font-size: .85rem; opacity: .8; }
|
||||||
|
.m-pagination button { padding: .3rem .7rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 4px; cursor: pointer; }
|
||||||
|
.m-pagination button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.m-modal { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.m-modal.show { display: flex; }
|
||||||
|
.m-modal .box { background: #1a1f2b; border-radius: 10px; padding: 1.5rem; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; }
|
||||||
|
.m-modal h3 { margin-top: 0; }
|
||||||
|
.m-form label { display: block; margin-top: .75rem; font-size: .85rem; opacity: .8; }
|
||||||
|
.m-form input, .m-form select, .m-form textarea { width: 100%; padding: .5rem .7rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 5px; margin-top: .25rem; font-size: .9rem; }
|
||||||
|
.m-form .row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
|
||||||
|
.m-form .vars { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .35rem; }
|
||||||
|
.m-form .vars label { display: inline-flex; gap: .3rem; align-items: center; background: rgba(255,255,255,.05); padding: .25rem .5rem; border-radius: 4px; margin: 0; font-size: .8rem; }
|
||||||
|
.m-form .actions { margin-top: 1.2rem; display: flex; gap: .5rem; justify-content: flex-end; }
|
||||||
|
.m-form .actions button { padding: .5rem 1.2rem; border: none; border-radius: 5px; cursor: pointer; font-weight: 600; }
|
||||||
|
.btn-cancel { background: rgba(255,255,255,.1); color: inherit; }
|
||||||
|
.btn-go { background: #27ae60; color: #fff; }
|
||||||
|
.progress { height: 8px; background: rgba(255,255,255,.1); border-radius: 4px; overflow: hidden; margin-top: .5rem; }
|
||||||
|
.progress .bar { height: 100%; background: #3498db; transition: width .3s; }
|
||||||
|
.m-datasets-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||||
|
.m-datasets-table th, .m-datasets-table td { text-align: left; padding: .6rem .5rem; border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||||
|
.m-datasets-table th { font-size: .75rem; opacity: .6; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.m-datasets-table tr:hover { background: rgba(255,255,255,.02); }
|
||||||
|
.m-empty { text-align: center; padding: 3rem; opacity: .5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="contnent" style="padding:0;">
|
||||||
|
<div class="header" style="padding: .5rem 1.5rem;">
|
||||||
|
<h1 style="font-size:1.2rem;">Copernicus Marine</h1>
|
||||||
|
<div class="profile"><a href="/dashboard">← Dashboard</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-tabs">
|
||||||
|
<button class="active" data-tab="search">Ricerca catalogo</button>
|
||||||
|
<button data-tab="saved">I miei dataset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="tab-search" class="m-panel">
|
||||||
|
<div class="m-search">
|
||||||
|
<input type="search" id="searchInput" placeholder="Cerca dataset (es. 'currents', 'waves', 'mediterranean')…">
|
||||||
|
<button id="searchBtn">Cerca</button>
|
||||||
|
</div>
|
||||||
|
<div id="results" class="m-results"><p class="m-empty">Inserisci una query per cercare nel catalogo Copernicus Marine.</p></div>
|
||||||
|
<div class="m-pagination" id="pagination" style="display:none;">
|
||||||
|
<button id="prevPage">‹ Precedente</button>
|
||||||
|
<span id="pageInfo"></span>
|
||||||
|
<button id="nextPage">Successiva ›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-saved" class="m-panel" style="display:none;">
|
||||||
|
<div id="saved" class="m-results"><p class="m-empty">Carico…</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Modal download -->
|
||||||
|
<div class="m-modal" id="downloadModal">
|
||||||
|
<div class="box">
|
||||||
|
<h3>Scarica dataset</h3>
|
||||||
|
<div id="dsInfo" style="font-size:.85rem; opacity:.7; margin-bottom:.75rem;"></div>
|
||||||
|
<form class="m-form" id="downloadForm" onsubmit="return false;">
|
||||||
|
<label>Nome del dataset salvato</label>
|
||||||
|
<input name="nome" required>
|
||||||
|
<label>Variabili</label>
|
||||||
|
<div class="vars" id="varsList"></div>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Min longitudine</label><input name="min_longitude" type="number" step="any" required></div>
|
||||||
|
<div><label>Max longitudine</label><input name="max_longitude" type="number" step="any" required></div>
|
||||||
|
<div><label>Min latitudine</label><input name="min_latitude" type="number" step="any" required></div>
|
||||||
|
<div><label>Max latitudine</label><input name="max_latitude" type="number" step="any" required></div>
|
||||||
|
<div><label>Data inizio</label><input name="start_date" type="date" required></div>
|
||||||
|
<div><label>Data fine</label><input name="end_date" type="date" required></div>
|
||||||
|
</div>
|
||||||
|
<label>Formato</label>
|
||||||
|
<select name="format"><option value="csv">CSV</option><option value="json">JSON</option></select>
|
||||||
|
<label>Tag (separati da virgola)</label>
|
||||||
|
<input name="tags" placeholder="marine, currents">
|
||||||
|
<label>Note</label>
|
||||||
|
<textarea name="notes" rows="2"></textarea>
|
||||||
|
<label><input type="checkbox" id="downloadLocal"> Scarica anche sul mio computer</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn-cancel" id="cancelDl">Annulla</button>
|
||||||
|
<button type="button" class="btn-go" id="startDl">Scarica</button>
|
||||||
|
</div>
|
||||||
|
<div id="jobProgress" style="display:none; margin-top:1rem;">
|
||||||
|
<div id="jobMessage" style="font-size:.85rem;">In attesa…</div>
|
||||||
|
<div class="progress"><div class="bar" id="jobBar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = "{{ apiUrl }}";
|
||||||
|
const MARINE = "{{ marineUrl }}";
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
let currentSearch = '', currentOffset = 0, pageLimit = 20, lastTotal = 0;
|
||||||
|
let currentDataset = null;
|
||||||
|
|
||||||
|
// ── Tabs ──
|
||||||
|
document.querySelectorAll('.m-tabs button').forEach(b => b.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.m-tabs button').forEach(x => x.classList.toggle('active', x === b));
|
||||||
|
$('tab-search').style.display = b.dataset.tab === 'search' ? '' : 'none';
|
||||||
|
$('tab-saved').style.display = b.dataset.tab === 'saved' ? '' : 'none';
|
||||||
|
if (b.dataset.tab === 'saved') loadSaved();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Search ──
|
||||||
|
async function runSearch(offset = 0) {
|
||||||
|
const q = $('searchInput').value.trim();
|
||||||
|
if (!q) { $('results').innerHTML = '<p class="m-empty">Inserisci una query.</p>'; $('pagination').style.display='none'; return; }
|
||||||
|
currentSearch = q; currentOffset = offset;
|
||||||
|
$('results').innerHTML = '<p class="m-empty">Cerco…</p>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MARINE}/catalog?search=${encodeURIComponent(q)}&limit=${pageLimit}&offset=${offset}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
lastTotal = data.total || 0;
|
||||||
|
renderResults(data.datasets || []);
|
||||||
|
renderPagination();
|
||||||
|
} catch (e) { $('results').innerHTML = `<p class="m-empty">Errore: ${e.message}</p>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(list) {
|
||||||
|
if (!list.length) { $('results').innerHTML = '<p class="m-empty">Nessun risultato.</p>'; return; }
|
||||||
|
$('results').innerHTML = list.map(d => `
|
||||||
|
<div class="m-card">
|
||||||
|
<div class="title">${escapeHtml(d.title || d.dataset_id)}</div>
|
||||||
|
<div class="id">${escapeHtml(d.dataset_id)}</div>
|
||||||
|
<div class="desc">${escapeHtml(d.description || '')}</div>
|
||||||
|
<div class="meta">
|
||||||
|
${d.variables ? d.variables.slice(0,6).map(v => `<span class="chip">${escapeHtml(v.short_name)}</span>`).join('') : ''}
|
||||||
|
${d.variables && d.variables.length > 6 ? `<span class="chip">+${d.variables.length - 6}</span>` : ''}
|
||||||
|
${d.start_datetime ? `<span class="chip">${d.start_datetime} → ${d.end_datetime || 'oggi'}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" data-id="${escapeHtml(d.dataset_id)}">⇩ Scarica</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
document.querySelectorAll('#results button[data-id]').forEach(b => {
|
||||||
|
b.addEventListener('click', () => openDownload(b.dataset.id, list.find(x => x.dataset_id === b.dataset.id)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination() {
|
||||||
|
const pag = $('pagination');
|
||||||
|
if (lastTotal <= pageLimit) { pag.style.display='none'; return; }
|
||||||
|
pag.style.display = 'flex';
|
||||||
|
$('pageInfo').textContent = `${currentOffset + 1}–${Math.min(currentOffset + pageLimit, lastTotal)} di ${lastTotal}`;
|
||||||
|
$('prevPage').disabled = currentOffset === 0;
|
||||||
|
$('nextPage').disabled = currentOffset + pageLimit >= lastTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('searchBtn').addEventListener('click', () => runSearch(0));
|
||||||
|
$('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') runSearch(0); });
|
||||||
|
$('prevPage').addEventListener('click', () => runSearch(Math.max(0, currentOffset - pageLimit)));
|
||||||
|
$('nextPage').addEventListener('click', () => runSearch(currentOffset + pageLimit));
|
||||||
|
|
||||||
|
// ── Download modal ──
|
||||||
|
function openDownload(id, ds) {
|
||||||
|
currentDataset = ds;
|
||||||
|
$('dsInfo').innerHTML = `<strong>${escapeHtml(ds.title || id)}</strong><br><span style="font-family:monospace; font-size:.75rem;">${escapeHtml(id)}</span>`;
|
||||||
|
const form = $('downloadForm');
|
||||||
|
form.nome.value = (ds.title || id).slice(0, 60);
|
||||||
|
form.min_longitude.value = ds.min_longitude ?? '';
|
||||||
|
form.max_longitude.value = ds.max_longitude ?? '';
|
||||||
|
form.min_latitude.value = ds.min_latitude ?? '';
|
||||||
|
form.max_latitude.value = ds.max_latitude ?? '';
|
||||||
|
form.start_date.value = ds.start_datetime || '';
|
||||||
|
form.end_date.value = ds.end_datetime || '';
|
||||||
|
$('varsList').innerHTML = (ds.variables || []).map(v =>
|
||||||
|
`<label><input type="checkbox" value="${escapeHtml(v.short_name)}" checked>${escapeHtml(v.short_name)}</label>`
|
||||||
|
).join('');
|
||||||
|
$('jobProgress').style.display = 'none';
|
||||||
|
$('downloadModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('cancelDl').addEventListener('click', () => $('downloadModal').classList.remove('show'));
|
||||||
|
|
||||||
|
$('startDl').addEventListener('click', async () => {
|
||||||
|
const form = $('downloadForm');
|
||||||
|
const variables = [...$('varsList').querySelectorAll('input:checked')].map(x => x.value);
|
||||||
|
if (!variables.length) return alert('Seleziona almeno una variabile');
|
||||||
|
const payload = {
|
||||||
|
dataset_id: currentDataset.dataset_id,
|
||||||
|
variables,
|
||||||
|
min_longitude: parseFloat(form.min_longitude.value),
|
||||||
|
max_longitude: parseFloat(form.max_longitude.value),
|
||||||
|
min_latitude: parseFloat(form.min_latitude.value),
|
||||||
|
max_latitude: parseFloat(form.max_latitude.value),
|
||||||
|
start_date: form.start_date.value,
|
||||||
|
end_date: form.end_date.value,
|
||||||
|
format: form.format.value,
|
||||||
|
nome: form.nome.value,
|
||||||
|
tags: form.tags.value.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
notes: form.notes.value,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
$('startDl').disabled = true;
|
||||||
|
const res = await fetch(`${MARINE}/jobs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const job = await res.json();
|
||||||
|
pollJob(job.job_id);
|
||||||
|
} catch (e) { alert('Errore: ' + e.message); $('startDl').disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function pollJob(jobId) {
|
||||||
|
$('jobProgress').style.display = '';
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MARINE}/jobs/${jobId}`, { credentials: 'include' });
|
||||||
|
const j = await res.json();
|
||||||
|
$('jobMessage').textContent = j.message || j.status;
|
||||||
|
$('jobBar').style.width = (j.progress || 0) + '%';
|
||||||
|
if (j.status === 'done') {
|
||||||
|
clearInterval(timer);
|
||||||
|
$('startDl').disabled = false;
|
||||||
|
if ($('downloadLocal').checked && j.dataset_id) {
|
||||||
|
window.open(`${API}/marine/datasets/${j.dataset_id}/raw`, '_blank');
|
||||||
|
}
|
||||||
|
setTimeout(() => { $('downloadModal').classList.remove('show'); loadSaved(); }, 1000);
|
||||||
|
}
|
||||||
|
if (j.status === 'error') { clearInterval(timer); $('startDl').disabled = false; }
|
||||||
|
} catch (e) { clearInterval(timer); $('startDl').disabled = false; }
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Saved datasets ──
|
||||||
|
async function loadSaved() {
|
||||||
|
$('saved').innerHTML = '<p class="m-empty">Carico…</p>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/marine/datasets`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const { datasets = [] } = await res.json();
|
||||||
|
if (!datasets.length) { $('saved').innerHTML = '<p class="m-empty">Nessun dataset salvato.</p>'; return; }
|
||||||
|
$('saved').innerHTML = `
|
||||||
|
<table class="m-datasets-table">
|
||||||
|
<thead><tr><th>Nome</th><th>Type</th><th>Formato</th><th>Size</th><th>Tag</th><th>Creato</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${datasets.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(d.nome)}</strong></td>
|
||||||
|
<td>${escapeHtml(d.type)}</td>
|
||||||
|
<td>${escapeHtml(d.format)}</td>
|
||||||
|
<td>${fmtBytes(d.size_bytes)}</td>
|
||||||
|
<td>${(d.tags||[]).map(t => `<span class="chip">${escapeHtml(t)}</span>`).join(' ')}</td>
|
||||||
|
<td style="font-size:.8rem; opacity:.7;">${new Date(d.created_at).toLocaleDateString('it-IT')}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="window.open('${API}/marine/datasets/${d.id}/raw','_blank')">⇩</button>
|
||||||
|
<button onclick="deleteDs('${d.id}')">🗑</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) { $('saved').innerHTML = `<p class="m-empty">Errore: ${e.message}</p>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.deleteDs = async (id) => {
|
||||||
|
if (!confirm('Eliminare il dataset?')) return;
|
||||||
|
await fetch(`${API}/marine/datasets/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||||
|
loadSaved();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── utils ──
|
||||||
|
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const fmtBytes = (b) => { if (!b) return '0 B'; const u = ['B','KB','MB','GB']; let i = 0; while (b >= 1024 && i < 3) { b /= 1024; i++; } return b.toFixed(1) + ' ' + u[i]; };
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
736
console/src/pages/rulesets.html
Normal file
736
console/src/pages/rulesets.html
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="/static/styles/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/styles/rulesets.css">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="rs-page">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="rs-header">
|
||||||
|
<div class="rs-header-left">
|
||||||
|
<a href="/dashboard" class="rs-back">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</a>
|
||||||
|
<h1>Rulesets</h1>
|
||||||
|
<div class="rs-type-picker" id="typePicker">
|
||||||
|
<button class="active" data-type="logs">Logs</button>
|
||||||
|
<button data-type="forecast_current">Forecast · Current</button>
|
||||||
|
<button data-type="forecast_hourly">Forecast · Hourly</button>
|
||||||
|
<button data-type="marine_current">Marine · Current</button>
|
||||||
|
<button data-type="marine_hourly">Marine · Hourly</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rs-header-right">
|
||||||
|
<span class="rs-saving" id="savingIndicator">Salvato</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="rs-toolbar">
|
||||||
|
<div class="rs-toolbar-left">
|
||||||
|
<button class="rs-filter-btn active" data-filter="all">Tutte</button>
|
||||||
|
<button class="rs-filter-btn" data-filter="active">Attive</button>
|
||||||
|
<button class="rs-filter-btn" data-filter="archived">Archiviate</button>
|
||||||
|
<select class="rs-sort-select" id="sortSelect">
|
||||||
|
<option value="created_at">Data creazione</option>
|
||||||
|
<option value="version">Versione</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="rs-toolbar-right">
|
||||||
|
<button class="rs-new-btn" id="newRuleBtn">+ Nuova Versione</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rules Grid -->
|
||||||
|
<div class="rs-grid" id="rulesGrid">
|
||||||
|
<div class="rs-empty">Caricamento...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rule Detail Popup -->
|
||||||
|
<div class="rs-overlay" id="ruleOverlay" style="display:none">
|
||||||
|
<div class="rs-popup">
|
||||||
|
<div class="rs-popup-header">
|
||||||
|
<div class="rs-popup-header-left">
|
||||||
|
<span class="rs-card-id" id="popupId"></span>
|
||||||
|
<span class="rs-saving" id="popupSaving">Salvato</span>
|
||||||
|
</div>
|
||||||
|
<button class="rs-popup-close" id="popupClose">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="rs-popup-body">
|
||||||
|
|
||||||
|
<!-- Version + Description -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-field-row">
|
||||||
|
<span class="rs-field-label">Versione</span>
|
||||||
|
<div class="rs-version-inputs">
|
||||||
|
<input class="rs-version-num" id="popupVMajor" type="number" min="1" max="100" placeholder="1" />
|
||||||
|
<span class="rs-version-dot">.</span>
|
||||||
|
<input class="rs-version-num" id="popupVBuild" type="number" min="0" max="100" placeholder="0" />
|
||||||
|
<span class="rs-version-dot">.</span>
|
||||||
|
<input class="rs-version-num" id="popupVPatch" type="number" min="0" max="100" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rs-field-row">
|
||||||
|
<span class="rs-field-label">Descrizione</span>
|
||||||
|
<textarea class="rs-field-textarea" id="popupDesc" placeholder="Descrizione opzionale..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-section-title">Tags</div>
|
||||||
|
<div class="rs-tags-wrap" id="popupTags">
|
||||||
|
<input class="rs-tag-input" id="tagInput" placeholder="Aggiungi tag..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-section-title">Azioni</div>
|
||||||
|
<div class="rs-actions">
|
||||||
|
<button class="rs-action-btn active-toggle" id="toggleActiveBtn">Attiva</button>
|
||||||
|
<button class="rs-action-btn archive-toggle" id="toggleArchiveBtn">Archivia</button>
|
||||||
|
<button class="rs-action-btn danger" id="deleteRuleBtn">Elimina</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-items-header">
|
||||||
|
<div class="rs-section-title">Items</div>
|
||||||
|
<button class="rs-add-item-btn" id="addItemBtn">+ Aggiungi</button>
|
||||||
|
</div>
|
||||||
|
<div class="rs-item-labels" id="itemLabelsRow"></div>
|
||||||
|
<div id="itemsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deploy -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-section-title">Deploy ai sensori</div>
|
||||||
|
<div class="rs-deploy-wrap">
|
||||||
|
<div id="deploySensorsList" class="rs-deploy-sensors">
|
||||||
|
<div class="rs-empty" style="padding:8px">Caricamento sensori...</div>
|
||||||
|
</div>
|
||||||
|
<div class="rs-deploy-actions">
|
||||||
|
<button class="rs-action-btn" id="deployBtn">Invia versione ai selezionati</button>
|
||||||
|
<span class="rs-deploy-result" id="deployResult"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Dialog -->
|
||||||
|
<div class="rs-confirm-overlay" id="confirmOverlay" style="display:none">
|
||||||
|
<div class="rs-confirm-box">
|
||||||
|
<h3 id="confirmTitle">Conferma</h3>
|
||||||
|
<p id="confirmText">Sei sicuro?</p>
|
||||||
|
<div class="rs-confirm-actions">
|
||||||
|
<button id="confirmCancel">Annulla</button>
|
||||||
|
<button class="confirm-danger" id="confirmOk">Conferma</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_URL = '{{ apiUrl }}';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
const TYPES = ['logs','forecast_current','forecast_hourly','marine_current','marine_hourly'];
|
||||||
|
let currentType = 'logs';
|
||||||
|
let currentFilter = 'all';
|
||||||
|
let currentSort = 'created_at';
|
||||||
|
let allRules = [];
|
||||||
|
let openRule = null;
|
||||||
|
let saveTimers = {};
|
||||||
|
let sensorsCache = [];
|
||||||
|
let deploymentsForOpen = []; // deployments relativi alla rule aperta
|
||||||
|
|
||||||
|
// --- Item field definitions per tipo ---
|
||||||
|
// Per ogni tipo, definiamo gli input visibili. Tutti finiscono nei campi
|
||||||
|
// { ref, path, enabled, meta: {...} } del JSONB dell'item.
|
||||||
|
//
|
||||||
|
// - logs: ref, path (SK path), meta.measurement, meta.unit
|
||||||
|
// - forecast_*: ref, path (codice openmeteo), meta.sk_path, meta.unit
|
||||||
|
// - marine_*: ref, path (codice openmeteo), meta.sk_path, meta.unit
|
||||||
|
const ITEM_SCHEMA = {
|
||||||
|
logs: [
|
||||||
|
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||||
|
{ key: 'path', label: 'SK Path', cls: 'wide' },
|
||||||
|
{ key: 'meta.measurement', label: 'Measurement', cls: 'medium' },
|
||||||
|
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
],
|
||||||
|
forecast_current: [
|
||||||
|
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||||
|
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
|
||||||
|
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
|
||||||
|
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
],
|
||||||
|
forecast_hourly: [
|
||||||
|
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||||
|
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
|
||||||
|
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
|
||||||
|
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
],
|
||||||
|
marine_current: [
|
||||||
|
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||||
|
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
|
||||||
|
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
|
||||||
|
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
],
|
||||||
|
marine_hourly: [
|
||||||
|
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||||
|
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
|
||||||
|
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
|
||||||
|
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Helpers ==========
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = String(str);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getField(obj, dottedKey) {
|
||||||
|
if (!dottedKey.includes('.')) return obj?.[dottedKey];
|
||||||
|
const [a, b] = dottedKey.split('.');
|
||||||
|
return obj?.[a]?.[b];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setField(obj, dottedKey, value) {
|
||||||
|
if (!dottedKey.includes('.')) { obj[dottedKey] = value; return; }
|
||||||
|
const [a, b] = dottedKey.split('.');
|
||||||
|
if (!obj[a] || typeof obj[a] !== 'object') obj[a] = {};
|
||||||
|
obj[a][b] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionStr(v) {
|
||||||
|
if (!v) return '';
|
||||||
|
if (v.str) return v.str;
|
||||||
|
return `${v.major ?? 0}.${v.build ?? 0}.${v.patch ?? 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== API helpers ==========
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = { method, headers: {}, credentials: 'include' };
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_URL}${path}`, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Load & Render Rules ==========
|
||||||
|
|
||||||
|
async function loadRules() {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', '/rules');
|
||||||
|
allRules = data[currentType] || [];
|
||||||
|
renderGrid();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading rules:', err);
|
||||||
|
document.getElementById('rulesGrid').innerHTML = '<div class="rs-empty">Errore nel caricamento</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSensors() {
|
||||||
|
if (sensorsCache.length) return sensorsCache;
|
||||||
|
try {
|
||||||
|
sensorsCache = await api('GET', '/rules/-/sensors');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading sensors:', err);
|
||||||
|
sensorsCache = [];
|
||||||
|
}
|
||||||
|
return sensorsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAndSort(rules) {
|
||||||
|
let filtered = rules;
|
||||||
|
if (currentFilter === 'active') filtered = rules.filter(r => r.active && !r.archived);
|
||||||
|
else if (currentFilter === 'archived') filtered = rules.filter(r => r.archived);
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
if (currentSort === 'version') {
|
||||||
|
const va = versionStr(a.version), vb = versionStr(b.version);
|
||||||
|
return vb.localeCompare(va, undefined, { numeric: true });
|
||||||
|
}
|
||||||
|
return new Date(b.created_at) - new Date(a.created_at);
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('rulesGrid');
|
||||||
|
const rules = filterAndSort(allRules);
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="rs-empty">Nessuna versione trovata</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = rules.map(r => {
|
||||||
|
const badges = [];
|
||||||
|
if (r.active) badges.push('<span class="rs-badge active">Attiva</span>');
|
||||||
|
else badges.push('<span class="rs-badge inactive">Inattiva</span>');
|
||||||
|
if (r.archived) badges.push('<span class="rs-badge archived">Archiviata</span>');
|
||||||
|
|
||||||
|
const tags = (r.tags || []).map(t => `<span class="rs-tag">${esc(t)}</span>`).join('');
|
||||||
|
const desc = r.description ? `<div class="rs-card-desc">${esc(r.description)}</div>` : '';
|
||||||
|
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '';
|
||||||
|
const itemsCount = (r.items_count !== undefined ? r.items_count : (r.items?.length || 0));
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rs-card" data-id="${esc(r.id)}" onclick="openRuleDetail('${esc(r.id)}')">
|
||||||
|
<div class="rs-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="rs-card-version">v${esc(versionStr(r.version))}</div>
|
||||||
|
<span class="rs-card-id" title="${esc(r.id)}">${esc(String(r.id).slice(0,8))}…</span>
|
||||||
|
</div>
|
||||||
|
<div class="rs-card-badges">${badges.join('')}</div>
|
||||||
|
</div>
|
||||||
|
${desc}
|
||||||
|
${tags ? `<div class="rs-card-tags">${tags}</div>` : ''}
|
||||||
|
<div class="rs-card-footer">
|
||||||
|
<span class="rs-card-date">${date}</span>
|
||||||
|
<span class="rs-card-items">${itemsCount} items</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Type Picker ==========
|
||||||
|
|
||||||
|
document.querySelectorAll('#typePicker button').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
document.querySelectorAll('#typePicker button').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentType = btn.dataset.type;
|
||||||
|
loadRules();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Filters ==========
|
||||||
|
|
||||||
|
document.querySelectorAll('.rs-filter-btn').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
document.querySelectorAll('.rs-filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentFilter = btn.dataset.filter;
|
||||||
|
renderGrid();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sortSelect').onchange = (e) => {
|
||||||
|
currentSort = e.target.value;
|
||||||
|
renderGrid();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== New Rule ==========
|
||||||
|
|
||||||
|
document.getElementById('newRuleBtn').onclick = async () => {
|
||||||
|
try {
|
||||||
|
// Calcola una versione libera: prendi la maggiore esistente e incrementa patch
|
||||||
|
let M = 1, B = 0, P = 0;
|
||||||
|
if (allRules.length) {
|
||||||
|
const sorted = [...allRules].sort((a,b) => {
|
||||||
|
const va = a.version, vb = b.version;
|
||||||
|
return (vb.major - va.major) || (vb.build - va.build) || (vb.patch - va.patch);
|
||||||
|
});
|
||||||
|
const top = sorted[0].version;
|
||||||
|
M = top.major; B = top.build; P = Math.min(100, top.patch + 1);
|
||||||
|
if (P === 100 && top.patch === 100) { P = 0; B = Math.min(100, B + 1); }
|
||||||
|
}
|
||||||
|
const rule = await api('POST', `/rules/${currentType}`, {
|
||||||
|
version_major: M, version_build: B, version_patch: P,
|
||||||
|
tags: [], description: '', items: []
|
||||||
|
});
|
||||||
|
allRules.unshift(rule);
|
||||||
|
renderGrid();
|
||||||
|
openRuleDetail(rule.id);
|
||||||
|
flash('Salvato');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating rule:', err);
|
||||||
|
alert(`Errore: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Rule Detail Popup ==========
|
||||||
|
|
||||||
|
async function openRuleDetail(ruleId) {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', `/rules/${currentType}/${ruleId}`);
|
||||||
|
openRule = data;
|
||||||
|
deploymentsForOpen = [];
|
||||||
|
try {
|
||||||
|
deploymentsForOpen = await api('GET', `/rules/${currentType}/${ruleId}/deployments`);
|
||||||
|
} catch {}
|
||||||
|
await loadSensors();
|
||||||
|
renderPopup();
|
||||||
|
document.getElementById('ruleOverlay').style.display = 'flex';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading rule detail:', err);
|
||||||
|
alert(`Errore: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopup() {
|
||||||
|
document.getElementById('ruleOverlay').style.display = 'none';
|
||||||
|
openRule = null;
|
||||||
|
loadRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('popupClose').onclick = closePopup;
|
||||||
|
document.getElementById('ruleOverlay').onclick = (e) => {
|
||||||
|
if (e.target === document.getElementById('ruleOverlay')) closePopup();
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderPopup() {
|
||||||
|
const r = openRule;
|
||||||
|
document.getElementById('popupId').textContent = `${currentType} · ${String(r.id).slice(0,8)}`;
|
||||||
|
document.getElementById('popupVMajor').value = r.version?.major ?? 1;
|
||||||
|
document.getElementById('popupVBuild').value = r.version?.build ?? 0;
|
||||||
|
document.getElementById('popupVPatch').value = r.version?.patch ?? 0;
|
||||||
|
document.getElementById('popupDesc').value = r.description || '';
|
||||||
|
|
||||||
|
renderTags();
|
||||||
|
updateActionButtons();
|
||||||
|
renderItems();
|
||||||
|
renderDeploySensors();
|
||||||
|
document.getElementById('deployResult').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto-save fields ---
|
||||||
|
|
||||||
|
['popupVMajor','popupVBuild','popupVPatch'].forEach(id => {
|
||||||
|
document.getElementById(id).oninput = () => debounceFieldSave('version');
|
||||||
|
});
|
||||||
|
document.getElementById('popupDesc').oninput = () => debounceFieldSave('description');
|
||||||
|
|
||||||
|
function debounceFieldSave(field) {
|
||||||
|
clearTimeout(saveTimers[field]);
|
||||||
|
saveTimers[field] = setTimeout(() => saveRuleField(field), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRuleField(field) {
|
||||||
|
if (!openRule) return;
|
||||||
|
const body = {};
|
||||||
|
if (field === 'version') {
|
||||||
|
body.version_major = parseInt(document.getElementById('popupVMajor').value, 10) || 1;
|
||||||
|
body.version_build = parseInt(document.getElementById('popupVBuild').value, 10) || 0;
|
||||||
|
body.version_patch = parseInt(document.getElementById('popupVPatch').value, 10) || 0;
|
||||||
|
}
|
||||||
|
if (field === 'description') body.description = document.getElementById('popupDesc').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, body);
|
||||||
|
Object.assign(openRule, updated);
|
||||||
|
const idx = allRules.findIndex(r => r.id === openRule.id);
|
||||||
|
if (idx >= 0) Object.assign(allRules[idx], updated);
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving field:', err);
|
||||||
|
flash('Errore: ' + err.message, 'popupSaving');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tags ---
|
||||||
|
|
||||||
|
function renderTags() {
|
||||||
|
const wrap = document.getElementById('popupTags');
|
||||||
|
const input = document.getElementById('tagInput');
|
||||||
|
wrap.querySelectorAll('.rs-tag-chip').forEach(c => c.remove());
|
||||||
|
(openRule.tags || []).forEach((tag, i) => {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'rs-tag-chip';
|
||||||
|
chip.innerHTML = `${esc(tag)}<button data-idx="${i}">×</button>`;
|
||||||
|
chip.querySelector('button').onclick = () => removeTag(i);
|
||||||
|
wrap.insertBefore(chip, input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tagInput').onkeydown = async (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = e.target.value.trim().replace(/,$/, '');
|
||||||
|
if (!val || !openRule) return;
|
||||||
|
const tags = [...(openRule.tags || []), val];
|
||||||
|
e.target.value = '';
|
||||||
|
try {
|
||||||
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, { tags });
|
||||||
|
openRule.tags = updated.tags;
|
||||||
|
renderTags();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function removeTag(idx) {
|
||||||
|
if (!openRule) return;
|
||||||
|
const tags = [...(openRule.tags || [])];
|
||||||
|
tags.splice(idx, 1);
|
||||||
|
try {
|
||||||
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, { tags });
|
||||||
|
openRule.tags = updated.tags;
|
||||||
|
renderTags();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action Buttons ---
|
||||||
|
|
||||||
|
function updateActionButtons() {
|
||||||
|
const r = openRule;
|
||||||
|
const activeBtn = document.getElementById('toggleActiveBtn');
|
||||||
|
const archiveBtn = document.getElementById('toggleArchiveBtn');
|
||||||
|
activeBtn.textContent = r.active ? 'Disattiva' : 'Attiva';
|
||||||
|
activeBtn.className = `rs-action-btn active-toggle${r.active ? '' : ' off'}`;
|
||||||
|
archiveBtn.textContent = r.archived ? 'Dearchivia' : 'Archivia';
|
||||||
|
archiveBtn.className = `rs-action-btn archive-toggle${r.archived ? ' on' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('toggleActiveBtn').onclick = async () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
try {
|
||||||
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/active`);
|
||||||
|
openRule.active = res.active;
|
||||||
|
if (res.ruleset) Object.assign(openRule, res.ruleset);
|
||||||
|
updateActionButtons();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert(`Errore: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('toggleArchiveBtn').onclick = async () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
try {
|
||||||
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/archive`);
|
||||||
|
openRule.archived = res.archived;
|
||||||
|
if (res.ruleset) Object.assign(openRule, res.ruleset);
|
||||||
|
updateActionButtons();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('deleteRuleBtn').onclick = () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
showConfirm('Elimina Versione', `Eliminare la versione v${versionStr(openRule.version)}? Questa azione e irreversibile.`, async () => {
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/rules/${currentType}/${openRule.id}`);
|
||||||
|
allRules = allRules.filter(r => r.id !== openRule.id);
|
||||||
|
closePopup();
|
||||||
|
} catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Items ---
|
||||||
|
|
||||||
|
function renderItems() {
|
||||||
|
const schema = ITEM_SCHEMA[currentType];
|
||||||
|
const items = openRule.items || [];
|
||||||
|
|
||||||
|
const labelsRow = document.getElementById('itemLabelsRow');
|
||||||
|
labelsRow.innerHTML = schema.map(f => `<span class="${f.cls}">${f.label}</span>`).join('') +
|
||||||
|
'<span class="toggle-space">On</span><span class="delete-space"></span>';
|
||||||
|
|
||||||
|
const list = document.getElementById('itemsList');
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi".</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = items.map(item => {
|
||||||
|
const fields = schema.map(f =>
|
||||||
|
`<input class="rs-item-field ${f.cls}"
|
||||||
|
value="${esc(getField(item, f.key) ?? '')}"
|
||||||
|
data-field="${f.key}"
|
||||||
|
data-ref="${esc(item.ref)}"
|
||||||
|
onchange="saveItemField(this)" />`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const toggleCls = item.enabled !== false ? 'on' : '';
|
||||||
|
return `<div class="rs-item" data-ref="${esc(item.ref)}">
|
||||||
|
${fields}
|
||||||
|
<div class="rs-toggle ${toggleCls}" onclick="toggleItem('${esc(item.ref)}')"></div>
|
||||||
|
<button class="rs-item-delete" onclick="deleteItem('${esc(item.ref)}')">×</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addItemBtn').onclick = async () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
const ref = prompt('Ref dell\'item (identificatore stabile, es. "batt_traction_soc"):');
|
||||||
|
if (!ref) return;
|
||||||
|
try {
|
||||||
|
const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, {
|
||||||
|
ref: ref.trim(), path: '', enabled: true, meta: {}
|
||||||
|
});
|
||||||
|
if (!openRule.items) openRule.items = [];
|
||||||
|
openRule.items.push(item);
|
||||||
|
renderItems();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert(`Errore: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function saveItemField(input) {
|
||||||
|
if (!openRule) return;
|
||||||
|
const ref = input.dataset.ref;
|
||||||
|
const field = input.dataset.field;
|
||||||
|
const value = input.value;
|
||||||
|
|
||||||
|
const item = (openRule.items || []).find(i => i.ref === ref);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
// costruisci body rispettando ref/path/enabled oppure meta.<x>
|
||||||
|
const body = {};
|
||||||
|
if (field === 'ref') body.ref = value.trim();
|
||||||
|
else if (field === 'path') body.path = value;
|
||||||
|
else if (field === 'enabled') body.enabled = !!value;
|
||||||
|
else if (field.startsWith('meta.')) {
|
||||||
|
const metaKey = field.slice(5);
|
||||||
|
body.meta = { ...(item.meta || {}), [metaKey]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`, body);
|
||||||
|
// replace item in place
|
||||||
|
const idx = openRule.items.findIndex(i => i.ref === ref);
|
||||||
|
if (idx >= 0) openRule.items[idx] = updated;
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
flash('Errore', 'popupSaving');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleItem(ref) {
|
||||||
|
if (!openRule) return;
|
||||||
|
try {
|
||||||
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}/toggle`);
|
||||||
|
const item = openRule.items.find(i => i.ref === ref);
|
||||||
|
if (item) item.enabled = res.enabled;
|
||||||
|
renderItems();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(ref) {
|
||||||
|
if (!openRule) return;
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`);
|
||||||
|
openRule.items = openRule.items.filter(i => i.ref !== ref);
|
||||||
|
renderItems();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Deploy ---
|
||||||
|
|
||||||
|
function renderDeploySensors() {
|
||||||
|
const wrap = document.getElementById('deploySensorsList');
|
||||||
|
if (!sensorsCache.length) {
|
||||||
|
wrap.innerHTML = '<div class="rs-empty" style="padding:8px">Nessun sensore registrato</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const byName = Object.fromEntries(deploymentsForOpen.map(d => [d.sensor_name, d]));
|
||||||
|
wrap.innerHTML = sensorsCache.map(s => {
|
||||||
|
const d = byName[s.name];
|
||||||
|
let status = '';
|
||||||
|
if (d) {
|
||||||
|
status = d.acked_at
|
||||||
|
? `<span class="rs-deploy-status ok">ACK ${new Date(d.acked_at).toLocaleString('it-IT')}</span>`
|
||||||
|
: `<span class="rs-deploy-status pending">In attesa…</span>`;
|
||||||
|
}
|
||||||
|
return `<label class="rs-deploy-item">
|
||||||
|
<input type="checkbox" class="rs-deploy-check" value="${esc(s.name)}" />
|
||||||
|
<span class="rs-deploy-name">${esc(s.name)}</span>
|
||||||
|
${status}
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('deployBtn').onclick = async () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
if (openRule.archived) { alert('Non puoi deployare una versione archiviata'); return; }
|
||||||
|
const checks = [...document.querySelectorAll('.rs-deploy-check:checked')];
|
||||||
|
const sensors = checks.map(c => c.value);
|
||||||
|
if (!sensors.length) { alert('Seleziona almeno un sensore'); return; }
|
||||||
|
const resultEl = document.getElementById('deployResult');
|
||||||
|
resultEl.textContent = 'Invio...';
|
||||||
|
try {
|
||||||
|
const res = await api('POST', `/rules/${currentType}/${openRule.id}/deploy`, { sensors });
|
||||||
|
const parts = [];
|
||||||
|
if (res.pushed?.length) parts.push(`${res.pushed.length} online`);
|
||||||
|
if (res.offline?.length) parts.push(`${res.offline.length} offline`);
|
||||||
|
if (res.errors?.length) parts.push(`${res.errors.length} errori`);
|
||||||
|
resultEl.textContent = parts.join(' · ') || 'OK';
|
||||||
|
// refresh deployments
|
||||||
|
try { deploymentsForOpen = await api('GET', `/rules/${currentType}/${openRule.id}/deployments`); renderDeploySensors(); } catch {}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
resultEl.textContent = `Errore: ${err.message}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Confirm Dialog ==========
|
||||||
|
|
||||||
|
let confirmCallback = null;
|
||||||
|
|
||||||
|
function showConfirm(title, text, onConfirm) {
|
||||||
|
document.getElementById('confirmTitle').textContent = title;
|
||||||
|
document.getElementById('confirmText').textContent = text;
|
||||||
|
confirmCallback = onConfirm;
|
||||||
|
document.getElementById('confirmOverlay').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('confirmCancel').onclick = () => {
|
||||||
|
document.getElementById('confirmOverlay').style.display = 'none';
|
||||||
|
confirmCallback = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('confirmOk').onclick = async () => {
|
||||||
|
document.getElementById('confirmOverlay').style.display = 'none';
|
||||||
|
if (confirmCallback) await confirmCallback();
|
||||||
|
confirmCallback = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Flash ==========
|
||||||
|
|
||||||
|
function flash(text, elId = 'savingIndicator') {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.add('visible');
|
||||||
|
setTimeout(() => el.classList.remove('visible'), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Init ==========
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadRules();
|
||||||
|
loadSensors();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
922
console/src/pages/sessions.html
Normal file
922
console/src/pages/sessions.html
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="/static/styles/style.css">
|
||||||
|
<link href="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.css" rel="stylesheet">
|
||||||
|
<script src="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
<style>
|
||||||
|
/* === Layout === */
|
||||||
|
html, body { height: 100%; overflow: hidden; background: var(--surface); }
|
||||||
|
.page-wrap { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
|
||||||
|
/* === Header === */
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--header-bg); border-bottom: 1px solid var(--header-border);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
flex-shrink: 0; z-index: 100;
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.header-left h1 { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
#changeSessionBtn { display: none; padding: 8px 16px; font-size: 13px; }
|
||||||
|
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||||
|
#sessionNameDisplay { font-size: 0.95rem; font-weight: 700; color: var(--text-primary); display: none; }
|
||||||
|
#sessionMetaDisplay { font-size: 0.75rem; color: var(--text-secondary); display: none; }
|
||||||
|
|
||||||
|
/* === Session Popup Overlay === */
|
||||||
|
.session-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 2000;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
background: rgba(15,23,42,0.4);
|
||||||
|
}
|
||||||
|
.session-popup {
|
||||||
|
background: var(--header-bg, #fff); border: 1px solid var(--header-border);
|
||||||
|
border-radius: 20px; padding: 28px; width: 860px; max-width: 95vw;
|
||||||
|
max-height: 82vh; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.popup-head { margin-bottom: 16px; }
|
||||||
|
.popup-head h2 { font-size: 1.15rem; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.popup-head p { font-size: 0.82rem; color: var(--text-secondary); }
|
||||||
|
.popup-filters {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.popup-filters input {
|
||||||
|
flex: 1; padding: 9px 14px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--header-border); background: var(--surface);
|
||||||
|
font-family: inherit; font-size: 13px; color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.popup-filters input:focus { border-color: var(--accent-color); }
|
||||||
|
.popup-filters select {
|
||||||
|
padding: 9px 14px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--header-border); background: var(--surface);
|
||||||
|
font-family: inherit; font-size: 13px; color: var(--text-primary);
|
||||||
|
outline: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.session-grid {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 12px; padding-right: 4px;
|
||||||
|
}
|
||||||
|
.sess-card {
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface); padding: 16px; cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.sess-card:hover {
|
||||||
|
border-color: var(--accent-color); transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(37,99,235,0.12);
|
||||||
|
}
|
||||||
|
.sess-card-name { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.sess-card-id { font-size: 0.72rem; color: var(--text-tertiary); font-family: monospace; }
|
||||||
|
.sess-card-sensor { font-size: 0.78rem; color: var(--text-secondary); }
|
||||||
|
.sess-card-dates { font-size: 0.75rem; color: var(--text-secondary); }
|
||||||
|
.sess-card-duration { font-size: 0.78rem; font-weight: 600; color: var(--accent-color); }
|
||||||
|
.sess-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||||
|
.sess-tag {
|
||||||
|
font-size: 0.68rem; padding: 2px 8px; border-radius: 20px;
|
||||||
|
background: var(--accent-light); color: var(--accent-color);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
}
|
||||||
|
.sess-empty { grid-column: 1/-1; text-align: center; color: var(--text-secondary); padding: 40px; }
|
||||||
|
|
||||||
|
/* === Detail Panel === */
|
||||||
|
.detail-panel {
|
||||||
|
flex: 1; display: none; flex-direction: column;
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.detail-panel.visible { display: flex; }
|
||||||
|
|
||||||
|
/* === Map === */
|
||||||
|
.map-section {
|
||||||
|
flex-shrink: 0; height: 280px; position: relative; border-bottom: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
#sessionMap { width: 100%; height: 100%; }
|
||||||
|
.no-gps-msg {
|
||||||
|
display: none; position: absolute; inset: 0; align-items: center; justify-content: center;
|
||||||
|
background: var(--surface); color: var(--text-secondary); font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Data section (scrollable) === */
|
||||||
|
.data-section {
|
||||||
|
flex: 1; overflow-y: auto; padding: 16px 24px 140px;
|
||||||
|
}
|
||||||
|
.data-controls {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center;
|
||||||
|
}
|
||||||
|
.search-field {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: var(--header-bg); border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg); padding: 8px 14px; flex: 1; min-width: 140px;
|
||||||
|
}
|
||||||
|
.search-field input {
|
||||||
|
border: none; background: transparent; font-family: inherit; font-size: 13px;
|
||||||
|
color: var(--text-primary); outline: none; width: 100%;
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
display: flex; gap: 4px; background: var(--header-bg);
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.filter button { padding: 6px 12px; border-radius: 10px; font-size: 12px; border: none; }
|
||||||
|
.filter button.active {
|
||||||
|
background: var(--accent-color); color: #fff; border-color: transparent;
|
||||||
|
}
|
||||||
|
.data-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Data Cards === */
|
||||||
|
.data-card {
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
background: white; padding: 14px; display: flex; flex-direction: column; gap: 8px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.card-top { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||||
|
.card-info h4 { font-size: 0.78rem; color: var(--text-secondary); font-weight: 600; }
|
||||||
|
.card-action-btn {
|
||||||
|
padding: 4px; border-radius: 6px; border: none; background: transparent;
|
||||||
|
color: var(--text-secondary); cursor: pointer; opacity: 0.6; transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.card-action-btn:hover { opacity: 1; background: var(--accent-light); color: var(--accent-color); }
|
||||||
|
.card-body { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.card-values { display: flex; align-items: baseline; gap: 4px; }
|
||||||
|
.card-main-val { font-size: 1.4rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.card-unit { font-size: 0.75rem; color: var(--text-secondary); }
|
||||||
|
.card-mini-chart { height: 44px; position: relative; }
|
||||||
|
.card-mini-chart canvas { width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* === Expanded Chart === */
|
||||||
|
.expanded-chart-container {
|
||||||
|
display: none; position: fixed; top: 72px; left: 24px; right: 24px; bottom: 110px;
|
||||||
|
background: white; border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg); z-index: 500;
|
||||||
|
flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.expanded-chart-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 18px; border-bottom: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
.expanded-chart-header h3 { font-size: 0.95rem; font-weight: 700; }
|
||||||
|
.close-expanded-btn {
|
||||||
|
padding: 4px 10px; border-radius: 8px; border: none; font-size: 18px;
|
||||||
|
background: transparent; cursor: pointer; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.expanded-chart-body { flex: 1; padding: 16px; position: relative; }
|
||||||
|
.expanded-chart-body canvas { width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* === Timeline Bar === */
|
||||||
|
.timeline-bar {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
|
||||||
|
background: var(--header-bg); border-top: 1px solid var(--header-border);
|
||||||
|
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||||
|
display: none; align-items: center; gap: 16px;
|
||||||
|
padding: 12px 20px; min-height: 84px; flex-direction: column;
|
||||||
|
}
|
||||||
|
.timeline-bar.visible { display: flex; }
|
||||||
|
.tl-row { display: flex; align-items: center; gap: 12px; width: 100%; }
|
||||||
|
.tl-track-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.tl-track {
|
||||||
|
position: relative; height: 6px; border-radius: 3px;
|
||||||
|
background: #334155; cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.tl-fill-inner {
|
||||||
|
position: absolute; top: 0; height: 100%; background: var(--accent-color);
|
||||||
|
border-radius: 3px; pointer-events: none;
|
||||||
|
}
|
||||||
|
.tl-handle {
|
||||||
|
position: absolute; top: 50%; width: 18px; height: 18px;
|
||||||
|
background: white; border: 2px solid var(--accent-color);
|
||||||
|
border-radius: 50%; transform: translate(-50%, -50%);
|
||||||
|
cursor: grab; box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
transition: box-shadow 0.15s; z-index: 2;
|
||||||
|
}
|
||||||
|
.tl-handle:active { cursor: grabbing; box-shadow: 0 3px 10px rgba(37,99,235,0.35); }
|
||||||
|
.tl-handle.hidden { display: none; }
|
||||||
|
.tl-label-wrap {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.tl-label-start, .tl-label-end { font-size: 0.68rem; color: var(--text-secondary); }
|
||||||
|
.tl-label-current { font-size: 0.72rem; font-weight: 700; color: var(--accent-color); }
|
||||||
|
.tl-btn { padding: 8px 16px; font-size: 12px; flex-shrink: 0; }
|
||||||
|
#restrictBtn.active { background: var(--accent-color); color: white; border-color: transparent; }
|
||||||
|
.tl-loading { font-size: 0.8rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* === Loading overlay === */
|
||||||
|
.loading-overlay {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 1500;
|
||||||
|
background: rgba(248,250,252,0.85); backdrop-filter: blur(4px);
|
||||||
|
align-items: center; justify-content: center; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.loading-overlay.visible { display: flex; }
|
||||||
|
.loading-spinner {
|
||||||
|
width: 36px; height: 36px; border: 3px solid var(--accent-border);
|
||||||
|
border-top-color: var(--accent-color); border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.loading-text { font-size: 0.9rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* === Toast === */
|
||||||
|
#dl-toast {
|
||||||
|
position: fixed; bottom: 96px; right: 24px;
|
||||||
|
background: rgba(255,255,255,0.95); padding: 14px 18px;
|
||||||
|
border-radius: var(--radius-lg); border: 1px solid var(--header-border);
|
||||||
|
box-shadow: var(--shadow-md); color: var(--text-primary);
|
||||||
|
font-size: 13px; font-weight: 600; z-index: 9999;
|
||||||
|
backdrop-filter: blur(10px); transform: translateY(120px); opacity: 0;
|
||||||
|
transition: all 0.4s cubic-bezier(0.8,0,0.2,1); pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Map secondary bar === */
|
||||||
|
.map-bar {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||||
|
background: rgba(15,23,42,0.6); backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.map-bar .filter button { color: #cbd5e1; }
|
||||||
|
.map-bar .filter button.active { background: #3b82f6; color: #fff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page-wrap">
|
||||||
|
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-text" id="loadingText">Caricamento dati sessione...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session picker popup -->
|
||||||
|
<div class="session-overlay" id="sessionOverlay">
|
||||||
|
<div class="session-popup">
|
||||||
|
<div class="popup-head">
|
||||||
|
<h2>Seleziona una sessione</h2>
|
||||||
|
<p>Scegli una sessione di registrazione da analizzare</p>
|
||||||
|
</div>
|
||||||
|
<div class="popup-filters">
|
||||||
|
<input type="text" id="popupSearch" placeholder="Cerca per nome o sensore...">
|
||||||
|
<select id="popupSensorFilter"><option value="">Tutti i sensori</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="session-grid" id="sessionGrid">
|
||||||
|
<div class="sess-empty">Caricamento sessioni...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button id="changeSessionBtn">← Cambia sessione</button>
|
||||||
|
<h1>Sessioni</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span id="sessionNameDisplay"></span>
|
||||||
|
<span id="sessionMetaDisplay"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail panel -->
|
||||||
|
<div class="detail-panel" id="detailPanel">
|
||||||
|
|
||||||
|
<!-- Map -->
|
||||||
|
<div class="map-section" id="mapSection">
|
||||||
|
<div id="sessionMap"></div>
|
||||||
|
<div class="no-gps-msg" id="noGpsMsg">Nessun dato GPS per questa sessione</div>
|
||||||
|
<div class="map-bar" id="mapBar" style="display:none">
|
||||||
|
<div class="filter" style="background:transparent;border:none;">
|
||||||
|
<button class="active" data-zoom="10" style="font-size:11px;padding:4px 10px;">10x</button>
|
||||||
|
<button data-zoom="5" style="font-size:11px;padding:4px 10px;">5x</button>
|
||||||
|
<button data-zoom="1" style="font-size:11px;padding:4px 10px;">1x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data section -->
|
||||||
|
<div class="data-section" id="dataSection">
|
||||||
|
<div class="data-controls">
|
||||||
|
<div class="search-field">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 12 12" fill="none"><path d="M11.0835 11.0834L8.57516 8.57504M9.91683 5.25004C9.91683 7.82737 7.82749 9.91671 5.25016 9.91671C2.67283 9.91671 0.583496 7.82737 0.583496 5.25004C0.583496 2.67271 2.67283 0.583374 5.25016 0.583374C7.82749 0.583374 9.91683 2.67271 9.91683 5.25004Z" stroke="var(--text-secondary)" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<input type="text" id="dataSearch" placeholder="Cerca campo...">
|
||||||
|
</div>
|
||||||
|
<div class="filter" id="catFilter">
|
||||||
|
<button class="active" data-cat="all">Tutto</button>
|
||||||
|
<button data-cat="weather">Meteo</button>
|
||||||
|
<button data-cat="navigation">Navigazione</button>
|
||||||
|
<button data-cat="engine">Motore</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-grid" id="dataGrid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded chart -->
|
||||||
|
<div class="expanded-chart-container" id="expandedChartContainer">
|
||||||
|
<div class="expanded-chart-header">
|
||||||
|
<h3 id="expChartTitle">Dettaglio</h3>
|
||||||
|
<button class="close-expanded-btn" id="closeExpBtn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="expanded-chart-body">
|
||||||
|
<canvas id="expandedChartCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline bar -->
|
||||||
|
<div class="timeline-bar" id="timelineBar">
|
||||||
|
<div class="tl-row">
|
||||||
|
<button class="tl-btn" id="restrictBtn">Restringi</button>
|
||||||
|
<div class="tl-track-wrap">
|
||||||
|
<div class="tl-track" id="tlTrack">
|
||||||
|
<div class="tl-fill-inner" id="tlFill"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleLeft" style="left:0%"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleSingle" style="left:100%"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleRight" style="left:100%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-label-wrap">
|
||||||
|
<span class="tl-label-start" id="tlLabelStart"></span>
|
||||||
|
<span class="tl-label-current" id="tlLabelCurrent"></span>
|
||||||
|
<span class="tl-label-end" id="tlLabelEnd"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="tl-btn" id="downloadBtn">Scarica</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dl-toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Config (injected by Nunjucks) ---
|
||||||
|
const API_URL = '{{ apiUrl }}';
|
||||||
|
mapboxgl.accessToken = '{{ mapboxToken }}';
|
||||||
|
|
||||||
|
// --- FIELD_DEFS (same as live.html) ---
|
||||||
|
const FIELD_DEFS = {
|
||||||
|
'meb.forecasts.temperature': { name: 'Temperatura', unit: '°C', category: 'weather' },
|
||||||
|
'meb.forecast.wind.speed': { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
|
||||||
|
'meb.forecast.wind.direction': { name: 'Direzione Vento', unit: '°', category: 'weather' },
|
||||||
|
'meb.forecast.wind.gusts': { name: 'Raffiche', unit: 'km/h', category: 'weather' },
|
||||||
|
'meb.forecast.humidity': { name: 'Umidita', unit: '%', category: 'weather' },
|
||||||
|
'meb.forecast.pressure': { name: 'Pressione', unit: 'hPa', category: 'weather' },
|
||||||
|
'meb.forecast.precipitation': { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
|
||||||
|
'meb.forecast.rain': { name: 'Pioggia', unit: 'mm', category: 'weather' },
|
||||||
|
'meb.forecast.cloudCover': { name: 'Copertura Nuvole', unit: '%', category: 'weather' },
|
||||||
|
'meb.forecast.precipitationProbability': { name: 'Prob. Precipitazioni', unit: '%', category: 'weather' },
|
||||||
|
'meb.waves.height': { name: 'Altezza Onde', unit: 'm', category: 'weather' },
|
||||||
|
'meb.waves.direction': { name: 'Direzione Onde', unit: '°', category: 'weather' },
|
||||||
|
'meb.waves.period': { name: 'Periodo Onde', unit: 's', category: 'weather' },
|
||||||
|
'meb.waves.peakPeriod': { name: 'Periodo Picco', unit: 's', category: 'weather' },
|
||||||
|
'meb.waves.currentVelocity': { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
|
||||||
|
'meb.waves.currentDirection': { name: 'Dir. Corrente', unit: '°', category: 'weather' },
|
||||||
|
'navigation.position.latitude': { name: 'Latitudine', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.position.longitude': { name: 'Longitudine', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.headingTrue': { name: 'Heading', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.speedOverGround': { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
|
||||||
|
'navigation.courseOverGroundTrue': { name: 'Rotta COG', unit: '°', category: 'navigation' },
|
||||||
|
'electrical.batteries.service.Voltage': { name: 'Batteria Serv. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.service.current': { name: 'Batteria Serv. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.service.stateOfCharge': { name: 'Batteria Serv. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.Voltage': { name: 'Batteria Traz. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.current': { name: 'Batteria Traz. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.stateOfCharge': { name: 'Batteria Traz. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.temperature': { name: 'Batteria Traz. Temp', unit: '°C', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.power': { name: 'Batteria Traz. W', unit: 'W', category: 'engine' },
|
||||||
|
'propulsion.0.revolutions': { name: 'Giri Motore', unit: 'RPM', category: 'engine' },
|
||||||
|
'system.uptime': { name: 'Uptime', unit: 's', category: 'engine' },
|
||||||
|
};
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'rgba(59,130,246,1)', 'rgba(16,185,129,1)', 'rgba(245,158,11,1)',
|
||||||
|
'rgba(239,68,68,1)', 'rgba(139,92,246,1)', 'rgba(236,72,153,1)',
|
||||||
|
];
|
||||||
|
const TICK_COLOR = '#94a3b8';
|
||||||
|
const GRID_COLOR = 'rgba(148,163,184,0.08)';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let sessionRows = []; // [{_time, field: val, ...}]
|
||||||
|
let sessionTimes = []; // ms timestamps (sorted)
|
||||||
|
let positionData = []; // [{ts, lat, lon}]
|
||||||
|
let fieldColorMap = {};
|
||||||
|
let colorIdx = 0;
|
||||||
|
let miniCharts = {};
|
||||||
|
let expChart = null;
|
||||||
|
let expActiveField = null;
|
||||||
|
let currentSensorId = null;
|
||||||
|
let currentSessionId = null;
|
||||||
|
let currentSessionMeta = null;
|
||||||
|
let tStart = 0, tEnd = 0;
|
||||||
|
let currentT = 0;
|
||||||
|
let restrictMode = false;
|
||||||
|
let restrictStart = 0, restrictEnd = 0;
|
||||||
|
let activeCategory = 'all';
|
||||||
|
let searchQuery = '';
|
||||||
|
let mapbox = null;
|
||||||
|
let mapDot = null;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
function fmtDuration(ms) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s/60)}m ${s%60}s`;
|
||||||
|
return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
|
||||||
|
}
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString('it-IT', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||||
|
}
|
||||||
|
function fmtTime(ms) {
|
||||||
|
return new Date(ms).toLocaleTimeString('it-IT', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
||||||
|
}
|
||||||
|
function getFieldColor(key) {
|
||||||
|
if (!fieldColorMap[key]) {
|
||||||
|
fieldColorMap[key] = CHART_COLORS[colorIdx % CHART_COLORS.length];
|
||||||
|
colorIdx++;
|
||||||
|
}
|
||||||
|
return fieldColorMap[key];
|
||||||
|
}
|
||||||
|
function showToast(msg) {
|
||||||
|
const t = document.getElementById('dl-toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.style.transform = 'translateY(0)'; t.style.opacity = '1';
|
||||||
|
setTimeout(() => { t.style.transform = 'translateY(120px)'; t.style.opacity = '0'; }, 4500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary search: nearest index in sessionTimes to ts
|
||||||
|
function nearestIdx(ts) {
|
||||||
|
if (!sessionTimes.length) return -1;
|
||||||
|
let lo = 0, hi = sessionTimes.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (sessionTimes[mid] < ts) lo = mid + 1; else hi = mid;
|
||||||
|
}
|
||||||
|
if (lo > 0 && Math.abs(sessionTimes[lo-1]-ts) < Math.abs(sessionTimes[lo]-ts)) return lo - 1;
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load sessions list ---
|
||||||
|
async function loadSessionsList() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/sessions/history`, { credentials: 'include' });
|
||||||
|
const sessions = await res.json();
|
||||||
|
renderSessionGrid(sessions);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('sessionGrid').innerHTML = '<div class="sess-empty">Errore nel caricamento sessioni.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allSessions = [];
|
||||||
|
function renderSessionGrid(sessions) {
|
||||||
|
allSessions = sessions;
|
||||||
|
const sensors = [...new Set(sessions.map(s => s.sensor_name).filter(Boolean))];
|
||||||
|
const selEl = document.getElementById('popupSensorFilter');
|
||||||
|
selEl.innerHTML = '<option value="">Tutti i sensori</option>';
|
||||||
|
sensors.forEach(s => { const o = document.createElement('option'); o.value = s; o.textContent = s; selEl.appendChild(o); });
|
||||||
|
filterSessionGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSessionGrid() {
|
||||||
|
const q = document.getElementById('popupSearch').value.toLowerCase();
|
||||||
|
const sensor = document.getElementById('popupSensorFilter').value;
|
||||||
|
const filtered = allSessions.filter(s => {
|
||||||
|
const name = (s.name || s.session_id || '').toLowerCase();
|
||||||
|
const sname = (s.sensor_name || '').toLowerCase();
|
||||||
|
const matchQ = !q || name.includes(q) || sname.includes(q);
|
||||||
|
const matchSensor = !sensor || s.sensor_name === sensor;
|
||||||
|
return matchQ && matchSensor;
|
||||||
|
});
|
||||||
|
const grid = document.getElementById('sessionGrid');
|
||||||
|
if (!filtered.length) { grid.innerHTML = '<div class="sess-empty">Nessuna sessione trovata.</div>'; return; }
|
||||||
|
grid.innerHTML = '';
|
||||||
|
filtered.forEach(s => {
|
||||||
|
const start = s.startTime ? new Date(s.startTime).getTime() : null;
|
||||||
|
const end = s.endTime ? new Date(s.endTime).getTime() : null;
|
||||||
|
const dur = start && end ? fmtDuration(end - start) : (start ? 'In corso' : '—');
|
||||||
|
const tags = Array.isArray(s.tags) ? s.tags : [];
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'sess-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="sess-card-name">${s.name || s.session_id || '—'}</div>
|
||||||
|
<div class="sess-card-id">${s.session_id || ''}</div>
|
||||||
|
<div class="sess-card-sensor">${s.sensor_name || '—'}</div>
|
||||||
|
<div class="sess-card-dates">${fmtDate(s.startTime)}</div>
|
||||||
|
${end ? `<div class="sess-card-dates">${fmtDate(s.endTime)}</div>` : ''}
|
||||||
|
<div class="sess-card-duration">${dur}</div>
|
||||||
|
${tags.length ? `<div class="sess-tags">${tags.map(t=>`<span class="sess-tag">${t}</span>`).join('')}</div>` : ''}
|
||||||
|
`;
|
||||||
|
card.onclick = () => selectSession(s);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('popupSearch').oninput = filterSessionGrid;
|
||||||
|
document.getElementById('popupSensorFilter').onchange = filterSessionGrid;
|
||||||
|
|
||||||
|
// --- Select session ---
|
||||||
|
async function selectSession(meta) {
|
||||||
|
currentSensorId = meta.sensor_name;
|
||||||
|
currentSessionId = meta.session_id;
|
||||||
|
currentSessionMeta = meta;
|
||||||
|
document.getElementById('sessionOverlay').style.display = 'none';
|
||||||
|
document.getElementById('changeSessionBtn').style.display = '';
|
||||||
|
document.getElementById('sessionNameDisplay').style.display = '';
|
||||||
|
document.getElementById('sessionMetaDisplay').style.display = '';
|
||||||
|
document.getElementById('sessionNameDisplay').textContent = meta.name || meta.session_id;
|
||||||
|
document.getElementById('sessionMetaDisplay').textContent = `${meta.sensor_name || ''} • ${meta.session_id}`;
|
||||||
|
document.getElementById('detailPanel').classList.add('visible');
|
||||||
|
document.getElementById('timelineBar').classList.add('visible');
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
sessionRows = []; sessionTimes = []; positionData = [];
|
||||||
|
fieldColorMap = {}; colorIdx = 0;
|
||||||
|
Object.values(miniCharts).forEach(c => c.destroy());
|
||||||
|
miniCharts = {};
|
||||||
|
if (expChart) { expChart.destroy(); expChart = null; expActiveField = null; }
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'none';
|
||||||
|
document.getElementById('dataGrid').innerHTML = '';
|
||||||
|
|
||||||
|
await loadSessionData(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionData(meta) {
|
||||||
|
document.getElementById('loadingText').textContent = 'Caricamento dati sessione...';
|
||||||
|
document.getElementById('loadingOverlay').classList.add('visible');
|
||||||
|
try {
|
||||||
|
const from = meta.startTime ? new Date(meta.startTime).toISOString() : null;
|
||||||
|
const params = new URLSearchParams({ session: meta.session_id });
|
||||||
|
if (from) params.set('from', from);
|
||||||
|
const res = await fetch(`${API_URL}/sessions/${encodeURIComponent(meta.sensor_name)}/data?${params}`, { credentials: 'include' });
|
||||||
|
sessionRows = await res.json();
|
||||||
|
|
||||||
|
if (!sessionRows.length) { showToast('Nessun dato trovato per questa sessione'); return; }
|
||||||
|
|
||||||
|
sessionTimes = sessionRows.map(r => new Date(r._time).getTime());
|
||||||
|
tStart = sessionTimes[0]; tEnd = sessionTimes[sessionTimes.length - 1];
|
||||||
|
currentT = tEnd;
|
||||||
|
restrictStart = tStart; restrictEnd = tEnd;
|
||||||
|
|
||||||
|
positionData = sessionRows
|
||||||
|
.map((r, i) => ({ ts: sessionTimes[i], lat: r['navigation.position.latitude'], lon: r['navigation.position.longitude'] }))
|
||||||
|
.filter(p => p.lat != null && p.lon != null);
|
||||||
|
|
||||||
|
buildGrid();
|
||||||
|
initMap();
|
||||||
|
initTimeline();
|
||||||
|
updateGrid(currentT);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Errore nel caricamento dei dati');
|
||||||
|
} finally {
|
||||||
|
document.getElementById('loadingOverlay').classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grid ---
|
||||||
|
function getFieldsFromRows() {
|
||||||
|
const meta = new Set(['result', 'table', '_start', '_stop', '_measurement', '_time', 'sensor', 'session', '']);
|
||||||
|
const fields = new Set();
|
||||||
|
sessionRows.forEach(r => Object.keys(r).forEach(k => { if (!meta.has(k)) fields.add(k); }));
|
||||||
|
return [...fields];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGrid() {
|
||||||
|
const grid = document.getElementById('dataGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const fields = getFieldsFromRows();
|
||||||
|
fields.forEach(key => {
|
||||||
|
const def = FIELD_DEFS[key] || { name: key, unit: '', category: 'engine' };
|
||||||
|
const numericVals = sessionRows.map(r => r[key]).filter(v => typeof v === 'number');
|
||||||
|
if (!numericVals.length) return;
|
||||||
|
const col = getFieldColor(key);
|
||||||
|
const bgCol = col.replace(', 1)', ', 0.12)');
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'data-card';
|
||||||
|
card.dataset.key = key;
|
||||||
|
card.dataset.category = def.category;
|
||||||
|
card.dataset.name = def.name.toLowerCase();
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-top">
|
||||||
|
<div class="card-info"><h4>${def.name}</h4></div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="card-action-btn enlarge-btn" title="Espandi">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-values">
|
||||||
|
<span class="card-main-val">—</span>
|
||||||
|
<span class="card-unit">${def.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-mini-chart"><canvas id="mini-${CSS.escape(key)}"></canvas></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.querySelector('.enlarge-btn').onclick = e => { e.stopPropagation(); openExpandedChart(key); };
|
||||||
|
grid.appendChild(card);
|
||||||
|
|
||||||
|
const ctx = document.getElementById(`mini-${CSS.escape(key)}`).getContext('2d');
|
||||||
|
miniCharts[key] = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: sessionRows.map(() => ''),
|
||||||
|
datasets: [{ data: sessionRows.map(r => r[key] ?? null), borderColor: col, backgroundColor: bgCol, fill: false, tension: 0.3, pointRadius: 0, borderWidth: 2 }]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||||
|
scales: {
|
||||||
|
x: { type: 'category', display: false },
|
||||||
|
y: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGrid(ts) {
|
||||||
|
const idx = nearestIdx(ts);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const row = sessionRows[idx];
|
||||||
|
document.querySelectorAll('.data-card').forEach(card => {
|
||||||
|
const key = card.dataset.key;
|
||||||
|
const v = row[key];
|
||||||
|
const el = card.querySelector('.card-main-val');
|
||||||
|
if (typeof v === 'number') el.textContent = v.toFixed(2);
|
||||||
|
else el.textContent = v != null ? String(v) : '—';
|
||||||
|
});
|
||||||
|
if (expChart && expActiveField) updateExpandedChartLine(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
document.querySelectorAll('.data-card').forEach(c => {
|
||||||
|
const matchCat = activeCategory === 'all' || c.dataset.category === activeCategory;
|
||||||
|
const matchStr = !searchQuery || c.dataset.name.includes(searchQuery);
|
||||||
|
c.style.display = matchCat && matchStr ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dataSearch').oninput = e => { searchQuery = e.target.value.toLowerCase(); applyFilters(); };
|
||||||
|
document.querySelectorAll('#catFilter button').forEach(b => {
|
||||||
|
b.onclick = () => {
|
||||||
|
document.querySelectorAll('#catFilter button').forEach(x => x.classList.remove('active'));
|
||||||
|
b.classList.add('active'); activeCategory = b.dataset.cat; applyFilters();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Expanded Chart ---
|
||||||
|
function openExpandedChart(key) {
|
||||||
|
expActiveField = key;
|
||||||
|
const def = FIELD_DEFS[key] || { name: key, unit: '' };
|
||||||
|
document.getElementById('expChartTitle').textContent = `Dettaglio: ${def.name}`;
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'flex';
|
||||||
|
const col = getFieldColor(key);
|
||||||
|
if (!expChart) {
|
||||||
|
const ctx = document.getElementById('expandedChartCanvas').getContext('2d');
|
||||||
|
expChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: [
|
||||||
|
{ label: def.name, data: [], borderColor: col, backgroundColor: col.replace(',1)',',0.12)'), fill: false, tension: 0.3, pointRadius: 0, borderWidth: 2 },
|
||||||
|
{ type: 'line', data: [], borderColor: 'rgba(239,68,68,0.8)', borderWidth: 1, borderDash: [3,3], pointRadius: 0, fill: false }
|
||||||
|
]},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
scales: {
|
||||||
|
x: { type: 'category', ticks: { maxTicksLimit: 8, color: TICK_COLOR, font: { size: 10 } }, grid: { display: false } },
|
||||||
|
y: { ticks: { color: TICK_COLOR, font: { size: 10 }, maxTicksLimit: 5 }, grid: { color: GRID_COLOR } }
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `${ctx.parsed.y?.toFixed(3)} ${def.unit}` } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const data = sessionRows.map(r => r[key] ?? null);
|
||||||
|
const labels = sessionTimes.map(t => fmtTime(t));
|
||||||
|
expChart.data.labels = labels;
|
||||||
|
expChart.data.datasets[0].data = data;
|
||||||
|
expChart.data.datasets[0].borderColor = col;
|
||||||
|
expChart.data.datasets[0].backgroundColor = col.replace(',1)',',0.12)');
|
||||||
|
expChart.update('none');
|
||||||
|
updateExpandedChartLine(nearestIdx(currentT));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpandedChartLine(idx) {
|
||||||
|
if (!expChart) return;
|
||||||
|
const key = expActiveField;
|
||||||
|
const nulls = sessionRows.map(() => null);
|
||||||
|
if (idx >= 0) nulls[idx] = sessionRows[idx][key];
|
||||||
|
expChart.data.datasets[1].data = nulls;
|
||||||
|
expChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('closeExpBtn').onclick = () => {
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'none';
|
||||||
|
expActiveField = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Mapbox ---
|
||||||
|
function initMap() {
|
||||||
|
if (mapbox) { mapbox.remove(); mapbox = null; mapDot = null; }
|
||||||
|
document.getElementById('sessionMap').innerHTML = '';
|
||||||
|
|
||||||
|
if (!positionData.length) {
|
||||||
|
document.getElementById('noGpsMsg').style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('noGpsMsg').style.display = 'none';
|
||||||
|
|
||||||
|
mapbox = new mapboxgl.Map({
|
||||||
|
container: 'sessionMap',
|
||||||
|
style: 'mapbox://styles/mapbox/dark-v11',
|
||||||
|
center: [positionData[0].lon, positionData[0].lat],
|
||||||
|
zoom: 12
|
||||||
|
});
|
||||||
|
|
||||||
|
mapbox.on('load', () => {
|
||||||
|
const coordinates = positionData.map(p => [p.lon, p.lat]);
|
||||||
|
|
||||||
|
mapbox.addSource('route', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'LineString', coordinates } }
|
||||||
|
});
|
||||||
|
mapbox.addLayer({ id: 'route', type: 'line', source: 'route', paint: { 'line-color': '#3b82f6', 'line-width': 3 } });
|
||||||
|
|
||||||
|
// Wider hit area for hover
|
||||||
|
mapbox.addLayer({ id: 'route-hover', type: 'line', source: 'route', paint: { 'line-color': 'transparent', 'line-width': 16 } });
|
||||||
|
|
||||||
|
mapbox.addSource('dot', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [positionData[positionData.length-1].lon, positionData[positionData.length-1].lat] } }
|
||||||
|
});
|
||||||
|
mapbox.addLayer({ id: 'dot', type: 'circle', source: 'dot', paint: { 'circle-radius': 8, 'circle-color': '#fff', 'circle-stroke-color': '#3b82f6', 'circle-stroke-width': 3 } });
|
||||||
|
|
||||||
|
mapbox.fitBounds(coordinates.reduce((b, c) => b.extend(c), new mapboxgl.LngLatBounds(coordinates[0], coordinates[0])), { padding: 30 });
|
||||||
|
|
||||||
|
mapbox.on('mousemove', 'route-hover', e => {
|
||||||
|
if (!e.features.length) return;
|
||||||
|
const pt = e.lngLat;
|
||||||
|
let nearest = positionData[0], minD = Infinity;
|
||||||
|
positionData.forEach(p => {
|
||||||
|
const d = Math.hypot(p.lon - pt.lng, p.lat - pt.lat);
|
||||||
|
if (d < minD) { minD = d; nearest = p; }
|
||||||
|
});
|
||||||
|
seekTo(nearest.ts);
|
||||||
|
});
|
||||||
|
mapbox.on('mouseleave', 'route-hover', () => {});
|
||||||
|
|
||||||
|
document.getElementById('mapBar').style.display = 'flex';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMapDot(ts) {
|
||||||
|
if (!mapbox || !positionData.length) return;
|
||||||
|
const idx = nearestIdx(ts);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const row = sessionRows[idx];
|
||||||
|
const lat = row['navigation.position.latitude'];
|
||||||
|
const lon = row['navigation.position.longitude'];
|
||||||
|
if (lat == null || lon == null) return;
|
||||||
|
mapbox.getSource('dot')?.setData({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Timeline ---
|
||||||
|
function initTimeline() {
|
||||||
|
document.getElementById('tlLabelStart').textContent = fmtTime(tStart);
|
||||||
|
document.getElementById('tlLabelEnd').textContent = fmtTime(tEnd);
|
||||||
|
setTimelineMode(false);
|
||||||
|
setHandlePos('single', 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHandlePos(which, frac) {
|
||||||
|
const el = which === 'single' ? document.getElementById('tlHandleSingle')
|
||||||
|
: which === 'left' ? document.getElementById('tlHandleLeft')
|
||||||
|
: document.getElementById('tlHandleRight');
|
||||||
|
el.style.left = `${(frac * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function posToFrac(px) {
|
||||||
|
const track = document.getElementById('tlTrack');
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
return Math.max(0, Math.min(1, (px - rect.left) / rect.width));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fracToTs(frac) { return tStart + frac * (tEnd - tStart); }
|
||||||
|
function tsToFrac(ts) { return (tEnd === tStart) ? 0 : (ts - tStart) / (tEnd - tStart); }
|
||||||
|
|
||||||
|
function updateFill() {
|
||||||
|
const fill = document.getElementById('tlFill');
|
||||||
|
if (!restrictMode) {
|
||||||
|
const f = tsToFrac(currentT);
|
||||||
|
fill.style.left = '0%';
|
||||||
|
fill.style.width = `${f * 100}%`;
|
||||||
|
} else {
|
||||||
|
const fl = tsToFrac(restrictStart);
|
||||||
|
const fr = tsToFrac(restrictEnd);
|
||||||
|
fill.style.left = `${fl * 100}%`;
|
||||||
|
fill.style.width = `${(fr - fl) * 100}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimelineMode(restrict) {
|
||||||
|
restrictMode = restrict;
|
||||||
|
document.getElementById('restrictBtn').classList.toggle('active', restrict);
|
||||||
|
document.getElementById('tlHandleSingle').classList.toggle('hidden', restrict);
|
||||||
|
document.getElementById('tlHandleLeft').classList.toggle('hidden', !restrict);
|
||||||
|
document.getElementById('tlHandleRight').classList.toggle('hidden', !restrict);
|
||||||
|
if (restrict) {
|
||||||
|
restrictStart = tStart; restrictEnd = tEnd;
|
||||||
|
setHandlePos('left', 0); setHandlePos('right', 1);
|
||||||
|
}
|
||||||
|
updateFill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekTo(ts) {
|
||||||
|
currentT = Math.max(tStart, Math.min(tEnd, ts));
|
||||||
|
setHandlePos('single', tsToFrac(currentT));
|
||||||
|
document.getElementById('tlLabelCurrent').textContent = fmtTime(currentT);
|
||||||
|
updateFill();
|
||||||
|
updateGrid(currentT);
|
||||||
|
updateMapDot(currentT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag logic
|
||||||
|
let dragging = null;
|
||||||
|
function startDrag(id, e) {
|
||||||
|
dragging = id;
|
||||||
|
e.preventDefault();
|
||||||
|
const onMove = ev => {
|
||||||
|
const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
|
||||||
|
const frac = posToFrac(clientX);
|
||||||
|
const ts = fracToTs(frac);
|
||||||
|
if (id === 'single') {
|
||||||
|
seekTo(ts);
|
||||||
|
} else if (id === 'left') {
|
||||||
|
restrictStart = Math.max(tStart, Math.min(restrictEnd - 1000, ts));
|
||||||
|
setHandlePos('left', tsToFrac(restrictStart));
|
||||||
|
updateFill();
|
||||||
|
} else if (id === 'right') {
|
||||||
|
restrictEnd = Math.min(tEnd, Math.max(restrictStart + 1000, ts));
|
||||||
|
setHandlePos('right', tsToFrac(restrictEnd));
|
||||||
|
updateFill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onUp = () => { dragging = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tlHandleSingle').addEventListener('mousedown', e => startDrag('single', e));
|
||||||
|
document.getElementById('tlHandleLeft').addEventListener('mousedown', e => startDrag('left', e));
|
||||||
|
document.getElementById('tlHandleRight').addEventListener('mousedown', e => startDrag('right', e));
|
||||||
|
|
||||||
|
document.getElementById('tlTrack').addEventListener('click', e => {
|
||||||
|
if (dragging) return;
|
||||||
|
const frac = posToFrac(e.clientX);
|
||||||
|
if (!restrictMode) seekTo(fracToTs(frac));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('restrictBtn').onclick = () => setTimelineMode(!restrictMode);
|
||||||
|
|
||||||
|
// --- Download ---
|
||||||
|
document.getElementById('downloadBtn').onclick = async () => {
|
||||||
|
if (!currentSensorId || !currentSessionId) return;
|
||||||
|
const btn = document.getElementById('downloadBtn');
|
||||||
|
const orig = btn.textContent; btn.textContent = '...';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ session: currentSessionId, from: String(tStart) });
|
||||||
|
if (restrictMode) params.set('to', String(restrictEnd));
|
||||||
|
const res = await fetch(`${API_URL}/sessions/${encodeURIComponent(currentSensorId)}/csv?${params}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) { showToast('Errore durante il download'); return; }
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url;
|
||||||
|
a.download = `session_${currentSessionId}_${currentSensorId}.csv`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const sizeStr = blob.size < 1024 ? `${blob.size} B` : blob.size < 1048576 ? `${(blob.size/1024).toFixed(1)} KB` : `${(blob.size/1048576).toFixed(1)} MB`;
|
||||||
|
const text = await blob.text();
|
||||||
|
const rows = Math.max(0, text.split('\n').length - 2);
|
||||||
|
const dur = restrictMode ? fmtDuration(restrictEnd - restrictStart) : fmtDuration(tEnd - tStart);
|
||||||
|
showToast(`${rows} righe • ${sizeStr} • ${dur}`);
|
||||||
|
} catch { showToast('Errore durante il download'); }
|
||||||
|
finally { btn.textContent = orig; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Change session ---
|
||||||
|
document.getElementById('changeSessionBtn').onclick = () => {
|
||||||
|
document.getElementById('sessionOverlay').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
document.addEventListener('DOMContentLoaded', loadSessionsList);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
console/src/static/fonts/atkinson-bold.ttf
Normal file
BIN
console/src/static/fonts/atkinson-bold.ttf
Normal file
Binary file not shown.
BIN
console/src/static/fonts/atkinson-regular.ttf
Normal file
BIN
console/src/static/fonts/atkinson-regular.ttf
Normal file
Binary file not shown.
@@ -24,3 +24,25 @@
|
|||||||
.card[title="Live"]:hover::before {
|
.card[title="Live"]:hover::before {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
padding-bottom: 8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category h2 {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 20px;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight:900
|
||||||
|
}
|
||||||
|
|
||||||
|
.category section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-inline: 30px;
|
||||||
|
}
|
||||||
634
console/src/static/styles/kiosk.css
Normal file
634
console/src/static/styles/kiosk.css
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
:root {
|
||||||
|
--card-bg: #101415;
|
||||||
|
--card-border: #2a2d2e;
|
||||||
|
--card-border-active: #3a9bff;
|
||||||
|
--danger: #ff4d4d;
|
||||||
|
--success: #34d399;
|
||||||
|
--grid-dot: rgba(255, 255, 255, 0.04);
|
||||||
|
--snap-line: rgba(50, 152, 255, 0.25);
|
||||||
|
--cols: 24;
|
||||||
|
--rows: 18
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'hyperlegible';
|
||||||
|
src: url('../fonts/atkinson-regular.ttf');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'hyperlegible';
|
||||||
|
src: url('../fonts/atkinson-bold.ttf');
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'hyperlegible', sans-serif;
|
||||||
|
background-color: black;
|
||||||
|
color: white
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar {
|
||||||
|
background: rgba(16, 20, 21, 0.88);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 20px;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.toolbar button.primary {
|
||||||
|
background-color: rgba(255, 255, 255, 0.103);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.primary:hover {
|
||||||
|
background: #4da8ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** CAMVAS!!! */
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 70%;
|
||||||
|
position: absolute;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle 1px, #393b3c 0.8px, transparent 0.8px);
|
||||||
|
background-size: calc(100% / var(--cols)) calc(100% / var(--rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Snap guides ───────────────────────────────────── */
|
||||||
|
.guide {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--snap-line);
|
||||||
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.horizontal {
|
||||||
|
height: 1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.vertical {
|
||||||
|
width: 1px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CARDS */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #101415;
|
||||||
|
border: 2px dashed var(--card-border);
|
||||||
|
border-radius: 15px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: box-shadow 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94), border-color 0.25s ease;
|
||||||
|
will-change: left, top, width, height;
|
||||||
|
overflow: visible;
|
||||||
|
/* Necessario per vedere i manigli di resize fuori bordo se necessario */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stili Header Card */
|
||||||
|
.card-header {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Path Picker Menu ────────────────────────────── */
|
||||||
|
.label-wrapper {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 28px;
|
||||||
|
left: -8px;
|
||||||
|
background: rgba(26, 30, 31, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--card-border-active);
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 2000;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 5px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.editable .label-wrapper:hover .path-menu {
|
||||||
|
display: block;
|
||||||
|
animation: spawnIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-option {
|
||||||
|
padding: 10px 15px;
|
||||||
|
color: #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-option:hover {
|
||||||
|
background: var(--card-border-active);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 0, 0, 0.404);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-close:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:not(.editable) .card-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 70px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
height: calc(100% - 33px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RESIZE HANDLERS (.rh) */
|
||||||
|
.rh {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
border-radius: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.corner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--card-border-active);
|
||||||
|
border: 2px solid #101415;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.edge {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner alignment */
|
||||||
|
.rh.nw {
|
||||||
|
top: -6px;
|
||||||
|
left: -6px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.ne {
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.se {
|
||||||
|
bottom: -6px;
|
||||||
|
right: -6px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.sw {
|
||||||
|
bottom: -6px;
|
||||||
|
left: -6px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edge alignment */
|
||||||
|
.rh.n {
|
||||||
|
top: -4px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 8px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.s {
|
||||||
|
bottom: -4px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 8px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.e {
|
||||||
|
right: -4px;
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 8px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.w {
|
||||||
|
left: -4px;
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 8px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:not(.selected) .rh.corner {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.selected .rh.corner {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardSpawn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.spawning {
|
||||||
|
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardRemove {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.removing {
|
||||||
|
animation: cardRemove 0.2s ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stili per le classi dinamiche delle card */
|
||||||
|
.card.selected {
|
||||||
|
border-color: var(--card-border-active);
|
||||||
|
box-shadow: 0 0 15px rgba(50, 152, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.dragging,
|
||||||
|
.card.resizing {
|
||||||
|
cursor: grabbing;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stili per gli elementi aggiunti da canvas.js */
|
||||||
|
.tooltip {
|
||||||
|
position: fixed;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-badge {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 60px;
|
||||||
|
/* Regola in base all'altezza della toolbar */
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 3000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content textarea {
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
min-height: 200px;
|
||||||
|
background-color: #2a2d2e;
|
||||||
|
border: 1px solid #3a3d3e;
|
||||||
|
color: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
white-space: nowrap;
|
||||||
|
background-color: rgba(255, 255, 255, 0.103);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button.primary {
|
||||||
|
background-color: #4da8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edit Mode & Animations ──────────────────────── */
|
||||||
|
@keyframes cardSpawn {
|
||||||
|
0% { opacity: 0; transform: scale(0.92); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardRemove {
|
||||||
|
0% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(0.88); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.spawning {
|
||||||
|
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.removing {
|
||||||
|
animation: cardRemove 0.2s ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas state during editing */
|
||||||
|
.canvas.edit-active {
|
||||||
|
outline: 2px dashed rgba(58, 155, 255, 0.3);
|
||||||
|
outline-offset: -10px;
|
||||||
|
background-color: rgba(58, 155, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.editable {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.editable:not(.selected) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide handlers when not editing */
|
||||||
|
.card:not(.editable) .rh {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:not(.editable) {
|
||||||
|
cursor: default;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rimuovi padding se la card contiene la mappa per farla aderire ai bordi */
|
||||||
|
.card[data-type="map"] .card-body {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-map-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0 0 15px 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix per Mapbox canvas che a volte non prende il 100% se il container è flex */
|
||||||
|
.card-map-canvas .mapboxgl-canvas-container,
|
||||||
|
.card-map-canvas .mapboxgl-canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.primary {
|
||||||
|
background-color: var(--card-border-active) !important;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Global Edit Mode Overrides ──────────────────── */
|
||||||
|
body.edit-mode {
|
||||||
|
background-color: #0a0e0f;
|
||||||
|
transition: background-color 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode .toolbar {
|
||||||
|
background: rgba(58, 155, 255, 0.15) !important;
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
border: 2px dashed var(--card-border-active) !important;
|
||||||
|
box-shadow: 0 0 20px rgba(58, 155, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode .toolbar p#cardCount {
|
||||||
|
color: var(--card-border-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes editPulse {
|
||||||
|
0% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode .canvas.edit-active::after {
|
||||||
|
content: "DASHBOARD EDITING";
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--card-border-active);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
animation: editPulse 2s infinite ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
849
console/src/static/styles/rulesets.css
Normal file
849
console/src/static/styles/rulesets.css
Normal file
@@ -0,0 +1,849 @@
|
|||||||
|
/* --- Rulesets Page --- */
|
||||||
|
|
||||||
|
.rs-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.rs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 0 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--header-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type Picker */
|
||||||
|
.rs-type-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(241, 245, 249, 0.8);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-type-picker button {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-type-picker button.active {
|
||||||
|
background: white;
|
||||||
|
color: var(--accent-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-type-picker button:hover:not(.active) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.rs-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-filter-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-filter-btn.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-filter-btn:hover:not(.active) {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-sort-select {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
padding-right: 28px;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-new-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-new-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules Grid */
|
||||||
|
.rs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rule Card */
|
||||||
|
.rs-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
box-shadow: 0 8px 30px rgba(191, 219, 254, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-id {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-version {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-badge {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-badge.active {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-badge.archived {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-badge.inactive {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(226, 232, 240, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-items-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(241, 245, 249, 0.8);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== POPUP OVERLAY ===== */
|
||||||
|
.rs-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup {
|
||||||
|
background: white;
|
||||||
|
border-radius: 24px;
|
||||||
|
width: 700px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 28px 16px;
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 24px 24px 0 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-close:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-body {
|
||||||
|
padding: 20px 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popup sections */
|
||||||
|
.rs-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline editable fields */
|
||||||
|
.rs-field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 80px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons row */
|
||||||
|
.rs-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.active-toggle {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.active-toggle.off {
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.archive-toggle.on {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Items section */
|
||||||
|
.rs-items-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-add-item-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px dashed var(--accent-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-add-item-btn:hover {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item row */
|
||||||
|
.rs-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item:hover {
|
||||||
|
background: rgba(241, 245, 249, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field:focus {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: white;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field.narrow {
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field.medium {
|
||||||
|
width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field.wide {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch for item enabled */
|
||||||
|
.rs-toggle {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toggle.on {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toggle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toggle.on::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-delete {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags input */
|
||||||
|
.rs-tags-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tags-wrap:focus-within {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag-chip button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag-chip button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Saving indicator */
|
||||||
|
.rs-saving {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-saving.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item field labels header */
|
||||||
|
.rs-item-labels {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-labels span.narrow { width: 60px; flex-shrink: 0; }
|
||||||
|
.rs-item-labels span.medium { width: 100px; flex-shrink: 0; }
|
||||||
|
.rs-item-labels span.wide { flex: 1; min-width: 80px; }
|
||||||
|
.rs-item-labels span.toggle-space { width: 36px; flex-shrink: 0; }
|
||||||
|
.rs-item-labels span.delete-space { width: 24px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Confirm dialog */
|
||||||
|
.rs-confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-box {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-box h3 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-box p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-actions button {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-actions button.confirm-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-actions button.confirm-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back link */
|
||||||
|
.rs-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-back:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Version number inputs (major.build.patch) ── */
|
||||||
|
.rs-version-inputs {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.rs-version-num {
|
||||||
|
width: 48px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-color, #e2e8f0);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--input-bg, #fff);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.rs-version-num::-webkit-outer-spin-button,
|
||||||
|
.rs-version-num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
.rs-version-dot {
|
||||||
|
color: var(--text-tertiary, #94a3b8);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-items {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Deploy section ── */
|
||||||
|
.rs-deploy-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.rs-deploy-sensors {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-color, #e2e8f0);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--input-bg, #fafafa);
|
||||||
|
}
|
||||||
|
.rs-deploy-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.rs-deploy-item:hover { background: rgba(0,0,0,0.03); }
|
||||||
|
.rs-deploy-name { flex: 1; font-weight: 500; color: var(--text-primary); }
|
||||||
|
.rs-deploy-check { width: 16px; height: 16px; cursor: pointer; }
|
||||||
|
.rs-deploy-status {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.rs-deploy-status.ok { background: #dcfce7; color: #166534; }
|
||||||
|
.rs-deploy-status.pending { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
|
.rs-deploy-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.rs-deploy-result {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
:root {
|
:root {
|
||||||
--accent-color: #2563eb;
|
--accent-color: #2563eb;
|
||||||
--accent-hover: #1d4ed8;
|
--accent-hover: #1d4ed8;
|
||||||
--accent-light: #eff6ff;
|
--accent-light: #dce6f3;
|
||||||
--accent-border: #bfdbfe;
|
--accent-border: #bfdbfe;
|
||||||
|
|
||||||
--text-primary: #0f172a;
|
--text-primary: #000000;
|
||||||
--text-secondary: #4755698f;
|
--text-secondary: #4755698f;
|
||||||
--text-tertiary: #94a3b8c0;
|
--text-tertiary: #94a3b8c0;
|
||||||
|
|
||||||
--surface: #f8fafc;
|
--surface: #ffffff;
|
||||||
|
|
||||||
--header-bg: rgba(255, 255, 255, 0.85);
|
--header-bg: rgba(255, 255, 255, 0.85);
|
||||||
/* For Glassmorphism */
|
|
||||||
--header-border: #e2e8f0;
|
--header-border: #e2e8f0;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
@@ -20,6 +19,33 @@
|
|||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DARK MODE */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--accent-color: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--accent-light: #1e3a8a;
|
||||||
|
--accent-border: #1e40af;
|
||||||
|
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #cbd5e1;
|
||||||
|
--text-tertiary: #94a3b8;
|
||||||
|
|
||||||
|
--surface: #000000;
|
||||||
|
|
||||||
|
--header-bg: rgba(15, 23, 42, 0.85);
|
||||||
|
--header-border: #334155;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transition for dark mode */
|
||||||
|
body {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -42,6 +68,7 @@
|
|||||||
body {
|
body {
|
||||||
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
background-color: var(--surface);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +76,7 @@ button {
|
|||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--header-border);
|
border: 1px solid var(--header-border);
|
||||||
background-color: var(--bg-surface);
|
background-color: var(--surface);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -144,7 +171,6 @@ button.prominent:active {
|
|||||||
.card p {
|
.card p {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
opacity: 0.4;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -158,7 +184,34 @@ button.prominent:active {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: default;
|
||||||
|
/* No global opacity on the container to allow badge to remain fully visible */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dim specific internals to 50% while keeping the badge fully opaque */
|
||||||
|
.card.disabled h3,
|
||||||
|
.card.disabled p,
|
||||||
|
.card.disabled .page-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.disabled .badge {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .badge {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +1,292 @@
|
|||||||
"""
|
"""
|
||||||
Redis Keys:
|
Cache two-tier per il servizio Marine.
|
||||||
- marine:catalog:full → lista dei dataset completo (TTL 1h)
|
|
||||||
- marine:catalog:search:{hash} → risultati ricerca (TTL 30min)
|
L1 = Redis (RAM): scadenza 2 ore, velocissima, condivisa tra processi.
|
||||||
- marine:job:{session_id} → stato job download (TTL 48h)
|
L2 = SQLite+disco: persistente (200GB), fallback quando Redis non c'è
|
||||||
|
o quando L1 è scaduta. Scadenza configurabile (default 30 giorni).
|
||||||
|
|
||||||
|
Flusso lettura:
|
||||||
|
1. Prova L1 (Redis). Se hit → ritorna.
|
||||||
|
2. Prova L2 (SQLite). Se hit non scaduta → ritorna E ripopola L1 (re-warm).
|
||||||
|
3. Miss totale → None.
|
||||||
|
|
||||||
|
Flusso scrittura:
|
||||||
|
Scrive in entrambi i tier contemporaneamente.
|
||||||
|
|
||||||
|
Chiavi standard:
|
||||||
|
- marine:catalog:full → lista completa dataset Copernicus
|
||||||
|
- marine:catalog:search:{hash} → risultati ricerca utente
|
||||||
|
- marine:job:{session_id} → stato job download (solo Redis, ephemeri)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import gzip
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Configurazione Redis da variabili ambiente
|
# ── Config ───────────────────────────────────────────────────────────────
|
||||||
REDIS_HOST = os.getenv("REDIS_HOST", "meb-redis")
|
REDIS_HOST = os.getenv("REDIS_HOST", "meb-redis")
|
||||||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||||
|
|
||||||
# Pool di connessioni condiviso (thread-safe, riutilizzabile)
|
# Il volume persistente è montato dal container, default /app/cache
|
||||||
|
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/app/cache"))
|
||||||
|
CACHE_DB = CACHE_DIR / "catalog.sqlite"
|
||||||
|
BLOB_DIR = CACHE_DIR / "blobs"
|
||||||
|
|
||||||
|
# TTL default
|
||||||
|
DEFAULT_REDIS_TTL = 2 * 3600 # 2 ore (L1)
|
||||||
|
DEFAULT_DISK_TTL = 30 * 24 * 3600 # 30 giorni (L2)
|
||||||
|
|
||||||
|
# Soglia sopra la quale il valore va in un file su disco invece che in sqlite
|
||||||
|
BLOB_THRESHOLD_BYTES = 64 * 1024 # 64 KB
|
||||||
|
|
||||||
|
# ── Stato globale ────────────────────────────────────────────────────────
|
||||||
_pool: Optional[redis.ConnectionPool] = None
|
_pool: Optional[redis.ConnectionPool] = None
|
||||||
_client: Optional[redis.Redis] = None
|
_client: Optional[redis.Redis] = None
|
||||||
|
_redis_disabled = False
|
||||||
|
|
||||||
|
_sqlite_lock = threading.Lock()
|
||||||
|
_sqlite_initialized = False
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> Optional[redis.Redis]:
|
# ── Redis (L1) ───────────────────────────────────────────────────────────
|
||||||
"""Restituisce il client Redis singleton con connection pool.
|
def _get_redis() -> Optional[redis.Redis]:
|
||||||
Ritorna None se Redis non è raggiungibile."""
|
global _pool, _client, _redis_disabled
|
||||||
global _pool, _client
|
if _redis_disabled:
|
||||||
|
return None
|
||||||
if _client is not None:
|
if _client is not None:
|
||||||
return _client
|
return _client
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_pool = redis.ConnectionPool(
|
_pool = redis.ConnectionPool(
|
||||||
host=REDIS_HOST,
|
host=REDIS_HOST,
|
||||||
port=REDIS_PORT,
|
port=REDIS_PORT,
|
||||||
# Decodifica automatica delle risposte in stringhe UTF-8
|
decode_responses=False, # tratto blob binari (gzip)
|
||||||
decode_responses=True,
|
|
||||||
# Massimo 5 connessioni nel pool (VPS 1-core, non serve di più)
|
|
||||||
max_connections=5,
|
max_connections=5,
|
||||||
# Timeout connessione e socket per evitare blocchi
|
|
||||||
socket_connect_timeout=3,
|
socket_connect_timeout=3,
|
||||||
socket_timeout=3,
|
socket_timeout=3,
|
||||||
# Riprova automaticamente se la connessione viene interrotta
|
|
||||||
retry_on_timeout=True,
|
retry_on_timeout=True,
|
||||||
)
|
)
|
||||||
_client = redis.Redis(connection_pool=_pool)
|
_client = redis.Redis(connection_pool=_pool)
|
||||||
# Test connessione
|
|
||||||
_client.ping()
|
_client.ping()
|
||||||
logger.info("[Redis] Connessione stabilita per il servizio Marine")
|
logger.info("[Cache] Redis L1 connesso")
|
||||||
return _client
|
return _client
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[Redis] Non disponibile, la cache è disabilitata: {e}")
|
logger.warning(f"[Cache] Redis non disponibile, uso solo disco: {e}")
|
||||||
|
_redis_disabled = True
|
||||||
_client = None
|
_client = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── SQLite (L2) ──────────────────────────────────────────────────────────
|
||||||
|
def _ensure_sqlite() -> sqlite3.Connection:
|
||||||
|
"""Apre/crea il db SQLite su disco. Crea anche la dir blob."""
|
||||||
|
global _sqlite_initialized
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
BLOB_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(CACHE_DB), timeout=5.0, isolation_level=None)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
if not _sqlite_initialized:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS cache (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
is_blob INTEGER NOT NULL DEFAULT 0,
|
||||||
|
value BLOB,
|
||||||
|
blob_path TEXT,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at)")
|
||||||
|
_sqlite_initialized = True
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _blob_path(key: str) -> Path:
|
||||||
|
# Nome file safe: solo caratteri alfanumerici + hash per unicità
|
||||||
|
safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in key)
|
||||||
|
return BLOB_DIR / f"{safe}.json.gz"
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_get(key: str) -> Optional[Any]:
|
||||||
|
try:
|
||||||
|
with _sqlite_lock:
|
||||||
|
conn = _ensure_sqlite()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT expires_at, is_blob, value, blob_path FROM cache WHERE key = ?",
|
||||||
|
(key,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
expires_at, is_blob, value, blob_path = row
|
||||||
|
if expires_at < int(time.time()):
|
||||||
|
# Scaduta: la elimino in lazy
|
||||||
|
_disk_delete(key)
|
||||||
|
return None
|
||||||
|
if is_blob:
|
||||||
|
data = Path(blob_path).read_bytes()
|
||||||
|
else:
|
||||||
|
data = value
|
||||||
|
return json.loads(gzip.decompress(data).decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Cache] Errore lettura disco '{key}': {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_set(key: str, raw_gz: bytes, ttl: int) -> None:
|
||||||
|
try:
|
||||||
|
expires_at = int(time.time()) + ttl
|
||||||
|
updated_at = int(time.time())
|
||||||
|
size = len(raw_gz)
|
||||||
|
if size > BLOB_THRESHOLD_BYTES:
|
||||||
|
path = _blob_path(key)
|
||||||
|
path.write_bytes(raw_gz)
|
||||||
|
with _sqlite_lock:
|
||||||
|
conn = _ensure_sqlite()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO cache(key, expires_at, is_blob, value, blob_path, size_bytes, updated_at) "
|
||||||
|
"VALUES(?,?,?,?,?,?,?)",
|
||||||
|
(key, expires_at, 1, None, str(path), size, updated_at)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with _sqlite_lock:
|
||||||
|
conn = _ensure_sqlite()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO cache(key, expires_at, is_blob, value, blob_path, size_bytes, updated_at) "
|
||||||
|
"VALUES(?,?,?,?,?,?,?)",
|
||||||
|
(key, expires_at, 0, raw_gz, None, size, updated_at)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Cache] Errore scrittura disco '{key}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_delete(key: str) -> None:
|
||||||
|
try:
|
||||||
|
with _sqlite_lock:
|
||||||
|
conn = _ensure_sqlite()
|
||||||
|
row = conn.execute("SELECT blob_path FROM cache WHERE key = ?", (key,)).fetchone()
|
||||||
|
conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
||||||
|
if row and row[0]:
|
||||||
|
try:
|
||||||
|
Path(row[0]).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Cache] Errore delete disco '{key}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── API pubblica ─────────────────────────────────────────────────────────
|
||||||
def cache_get(key: str) -> Optional[Any]:
|
def cache_get(key: str) -> Optional[Any]:
|
||||||
"""Legge un valore dalla cache Redis.
|
"""Legge L1 → L2. Se L2 hit, ripopola L1 (re-warm)."""
|
||||||
|
# L1
|
||||||
Args:
|
client = _get_redis()
|
||||||
key: Chiave Redis (es. 'marine:catalog:full')
|
if client is not None:
|
||||||
|
|
||||||
Returns:
|
|
||||||
Il valore deserializzato da JSON, oppure None se non trovato o errore
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = _get_client()
|
raw = client.get(key)
|
||||||
if client is None:
|
if raw is not None:
|
||||||
return None
|
return json.loads(gzip.decompress(raw).decode("utf-8"))
|
||||||
|
|
||||||
data = client.get(key)
|
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return json.loads(data)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[Redis] Errore lettura chiave '{key}': {e}")
|
logger.warning(f"[Cache] Errore Redis '{key}': {e}")
|
||||||
return None
|
|
||||||
|
|
||||||
|
# L2
|
||||||
def cache_set(key: str, value: Any, ttl: int = 3600) -> bool:
|
value = _disk_get(key)
|
||||||
"""Scrive un valore nella cache Redis con TTL.
|
if value is not None and client is not None:
|
||||||
|
# Re-warm L1 con TTL standard
|
||||||
Args:
|
|
||||||
key: Chiave Redis
|
|
||||||
value: Valore da serializzare in JSON
|
|
||||||
ttl: Tempo di vita in secondi (default: 1 ora)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True se scritto con successo, False altrimenti
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = _get_client()
|
raw_gz = gzip.compress(json.dumps(value).encode("utf-8"))
|
||||||
if client is None:
|
client.setex(key, DEFAULT_REDIS_TTL, raw_gz)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set(key: str, value: Any, ttl: int = DEFAULT_REDIS_TTL, disk_ttl: Optional[int] = None) -> bool:
|
||||||
|
"""Scrive in L1 (ttl) e L2 (disk_ttl, default 30 giorni).
|
||||||
|
Per chiavi ephemere (es. job state) passa disk_ttl=0 per saltare il disco."""
|
||||||
|
if disk_ttl is None:
|
||||||
|
disk_ttl = DEFAULT_DISK_TTL
|
||||||
|
try:
|
||||||
|
serialized = json.dumps(value).encode("utf-8")
|
||||||
|
raw_gz = gzip.compress(serialized)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Cache] Errore serializzazione '{key}': {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
serialized = json.dumps(value)
|
ok = False
|
||||||
client.setex(key, ttl, serialized)
|
# L1
|
||||||
return True
|
client = _get_redis()
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
client.setex(key, ttl, raw_gz)
|
||||||
|
ok = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[Redis] Errore scrittura chiave '{key}': {e}")
|
logger.warning(f"[Cache] Errore scrittura Redis '{key}': {e}")
|
||||||
return False
|
|
||||||
|
# L2
|
||||||
|
if disk_ttl > 0:
|
||||||
|
_disk_set(key, raw_gz, disk_ttl)
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
def cache_delete(key: str) -> bool:
|
def cache_delete(key: str) -> bool:
|
||||||
"""Elimina una chiave dalla cache Redis.
|
client = _get_redis()
|
||||||
|
if client is not None:
|
||||||
Args:
|
|
||||||
key: Chiave Redis da eliminare
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True se eliminata, False altrimenti
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = _get_client()
|
|
||||||
if client is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
client.delete(key)
|
client.delete(key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_disk_delete(key)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cache_stats() -> dict:
|
||||||
|
"""Ritorna statistiche della cache: utile per /health e debug."""
|
||||||
|
stats = {"redis": False, "disk": {"entries": 0, "bytes": 0, "blobs": 0}}
|
||||||
|
if _get_redis() is not None:
|
||||||
|
stats["redis"] = True
|
||||||
|
try:
|
||||||
|
with _sqlite_lock:
|
||||||
|
conn = _ensure_sqlite()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(size_bytes),0), COALESCE(SUM(is_blob),0) FROM cache"
|
||||||
|
).fetchone()
|
||||||
|
stats["disk"]["entries"] = row[0]
|
||||||
|
stats["disk"]["bytes"] = row[1]
|
||||||
|
stats["disk"]["blobs"] = row[2]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def cache_sweep() -> int:
|
||||||
|
"""Rimuove voci scadute su disco (da chiamare periodicamente). Ritorna numero eliminate."""
|
||||||
|
try:
|
||||||
|
now = int(time.time())
|
||||||
|
with _sqlite_lock:
|
||||||
|
conn = _ensure_sqlite()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT key, blob_path FROM cache WHERE expires_at < ?", (now,)
|
||||||
|
).fetchall()
|
||||||
|
conn.execute("DELETE FROM cache WHERE expires_at < ?", (now,))
|
||||||
|
for _, path in rows:
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return len(rows)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[Redis] Errore eliminazione chiave '{key}': {e}")
|
logger.warning(f"[Cache] Errore sweep: {e}")
|
||||||
return False
|
return 0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import hashlib
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Callable, List, Optional
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
@@ -11,13 +12,20 @@ from core.cache import cache_get, cache_set
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Chiavi Redis e TTL ────────────────────────────────────────────────
|
# Lock di "single-flight" per il fetch del catalogo Copernicus.
|
||||||
|
# Senza questo, N richieste concorrenti con cache miss farebbero N chiamate
|
||||||
|
# all'SDK (10-30s ciascuna, ~200MB di response). Con il lock, solo la prima
|
||||||
|
# scarica e popola la cache; le altre attendono e leggono da cache.
|
||||||
|
_catalog_fetch_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ── Chiavi cache e TTL ────────────────────────────────────────────────
|
||||||
# Chiave per il catalogo completo Copernicus
|
# Chiave per il catalogo completo Copernicus
|
||||||
_CATALOG_KEY = "marine:catalog:full"
|
_CATALOG_KEY = "marine:catalog:full"
|
||||||
# TTL del catalogo: 1 ora (il catalogo Copernicus cambia raramente)
|
# TTL L1 (Redis): 2 ore. L2 (disco) usa il default 30 giorni.
|
||||||
_CATALOG_TTL = 3600
|
# Il catalogo Copernicus cambia raramente, ha senso tenerlo a lungo su disco.
|
||||||
# TTL per i risultati di ricerca: 30 minuti
|
_CATALOG_TTL = 2 * 3600
|
||||||
_SEARCH_TTL = 1800
|
# TTL L1 per le ricerche utente: 2 ore. Su disco 30 giorni.
|
||||||
|
_SEARCH_TTL = 2 * 3600
|
||||||
|
|
||||||
|
|
||||||
def _fmt_description(name: Optional[str]) -> Optional[str]:
|
def _fmt_description(name: Optional[str]) -> Optional[str]:
|
||||||
@@ -44,6 +52,13 @@ def _get_raw_catalog() -> dict:
|
|||||||
logger.debug("[Catalogo] Servito da cache Redis")
|
logger.debug("[Catalogo] Servito da cache Redis")
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
# Single-flight: solo un thread alla volta scarica il catalogo. Gli altri
|
||||||
|
# attendono il lock e poi leggono il valore appena messo in cache.
|
||||||
|
with _catalog_fetch_lock:
|
||||||
|
cached = cache_get(_CATALOG_KEY)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
# Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s)
|
# Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s)
|
||||||
logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...")
|
logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...")
|
||||||
import copernicusmarine
|
import copernicusmarine
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from routers import catalog, datasets, jobs
|
from routers import catalog, datasets, jobs
|
||||||
|
from core.cache import cache_stats, cache_sweep
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
api_url = os.getenv("API_SERVICE_URL", "http://api:3003")
|
api_url = os.getenv("API_SERVICE_URL", "http://api:3003")
|
||||||
|
# Pulizia voci scadute della cache su disco all'avvio
|
||||||
|
removed = cache_sweep()
|
||||||
|
if removed:
|
||||||
|
print(f"[Cache] Rimosse {removed} voci scadute dal disco")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -50,4 +55,9 @@ async def root():
|
|||||||
|
|
||||||
@app.get("/health", tags=["health"])
|
@app.get("/health", tags=["health"])
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy", "cache": cache_stats()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/cache/sweep", tags=["health"])
|
||||||
|
async def sweep():
|
||||||
|
return {"removed": cache_sweep()}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Flusso:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
@@ -24,6 +25,13 @@ API_URL = os.getenv("API_SERVICE_URL", "http://api:3003")
|
|||||||
# TTL per lo stato dei job: 48 ore (i job completati vengono puliti automaticamente)
|
# TTL per lo stato dei job: 48 ore (i job completati vengono puliti automaticamente)
|
||||||
_JOB_TTL = 48 * 3600
|
_JOB_TTL = 48 * 3600
|
||||||
|
|
||||||
|
# Limite di download Copernicus concorrenti. Le subset() dell'SDK sono
|
||||||
|
# CPU + memoria intensive (xarray + netCDF + pandas conversion) e sul server
|
||||||
|
# le risorse sono limitate. Senza semaforo, N utenti che cliccano insieme
|
||||||
|
# saturano la RAM e fanno OOM-kill del processo.
|
||||||
|
_DOWNLOAD_CONCURRENCY = int(os.getenv("MARINE_DOWNLOAD_CONCURRENCY", "2"))
|
||||||
|
_download_semaphore = threading.BoundedSemaphore(_DOWNLOAD_CONCURRENCY)
|
||||||
|
|
||||||
|
|
||||||
def _job_key(session_id: str) -> str:
|
def _job_key(session_id: str) -> str:
|
||||||
"""Genera la chiave Redis per un job."""
|
"""Genera la chiave Redis per un job."""
|
||||||
@@ -42,7 +50,7 @@ def _set_job(session_id: str, **kwargs):
|
|||||||
if job is None:
|
if job is None:
|
||||||
return
|
return
|
||||||
job.update(kwargs)
|
job.update(kwargs)
|
||||||
cache_set(_job_key(session_id), job, _JOB_TTL)
|
cache_set(_job_key(session_id), job, _JOB_TTL, disk_ttl=0)
|
||||||
|
|
||||||
|
|
||||||
def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_token: str):
|
def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_token: str):
|
||||||
@@ -55,6 +63,12 @@ def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_
|
|||||||
_set_job(session_id, progress=pct, message=msg)
|
_set_job(session_id, progress=pct, message=msg)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_set_job(session_id, status="queued", progress=2, message="In coda (max concorrenti raggiunto)...")
|
||||||
|
|
||||||
|
# Acquisisce uno slot di download (blocca se già al limite). Garantisce
|
||||||
|
# che il numero di chiamate Copernicus simultanee non superi
|
||||||
|
# MARINE_DOWNLOAD_CONCURRENCY, proteggendo CPU/RAM del server.
|
||||||
|
with _download_semaphore:
|
||||||
_set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...")
|
_set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...")
|
||||||
|
|
||||||
# Scarica dati dal catalogo Copernicus
|
# Scarica dati dal catalogo Copernicus
|
||||||
@@ -85,7 +99,7 @@ def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_
|
|||||||
"created_by": username,
|
"created_by": username,
|
||||||
"type": req.format,
|
"type": req.format,
|
||||||
"notes": req.notes,
|
"notes": req.notes,
|
||||||
"copernicus_dataset_id": req.dataset_id,
|
"copernicus_id": req.dataset_id,
|
||||||
"variables": req.variables,
|
"variables": req.variables,
|
||||||
"variable_renames": req.variable_renames,
|
"variable_renames": req.variable_renames,
|
||||||
"bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude],
|
"bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude],
|
||||||
@@ -129,7 +143,7 @@ async def new_download_session(
|
|||||||
"message": "In coda",
|
"message": "In coda",
|
||||||
"dataset_id": None,
|
"dataset_id": None,
|
||||||
}
|
}
|
||||||
cache_set(_job_key(session_id), initial_state, _JOB_TTL)
|
cache_set(_job_key(session_id), initial_state, _JOB_TTL, disk_ttl=0)
|
||||||
|
|
||||||
# Avvia il download in background
|
# Avvia il download in background
|
||||||
background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"])
|
background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"])
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class DatasetMeta(BaseModel):
|
|||||||
notes: str = ""
|
notes: str = ""
|
||||||
version: int = 1
|
version: int = 1
|
||||||
filename: str
|
filename: str
|
||||||
copernicus_dataset_id: str
|
copernicus_id: str
|
||||||
variables: List[str] = []
|
variables: List[str] = []
|
||||||
bbox: List[float] = [] # [min_lon, min_lat, max_lon, max_lat]
|
bbox: List[float] = [] # [min_lon, min_lat, max_lon, max_lat]
|
||||||
start_date: str
|
start_date: str
|
||||||
|
|||||||
@@ -17,16 +17,16 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
networks:
|
networks:
|
||||||
- meb-proxy-net
|
- meb-public
|
||||||
- meb-internal
|
- meb-private
|
||||||
ports:
|
ports:
|
||||||
- "3006:3006"
|
- "3006:3006"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.auth.rule=Host(`auth.${URL_DOMAIN}`)"
|
- "traefik.http.routers.auth.rule=Host(`auth.${DOMAIN:-mebboat.it}`)"
|
||||||
- "traefik.http.routers.auth.entrypoints=web"
|
- "traefik.http.routers.auth.entrypoints=websecure"
|
||||||
- "traefik.http.services.auth.loadbalancer.server.port=3006"
|
- "traefik.http.services.auth.loadbalancer.server.port=3006"
|
||||||
- "traefik.docker.network=meb-proxy-net"
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
container_name: api-services
|
container_name: api-services
|
||||||
@@ -37,16 +37,20 @@ services:
|
|||||||
command: npm run dev
|
command: npm run dev
|
||||||
volumes:
|
volumes:
|
||||||
- ./api/src:/app/src
|
- ./api/src:/app/src
|
||||||
- /app/node_modules
|
|
||||||
- ./ml:/ml-source
|
- ./ml:/ml-source
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
env_file:
|
env_file:
|
||||||
- ./api/.env
|
- ./api/.env
|
||||||
networks:
|
networks:
|
||||||
- meb-proxy-net
|
- meb-public
|
||||||
- meb-internal
|
- meb-private
|
||||||
ports:
|
labels:
|
||||||
- "3003:3003"
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.api.rule=Host(`api.${DOMAIN:-mebboat.it}`)"
|
||||||
|
- "traefik.http.routers.api.entrypoints=websecure"
|
||||||
|
- "traefik.http.services.api.loadbalancer.server.port=3003"
|
||||||
|
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
console:
|
console:
|
||||||
build:
|
build:
|
||||||
@@ -60,10 +64,15 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./console/.env
|
- ./console/.env
|
||||||
networks:
|
networks:
|
||||||
- meb-proxy-net
|
- meb-public
|
||||||
- meb-internal
|
- meb-private
|
||||||
ports:
|
labels:
|
||||||
- "3004:3004"
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.console.rule=Host(`console.${DOMAIN:-mebboat.it}`)"
|
||||||
|
- "traefik.http.routers.console.entrypoints=websecure"
|
||||||
|
- "traefik.http.services.console.loadbalancer.server.port=3004"
|
||||||
|
- "traefik.http.routers.console.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
realtime:
|
realtime:
|
||||||
build:
|
build:
|
||||||
@@ -71,58 +80,84 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
ports:
|
|
||||||
- "3002:3002"
|
|
||||||
- "3102:3102"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./realtime:/app
|
- ./realtime:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
env_file:
|
env_file:
|
||||||
- ./realtime/.env
|
- ./realtime/.env
|
||||||
networks:
|
networks:
|
||||||
- meb-proxy-net
|
- meb-private
|
||||||
- meb-internal
|
- meb-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.realtime.rule=Host(`realtime.${DOMAIN:-mebboat.it}`)"
|
||||||
|
- "traefik.http.routers.realtime.entrypoints=websecure"
|
||||||
|
- "traefik.http.services.realtime.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.http.routers.realtime.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
# ml:
|
- "traefik.http.services.realtime.loadbalancer.sticky.cookie=true"
|
||||||
# container_name: ml-service
|
- "traefik.http.services.realtime.loadbalancer.sticky.cookie.name=realtime-ws"
|
||||||
# build:
|
- "traefik.http.services.realtime.loadbalancer.sticky.cookie.secure=true"
|
||||||
# context: ./ml
|
|
||||||
# dockerfile: Dockerfile
|
|
||||||
# restart: unless-stopped
|
|
||||||
# volumes:
|
|
||||||
# - ./ml:/app
|
|
||||||
# env_file:
|
|
||||||
# - ./ml/.env
|
|
||||||
# ports:
|
|
||||||
# - "3005:3005"
|
|
||||||
# networks:
|
|
||||||
# - meb-proxy-net
|
|
||||||
# - meb-internal
|
|
||||||
|
|
||||||
# marine:
|
ml:
|
||||||
# container_name: marine-service
|
container_name: ml-service
|
||||||
# build:
|
build:
|
||||||
# context: ./marine
|
context: ./ml
|
||||||
# dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# restart: unless-stopped
|
restart: unless-stopped
|
||||||
# volumes:
|
command: uvicorn main:app --host 0.0.0.0 --port 3007 --reload
|
||||||
# - ./marine:/app
|
volumes:
|
||||||
# env_file:
|
- ./ml:/app
|
||||||
# - ./marine/.env
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# environment:
|
- ml_tmp:/var/ml/tmp
|
||||||
# - REDIS_HOST=meb-redis
|
- ml_gitcache:/var/ml/gitcache
|
||||||
# - REDIS_PORT=6379
|
env_file:
|
||||||
# networks:
|
- ./ml/.env
|
||||||
# - meb-proxy-net
|
networks:
|
||||||
# - meb-internal
|
- meb-private
|
||||||
# labels:
|
- meb-public
|
||||||
# - "traefik.enable=true"
|
labels:
|
||||||
# - "traefik.http.routers.marine.rule=Host(`api.${URL_DOMAIN}`) && PathPrefix(`/marine`)"
|
- "traefik.enable=true"
|
||||||
# - "traefik.http.routers.marine.entrypoints=web"
|
- "traefik.http.routers.ml.rule=Host(`ml.${DOMAIN:-mebboat.it}`)"
|
||||||
# - "traefik.http.services.marine.loadbalancer.server.port=8001"
|
- "traefik.http.routers.ml.entrypoints=websecure"
|
||||||
# - "traefik.docker.network=meb-proxy-net"
|
- "traefik.http.services.ml.loadbalancer.server.port=3007"
|
||||||
# - "traefik.http.middlewares.marine-strip.stripprefix.prefixes=/marine"
|
- "traefik.http.routers.ml.tls.certresolver=letsencrypt"
|
||||||
# - "traefik.http.routers.marine.middlewares=marine-strip"
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
|
copernicus:
|
||||||
|
container_name: copernicus-service
|
||||||
|
build:
|
||||||
|
context: ./copernicus
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./copernicus:/app
|
||||||
|
- copernicus_cache:/app/cache
|
||||||
|
env_file:
|
||||||
|
- ./copernicus/.env
|
||||||
|
environment:
|
||||||
|
- REDIS_HOST=meb-redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- API_SERVICE_URL=http://api:3003
|
||||||
|
- CACHE_DIR=/app/cache
|
||||||
|
- MINIO_ENDPOINT=minio
|
||||||
|
- MINIO_PORT=9000
|
||||||
|
networks:
|
||||||
|
- meb-public
|
||||||
|
- meb-private
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# Esponi sotto api.mebboat.it/marine/* (Traefik strippa "/marine")
|
||||||
|
- "traefik.http.routers.copernicus.rule=Host(`api.${DOMAIN:-mebboat.it}`) && PathPrefix(`/marine`)"
|
||||||
|
- "traefik.http.routers.copernicus.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.copernicus.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.copernicus.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.docker.network=meb-public"
|
||||||
|
- "traefik.http.middlewares.copernicus-strip.stripprefix.prefixes=/marine"
|
||||||
|
- "traefik.http.routers.copernicus.middlewares=copernicus-strip"
|
||||||
|
# Priorità alta: la regola col PathPrefix deve vincere su quella generica api.
|
||||||
|
- "traefik.http.routers.copernicus.priority=100"
|
||||||
|
|
||||||
# circuits:
|
# circuits:
|
||||||
# container_name: meb-circuits
|
# container_name: meb-circuits
|
||||||
@@ -133,8 +168,8 @@ services:
|
|||||||
# environment:
|
# environment:
|
||||||
# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits
|
# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits
|
||||||
# - AUTH_SERVICE_URL=http://auth:3001
|
# - AUTH_SERVICE_URL=http://auth:3001
|
||||||
# - AUTH_URL=http://auth.${URL_DOMAIN:-localhost}
|
# - AUTH_URL=http://auth.${DOMAIN:-localhost}
|
||||||
# - API_URL=http://api.${URL_DOMAIN:-localhost}
|
# - API_URL=http://api.${DOMAIN:-localhost}
|
||||||
# - NODE_ENV=${NODE_ENV:-development}
|
# - NODE_ENV=${NODE_ENV:-development}
|
||||||
# volumes:
|
# volumes:
|
||||||
# - ./circuits/src:/app/src
|
# - ./circuits/src:/app/src
|
||||||
@@ -151,14 +186,19 @@ services:
|
|||||||
# - meb-internal
|
# - meb-internal
|
||||||
# labels:
|
# labels:
|
||||||
# - "traefik.enable=true"
|
# - "traefik.enable=true"
|
||||||
# - "traefik.http.routers.circuits.rule=Host(`circuits.${URL_DOMAIN}`)"
|
# - "traefik.http.routers.circuits.rule=Host(`circuits.${DOMAIN:-mebboat.it}`)"
|
||||||
# - "traefik.http.routers.circuits.entrypoints=web"
|
# - "traefik.http.routers.circuits.entrypoints=web"
|
||||||
# - "traefik.http.services.circuits.loadbalancer.server.port=3005"
|
# - "traefik.http.services.circuits.loadbalancer.server.port=3005"
|
||||||
# - "traefik.docker.network=meb-proxy-net"
|
# - "traefik.docker.network=meb-proxy-net"
|
||||||
# - "traefik.http.routers.circuits.middlewares=cors-ignore"
|
# - "traefik.http.routers.circuits.middlewares=cors-ignore"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
meb-proxy-net:
|
meb-public:
|
||||||
external: true
|
external: true
|
||||||
meb-internal:
|
meb-private:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
copernicus_cache:
|
||||||
|
ml_tmp:
|
||||||
|
ml_gitcache:
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
PORT=3007
|
||||||
|
|
||||||
|
# Auth condiviso
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
INTERNAL_API_KEY=change-me
|
||||||
|
AUTH_LOGIN_URL=https://auth.mebboat.it/login
|
||||||
|
|
||||||
|
# Postgres (db ml)
|
||||||
|
PG_HOST=meb-postgres
|
||||||
|
PG_PORT=5432
|
||||||
|
DB_USER=meb
|
||||||
|
DB_PASSWORD=meb
|
||||||
|
ML_DB=ml
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=meb-redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# MinIO (bucket unico)
|
||||||
|
MINIO_ENDPOINT=minio
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_ACCESS_KEY=
|
||||||
|
MINIO_SECRET_KEY=
|
||||||
|
MINIO_BUCKET=ml
|
||||||
|
|
||||||
|
# InfluxDB
|
||||||
|
INFLUX_URL=http://meb-influx:8086
|
||||||
|
INFLUX_TOKEN=
|
||||||
|
INFLUX_ORG=meb
|
||||||
|
INFLUX_BUCKET=ml_metrics
|
||||||
|
|
||||||
|
# Gitea (self-hosted esterno)
|
||||||
|
GITEA_URL=https://git.mebboat.it
|
||||||
|
GITEA_TOKEN=
|
||||||
|
|
||||||
|
# API service
|
||||||
|
API_URL=http://api:3003
|
||||||
|
|
||||||
|
# Training runtime
|
||||||
|
ML_TRAIN_CONCURRENCY=1
|
||||||
|
ML_RUNNER_IMAGE=meb-ml-runner:latest
|
||||||
|
ML_RUNNER_TMP=/var/ml/tmp
|
||||||
|
ML_GITCACHE_DIR=/var/ml/gitcache
|
||||||
|
ML_MAX_UPLOAD_MB=500
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ FROM python:3.11-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY ./requirements.txt .
|
COPY ./requirements.txt .
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
72
ml/core/api_client.py
Normal file
72
ml/core/api_client.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Client HTTP verso l'api-service (service-to-service via x-api-key).
|
||||||
|
|
||||||
|
Espone accesso a:
|
||||||
|
/jobs ciclo di vita job
|
||||||
|
/queue stato coda
|
||||||
|
/pageconnections registro sessioni di pagina (enforcement /test max 2)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {"x-api-key": settings.internal_api_key, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _req(method: str, path: str, json: Optional[dict] = None, params: Optional[dict] = None) -> Any:
|
||||||
|
url = f"{settings.api_url}{path}"
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||||
|
r = await c.request(method, url, json=json, params=params, headers=_headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
if r.status_code == 204 or not r.content:
|
||||||
|
return None
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── jobs ────────────────────────────────────────────────────────────────────
|
||||||
|
async def create_job(type_: str, created_by: str, payload: dict) -> dict:
|
||||||
|
return await _req("POST", "/jobs", json={"type": type_, "created_by": created_by, "payload": payload})
|
||||||
|
|
||||||
|
|
||||||
|
async def update_job(job_id: str, **fields) -> dict:
|
||||||
|
return await _req("PATCH", f"/jobs/{job_id}", json=fields)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_job(job_id: str) -> dict:
|
||||||
|
return await _req("GET", f"/jobs/{job_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_jobs(type_: Optional[str] = None, status: Optional[str] = None, limit: int = 50) -> list:
|
||||||
|
params = {"limit": str(limit)}
|
||||||
|
if type_:
|
||||||
|
params["type"] = type_
|
||||||
|
if status:
|
||||||
|
params["status"] = status
|
||||||
|
return await _req("GET", "/jobs", params=params) or []
|
||||||
|
|
||||||
|
|
||||||
|
# ── queue ───────────────────────────────────────────────────────────────────
|
||||||
|
async def queue_status(type_: str = "train") -> dict:
|
||||||
|
return await _req("GET", "/queue", params={"type": type_})
|
||||||
|
|
||||||
|
|
||||||
|
# ── page connections ───────────────────────────────────────────────────────
|
||||||
|
async def page_connect(page: str, user_id: str, session_id: str) -> dict:
|
||||||
|
return await _req("POST", "/pageconnections", json={"page": page, "user_id": user_id, "session_id": session_id})
|
||||||
|
|
||||||
|
|
||||||
|
async def page_ping(session_id: str) -> dict:
|
||||||
|
return await _req("POST", f"/pageconnections/{session_id}/ping")
|
||||||
|
|
||||||
|
|
||||||
|
async def page_disconnect(session_id: str) -> None:
|
||||||
|
await _req("DELETE", f"/pageconnections/{session_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def page_count(page: str) -> dict:
|
||||||
|
return await _req("GET", f"/pageconnections/{page}")
|
||||||
85
ml/core/auth.py
Normal file
85
ml/core/auth.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Middleware / dependency di autenticazione per FastAPI (servizio ML).
|
||||||
|
Verifica il JWT firmato da auth.mebboat.it (JWT_SECRET condiviso).
|
||||||
|
Supporta cookie `auth_token` (SSO via .mebboat.it) e header Authorization: Bearer <jwt>.
|
||||||
|
|
||||||
|
Il cookie auth_token è condiviso tra i sottodomini grazie a domain=.mebboat.it:
|
||||||
|
- console.mebboat.it imposta il cookie al login
|
||||||
|
- ml.mebboat.it lo riceve automaticamente dal browser
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
from core.auth import require_auth, require_internal
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected_route(user = Depends(require_auth)):
|
||||||
|
return {"user": user}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import Cookie, Header, HTTPException, Request, status
|
||||||
|
|
||||||
|
SECRET = os.environ.get("JWT_SECRET")
|
||||||
|
INTERNAL_KEY = os.environ.get("INTERNAL_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def _verify(token: Optional[str]):
|
||||||
|
"""Verifica e decodifica un JWT. Ritorna il payload o None."""
|
||||||
|
if not token or not isinstance(token, str) or len(token) > 2048:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
|
||||||
|
return {
|
||||||
|
"user_id": payload.get("sub"),
|
||||||
|
"username": payload.get("username"),
|
||||||
|
"session_id": payload.get("session_id"),
|
||||||
|
"iat": payload.get("iat"),
|
||||||
|
"exp": payload.get("exp"),
|
||||||
|
}
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(
|
||||||
|
request: Request,
|
||||||
|
auth_token: Optional[str] = Cookie(default=None),
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
x_api_key: Optional[str] = Header(default=None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
FastAPI dependency: accetta utente loggato (cookie/bearer) o chiamata interna.
|
||||||
|
Uso: `user = Depends(require_auth)`.
|
||||||
|
|
||||||
|
Il cookie auth_token arriva automaticamente dal browser se l'utente
|
||||||
|
ha effettuato il login su auth.mebboat.it (dominio .mebboat.it).
|
||||||
|
"""
|
||||||
|
# Service-to-service
|
||||||
|
if x_api_key and INTERNAL_KEY and x_api_key == INTERNAL_KEY:
|
||||||
|
request.state.internal = True
|
||||||
|
return {"internal": True}
|
||||||
|
|
||||||
|
# Bearer token
|
||||||
|
bearer = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
bearer = authorization[7:]
|
||||||
|
|
||||||
|
user = _verify(auth_token or bearer)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="unauthorized",
|
||||||
|
)
|
||||||
|
request.state.user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_internal(x_api_key: Optional[str] = Header(default=None)):
|
||||||
|
"""FastAPI dependency: solo chiamate service-to-service con x-api-key."""
|
||||||
|
if not INTERNAL_KEY or x_api_key != INTERNAL_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return True
|
||||||
64
ml/core/config.py
Normal file
64
ml/core/config.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Configurazione centralizzata del servizio ML, letta da env."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
def _b(name: str, default: bool = False) -> bool:
|
||||||
|
return os.environ.get(name, str(default)).lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
# Postgres (db "ml")
|
||||||
|
pg_host: str = os.environ.get("PG_HOST", "meb-postgres")
|
||||||
|
pg_port: int = int(os.environ.get("PG_PORT", "5432"))
|
||||||
|
pg_user: str = os.environ.get("DB_USER", "meb")
|
||||||
|
pg_password: str = os.environ.get("DB_PASSWORD", "meb")
|
||||||
|
pg_db: str = os.environ.get("ML_DB", "ml")
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_host: str = os.environ.get("REDIS_HOST", "meb-redis")
|
||||||
|
redis_port: int = int(os.environ.get("REDIS_PORT", "6379"))
|
||||||
|
|
||||||
|
# MinIO (bucket unico)
|
||||||
|
minio_endpoint: str = os.environ.get("MINIO_ENDPOINT", "minio")
|
||||||
|
minio_port: int = int(os.environ.get("MINIO_PORT", "9000"))
|
||||||
|
minio_use_ssl: bool = _b("MINIO_USE_SSL", False)
|
||||||
|
minio_access_key: str = os.environ.get("MINIO_ACCESS_KEY", "")
|
||||||
|
minio_secret_key: str = os.environ.get("MINIO_SECRET_KEY", "")
|
||||||
|
minio_bucket: str = os.environ.get("MINIO_BUCKET", "ml")
|
||||||
|
|
||||||
|
# InfluxDB — accetta sia INFLUX_* che INFLX_* per allinearsi alle var già
|
||||||
|
# usate dagli altri servizi (realtime, api) senza dover duplicare la config.
|
||||||
|
influx_url: str = os.environ.get("INFLUX_URL") or os.environ.get("INFLX_URL", "http://meb-influx:8086")
|
||||||
|
influx_token: str = os.environ.get("INFLUX_TOKEN") or os.environ.get("INFLX_TOKEN", "")
|
||||||
|
influx_org: str = os.environ.get("INFLUX_ORG") or os.environ.get("INFLX_ORG", "meb")
|
||||||
|
# Bucket dedicato alle metriche di training/test ML, separato dai logs e
|
||||||
|
# dai dati meteo. Sovrascrivibile via INFLUX_BUCKET o ML_INFLUX_BUCKET.
|
||||||
|
influx_bucket: str = os.environ.get("ML_INFLUX_BUCKET") or os.environ.get("INFLUX_BUCKET", "ml_metrics")
|
||||||
|
|
||||||
|
# Gitea (installato esternamente)
|
||||||
|
gitea_url: str = os.environ.get("GITEA_URL", "")
|
||||||
|
gitea_token: str = os.environ.get("GITEA_TOKEN", "")
|
||||||
|
|
||||||
|
# API service (per jobs/queue/pageconnections)
|
||||||
|
api_url: str = os.environ.get("API_URL", "http://api:3003")
|
||||||
|
internal_api_key: str = os.environ.get("INTERNAL_API_KEY", "")
|
||||||
|
|
||||||
|
# Auth (condiviso)
|
||||||
|
jwt_secret: str = os.environ.get("JWT_SECRET", "")
|
||||||
|
auth_login_url: str = os.environ.get("AUTH_LOGIN_URL", "https://auth.mebboat.it/login")
|
||||||
|
|
||||||
|
# Esecuzione training
|
||||||
|
train_concurrency: int = int(os.environ.get("ML_TRAIN_CONCURRENCY", "1"))
|
||||||
|
runner_image: str = os.environ.get("ML_RUNNER_IMAGE", "meb-ml-runner:latest")
|
||||||
|
runner_tmp_dir: str = os.environ.get("ML_RUNNER_TMP", "/var/ml/tmp")
|
||||||
|
gitcache_dir: str = os.environ.get("ML_GITCACHE_DIR", "/var/ml/gitcache")
|
||||||
|
|
||||||
|
# Limiti runtime
|
||||||
|
max_upload_mb: int = int(os.environ.get("ML_MAX_UPLOAD_MB", "500"))
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
53
ml/core/db.py
Normal file
53
ml/core/db.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Connessione asyncpg al database ml. Pool singleton."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
_pool: Optional[asyncpg.Pool] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_pool() -> asyncpg.Pool:
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
_pool = await asyncpg.create_pool(
|
||||||
|
host=settings.pg_host,
|
||||||
|
port=settings.pg_port,
|
||||||
|
user=settings.pg_user,
|
||||||
|
password=settings.pg_password,
|
||||||
|
database=settings.pg_db,
|
||||||
|
min_size=1,
|
||||||
|
max_size=10,
|
||||||
|
command_timeout=30,
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool() -> None:
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
def pool() -> asyncpg.Pool:
|
||||||
|
if _pool is None:
|
||||||
|
raise RuntimeError("DB pool not initialized — call init_pool() at startup")
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch(sql: str, *args):
|
||||||
|
async with pool().acquire() as c:
|
||||||
|
return await c.fetch(sql, *args)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetchrow(sql: str, *args):
|
||||||
|
async with pool().acquire() as c:
|
||||||
|
return await c.fetchrow(sql, *args)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute(sql: str, *args):
|
||||||
|
async with pool().acquire() as c:
|
||||||
|
return await c.execute(sql, *args)
|
||||||
439
ml/core/docker_runner.py
Normal file
439
ml/core/docker_runner.py
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
"""Runner Docker per train e test.
|
||||||
|
|
||||||
|
train:
|
||||||
|
- clone repo Gitea @ sha
|
||||||
|
- prepara workdir /var/ml/tmp/{training_id}
|
||||||
|
- scarica dataset da MinIO in workdir/data.<ext>
|
||||||
|
- docker run meb-ml-runner con mount tmp, env, limits da model.yml
|
||||||
|
- legge stdout JSON → Redis stream + Influx; docker stats ogni 5s
|
||||||
|
- a fine: collect outputs, upload su MinIO prefix artifacts_prefix
|
||||||
|
- UPDATE trainings
|
||||||
|
|
||||||
|
test:
|
||||||
|
- analogo ma sincrono, stdin JSON → stdout JSON
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import docker
|
||||||
|
from influxdb_client import Point
|
||||||
|
|
||||||
|
from core import db, gitea, influx_client, minio_client, redis_client
|
||||||
|
from core.config import settings
|
||||||
|
from core.model_spec import fetch_and_parse_spec
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_docker = None
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_client():
|
||||||
|
global _docker
|
||||||
|
if _docker is None:
|
||||||
|
_docker = docker.from_env()
|
||||||
|
return _docker
|
||||||
|
|
||||||
|
|
||||||
|
async def _emit(stream_key: str, payload: dict) -> None:
|
||||||
|
try:
|
||||||
|
await redis_client.client().xadd(stream_key, {"payload": json.dumps(payload)}, maxlen=10_000)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("xadd failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _clone_repo(owner_repo: str, sha: str, dest: Path) -> None:
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
url = gitea.clone_url(owner_repo)
|
||||||
|
# clone shallow del branch/sha specifico
|
||||||
|
# per evitare leak del token nei log, logghiamo solo host
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"git", "clone", "--depth", "50", url, str(dest),
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, err = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"git clone failed: {err.decode(errors='replace')[:400]}")
|
||||||
|
# checkout sha
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"git", "-C", str(dest), "checkout", sha,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, err = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"git checkout failed: {err.decode(errors='replace')[:400]}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_dataset(dataset_id: str, dest: Path) -> str:
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"SELECT file_key, format FROM datasets WHERE id = $1", uuid.UUID(dataset_id)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise RuntimeError("dataset not found")
|
||||||
|
data = minio_client.get_bytes(row["file_key"], bucket="ml.datasets")
|
||||||
|
ext = {"csv": "csv", "json": "json", "netcdf": "nc"}.get(row["format"], "bin")
|
||||||
|
out = dest / f"data.{ext}"
|
||||||
|
out.write_bytes(data)
|
||||||
|
return str(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_loop_sync(container, training_id: str, model_id: str, samples: list, stop_evt: asyncio.Event, loop: asyncio.AbstractEventLoop):
|
||||||
|
"""Sincrono, eseguito in thread. Ogni 5s legge docker stats → Influx + samples."""
|
||||||
|
while not stop_evt.is_set():
|
||||||
|
try:
|
||||||
|
stats = container.stats(stream=False)
|
||||||
|
# CPU%
|
||||||
|
cpu_delta = stats["cpu_stats"]["cpu_usage"]["total_usage"] - stats["precpu_stats"]["cpu_usage"]["total_usage"]
|
||||||
|
sys_delta = stats["cpu_stats"].get("system_cpu_usage", 0) - stats["precpu_stats"].get("system_cpu_usage", 0)
|
||||||
|
online = stats["cpu_stats"].get("online_cpus") or len(stats["cpu_stats"]["cpu_usage"].get("percpu_usage") or [1])
|
||||||
|
cpu_pct = (cpu_delta / sys_delta) * online * 100.0 if sys_delta > 0 else 0.0
|
||||||
|
mem_mb = (stats["memory_stats"].get("usage") or 0) / (1024 * 1024)
|
||||||
|
|
||||||
|
samples.append((cpu_pct, mem_mb))
|
||||||
|
point = (
|
||||||
|
Point("ml_training")
|
||||||
|
.tag("training_id", training_id)
|
||||||
|
.tag("model_id", model_id)
|
||||||
|
.field("cpu_pct", float(cpu_pct))
|
||||||
|
.field("mem_mb", float(mem_mb))
|
||||||
|
)
|
||||||
|
asyncio.run_coroutine_threadsafe(influx_client.write_points([point]), loop)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("stats loop error: %s", e)
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_container_logs(container, training_id: str, model_id: str, stream_key: str):
|
||||||
|
"""Legge stdout del container, pubblica righe JSON su Redis stream e Influx."""
|
||||||
|
def _iter():
|
||||||
|
return container.logs(stream=True, follow=True, stdout=True, stderr=True)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
it = await loop.run_in_executor(None, _iter)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
line = await loop.run_in_executor(None, next, it, None)
|
||||||
|
if line is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
text = line.decode("utf-8", errors="replace").rstrip("\n")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
# righe non-JSON → log
|
||||||
|
payload: dict
|
||||||
|
if text.startswith("{") and text.endswith("}"):
|
||||||
|
try:
|
||||||
|
payload = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {"type": "log", "level": "info", "message": text}
|
||||||
|
else:
|
||||||
|
payload = {"type": "log", "level": "info", "message": text}
|
||||||
|
|
||||||
|
await _emit(stream_key, payload)
|
||||||
|
|
||||||
|
if payload.get("type") == "metric":
|
||||||
|
p = Point("ml_training").tag("training_id", training_id).tag("model_id", model_id)
|
||||||
|
for k, v in payload.items():
|
||||||
|
if k == "type":
|
||||||
|
continue
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
p = p.field(k, float(v))
|
||||||
|
try:
|
||||||
|
await influx_client.write_points([p])
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("influx write metric failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_training_job(training_id: str) -> None:
|
||||||
|
"""Esegue un job di training end-to-end. Aggiorna Postgres e Redis state."""
|
||||||
|
r = redis_client.client()
|
||||||
|
state_key = f"ml:train:{training_id}"
|
||||||
|
stream_key = f"ml:train:{training_id}:events"
|
||||||
|
|
||||||
|
tr = await db.fetchrow("SELECT * FROM trainings WHERE id = $1", uuid.UUID(training_id))
|
||||||
|
if not tr:
|
||||||
|
log.error("training %s not found", training_id)
|
||||||
|
return
|
||||||
|
model = await db.fetchrow("SELECT * FROM models WHERE id = $1", tr["model_id"])
|
||||||
|
if not model:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE trainings SET status='failed', error=$2 WHERE id=$1",
|
||||||
|
uuid.UUID(training_id), "model not found",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE trainings SET status='running', started_at=NOW() WHERE id=$1",
|
||||||
|
uuid.UUID(training_id),
|
||||||
|
)
|
||||||
|
await r.hset(state_key, mapping={"status": "running", "progress": "0", "message": "starting"})
|
||||||
|
|
||||||
|
workdir = Path(settings.runner_tmp_dir) / training_id
|
||||||
|
artifacts_prefix = f"models/{tr['model_id']}/{tr['version']}/{tr['patch']}"
|
||||||
|
error: Optional[str] = None
|
||||||
|
samples: list[tuple[float, float]] = []
|
||||||
|
try:
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
await _emit(stream_key, {"type": "log", "level": "info", "message": "cloning repo"})
|
||||||
|
await _clone_repo(model["gitea_repo"], tr["patch"], workdir / "repo")
|
||||||
|
|
||||||
|
await _emit(stream_key, {"type": "log", "level": "info", "message": "parsing model.yml"})
|
||||||
|
spec = await fetch_and_parse_spec(model["gitea_repo"], tr["patch"]) or {}
|
||||||
|
train_spec = spec.get("train", {})
|
||||||
|
entrypoint = train_spec.get("entrypoint") or "python -m src.train"
|
||||||
|
resources = spec.get("resources", {}) or {}
|
||||||
|
|
||||||
|
await _emit(stream_key, {"type": "log", "level": "info", "message": "downloading dataset"})
|
||||||
|
dataset_path = await _download_dataset(str(tr["dataset_id"]), workdir)
|
||||||
|
|
||||||
|
out_dir = workdir / "out"
|
||||||
|
out_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# docker run
|
||||||
|
dc = _docker_client()
|
||||||
|
await _emit(stream_key, {"type": "log", "level": "info", "message": "starting container"})
|
||||||
|
container = dc.containers.run(
|
||||||
|
settings.runner_image,
|
||||||
|
command=["sh", "-c", f"cd /workdir/repo && pip install -q -r requirements.txt 2>&1 || true && {entrypoint}"],
|
||||||
|
detach=True,
|
||||||
|
working_dir="/workdir/repo",
|
||||||
|
environment={
|
||||||
|
"MEB_DATASET_PATH": f"/workdir/{Path(dataset_path).name}",
|
||||||
|
"MEB_ARTIFACTS_DIR": "/workdir/out",
|
||||||
|
"MEB_TRAINING_ID": training_id,
|
||||||
|
},
|
||||||
|
volumes={str(workdir): {"bind": "/workdir", "mode": "rw"}},
|
||||||
|
network_mode="none",
|
||||||
|
mem_limit=f"{int(resources.get('mem_mb', 2048))}m",
|
||||||
|
nano_cpus=int(float(resources.get("cpu", 1)) * 1e9),
|
||||||
|
read_only=False,
|
||||||
|
tty=False,
|
||||||
|
detach_mode=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
stop_evt = asyncio.Event()
|
||||||
|
stats_task = loop.run_in_executor(
|
||||||
|
None, _stats_loop_sync, container, training_id, str(tr["model_id"]), samples, stop_evt, loop
|
||||||
|
)
|
||||||
|
log_task = asyncio.create_task(
|
||||||
|
_stream_container_logs(container, training_id, str(tr["model_id"]), stream_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
# attendi exit
|
||||||
|
exit_code = await loop.run_in_executor(None, lambda: container.wait()["StatusCode"])
|
||||||
|
stop_evt.set()
|
||||||
|
await log_task
|
||||||
|
try:
|
||||||
|
stats_task.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if exit_code != 0:
|
||||||
|
error = f"container exited with code {exit_code}"
|
||||||
|
|
||||||
|
# raccogli outputs
|
||||||
|
results: dict = {}
|
||||||
|
final_metrics_path = out_dir / "metrics.json"
|
||||||
|
if final_metrics_path.exists():
|
||||||
|
try:
|
||||||
|
results = json.loads(final_metrics_path.read_text())
|
||||||
|
except Exception:
|
||||||
|
results = {"raw": final_metrics_path.read_text()[:10000]}
|
||||||
|
|
||||||
|
# upload artefatti (tutta la cartella out/)
|
||||||
|
for p in out_dir.rglob("*"):
|
||||||
|
if p.is_file():
|
||||||
|
rel = p.relative_to(out_dir).as_posix()
|
||||||
|
key = f"{artifacts_prefix}/{rel}"
|
||||||
|
minio_client.put_bytes(key, p.read_bytes())
|
||||||
|
|
||||||
|
# upload logs jsonl dallo stream redis (copia su minio per persistenza)
|
||||||
|
try:
|
||||||
|
entries = await r.xrange(stream_key, min="-", max="+")
|
||||||
|
lines = "\n".join(json.dumps({"id": i, **({"payload": json.loads(f.get("payload", "{}"))} if "payload" in f else f)}) for i, f in entries)
|
||||||
|
minio_client.put_bytes(f"trainings/{training_id}/logs.jsonl", lines.encode("utf-8"), "application/x-ndjson")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("log archive failed: %s", e)
|
||||||
|
|
||||||
|
cpu_avg = sum(s[0] for s in samples) / len(samples) if samples else 0.0
|
||||||
|
cpu_peak = max((s[0] for s in samples), default=0.0)
|
||||||
|
mem_avg = sum(s[1] for s in samples) / len(samples) if samples else 0.0
|
||||||
|
mem_peak = max((s[1] for s in samples), default=0.0)
|
||||||
|
resource_summary = {
|
||||||
|
"cpu_avg": round(cpu_avg, 2),
|
||||||
|
"cpu_peak": round(cpu_peak, 2),
|
||||||
|
"mem_avg_mb": round(mem_avg, 2),
|
||||||
|
"mem_peak_mb": round(mem_peak, 2),
|
||||||
|
"samples": len(samples),
|
||||||
|
}
|
||||||
|
|
||||||
|
status = "failed" if error else "succeeded"
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE trainings SET
|
||||||
|
status=$2,
|
||||||
|
finished_at=NOW(),
|
||||||
|
duration_ms=EXTRACT(EPOCH FROM (NOW() - started_at))*1000,
|
||||||
|
artifacts_prefix=$3,
|
||||||
|
results=$4::jsonb,
|
||||||
|
resource_summary=$5::jsonb,
|
||||||
|
error=$6
|
||||||
|
WHERE id=$1
|
||||||
|
""",
|
||||||
|
uuid.UUID(training_id),
|
||||||
|
status,
|
||||||
|
artifacts_prefix,
|
||||||
|
json.dumps(results),
|
||||||
|
json.dumps(resource_summary),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
await r.hset(state_key, mapping={"status": status, "progress": "100", "message": error or "done"})
|
||||||
|
await _emit(stream_key, {"type": "end", "status": status, "error": error})
|
||||||
|
|
||||||
|
# Flush dei punti Influx accumulati durante il training (batched).
|
||||||
|
await influx_client.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
container.remove(force=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("training %s failed: %s", training_id, e)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE trainings SET status='failed', finished_at=NOW(), error=$2 WHERE id=$1",
|
||||||
|
uuid.UUID(training_id), str(e)[:1000],
|
||||||
|
)
|
||||||
|
await r.hset(state_key, mapping={"status": "failed", "message": str(e)[:200]})
|
||||||
|
await _emit(stream_key, {"type": "end", "status": "failed", "error": str(e)[:400]})
|
||||||
|
finally:
|
||||||
|
# cleanup workdir
|
||||||
|
try:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def run_test_once(training_id: str, inputs: dict) -> dict:
|
||||||
|
"""Esegue una singola predizione via container spawn."""
|
||||||
|
tr = await db.fetchrow(
|
||||||
|
"SELECT t.*, m.gitea_repo FROM trainings t JOIN models m ON t.model_id = m.id WHERE t.id=$1",
|
||||||
|
uuid.UUID(training_id),
|
||||||
|
)
|
||||||
|
if not tr:
|
||||||
|
raise RuntimeError("training not found")
|
||||||
|
|
||||||
|
spec = await fetch_and_parse_spec(tr["gitea_repo"], tr["patch"]) or {}
|
||||||
|
test_spec = spec.get("test") or {}
|
||||||
|
entrypoint = test_spec.get("entrypoint") or "python -m src.predict"
|
||||||
|
|
||||||
|
workdir = Path(settings.runner_tmp_dir) / f"test-{uuid.uuid4()}"
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
await _clone_repo(tr["gitea_repo"], tr["patch"], workdir / "repo")
|
||||||
|
|
||||||
|
# scarica artefatti
|
||||||
|
if tr["artifacts_prefix"]:
|
||||||
|
art_dir = workdir / "artifacts"
|
||||||
|
art_dir.mkdir(exist_ok=True)
|
||||||
|
for obj in minio_client.list_prefix(tr["artifacts_prefix"] + "/"):
|
||||||
|
rel = obj["name"][len(tr["artifacts_prefix"]) + 1:]
|
||||||
|
out_path = art_dir / rel
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_bytes(minio_client.get_bytes(obj["name"]))
|
||||||
|
|
||||||
|
dc = _docker_client()
|
||||||
|
payload = json.dumps({"inputs": inputs}).encode()
|
||||||
|
container = dc.containers.run(
|
||||||
|
settings.runner_image,
|
||||||
|
command=["sh", "-c", f"cd /workdir/repo && pip install -q -r requirements.txt 2>&1 >/dev/null || true && {entrypoint}"],
|
||||||
|
detach=True,
|
||||||
|
working_dir="/workdir/repo",
|
||||||
|
environment={
|
||||||
|
"MEB_ARTIFACTS_DIR": "/workdir/artifacts",
|
||||||
|
"MEB_TRAINING_ID": training_id,
|
||||||
|
},
|
||||||
|
volumes={str(workdir): {"bind": "/workdir", "mode": "ro"}},
|
||||||
|
network_mode="none",
|
||||||
|
mem_limit="2048m",
|
||||||
|
nano_cpus=int(1e9),
|
||||||
|
stdin_open=True,
|
||||||
|
tty=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# scrivi input su stdin via attach socket
|
||||||
|
sock = container.attach_socket(params={"stdin": 1, "stream": 1})
|
||||||
|
try:
|
||||||
|
sock._sock.sendall(payload + b"\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
# stats peak
|
||||||
|
peak_cpu = 0.0
|
||||||
|
peak_mem = 0.0
|
||||||
|
stop = False
|
||||||
|
|
||||||
|
def _stats():
|
||||||
|
nonlocal peak_cpu, peak_mem, stop
|
||||||
|
for st in container.stats(stream=True, decode=True):
|
||||||
|
if stop:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cpu_delta = st["cpu_stats"]["cpu_usage"]["total_usage"] - st["precpu_stats"]["cpu_usage"]["total_usage"]
|
||||||
|
sys_delta = st["cpu_stats"].get("system_cpu_usage", 0) - st["precpu_stats"].get("system_cpu_usage", 0)
|
||||||
|
online = st["cpu_stats"].get("online_cpus") or 1
|
||||||
|
cpu_pct = (cpu_delta / sys_delta) * online * 100 if sys_delta > 0 else 0
|
||||||
|
mem_mb = (st["memory_stats"].get("usage") or 0) / (1024 * 1024)
|
||||||
|
peak_cpu = max(peak_cpu, cpu_pct)
|
||||||
|
peak_mem = max(peak_mem, mem_mb)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stats_fut = loop.run_in_executor(None, _stats)
|
||||||
|
|
||||||
|
exit_info = await loop.run_in_executor(None, container.wait)
|
||||||
|
stop = True
|
||||||
|
logs = container.logs(stdout=True, stderr=False).decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
container.remove(force=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
outputs: dict = {}
|
||||||
|
for line in logs.strip().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("{") and line.endswith("}"):
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
if "outputs" in obj:
|
||||||
|
outputs = obj["outputs"]
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
"outputs": outputs,
|
||||||
|
"exit_code": exit_info.get("StatusCode"),
|
||||||
|
"cpu_peak": round(peak_cpu, 2),
|
||||||
|
"mem_peak_mb": round(peak_mem, 2),
|
||||||
|
"raw_log": logs[-2000:],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
57
ml/core/gitea.py
Normal file
57
ml/core/gitea.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Client Gitea: browse repo, branches, commits, file raw, clone URL autenticato."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
h = {"Accept": "application/json"}
|
||||||
|
if settings.gitea_token:
|
||||||
|
h["Authorization"] = f"token {settings.gitea_token}"
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def clone_url(owner_repo: str) -> str:
|
||||||
|
"""URL https://oauth2:TOKEN@<host>/owner/repo.git — usato SOLO lato server."""
|
||||||
|
if not settings.gitea_url:
|
||||||
|
raise RuntimeError("GITEA_URL not configured")
|
||||||
|
base = settings.gitea_url.rstrip("/")
|
||||||
|
if settings.gitea_token:
|
||||||
|
base = base.replace("https://", f"https://oauth2:{settings.gitea_token}@").replace(
|
||||||
|
"http://", f"http://oauth2:{settings.gitea_token}@"
|
||||||
|
)
|
||||||
|
return f"{base}/{owner_repo}.git"
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(path: str, params: Optional[dict] = None) -> list | dict:
|
||||||
|
url = f"{settings.gitea_url.rstrip('/')}/api/v1{path}"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as c:
|
||||||
|
r = await c.get(url, params=params, headers=_headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_repos(limit: int = 50) -> list[dict]:
|
||||||
|
data = await _get("/repos/search", params={"limit": str(limit)})
|
||||||
|
return data.get("data", []) if isinstance(data, dict) else []
|
||||||
|
|
||||||
|
|
||||||
|
async def list_branches(owner_repo: str) -> list[dict]:
|
||||||
|
return await _get(f"/repos/{owner_repo}/branches")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_commits(owner_repo: str, branch: str = "main", limit: int = 50) -> list[dict]:
|
||||||
|
return await _get(f"/repos/{owner_repo}/commits", params={"sha": branch, "limit": str(limit)})
|
||||||
|
|
||||||
|
|
||||||
|
async def get_file_raw(owner_repo: str, ref: str, path: str) -> bytes:
|
||||||
|
"""Scarica il file raw alla revisione indicata."""
|
||||||
|
url = f"{settings.gitea_url.rstrip('/')}/api/v1/repos/{owner_repo}/raw/{path}"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as c:
|
||||||
|
r = await c.get(url, params={"ref": ref}, headers=_headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
75
ml/core/influx_client.py
Normal file
75
ml/core/influx_client.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Client InfluxDB (influxdb-client sync wrapper in thread-pool per async).
|
||||||
|
|
||||||
|
Le scritture usano il batching async dell'SDK invece di SYNCHRONOUS.
|
||||||
|
Le metriche di training arrivano in burst (logs container, stats loop ogni 5s):
|
||||||
|
con SYNCHRONOUS ogni write era una HTTP request bloccante. Con WriteOptions
|
||||||
|
batched, l'SDK accumula i Point e fa flush periodico in background, senza
|
||||||
|
perdere durabilità (flush forzato a fine training).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from influxdb_client import InfluxDBClient, Point, WriteOptions
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
_client: Optional[InfluxDBClient] = None
|
||||||
|
_write_api = None
|
||||||
|
|
||||||
|
|
||||||
|
def client() -> InfluxDBClient:
|
||||||
|
global _client, _write_api
|
||||||
|
if _client is None:
|
||||||
|
_client = InfluxDBClient(
|
||||||
|
url=settings.influx_url, token=settings.influx_token, org=settings.influx_org
|
||||||
|
)
|
||||||
|
_write_api = _client.write_api(write_options=WriteOptions(
|
||||||
|
batch_size=200,
|
||||||
|
flush_interval=2_000,
|
||||||
|
jitter_interval=200,
|
||||||
|
retry_interval=2_000,
|
||||||
|
max_retries=3,
|
||||||
|
))
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _wa():
|
||||||
|
client()
|
||||||
|
return _write_api
|
||||||
|
|
||||||
|
|
||||||
|
async def write_points(points: Iterable[Point]) -> None:
|
||||||
|
wa = _wa()
|
||||||
|
pts = list(points)
|
||||||
|
await asyncio.to_thread(wa.write, settings.influx_bucket, settings.influx_org, pts)
|
||||||
|
|
||||||
|
|
||||||
|
async def flush() -> None:
|
||||||
|
"""Forza il flush del buffer batched. Da chiamare a fine training per
|
||||||
|
garantire che tutte le metriche raccolte siano persistite."""
|
||||||
|
if _write_api is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_write_api.flush)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def query_flux(flux: str) -> list[dict]:
|
||||||
|
c = client()
|
||||||
|
def _q():
|
||||||
|
tables = c.query_api().query(flux, org=settings.influx_org)
|
||||||
|
out = []
|
||||||
|
for table in tables:
|
||||||
|
for r in table.records:
|
||||||
|
out.append({
|
||||||
|
"time": r.get_time().isoformat() if r.get_time() else None,
|
||||||
|
"measurement": r.get_measurement(),
|
||||||
|
"field": r.get_field(),
|
||||||
|
"value": r.get_value(),
|
||||||
|
"tags": {k: v for k, v in r.values.items() if k.startswith("_") is False and k not in ("result", "table")},
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
return await asyncio.to_thread(_q)
|
||||||
118
ml/core/minio_client.py
Normal file
118
ml/core/minio_client.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Wrapper MinIO: bucket unico (settings.minio_bucket) con prefissi logici.
|
||||||
|
|
||||||
|
Prefissi usati:
|
||||||
|
datasets/<uuid>.<ext>
|
||||||
|
models/<model_id>/spec.yml
|
||||||
|
models/<model_id>/<version>/<patch>/... (artefatti training)
|
||||||
|
trainings/<training_id>/logs.jsonl
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from minio import Minio
|
||||||
|
from minio.error import S3Error
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
_client: Optional[Minio] = None
|
||||||
|
|
||||||
|
|
||||||
|
def client() -> Minio:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = Minio(
|
||||||
|
f"{settings.minio_endpoint}:{settings.minio_port}",
|
||||||
|
access_key=settings.minio_access_key,
|
||||||
|
secret_key=settings.minio_secret_key,
|
||||||
|
secure=settings.minio_use_ssl,
|
||||||
|
)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket(b: Optional[str] = None) -> str:
|
||||||
|
return b or settings.minio_bucket
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_bucket(bucket: Optional[str] = None) -> None:
|
||||||
|
name = _bucket(bucket)
|
||||||
|
c = client()
|
||||||
|
if not c.bucket_exists(name):
|
||||||
|
c.make_bucket(name)
|
||||||
|
|
||||||
|
|
||||||
|
def put_bytes(key: str, data: bytes, content_type: str = "application/octet-stream",
|
||||||
|
bucket: Optional[str] = None) -> None:
|
||||||
|
ensure_bucket(bucket)
|
||||||
|
client().put_object(
|
||||||
|
_bucket(bucket),
|
||||||
|
key,
|
||||||
|
io.BytesIO(data),
|
||||||
|
length=len(data),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def put_stream(key: str, stream, length: int, content_type: str = "application/octet-stream",
|
||||||
|
bucket: Optional[str] = None) -> None:
|
||||||
|
ensure_bucket(bucket)
|
||||||
|
client().put_object(
|
||||||
|
_bucket(bucket), key, stream, length=length, content_type=content_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_bytes(key: str, bucket: Optional[str] = None) -> bytes:
|
||||||
|
r = client().get_object(_bucket(bucket), key)
|
||||||
|
try:
|
||||||
|
return r.read()
|
||||||
|
finally:
|
||||||
|
r.close()
|
||||||
|
r.release_conn()
|
||||||
|
|
||||||
|
|
||||||
|
def remove(key: str, bucket: Optional[str] = None) -> None:
|
||||||
|
try:
|
||||||
|
client().remove_object(_bucket(bucket), key)
|
||||||
|
except S3Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def remove_prefix(prefix: str, bucket: Optional[str] = None) -> int:
|
||||||
|
name = _bucket(bucket)
|
||||||
|
n = 0
|
||||||
|
for obj in client().list_objects(name, prefix=prefix, recursive=True):
|
||||||
|
try:
|
||||||
|
client().remove_object(name, obj.object_name)
|
||||||
|
n += 1
|
||||||
|
except S3Error:
|
||||||
|
pass
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def presigned_get(key: str, expires_seconds: int = 3600, bucket: Optional[str] = None) -> str:
|
||||||
|
return client().presigned_get_object(
|
||||||
|
_bucket(bucket), key, expires=timedelta(seconds=expires_seconds)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_prefix(prefix: str, bucket: Optional[str] = None) -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for obj in client().list_objects(_bucket(bucket), prefix=prefix, recursive=True):
|
||||||
|
out.append({
|
||||||
|
"name": obj.object_name,
|
||||||
|
"size": obj.size,
|
||||||
|
"last_modified": obj.last_modified.isoformat() if obj.last_modified else None,
|
||||||
|
"etag": obj.etag,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def check() -> bool:
|
||||||
|
try:
|
||||||
|
client().list_buckets()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
90
ml/core/model_spec.py
Normal file
90
ml/core/model_spec.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Parse e validazione del contratto `model.yml` nelle repo utente.
|
||||||
|
|
||||||
|
Schema sintetico (vedi piano):
|
||||||
|
name, type, version, python
|
||||||
|
train: {entrypoint, inputs, outputs, metrics}
|
||||||
|
test: {entrypoint, io, input_schema[], output_schema[]}
|
||||||
|
resources: {cpu, mem_mb, gpu}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from core import gitea, redis_client
|
||||||
|
|
||||||
|
|
||||||
|
class _FieldSpec(BaseModel):
|
||||||
|
name: str
|
||||||
|
dtype: str
|
||||||
|
min: Optional[float] = None
|
||||||
|
max: Optional[float] = None
|
||||||
|
unit: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class _Train(BaseModel):
|
||||||
|
entrypoint: str
|
||||||
|
inputs: dict = {}
|
||||||
|
outputs: dict = {}
|
||||||
|
metrics: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _Test(BaseModel):
|
||||||
|
entrypoint: str
|
||||||
|
io: str = "stdio_json"
|
||||||
|
input_schema: list[_FieldSpec] = []
|
||||||
|
output_schema: list[_FieldSpec] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSpec(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
version: str = "0.1.0"
|
||||||
|
python: str = "3.11"
|
||||||
|
train: _Train
|
||||||
|
test: Optional[_Test] = None
|
||||||
|
resources: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_yaml(content: bytes | str) -> dict:
|
||||||
|
"""Parsa stringa YAML → dict validato. Solleva ValueError su errore."""
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
content = content.decode("utf-8")
|
||||||
|
try:
|
||||||
|
raw = yaml.safe_load(content) or {}
|
||||||
|
spec = ModelSpec(**raw)
|
||||||
|
return spec.model_dump()
|
||||||
|
except (yaml.YAMLError, ValidationError) as e:
|
||||||
|
raise ValueError(f"invalid model.yml: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_and_parse_spec(owner_repo: str, ref: str) -> Optional[dict]:
|
||||||
|
"""Recupera model.yml dalla repo alla revisione e lo parsa.
|
||||||
|
Cache Redis `ml:modelspec:{repo}:{ref}` TTL 1h.
|
||||||
|
"""
|
||||||
|
cache_key = f"ml:modelspec:{owner_repo}:{ref}"
|
||||||
|
try:
|
||||||
|
cached = await redis_client.client().get(cache_key)
|
||||||
|
if cached:
|
||||||
|
import json
|
||||||
|
return json.loads(cached)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await gitea.get_file_raw(owner_repo, ref, "model.yml")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
raw = await gitea.get_file_raw(owner_repo, ref, "model.yaml")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
spec = parse_yaml(raw)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
await redis_client.client().set(cache_key, json.dumps(spec), ex=3600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return spec
|
||||||
29
ml/core/redis_client.py
Normal file
29
ml/core/redis_client.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Client Redis asincrono (redis-py asyncio). Singleton semplice."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
_client: Optional[redis.Redis] = None
|
||||||
|
|
||||||
|
|
||||||
|
def client() -> redis.Redis:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = redis.Redis(
|
||||||
|
host=settings.redis_host,
|
||||||
|
port=settings.redis_port,
|
||||||
|
decode_responses=True,
|
||||||
|
health_check_interval=30,
|
||||||
|
)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def close() -> None:
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
await _client.aclose()
|
||||||
|
_client = None
|
||||||
54
ml/core/worker.py
Normal file
54
ml/core/worker.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Worker loop: BRPOP da ml:queue:train e dispatch al docker_runner.
|
||||||
|
|
||||||
|
Parte N task asincroni concorrenti (settings.train_concurrency).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from core import redis_client
|
||||||
|
from core.config import settings
|
||||||
|
from core.docker_runner import run_training_job
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
|
async def _worker_loop(idx: int):
|
||||||
|
r = redis_client.client()
|
||||||
|
log.info("ml worker[%d] started", idx)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
res = await r.brpop("ml:queue:train", timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("brpop error: %s", e)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
|
if res is None:
|
||||||
|
continue
|
||||||
|
_, training_id = res
|
||||||
|
log.info("worker[%d] picked training %s", idx, training_id)
|
||||||
|
try:
|
||||||
|
await run_training_job(training_id)
|
||||||
|
except Exception:
|
||||||
|
log.exception("worker[%d] training %s crashed", idx, training_id)
|
||||||
|
|
||||||
|
|
||||||
|
def start_workers() -> None:
|
||||||
|
global _tasks
|
||||||
|
n = max(1, settings.train_concurrency)
|
||||||
|
for i in range(n):
|
||||||
|
_tasks.append(asyncio.create_task(_worker_loop(i)))
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_workers() -> None:
|
||||||
|
for t in _tasks:
|
||||||
|
t.cancel()
|
||||||
|
for t in _tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_tasks.clear()
|
||||||
95
ml/main.py
95
ml/main.py
@@ -1,19 +1,90 @@
|
|||||||
from fastapi import FastAPI, Request, Response, Header
|
"""ml-service — FastAPI entrypoint.
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
|
||||||
import time
|
Monta:
|
||||||
|
/ → RedirectResponse
|
||||||
|
/datasets /models /train /test /results → pagine Jinja
|
||||||
|
/api/datasets /api/models /api/repos /api/trainings /api/tests /api/results → JSON
|
||||||
|
/api/trainings/{id}/events → SSE
|
||||||
|
/health → check
|
||||||
|
/static/* → file statici
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from core import db, minio_client, redis_client, worker
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
log.info("ml-service starting")
|
||||||
|
await db.init_pool()
|
||||||
|
try:
|
||||||
|
minio_client.ensure_bucket()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("minio bucket ensure failed: %s", e)
|
||||||
|
worker.start_workers()
|
||||||
|
yield
|
||||||
|
log.info("ml-service stopping")
|
||||||
|
await worker.stop_workers()
|
||||||
|
await db.close_pool()
|
||||||
|
await redis_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="MEB ML Service", lifespan=lifespan)
|
||||||
|
|
||||||
|
# static
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
async def health():
|
||||||
|
pg_ok = True
|
||||||
|
try:
|
||||||
|
await db.fetchrow("SELECT 1")
|
||||||
|
except Exception:
|
||||||
|
pg_ok = False
|
||||||
|
redis_ok = True
|
||||||
|
try:
|
||||||
|
await redis_client.client().ping()
|
||||||
|
except Exception:
|
||||||
|
redis_ok = False
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok" if (pg_ok and redis_ok) else "degraded",
|
||||||
"service": "ml",
|
"service": "ml",
|
||||||
"version": "1.0.0",
|
"postgres": "connected" if pg_ok else "disconnected",
|
||||||
"build_number": "1",
|
"redis": "connected" if redis_ok else "disconnected",
|
||||||
"version_state": "dev"
|
"minio": "connected" if minio_client.check() else "disconnected",
|
||||||
|
"version": "2.0.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def root():
|
from routers import ( # noqa: E402
|
||||||
return {"message": "ML Service"}
|
datasets,
|
||||||
|
models,
|
||||||
|
pages,
|
||||||
|
repos,
|
||||||
|
results,
|
||||||
|
tests,
|
||||||
|
trainings,
|
||||||
|
trainings_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(pages.router)
|
||||||
|
app.include_router(datasets.router)
|
||||||
|
app.include_router(models.router)
|
||||||
|
app.include_router(repos.router)
|
||||||
|
app.include_router(trainings.router)
|
||||||
|
app.include_router(trainings_stream.router)
|
||||||
|
app.include_router(tests.router)
|
||||||
|
app.include_router(results.router)
|
||||||
|
|||||||
@@ -1,2 +1,15 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn[standard]
|
||||||
|
PyJWT
|
||||||
|
asyncpg
|
||||||
|
redis>=5
|
||||||
|
minio
|
||||||
|
influxdb-client
|
||||||
|
docker
|
||||||
|
PyYAML
|
||||||
|
pydantic>=2
|
||||||
|
python-multipart
|
||||||
|
jinja2
|
||||||
|
aiofiles
|
||||||
|
httpx
|
||||||
|
sse-starlette
|
||||||
|
|||||||
160
ml/routers/datasets.py
Normal file
160
ml/routers/datasets.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""API datasets (ml.mebboat.it/api/datasets).
|
||||||
|
|
||||||
|
Upload/list/get/download/delete. Storage:
|
||||||
|
MinIO bucket "ml" con key "datasets/<uuid>.<ext>"
|
||||||
|
Postgres db "ml" tabella "datasets"
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||||
|
|
||||||
|
from core import db, minio_client
|
||||||
|
from core.auth import require_auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/datasets", tags=["datasets"])
|
||||||
|
|
||||||
|
# Bucket MinIO fisso per tutti i dataset (no prefix nelle key).
|
||||||
|
BUCKET = "ml.datasets"
|
||||||
|
_EXT = {"csv": "csv", "json": "json", "netcdf": "nc"}
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r) -> dict:
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
d = dict(r)
|
||||||
|
# asyncpg ritorna JSONB come dict già; date/time come datetime
|
||||||
|
for k in ("created_at", "updated_at", "start_date", "end_date"):
|
||||||
|
if d.get(k) is not None and hasattr(d[k], "isoformat"):
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_datasets(
|
||||||
|
type: Optional[str] = Query(None),
|
||||||
|
tags: Optional[str] = Query(None),
|
||||||
|
mine: Optional[int] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
where = []
|
||||||
|
args: list = []
|
||||||
|
if type:
|
||||||
|
args.append(type)
|
||||||
|
where.append(f"type = ${len(args)}")
|
||||||
|
if tags:
|
||||||
|
tag_arr = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
if tag_arr:
|
||||||
|
args.append(tag_arr)
|
||||||
|
where.append(f"tags && ${len(args)}")
|
||||||
|
if mine and user.get("username"):
|
||||||
|
args.append(user["username"])
|
||||||
|
where.append(f"created_by = ${len(args)}")
|
||||||
|
if search:
|
||||||
|
args.append(f"%{search}%")
|
||||||
|
where.append(f"(nome ILIKE ${len(args)} OR description ILIKE ${len(args)})")
|
||||||
|
sql = "SELECT * FROM datasets"
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
sql += " ORDER BY created_at DESC LIMIT 500"
|
||||||
|
rows = await db.fetch(sql, *args)
|
||||||
|
return {"count": len(rows), "datasets": [_row(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def upload_dataset(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
metadata: str = Form("{}"),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
meta = json.loads(metadata or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(400, "metadata must be valid JSON")
|
||||||
|
|
||||||
|
fmt = meta.get("format") or meta.get("type") or "csv"
|
||||||
|
if fmt not in ("csv", "json", "netcdf"):
|
||||||
|
fmt = "csv"
|
||||||
|
ext = _EXT[fmt]
|
||||||
|
ds_id = str(uuid.uuid4())
|
||||||
|
file_key = f"{ds_id}.{ext}"
|
||||||
|
|
||||||
|
data = await file.read()
|
||||||
|
minio_client.put_bytes(file_key, data, content_type=file.content_type or "application/octet-stream", bucket=BUCKET)
|
||||||
|
|
||||||
|
created_by = user.get("username") or meta.get("created_by") or "unknown"
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO datasets (
|
||||||
|
id, file_key, nome, description, tags, type, format, notes,
|
||||||
|
created_by, size_bytes, copernicus_id
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
uuid.UUID(ds_id),
|
||||||
|
file_key,
|
||||||
|
meta.get("nome") or file.filename or file_key,
|
||||||
|
meta.get("description"),
|
||||||
|
meta.get("tags") or [],
|
||||||
|
meta.get("dataset_type") or "custom",
|
||||||
|
fmt,
|
||||||
|
meta.get("notes"),
|
||||||
|
created_by,
|
||||||
|
len(data),
|
||||||
|
meta.get("copernicus_id") or meta.get("copernicus_dataset_id"),
|
||||||
|
)
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dataset_id}")
|
||||||
|
async def get_dataset(dataset_id: str, user=Depends(require_auth)):
|
||||||
|
row = await db.fetchrow("SELECT * FROM datasets WHERE id = $1", uuid.UUID(dataset_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dataset_id}/download")
|
||||||
|
async def download_dataset(dataset_id: str, user=Depends(require_auth)):
|
||||||
|
row = await db.fetchrow("SELECT file_key FROM datasets WHERE id = $1", uuid.UUID(dataset_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
url = minio_client.presigned_get(row["file_key"], 3600, bucket=BUCKET)
|
||||||
|
return {"url": url, "expires_in": 3600}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{dataset_id}")
|
||||||
|
async def patch_dataset(dataset_id: str, body: dict, user=Depends(require_auth)):
|
||||||
|
allowed = {"nome", "description", "tags", "notes"}
|
||||||
|
sets = []
|
||||||
|
args: list = []
|
||||||
|
for k, v in body.items():
|
||||||
|
if k in allowed:
|
||||||
|
args.append(v)
|
||||||
|
sets.append(f"{k} = ${len(args)}")
|
||||||
|
if not sets:
|
||||||
|
raise HTTPException(400, "no fields to update")
|
||||||
|
# Trigger updated_at non presente nel DB: lo aggiorniamo manualmente.
|
||||||
|
sets.append("updated_at = NOW()")
|
||||||
|
args.append(uuid.UUID(dataset_id))
|
||||||
|
row = await db.fetchrow(
|
||||||
|
f"UPDATE datasets SET {', '.join(sets)} WHERE id = ${len(args)} RETURNING *",
|
||||||
|
*args,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{dataset_id}", status_code=204)
|
||||||
|
async def delete_dataset(dataset_id: str, user=Depends(require_auth)):
|
||||||
|
row = await db.fetchrow("SELECT file_key FROM datasets WHERE id = $1", uuid.UUID(dataset_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
minio_client.remove(row["file_key"], bucket=BUCKET)
|
||||||
|
await db.execute("DELETE FROM datasets WHERE id = $1", uuid.UUID(dataset_id))
|
||||||
|
return None
|
||||||
131
ml/routers/models.py
Normal file
131
ml/routers/models.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""API /api/models — registro modelli (repo Gitea + metadata)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from core import db
|
||||||
|
from core.auth import require_auth
|
||||||
|
from core.model_spec import fetch_and_parse_spec
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/models", tags=["models"])
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r) -> Optional[dict]:
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
d = dict(r)
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
if d.get(k) is not None and hasattr(d[k], "isoformat"):
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_models(user=Depends(require_auth)):
|
||||||
|
rows = await db.fetch("SELECT * FROM models ORDER BY created_at DESC LIMIT 500")
|
||||||
|
return {"count": len(rows), "models": [_row(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_model(body: dict, user=Depends(require_auth)):
|
||||||
|
required = ("name", "type", "gitea_repo")
|
||||||
|
for k in required:
|
||||||
|
if not body.get(k):
|
||||||
|
raise HTTPException(400, f"missing field: {k}")
|
||||||
|
|
||||||
|
# prova a pre-caricare model.yml dal default branch (non fatale)
|
||||||
|
spec = None
|
||||||
|
try:
|
||||||
|
spec = await fetch_and_parse_spec(body["gitea_repo"], body.get("default_branch") or "main")
|
||||||
|
except Exception:
|
||||||
|
spec = None
|
||||||
|
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (name, type, gitea_repo, default_branch, spec, created_by)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
body["name"],
|
||||||
|
body["type"],
|
||||||
|
body["gitea_repo"],
|
||||||
|
body.get("default_branch") or "main",
|
||||||
|
spec,
|
||||||
|
user.get("username") or "unknown",
|
||||||
|
)
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{model_id}")
|
||||||
|
async def get_model(model_id: str, user=Depends(require_auth)):
|
||||||
|
row = await db.fetchrow("SELECT * FROM models WHERE id = $1", uuid.UUID(model_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{model_id}")
|
||||||
|
async def patch_model(model_id: str, body: dict, user=Depends(require_auth)):
|
||||||
|
allowed = {"name", "type", "default_branch"}
|
||||||
|
sets = []
|
||||||
|
args: list = []
|
||||||
|
for k, v in body.items():
|
||||||
|
if k in allowed:
|
||||||
|
args.append(v)
|
||||||
|
sets.append(f"{k} = ${len(args)}")
|
||||||
|
if not sets:
|
||||||
|
raise HTTPException(400, "no fields to update")
|
||||||
|
args.append(uuid.UUID(model_id))
|
||||||
|
row = await db.fetchrow(
|
||||||
|
f"UPDATE models SET {', '.join(sets)} WHERE id = ${len(args)} RETURNING *",
|
||||||
|
*args,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{model_id}", status_code=204)
|
||||||
|
async def delete_model(model_id: str, user=Depends(require_auth)):
|
||||||
|
await db.execute("DELETE FROM models WHERE id = $1", uuid.UUID(model_id))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Notes ──────────────────────────────────────────────────────────────────
|
||||||
|
@router.get("/{model_id}/notes")
|
||||||
|
async def list_notes(model_id: str, user=Depends(require_auth)):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"SELECT id, author, text, created_at FROM model_notes WHERE model_id = $1 ORDER BY created_at DESC",
|
||||||
|
uuid.UUID(model_id),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"author": r["author"],
|
||||||
|
"text": r["text"],
|
||||||
|
"created_at": r["created_at"].isoformat(),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{model_id}/notes", status_code=201)
|
||||||
|
async def add_note(model_id: str, body: dict, user=Depends(require_auth)):
|
||||||
|
text = (body.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(400, "text required")
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"INSERT INTO model_notes (model_id, author, text) VALUES ($1, $2, $3) RETURNING *",
|
||||||
|
uuid.UUID(model_id),
|
||||||
|
user.get("username") or "unknown",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"author": row["author"],
|
||||||
|
"text": row["text"],
|
||||||
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
}
|
||||||
75
ml/routers/pages.py
Normal file
75
ml/routers/pages.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Pagine HTML servite direttamente da ml.mebboat.it.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
/ redirect a /datasets (o landing console)
|
||||||
|
/datasets lista/upload dataset
|
||||||
|
/models registro modelli
|
||||||
|
/train avvia training
|
||||||
|
/test esegue test su modello trainato
|
||||||
|
/results storico e confronto risultati
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from core.auth import _verify
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(tags=["pages"])
|
||||||
|
|
||||||
|
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _user_or_redirect(request: Request):
|
||||||
|
"""Per le pagine, se non autenticato redirect al login. Ritorna user dict o RedirectResponse."""
|
||||||
|
token = request.cookies.get("auth_token")
|
||||||
|
auth = request.headers.get("authorization")
|
||||||
|
if not token and auth and auth.startswith("Bearer "):
|
||||||
|
token = auth[7:]
|
||||||
|
user = _verify(token)
|
||||||
|
if not user:
|
||||||
|
target = str(request.url)
|
||||||
|
return RedirectResponse(url=f"{settings.auth_login_url}?redirect={target}", status_code=302)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request: Request, template: str, **ctx):
|
||||||
|
user = _user_or_redirect(request)
|
||||||
|
if isinstance(user, RedirectResponse):
|
||||||
|
return user
|
||||||
|
return templates.TemplateResponse(template, {"request": request, "user": user, **ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def home(request: Request):
|
||||||
|
return RedirectResponse(url="/datasets")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/datasets", response_class=HTMLResponse)
|
||||||
|
async def page_datasets(request: Request):
|
||||||
|
return _render(request, "datasets.html", page="datasets")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_class=HTMLResponse)
|
||||||
|
async def page_models(request: Request):
|
||||||
|
return _render(request, "models.html", page="models")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/train", response_class=HTMLResponse)
|
||||||
|
async def page_train(request: Request):
|
||||||
|
return _render(request, "train.html", page="train")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/test", response_class=HTMLResponse)
|
||||||
|
async def page_test(request: Request):
|
||||||
|
return _render(request, "test.html", page="test")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/results", response_class=HTMLResponse)
|
||||||
|
async def page_results(request: Request):
|
||||||
|
return _render(request, "results.html", page="results")
|
||||||
51
ml/routers/repos.py
Normal file
51
ml/routers/repos.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""API /api/repos — proxy autenticato verso Gitea."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from core import gitea
|
||||||
|
from core.auth import require_auth
|
||||||
|
from core.model_spec import fetch_and_parse_spec
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/repos", tags=["repos"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_repos(user=Depends(require_auth)):
|
||||||
|
try:
|
||||||
|
return await gitea.list_repos()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"gitea: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{owner}/{repo}/branches")
|
||||||
|
async def branches(owner: str, repo: str, user=Depends(require_auth)):
|
||||||
|
try:
|
||||||
|
return await gitea.list_branches(f"{owner}/{repo}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"gitea: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{owner}/{repo}/commits")
|
||||||
|
async def commits(owner: str, repo: str, branch: str = Query("main"), user=Depends(require_auth)):
|
||||||
|
try:
|
||||||
|
return await gitea.list_commits(f"{owner}/{repo}", branch)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"gitea: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{owner}/{repo}/file")
|
||||||
|
async def file_raw(owner: str, repo: str, ref: str, path: str, user=Depends(require_auth)):
|
||||||
|
try:
|
||||||
|
raw = await gitea.get_file_raw(f"{owner}/{repo}", ref, path)
|
||||||
|
return {"content": raw.decode("utf-8", errors="replace"), "size": len(raw)}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(404, f"file not found: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{owner}/{repo}/spec")
|
||||||
|
async def spec(owner: str, repo: str, ref: str = Query("main"), user=Depends(require_auth)):
|
||||||
|
s = await fetch_and_parse_spec(f"{owner}/{repo}", ref)
|
||||||
|
if s is None:
|
||||||
|
raise HTTPException(404, "model.yml not found at ref")
|
||||||
|
return s
|
||||||
89
ml/routers/results.py
Normal file
89
ml/routers/results.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""API /api/results — lista trainings/tests + compare multi-training."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from core import db, influx_client
|
||||||
|
from core.auth import require_auth
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/results", tags=["results"])
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r):
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
d = dict(r)
|
||||||
|
for k in ("queued_at", "started_at", "finished_at", "started_at", "ended_at"):
|
||||||
|
if d.get(k) is not None and hasattr(d[k], "isoformat"):
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_results(
|
||||||
|
model_id: Optional[str] = Query(None),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
where = []
|
||||||
|
args: list = []
|
||||||
|
if model_id:
|
||||||
|
args.append(uuid.UUID(model_id))
|
||||||
|
where.append(f"model_id = ${len(args)}")
|
||||||
|
sql = "SELECT * FROM trainings"
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
sql += " ORDER BY finished_at DESC NULLS LAST, queued_at DESC LIMIT 200"
|
||||||
|
rows = await db.fetch(sql, *args)
|
||||||
|
return {"count": len(rows), "trainings": [_row(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{training_id}")
|
||||||
|
async def get_result(training_id: str, user=Depends(require_auth)):
|
||||||
|
row = await db.fetchrow("SELECT * FROM trainings WHERE id = $1", uuid.UUID(training_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
# timeseries via Influx: loss per iter + cpu/mem
|
||||||
|
flux = (
|
||||||
|
f'from(bucket:"{settings.influx_bucket}") '
|
||||||
|
f'|> range(start:-90d) '
|
||||||
|
f'|> filter(fn: (r) => r._measurement == "ml_training" and r.training_id == "{training_id}")'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ts = await influx_client.query_flux(flux)
|
||||||
|
except Exception:
|
||||||
|
ts = []
|
||||||
|
return {"training": _row(row), "timeseries": ts}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compare")
|
||||||
|
async def compare(
|
||||||
|
trainings: str = Query(..., description="comma-separated training IDs"),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
ids = [s.strip() for s in trainings.split(",") if s.strip()]
|
||||||
|
if len(ids) < 2:
|
||||||
|
raise HTTPException(400, "at least 2 training IDs required")
|
||||||
|
out = []
|
||||||
|
for tid in ids:
|
||||||
|
try:
|
||||||
|
tid_uuid = uuid.UUID(tid)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
row = await db.fetchrow("SELECT * FROM trainings WHERE id = $1", tid_uuid)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
flux = (
|
||||||
|
f'from(bucket:"{settings.influx_bucket}") '
|
||||||
|
f'|> range(start:-90d) '
|
||||||
|
f'|> filter(fn: (r) => r._measurement == "ml_training" and r.training_id == "{tid}")'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ts = await influx_client.query_flux(flux)
|
||||||
|
except Exception:
|
||||||
|
ts = []
|
||||||
|
out.append({"training": _row(row), "timeseries": ts})
|
||||||
|
return {"results": out}
|
||||||
109
ml/routers/tests.py
Normal file
109
ml/routers/tests.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""API /api/tests — sessioni di test su training esistente (max 2 utenti simultanei)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from core import api_client, db, minio_client
|
||||||
|
from core.auth import require_auth
|
||||||
|
from core.docker_runner import run_test_once
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tests", tags=["tests"])
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r):
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
d = dict(r)
|
||||||
|
for k in ("started_at", "ended_at"):
|
||||||
|
if d.get(k) is not None and hasattr(d[k], "isoformat"):
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", status_code=201)
|
||||||
|
async def start_session(body: dict, user=Depends(require_auth)):
|
||||||
|
training_id = body.get("training_id")
|
||||||
|
if not training_id:
|
||||||
|
raise HTTPException(400, "training_id required")
|
||||||
|
|
||||||
|
tr = await db.fetchrow(
|
||||||
|
"SELECT id, status FROM trainings WHERE id = $1", uuid.UUID(training_id)
|
||||||
|
)
|
||||||
|
if not tr:
|
||||||
|
raise HTTPException(404, "training not found")
|
||||||
|
if tr["status"] != "succeeded":
|
||||||
|
raise HTTPException(409, "training not completed")
|
||||||
|
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
try:
|
||||||
|
await api_client.page_connect("test", user.get("username") or "unknown", sid)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(429, "test slots full (max 2 users)")
|
||||||
|
raise HTTPException(502, f"api: {e}")
|
||||||
|
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"INSERT INTO tests (id, training_id, user_id) VALUES ($1,$2,$3) RETURNING *",
|
||||||
|
uuid.UUID(sid),
|
||||||
|
uuid.UUID(training_id),
|
||||||
|
user.get("username") or "unknown",
|
||||||
|
)
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/ping")
|
||||||
|
async def ping_session(session_id: str, user=Depends(require_auth)):
|
||||||
|
try:
|
||||||
|
await api_client.page_ping(session_id)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise HTTPException(e.response.status_code, e.response.text)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/runs", status_code=201)
|
||||||
|
async def run_test(session_id: str, body: dict, user=Depends(require_auth)):
|
||||||
|
row = await db.fetchrow("SELECT * FROM tests WHERE id = $1", uuid.UUID(session_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "session not found")
|
||||||
|
|
||||||
|
inputs = body.get("inputs") or {}
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
result = await run_test_once(str(row["training_id"]), inputs)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"test run failed: {e}")
|
||||||
|
dt_ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
|
||||||
|
run = {
|
||||||
|
"inputs": inputs,
|
||||||
|
"outputs": result.get("outputs", {}),
|
||||||
|
"duration_ms": dt_ms,
|
||||||
|
"cpu_peak": result.get("cpu_peak"),
|
||||||
|
"mem_peak_mb": result.get("mem_peak_mb"),
|
||||||
|
"ts": time.time(),
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE tests SET runs = runs || $1::jsonb WHERE id = $2",
|
||||||
|
json.dumps([run]),
|
||||||
|
uuid.UUID(session_id),
|
||||||
|
)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_id}", status_code=204)
|
||||||
|
async def end_session(session_id: str, user=Depends(require_auth)):
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE tests SET ended_at = NOW() WHERE id = $1 AND ended_at IS NULL",
|
||||||
|
uuid.UUID(session_id),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await api_client.page_disconnect(session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
129
ml/routers/trainings.py
Normal file
129
ml/routers/trainings.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""API /api/trainings — enqueue, list, get, artifacts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from core import db, minio_client, redis_client, api_client
|
||||||
|
from core.auth import require_auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/trainings", tags=["trainings"])
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r) -> Optional[dict]:
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
d = dict(r)
|
||||||
|
for k in ("queued_at", "started_at", "finished_at"):
|
||||||
|
if d.get(k) is not None and hasattr(d[k], "isoformat"):
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_trainings(
|
||||||
|
model_id: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(100, le=500),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
where = []
|
||||||
|
args: list = []
|
||||||
|
if model_id:
|
||||||
|
args.append(uuid.UUID(model_id))
|
||||||
|
where.append(f"model_id = ${len(args)}")
|
||||||
|
if status:
|
||||||
|
args.append(status)
|
||||||
|
where.append(f"status = ${len(args)}")
|
||||||
|
sql = "SELECT * FROM trainings"
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
args.append(limit)
|
||||||
|
sql += f" ORDER BY queued_at DESC LIMIT ${len(args)}"
|
||||||
|
rows = await db.fetch(sql, *args)
|
||||||
|
return {"count": len(rows), "trainings": [_row(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=202)
|
||||||
|
async def enqueue_training(body: dict, user=Depends(require_auth)):
|
||||||
|
for k in ("model_id", "version", "patch", "dataset_id"):
|
||||||
|
if not body.get(k):
|
||||||
|
raise HTTPException(400, f"missing field: {k}")
|
||||||
|
|
||||||
|
model_row = await db.fetchrow("SELECT * FROM models WHERE id = $1", uuid.UUID(body["model_id"]))
|
||||||
|
if not model_row:
|
||||||
|
raise HTTPException(404, "model not found")
|
||||||
|
|
||||||
|
ds_row = await db.fetchrow("SELECT id FROM datasets WHERE id = $1", uuid.UUID(body["dataset_id"]))
|
||||||
|
if not ds_row:
|
||||||
|
raise HTTPException(404, "dataset not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
training_row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO trainings (model_id, version, patch, dataset_id, started_by, status)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,'queued')
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
uuid.UUID(body["model_id"]),
|
||||||
|
body["version"],
|
||||||
|
body["patch"],
|
||||||
|
uuid.UUID(body["dataset_id"]),
|
||||||
|
user.get("username") or "unknown",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(409, f"training already exists or invalid: {e}")
|
||||||
|
|
||||||
|
training_id = str(training_row["id"])
|
||||||
|
|
||||||
|
# crea job lato api-service (cross-service registry)
|
||||||
|
try:
|
||||||
|
await api_client.create_job(
|
||||||
|
"train",
|
||||||
|
created_by=user.get("username") or "unknown",
|
||||||
|
payload={
|
||||||
|
"training_id": training_id,
|
||||||
|
"model_id": body["model_id"],
|
||||||
|
"version": body["version"],
|
||||||
|
"patch": body["patch"],
|
||||||
|
"dataset_id": body["dataset_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# non-fatale: il worker locale può comunque procedere; logghiamo e continuiamo
|
||||||
|
import logging
|
||||||
|
logging.warning("create_job failed: %s", e)
|
||||||
|
|
||||||
|
# enqueue in Redis (il worker locale lo raccoglie)
|
||||||
|
await redis_client.client().lpush("ml:queue:train", training_id)
|
||||||
|
await redis_client.client().hset(
|
||||||
|
f"ml:train:{training_id}",
|
||||||
|
mapping={"status": "queued", "progress": "0", "message": "queued"},
|
||||||
|
)
|
||||||
|
await redis_client.client().expire(f"ml:train:{training_id}", 48 * 3600)
|
||||||
|
|
||||||
|
return _row(training_row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{training_id}")
|
||||||
|
async def get_training(training_id: str, user=Depends(require_auth)):
|
||||||
|
row = await db.fetchrow("SELECT * FROM trainings WHERE id = $1", uuid.UUID(training_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{training_id}/artifacts")
|
||||||
|
async def list_artifacts(training_id: str, user=Depends(require_auth)):
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"SELECT artifacts_prefix FROM trainings WHERE id = $1", uuid.UUID(training_id)
|
||||||
|
)
|
||||||
|
if not row or not row["artifacts_prefix"]:
|
||||||
|
raise HTTPException(404, "no artifacts")
|
||||||
|
objs = minio_client.list_prefix(row["artifacts_prefix"] + "/")
|
||||||
|
for o in objs:
|
||||||
|
o["url"] = minio_client.presigned_get(o["name"], 3600)
|
||||||
|
return objs
|
||||||
64
ml/routers/trainings_stream.py
Normal file
64
ml/routers/trainings_stream.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""SSE endpoint per live progress del training.
|
||||||
|
|
||||||
|
GET /api/trainings/{id}/events
|
||||||
|
Streamma eventi dal Redis stream `ml:train:{id}:events` via Server-Sent Events.
|
||||||
|
Termina quando lo stato del training è terminale (succeeded/failed/cancelled).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
|
from core import db, redis_client
|
||||||
|
from core.auth import require_auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/trainings", tags=["trainings-sse"])
|
||||||
|
|
||||||
|
_TERMINAL = {"succeeded", "failed", "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{training_id}/events")
|
||||||
|
async def training_events(training_id: str, user=Depends(require_auth)):
|
||||||
|
# verifica esistenza
|
||||||
|
row = await db.fetchrow("SELECT status FROM trainings WHERE id = $1", uuid.UUID(training_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
|
||||||
|
stream_key = f"ml:train:{training_id}:events"
|
||||||
|
status_key = f"ml:train:{training_id}"
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
last_id = "0-0"
|
||||||
|
r = redis_client.client()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# XREAD block 5s per non tenere la connessione idle troppo a lungo
|
||||||
|
resp = await r.xread({stream_key: last_id}, count=50, block=5000)
|
||||||
|
except Exception as e:
|
||||||
|
yield {"event": "error", "data": json.dumps({"error": str(e)})}
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resp:
|
||||||
|
for _stream, entries in resp:
|
||||||
|
for entry_id, fields in entries:
|
||||||
|
last_id = entry_id
|
||||||
|
yield {"event": "message", "id": entry_id, "data": json.dumps(fields)}
|
||||||
|
|
||||||
|
# controlla stato terminale
|
||||||
|
state = await r.hget(status_key, "status")
|
||||||
|
if not state:
|
||||||
|
# fallback su db se redis scaduto
|
||||||
|
db_row = await db.fetchrow(
|
||||||
|
"SELECT status FROM trainings WHERE id = $1", uuid.UUID(training_id)
|
||||||
|
)
|
||||||
|
state = db_row["status"] if db_row else "unknown"
|
||||||
|
if state in _TERMINAL:
|
||||||
|
yield {"event": "end", "data": json.dumps({"status": state})}
|
||||||
|
return
|
||||||
|
|
||||||
|
return EventSourceResponse(gen())
|
||||||
18
ml/runner/Dockerfile
Normal file
18
ml/runner/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
numpy pandas scikit-learn \
|
||||||
|
xgboost \
|
||||||
|
matplotlib \
|
||||||
|
pyyaml
|
||||||
|
|
||||||
|
COPY sdk.py /opt/meb/meb_ml.py
|
||||||
|
ENV PYTHONPATH=/opt/meb
|
||||||
|
|
||||||
|
WORKDIR /workdir
|
||||||
|
CMD ["bash"]
|
||||||
80
ml/runner/sdk.py
Normal file
80
ml/runner/sdk.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""meb_ml — SDK importabile dal codice utente dentro il container runner.
|
||||||
|
|
||||||
|
API:
|
||||||
|
from meb_ml import emit_metric, emit_series, emit_matrix, emit_log, save_artifact
|
||||||
|
|
||||||
|
emit_metric(iter=10, loss=0.23)
|
||||||
|
emit_series("roc_curve", x=fpr, y=tpr, kind="line")
|
||||||
|
emit_matrix("confusion", labels=[...], values=[[...],[...]])
|
||||||
|
emit_log("info", "epoch done")
|
||||||
|
|
||||||
|
Scrive righe JSON su stdout; il parent (ml-service) le inoltra su Redis/Influx.
|
||||||
|
Per risultati finali scrivere `out/metrics.json` con:
|
||||||
|
{"metrics": {...}, "plots": {"loss_curve": {"x": [...], "y": [...]}, ...}}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def _print(obj: dict) -> None:
|
||||||
|
sys.stdout.write(json.dumps(obj, default=float) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def emit_metric(**fields: Any) -> None:
|
||||||
|
_print({"type": "metric", **fields})
|
||||||
|
|
||||||
|
|
||||||
|
def emit_series(name: str, x: Sequence, y: Sequence, kind: str = "line") -> None:
|
||||||
|
_print({
|
||||||
|
"type": "series",
|
||||||
|
"name": name,
|
||||||
|
"kind": kind,
|
||||||
|
"x": list(x),
|
||||||
|
"y": list(y),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def emit_matrix(name: str, labels: Sequence, values: Sequence[Sequence]) -> None:
|
||||||
|
_print({
|
||||||
|
"type": "matrix",
|
||||||
|
"name": name,
|
||||||
|
"labels": list(labels),
|
||||||
|
"values": [list(row) for row in values],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def emit_log(level: str, message: str) -> None:
|
||||||
|
_print({"type": "log", "level": level, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
def save_artifact(path: str) -> str:
|
||||||
|
"""Copia `path` nella cartella artefatti (MEB_ARTIFACTS_DIR). Ritorna la dest."""
|
||||||
|
dest_dir = Path(os.environ.get("MEB_ARTIFACTS_DIR", "/workdir/out"))
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
src = Path(path)
|
||||||
|
dest = dest_dir / src.name
|
||||||
|
dest.write_bytes(src.read_bytes())
|
||||||
|
return str(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def dataset_path() -> str:
|
||||||
|
return os.environ["MEB_DATASET_PATH"]
|
||||||
|
|
||||||
|
|
||||||
|
def artifacts_dir() -> str:
|
||||||
|
return os.environ.get("MEB_ARTIFACTS_DIR", "/workdir/out")
|
||||||
|
|
||||||
|
|
||||||
|
def read_test_input() -> dict:
|
||||||
|
"""Legge un singolo JSON da stdin (per script di test)."""
|
||||||
|
return json.loads(sys.stdin.readline())
|
||||||
|
|
||||||
|
|
||||||
|
def write_test_output(outputs: dict) -> None:
|
||||||
|
_print({"type": "result", "outputs": outputs})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user