From 0ce879aa4449ecfad3eacc84e11e292802ad8ac4 Mon Sep 17 00:00:00 2001
From: Giuseppe Raffa <77052701+sesee3@users.noreply.github.com>
Date: Tue, 28 Apr 2026 09:24:38 +0200
Subject: [PATCH] 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
---
api/package-lock.json | 193 ++++++
api/package.json | 1 +
api/src/index.js | 27 +
api/src/migrations/001_ml_datasets.sql | 71 ++
.../002_ml_datasets_bucket_default.sql | 5 +
api/src/migrations/003_ml_models.sql | 35 +
api/src/migrations/004_ml_trainings.sql | 26 +
api/src/migrations/005_ml_tests.sql | 17 +
api/src/migrations/006_jobs.sql | 27 +
api/src/migrations/007_kiosktemplates.sql | 79 +++
api/src/migrations/008_rulesets.sql | 124 ++++
api/src/routes/docs.js | 80 +++
api/src/routes/jobs.js | 120 ++++
api/src/routes/kiosk.js | 443 +++++++++++++
api/src/routes/kiosk.public.js | 49 ++
api/src/routes/kiosk.sensor.js | 67 ++
api/src/routes/marine.datasets.js | 232 +++++++
api/src/routes/pageconnections.js | 100 +++
api/src/routes/queue.js | 35 +
api/src/routes/rules.js | 609 ++++++++++++++++++
api/src/storage/influx.js | 4 +-
api/src/storage/minio.js | 56 +-
api/src/storage/postgres.js | 9 +
console/src/index.js | 24 +
console/src/pages/dashboard.html | 348 +++++-----
console/src/pages/documentation.html | 230 +++++++
console/src/pages/forecasts.html | 300 +++++++++
console/src/pages/kioskedit.html | 21 -
console/src/pages/kiosklive.html | 331 ++++++++++
console/src/pages/live.html | 22 -
console/src/pages/marine.html | 301 +++++++++
console/src/pages/rulesets.html | 410 +++++++-----
console/src/pages/sessions.html | 22 -
console/src/static/styles/dashboard.css | 22 +
console/src/static/styles/rulesets.css | 78 +++
console/src/static/styles/style.css | 57 +-
console/src/static/theme-toggle.js | 85 ---
copernicus/core/cache.py | 303 +++++++--
copernicus/core/copernicus.py | 41 +-
copernicus/main.py | 12 +-
copernicus/routers/jobs.py | 46 +-
copernicus/schemas.py | 2 +-
docker-compose.yml | 68 +-
ml/.env.example | 45 ++
ml/Dockerfile | 3 +
ml/core/api_client.py | 72 +++
ml/core/config.py | 64 ++
ml/core/db.py | 53 ++
ml/core/docker_runner.py | 439 +++++++++++++
ml/core/gitea.py | 57 ++
ml/core/influx_client.py | 75 +++
ml/core/minio_client.py | 118 ++++
ml/core/model_spec.py | 90 +++
ml/core/redis_client.py | 29 +
ml/core/worker.py | 54 ++
ml/main.py | 95 ++-
ml/requirements.txt | 14 +-
ml/routers/datasets.py | 160 +++++
ml/routers/models.py | 131 ++++
ml/routers/pages.py | 75 +++
ml/routers/repos.py | 51 ++
ml/routers/results.py | 89 +++
ml/routers/tests.py | 109 ++++
ml/routers/trainings.py | 129 ++++
ml/routers/trainings_stream.py | 64 ++
ml/runner/Dockerfile | 18 +
ml/runner/sdk.py | 80 +++
ml/static/styles/ml.css | 146 +++++
ml/templates/_layout.html | 33 +
ml/templates/datasets.html | 39 ++
ml/templates/models.html | 57 ++
ml/templates/results.html | 116 +---
ml/templates/test.html | 33 +
ml/templates/train.html | 35 +
realtime/package-lock.json | 153 +++++
realtime/src/index.js | 4 +
realtime/src/routes/kiosk.js | 33 +-
realtime/src/routes/rules.js | 57 ++
realtime/src/store/influx.js | 60 +-
realtime/src/ws/handler.js | 58 +-
realtime/src/ws/kiosk.js | 167 +++++
81 files changed, 7491 insertions(+), 746 deletions(-)
create mode 100644 api/src/migrations/001_ml_datasets.sql
create mode 100644 api/src/migrations/002_ml_datasets_bucket_default.sql
create mode 100644 api/src/migrations/003_ml_models.sql
create mode 100644 api/src/migrations/004_ml_trainings.sql
create mode 100644 api/src/migrations/005_ml_tests.sql
create mode 100644 api/src/migrations/006_jobs.sql
create mode 100644 api/src/migrations/007_kiosktemplates.sql
create mode 100644 api/src/migrations/008_rulesets.sql
create mode 100644 api/src/routes/docs.js
create mode 100644 api/src/routes/jobs.js
create mode 100644 api/src/routes/kiosk.js
create mode 100644 api/src/routes/kiosk.public.js
create mode 100644 api/src/routes/kiosk.sensor.js
create mode 100644 api/src/routes/marine.datasets.js
create mode 100644 api/src/routes/pageconnections.js
create mode 100644 api/src/routes/queue.js
create mode 100644 api/src/routes/rules.js
create mode 100644 console/src/pages/documentation.html
create mode 100644 console/src/pages/forecasts.html
create mode 100644 console/src/pages/kiosklive.html
create mode 100644 console/src/pages/marine.html
delete mode 100644 console/src/static/theme-toggle.js
create mode 100644 ml/core/api_client.py
create mode 100644 ml/core/config.py
create mode 100644 ml/core/db.py
create mode 100644 ml/core/docker_runner.py
create mode 100644 ml/core/gitea.py
create mode 100644 ml/core/influx_client.py
create mode 100644 ml/core/minio_client.py
create mode 100644 ml/core/model_spec.py
create mode 100644 ml/core/redis_client.py
create mode 100644 ml/core/worker.py
create mode 100644 ml/routers/datasets.py
create mode 100644 ml/routers/models.py
create mode 100644 ml/routers/pages.py
create mode 100644 ml/routers/repos.py
create mode 100644 ml/routers/results.py
create mode 100644 ml/routers/tests.py
create mode 100644 ml/routers/trainings.py
create mode 100644 ml/routers/trainings_stream.py
create mode 100644 ml/runner/Dockerfile
create mode 100644 ml/runner/sdk.py
create mode 100644 ml/static/styles/ml.css
create mode 100644 ml/templates/_layout.html
create mode 100644 ml/templates/models.html
create mode 100644 realtime/src/routes/rules.js
create mode 100644 realtime/src/ws/kiosk.js
diff --git a/api/package-lock.json b/api/package-lock.json
index 496e131..e0513db 100644
--- a/api/package-lock.json
+++ b/api/package-lock.json
@@ -17,6 +17,7 @@
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"minio": "^8.0.7",
+ "multer": "^1.4.5-lts.1",
"pg": "^8.20.0"
}
},
@@ -63,6 +64,12 @@
"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": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -123,6 +130,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -170,6 +194,51 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -229,6 +298,12 @@
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -663,6 +738,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"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": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -821,6 +902,15 @@
"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": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
@@ -875,12 +965,86 @@
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -890,6 +1054,15 @@
"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": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1085,6 +1258,12 @@
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1424,6 +1603,14 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
@@ -1486,6 +1673,12 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
diff --git a/api/package.json b/api/package.json
index daca388..8ae1ab0 100644
--- a/api/package.json
+++ b/api/package.json
@@ -18,6 +18,7 @@
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"minio": "^8.0.7",
+ "multer": "^1.4.5-lts.1",
"pg": "^8.20.0"
}
}
\ No newline at end of file
diff --git a/api/src/index.js b/api/src/index.js
index 21b218a..e53c8f2 100644
--- a/api/src/index.js
+++ b/api/src/index.js
@@ -57,6 +57,12 @@ app.get('/health', async (req, res) => {
const paramsSensorRoutes = require('./routes/params.sensor');
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
app.use(requireAuth);
@@ -75,6 +81,27 @@ app.use('/settings', settingsRoutes)
const sessionsRoutes = require('./routes/sessions')
app.use('/sessions', sessionsRoutes)
+const docsRoutes = require('./routes/docs')
+app.use('/docs', docsRoutes)
+
+const marineDatasetsRoutes = require('./routes/marine.datasets')
+app.use('/marine/datasets', marineDatasetsRoutes)
+
+const jobsRoutes = require('./routes/jobs')
+app.use('/jobs', jobsRoutes)
+
+const queueRoutes = require('./routes/queue')
+app.use('/queue', queueRoutes)
+
+const pageconnectionsRoutes = require('./routes/pageconnections')
+app.use('/pageconnections', pageconnectionsRoutes)
+
+const kioskRoutes = require('./routes/kiosk')
+app.use('/kiosk', kioskRoutes)
+
+const rulesRoutes = require('./routes/rules')
+app.use('/rules', rulesRoutes)
+
app.listen(PORT, '0.0.0.0', () => {
console.log(`Started on port ${PORT}`);
});
diff --git a/api/src/migrations/001_ml_datasets.sql b/api/src/migrations/001_ml_datasets.sql
new file mode 100644
index 0000000..50135bf
--- /dev/null
+++ b/api/src/migrations/001_ml_datasets.sql
@@ -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();
diff --git a/api/src/migrations/002_ml_datasets_bucket_default.sql b/api/src/migrations/002_ml_datasets_bucket_default.sql
new file mode 100644
index 0000000..bdbda76
--- /dev/null
+++ b/api/src/migrations/002_ml_datasets_bucket_default.sql
@@ -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.
diff --git a/api/src/migrations/003_ml_models.sql b/api/src/migrations/003_ml_models.sql
new file mode 100644
index 0000000..64d0e66
--- /dev/null
+++ b/api/src/migrations/003_ml_models.sql
@@ -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.
\ No newline at end of file
diff --git a/api/src/migrations/004_ml_trainings.sql b/api/src/migrations/004_ml_trainings.sql
new file mode 100644
index 0000000..a47003c
--- /dev/null
+++ b/api/src/migrations/004_ml_trainings.sql
@@ -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///"
+ 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);
diff --git a/api/src/migrations/005_ml_tests.sql b/api/src/migrations/005_ml_tests.sql
new file mode 100644
index 0000000..8c61549
--- /dev/null
+++ b/api/src/migrations/005_ml_tests.sql
@@ -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);
diff --git a/api/src/migrations/006_jobs.sql b/api/src/migrations/006_jobs.sql
new file mode 100644
index 0000000..5a55738
--- /dev/null
+++ b/api/src/migrations/006_jobs.sql
@@ -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();
diff --git a/api/src/migrations/007_kiosktemplates.sql b/api/src/migrations/007_kiosktemplates.sql
new file mode 100644
index 0000000..ae713c9
--- /dev/null
+++ b/api/src/migrations/007_kiosktemplates.sql
@@ -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);
diff --git a/api/src/migrations/008_rulesets.sql b/api/src/migrations/008_rulesets.sql
new file mode 100644
index 0000000..d3434a0
--- /dev/null
+++ b/api/src/migrations/008_rulesets.sql
@@ -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);
diff --git a/api/src/routes/docs.js b/api/src/routes/docs.js
new file mode 100644
index 0000000..c733017
--- /dev/null
+++ b/api/src/routes/docs.js
@@ -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;
diff --git a/api/src/routes/jobs.js b/api/src/routes/jobs.js
new file mode 100644
index 0000000..c229945
--- /dev/null
+++ b/api/src/routes/jobs.js
@@ -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;
diff --git a/api/src/routes/kiosk.js b/api/src/routes/kiosk.js
new file mode 100644
index 0000000..6a8e2ca
--- /dev/null
+++ b/api/src/routes/kiosk.js
@@ -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;
diff --git a/api/src/routes/kiosk.public.js b/api/src/routes/kiosk.public.js
new file mode 100644
index 0000000..0be47ab
--- /dev/null
+++ b/api/src/routes/kiosk.public.js
@@ -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;
diff --git a/api/src/routes/kiosk.sensor.js b/api/src/routes/kiosk.sensor.js
new file mode 100644
index 0000000..5b89839
--- /dev/null
+++ b/api/src/routes/kiosk.sensor.js
@@ -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;
diff --git a/api/src/routes/marine.datasets.js b/api/src/routes/marine.datasets.js
new file mode 100644
index 0000000..820f11c
--- /dev/null
+++ b/api/src/routes/marine.datasets.js
@@ -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;
diff --git a/api/src/routes/pageconnections.js b/api/src/routes/pageconnections.js
new file mode 100644
index 0000000..7ba6686
--- /dev/null
+++ b/api/src/routes/pageconnections.js
@@ -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;
diff --git a/api/src/routes/queue.js b/api/src/routes/queue.js
new file mode 100644
index 0000000..1d5480c
--- /dev/null
+++ b/api/src/routes/queue.js
@@ -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;
diff --git a/api/src/routes/rules.js b/api/src/routes/rules.js
new file mode 100644
index 0000000..434a736
--- /dev/null
+++ b/api/src/routes/rules.js
@@ -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;
diff --git a/api/src/storage/influx.js b/api/src/storage/influx.js
index eb31f57..e57c006 100644
--- a/api/src/storage/influx.js
+++ b/api/src/storage/influx.js
@@ -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.
diff --git a/api/src/storage/minio.js b/api/src/storage/minio.js
index fc016b8..e4c3299 100644
--- a/api/src/storage/minio.js
+++ b/api/src/storage/minio.js
@@ -8,6 +8,13 @@ const client = new Minio.Client({
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/.
+// models////...
+// trainings//logs.jsonl
+const ML_BUCKET = process.env.MINIO_BUCKET || 'ml';
+
// 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 = {
+ client,
+ ML_BUCKET,
bucketExists,
getBuckets,
getBucket,
@@ -142,5 +193,8 @@ module.exports = {
upload,
download,
getFileStream,
- checkMinio
+ checkMinio,
+ readText,
+ writeText,
+ listObjects,
}
\ No newline at end of file
diff --git a/api/src/storage/postgres.js b/api/src/storage/postgres.js
index 55b4124..4114016 100644
--- a/api/src/storage/postgres.js
+++ b/api/src/storage/postgres.js
@@ -15,6 +15,11 @@ const pools = {
users: new Pool({ ...config, database: process.env.USERS_DB }),
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }),
rules: new Pool({ ...config, database: process.env.RULES_DB || 'rules' }),
+ ml: new Pool({ ...config, database: process.env.ML_DB || 'ml' }),
+ references: new Pool({ ...config, database: process.env.REFERENCES_DB || 'references' }),
+ // Le tabelle kiosktemplates / kioskelements vivono nel DB `sensors`.
+ // KIOSK_DB resta override-abile per environment legacy.
+ kiosk: new Pool({ ...config, database: process.env.KIOSK_DB || 'sensors' }),
}
Object.entries(pools).forEach(([name, pool]) => {
@@ -73,6 +78,10 @@ async function remove(table, condition, params, type = 'users') {
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() {
const status = {};
console.log("Checking PostgreSQL connections with config:", config);
diff --git a/console/src/index.js b/console/src/index.js
index ace74f0..d96e6b8 100644
--- a/console/src/index.js
+++ b/console/src/index.js
@@ -66,6 +66,30 @@ app.get('/sessions', renderPage('sessions', {
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', () => {
console.log(`Started on port ${PORT}`);
});
diff --git a/console/src/pages/dashboard.html b/console/src/pages/dashboard.html
index 665f546..f8d4097 100644
--- a/console/src/pages/dashboard.html
+++ b/console/src/pages/dashboard.html
@@ -1,178 +1,188 @@
-
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
📄
+
Seleziona un documento dalla sidebar, o creane uno nuovo.
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/console/src/pages/documentation.html b/console/src/pages/documentation.html
new file mode 100644
index 0000000..7975444
--- /dev/null
+++ b/console/src/pages/documentation.html
@@ -0,0 +1,230 @@
+
+
+