feat: Add new API endpoints and HTML pages for ML model management
- Implemented HTML pages for datasets, models, training, testing, and results. - Created API endpoints for managing repositories, results, tests, and training sessions. - Added functionality for streaming training progress via Server-Sent Events (SSE). - Introduced a Dockerfile for the ML runner with necessary dependencies. - Developed an SDK for user code execution within the runner container. - Enhanced CSS styles for improved UI layout and navigation. - Established a layout template for consistent HTML structure across pages. - Added JavaScript for dynamic interactions on the models page. - Implemented WebSocket handling for real-time communication with kiosk devices and controllers. - Implemented model registration and management API at /api/models - Added Gitea proxy API for repository interactions at /api/repos - Created results API for listing and comparing training results at /api/results - Developed training management API for enqueueing and retrieving training jobs at /api/trainings - Introduced SSE endpoint for live training progress updates - Added HTML pages for models, datasets, and training management - Created a Dockerfile for the ML runner with necessary dependencies - Developed SDK for user code execution within the runner container - Enhanced CSS styles for improved UI/UX - Implemented WebSocket communication for real-time device and controller interactions in the kiosk system
This commit is contained in:
193
api/package-lock.json
generated
193
api/package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -63,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",
|
||||||
@@ -123,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",
|
||||||
@@ -170,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",
|
||||||
@@ -229,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",
|
||||||
@@ -663,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",
|
||||||
@@ -821,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",
|
||||||
@@ -875,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",
|
||||||
@@ -890,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",
|
||||||
@@ -1085,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",
|
||||||
@@ -1424,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",
|
||||||
@@ -1486,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",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,12 @@ 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);
|
||||||
|
|
||||||
|
const kioskSensorRoutes = require('./routes/kiosk.sensor');
|
||||||
|
app.use('/kiosk/sensor', kioskSensorRoutes);
|
||||||
|
|
||||||
|
const kioskPublicRoutes = require('./routes/kiosk.public');
|
||||||
|
app.use('/kiosk', kioskPublicRoutes);
|
||||||
|
|
||||||
// Middleware di autenticazione per tutte le API protette
|
// Middleware di autenticazione per tutte le API protette
|
||||||
app.use(requireAuth);
|
app.use(requireAuth);
|
||||||
|
|
||||||
@@ -75,6 +81,27 @@ app.use('/settings', settingsRoutes)
|
|||||||
const sessionsRoutes = require('./routes/sessions')
|
const sessionsRoutes = require('./routes/sessions')
|
||||||
app.use('/sessions', sessionsRoutes)
|
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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
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;
|
||||||
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;
|
||||||
@@ -60,7 +60,9 @@ async function query(bucket, relativeTime, measurement, sensor, field) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionBucket = 'boat';
|
// 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.
|
* Query storica per una sessione di registrazione.
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,11 @@ const pools = {
|
|||||||
users: new Pool({ ...config, database: process.env.USERS_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' }),
|
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]) => {
|
||||||
@@ -73,6 +78,10 @@ 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);
|
console.log("Checking PostgreSQL connections with config:", config);
|
||||||
|
|||||||
@@ -66,6 +66,30 @@ app.get('/sessions', renderPage('sessions', {
|
|||||||
mapboxToken: process.env.MAPBOX_TOKEN || ''
|
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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<link rel="stylesheet" href="/static/styles/style.css">
|
<link rel="stylesheet" href="../static/styles/style.css">
|
||||||
<link rel="stylesheet" href="/static/styles/dashboard.css">
|
<link rel="stylesheet" href="../static/styles/dashboard.css">
|
||||||
<script>
|
|
||||||
// Detect and apply dark mode immediately to prevent flash
|
|
||||||
const THEME_KEY = 'meb-console-theme';
|
|
||||||
const saved = localStorage.getItem(THEME_KEY);
|
|
||||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark-mode');
|
|
||||||
document.body.classList.add('dark-mode');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -21,7 +11,6 @@
|
|||||||
<h1></h1>
|
<h1></h1>
|
||||||
|
|
||||||
<div class="profile">
|
<div class="profile">
|
||||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
|
||||||
<p id="username">username</p>
|
<p id="username">username</p>
|
||||||
<a href="/settings" title="Impostazioni">Impostazioni</a>
|
<a href="/settings" title="Impostazioni">Impostazioni</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,14 +19,17 @@
|
|||||||
<div class="info-panel">
|
<div class="info-panel">
|
||||||
<div class="icon">🚤</div>
|
<div class="icon">🚤</div>
|
||||||
<h3>Benvenuto nella MEB Console</h3>
|
<h3>Benvenuto nella MEB Console</h3>
|
||||||
<p>Usa uno dei tool a disposizione per visualizzare i dati dalla barca in tempo reale, creare nuovi dataset condivisi e </p>
|
<p>Usa uno dei tool a disposizione per visualizzare i dati dalla barca in tempo reale, creare nuovi dataset
|
||||||
|
condivisi e </p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="grid">
|
|
||||||
|
|
||||||
<div style="display: block; text-align: center; padding: 20px 20px;">
|
|
||||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 10px; font-weight:900">Trasmissione e Analisi dati</p>
|
<!-- Trasmissione e Analisi dati -->
|
||||||
</div>
|
<div class="category">
|
||||||
|
<h2>Trasmissione e Analisi dati</h2>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
|
||||||
<a class="card standalone" href="/live" title="Live">
|
<a class="card standalone" href="/live" title="Live">
|
||||||
<div>
|
<div>
|
||||||
@@ -60,36 +52,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="card" href="/kioskedit" title="Kiosk">
|
<a class="card disabled" href="/kioskedit" title="Kiosk">
|
||||||
<div>
|
<div>
|
||||||
<h3>Kiosk</h3>
|
<h3><span class="badge">In sviluppo</span>Kiosk</h3>
|
||||||
<p>Modifica cosa vede il pilota a bordo in tempo reale.</p>
|
<p>Modifica cosa vede il pilota a bordo in tempo reale.</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a class="card" href="https://grafana.mebboat.it" title="Kiosk">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div style="display: block; text-align: center; padding: 20px 20px;">
|
|
||||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 10px; font-weight:900">Dati & Risorse</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<a class="card" href="/rulesets" title="Rulesets">
|
|
||||||
<div>
|
|
||||||
<h3>Rulesets</h3>
|
|
||||||
<p>Gestisci i template di configurazione per weather, data e logs.</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="card" href="/marine" title="Copernicus">
|
|
||||||
<div>
|
|
||||||
<h3>Copernicus</h3>
|
|
||||||
<p>Accedi alle risorse sui dati di Copernicus e crea dataset personalizzati.</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="page-card" href="{{ GRAFANA_URL }}" title="Grafana">
|
|
||||||
<div class="page-icon">
|
<div class="page-icon">
|
||||||
<svg width="47" height="51" viewBox="0 0 47 51" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="47" height="51" viewBox="0 0 47 51" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
@@ -103,76 +74,115 @@
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Grafana</h3>
|
<h3>Grafana</h3>
|
||||||
<p class="page-desc">Spostati sulla Dashboard di Grafana per visualizzare grafici e dati.</p>
|
<p>Spostati sulla Dashboard di Grafana per visualizzare grafici e dati.</p>
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div style="display: block; text-align: center; padding: 20px 20px;">
|
|
||||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 10px; font-weight:900">ML</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<a class="card" href="/dataset" title="Datasets">
|
|
||||||
<div>
|
|
||||||
<h3>Dataset</h3>
|
|
||||||
<p>Gestisci i dataset disponibili</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="card" href="/marine" title="Copernicus">
|
|
||||||
<div>
|
|
||||||
<h3>Documentazione</h3>
|
|
||||||
<p>Consulta i file Markdown di documentazione sul server e risorse aggiuntive sui modelli ML.</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="card" href="/roadmap" title="Roadmap">
|
|
||||||
<div>
|
|
||||||
<h3>Diario di Bordo</h3>
|
|
||||||
<p>Tieni traccia delle modifiche, dei progressi e dei cambiamenti del progetto.</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="page-card" href="https://git.mebboat.it" title="Repositories">
|
|
||||||
<div class="page-icon">
|
|
||||||
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M45.1326 20.9508L25.0485 0.867502C23.8926 -0.289167 22.0166 -0.289167 20.8592 0.867502L16.6887 5.03813L21.9792 10.3288C23.2087 9.91332 24.6185 10.192 25.5985 11.1721C26.5832 12.1584 26.8599 13.5803 26.4344 14.8137L31.5331 19.9126C32.7666 19.4876 34.1899 19.7625 35.1752 20.7494C36.5521 22.1258 36.5521 24.3567 35.1752 25.734C33.7978 27.1115 31.567 27.1115 30.189 25.734C29.1533 24.6972 28.8974 23.1761 29.4217 21.9001L24.6668 17.1452L24.6663 29.6583C25.011 29.8287 25.3258 30.0539 25.5985 30.3249C26.9755 31.7014 26.9755 33.9324 25.5985 35.3108C24.2212 36.6876 21.9893 36.6876 20.614 35.3108C19.237 33.9324 19.237 31.7016 20.614 30.3251C20.9439 29.995 21.3361 29.7335 21.7677 29.5559V16.9261C21.3355 16.7496 20.9431 16.488 20.614 16.1569C19.5707 15.115 19.3199 13.5842 19.8544 12.3032L14.6392 7.08732L0.867892 20.8579C-0.289297 22.0157 -0.289297 23.8918 0.867892 25.0487L20.9523 45.1324C22.1086 46.2892 23.9842 46.2892 25.1419 45.1324L45.1324 25.1419C46.2892 23.9847 46.2894 22.1075 45.1326 20.9508Z"
|
|
||||||
fill="url(#paint0_linear_9_18)" />
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_9_18" x1="12.7363" y1="1.40878" x2="32.7756" y2="40.3367"
|
|
||||||
gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#DE4C36" />
|
|
||||||
<stop offset="1" stop-color="#FF8D7C" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Repositories</h3>
|
|
||||||
<p class="page-desc">Controlla le repositories di codice di tutte le componenti del server, del plugin e
|
|
||||||
dei modelli ML
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
|
||||||
|
|
||||||
<script src="/static/theme-toggle.js"></script>
|
|
||||||
<script>
|
<!-- Dati & Risorse -->
|
||||||
// Theme toggle button event listener
|
<div class="category">
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
<h2>Dati & Risorse</h2>
|
||||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
|
||||||
if (themeBtn) {
|
<section>
|
||||||
themeBtn.addEventListener('click', toggleDarkMode);
|
|
||||||
}
|
<a class="card disabled" href="/rulesets" title="Rulesets">
|
||||||
});
|
<div>
|
||||||
</script>
|
|
||||||
|
<h3><span class="badge">In sviluppo</span>Rulesets</h3>
|
||||||
|
<p>Gestisci i template di configurazione per weather, data e logs.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
<a class="card" href="/documentation" title="Documentazione">
|
||||||
|
<div>
|
||||||
|
<h3>Documentazione</h3>
|
||||||
|
<p>Consulta i file Markdown di documentazione sul server e risorse aggiuntive sui modelli ML.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card standalone disabled " href="/roadmap" title="Roadmap">
|
||||||
|
<div>
|
||||||
|
<h3><span class="badge">In sviluppo</span>Diario di Bordo</h3>
|
||||||
|
<p>Tieni traccia delle modifiche, dei progressi e dei cambiamenti del progetto.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card" href="https://git.mebboat.it" title="Kiosk">
|
||||||
|
|
||||||
|
<div class="page-icon">
|
||||||
|
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M45.1326 20.9508L25.0485 0.867502C23.8926 -0.289167 22.0166 -0.289167 20.8592 0.867502L16.6887 5.03813L21.9792 10.3288C23.2087 9.91332 24.6185 10.192 25.5985 11.1721C26.5832 12.1584 26.8599 13.5803 26.4344 14.8137L31.5331 19.9126C32.7666 19.4876 34.1899 19.7625 35.1752 20.7494C36.5521 22.1258 36.5521 24.3567 35.1752 25.734C33.7978 27.1115 31.567 27.1115 30.189 25.734C29.1533 24.6972 28.8974 23.1761 29.4217 21.9001L24.6668 17.1452L24.6663 29.6583C25.011 29.8287 25.3258 30.0539 25.5985 30.3249C26.9755 31.7014 26.9755 33.9324 25.5985 35.3108C24.2212 36.6876 21.9893 36.6876 20.614 35.3108C19.237 33.9324 19.237 31.7016 20.614 30.3251C20.9439 29.995 21.3361 29.7335 21.7677 29.5559V16.9261C21.3355 16.7496 20.9431 16.488 20.614 16.1569C19.5707 15.115 19.3199 13.5842 19.8544 12.3032L14.6392 7.08732L0.867892 20.8579C-0.289297 22.0157 -0.289297 23.8918 0.867892 25.0487L20.9523 45.1324C22.1086 46.2892 23.9842 46.2892 25.1419 45.1324L45.1324 25.1419C46.2892 23.9847 46.2894 22.1075 45.1326 20.9508Z"
|
||||||
|
fill="url(#paint0_linear_9_18)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_9_18" x1="12.7363" y1="1.40878" x2="32.7756"
|
||||||
|
y2="40.3367" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#DE4C36" />
|
||||||
|
<stop offset="1" stop-color="#FF8D7C" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Repositories</h3>
|
||||||
|
<p>Controlla le repositories di codice di tutte le componenti del server, del plugin e dei modelli ML
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card" href="/marine" title="Copernicus">
|
||||||
|
<div>
|
||||||
|
<h3>Copernicus</h3>
|
||||||
|
<p>Accedi alle risorse sui dati di Copernicus e crea dataset personalizzati.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ML -->
|
||||||
|
<div class="category">
|
||||||
|
<h2>ML</h2>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<a class="card" href="/dataset" title="Datasets">
|
||||||
|
<div>
|
||||||
|
<h3>Datasets</h3>
|
||||||
|
<p>Gestisci i dataset disponibili</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card disabled" href="/helmremote" title="Timone">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h3><span class="badge">In sviluppo</span><span class="title-text">Test del Timone</span></h3>
|
||||||
|
<p>Scegli un modello ML e simula in tempo reale i suggerimenti di sterzate in base alle condizioni di navigazione</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card standalone" href="https://ml.mebboat.it/" title="ML Console">
|
||||||
|
<div>
|
||||||
|
<h3>Console ML</h3>
|
||||||
|
<p>Accedi alla console ML per effettuare test, training, agire sul codice e confrontare i risultati.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
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>
|
||||||
@@ -9,16 +9,6 @@
|
|||||||
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
|
<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/style.css">
|
||||||
<link rel="stylesheet" href="../static/styles/kiosk.css">
|
<link rel="stylesheet" href="../static/styles/kiosk.css">
|
||||||
<script>
|
|
||||||
// Detect and apply dark mode immediately to prevent flash
|
|
||||||
const THEME_KEY = 'meb-console-theme';
|
|
||||||
const saved = localStorage.getItem(THEME_KEY);
|
|
||||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark-mode');
|
|
||||||
document.body.classList.add('dark-mode');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -48,7 +38,6 @@
|
|||||||
<p id="cardCount">0 cards</p>
|
<p id="cardCount">0 cards</p>
|
||||||
|
|
||||||
<button id="editBtn">Edit</button>
|
<button id="editBtn">Edit</button>
|
||||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
|
||||||
<button id="addCardBtn" title="Aggiungi Widget">+</button>
|
<button id="addCardBtn" title="Aggiungi Widget">+</button>
|
||||||
<button id="addMapBtn" title="Aggiungi Mappa">Map</button>
|
<button id="addMapBtn" title="Aggiungi Mappa">Map</button>
|
||||||
|
|
||||||
@@ -567,15 +556,5 @@ ws.onclose = () => {
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="../static/theme-toggle.js"></script>
|
|
||||||
<script>
|
|
||||||
// Theme toggle button event listener
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
|
||||||
if (themeBtn) {
|
|
||||||
themeBtn.addEventListener('click', toggleDarkMode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</html>
|
</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>
|
||||||
@@ -11,16 +11,6 @@
|
|||||||
.expanded-chart-container { display: none; }
|
.expanded-chart-container { display: none; }
|
||||||
.comparison-sidebar { display: none; }
|
.comparison-sidebar { display: none; }
|
||||||
</style>
|
</style>
|
||||||
<script>
|
|
||||||
// Detect and apply dark mode immediately to prevent flash
|
|
||||||
const THEME_KEY = 'meb-console-theme';
|
|
||||||
const saved = localStorage.getItem(THEME_KEY);
|
|
||||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark-mode');
|
|
||||||
document.body.classList.add('dark-mode');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -60,7 +50,6 @@
|
|||||||
<p class="last-update" id="lastUpdateText">In attesa di dati...</p>
|
<p class="last-update" id="lastUpdateText">In attesa di dati...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile">
|
<div class="profile">
|
||||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
|
||||||
<p id="sensorName">Sensore</p>
|
<p id="sensorName">Sensore</p>
|
||||||
<button id="changeSessionBtn">Cambia sessione</button>
|
<button id="changeSessionBtn">Cambia sessione</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -788,14 +777,3 @@ document.getElementById('saveSessionLabelBtn').onclick = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/theme-toggle.js"></script>
|
|
||||||
<script>
|
|
||||||
// Theme toggle button event listener
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
|
||||||
if (themeBtn) {
|
|
||||||
themeBtn.addEventListener('click', toggleDarkMode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</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>
|
||||||
@@ -4,16 +4,6 @@
|
|||||||
<link rel="stylesheet" href="/static/styles/style.css">
|
<link rel="stylesheet" href="/static/styles/style.css">
|
||||||
<link rel="stylesheet" href="/static/styles/rulesets.css">
|
<link rel="stylesheet" href="/static/styles/rulesets.css">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<script>
|
|
||||||
// Detect and apply dark mode immediately to prevent flash
|
|
||||||
const THEME_KEY = 'meb-console-theme';
|
|
||||||
const saved = localStorage.getItem(THEME_KEY);
|
|
||||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark-mode');
|
|
||||||
document.body.classList.add('dark-mode');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -27,14 +17,14 @@
|
|||||||
</a>
|
</a>
|
||||||
<h1>Rulesets</h1>
|
<h1>Rulesets</h1>
|
||||||
<div class="rs-type-picker" id="typePicker">
|
<div class="rs-type-picker" id="typePicker">
|
||||||
<button class="active" data-type="weather">Weather</button>
|
<button class="active" data-type="logs">Logs</button>
|
||||||
<button data-type="laterforecasts">Forecasts</button>
|
<button data-type="forecast_current">Forecast · Current</button>
|
||||||
<button data-type="data">Data</button>
|
<button data-type="forecast_hourly">Forecast · Hourly</button>
|
||||||
<button data-type="logs">Logs</button>
|
<button data-type="marine_current">Marine · Current</button>
|
||||||
|
<button data-type="marine_hourly">Marine · Hourly</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rs-header-right">
|
<div class="rs-header-right">
|
||||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
|
||||||
<span class="rs-saving" id="savingIndicator">Salvato</span>
|
<span class="rs-saving" id="savingIndicator">Salvato</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +41,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="rs-toolbar-right">
|
<div class="rs-toolbar-right">
|
||||||
<button class="rs-new-btn" id="newRuleBtn">+ Nuova Rule</button>
|
<button class="rs-new-btn" id="newRuleBtn">+ Nuova Versione</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,9 +68,15 @@
|
|||||||
<div class="rs-section">
|
<div class="rs-section">
|
||||||
<div class="rs-field-row">
|
<div class="rs-field-row">
|
||||||
<span class="rs-field-label">Versione</span>
|
<span class="rs-field-label">Versione</span>
|
||||||
<input class="rs-field-input" id="popupVersion" placeholder="1.0.0" />
|
<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" id="popupDescRow" style="display:none">
|
</div>
|
||||||
|
<div class="rs-field-row">
|
||||||
<span class="rs-field-label">Descrizione</span>
|
<span class="rs-field-label">Descrizione</span>
|
||||||
<textarea class="rs-field-textarea" id="popupDesc" placeholder="Descrizione opzionale..."></textarea>
|
<textarea class="rs-field-textarea" id="popupDesc" placeholder="Descrizione opzionale..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,6 +109,21 @@
|
|||||||
<div class="rs-item-labels" id="itemLabelsRow"></div>
|
<div class="rs-item-labels" id="itemLabelsRow"></div>
|
||||||
<div id="itemsList"></div>
|
<div id="itemsList"></div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,47 +144,89 @@
|
|||||||
const API_URL = '{{ apiUrl }}';
|
const API_URL = '{{ apiUrl }}';
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
let currentType = 'weather';
|
const TYPES = ['logs','forecast_current','forecast_hourly','marine_current','marine_hourly'];
|
||||||
|
let currentType = 'logs';
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let currentSort = 'created_at';
|
let currentSort = 'created_at';
|
||||||
let allRules = [];
|
let allRules = [];
|
||||||
let openRule = null; // rule attualmente aperta nel popup
|
let openRule = null;
|
||||||
let saveTimers = {};
|
let saveTimers = {};
|
||||||
|
let sensorsCache = [];
|
||||||
|
let deploymentsForOpen = []; // deployments relativi alla rule aperta
|
||||||
|
|
||||||
// --- Item field definitions per tipo ---
|
// --- 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 = {
|
const ITEM_SCHEMA = {
|
||||||
weather: [
|
|
||||||
{ key: 'group_name', label: 'Gruppo', cls: 'medium' },
|
|
||||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
|
||||||
{ key: 'name', label: 'Nome', cls: 'wide' },
|
|
||||||
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
|
||||||
],
|
|
||||||
laterforecasts: [
|
|
||||||
{ key: 'group_name', label: 'Gruppo', cls: 'medium' },
|
|
||||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
|
||||||
{ key: 'name', label: 'Nome', cls: 'wide' },
|
|
||||||
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ key: 'category', label: 'Categoria', cls: 'medium' },
|
|
||||||
{ key: 'path', label: 'Path', cls: 'wide' },
|
|
||||||
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
|
||||||
],
|
|
||||||
logs: [
|
logs: [
|
||||||
{ key: 'path', label: 'Path', cls: 'wide' },
|
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||||
{ key: 'ref', label: 'Ref', cls: 'narrow' },
|
{ key: 'path', label: 'SK Path', cls: 'wide' },
|
||||||
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
{ key: 'meta.measurement', label: 'Measurement', cls: 'medium' },
|
||||||
{ key: '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' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const HAS_DESC = { weather: true, laterforecasts: true, data: false, logs: true };
|
// ========== 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 ==========
|
// ========== API helpers ==========
|
||||||
|
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
const opts = { method, headers: {}, credentials: 'include' };
|
const opts = { method, headers: {}, credentials: 'include' };
|
||||||
if (body) {
|
if (body !== undefined) {
|
||||||
opts.headers['Content-Type'] = 'application/json';
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
@@ -198,13 +251,27 @@ async function loadRules() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function filterAndSort(rules) {
|
||||||
let filtered = rules;
|
let filtered = rules;
|
||||||
if (currentFilter === 'active') filtered = rules.filter(r => r.active && !r.archived);
|
if (currentFilter === 'active') filtered = rules.filter(r => r.active && !r.archived);
|
||||||
else if (currentFilter === 'archived') filtered = rules.filter(r => r.archived);
|
else if (currentFilter === 'archived') filtered = rules.filter(r => r.archived);
|
||||||
|
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
if (currentSort === 'version') return (b.version || '').localeCompare(a.version || '', undefined, { numeric: true });
|
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 new Date(b.created_at) - new Date(a.created_at);
|
||||||
});
|
});
|
||||||
return filtered;
|
return filtered;
|
||||||
@@ -215,7 +282,7 @@ function renderGrid() {
|
|||||||
const rules = filterAndSort(allRules);
|
const rules = filterAndSort(allRules);
|
||||||
|
|
||||||
if (rules.length === 0) {
|
if (rules.length === 0) {
|
||||||
grid.innerHTML = '<div class="rs-empty">Nessuna rule trovata</div>';
|
grid.innerHTML = '<div class="rs-empty">Nessuna versione trovata</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,13 +295,14 @@ function renderGrid() {
|
|||||||
const tags = (r.tags || []).map(t => `<span class="rs-tag">${esc(t)}</span>`).join('');
|
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 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 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 `
|
return `
|
||||||
<div class="rs-card" data-id="${r.id}" onclick="openRuleDetail('${r.id}')">
|
<div class="rs-card" data-id="${esc(r.id)}" onclick="openRuleDetail('${esc(r.id)}')">
|
||||||
<div class="rs-card-header">
|
<div class="rs-card-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="rs-card-version">v${esc(r.version)}</div>
|
<div class="rs-card-version">v${esc(versionStr(r.version))}</div>
|
||||||
<span class="rs-card-id">${esc(r.id)}</span>
|
<span class="rs-card-id" title="${esc(r.id)}">${esc(String(r.id).slice(0,8))}…</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rs-card-badges">${badges.join('')}</div>
|
<div class="rs-card-badges">${badges.join('')}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,18 +310,12 @@ function renderGrid() {
|
|||||||
${tags ? `<div class="rs-card-tags">${tags}</div>` : ''}
|
${tags ? `<div class="rs-card-tags">${tags}</div>` : ''}
|
||||||
<div class="rs-card-footer">
|
<div class="rs-card-footer">
|
||||||
<span class="rs-card-date">${date}</span>
|
<span class="rs-card-date">${date}</span>
|
||||||
|
<span class="rs-card-items">${itemsCount} items</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Type Picker ==========
|
// ========== Type Picker ==========
|
||||||
|
|
||||||
document.querySelectorAll('#typePicker button').forEach(btn => {
|
document.querySelectorAll('#typePicker button').forEach(btn => {
|
||||||
@@ -285,10 +347,20 @@ document.getElementById('sortSelect').onchange = (e) => {
|
|||||||
|
|
||||||
document.getElementById('newRuleBtn').onclick = async () => {
|
document.getElementById('newRuleBtn').onclick = async () => {
|
||||||
try {
|
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}`, {
|
const rule = await api('POST', `/rules/${currentType}`, {
|
||||||
version: '1.0.0',
|
version_major: M, version_build: B, version_patch: P,
|
||||||
tags: [],
|
tags: [], description: '', items: []
|
||||||
description: HAS_DESC[currentType] ? '' : undefined
|
|
||||||
});
|
});
|
||||||
allRules.unshift(rule);
|
allRules.unshift(rule);
|
||||||
renderGrid();
|
renderGrid();
|
||||||
@@ -296,6 +368,7 @@ document.getElementById('newRuleBtn').onclick = async () => {
|
|||||||
flash('Salvato');
|
flash('Salvato');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating rule:', err);
|
console.error('Error creating rule:', err);
|
||||||
|
alert(`Errore: ${err.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,17 +378,23 @@ async function openRuleDetail(ruleId) {
|
|||||||
try {
|
try {
|
||||||
const data = await api('GET', `/rules/${currentType}/${ruleId}`);
|
const data = await api('GET', `/rules/${currentType}/${ruleId}`);
|
||||||
openRule = data;
|
openRule = data;
|
||||||
|
deploymentsForOpen = [];
|
||||||
|
try {
|
||||||
|
deploymentsForOpen = await api('GET', `/rules/${currentType}/${ruleId}/deployments`);
|
||||||
|
} catch {}
|
||||||
|
await loadSensors();
|
||||||
renderPopup();
|
renderPopup();
|
||||||
document.getElementById('ruleOverlay').style.display = 'flex';
|
document.getElementById('ruleOverlay').style.display = 'flex';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading rule detail:', err);
|
console.error('Error loading rule detail:', err);
|
||||||
|
alert(`Errore: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePopup() {
|
function closePopup() {
|
||||||
document.getElementById('ruleOverlay').style.display = 'none';
|
document.getElementById('ruleOverlay').style.display = 'none';
|
||||||
openRule = null;
|
openRule = null;
|
||||||
loadRules(); // refresh grid
|
loadRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('popupClose').onclick = closePopup;
|
document.getElementById('popupClose').onclick = closePopup;
|
||||||
@@ -325,31 +404,24 @@ document.getElementById('ruleOverlay').onclick = (e) => {
|
|||||||
|
|
||||||
function renderPopup() {
|
function renderPopup() {
|
||||||
const r = openRule;
|
const r = openRule;
|
||||||
document.getElementById('popupId').textContent = r.id;
|
document.getElementById('popupId').textContent = `${currentType} · ${String(r.id).slice(0,8)}`;
|
||||||
document.getElementById('popupVersion').value = r.version || '';
|
document.getElementById('popupVMajor').value = r.version?.major ?? 1;
|
||||||
|
document.getElementById('popupVBuild').value = r.version?.build ?? 0;
|
||||||
// Description
|
document.getElementById('popupVPatch').value = r.version?.patch ?? 0;
|
||||||
const descRow = document.getElementById('popupDescRow');
|
|
||||||
if (HAS_DESC[currentType]) {
|
|
||||||
descRow.style.display = 'flex';
|
|
||||||
document.getElementById('popupDesc').value = r.description || '';
|
document.getElementById('popupDesc').value = r.description || '';
|
||||||
} else {
|
|
||||||
descRow.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags
|
|
||||||
renderTags();
|
renderTags();
|
||||||
|
|
||||||
// Action buttons state
|
|
||||||
updateActionButtons();
|
updateActionButtons();
|
||||||
|
|
||||||
// Items
|
|
||||||
renderItems();
|
renderItems();
|
||||||
|
renderDeploySensors();
|
||||||
|
document.getElementById('deployResult').textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auto-save fields ---
|
// --- Auto-save fields ---
|
||||||
|
|
||||||
document.getElementById('popupVersion').oninput = () => debounceFieldSave('version');
|
['popupVMajor','popupVBuild','popupVPatch'].forEach(id => {
|
||||||
|
document.getElementById(id).oninput = () => debounceFieldSave('version');
|
||||||
|
});
|
||||||
document.getElementById('popupDesc').oninput = () => debounceFieldSave('description');
|
document.getElementById('popupDesc').oninput = () => debounceFieldSave('description');
|
||||||
|
|
||||||
function debounceFieldSave(field) {
|
function debounceFieldSave(field) {
|
||||||
@@ -360,18 +432,22 @@ function debounceFieldSave(field) {
|
|||||||
async function saveRuleField(field) {
|
async function saveRuleField(field) {
|
||||||
if (!openRule) return;
|
if (!openRule) return;
|
||||||
const body = {};
|
const body = {};
|
||||||
if (field === 'version') body.version = document.getElementById('popupVersion').value.trim();
|
if (field === 'version') {
|
||||||
if (field === 'description') body.description = document.getElementById('popupDesc').value.trim();
|
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 {
|
try {
|
||||||
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, body);
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, body);
|
||||||
Object.assign(openRule, updated);
|
Object.assign(openRule, updated);
|
||||||
// Update in allRules too
|
|
||||||
const idx = allRules.findIndex(r => r.id === openRule.id);
|
const idx = allRules.findIndex(r => r.id === openRule.id);
|
||||||
if (idx >= 0) Object.assign(allRules[idx], updated);
|
if (idx >= 0) Object.assign(allRules[idx], updated);
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving field:', err);
|
console.error('Error saving field:', err);
|
||||||
|
flash('Errore: ' + err.message, 'popupSaving');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,9 +456,7 @@ async function saveRuleField(field) {
|
|||||||
function renderTags() {
|
function renderTags() {
|
||||||
const wrap = document.getElementById('popupTags');
|
const wrap = document.getElementById('popupTags');
|
||||||
const input = document.getElementById('tagInput');
|
const input = document.getElementById('tagInput');
|
||||||
// Remove old chips
|
|
||||||
wrap.querySelectorAll('.rs-tag-chip').forEach(c => c.remove());
|
wrap.querySelectorAll('.rs-tag-chip').forEach(c => c.remove());
|
||||||
// Re-add chips before input
|
|
||||||
(openRule.tags || []).forEach((tag, i) => {
|
(openRule.tags || []).forEach((tag, i) => {
|
||||||
const chip = document.createElement('span');
|
const chip = document.createElement('span');
|
||||||
chip.className = 'rs-tag-chip';
|
chip.className = 'rs-tag-chip';
|
||||||
@@ -404,9 +478,7 @@ document.getElementById('tagInput').onkeydown = async (e) => {
|
|||||||
openRule.tags = updated.tags;
|
openRule.tags = updated.tags;
|
||||||
renderTags();
|
renderTags();
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) { console.error(err); }
|
||||||
console.error('Error adding tag:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,9 +491,7 @@ async function removeTag(idx) {
|
|||||||
openRule.tags = updated.tags;
|
openRule.tags = updated.tags;
|
||||||
renderTags();
|
renderTags();
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) { console.error(err); }
|
||||||
console.error('Error removing tag:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Action Buttons ---
|
// --- Action Buttons ---
|
||||||
@@ -430,10 +500,8 @@ function updateActionButtons() {
|
|||||||
const r = openRule;
|
const r = openRule;
|
||||||
const activeBtn = document.getElementById('toggleActiveBtn');
|
const activeBtn = document.getElementById('toggleActiveBtn');
|
||||||
const archiveBtn = document.getElementById('toggleArchiveBtn');
|
const archiveBtn = document.getElementById('toggleArchiveBtn');
|
||||||
|
|
||||||
activeBtn.textContent = r.active ? 'Disattiva' : 'Attiva';
|
activeBtn.textContent = r.active ? 'Disattiva' : 'Attiva';
|
||||||
activeBtn.className = `rs-action-btn active-toggle${r.active ? '' : ' off'}`;
|
activeBtn.className = `rs-action-btn active-toggle${r.active ? '' : ' off'}`;
|
||||||
|
|
||||||
archiveBtn.textContent = r.archived ? 'Dearchivia' : 'Archivia';
|
archiveBtn.textContent = r.archived ? 'Dearchivia' : 'Archivia';
|
||||||
archiveBtn.className = `rs-action-btn archive-toggle${r.archived ? ' on' : ''}`;
|
archiveBtn.className = `rs-action-btn archive-toggle${r.archived ? ' on' : ''}`;
|
||||||
}
|
}
|
||||||
@@ -443,10 +511,12 @@ document.getElementById('toggleActiveBtn').onclick = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/active`);
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/active`);
|
||||||
openRule.active = res.active;
|
openRule.active = res.active;
|
||||||
|
if (res.ruleset) Object.assign(openRule, res.ruleset);
|
||||||
updateActionButtons();
|
updateActionButtons();
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error toggling active:', err);
|
console.error(err);
|
||||||
|
alert(`Errore: ${err.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -455,23 +525,20 @@ document.getElementById('toggleArchiveBtn').onclick = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/archive`);
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/archive`);
|
||||||
openRule.archived = res.archived;
|
openRule.archived = res.archived;
|
||||||
|
if (res.ruleset) Object.assign(openRule, res.ruleset);
|
||||||
updateActionButtons();
|
updateActionButtons();
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
|
||||||
console.error('Error toggling archive:', err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('deleteRuleBtn').onclick = () => {
|
document.getElementById('deleteRuleBtn').onclick = () => {
|
||||||
if (!openRule) return;
|
if (!openRule) return;
|
||||||
showConfirm('Elimina Rule', `Vuoi eliminare la rule ${openRule.id}? Questa azione e irreversibile.`, async () => {
|
showConfirm('Elimina Versione', `Eliminare la versione v${versionStr(openRule.version)}? Questa azione e irreversibile.`, async () => {
|
||||||
try {
|
try {
|
||||||
await api('DELETE', `/rules/${currentType}/${openRule.id}`);
|
await api('DELETE', `/rules/${currentType}/${openRule.id}`);
|
||||||
allRules = allRules.filter(r => r.id !== openRule.id);
|
allRules = allRules.filter(r => r.id !== openRule.id);
|
||||||
closePopup();
|
closePopup();
|
||||||
} catch (err) {
|
} catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
|
||||||
console.error('Error deleting rule:', err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -481,95 +548,152 @@ function renderItems() {
|
|||||||
const schema = ITEM_SCHEMA[currentType];
|
const schema = ITEM_SCHEMA[currentType];
|
||||||
const items = openRule.items || [];
|
const items = openRule.items || [];
|
||||||
|
|
||||||
// Labels row
|
|
||||||
const labelsRow = document.getElementById('itemLabelsRow');
|
const labelsRow = document.getElementById('itemLabelsRow');
|
||||||
labelsRow.innerHTML = schema.map(f => `<span class="${f.cls}">${f.label}</span>`).join('') +
|
labelsRow.innerHTML = schema.map(f => `<span class="${f.cls}">${f.label}</span>`).join('') +
|
||||||
'<span class="toggle-space">On</span><span class="delete-space"></span>';
|
'<span class="toggle-space">On</span><span class="delete-space"></span>';
|
||||||
|
|
||||||
// Items list
|
|
||||||
const list = document.getElementById('itemsList');
|
const list = document.getElementById('itemsList');
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi" per crearne uno.</div>';
|
list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi".</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = items.map(item => {
|
list.innerHTML = items.map(item => {
|
||||||
const fields = schema.map(f =>
|
const fields = schema.map(f =>
|
||||||
`<input class="rs-item-field ${f.cls}" value="${esc(String(item[f.key] || ''))}" data-field="${f.key}" data-item-id="${item.id}" onchange="saveItemField(this)" />`
|
`<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('');
|
).join('');
|
||||||
|
|
||||||
const toggleCls = item.enabled ? 'on' : '';
|
const toggleCls = item.enabled !== false ? 'on' : '';
|
||||||
return `<div class="rs-item" data-item-id="${item.id}">
|
return `<div class="rs-item" data-ref="${esc(item.ref)}">
|
||||||
${fields}
|
${fields}
|
||||||
<div class="rs-toggle ${toggleCls}" onclick="toggleItem(${item.id})"></div>
|
<div class="rs-toggle ${toggleCls}" onclick="toggleItem('${esc(item.ref)}')"></div>
|
||||||
<button class="rs-item-delete" onclick="deleteItem(${item.id})">×</button>
|
<button class="rs-item-delete" onclick="deleteItem('${esc(item.ref)}')">×</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addItemBtn').onclick = async () => {
|
document.getElementById('addItemBtn').onclick = async () => {
|
||||||
if (!openRule) return;
|
if (!openRule) return;
|
||||||
const schema = ITEM_SCHEMA[currentType];
|
const ref = prompt('Ref dell\'item (identificatore stabile, es. "batt_traction_soc"):');
|
||||||
const body = {};
|
if (!ref) return;
|
||||||
// Fill with empty/default values
|
|
||||||
schema.forEach(f => { body[f.key] = ''; });
|
|
||||||
|
|
||||||
// Need at least non-empty values — open with placeholders
|
|
||||||
// For now, create with placeholder values
|
|
||||||
schema.forEach(f => { body[f.key] = f.key === 'enabled' ? true : '-'; });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, body);
|
const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, {
|
||||||
|
ref: ref.trim(), path: '', enabled: true, meta: {}
|
||||||
|
});
|
||||||
if (!openRule.items) openRule.items = [];
|
if (!openRule.items) openRule.items = [];
|
||||||
openRule.items.push(item);
|
openRule.items.push(item);
|
||||||
renderItems();
|
renderItems();
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error adding item:', err);
|
console.error(err);
|
||||||
|
alert(`Errore: ${err.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function saveItemField(input) {
|
async function saveItemField(input) {
|
||||||
if (!openRule) return;
|
if (!openRule) return;
|
||||||
const itemId = input.dataset.itemId;
|
const ref = input.dataset.ref;
|
||||||
const field = input.dataset.field;
|
const field = input.dataset.field;
|
||||||
const value = input.value.trim();
|
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 {
|
try {
|
||||||
await api('PUT', `/rules/${currentType}/${openRule.id}/items/${itemId}`, { [field]: value });
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`, body);
|
||||||
// Update local
|
// replace item in place
|
||||||
const item = openRule.items.find(i => String(i.id) === String(itemId));
|
const idx = openRule.items.findIndex(i => i.ref === ref);
|
||||||
if (item) item[field] = value;
|
if (idx >= 0) openRule.items[idx] = updated;
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving item field:', err);
|
console.error(err);
|
||||||
|
flash('Errore', 'popupSaving');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleItem(itemId) {
|
async function toggleItem(ref) {
|
||||||
if (!openRule) return;
|
if (!openRule) return;
|
||||||
try {
|
try {
|
||||||
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${itemId}/toggle`);
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}/toggle`);
|
||||||
const item = openRule.items.find(i => i.id === itemId);
|
const item = openRule.items.find(i => i.ref === ref);
|
||||||
if (item) item.enabled = res.enabled;
|
if (item) item.enabled = res.enabled;
|
||||||
renderItems();
|
renderItems();
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) { console.error(err); }
|
||||||
console.error('Error toggling item:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteItem(itemId) {
|
async function deleteItem(ref) {
|
||||||
if (!openRule) return;
|
if (!openRule) return;
|
||||||
try {
|
try {
|
||||||
await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${itemId}`);
|
await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`);
|
||||||
openRule.items = openRule.items.filter(i => i.id !== itemId);
|
openRule.items = openRule.items.filter(i => i.ref !== ref);
|
||||||
renderItems();
|
renderItems();
|
||||||
flash('Salvato', 'popupSaving');
|
flash('Salvato', 'popupSaving');
|
||||||
} catch (err) {
|
} catch (err) { console.error(err); }
|
||||||
console.error('Error deleting item:', 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 ==========
|
// ========== Confirm Dialog ==========
|
||||||
|
|
||||||
let confirmCallback = null;
|
let confirmCallback = null;
|
||||||
@@ -592,7 +716,7 @@ document.getElementById('confirmOk').onclick = async () => {
|
|||||||
confirmCallback = null;
|
confirmCallback = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== Flash "Salvato" indicator ==========
|
// ========== Flash ==========
|
||||||
|
|
||||||
function flash(text, elId = 'savingIndicator') {
|
function flash(text, elId = 'savingIndicator') {
|
||||||
const el = document.getElementById(elId);
|
const el = document.getElementById(elId);
|
||||||
@@ -603,18 +727,10 @@ function flash(text, elId = 'savingIndicator') {
|
|||||||
|
|
||||||
// ========== Init ==========
|
// ========== Init ==========
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => loadRules());
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script src="/static/theme-toggle.js"></script>
|
|
||||||
<script>
|
|
||||||
// Theme toggle button event listener
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
loadRules();
|
||||||
if (themeBtn) {
|
loadSensors();
|
||||||
themeBtn.addEventListener('click', toggleDarkMode);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -244,16 +244,6 @@
|
|||||||
.map-bar .filter button { color: #cbd5e1; }
|
.map-bar .filter button { color: #cbd5e1; }
|
||||||
.map-bar .filter button.active { background: #3b82f6; color: #fff; }
|
.map-bar .filter button.active { background: #3b82f6; color: #fff; }
|
||||||
</style>
|
</style>
|
||||||
<script>
|
|
||||||
// Detect and apply dark mode immediately to prevent flash
|
|
||||||
const THEME_KEY = 'meb-console-theme';
|
|
||||||
const saved = localStorage.getItem(THEME_KEY);
|
|
||||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark-mode');
|
|
||||||
document.body.classList.add('dark-mode');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -289,7 +279,6 @@
|
|||||||
<h1>Sessioni</h1>
|
<h1>Sessioni</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
|
||||||
<span id="sessionNameDisplay"></span>
|
<span id="sessionNameDisplay"></span>
|
||||||
<span id="sessionMetaDisplay"></span>
|
<span id="sessionMetaDisplay"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -929,16 +918,5 @@ document.getElementById('changeSessionBtn').onclick = () => {
|
|||||||
// --- Init ---
|
// --- Init ---
|
||||||
document.addEventListener('DOMContentLoaded', loadSessionsList);
|
document.addEventListener('DOMContentLoaded', loadSessionsList);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/theme-toggle.js"></script>
|
|
||||||
<script>
|
|
||||||
// Theme toggle button event listener
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
|
||||||
if (themeBtn) {
|
|
||||||
themeBtn.addEventListener('click', toggleDarkMode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -769,3 +769,81 @@
|
|||||||
.rs-back:hover {
|
.rs-back:hover {
|
||||||
color: var(--accent-color);
|
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);
|
||||||
@@ -32,7 +31,7 @@
|
|||||||
--text-secondary: #cbd5e1;
|
--text-secondary: #cbd5e1;
|
||||||
--text-tertiary: #94a3b8;
|
--text-tertiary: #94a3b8;
|
||||||
|
|
||||||
--surface: #0f172a;
|
--surface: #000000;
|
||||||
|
|
||||||
--header-bg: rgba(15, 23, 42, 0.85);
|
--header-bg: rgba(15, 23, 42, 0.85);
|
||||||
--header-border: #334155;
|
--header-border: #334155;
|
||||||
@@ -42,26 +41,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Manual dark mode toggle */
|
|
||||||
body.dark-mode {
|
|
||||||
--accent-color: #3b82f6;
|
|
||||||
--accent-hover: #2563eb;
|
|
||||||
--accent-light: #1e3a8a;
|
|
||||||
--accent-border: #1e40af;
|
|
||||||
|
|
||||||
--text-primary: #f1f5f9;
|
|
||||||
--text-secondary: #cbd5e1;
|
|
||||||
--text-tertiary: #94a3b8;
|
|
||||||
|
|
||||||
--surface: #0f172a;
|
|
||||||
|
|
||||||
--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 */
|
/* Smooth transition for dark mode */
|
||||||
body {
|
body {
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
@@ -192,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;
|
||||||
}
|
}
|
||||||
@@ -206,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,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dark Mode Theme Toggle
|
|
||||||
* Manages light/dark theme with localStorage persistence
|
|
||||||
*/
|
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = 'meb-console-theme';
|
|
||||||
const DARK_MODE_CLASS = 'dark-mode';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize theme on page load
|
|
||||||
*/
|
|
||||||
function initializeTheme() {
|
|
||||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
|
||||||
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
|
|
||||||
// Use saved preference, or fallback to system preference
|
|
||||||
const shouldBeDark = savedTheme ? savedTheme === 'dark' : prefersDarkMode;
|
|
||||||
|
|
||||||
if (shouldBeDark) {
|
|
||||||
enableDarkMode();
|
|
||||||
} else {
|
|
||||||
disableDarkMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable dark mode
|
|
||||||
*/
|
|
||||||
function enableDarkMode() {
|
|
||||||
document.documentElement.classList.add(DARK_MODE_CLASS);
|
|
||||||
document.body.classList.add(DARK_MODE_CLASS);
|
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, 'dark');
|
|
||||||
updateThemeToggleButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable dark mode (light mode)
|
|
||||||
*/
|
|
||||||
function disableDarkMode() {
|
|
||||||
document.documentElement.classList.remove(DARK_MODE_CLASS);
|
|
||||||
document.body.classList.remove(DARK_MODE_CLASS);
|
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, 'light');
|
|
||||||
updateThemeToggleButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle dark mode
|
|
||||||
*/
|
|
||||||
function toggleDarkMode() {
|
|
||||||
const isDarkMode = document.body.classList.contains(DARK_MODE_CLASS);
|
|
||||||
|
|
||||||
if (isDarkMode) {
|
|
||||||
disableDarkMode();
|
|
||||||
} else {
|
|
||||||
enableDarkMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update theme toggle button appearance
|
|
||||||
*/
|
|
||||||
function updateThemeToggleButton() {
|
|
||||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
|
||||||
if (themeBtn) {
|
|
||||||
const isDarkMode = document.body.classList.contains(DARK_MODE_CLASS);
|
|
||||||
themeBtn.textContent = isDarkMode ? '☀️' : '🌙';
|
|
||||||
themeBtn.title = isDarkMode ? 'Light Mode' : 'Dark Mode';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listen for system theme preference changes
|
|
||||||
*/
|
|
||||||
function listenToSystemThemeChanges() {
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
||||||
if (!localStorage.getItem(THEME_STORAGE_KEY)) {
|
|
||||||
// If no manual preference set, follow system preference
|
|
||||||
e.matches ? enableDarkMode() : disableDarkMode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on load
|
|
||||||
document.addEventListener('DOMContentLoaded', initializeTheme);
|
|
||||||
listenToSystemThemeChanges();
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -106,8 +106,12 @@ services:
|
|||||||
context: ./ml
|
context: ./ml
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: uvicorn main:app --host 0.0.0.0 --port 3007 --reload
|
||||||
volumes:
|
volumes:
|
||||||
- ./ml:/app
|
- ./ml:/app
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ml_tmp:/var/ml/tmp
|
||||||
|
- ml_gitcache:/var/ml/gitcache
|
||||||
env_file:
|
env_file:
|
||||||
- ./ml/.env
|
- ./ml/.env
|
||||||
networks:
|
networks:
|
||||||
@@ -117,34 +121,43 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.ml.rule=Host(`ml.${DOMAIN:-mebboat.it}`)"
|
- "traefik.http.routers.ml.rule=Host(`ml.${DOMAIN:-mebboat.it}`)"
|
||||||
- "traefik.http.routers.ml.entrypoints=websecure"
|
- "traefik.http.routers.ml.entrypoints=websecure"
|
||||||
- "traefik.http.services.ml.loadbalancer.server.port=8000"
|
- "traefik.http.services.ml.loadbalancer.server.port=3007"
|
||||||
- "traefik.http.routers.ml.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.ml.tls.certresolver=letsencrypt"
|
||||||
- "traefik.docker.network=meb-public"
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
# marine:
|
copernicus:
|
||||||
# container_name: marine-service
|
container_name: copernicus-service
|
||||||
# build:
|
build:
|
||||||
# context: ./marine
|
context: ./copernicus
|
||||||
# dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# restart: unless-stopped
|
restart: unless-stopped
|
||||||
# volumes:
|
volumes:
|
||||||
# - ./marine:/app
|
- ./copernicus:/app
|
||||||
# env_file:
|
- copernicus_cache:/app/cache
|
||||||
# - ./marine/.env
|
env_file:
|
||||||
# environment:
|
- ./copernicus/.env
|
||||||
# - REDIS_HOST=meb-redis
|
environment:
|
||||||
# - REDIS_PORT=6379
|
- REDIS_HOST=meb-redis
|
||||||
# networks:
|
- REDIS_PORT=6379
|
||||||
# - meb-proxy-net
|
- API_SERVICE_URL=http://api:3003
|
||||||
# - meb-internal
|
- CACHE_DIR=/app/cache
|
||||||
# labels:
|
- MINIO_ENDPOINT=minio
|
||||||
# - "traefik.enable=true"
|
- MINIO_PORT=9000
|
||||||
# - "traefik.http.routers.marine.rule=Host(`api.${DOMAIN:-mebboat.it}`) && PathPrefix(`/marine`)"
|
networks:
|
||||||
# - "traefik.http.routers.marine.entrypoints=web"
|
- meb-public
|
||||||
# - "traefik.http.services.marine.loadbalancer.server.port=8001"
|
- meb-private
|
||||||
# - "traefik.docker.network=meb-proxy-net"
|
labels:
|
||||||
# - "traefik.http.middlewares.marine-strip.stripprefix.prefixes=/marine"
|
- "traefik.enable=true"
|
||||||
# - "traefik.http.routers.marine.middlewares=marine-strip"
|
# 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
|
||||||
@@ -184,3 +197,8 @@ networks:
|
|||||||
external: true
|
external: true
|
||||||
meb-private:
|
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}")
|
||||||
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,3 +1,15 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn[standard]
|
||||||
PyJWT
|
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})
|
||||||
146
ml/static/styles/ml.css
Normal file
146
ml/static/styles/ml.css
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
.ml-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ml-nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.ml-nav a:hover { background: var(--accent-light); color: var(--accent-color); }
|
||||||
|
.ml-nav a.active { background: var(--accent-light); color: var(--accent-color); }
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.page-head h2 { font-size: 1.5rem; }
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.list .item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: #fff;
|
||||||
|
transition: box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.list .item:hover { box-shadow: var(--shadow-md); }
|
||||||
|
.list .meta { color: var(--text-secondary); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-row label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.form-row input, .form-row select, .form-row textarea {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.queue-info {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
background: #fff;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.detail #btn-close-detail {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
width: min(500px, 90vw);
|
||||||
|
}
|
||||||
|
dialog form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
dialog label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
|
||||||
|
dialog menu { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; padding: 0; }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
th, td { padding: 8px 12px; border-bottom: 1px solid var(--header-border); text-align: left; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: auto;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
33
ml/templates/_layout.html
Normal file
33
ml/templates/_layout.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ML — {% block title %}{{ page|capitalize }}{% endblock %}</title>
|
||||||
|
<link href="/static/styles/style.css" rel="stylesheet">
|
||||||
|
<link href="/static/styles/ml.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Modelli ML</h1>
|
||||||
|
<nav class="ml-nav">
|
||||||
|
<a href="/datasets" class="{% if page=='datasets' %}active{% endif %}">Datasets</a>
|
||||||
|
<a href="/models" class="{% if page=='models' %}active{% endif %}">Modelli</a>
|
||||||
|
<a href="/train" class="{% if page=='train' %}active{% endif %}">Train</a>
|
||||||
|
<a href="/test" class="{% if page=='test' %}active{% endif %}">Test</a>
|
||||||
|
<a href="/results" class="{% if page=='results' %}active{% endif %}">Results</a>
|
||||||
|
</nav>
|
||||||
|
<div class="profile">
|
||||||
|
<p id="username">{{ user.username }}</p>
|
||||||
|
<button id="logout-btn">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% block title %}Datasets{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Datasets</h2>
|
||||||
|
<button class="prominent" id="btn-upload">+ Carica CSV</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="datasets-list" class="list"></div>
|
||||||
|
|
||||||
|
<dialog id="upload-dlg">
|
||||||
|
<form id="upload-form" method="dialog">
|
||||||
|
<h3>Carica dataset</h3>
|
||||||
|
<label>Nome<input type="text" name="nome" required></label>
|
||||||
|
<label>Tipo
|
||||||
|
<select name="dataset_type">
|
||||||
|
<option value="custom">custom</option>
|
||||||
|
<option value="imported">imported</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Formato
|
||||||
|
<select name="format">
|
||||||
|
<option value="csv">csv</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Tags (virgola)<input type="text" name="tags"></label>
|
||||||
|
<label>Descrizione<textarea name="description"></textarea></label>
|
||||||
|
<label>File<input type="file" name="file" required></label>
|
||||||
|
<menu>
|
||||||
|
<button type="button" id="upload-cancel">Annulla</button>
|
||||||
|
<button type="submit" class="prominent">Carica</button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/datasets.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
57
ml/templates/models.html
Normal file
57
ml/templates/models.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% block title %}Modelli{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Modelli</h2>
|
||||||
|
<button class="prominent" id="btn-add-model">+ Aggiungi modello</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="models-list" class="list"></div>
|
||||||
|
|
||||||
|
<div id="model-detail" class="detail hidden">
|
||||||
|
<button id="btn-close-detail">×</button>
|
||||||
|
<h3 id="md-name"></h3>
|
||||||
|
<p id="md-meta"></p>
|
||||||
|
<section>
|
||||||
|
<h4>Branch / Commits</h4>
|
||||||
|
<select id="md-branch"></select>
|
||||||
|
<ul id="md-commits"></ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>model.yml</h4>
|
||||||
|
<pre id="md-spec"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>Note</h4>
|
||||||
|
<ul id="md-notes"></ul>
|
||||||
|
<form id="md-note-form">
|
||||||
|
<textarea name="text" placeholder="Nuova nota"></textarea>
|
||||||
|
<button type="submit" class="prominent">Aggiungi</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="add-model-dlg">
|
||||||
|
<form id="add-model-form" method="dialog">
|
||||||
|
<h3>Nuovo modello</h3>
|
||||||
|
<label>Nome<input type="text" name="name" required></label>
|
||||||
|
<label>Tipo
|
||||||
|
<select name="type">
|
||||||
|
<option>xgboost</option>
|
||||||
|
<option>lstm</option>
|
||||||
|
<option>sklearn</option>
|
||||||
|
<option>other</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Repo Gitea (owner/repo)<input type="text" name="gitea_repo" required></label>
|
||||||
|
<label>Branch<input type="text" name="default_branch" value="main"></label>
|
||||||
|
<menu>
|
||||||
|
<button type="button" id="add-model-cancel">Annulla</button>
|
||||||
|
<button type="submit" class="prominent">Crea</button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/models.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,89 +1,33 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "_layout.html" %}
|
||||||
|
{% block title %}Risultati{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Risultati training</h2>
|
||||||
|
<button id="btn-compare" class="prominent">Confronta selezionati</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<html>
|
<div id="results-list" class="list"></div>
|
||||||
<head>
|
|
||||||
<title>Risultati</title>
|
|
||||||
<link href="../static/styles/style.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<style>
|
<section id="compare-panel" class="hidden">
|
||||||
.container {
|
<h3>Confronto</h3>
|
||||||
display: flex;
|
<div class="charts">
|
||||||
flex-direction: column;
|
<canvas id="cmp-loss"></canvas>
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker .header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<h1>Risultati</h1>
|
|
||||||
<div class="profile">
|
|
||||||
<p>Utente</p>
|
|
||||||
<button>Logout</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<table id="cmp-table"></table>
|
||||||
|
<div id="cmp-plots"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="detail-panel" class="hidden">
|
||||||
|
<h3>Dettaglio training <code id="dt-id"></code></h3>
|
||||||
|
<div id="dt-meta"></div>
|
||||||
|
<div class="charts">
|
||||||
|
<canvas id="dt-loss"></canvas>
|
||||||
|
<canvas id="dt-res"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="dt-plots"></div>
|
||||||
<div class="container">
|
</section>
|
||||||
|
{% endblock %}
|
||||||
<div class="picker">
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<div class="header">
|
<script src="/static/js/results.js"></script>
|
||||||
<h2>
|
{% endblock %}
|
||||||
Seleziona
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
una sessione di training eseguita per visualizzarne i risultati
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>sessione 1</h3>
|
|
||||||
<div class="train-info">
|
|
||||||
<p>24/03/2026</p>
|
|
||||||
<p>12:00</p>
|
|
||||||
<p>dataset: d-1</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>sessione 2</h3>
|
|
||||||
<p>24/03/2026</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% block title %}Test{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Test modello</h2>
|
||||||
|
<div id="slot-info" class="queue-info">Slot: <span id="slot-count">–</span>/2</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="slot-full" class="info-panel hidden">
|
||||||
|
<div class="icon">🚧</div>
|
||||||
|
<h3>Slot test pieni</h3>
|
||||||
|
<p>Massimo 2 utenti possono eseguire test contemporaneamente. Riprova tra qualche minuto.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="test-start" class="form-row">
|
||||||
|
<label>Modello<select id="t-model"></select></label>
|
||||||
|
<label>Training<select id="t-training"></select></label>
|
||||||
|
<button type="submit" class="prominent">Avvia sessione</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section id="test-session" class="hidden">
|
||||||
|
<h3>Sessione <code id="ts-id"></code></h3>
|
||||||
|
<form id="inputs-form"></form>
|
||||||
|
<button id="btn-run" class="prominent">Esegui test</button>
|
||||||
|
<button id="btn-end">Chiudi sessione</button>
|
||||||
|
|
||||||
|
<h4>Risultati</h4>
|
||||||
|
<div id="runs-list"></div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/test.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% block title %}Train{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Avvia training</h2>
|
||||||
|
<div class="queue-info">Coda: <span id="queue-count">–</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="train-form" class="form-row">
|
||||||
|
<label>Modello<select name="model_id" id="f-model"></select></label>
|
||||||
|
<label>Branch<select name="branch" id="f-branch"></select></label>
|
||||||
|
<label>Commit<select name="patch" id="f-patch"></select></label>
|
||||||
|
<label>Versione<input type="text" name="version" placeholder="1.0.0" required></label>
|
||||||
|
<label>Dataset<select name="dataset_id" id="f-dataset"></select></label>
|
||||||
|
<button type="submit" class="prominent">Avvia</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section id="live-panel" class="hidden">
|
||||||
|
<h3>Training <code id="live-id"></code> — <span id="live-status">queued</span></h3>
|
||||||
|
<div class="charts">
|
||||||
|
<canvas id="chart-loss"></canvas>
|
||||||
|
<canvas id="chart-cpu"></canvas>
|
||||||
|
</div>
|
||||||
|
<pre id="live-logs" class="logs"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Recenti</h3>
|
||||||
|
<div id="recent-trainings" class="list"></div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="/static/js/train.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
153
realtime/package-lock.json
generated
153
realtime/package-lock.json
generated
@@ -11,8 +11,10 @@
|
|||||||
"@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",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
}
|
}
|
||||||
@@ -84,6 +86,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -162,6 +170,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
@@ -220,6 +247,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -525,18 +561,103 @@
|
|||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash.defaults": {
|
"node_modules/lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isarguments": {
|
"node_modules/lodash.isarguments": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -876,12 +997,44 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
|
|||||||
@@ -45,9 +45,13 @@ app.get('/health', (req, res) => {
|
|||||||
app.use('/connect', require('./routes/connect'));
|
app.use('/connect', require('./routes/connect'));
|
||||||
app.use('/sensors', require('./routes/sensors'));
|
app.use('/sensors', require('./routes/sensors'));
|
||||||
app.use('/sessions', require('./routes/sessions'));
|
app.use('/sessions', require('./routes/sessions'));
|
||||||
|
app.use('/rules', require('./routes/rules'));
|
||||||
|
|
||||||
const server = app.listen(3000, '0.0.0.0', () => {
|
const server = app.listen(3000, '0.0.0.0', () => {
|
||||||
console.log(`Realtime started`);
|
console.log(`Realtime started`);
|
||||||
});
|
});
|
||||||
|
|
||||||
wsHandler.setup(server);
|
wsHandler.setup(server);
|
||||||
|
|
||||||
|
// deve essere caricato DOPO setup per avere kioskRelay pronto
|
||||||
|
app.use('/kiosk', require('./routes/kiosk'));
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const db = require('../store/db');
|
const { kioskRelay } = require('../ws/handler');
|
||||||
|
|
||||||
// Endpoint per ricevere dati dal kiosk
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
|
||||||
router.post('/data', async (req, res) => {
|
|
||||||
const { session_id, sensor_code, value, timestamp } = req.body;
|
function requireInternal(req, res, next) {
|
||||||
if (!session_id || !sensor_code || value === undefined) {
|
if (!INTERNAL_KEY || req.headers['x-api-key'] !== INTERNAL_KEY)
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chiamato dall'API quando cambia il template attivo
|
||||||
|
router.post('/notify-active', requireInternal, (req, res) => {
|
||||||
|
const { template } = req.body || {};
|
||||||
|
if (!template || !template.id) return res.status(400).json({ error: 'template.id required' });
|
||||||
|
kioskRelay.notifyActiveTemplateChange(template);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stato dispositivi connessi (diagnostica)
|
||||||
|
router.get('/status', requireInternal, (req, res) => {
|
||||||
|
const list = [];
|
||||||
|
for (const [name, ws] of kioskRelay.devices) {
|
||||||
|
list.push({ sensor: name, templateId: ws.templateId || null, lastSeen: ws.lastSeen || null });
|
||||||
}
|
}
|
||||||
|
res.json({ devices: list });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
57
realtime/src/routes/rules.js
Normal file
57
realtime/src/routes/rules.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Relay HTTP → WS per il push dei rulesets ai sensori.
|
||||||
|
* Chiamato SOLO dal servizio api (internal, x-api-key).
|
||||||
|
*
|
||||||
|
* POST /rules/push
|
||||||
|
* Body: { sensors: [name, ...], type, ruleset }
|
||||||
|
* -> invia msgpack { _t: 'ruleset_update', type, ruleset } ad ogni sensore
|
||||||
|
* online tramite la connessione WS gia' stabilita.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const { encode } = require('@msgpack/msgpack');
|
||||||
|
const { connectedSensors } = require('../ws/handler');
|
||||||
|
|
||||||
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
|
||||||
|
function requireInternal(req, res, next) {
|
||||||
|
const k = req.headers['x-api-key'];
|
||||||
|
if (!INTERNAL_KEY || !k || k !== INTERNAL_KEY) {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/push', requireInternal, (req, res) => {
|
||||||
|
const { sensors, type, ruleset } = req.body || {};
|
||||||
|
if (!Array.isArray(sensors) || !sensors.length) return res.status(400).json({ error: 'sensors array required' });
|
||||||
|
if (!type || !ruleset) return res.status(400).json({ error: 'type and ruleset required' });
|
||||||
|
|
||||||
|
const payload = { _t: 'ruleset_update', type, ruleset };
|
||||||
|
let encoded;
|
||||||
|
try {
|
||||||
|
encoded = encode(payload);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: `encode error: ${err.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushed = [], offline = [], errors = [];
|
||||||
|
for (const name of sensors) {
|
||||||
|
const ws = connectedSensors.get(name);
|
||||||
|
if (!ws || ws.readyState !== ws.OPEN) {
|
||||||
|
offline.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.send(encoded);
|
||||||
|
pushed.push(name);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ sensor: name, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[RULES] push type=${type} v=${ruleset?.version?.str || '?'} → pushed=${pushed.length} offline=${offline.length} err=${errors.length}`);
|
||||||
|
res.json({ pushed, offline, errors });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -5,16 +5,40 @@ const client = new InfluxDB({
|
|||||||
token: process.env.INFLX_TOKEN,
|
token: process.env.INFLX_TOKEN,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bucket = process.env.INFLX_BUCKET || 'logs';
|
|
||||||
const org = process.env.INFLX_ORG;
|
const org = process.env.INFLX_ORG;
|
||||||
|
|
||||||
const writeApi = client.getWriteApi(org, bucket, 'ms', {
|
// Bucket dedicati per dominio. Il default per i logs viene mantenuto su
|
||||||
flushInterval: 100,
|
// INFLX_BUCKET per retro-compatibilità con la configurazione esistente.
|
||||||
batchSize: 50,
|
// Per i dati meteo current e forecast usiamo bucket separati: sono dati
|
||||||
});
|
// indipendenti dai logs (frequenze e retention diverse) e tenerli separati
|
||||||
|
// permette policy di retention più aggressive per i forecast (timestamp
|
||||||
|
// futuri sovrascritti spesso) senza toccare il volume dei logs.
|
||||||
|
const BUCKETS = {
|
||||||
|
logs: process.env.INFLX_BUCKET_LOGS || process.env.INFLX_BUCKET || 'logs',
|
||||||
|
weather: process.env.INFLX_BUCKET_WEATHER || 'weather_current',
|
||||||
|
weather_forecast: process.env.INFLX_BUCKET_FORECAST || 'weather_forecast',
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeApis = {};
|
||||||
|
function getWriteApi(bucket) {
|
||||||
|
if (!writeApis[bucket]) {
|
||||||
|
writeApis[bucket] = client.getWriteApi(org, bucket, 'ms', {
|
||||||
|
flushInterval: 1000,
|
||||||
|
batchSize: 200,
|
||||||
|
maxRetries: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return writeApis[bucket];
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketFor(measurement) {
|
||||||
|
if (measurement === 'weather') return BUCKETS.weather;
|
||||||
|
if (measurement === 'weather_forecast') return BUCKETS.weather_forecast;
|
||||||
|
return BUCKETS.logs;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrive dati generici su InfluxDB senza mapping.
|
* Scrive dati generici su InfluxDB nel bucket appropriato per il measurement.
|
||||||
* @param {string} measurement - nome della measurement (es. 'logs', 'weather')
|
* @param {string} measurement - nome della measurement (es. 'logs', 'weather')
|
||||||
* @param {Object} fields - campi { key: value }
|
* @param {Object} fields - campi { key: value }
|
||||||
* @param {string} sensor - nome del sensore
|
* @param {string} sensor - nome del sensore
|
||||||
@@ -36,11 +60,12 @@ function writeGenericData(measurement, fields, sensor, session, timestamp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeApi.writePoint(point);
|
getWriteApi(bucketFor(measurement)).writePoint(point);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrive un batch di punti forecast (previsioni orarie).
|
* Scrive un batch di punti forecast (previsioni orarie).
|
||||||
|
* Usa il bucket weather_forecast (non i logs).
|
||||||
* @param {Array} points - array di [timestamp_ms, { key: value, ... }]
|
* @param {Array} points - array di [timestamp_ms, { key: value, ... }]
|
||||||
* @param {string} sensor - nome del sensore
|
* @param {string} sensor - nome del sensore
|
||||||
* @param {string} session - id sessione
|
* @param {string} session - id sessione
|
||||||
@@ -52,14 +77,14 @@ function writeForecastBatch(points, sensor, session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forza il flush del buffer di scrittura.
|
* Forza il flush dei buffer di scrittura su tutti i bucket.
|
||||||
*/
|
*/
|
||||||
async function flush() {
|
async function flush() {
|
||||||
try {
|
await Promise.all(Object.values(writeApis).map(async (wa) => {
|
||||||
await writeApi.flush();
|
try { await wa.flush(); } catch (err) {
|
||||||
} catch (err) {
|
|
||||||
console.error('[INFLUX] Flush error:', err.message);
|
console.error('[INFLUX] Flush error:', err.message);
|
||||||
}
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,7 +97,7 @@ async function flush() {
|
|||||||
async function queryHistory(sensor, session, since) {
|
async function queryHistory(sensor, session, since) {
|
||||||
const queryApi = client.getQueryApi(org);
|
const queryApi = client.getQueryApi(org);
|
||||||
const fluxQuery = `
|
const fluxQuery = `
|
||||||
from(bucket: "${bucket}")
|
from(bucket: "${BUCKETS.logs}")
|
||||||
|> range(start: ${since})
|
|> range(start: ${since})
|
||||||
|> filter(fn: (r) => r._measurement == "logs")
|
|> filter(fn: (r) => r._measurement == "logs")
|
||||||
|> filter(fn: (r) => r.sensor == "${sensor}")
|
|> filter(fn: (r) => r.sensor == "${sensor}")
|
||||||
@@ -95,10 +120,6 @@ async function queryHistory(sensor, session, since) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Esporta tutti i dati di una sessione come CSV.
|
* Esporta tutti i dati di una sessione come CSV.
|
||||||
* @param {string} sensor - nome sensore
|
|
||||||
* @param {string} session - session_id
|
|
||||||
* @param {string} since - ISO timestamp inizio (opzionale, default -30d)
|
|
||||||
* @returns {string} CSV content
|
|
||||||
*/
|
*/
|
||||||
async function exportSessionCSV(sensor, session, since) {
|
async function exportSessionCSV(sensor, session, since) {
|
||||||
const start = since || '-30d';
|
const start = since || '-30d';
|
||||||
@@ -106,7 +127,6 @@ async function exportSessionCSV(sensor, session, since) {
|
|||||||
|
|
||||||
if (rows.length === 0) return '';
|
if (rows.length === 0) return '';
|
||||||
|
|
||||||
// Raccogli tutti i field names (esclusi meta InfluxDB)
|
|
||||||
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
|
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
|
||||||
const fieldNames = new Set();
|
const fieldNames = new Set();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -133,4 +153,4 @@ async function exportSessionCSV(sensor, session, since) {
|
|||||||
return header + '\n' + csvRows.join('\n') + '\n';
|
return header + '\n' + csvRows.join('\n') + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV };
|
module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV, BUCKETS };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { decode } = require('@msgpack/msgpack');
|
|||||||
const { consumeConnectionToken, appendAsConnection, query, hset, del } = require('../store/redis');
|
const { consumeConnectionToken, appendAsConnection, query, hset, del } = require('../store/redis');
|
||||||
const { writeGenericData, writeForecastBatch } = require('../store/influx');
|
const { writeGenericData, writeForecastBatch } = require('../store/influx');
|
||||||
const db = require('../store/db');
|
const db = require('../store/db');
|
||||||
|
const kioskRelay = require('./kiosk');
|
||||||
|
|
||||||
// In-memory registries
|
// In-memory registries
|
||||||
const sensorWatchers = new Map(); // sensorName → Set<WebSocket> (watchers)
|
const sensorWatchers = new Map(); // sensorName → Set<WebSocket> (watchers)
|
||||||
@@ -42,6 +43,9 @@ function setup(server) {
|
|||||||
handleSensorConnection(ws);
|
handleSensorConnection(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} else if (path === '/kiosk') {
|
||||||
|
await kioskRelay.handleUpgrade(wss, req, socket, head, url);
|
||||||
|
|
||||||
} else if (path === '/live') {
|
} else if (path === '/live') {
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
handleWatcherConnection(ws);
|
handleWatcherConnection(ws);
|
||||||
@@ -95,6 +99,58 @@ async function handleSensorConnection(ws) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset sessione richiesto dal plugin (es. dopo un nuovo ruleset
|
||||||
|
// di logs/meteo). La connessione WS persiste: cambiamo solo il
|
||||||
|
// sessionId, marchiamo la vecchia come disconnessa e creiamo la
|
||||||
|
// nuova in sessiondataref. I dati successivi useranno il nuovo tag.
|
||||||
|
if (packet._t === 'session_reset') {
|
||||||
|
const prev = ws.sessionId;
|
||||||
|
const next = generateSessionId();
|
||||||
|
ws.sessionId = next;
|
||||||
|
console.log(`[${sensorName}] session_reset ${prev} → ${next} (reason: ${packet.reason || 'n/a'})`);
|
||||||
|
try {
|
||||||
|
await db.query('sensors',
|
||||||
|
`UPDATE sessiondataref SET disconnected_at = NOW() WHERE session_id = $1 AND disconnected_at IS NULL`,
|
||||||
|
[prev]
|
||||||
|
);
|
||||||
|
await db.query('sensors',
|
||||||
|
`INSERT INTO sessiondataref (session_id, sensor_name, name, created_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (session_id) DO NOTHING`,
|
||||||
|
[next, sensorName, next]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${sensorName}] session_reset DB error:`, err.message);
|
||||||
|
}
|
||||||
|
hset(`sensors:${sensorName}`, 'session', next);
|
||||||
|
try {
|
||||||
|
const { encode } = require('@msgpack/msgpack');
|
||||||
|
ws.send(encode({ _t: 'session_id', sessionId: next, prev }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${sensorName}] session_reset reply error:`, err.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACK di un ruleset ricevuto e applicato: il plugin ci dice
|
||||||
|
// che la versione X del tipo Y e' ora attiva sul device.
|
||||||
|
if (packet._t === 'ruleset_ack') {
|
||||||
|
const { type, ruleset_id } = packet;
|
||||||
|
if (type && ruleset_id) {
|
||||||
|
const API = process.env.API_URL || 'http://meb-api:3000';
|
||||||
|
const KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
if (KEY) {
|
||||||
|
fetch(`${API}/rules/${type}/${ruleset_id}/ack`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
|
||||||
|
body: JSON.stringify({ sensor: sensorName })
|
||||||
|
}).catch(err => console.error(`[${sensorName}] ruleset_ack forward error:`, err.message));
|
||||||
|
}
|
||||||
|
console.log(`[${sensorName}] ruleset_ack type=${type} id=${ruleset_id}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { ts, _m, ...fields } = packet;
|
const { ts, _m, ...fields } = packet;
|
||||||
|
|
||||||
// InfluxDB: usa SEMPRE sessionId come tag (non cambia mai)
|
// InfluxDB: usa SEMPRE sessionId come tag (non cambia mai)
|
||||||
@@ -203,4 +259,4 @@ function handleWatcherConnection(ws) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { setup, connectedSensors };
|
module.exports = { setup, connectedSensors, kioskRelay };
|
||||||
|
|||||||
167
realtime/src/ws/kiosk.js
Normal file
167
realtime/src/ws/kiosk.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Kiosk realtime relay.
|
||||||
|
* Due ruoli:
|
||||||
|
* - device: il plugin kiosk sulla barca (uno per sensorName)
|
||||||
|
* - controller: la pagina kiosklive.html (N per sensorName)
|
||||||
|
* Messaggi JSON (no msgpack, canale leggero).
|
||||||
|
*/
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { consumeConnectionToken } = require('../store/redis');
|
||||||
|
|
||||||
|
const devices = new Map(); // sensorName → ws
|
||||||
|
const controllers = new Map(); // sensorName → Set<ws>
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
|
||||||
|
function verifyJwt(token) {
|
||||||
|
if (!token || !JWT_SECRET) return null;
|
||||||
|
try { return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCookie(req, name) {
|
||||||
|
const raw = req.headers.cookie || '';
|
||||||
|
const m = raw.split(';').map(s => s.trim()).find(s => s.startsWith(name + '='));
|
||||||
|
return m ? decodeURIComponent(m.slice(name.length + 1)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(ws, obj) {
|
||||||
|
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastControllers(sensorName, obj) {
|
||||||
|
const set = controllers.get(sensorName);
|
||||||
|
if (!set) return;
|
||||||
|
const msg = JSON.stringify(obj);
|
||||||
|
for (const c of set) if (c.readyState === c.OPEN) c.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceStatus(sensorName) {
|
||||||
|
const d = devices.get(sensorName);
|
||||||
|
return {
|
||||||
|
t: 'kiosk_status',
|
||||||
|
online: !!d,
|
||||||
|
templateId: d?.templateId || null,
|
||||||
|
lastSeen: d?.lastSeen || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestisce l'upgrade per /kiosk?role=device|controller&sensor=<name>
|
||||||
|
* @returns true se gestito
|
||||||
|
*/
|
||||||
|
async function handleUpgrade(wss, req, socket, head, url) {
|
||||||
|
const role = url.searchParams.get('role');
|
||||||
|
const sensorName = url.searchParams.get('sensor');
|
||||||
|
|
||||||
|
if (!role || !sensorName) {
|
||||||
|
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'device') {
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
const sensor = token ? await consumeConnectionToken(token) : null;
|
||||||
|
if (!sensor || sensor !== sensorName) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return true;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
ws.sensorName = sensorName;
|
||||||
|
ws.role = 'device';
|
||||||
|
attachDevice(ws);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'controller') {
|
||||||
|
const token = extractCookie(req, 'auth_token') || url.searchParams.get('token');
|
||||||
|
const payload = verifyJwt(token);
|
||||||
|
if (!payload) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return true;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
ws.sensorName = sensorName;
|
||||||
|
ws.role = 'controller';
|
||||||
|
ws.user = payload.sub;
|
||||||
|
attachController(ws);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachDevice(ws) {
|
||||||
|
const name = ws.sensorName;
|
||||||
|
const prev = devices.get(name);
|
||||||
|
if (prev && prev.readyState === prev.OPEN) prev.close(4000, 'replaced');
|
||||||
|
devices.set(name, ws);
|
||||||
|
ws.lastSeen = Date.now();
|
||||||
|
broadcastControllers(name, deviceStatus(name));
|
||||||
|
console.log(`[kiosk] device online: ${name}`);
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let m; try { m = JSON.parse(raw.toString()); } catch { return; }
|
||||||
|
ws.lastSeen = Date.now();
|
||||||
|
switch (m.t) {
|
||||||
|
case 'hello':
|
||||||
|
ws.templateId = m.templateId || null;
|
||||||
|
broadcastControllers(name, deviceStatus(name));
|
||||||
|
break;
|
||||||
|
case 'ack':
|
||||||
|
broadcastControllers(name, m);
|
||||||
|
break;
|
||||||
|
case 'heartbeat':
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// echo diagnostici opzionali
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hb = setInterval(() => { if (ws.readyState === ws.OPEN) ws.ping(); }, 25000);
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
clearInterval(hb);
|
||||||
|
if (devices.get(name) === ws) devices.delete(name);
|
||||||
|
broadcastControllers(name, deviceStatus(name));
|
||||||
|
console.log(`[kiosk] device offline: ${name}`);
|
||||||
|
});
|
||||||
|
ws.on('error', () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachController(ws) {
|
||||||
|
const name = ws.sensorName;
|
||||||
|
if (!controllers.has(name)) controllers.set(name, new Set());
|
||||||
|
controllers.get(name).add(ws);
|
||||||
|
send(ws, deviceStatus(name));
|
||||||
|
console.log(`[kiosk] controller connected on ${name} (total=${controllers.get(name).size})`);
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let m; try { m = JSON.parse(raw.toString()); } catch { return; }
|
||||||
|
const allowed = ['patch_box','add_box','remove_box','load_template','apply_inline','persist','reload'];
|
||||||
|
if (!allowed.includes(m.t)) return;
|
||||||
|
const device = devices.get(name);
|
||||||
|
if (!device || device.readyState !== device.OPEN) {
|
||||||
|
send(ws, { t: 'ack', cmdId: m.cmdId, ok: false, err: 'device offline' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(device, m);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
const set = controllers.get(name);
|
||||||
|
if (set) { set.delete(ws); if (!set.size) controllers.delete(name); }
|
||||||
|
});
|
||||||
|
ws.on('error', () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP notify usato dall'API quando cambia template attivo */
|
||||||
|
function notifyActiveTemplateChange(template) {
|
||||||
|
for (const [name, ws] of devices) {
|
||||||
|
send(ws, { t: 'load_template', templateId: template.id });
|
||||||
|
}
|
||||||
|
for (const name of controllers.keys()) {
|
||||||
|
broadcastControllers(name, { t: 'active_template_changed', templateId: template.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleUpgrade, notifyActiveTemplateChange, devices };
|
||||||
Reference in New Issue
Block a user