53 Commits

Author SHA1 Message Date
Giuseppe Raffa
0ce879aa44 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
2026-04-28 09:24:38 +02:00
Giuseppe Raffa
ee478e52ef feat: implement dark mode toggle across application
- Added dark mode detection and application logic in HTML files (dashboard.html, kioskedit.html, live.html, rulesets.html, sessions.html).
- Introduced a theme toggle button for user interaction.
- Created a new theme-toggle.js script to manage dark mode state and persistence using localStorage.
- Updated CSS styles to support dark mode with appropriate color variables and transitions.
- Enhanced user experience by preventing flash of unstyled content during theme initialization.
2026-04-21 22:42:02 +02:00
Giuseppe Raffa
924c2b5367 refactor: implement centralized auth middleware and standardize cross-subdomain session management 2026-04-21 22:17:48 +02:00
Giuseppe Raffa
69012029ad refactor: clean up code and improve error handling in authentication and database modules 2026-04-21 20:47:32 +02:00
Giuseppe Raffa
5433529ffd fix: fixed erroneaus endpoints uses 2026-04-21 20:33:12 +02:00
Giuseppe Raffa
e43c330594 tempfix: added some debug console logs for easiest debug 2026-04-21 20:27:44 +02:00
Giuseppe Raffa
974cbe93cd fix: additional fix for auth login flow and auth web pages and database
connection.
2026-04-21 20:08:59 +02:00
Giuseppe Raffa
c8668920a6 fix: error in auth login, added basic font and more resources 2026-04-21 19:53:02 +02:00
Giuseppe Raffa
9e6bb26a2c fix: update sessionBucket to use 'boat' and refine session query logic 2026-04-18 12:57:03 +02:00
Giuseppe Raffa
ba0dbe6baf feat+fix: changed the primary data source for the data analysis and added the relative card in the dashboard page. 2026-04-18 12:43:50 +02:00
Giuseppe Raffa
b6c2a7e904 Add initial KioskCore and API endpoint for data analysis
- Created a new CSS file for kiosk styles, defining variables, typography, and layout for cards and toolbars.
- Implemented new routes for data anlaysis page
2026-04-18 12:32:32 +02:00
Giuseppe Raffa
ef62bb5da0 fix: update database configuration to include default values for user, password, host, and port 2026-04-16 15:42:31 +02:00
Giuseppe Raffa
981f498eb7 feat: update session handling and add session history endpoint 2026-04-16 15:37:10 +02:00
Giuseppe Raffa
5912c00a82 refactor: remove rules endpoint and related logic
- Deleted the rules routes and associated logic from the API.
- Removed rules-related functionality from params.sensor.js.
- Updated dashboard and rulesets HTML to remove references to rulesets.
- Removed force update button and related functionality from rulesets page.
- Cleaned up styles related to the force update button.
- Removed unused WebSocket client example.
- Updated realtime server to eliminate rules pushing logic.
- Refactored WebSocket handler to streamline data processing.
2026-04-16 14:27:27 +02:00
Giuseppe Raffa
edd7226966 feat: add support for later forecasts and implement force update functionality for rules 2026-04-16 08:14:10 +02:00
Giuseppe Raffa
c0be21a718 fix: update database queries to use 'active' instead of 'is_active' for consistency 2026-04-16 00:18:49 +02:00
Giuseppe Raffa
370f911063 feat: implement CORS support and update API_URL in environment configuration 2026-04-15 23:40:08 +02:00
Giuseppe Raffa
b4182c5c94 fix: remove unnecessary volume mapping for node_modules in docker-compose 2026-04-15 23:15:12 +02:00
Giuseppe Raffa
3094c06467 Add Rulesets page with HTML structure and CSS styles
- Created a new HTML file for the Rulesets page, including a header, toolbar, rules grid, and rule detail popup.
- Implemented JavaScript functionality for loading, filtering, sorting, and managing rules.
- Added CSS styles for the layout, components, and responsive design of the Rulesets page.
2026-04-15 08:06:29 +02:00
Giuseppe Raffa
c9402de2e4 fix: update exposed port in Dockerfile from 3002 to 3000 2026-04-14 20:20:28 +02:00
Giuseppe Raffa
bf66845528 feat: add CORS support and enhance session routes for better sensor management 2026-04-14 19:39:26 +02:00
Giuseppe Raffa
137c6131c3 feat: implement WebSocket server for real-time sensor data handling and add sensor status update routes 2026-04-14 19:05:37 +02:00
Giuseppe Raffa
a34048ae6b fix: update sensors route to correctly pass database name to query function 2026-04-14 18:13:07 +02:00
Giuseppe Raffa
81e6e1960d fix: refactor database connection configuration to use baseConfig 2026-04-14 18:10:03 +02:00
Giuseppe Raffa
59f7135b61 debug: adds a debug log to fix password environment variable problem 2026-04-14 18:09:08 +02:00
Giuseppe Raffa
d17c78f42a fix: correct database password environment variable reference 2026-04-14 17:59:53 +02:00
Giuseppe Raffa
ea4af13840 fix: correct database password environment variable reference 2026-04-14 17:58:42 +02:00
Giuseppe Raffa
a19c6988f4 fix: update database password environment variable and correct sensors database name 2026-04-14 17:57:55 +02:00
Giuseppe Raffa
2bbc5e0320 feat: add database configuration logging for improved visibility 2026-04-14 17:55:55 +02:00
Giuseppe Raffa
b6b1ed7a2b feat: implement sensor connection endpoint and add pending tokens route 2026-04-14 17:45:40 +02:00
Giuseppe Raffa
a79ab2af38 feat: add Redis integration, enhanced health checks and fixed an error in the database core 2026-04-14 17:11:24 +02:00
Giuseppe Raffa
d79c12b6e9 feat: implement sensor connection and health check endpoints 2026-04-14 17:05:24 +02:00
Giuseppe Raffa
c478f5c13c reset: removed the old code to start from scratch 2026-04-14 15:56:24 +02:00
Giuseppe Raffa
c597d4a414 fix: refactor verifyClient to use promise instead of async/await for token validation 2026-04-14 15:49:22 +02:00
Giuseppe Raffa
82310a521f fix: add logging for URL and token in WebSocket client verification 2026-04-14 15:47:40 +02:00
Giuseppe Raffa
73675ddfff fix: update load balancer port for realtime service from 3002 to 3000 2026-04-14 15:29:46 +02:00
Giuseppe Raffa
40dd392696 fix: change network configuration to external for public and private networks 2026-04-14 15:17:09 +02:00
Giuseppe Raffa
32de4b1441 fix: update database name in connection pool configuration 2026-04-14 15:11:13 +02:00
Giuseppe Raffa
8fe514ed14 fix: add missing password configuration for Redis clients 2026-04-14 15:09:31 +02:00
Giuseppe Raffa
8b5937fa19 refactor: update WebSocket server setup and improve session handling 2026-04-14 15:04:10 +02:00
Giuseppe Raffa
ccd6143253 fix: update form action URL for login to include API path 2026-04-14 13:50:50 +02:00
Giuseppe Raffa
acb6b39dcf feat: add password configuration for Redis connection and update environment variables for Redis configuration 2026-04-14 13:27:22 +02:00
Giuseppe Raffa
1044837080 feat: configure realtime services and update environment variables 2026-04-14 13:23:26 +02:00
Giuseppe Raffa
0ae64d0c5b feat: update database configuration to use USERS_DB environment variable 2026-04-14 13:13:24 +02:00
Giuseppe Raffa
3032dbcc96 feat: enhance InfluxDB health check logging with detailed error information 2026-04-14 12:54:25 +02:00
Giuseppe Raffa
e13bbe3d02 feat: update logging to display InfluxDB token in initialization message 2026-04-14 12:37:34 +02:00
Giuseppe Raffa
063fccfaea feat: add logging for health check results and PostgreSQL connection status 2026-04-14 12:33:35 +02:00
Giuseppe Raffa
e003770187 feat: add connection check logging for PostgreSQL pools 2026-04-14 12:31:12 +02:00
Giuseppe Raffa
1ef9160361 feat: update PostgreSQL configuration to use environment variables consistently 2026-04-14 12:29:09 +02:00
Giuseppe Raffa
dcf1c47328 feat: add logging for InfluxDB client initialization and PostgreSQL client acquisition 2026-04-14 12:25:31 +02:00
Giuseppe Raffa
7d61d6361c feat: update Traefik router entrypoint to use websecure for API service 2026-04-14 12:22:06 +02:00
Giuseppe Raffa
1f161270ef feat: add TLS certresolver for API service in docker-compose 2026-04-14 12:19:07 +02:00
Giuseppe Raffa
c3bc6dabc0 refactor: comment out unused service configurations and update network names in docker-compose 2026-04-14 12:13:35 +02:00
129 changed files with 13322 additions and 1875 deletions

View File

@@ -0,0 +1,4 @@
DOMAIN=
#production= mebboat.it
#development= localhost

6
.gitignore vendored
View File

@@ -16,4 +16,8 @@ Thumbs.db
.vscode/ .vscode/
.idea/ .idea/
**/tsconfig.tsbuildinfo **/tsconfig.tsbuildinfo
.eslintcache .eslintcache
.venv/
.claude/

203
api/package-lock.json generated
View File

@@ -10,12 +10,14 @@
"dependencies": { "dependencies": {
"@influxdata/influxdb-client": "^1.35.0", "@influxdata/influxdb-client": "^1.35.0",
"@influxdata/influxdb-client-apis": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0",
"@msgpack/msgpack": "^3.1.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"minio": "^8.0.7", "minio": "^8.0.7",
"multer": "^1.4.5-lts.1",
"pg": "^8.20.0" "pg": "^8.20.0"
} }
}, },
@@ -40,6 +42,15 @@
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
"license": "ISC",
"engines": {
"node": ">= 18"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -53,6 +64,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/async": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -113,6 +130,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -160,6 +194,51 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/concat-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/concat-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -219,6 +298,12 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -653,6 +738,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -811,6 +902,15 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minio": { "node_modules/minio": {
"version": "8.0.7", "version": "8.0.7",
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz", "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
@@ -865,12 +965,86 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -880,6 +1054,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1075,6 +1258,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1414,6 +1603,14 @@
"stream-chain": "^2.2.5" "stream-chain": "^2.2.5"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/strict-uri-encode": { "node_modules/strict-uri-encode": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
@@ -1476,6 +1673,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -11,12 +11,14 @@
"dependencies": { "dependencies": {
"@influxdata/influxdb-client": "^1.35.0", "@influxdata/influxdb-client": "^1.35.0",
"@influxdata/influxdb-client-apis": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0",
"@msgpack/msgpack": "^3.1.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"minio": "^8.0.7", "minio": "^8.0.7",
"multer": "^1.4.5-lts.1",
"pg": "^8.20.0" "pg": "^8.20.0"
} }
} }

View File

@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const parser = require('cookie-parser'); const parser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { requireAuth } = require('./middlewares/auth');
const app = express(); const app = express();
const PORT = process.env.PORT; const PORT = process.env.PORT;
@@ -12,6 +13,21 @@ const vState = process.env.VERSION_STATE;
app.use(express.json()); app.use(express.json());
app.use(parser()); app.use(parser());
// CORS per permettere chiamate cross-origin dalla console
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
// Accetta origini nella whitelist, oppure tutte in dev
if (allowed.length === 0 || allowed.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin || '*');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key');
res.setHeader('Access-Control-Allow-Credentials', 'true');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.redirect('/health'); res.redirect('/health');
}); });
@@ -23,6 +39,8 @@ app.get('/health', async (req, res) => {
const allOk = Object.values(postgres).every(s => s === 'connected') && influx && minio; const allOk = Object.values(postgres).every(s => s === 'connected') && influx && minio;
console.log("Health check results:", { postgres, influx: influx ? 'connected' : 'disconnected', minio: minio ? 'connected' : 'disconnected' });
res.json({ res.json({
status: allOk ? "ok" : "degraded", status: allOk ? "ok" : "degraded",
service: "api", service: "api",
@@ -39,33 +57,14 @@ app.get('/health', async (req, res) => {
const paramsSensorRoutes = require('./routes/params.sensor'); const paramsSensorRoutes = require('./routes/params.sensor');
app.use('/params/sensor', paramsSensorRoutes); app.use('/params/sensor', paramsSensorRoutes);
// Middleware di autenticazione per le API const kioskSensorRoutes = require('./routes/kiosk.sensor');
app.use((req, res, next) => { app.use('/kiosk/sensor', kioskSensorRoutes);
if (req.path === '/health' || req.path === '/') return next();
// 1. Service-to-service: x-api-key header const kioskPublicRoutes = require('./routes/kiosk.public');
const apiKey = req.headers['x-api-key']; app.use('/kiosk', kioskPublicRoutes);
if (apiKey && apiKey === process.env.INTERNAL_API_KEY) {
req.internal = true;
return next();
}
// 2. User auth: cookie o Authorization header // Middleware di autenticazione per tutte le API protette
const token = req.cookies?.auth_token app.use(requireAuth);
|| (req.headers.authorization?.startsWith('Bearer ') && req.headers.authorization.slice(7));
if (!token) {
return res.status(401).json({ error: 'Unauthorized: Nessun token di autenticazione fornito' });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized: Token non valido o scaduto' });
}
});
const dataRoutes = require('./routes/data'); const dataRoutes = require('./routes/data');
app.use('/data', dataRoutes); app.use('/data', dataRoutes);
@@ -79,7 +78,30 @@ app.use('/params', paramsRoutes)
const settingsRoutes = require('./routes/settings') const settingsRoutes = require('./routes/settings')
app.use('/settings', settingsRoutes) app.use('/settings', settingsRoutes)
// Avvio del server const sessionsRoutes = require('./routes/sessions')
app.use('/sessions', sessionsRoutes)
const docsRoutes = require('./routes/docs')
app.use('/docs', docsRoutes)
const marineDatasetsRoutes = require('./routes/marine.datasets')
app.use('/marine/datasets', marineDatasetsRoutes)
const jobsRoutes = require('./routes/jobs')
app.use('/jobs', jobsRoutes)
const queueRoutes = require('./routes/queue')
app.use('/queue', queueRoutes)
const pageconnectionsRoutes = require('./routes/pageconnections')
app.use('/pageconnections', pageconnectionsRoutes)
const kioskRoutes = require('./routes/kiosk')
app.use('/kiosk', kioskRoutes)
const rulesRoutes = require('./routes/rules')
app.use('/rules', rulesRoutes)
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Started on port ${PORT}`); console.log(`Started on port ${PORT}`);
}); });

View File

@@ -0,0 +1,70 @@
/**
* Middleware di autenticazione per API REST.
* Supporta tre modalità:
* - x-api-key (service-to-service, INTERNAL_API_KEY)
* - cookie auth_token (utenti loggati dal browser, SSO via .mebboat.it)
* - Authorization: Bearer <jwt>
*
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET e verificato localmente.
* Il cookie è condiviso tra i sottodomini grazie a domain=.mebboat.it
*/
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
function extractToken(req) {
const header = req.headers.authorization;
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
return (req.cookies && req.cookies.auth_token) || bearer || null;
}
function verifyToken(token) {
if (!token || typeof token !== 'string' || token.length > 2048) return null;
try {
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
return {
user_id: p.sub,
username: p.username,
session_id: p.session_id,
iat: p.iat,
exp: p.exp
};
} catch {
return null;
}
}
/**
* Accetta utente loggato (cookie/bearer) o chiamata interna (x-api-key).
* Imposta req.user con i dati dell'utente, oppure req.internal = true.
*/
function requireAuth(req, res, next) {
// 1. Service-to-service
const apiKey = req.headers['x-api-key'];
if (apiKey && INTERNAL_KEY && apiKey === INTERNAL_KEY) {
req.internal = true;
return next();
}
// 2. User auth (cookie o Bearer)
const user = verifyToken(extractToken(req));
if (!user) return res.status(401).json({ error: 'unauthorized' });
req.user = user;
next();
}
/**
* Solo service-to-service (x-api-key).
*/
function requireInternal(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!INTERNAL_KEY || !apiKey || apiKey !== INTERNAL_KEY) {
return res.status(403).json({ error: 'forbidden' });
}
req.internal = true;
next();
}
module.exports = { requireAuth, requireInternal, verifyToken, extractToken };

View File

@@ -0,0 +1,71 @@
-- -- Database: ml
-- -- Eseguire con: psql -U meb -d ml -f 001_ml_datasets.sql
-- --
-- -- Tabella dei metadati dei dataset salvati su MinIO.
-- -- Ogni riga è associata ad un file nel bucket "ml.datasets" (o altri bucket future)
-- -- tramite minio_key (= nome oggetto in MinIO, che è anche il suo "id" nativo).
-- CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- CREATE TABLE IF NOT EXISTS datasets (
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- -- Storage MinIO
-- minio_key TEXT NOT NULL UNIQUE, -- es. "2026-04-22_currents_med.csv"
-- bucket TEXT NOT NULL DEFAULT 'ml.datasets',
-- -- Identità dataset
-- nome TEXT NOT NULL,
-- description TEXT,
-- tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
-- type TEXT NOT NULL DEFAULT 'copernicus', -- copernicus | custom | imported
-- format TEXT NOT NULL, -- csv | json | netcdf
-- notes TEXT,
-- -- Provenienza / audit
-- created_by TEXT NOT NULL, -- username
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- last_used_at TIMESTAMPTZ,
-- -- Misure del file
-- size_bytes BIGINT NOT NULL DEFAULT 0,
-- row_count BIGINT, -- numero righe (se noto)
-- columns TEXT[], -- nomi colonne finali (dopo rename)
-- -- Specifico Copernicus (nullable per altri type)
-- copernicus_dataset_id TEXT,
-- variables TEXT[], -- variabili richieste
-- variable_renames JSONB, -- {original: custom}
-- bbox JSONB, -- [min_lon, min_lat, max_lon, max_lat]
-- start_date DATE,
-- end_date DATE,
-- -- Estensibile per type futuri senza migration
-- params JSONB NOT NULL DEFAULT '{}'::JSONB,
-- -- Versioning semplice
-- version INT NOT NULL DEFAULT 1,
-- CONSTRAINT datasets_format_ok CHECK (format IN ('csv','json','netcdf')),
-- CONSTRAINT datasets_type_ok CHECK (type IN ('copernicus','custom','imported'))
-- );
-- CREATE INDEX IF NOT EXISTS idx_datasets_created_by ON datasets(created_by);
-- CREATE INDEX IF NOT EXISTS idx_datasets_type ON datasets(type);
-- CREATE INDEX IF NOT EXISTS idx_datasets_tags ON datasets USING gin(tags);
-- CREATE INDEX IF NOT EXISTS idx_datasets_created_at ON datasets(created_at DESC);
-- CREATE INDEX IF NOT EXISTS idx_datasets_minio_key ON datasets(minio_key);
-- -- Trigger per aggiornare updated_at automaticamente
-- CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
-- BEGIN
-- NEW.updated_at = NOW();
-- RETURN NEW;
-- END;
-- $$ LANGUAGE plpgsql;
-- DROP TRIGGER IF EXISTS trg_datasets_updated_at ON datasets;
-- CREATE TRIGGER trg_datasets_updated_at
-- BEFORE UPDATE ON datasets
-- FOR EACH ROW EXECUTE FUNCTION set_updated_at();

View File

@@ -0,0 +1,5 @@
-- Database: ml
-- DEPRECATED: la colonna `bucket` e' stata rimossa dalla tabella `datasets`.
-- Il bucket e' ora fisso a 'ml.datasets' lato applicazione (vedi
-- api/src/routes/marine.datasets.js e ml/routers/datasets.py).
-- Questo file e' lasciato vuoto per non rompere lo storico delle migration.

View File

@@ -0,0 +1,35 @@
-- Database: ml
-- Registro modelli ML: ogni riga punta a una repo Gitea con codice del modello.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'xgboost'|'lstm'|'sklearn'|...
gitea_repo TEXT NOT NULL, -- "owner/repo"
default_branch TEXT NOT NULL DEFAULT 'main',
spec JSONB, -- copia cached di model.yml @ tip del default_branch
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_models_created_by ON models(created_by);
CREATE INDEX IF NOT EXISTS idx_models_type ON models(type);
CREATE TABLE IF NOT EXISTS model_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
author TEXT NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_model_notes_model ON model_notes(model_id, created_at DESC);
DROP TRIGGER IF EXISTS trg_models_updated_at ON models;
CREATE TRIGGER trg_models_updated_at
BEFORE UPDATE ON models
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- La colonna `bucket` su datasets e' stata rimossa: il bucket e' fisso a
-- 'ml.datasets' (vedi codice). Lasciato come no-op per coerenza storica.

View File

@@ -0,0 +1,26 @@
-- Database: ml
-- Storico training di modelli. Le time-series (cpu/mem/loss) vivono su InfluxDB;
-- qui salviamo solo anagrafica + risultati finali e riepilogo risorse.
CREATE TABLE IF NOT EXISTS trainings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
version TEXT NOT NULL,
patch TEXT NOT NULL, -- git commit sha (short o full)
dataset_id UUID NOT NULL,
started_by TEXT NOT NULL,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
duration_ms BIGINT,
status TEXT NOT NULL DEFAULT 'queued', -- queued|running|succeeded|failed|cancelled
artifacts_prefix TEXT, -- es. "models/<id>/<version>/<patch>"
results JSONB, -- final metrics + plots (arrays puri)
resource_summary JSONB, -- {cpu_peak,cpu_avg,mem_peak_mb,mem_avg_mb,samples}
error TEXT,
CONSTRAINT trainings_status_ok CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
UNIQUE(model_id, version, patch)
);
CREATE INDEX IF NOT EXISTS idx_trainings_model ON trainings(model_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_trainings_status ON trainings(status);
CREATE INDEX IF NOT EXISTS idx_trainings_user ON trainings(started_by);

View File

@@ -0,0 +1,17 @@
-- Database: ml
-- Sessioni di test: una sessione contiene 1..N run (set di input → output).
CREATE TABLE IF NOT EXISTS tests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
training_id UUID NOT NULL REFERENCES trainings(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
runs JSONB NOT NULL DEFAULT '[]'::JSONB,
-- ogni elemento: {inputs, outputs, duration_ms, cpu_peak, mem_peak_mb, ts}
model_size_bytes BIGINT,
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_tests_training ON tests(training_id);
CREATE INDEX IF NOT EXISTS idx_tests_user ON tests(user_id);
CREATE INDEX IF NOT EXISTS idx_tests_started ON tests(started_at DESC);

View File

@@ -0,0 +1,27 @@
-- Database: ml
-- Tabella jobs: ciclo di vita di un lavoro asincrono (training oggi, domani altro).
-- L'api-service espone /jobs /queue /pageconnections per coordinare accessi e coda.
CREATE TABLE IF NOT EXISTS jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL, -- 'train' | 'test' | ...
status TEXT NOT NULL DEFAULT 'queued',-- queued|running|succeeded|failed|cancelled
payload JSONB NOT NULL DEFAULT '{}'::JSONB,
result JSONB,
error TEXT,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
CONSTRAINT jobs_status_ok CHECK (status IN ('queued','running','succeeded','failed','cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(type);
CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by);
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at DESC);
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

View File

@@ -0,0 +1,79 @@
-- ============================================================
-- Kiosk schema (DB: sensors)
-- ------------------------------------------------------------
-- SOLO struttura: PK, FK, NOT NULL, sequence, indice.
-- Tutta la logica (id char(8), updated_at, defaults, CHECK,
-- "un solo active") e' gestita lato applicazione (Node/JS).
-- ============================================================
-- ─────────────────────────────────────────────────────────────
-- Templates
-- ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kiosktemplates (
id char(8) PRIMARY KEY,
name varchar(50) NOT NULL,
tags text[],
active boolean,
archived boolean,
created_at timestamp,
updated_at timestamp
);
-- Idempotente: se la tabella esiste gia', garantisci PK + NOT NULL critici.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'kiosktemplates'::regclass AND contype = 'p'
) THEN
ALTER TABLE kiosktemplates ADD PRIMARY KEY (id);
END IF;
END $$;
ALTER TABLE kiosktemplates ALTER COLUMN name SET NOT NULL;
-- ─────────────────────────────────────────────────────────────
-- Elements (un template -> N elementi)
-- ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kioskelements (
id bigint PRIMARY KEY,
template_id char(8) NOT NULL,
font integer,
label varchar(100),
x integer,
y integer,
width integer,
height integer,
color varchar(20)
);
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'kioskelements'::regclass AND contype = 'p'
) THEN
ALTER TABLE kioskelements ADD PRIMARY KEY (id);
END IF;
END $$;
ALTER TABLE kioskelements ALTER COLUMN template_id SET NOT NULL;
-- Auto-increment per kioskelements.id (l'app fa INSERT senza specificare id).
CREATE SEQUENCE IF NOT EXISTS kioskelements_id_seq OWNED BY kioskelements.id;
SELECT setval(
'kioskelements_id_seq',
COALESCE((SELECT MAX(id) FROM kioskelements), 0) + 1,
false
);
ALTER TABLE kioskelements ALTER COLUMN id SET DEFAULT nextval('kioskelements_id_seq');
-- Foreign Key con CASCADE.
ALTER TABLE kioskelements DROP CONSTRAINT IF EXISTS fk_kioskelements_template;
ALTER TABLE kioskelements
ADD CONSTRAINT fk_kioskelements_template
FOREIGN KEY (template_id)
REFERENCES kiosktemplates(id)
ON DELETE CASCADE
ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS kioskelements_template_idx
ON kioskelements (template_id);

View File

@@ -0,0 +1,124 @@
-- ============================================================
-- Rulesets schema (DB: rules)
-- ------------------------------------------------------------
-- Un ruleset e' una collezione versionata di "items" (paths
-- Signal K per i logs, codici openmeteo per i forecasts, ...).
-- Modello:
-- * 5 tipi fissi: logs | forecast_current | forecast_hourly
-- | marine_current | marine_hourly
-- * Un solo ruleset puo' essere "active" per ciascun tipo.
-- * Le versioni sono triple di interi 1..100 (major.build.patch).
-- * Gli items sono JSONB per massima flessibilita'.
-- * Ogni item ha un "ref" stabile scelto dall'utente: e' la
-- chiave logica che garantisce continuita' su InfluxDB anche
-- se il path del sensore cambia.
-- * Le deployments tracciano quale ruleset-version e' stato
-- pushato ad ogni sensore.
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ─────────────────────────────────────────────────────────────
-- RULESETS
-- ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS rulesets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL
CHECK (type IN ('logs','forecast_current','forecast_hourly','marine_current','marine_hourly')),
version_major SMALLINT NOT NULL DEFAULT 1 CHECK (version_major BETWEEN 1 AND 100),
version_build SMALLINT NOT NULL DEFAULT 0 CHECK (version_build BETWEEN 0 AND 100),
version_patch SMALLINT NOT NULL DEFAULT 0 CHECK (version_patch BETWEEN 0 AND 100),
description TEXT NOT NULL DEFAULT '',
tags TEXT[] NOT NULL DEFAULT '{}',
-- items: [{ ref, path, enabled, meta: {...} }, ...]
-- ref: identificatore logico stabile (chiave su Influx)
-- path: SK path (logs) | codice openmeteo (forecast/marine)
-- meta: { name, unit, measurement, sk_path, group_name, category, ... }
items JSONB NOT NULL DEFAULT '[]'::jsonb,
active BOOLEAN NOT NULL DEFAULT false,
archived BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (type, version_major, version_build, version_patch)
);
-- solo UN ruleset active per tipo (archiviati esclusi)
CREATE UNIQUE INDEX IF NOT EXISTS rulesets_one_active_per_type
ON rulesets (type)
WHERE active = true AND archived = false;
CREATE INDEX IF NOT EXISTS rulesets_type_idx ON rulesets (type);
CREATE INDEX IF NOT EXISTS rulesets_active_idx ON rulesets (type) WHERE active = true;
CREATE INDEX IF NOT EXISTS rulesets_archived_idx ON rulesets (archived);
CREATE INDEX IF NOT EXISTS rulesets_items_gin_idx ON rulesets USING GIN (items);
-- Validazione items: array di oggetti con almeno ref+path
CREATE OR REPLACE FUNCTION rulesets_validate_items() RETURNS trigger AS $$
DECLARE
refs TEXT[];
BEGIN
IF jsonb_typeof(NEW.items) <> 'array' THEN
RAISE EXCEPTION 'items must be a JSON array';
END IF;
-- tutti gli item devono avere ref non vuoto e path (anche vuoto ammesso)
IF EXISTS (
SELECT 1 FROM jsonb_array_elements(NEW.items) it
WHERE jsonb_typeof(it) <> 'object'
OR NULLIF(it->>'ref','') IS NULL
) THEN
RAISE EXCEPTION 'every item must be an object with a non-empty "ref"';
END IF;
-- unicita' ref all'interno dello stesso ruleset
SELECT array_agg(it->>'ref') INTO refs
FROM jsonb_array_elements(NEW.items) it;
IF (SELECT count(DISTINCT x) FROM unnest(refs) x) <> COALESCE(array_length(refs,1),0) THEN
RAISE EXCEPTION 'item refs must be unique within the ruleset';
END IF;
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS rulesets_validate_trigger ON rulesets;
CREATE TRIGGER rulesets_validate_trigger
BEFORE INSERT OR UPDATE ON rulesets
FOR EACH ROW EXECUTE FUNCTION rulesets_validate_items();
-- ─────────────────────────────────────────────────────────────
-- DEPLOYMENTS
-- Traccia quale ruleset-version e' stato pushato ad ogni
-- sensore (per tipo). Un solo ruleset per (sensor,type).
-- ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ruleset_deployments (
sensor_name TEXT NOT NULL,
type TEXT NOT NULL,
ruleset_id UUID NOT NULL REFERENCES rulesets(id) ON DELETE CASCADE,
deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
acked_at TIMESTAMPTZ,
PRIMARY KEY (sensor_name, type)
);
CREATE INDEX IF NOT EXISTS ruleset_deployments_ruleset_idx
ON ruleset_deployments (ruleset_id);
-- ─────────────────────────────────────────────────────────────
-- AUDIT LOG (opzionale ma utile)
-- ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ruleset_changes (
id BIGSERIAL PRIMARY KEY,
ruleset_id UUID,
type TEXT,
action TEXT NOT NULL, -- created | updated | activated | archived | deleted | deployed
user_id TEXT,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ruleset_changes_ruleset_idx ON ruleset_changes (ruleset_id);
CREATE INDEX IF NOT EXISTS ruleset_changes_created_idx ON ruleset_changes (created_at DESC);

80
api/src/routes/docs.js Normal file
View File

@@ -0,0 +1,80 @@
/**
* Gestione file Markdown su MinIO nel bucket "documentation".
*
* GET /docs → lista file (name, size, lastModified)
* GET /docs/:name → contenuto markdown raw
* POST /docs → crea nuovo documento body {name, content}
* PUT /docs/:name → sovrascrive contenuto body {content}
* DELETE /docs/:name → elimina
*/
const express = require('express');
const { listObjects, readText, writeText, removeObject } = require('../storage/minio');
const router = express.Router();
const BUCKET = 'documentation';
const sanitize = (name) => {
// Solo caratteri safe per oggetti MinIO; forza estensione .md
const clean = String(name || '').trim().replace(/[^a-zA-Z0-9 _\-./]/g, '').replace(/\.+/g, '.');
if (!clean) return null;
return clean.endsWith('.md') ? clean : `${clean}.md`;
};
router.get('/', async (req, res) => {
try {
const files = await listObjects(BUCKET);
res.json(files.filter(f => f.name.endsWith('.md')));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/:name', async (req, res) => {
const name = sanitize(req.params.name);
if (!name) return res.status(400).json({ error: 'invalid name' });
try {
const content = await readText(BUCKET, name);
res.type('text/markdown').send(content);
} catch (e) {
if (e.code === 'NoSuchKey') return res.status(404).json({ error: 'not found' });
res.status(500).json({ error: e.message });
}
});
router.post('/', async (req, res) => {
const name = sanitize(req.body?.name);
const content = req.body?.content ?? '';
if (!name) return res.status(400).json({ error: 'name required' });
try {
const r = await writeText(BUCKET, name, content);
res.status(201).json(r);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.put('/:name', async (req, res) => {
const name = sanitize(req.params.name);
if (!name) return res.status(400).json({ error: 'invalid name' });
const content = req.body?.content ?? '';
try {
const r = await writeText(BUCKET, name, content);
res.json(r);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.delete('/:name', async (req, res) => {
const name = sanitize(req.params.name);
if (!name) return res.status(400).json({ error: 'invalid name' });
try {
await removeObject(BUCKET, name);
res.status(204).end();
} catch (e) {
res.status(500).json({ error: e.message });
}
});
module.exports = router;

120
api/src/routes/jobs.js Normal file
View File

@@ -0,0 +1,120 @@
/**
* /jobs — ciclo di vita dei job asincroni (es. training).
*
* Tabella: jobs (db "ml") — vedi migrations/006_jobs.sql
*/
const express = require('express');
const crypto = require('crypto');
const { query } = require('../storage/postgres');
const router = express.Router();
// In assenza dei trigger/funzioni DB (`gen_random_uuid` default,
// `set_updated_at` trigger, `jobs_status_ok` CHECK) gestiamo tutto qui.
const VALID_STATUSES = ['queued', 'running', 'succeeded', 'failed', 'cancelled'];
function genUUID() { return crypto.randomUUID(); }
function rowToJob(r) {
return {
id: r.id,
type: r.type,
status: r.status,
payload: r.payload,
result: r.result,
error: r.error,
created_by: r.created_by,
created_at: r.created_at,
updated_at: r.updated_at,
started_at: r.started_at,
finished_at: r.finished_at,
};
}
router.post('/', async (req, res) => {
try {
const { type, created_by, payload } = req.body || {};
if (!type) return res.status(400).json({ error: 'type required' });
const newId = genUUID();
const r = await query(
`INSERT INTO jobs (id, type, created_by, payload, status, created_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, 'queued', NOW(), NOW()) RETURNING *`,
[newId, type, created_by || req.user?.username || 'unknown', JSON.stringify(payload || {})],
'ml'
);
res.status(201).json(rowToJob(r.rows[0]));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/', async (req, res) => {
try {
const filters = [];
const params = [];
if (req.query.type) { params.push(req.query.type); filters.push(`type = $${params.length}`); }
if (req.query.status) { params.push(req.query.status); filters.push(`status = $${params.length}`); }
if (req.query.user === 'me' && req.user?.username) {
params.push(req.user.username);
filters.push(`created_by = $${params.length}`);
}
const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
params.push(limit);
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const r = await query(
`SELECT * FROM jobs ${where} ORDER BY created_at DESC LIMIT $${params.length}`,
params, 'ml'
);
res.json(r.rows.map(rowToJob));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/:id', async (req, res) => {
try {
const r = await query('SELECT * FROM jobs WHERE id = $1', [req.params.id], 'ml');
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
res.json(rowToJob(r.rows[0]));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.patch('/:id', async (req, res) => {
try {
const allowed = ['status', 'payload', 'result', 'error', 'started_at', 'finished_at'];
const sets = [];
const params = [];
// CHECK status sostituito da whitelist applicativa
if ('status' in req.body && !VALID_STATUSES.includes(req.body.status)) {
return res.status(400).json({
error: `invalid status, must be one of: ${VALID_STATUSES.join(', ')}`
});
}
for (const k of allowed) {
if (k in req.body) {
params.push(k === 'payload' || k === 'result' ? JSON.stringify(req.body[k]) : req.body[k]);
const cast = (k === 'payload' || k === 'result') ? '::jsonb' : '';
sets.push(`${k} = $${params.length}${cast}`);
}
}
if (!sets.length) return res.status(400).json({ error: 'no fields' });
// Trigger trg_jobs_updated_at non presente: lo facciamo manualmente.
sets.push('updated_at = NOW()');
params.push(req.params.id);
const r = await query(
`UPDATE jobs SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
params, 'ml'
);
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
res.json(rowToJob(r.rows[0]));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
module.exports = router;

443
api/src/routes/kiosk.js Normal file
View File

@@ -0,0 +1,443 @@
/**
* Kiosk templates API
* Base: /kiosk
*
* Schema reale (DB `sensors`):
* kiosktemplates(id char(8) PK, name varchar(50) NOT NULL,
* tags text[], active bool, archived bool,
* created_at timestamp, updated_at timestamp)
* kioskelements(id bigint PK auto-seq, template_id char(8) NOT NULL FK CASCADE,
* font, label varchar(100), x, y, width, height, color varchar(20))
*
* NOTA: il DB contiene SOLO i constraint strutturali (PK, FK, NOT NULL).
* Tutta la logica (id char(8), updated_at, "un solo active per tabella",
* validazione, CHECK su x/y/w/h, prevenzione active+archived) e' gestita qui.
*
* Le route restituiscono il template arricchito con `elements`:
* { id, name, tags, active, archived, created_at, updated_at, elements: [...] }
*/
const router = require('express').Router();
const crypto = require('crypto');
const { query, getClient } = require('../storage/postgres');
const DB = 'kiosk'; // pool puntato al DB `sensors` (vedi postgres.js)
// Sostituisce il default DB `gen_short_id8()` (funzione SQL non presente nel DB).
function genShortId8() {
return crypto.randomBytes(4).toString('hex'); // 8 char hex [0-9a-f]
}
// ────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────
const ELEMENT_FIELDS = ['font', 'label', 'x', 'y', 'width', 'height', 'color'];
function sanitizeElement(e) {
const out = {};
if (e.font !== undefined) out.font = parseInt(e.font, 10);
if (e.label !== undefined) out.label = String(e.label).slice(0, 100);
if (e.x !== undefined) out.x = parseInt(e.x, 10);
if (e.y !== undefined) out.y = parseInt(e.y, 10);
if (e.width !== undefined) out.width = parseInt(e.width, 10);
if (e.height !== undefined) out.height = parseInt(e.height, 10);
if (e.color !== undefined) out.color = String(e.color).slice(0, 20);
return out;
}
function validateElementForInsert(e) {
const errs = [];
if (typeof e.x !== 'number' || e.x < 0) errs.push('x must be >= 0');
if (typeof e.y !== 'number' || e.y < 0) errs.push('y must be >= 0');
if (typeof e.width !== 'number' || e.width <= 0) errs.push('width must be > 0');
if (typeof e.height !== 'number' || e.height <= 0) errs.push('height must be > 0');
return errs.length ? errs.join(', ') : null;
}
/**
* Inserisce N elementi per un template in batch.
* Usa il client passato (per transazione).
*/
async function insertElements(client, templateId, elements) {
if (!Array.isArray(elements) || !elements.length) return [];
const rows = [];
for (const raw of elements) {
const e = sanitizeElement(raw);
const err = validateElementForInsert({ x:0, y:0, width:1, height:1, ...e });
if (err) throw new Error(`element invalid: ${err}`);
const r = await client.query(
`INSERT INTO kioskelements (template_id, font, label, x, y, width, height, color)
VALUES ($1, COALESCE($2,16), COALESCE($3,''), COALESCE($4,0), COALESCE($5,0),
COALESCE($6,1), COALESCE($7,1), COALESCE($8,'#1e293b'))
RETURNING *`,
[templateId, e.font, e.label, e.x, e.y, e.width, e.height, e.color]
);
rows.push(r.rows[0]);
}
return rows;
}
/**
* Ritorna un template con la lista elements aggregata.
*/
async function fetchTemplateWithElements(idOrCondition, value) {
const where = idOrCondition === 'active'
? 't.active = true AND t.archived = false'
: 't.id = $1';
const params = idOrCondition === 'active' ? [] : [value];
const sql = `
SELECT t.*,
COALESCE(
(SELECT json_agg(e.* ORDER BY e.id)
FROM kioskelements e WHERE e.template_id = t.id),
'[]'::json
) AS elements
FROM kiosktemplates t
WHERE ${where}
LIMIT 1`;
const r = await query(sql, params, DB);
return r.rows[0] || null;
}
// ════════════════════════════════════════════════════════════
// READS
// ════════════════════════════════════════════════════════════
// GET /kiosk/template/active → template attivo (con elements)
router.get('/template/active', async (req, res) => {
try {
const tpl = await fetchTemplateWithElements('active');
if (!tpl) return res.status(404).json({ error: 'no active template' });
res.json(tpl);
} catch (err) {
console.error('[KIOSK] active error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// GET /kiosk/templates → lista (senza elements)
router.get('/templates', async (req, res) => {
try {
const r = await query(
`SELECT t.id, t.name, t.tags, t.active, t.archived,
t.created_at, t.updated_at,
(SELECT COUNT(*)::int FROM kioskelements e WHERE e.template_id = t.id) AS elements_count
FROM kiosktemplates t
ORDER BY t.updated_at DESC`,
[], DB
);
res.json(r.rows);
} catch (err) {
console.error('[KIOSK] list error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// GET /kiosk/templates/:id → dettaglio con elements
router.get('/templates/:id', async (req, res) => {
try {
const tpl = await fetchTemplateWithElements('id', req.params.id);
if (!tpl) return res.status(404).json({ error: 'not found' });
res.json(tpl);
} catch (err) {
console.error('[KIOSK] get error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// ════════════════════════════════════════════════════════════
// WRITES
// ════════════════════════════════════════════════════════════
// POST /kiosk/templates → crea template + elements (transazionale)
router.post('/templates', async (req, res) => {
const { name, tags, elements } = req.body || {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'name required' });
}
const tagsArr = Array.isArray(tags) ? tags.map(String) : [];
const els = Array.isArray(elements) ? elements : [];
const client = await getClient(DB);
try {
await client.query('BEGIN');
// Genera id in app (no default DB). Retry se collisione (estremamente rara).
let tpl = null;
for (let attempt = 0; attempt < 5 && !tpl; attempt++) {
const id = genShortId8();
try {
const t = await client.query(
`INSERT INTO kiosktemplates (id, name, tags, active, archived, created_at, updated_at)
VALUES ($1, $2, $3, false, false, NOW(), NOW()) RETURNING *`,
[id, name.slice(0, 50), tagsArr]
);
tpl = t.rows[0];
} catch (e) {
if (e.code !== '23505') throw e; // PK conflict → retry
}
}
if (!tpl) throw new Error('id generation failed');
const insertedEls = await insertElements(client, tpl.id, els);
await client.query('COMMIT');
res.status(201).json({ ...tpl, elements: insertedEls });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('[KIOSK] create error:', err.message);
res.status(400).json({ error: err.message || 'internal error' });
} finally {
client.release();
}
});
// PUT /kiosk/templates/:id → patch metadata. Se `elements` viene passato,
// sostituisce TUTTI gli elements (delete + insert in transazione).
router.put('/templates/:id', async (req, res) => {
const { name, tags, elements } = req.body || {};
const fields = [], values = [];
let i = 1;
if (name !== undefined) { fields.push(`name = $${i++}`); values.push(String(name).slice(0, 50)); }
if (tags !== undefined) {
if (!Array.isArray(tags)) return res.status(400).json({ error: 'tags must be array' });
fields.push(`tags = $${i++}`); values.push(tags.map(String));
}
if (!fields.length && elements === undefined) {
return res.status(400).json({ error: 'no fields' });
}
const client = await getClient(DB);
try {
await client.query('BEGIN');
let tpl;
if (fields.length) {
// Trigger set_updated_at non presente: lo facciamo manualmente.
fields.push('updated_at = NOW()');
values.push(req.params.id);
const r = await client.query(
`UPDATE kiosktemplates SET ${fields.join(', ')}
WHERE id = $${i} RETURNING *`,
values
);
if (!r.rows[0]) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'not found' });
}
tpl = r.rows[0];
} else {
const r = await client.query(`SELECT * FROM kiosktemplates WHERE id = $1`, [req.params.id]);
if (!r.rows[0]) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'not found' });
}
tpl = r.rows[0];
}
let elsRows;
if (Array.isArray(elements)) {
await client.query(`DELETE FROM kioskelements WHERE template_id = $1`, [tpl.id]);
elsRows = await insertElements(client, tpl.id, elements);
} else {
const r = await client.query(
`SELECT * FROM kioskelements WHERE template_id = $1 ORDER BY id`,
[tpl.id]
);
elsRows = r.rows;
}
await client.query('COMMIT');
res.json({ ...tpl, elements: elsRows });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('[KIOSK] update error:', err.message);
res.status(400).json({ error: err.message || 'internal error' });
} finally {
client.release();
}
});
// POST /kiosk/templates/:id/activate → attiva (disattiva tutti gli altri)
router.post('/templates/:id/activate', async (req, res) => {
const client = await getClient(DB);
try {
await client.query('BEGIN');
// pre-check: archived non puo' diventare active
const cur = await client.query(
`SELECT archived FROM kiosktemplates WHERE id = $1`,
[req.params.id]
);
if (!cur.rows[0]) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'not found' });
}
if (cur.rows[0].archived) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'cannot activate archived template' });
}
await client.query(
`UPDATE kiosktemplates SET active = false, updated_at = NOW()
WHERE active = true AND id <> $1`,
[req.params.id]
);
const r = await client.query(
`UPDATE kiosktemplates SET active = true, updated_at = NOW()
WHERE id = $1 RETURNING *`,
[req.params.id]
);
await client.query('COMMIT');
const tpl = r.rows[0];
// notifica realtime (best-effort)
const RT = process.env.REALTIME_URL || 'http://meb-realtime:3000';
const KEY = process.env.INTERNAL_API_KEY;
if (KEY) {
fetch(`${RT}/kiosk/notify-active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
body: JSON.stringify({ template: tpl })
}).catch(() => {});
}
res.json(tpl);
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('[KIOSK] activate error:', err.message);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
// PATCH /kiosk/templates/:id/archive → toggle archived (e disattiva)
router.patch('/templates/:id/archive', async (req, res) => {
try {
const cur = await query(
`SELECT archived FROM kiosktemplates WHERE id = $1`,
[req.params.id], DB
);
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
const willArchive = !cur.rows[0].archived;
const r = await query(
`UPDATE kiosktemplates
SET archived = $1,
active = CASE WHEN $1 = true THEN false ELSE active END,
updated_at = NOW()
WHERE id = $2 RETURNING *`,
[willArchive, req.params.id], DB
);
res.json(r.rows[0]);
} catch (err) {
console.error('[KIOSK] archive error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// DELETE /kiosk/templates/:id
// FK CASCADE non e' garantita lato DB: cancelliamo manualmente in transazione
// prima gli elements e poi il template.
router.delete('/templates/:id', async (req, res) => {
const client = await getClient(DB);
try {
await client.query('BEGIN');
const check = await client.query(
`SELECT active FROM kiosktemplates WHERE id = $1`,
[req.params.id]
);
if (!check.rows[0]) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'not found' });
}
if (check.rows[0].active) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'cannot delete active template' });
}
await client.query(`DELETE FROM kioskelements WHERE template_id = $1`, [req.params.id]);
await client.query(`DELETE FROM kiosktemplates WHERE id = $1`, [req.params.id]);
await client.query('COMMIT');
res.json({ deleted: true });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('[KIOSK] delete error:', err.message);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
// ════════════════════════════════════════════════════════════
// ELEMENTS — CRUD granulare (utile per editor live)
// ════════════════════════════════════════════════════════════
// POST /kiosk/templates/:id/elements → aggiunge un singolo element
router.post('/templates/:id/elements', async (req, res) => {
const client = await getClient(DB);
try {
const tpl = await client.query(
`SELECT id FROM kiosktemplates WHERE id = $1`, [req.params.id]
);
if (!tpl.rows[0]) return res.status(404).json({ error: 'template not found' });
const [created] = await insertElements(client, req.params.id, [req.body || {}]);
// bumpa updated_at del template
await client.query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id]);
res.status(201).json(created);
} catch (err) {
console.error('[KIOSK] add element error:', err.message);
res.status(400).json({ error: err.message || 'internal error' });
} finally {
client.release();
}
});
// PUT /kiosk/templates/:id/elements/:elementId → patch element
router.put('/templates/:id/elements/:elementId', async (req, res) => {
const e = sanitizeElement(req.body || {});
const fields = [], values = [];
let i = 1;
for (const k of ELEMENT_FIELDS) {
if (e[k] !== undefined) { fields.push(`${k} = $${i++}`); values.push(e[k]); }
}
if (!fields.length) return res.status(400).json({ error: 'no fields' });
values.push(req.params.elementId, req.params.id);
try {
const r = await query(
`UPDATE kioskelements SET ${fields.join(', ')}
WHERE id = $${i++} AND template_id = $${i} RETURNING *`,
values, DB
);
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
await query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id], DB);
res.json(r.rows[0]);
} catch (err) {
console.error('[KIOSK] update element error:', err.message);
res.status(400).json({ error: err.message || 'internal error' });
}
});
// DELETE /kiosk/templates/:id/elements/:elementId
router.delete('/templates/:id/elements/:elementId', async (req, res) => {
try {
const r = await query(
`DELETE FROM kioskelements
WHERE id = $1 AND template_id = $2 RETURNING id`,
[req.params.elementId, req.params.id], DB
);
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
await query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id], DB);
res.json({ deleted: true });
} catch (err) {
console.error('[KIOSK] delete element error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
module.exports = router;

View File

@@ -0,0 +1,49 @@
const router = require('express').Router();
const { query } = require('../storage/postgres');
// Endpoint pubblici (usati dal plugin kiosk sulla barca). Solo read.
// Restituisce template + array `elements`.
const DB = 'kiosk';
const SELECT_WITH_ELEMENTS = `
SELECT t.*,
COALESCE(
(SELECT json_agg(e.* ORDER BY e.id)
FROM kioskelements e WHERE e.template_id = t.id),
'[]'::json
) AS elements
FROM kiosktemplates t
`;
router.get('/template/active', async (req, res) => {
try {
const r = await query(
`${SELECT_WITH_ELEMENTS}
WHERE t.active = true AND t.archived = false
LIMIT 1`,
[], DB
);
if (!r.rows[0]) return res.status(404).json({ error: 'no active template' });
res.json(r.rows[0]);
} catch (err) {
console.error('[KIOSK/PUB] active error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
router.get('/templates/:id', async (req, res) => {
try {
const r = await query(
`${SELECT_WITH_ELEMENTS} WHERE t.id = $1 LIMIT 1`,
[req.params.id], DB
);
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
res.json(r.rows[0]);
} catch (err) {
console.error('[KIOSK/PUB] get error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
module.exports = router;

View File

@@ -0,0 +1,67 @@
const router = require('express').Router();
const crypto = require('crypto');
const { query } = require('../storage/postgres');
const DB = 'kiosk';
function hash(code) { return crypto.createHash('sha256').update(code).digest('hex'); }
async function authSensor(req, res, next) {
try {
const r = await query(
'SELECT id, active FROM sensors WHERE code_hash = $1',
[hash(req.params.sensorCode)], 'sensors'
);
if (!r.rows[0]) return res.status(401).json({ error: 'invalid sensor code' });
if (!r.rows[0].active) return res.status(403).json({ error: 'sensor inactive' });
req.sensorId = r.rows[0].id;
next();
} catch (err) {
console.error('[KIOSK/SENSOR] auth error:', err.message);
res.status(500).json({ error: 'internal error' });
}
}
const SELECT_WITH_ELEMENTS = `
SELECT t.*,
COALESCE(
(SELECT json_agg(e.* ORDER BY e.id)
FROM kioskelements e WHERE e.template_id = t.id),
'[]'::json
) AS elements
FROM kiosktemplates t
`;
// GET /kiosk/sensor/:sensorCode/template/active
router.get('/:sensorCode/template/active', authSensor, async (req, res) => {
try {
const r = await query(
`${SELECT_WITH_ELEMENTS}
WHERE t.active = true AND t.archived = false
LIMIT 1`,
[], DB
);
if (!r.rows[0]) return res.status(404).json({ error: 'no active template' });
res.json(r.rows[0]);
} catch (err) {
console.error('[KIOSK/SENSOR] active error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// GET /kiosk/sensor/:sensorCode/templates/:id
router.get('/:sensorCode/templates/:id', authSensor, async (req, res) => {
try {
const r = await query(
`${SELECT_WITH_ELEMENTS} WHERE t.id = $1 LIMIT 1`,
[req.params.id], DB
);
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
res.json(r.rows[0]);
} catch (err) {
console.error('[KIOSK/SENSOR] get error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
module.exports = router;

View File

@@ -0,0 +1,232 @@
/**
* /marine/datasets — CRUD sulla tabella `datasets` del database `ml`.
*
* Layout:
* POST /marine/datasets/upload (multipart: file + metadata JSON)
* GET /marine/datasets (query: ?tags=a,b&type=copernicus&mine=1)
* GET /marine/datasets/:id (metadata)
* GET /marine/datasets/:id/download (presigned URL 1h)
* GET /marine/datasets/:id/raw (stream diretto)
* PATCH /marine/datasets/:id (aggiorna nome/tags/notes)
* DELETE /marine/datasets/:id (rimuove da MinIO + DB)
*
* I file vivono SEMPRE nel bucket MinIO "ml.datasets". La colonna `file_key` salva
* il nome dell'oggetto (basta `${uuid}.${ext}`, senza prefissi).
*/
const express = require('express');
const multer = require('multer');
const { randomUUID } = require('crypto');
const { query } = require('../storage/postgres');
const { bucketExists, upload, download, removeObject, getFileStream } = require('../storage/minio');
const router = express.Router();
const upload_mw = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 } });
// Bucket MinIO fisso per tutti i dataset.
const BUCKET = 'ml.datasets';
const parseTags = (s) => (s ? String(s).split(',').map(x => x.trim()).filter(Boolean) : []);
function rowToDataset(r) {
return {
id: r.id,
file_key: r.file_key,
nome: r.nome,
description: r.description,
tags: r.tags,
type: r.type,
format: r.format,
notes: r.notes,
created_by: r.created_by,
created_at: r.created_at,
updated_at: r.updated_at,
size_bytes: Number(r.size_bytes),
row_count: r.row_count != null ? Number(r.row_count) : null,
columns: r.columns,
copernicus_id: r.copernicus_id,
variables: r.variables,
variable_renames: r.variable_renames,
bbox: r.bbox,
start_date: r.start_date,
end_date: r.end_date,
params: r.params,
version: r.version,
};
}
// ── LIST ─────────────────────────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const filters = [];
const params = [];
if (req.query.type) {
params.push(req.query.type);
filters.push(`type = $${params.length}`);
}
if (req.query.tags) {
const tags = parseTags(req.query.tags);
if (tags.length) {
params.push(tags);
filters.push(`tags && $${params.length}`);
}
}
if (req.query.mine === '1' && req.user?.username) {
params.push(req.user.username);
filters.push(`created_by = $${params.length}`);
}
if (req.query.search) {
params.push(`%${req.query.search}%`);
filters.push(`(nome ILIKE $${params.length} OR description ILIKE $${params.length})`);
}
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const r = await query(
`SELECT * FROM datasets ${where} ORDER BY created_at DESC LIMIT 500`,
params, 'ml'
);
res.json({ count: r.rows.length, datasets: r.rows.map(rowToDataset) });
} catch (e) {
console.error('[marine/datasets list]', e);
res.status(500).json({ error: e.message });
}
});
// ── UPLOAD (usato dal servizio copernicus) ───────────────────────────────
router.post('/upload', upload_mw.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'file required (multipart field "file")' });
let meta = {};
try { meta = JSON.parse(req.body.metadata || '{}'); } catch { /* ignore */ }
const fmt = (meta.type && ['csv', 'json', 'netcdf'].includes(meta.type)) ? meta.type : 'csv';
const id = randomUUID();
const ext = fmt === 'netcdf' ? 'nc' : fmt;
const fileKey = `${id}.${ext}`;
await bucketExists(BUCKET);
await upload(BUCKET, fileKey, req.file.buffer, req.file.size, req.file.mimetype || 'application/octet-stream');
const createdBy = req.user?.username || meta.created_by || 'unknown';
const insert = await query(
`INSERT INTO datasets (
id, file_key, nome, description, tags, type, format, notes,
created_by, size_bytes, copernicus_id, variables, variable_renames,
bbox, start_date, end_date, params
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING *`,
[
id,
fileKey,
meta.nome || req.file.originalname || fileKey,
meta.description || null,
Array.isArray(meta.tags) ? meta.tags : [],
'copernicus',
fmt,
meta.notes || null,
createdBy,
req.file.size,
meta.copernicus_id || meta.copernicus_dataset_id || null,
Array.isArray(meta.variables) ? meta.variables : null,
meta.variable_renames ? JSON.stringify(meta.variable_renames) : null,
meta.bbox ? JSON.stringify(meta.bbox) : null,
meta.start_date || null,
meta.end_date || null,
JSON.stringify(meta.params || {}),
],
'ml'
);
res.status(201).json(rowToDataset(insert.rows[0]));
} catch (e) {
console.error('[marine/datasets upload]', e);
res.status(500).json({ error: e.message });
}
});
// ── DETAIL ───────────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
try {
const r = await query(`SELECT * FROM datasets WHERE id = $1`, [req.params.id], 'ml');
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
res.json(rowToDataset(r.rows[0]));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── DOWNLOAD presigned ───────────────────────────────────────────────────
router.get('/:id/download', async (req, res) => {
try {
const r = await query(`SELECT file_key FROM datasets WHERE id = $1`, [req.params.id], 'ml');
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
const { file_key } = r.rows[0];
const url = await download(BUCKET, file_key, 3600);
res.json({ url, expires_in: 3600 });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── STREAM raw (per download diretto dal browser) ────────────────────────
router.get('/:id/raw', async (req, res) => {
try {
const r = await query(`SELECT file_key, nome, format FROM datasets WHERE id = $1`, [req.params.id], 'ml');
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
const { file_key, nome, format } = r.rows[0];
const mime = format === 'json' ? 'application/json' : format === 'csv' ? 'text/csv' : 'application/octet-stream';
const ext = format === 'netcdf' ? 'nc' : format;
res.setHeader('Content-Type', mime);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(nome)}.${ext}"`);
const stream = await getFileStream(BUCKET, file_key);
stream.on('error', (err) => { console.error(err); res.end(); });
stream.pipe(res);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── PATCH metadata ───────────────────────────────────────────────────────
router.patch('/:id', async (req, res) => {
try {
const allowed = ['nome', 'description', 'tags', 'notes'];
const sets = [];
const params = [];
for (const k of allowed) {
if (k in req.body) {
params.push(req.body[k]);
sets.push(`${k} = $${params.length}`);
}
}
if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
// Trigger updated_at non presente nel DB: lo aggiorniamo manualmente.
sets.push('updated_at = NOW()');
params.push(req.params.id);
const r = await query(
`UPDATE datasets SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
params, 'ml'
);
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
res.json(rowToDataset(r.rows[0]));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── DELETE ───────────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
try {
const r = await query(`SELECT file_key FROM datasets WHERE id = $1`, [req.params.id], 'ml');
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
const { file_key } = r.rows[0];
try { await removeObject(BUCKET, file_key); } catch (e) { console.warn('[minio remove]', e.message); }
await query(`DELETE FROM datasets WHERE id = $1`, [req.params.id], 'ml');
res.status(204).end();
} catch (e) {
res.status(500).json({ error: e.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,100 @@
/**
* /pageconnections — registro sessioni di pagina attive, con heartbeat.
*
* Storage Redis:
* pageconn:{page} ZSET score=lastPing, member=session_id
* pageconn:meta:{session_id} HASH {page, user_id, created_at}
*
* Limiti:
* page = "test" → max 2 session_id attive (entro TTL heartbeat). Altrimenti 429.
*/
const express = require('express');
const Redis = require('ioredis');
const router = express.Router();
const redis = new Redis({
host: process.env.REDIS_HOST || 'meb-redis',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
});
const HEARTBEAT_TTL_SEC = 30;
const LIMITS = { test: 2 };
function nowSec() { return Math.floor(Date.now() / 1000); }
async function activeMembers(page) {
const min = nowSec() - HEARTBEAT_TTL_SEC;
// rimuovi stale
await redis.zremrangebyscore(`pageconn:${page}`, '-inf', `(${min}`);
return redis.zrange(`pageconn:${page}`, 0, -1);
}
router.post('/', async (req, res) => {
try {
const { page, session_id, user_id } = req.body || {};
if (!page || !session_id) return res.status(400).json({ error: 'page and session_id required' });
const active = await activeMembers(page);
const limit = LIMITS[page];
if (limit && !active.includes(session_id) && active.length >= limit) {
return res.status(429).json({ error: 'slot full', active: active.length, limit });
}
const ts = nowSec();
await redis.zadd(`pageconn:${page}`, ts, session_id);
await redis.hset(`pageconn:meta:${session_id}`, {
page,
user_id: user_id || req.user?.username || 'unknown',
created_at: String(ts),
});
await redis.expire(`pageconn:meta:${session_id}`, HEARTBEAT_TTL_SEC * 4);
res.status(201).json({ session_id, page, active: (await activeMembers(page)).length, limit: limit || null });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/:sid/ping', async (req, res) => {
try {
const sid = req.params.sid;
const meta = await redis.hgetall(`pageconn:meta:${sid}`);
if (!meta || !meta.page) return res.status(404).json({ error: 'session not found' });
const ts = nowSec();
await redis.zadd(`pageconn:${meta.page}`, ts, sid);
await redis.expire(`pageconn:meta:${sid}`, HEARTBEAT_TTL_SEC * 4);
res.json({ ok: true, ts });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.delete('/:sid', async (req, res) => {
try {
const sid = req.params.sid;
const meta = await redis.hgetall(`pageconn:meta:${sid}`);
if (meta && meta.page) {
await redis.zrem(`pageconn:${meta.page}`, sid);
}
await redis.del(`pageconn:meta:${sid}`);
res.status(204).end();
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/:page', async (req, res) => {
try {
const members = await activeMembers(req.params.page);
res.json({
page: req.params.page,
active: members.length,
limit: LIMITS[req.params.page] || null,
sessions: members,
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
module.exports = router;

View File

@@ -2,27 +2,22 @@ const router = require('express').Router();
const crypto = require('crypto'); const crypto = require('crypto');
const { query } = require('../storage/postgres'); const { query } = require('../storage/postgres');
const sets = ['forecasts', 'sensors']; const sets = ['forecasts', 'sensors', 'marine'];
function hashSensorCode(code) { function hashSensorCode(code) {
return crypto.createHash('sha256').update(code).digest('hex'); return crypto.createHash('sha256').update(code).digest('hex');
} }
/** /**
* GET /params/sensor/:sensorCode/active?set=sensors * Middleware: valida sensor code e verifica che il sensore sia attivo.
* Autenticazione tramite SENSOR_CODE (stesso meccanismo di realtime) * Salva sensor.id in req.sensorId.
*/ */
router.get('/:sensorCode/active', async (req, res) => { async function authenticateSensor(req, res, next) {
const { sensorCode } = req.params; const { sensorCode } = req.params;
const { set } = req.query;
if (!set || !sets.includes(set))
return res.status(400).json({ error: 'SET parameter invalid' });
try { try {
const hashed = hashSensorCode(sensorCode); const hashed = hashSensorCode(sensorCode);
const sensor = await query( const sensor = await query(
'SELECT id, is_active FROM sensors WHERE code_hash = $1', 'SELECT id, active FROM sensors WHERE code_hash = $1',
[hashed], [hashed],
'sensors' 'sensors'
); );
@@ -30,11 +25,29 @@ router.get('/:sensorCode/active', async (req, res) => {
if (!sensor.rows[0]) { if (!sensor.rows[0]) {
return res.status(401).json({ error: 'Sensor code not valid' }); return res.status(401).json({ error: 'Sensor code not valid' });
} }
if (!sensor.rows[0].active) {
if (!sensor.rows[0].is_active) {
return res.status(403).json({ error: 'Sensor is not active' }); return res.status(403).json({ error: 'Sensor is not active' });
} }
req.sensorId = sensor.rows[0].id;
next();
} catch (err) {
console.error('[PARAMS/SENSOR] Auth error:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
}
/**
* GET /params/sensor/:sensorCode/active?set=sensors
* Ritorna il set di parametri attivo (forecasts, sensors, marine)
*/
router.get('/:sensorCode/active', authenticateSensor, async (req, res) => {
const { set } = req.query;
if (!set || !sets.includes(set))
return res.status(400).json({ error: 'SET parameter invalid' });
try {
const result = await query( const result = await query(
`SELECT * FROM ${set} WHERE active = true LIMIT 1`, `SELECT * FROM ${set} WHERE active = true LIMIT 1`,
[], [],

35
api/src/routes/queue.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* /queue — stato delle code (solo read). La coda vera è gestita in Redis
* dai servizi esecutori (ml-service per `train`). Qui aggreghiamo lo stato
* leggendo la tabella `jobs`.
*/
const express = require('express');
const { query } = require('../storage/postgres');
const router = express.Router();
router.get('/', async (req, res) => {
try {
const type = req.query.type || 'train';
const r = await query(
`SELECT id, type, status, created_by, created_at, started_at
FROM jobs
WHERE type = $1 AND status IN ('queued','running')
ORDER BY created_at ASC`,
[type], 'ml'
);
const queued = r.rows.filter(x => x.status === 'queued');
const running = r.rows.filter(x => x.status === 'running');
res.json({
type,
queued_count: queued.length,
running_count: running.length,
queued,
running,
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
module.exports = router;

609
api/src/routes/rules.js Normal file
View File

@@ -0,0 +1,609 @@
/**
* Rulesets API
* Base: /rules
*
* Tipi supportati: logs | forecast_current | forecast_hourly | marine_current | marine_hourly
*
* Un ruleset ha:
* - version {major, build, patch} (interi 1..100, unici per tipo)
* - description, tags[]
* - items JSONB: array di { ref, path, enabled, meta }
* ref: identificatore STABILE scelto dall'utente (chiave logica, usata come tag Influx)
* path: SK path (logs) o codice openmeteo (forecast/marine)
* meta: libero (unit, measurement, sk_path, name, group_name, category, ...)
* - active (un solo attivo per tipo), archived
*
* Deploy: POST /rules/:type/:id/deploy { sensors: [name,...] }
* -> salva in ruleset_deployments
* -> notifica il servizio realtime (HTTP interno) che fara' il push WS al plugin
*/
const router = require('express').Router();
const crypto = require('crypto');
const { query, getClient } = require('../storage/postgres');
const DB = 'rules';
const VALID_TYPES = ['logs', 'forecast_current', 'forecast_hourly', 'marine_current', 'marine_hourly'];
// Sostituisce il default DB `gen_random_uuid()` (estensione pgcrypto non presente).
function genUUID() {
return crypto.randomUUID();
}
function isValidType(t) { return VALID_TYPES.includes(t); }
function parseVersion(body) {
// accetta sia { version_major, version_build, version_patch } che { version: "1.0.0" }
let M = body?.version_major, B = body?.version_build, P = body?.version_patch;
if (M === undefined && typeof body?.version === 'string') {
const m = body.version.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (m) { M = +m[1]; B = +m[2]; P = +m[3]; }
}
const toInt = v => (v === undefined || v === null || v === '') ? null : parseInt(v, 10);
M = toInt(M); B = toInt(B); P = toInt(P);
return { M, B, P };
}
function validVersionPart(n, min = 0) {
return Number.isInteger(n) && n >= min && n <= 100;
}
function validateItems(items) {
if (!Array.isArray(items)) return 'items must be an array';
const refs = new Set();
for (const it of items) {
if (!it || typeof it !== 'object') return 'each item must be an object';
if (!it.ref || typeof it.ref !== 'string') return 'each item needs a non-empty "ref"';
if (refs.has(it.ref)) return `duplicate ref "${it.ref}"`;
refs.add(it.ref);
if (it.path !== undefined && typeof it.path !== 'string') return `item "${it.ref}" has invalid path`;
if (it.enabled !== undefined && typeof it.enabled !== 'boolean') return `item "${it.ref}" enabled must be boolean`;
if (it.meta !== undefined && (typeof it.meta !== 'object' || it.meta === null || Array.isArray(it.meta))) {
return `item "${it.ref}" meta must be an object`;
}
}
return null;
}
function normalizeItems(items) {
return (items || []).map(it => ({
ref: String(it.ref),
path: it.path != null ? String(it.path) : '',
enabled: it.enabled === undefined ? true : !!it.enabled,
meta: it.meta && typeof it.meta === 'object' ? it.meta : {}
}));
}
function rowToRuleset(row) {
if (!row) return null;
return {
id: row.id,
type: row.type,
version: {
major: row.version_major,
build: row.version_build,
patch: row.version_patch,
str: `${row.version_major}.${row.version_build}.${row.version_patch}`
},
description: row.description,
tags: row.tags || [],
items: row.items || [],
active: row.active,
archived: row.archived,
created_at: row.created_at,
updated_at: row.updated_at
};
}
async function logChange(rulesetId, type, action, userId, payload) {
try {
await query(
`INSERT INTO ruleset_changes (ruleset_id, type, action, user_id, payload)
VALUES ($1, $2, $3, $4, $5)`,
[rulesetId, type, action, userId || null, payload ? JSON.stringify(payload) : null],
DB
);
} catch (e) {
console.error('[RULES] audit log error:', e.message);
}
}
/* ══════════════════════════════════════════════════════════════
SENSORS (deve stare PRIMA delle route /:type/:id per via del routing Express)
══════════════════════════════════════════════════════════════ */
// GET /rules/-/sensors → lista sensori disponibili (dal DB sensors)
router.get('/-/sensors', async (req, res) => {
try {
const r = await query(`SELECT name, created_at FROM sensors ORDER BY name`, [], 'sensors');
res.json(r.rows);
} catch (err) {
console.error('[RULES] sensors list error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
/* ══════════════════════════════════════════════════════════════
READS
══════════════════════════════════════════════════════════════ */
// GET /rules → { logs:[], forecast_current:[], ... } (lista completa, senza items per leggerezza)
router.get('/', async (req, res) => {
try {
const r = await query(
`SELECT id, type, version_major, version_build, version_patch,
description, tags, active, archived, created_at, updated_at,
jsonb_array_length(items) AS items_count
FROM rulesets
ORDER BY type, version_major DESC, version_build DESC, version_patch DESC`,
[], DB
);
const grouped = Object.fromEntries(VALID_TYPES.map(t => [t, []]));
for (const row of r.rows) {
grouped[row.type].push({
...rowToRuleset(row),
items: undefined,
items_count: row.items_count
});
}
res.json(grouped);
} catch (err) {
console.error('[RULES] list error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// GET /rules/:type → lista versioni del tipo
router.get('/:type', async (req, res) => {
const { type } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const r = await query(
`SELECT * FROM rulesets WHERE type = $1
ORDER BY version_major DESC, version_build DESC, version_patch DESC`,
[type], DB
);
res.json(r.rows.map(rowToRuleset));
} catch (err) {
console.error('[RULES] list type error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// GET /rules/:type/:id
router.get('/:type/:id', async (req, res) => {
const { type, id } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const r = await query(`SELECT * FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
res.json(rowToRuleset(r.rows[0]));
} catch (err) {
console.error('[RULES] get error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// GET /rules/:type/active → ruleset attivo
router.get('/:type/-/active', async (req, res) => {
const { type } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const r = await query(
`SELECT * FROM rulesets WHERE type = $1 AND active = true AND archived = false LIMIT 1`,
[type], DB
);
if (!r.rows[0]) return res.status(404).json({ error: 'no active ruleset' });
res.json(rowToRuleset(r.rows[0]));
} catch (err) {
console.error('[RULES] active error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
/* ══════════════════════════════════════════════════════════════
WRITES
══════════════════════════════════════════════════════════════ */
// POST /rules/:type
router.post('/:type', async (req, res) => {
const { type } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
let { M, B, P } = parseVersion(req.body);
if (M === null) M = 1;
if (B === null) B = 0;
if (P === null) P = 0;
if (!validVersionPart(M, 1) || !validVersionPart(B) || !validVersionPart(P)) {
return res.status(400).json({ error: 'version parts must be integers (major 1..100, build/patch 0..100)' });
}
const description = typeof req.body?.description === 'string' ? req.body.description : '';
const tags = Array.isArray(req.body?.tags) ? req.body.tags.map(String) : [];
const items = normalizeItems(req.body?.items);
const itemsErr = validateItems(items);
if (itemsErr) return res.status(400).json({ error: itemsErr });
try {
const newId = genUUID();
const r = await query(
`INSERT INTO rulesets (id, type, version_major, version_build, version_patch,
description, tags, items, active, archived,
created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,false,false,NOW(),NOW()) RETURNING *`,
[newId, type, M, B, P, description, tags, JSON.stringify(items)],
DB
);
const rs = rowToRuleset(r.rows[0]);
logChange(rs.id, type, 'created', req.user?.user_id, { version: rs.version.str });
res.status(201).json(rs);
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'version already exists for this type' });
}
console.error('[RULES] create error:', err.message);
res.status(500).json({ error: err.message || 'internal error' });
}
});
// PUT /rules/:type/:id → update campi (version, description, tags, items)
router.put('/:type/:id', async (req, res) => {
const { type, id } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
const fields = [];
const values = [];
let i = 1;
if (req.body?.description !== undefined) {
fields.push(`description = $${i++}`); values.push(String(req.body.description));
}
if (req.body?.tags !== undefined) {
if (!Array.isArray(req.body.tags)) return res.status(400).json({ error: 'tags must be array' });
fields.push(`tags = $${i++}`); values.push(req.body.tags.map(String));
}
if (req.body?.items !== undefined) {
const items = normalizeItems(req.body.items);
const itemsErr = validateItems(items);
if (itemsErr) return res.status(400).json({ error: itemsErr });
fields.push(`items = $${i++}`); values.push(JSON.stringify(items));
}
if (req.body?.version_major !== undefined || req.body?.version_build !== undefined ||
req.body?.version_patch !== undefined || req.body?.version !== undefined) {
const { M, B, P } = parseVersion(req.body);
if (!validVersionPart(M, 1) || !validVersionPart(B) || !validVersionPart(P)) {
return res.status(400).json({ error: 'version parts must be integers (major 1..100, build/patch 0..100)' });
}
fields.push(`version_major = $${i++}`); values.push(M);
fields.push(`version_build = $${i++}`); values.push(B);
fields.push(`version_patch = $${i++}`); values.push(P);
}
if (!fields.length) return res.status(400).json({ error: 'no fields to update' });
// Trigger set_updated_at non presente: lo facciamo manualmente.
fields.push('updated_at = NOW()');
values.push(id, type);
try {
const r = await query(
`UPDATE rulesets SET ${fields.join(', ')}
WHERE id = $${i++} AND type = $${i}
RETURNING *`,
values, DB
);
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
const rs = rowToRuleset(r.rows[0]);
logChange(rs.id, type, 'updated', req.user?.user_id, null);
res.json(rs);
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'version already exists for this type' });
}
console.error('[RULES] update error:', err.message);
res.status(500).json({ error: err.message || 'internal error' });
}
});
// PATCH /rules/:type/:id/active → toggle (deattiva le altre dello stesso tipo)
router.patch('/:type/:id/active', async (req, res) => {
const { type, id } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
const client = await getClient(DB);
try {
await client.query('BEGIN');
const cur = await client.query(
`SELECT active, archived FROM rulesets WHERE id = $1 AND type = $2`,
[id, type]
);
if (!cur.rows[0]) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); }
if (cur.rows[0].archived && !cur.rows[0].active) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'cannot activate an archived ruleset' });
}
const willActivate = !cur.rows[0].active;
if (willActivate) {
await client.query(
`UPDATE rulesets SET active = false, updated_at = NOW()
WHERE type = $1 AND active = true`,
[type]
);
}
const r = await client.query(
`UPDATE rulesets SET active = $1, updated_at = NOW()
WHERE id = $2 RETURNING *`,
[willActivate, id]
);
await client.query('COMMIT');
logChange(id, type, willActivate ? 'activated' : 'deactivated', req.user?.user_id, null);
res.json({ active: r.rows[0].active, ruleset: rowToRuleset(r.rows[0]) });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('[RULES] active error:', err.message);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
// PATCH /rules/:type/:id/archive → toggle
router.patch('/:type/:id/archive', async (req, res) => {
const { type, id } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const cur = await query(`SELECT archived, active FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
const willArchive = !cur.rows[0].archived;
// archiviare implica disattivare
const r = await query(
`UPDATE rulesets SET archived = $1,
active = CASE WHEN $1 = true THEN false ELSE active END,
updated_at = NOW()
WHERE id = $2 RETURNING *`,
[willArchive, id], DB
);
logChange(id, type, willArchive ? 'archived' : 'unarchived', req.user?.user_id, null);
res.json({ archived: r.rows[0].archived, ruleset: rowToRuleset(r.rows[0]) });
} catch (err) {
console.error('[RULES] archive error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// DELETE /rules/:type/:id
// FK CASCADE non e' garantita lato DB: cancelliamo manualmente in transazione
// prima i deployments e poi il ruleset. ruleset_changes (audit) viene preservato
// volutamente — annulliamo solo ruleset_id se necessario.
router.delete('/:type/:id', async (req, res) => {
const { type, id } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
const client = await getClient(DB);
try {
await client.query('BEGIN');
const cur = await client.query(
`SELECT active FROM rulesets WHERE id = $1 AND type = $2`,
[id, type]
);
if (!cur.rows[0]) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'not found' });
}
if (cur.rows[0].active) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'cannot delete active ruleset' });
}
await client.query(`DELETE FROM ruleset_deployments WHERE ruleset_id = $1`, [id]);
await client.query(`DELETE FROM rulesets WHERE id = $1`, [id]);
await client.query('COMMIT');
logChange(id, type, 'deleted', req.user?.user_id, null);
res.json({ deleted: true });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('[RULES] delete error:', err.message);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
/* ══════════════════════════════════════════════════════════════
ITEMS: helper endpoints (comodi per la UI)
══════════════════════════════════════════════════════════════ */
// POST /rules/:type/:id/items → aggiungi item
router.post('/:type/:id/items', async (req, res) => {
const { type, id } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
const items = cur.rows[0].items || [];
const newItem = normalizeItems([req.body || {}])[0];
if (!newItem.ref) return res.status(400).json({ error: 'ref required' });
if (items.some(it => it.ref === newItem.ref)) {
return res.status(409).json({ error: `ref "${newItem.ref}" already exists` });
}
items.push(newItem);
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
logChange(id, type, 'item_added', req.user?.user_id, { ref: newItem.ref });
res.status(201).json(newItem);
} catch (err) {
console.error('[RULES] add item error:', err.message);
res.status(500).json({ error: err.message || 'internal error' });
}
});
// PUT /rules/:type/:id/items/:ref → patch item (per ref)
router.put('/:type/:id/items/:ref', async (req, res) => {
const { type, id, ref } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
const items = cur.rows[0].items || [];
const idx = items.findIndex(it => it.ref === ref);
if (idx < 0) return res.status(404).json({ error: 'item not found' });
const body = req.body || {};
const newRef = body.ref !== undefined ? String(body.ref) : items[idx].ref;
if (newRef !== ref && items.some(it => it.ref === newRef)) {
return res.status(409).json({ error: `ref "${newRef}" already exists` });
}
items[idx] = {
ref: newRef,
path: body.path !== undefined ? String(body.path) : items[idx].path,
enabled: body.enabled !== undefined ? !!body.enabled : items[idx].enabled,
meta: body.meta !== undefined
? (body.meta && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : {})
: { ...(items[idx].meta || {}), ...Object.fromEntries(
Object.entries(body).filter(([k]) => !['ref','path','enabled','meta'].includes(k))
) }
};
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
logChange(id, type, 'item_updated', req.user?.user_id, { ref: newRef });
res.json(items[idx]);
} catch (err) {
console.error('[RULES] update item error:', err.message);
res.status(500).json({ error: err.message || 'internal error' });
}
});
// PATCH /rules/:type/:id/items/:ref/toggle
router.patch('/:type/:id/items/:ref/toggle', async (req, res) => {
const { type, id, ref } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
const items = cur.rows[0].items || [];
const idx = items.findIndex(it => it.ref === ref);
if (idx < 0) return res.status(404).json({ error: 'item not found' });
items[idx].enabled = !items[idx].enabled;
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
res.json({ enabled: items[idx].enabled });
} catch (err) {
console.error('[RULES] toggle item error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// DELETE /rules/:type/:id/items/:ref
router.delete('/:type/:id/items/:ref', async (req, res) => {
const { type, id, ref } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
const items = (cur.rows[0].items || []).filter(it => it.ref !== ref);
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
res.json({ deleted: true });
} catch (err) {
console.error('[RULES] delete item error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
/* ══════════════════════════════════════════════════════════════
SENSORS & DEPLOYMENT
══════════════════════════════════════════════════════════════ */
// GET /rules/:type/:id/deployments → sensori su cui e' deployato
router.get('/:type/:id/deployments', async (req, res) => {
const { type, id } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
try {
const r = await query(
`SELECT sensor_name, deployed_at, acked_at
FROM ruleset_deployments
WHERE ruleset_id = $1 AND type = $2
ORDER BY deployed_at DESC`,
[id, type], DB
);
res.json(r.rows);
} catch (err) {
console.error('[RULES] deployments error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
// POST /rules/:type/:id/deploy { sensors: [name, ...] }
// Registra il deploy e notifica il servizio realtime, che fara' il push WS al plugin.
router.post('/:type/:id/deploy', async (req, res) => {
const { type, id } = req.params;
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
const sensors = Array.isArray(req.body?.sensors) ? req.body.sensors.map(String).filter(Boolean) : [];
if (!sensors.length) return res.status(400).json({ error: 'sensors array required' });
try {
const rs = await query(`SELECT * FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
if (!rs.rows[0]) return res.status(404).json({ error: 'not found' });
if (rs.rows[0].archived) return res.status(409).json({ error: 'cannot deploy archived ruleset' });
const ruleset = rowToRuleset(rs.rows[0]);
// upsert deployments
for (const name of sensors) {
await query(
`INSERT INTO ruleset_deployments (sensor_name, type, ruleset_id, deployed_at, acked_at)
VALUES ($1, $2, $3, NOW(), NULL)
ON CONFLICT (sensor_name, type) DO UPDATE
SET ruleset_id = EXCLUDED.ruleset_id,
deployed_at = NOW(),
acked_at = NULL`,
[name, type, id], DB
);
}
// notifica realtime (best-effort)
const RT = process.env.REALTIME_URL || 'http://meb-realtime:3000';
const KEY = process.env.INTERNAL_API_KEY;
const results = { pushed: [], offline: [], errors: [] };
if (KEY) {
try {
const r = await fetch(`${RT}/rules/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
body: JSON.stringify({ sensors, type, ruleset })
});
if (r.ok) {
const j = await r.json().catch(() => ({}));
Object.assign(results, j);
} else {
results.errors.push(`realtime HTTP ${r.status}`);
}
} catch (e) {
results.errors.push(`realtime unreachable: ${e.message}`);
}
} else {
results.errors.push('INTERNAL_API_KEY missing');
}
logChange(id, type, 'deployed', req.user?.user_id, { sensors, results });
res.json({ deployed: sensors, ...results });
} catch (err) {
console.error('[RULES] deploy error:', err.message);
res.status(500).json({ error: err.message || 'internal error' });
}
});
// POST /rules/:type/:id/ack { sensor } → chiamato dal servizio realtime quando il plugin conferma
router.post('/:type/:id/ack', async (req, res) => {
if (!req.internal) return res.status(403).json({ error: 'forbidden' });
const { type, id } = req.params;
const sensor = req.body?.sensor;
if (!isValidType(type) || !sensor) return res.status(400).json({ error: 'bad request' });
try {
await query(
`UPDATE ruleset_deployments
SET acked_at = NOW()
WHERE sensor_name = $1 AND type = $2 AND ruleset_id = $3`,
[String(sensor), type, id], DB
);
res.json({ ok: true });
} catch (err) {
console.error('[RULES] ack error:', err.message);
res.status(500).json({ error: 'internal error' });
}
});
module.exports = router;

107
api/src/routes/sessions.js Normal file
View File

@@ -0,0 +1,107 @@
const router = require('express').Router();
const { query: dbQuery } = require('../storage/postgres');
const { listInfluxSessions, querySessionHistory, exportSessionCSV } = require('../storage/influx');
/**
* GET /sessions/history
* Fonte primaria: InfluxDB (tag sensor + session sul measurement "logs").
* Arricchisce con i metadati opzionali da PostgreSQL (sessiondataref): nome, tags, descrizione.
*/
router.get('/history', async (req, res) => {
try {
const sessions = await listInfluxSessions();
let pgMap = {};
try {
const result = await dbQuery(
`SELECT * FROM sessiondataref`,
[],
'sensors'
);
result.rows.forEach(r => { pgMap[r.session_id] = r; });
} catch (_) {}
const enriched = sessions.map(s => ({
session_id: s.session,
sensor_name: s.sensor,
startTime: s.startTime,
endTime: s.endTime,
// campi opzionali da PostgreSQL
name: pgMap[s.session]?.name || null,
description: pgMap[s.session]?.description || null,
tags: pgMap[s.session]?.tags || [],
}));
res.json(enriched);
} catch (err) {
console.error('[sessions] history error:', err.message);
res.status(500).json({ error: 'internal server error' });
}
});
/**
* GET /sessions/:sensorId/data?session=sXXXX&from=ISO&to=ISO
* Restituisce i dati storici di una sessione come JSON (righe pivotate da InfluxDB).
*/
router.get('/:sensorId/data', async (req, res) => {
const { sensorId } = req.params;
const { session, from, to } = req.query;
if (!session) return res.status(400).json({ error: 'session param required' });
try {
let since = from || null;
if (!since) {
const result = await dbQuery(
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
[session],
'sensors'
);
since = result.rows[0]?.created_at?.toISOString() || '-30d';
}
const until = to ? new Date(parseInt(to)).toISOString() : null;
const rows = await querySessionHistory(sensorId, session, since, until);
res.json(rows);
} catch (err) {
console.error('[sessions] data error:', err.message);
res.status(500).json({ error: 'internal server error' });
}
});
/**
* GET /sessions/:sensorId/csv?session=sXXXX&from=ms&to=ms
* Esporta i dati di una sessione come CSV (supporta intervallo opzionale).
*/
router.get('/:sensorId/csv', async (req, res) => {
const { sensorId } = req.params;
const { session, from, to } = req.query;
if (!session) return res.status(400).json({ error: 'session param required' });
try {
let since = from ? new Date(parseInt(from)).toISOString() : null;
if (!since) {
const result = await dbQuery(
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
[session],
'sensors'
);
since = result.rows[0]?.created_at?.toISOString() || '-30d';
}
const until = to ? new Date(parseInt(to)).toISOString() : null;
const csv = await exportSessionCSV(sensorId, session, since, until);
if (!csv) return res.status(404).json({ error: 'No data found for this session' });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="session_${session}_${sensorId}.csv"`);
res.send(csv);
} catch (err) {
console.error('[sessions] csv error:', err.message);
res.status(500).json({ error: 'CSV export failed' });
}
});
module.exports = router;

View File

@@ -10,6 +10,7 @@ const client = new InfluxDB({ url, token })
const write = client.getWriteApi(org, boatTelemetry); const write = client.getWriteApi(org, boatTelemetry);
const querying = client.getQueryApi(org); const querying = client.getQueryApi(org);
console.log("InfluxDB client initialized with config:", { url, org, token });
async function append(measurement, sensor, data) { async function append(measurement, sensor, data) {
const point = new Point(measurement) const point = new Point(measurement)
@@ -59,19 +60,134 @@ async function query(bucket, relativeTime, measurement, sensor, field) {
} }
// Sorgente di verità per i logs di sessione: stesso bucket usato da
// realtime/store/influx.js. Sovrascrivibile via env per ambiente.
const sessionBucket = process.env.INFLX_BUCKET_LOGS || process.env.INFLX_BUCKET || 'logs';
/**
* Query storica per una sessione di registrazione.
* @param {string} sensor - nome sensore
* @param {string} session - session_id (tag InfluxDB)
* @param {string} since - ISO timestamp o duration (es. "-30d")
* @param {string|null} until - ISO timestamp fine (opzionale)
* @returns {Promise<Array<Object>>}
*/
async function querySessionHistory(sensor, session, since, until = null) {
const rangeStr = until ? `start: ${since}, stop: ${until}` : `start: ${since}`;
const fluxQuery = `
from(bucket: "${sessionBucket}")
|> range(${rangeStr})
|> filter(fn: (r) => r._measurement == "logs")
|> filter(fn: (r) => r.sensor == "${sensor}")
|> filter(fn: (r) => r.session == "${session}")
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
|> sort(columns: ["_time"])
`;
const rows = [];
return new Promise((resolve, reject) => {
querying.queryRows(fluxQuery, {
next(row, tableMeta) { rows.push(tableMeta.toObject(row)); },
error: reject,
complete() { resolve(rows); },
});
});
}
/**
* Esporta i dati di una sessione come stringa CSV.
* @param {string} sensor
* @param {string} session
* @param {string} since
* @param {string|null} until
* @returns {Promise<string>}
*/
async function exportSessionCSV(sensor, session, since, until = null) {
const rows = await querySessionHistory(sensor, session, since, until);
if (rows.length === 0) return '';
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
const fieldNames = new Set();
for (const row of rows) {
for (const key of Object.keys(row)) {
if (!metaKeys.has(key) && key !== '_time') fieldNames.add(key);
}
}
const fields = Array.from(fieldNames).sort();
const header = ['timestamp', ...fields].join(',');
const csvRows = rows.map(row => {
const values = fields.map(f => { const v = row[f]; return (v == null) ? '' : v; });
return [row._time || '', ...values].join(',');
});
return header + '\n' + csvRows.join('\n') + '\n';
}
/**
* Utility interna: esegue una Flux query e restituisce le righe come array di oggetti.
*/
function runFlux(fluxQuery) {
const rows = [];
return new Promise((resolve, reject) => {
querying.queryRows(fluxQuery, {
next(row, tableMeta) { rows.push(tableMeta.toObject(row)); },
error: reject,
complete() { resolve(rows); },
});
});
}
/**
* Elenca tutte le sessioni presenti in InfluxDB, con primo e ultimo timestamp.
* Sorgente di verità: tag sensor + session sul measurement "logs".
* @param {string} [lookback='-5y'] - range di ricerca (es. '-365d', '-5y')
* @returns {Promise<Array<{session, sensor, startTime, endTime}>>}
*/
async function listInfluxSessions(lookback = '-5y') {
const base = `
from(bucket: "${sessionBucket}")
|> range(start: ${lookback})
|> filter(fn: (r) => r._measurement == "logs")
|> group(columns: ["sensor", "session"])
`;
const [firstRows, lastRows] = await Promise.all([
runFlux(base + '|> first() |> keep(columns: ["_time", "sensor", "session"])'),
runFlux(base + '|> last() |> keep(columns: ["_time", "sensor", "session"])'),
]);
const map = {};
firstRows.forEach(r => {
if (!r.session) return;
map[r.session] = { session: r.session, sensor: r.sensor, startTime: r._time };
});
lastRows.forEach(r => {
if (map[r.session]) map[r.session].endTime = r._time;
});
return Object.values(map).sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
}
async function checkInflux() { async function checkInflux() {
try { try {
await querying.rows(`from(bucket: "boat") |> range(start: -1s) |> limit(n:1)`).next(); const result = await querying.collectRows(
return true; `from(bucket: "boat") |> range(start: -1s) |> limit(n:1)`
);
console.log('InfluxDB: OK');
return { ok: true };
} catch (error) { } catch (error) {
return false; console.error('InfluxDB check failed:', {
message: error.message,
statusCode: error.statusCode ?? 'N/A',
body: error.body ?? 'N/A'
});
return false
} }
} }
module.exports = { module.exports = {
write:append, write: append,
writeBatch, writeBatch,
query, query,
listInfluxSessions,
querySessionHistory,
exportSessionCSV,
checkInflux checkInflux
} }

View File

@@ -8,6 +8,13 @@ const client = new Minio.Client({
secretKey: process.env.MINIO_SECRET_KEY secretKey: process.env.MINIO_SECRET_KEY
}) })
// Unified ML bucket: tutti gli oggetti del dominio ML vivono nel bucket
// indicato da MINIO_BUCKET (default "ml"), con prefissi logici:
// datasets/<uuid>.<ext>
// models/<model_id>/<version>/<patch>/...
// trainings/<training_id>/logs.jsonl
const ML_BUCKET = process.env.MINIO_BUCKET || 'ml';
// Buckets // Buckets
@@ -132,7 +139,51 @@ async function checkMinio() {
} }
} }
/**
* Legge un oggetto e restituisce il suo contenuto come stringa UTF-8 (comodo per file testo/markdown).
*/
async function readText(bucket, objectName) {
const stream = await client.getObject(bucket, objectName);
return await new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (c) => chunks.push(c));
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
stream.on('error', reject);
});
}
/**
* Scrive una stringa come oggetto (auto-create del bucket).
*/
async function writeText(bucket, objectName, content, contentType = 'text/markdown; charset=utf-8') {
await bucketExists(bucket);
const buf = Buffer.from(content, 'utf8');
await client.putObject(bucket, objectName, buf, buf.length, { 'Content-Type': contentType });
return { bucket, objectName, size: buf.length };
}
/**
* Elenca oggetti di un bucket in forma compatta (name, size, lastModified).
*/
async function listObjects(bucket) {
await bucketExists(bucket);
return new Promise((resolve, reject) => {
const out = [];
const stream = client.listObjects(bucket, '', true);
stream.on('data', (o) => out.push({
name: o.name,
size: o.size,
lastModified: o.lastModified,
etag: o.etag,
}));
stream.on('error', reject);
stream.on('end', () => resolve(out));
});
}
module.exports = { module.exports = {
client,
ML_BUCKET,
bucketExists, bucketExists,
getBuckets, getBuckets,
getBucket, getBucket,
@@ -142,5 +193,8 @@ module.exports = {
upload, upload,
download, download,
getFileStream, getFileStream,
checkMinio checkMinio,
readText,
writeText,
listObjects,
} }

View File

@@ -1,19 +1,25 @@
const { Pool } = require('pg'); const { Pool } = require('pg');
const config = { const config = {
user: process.env.POSTGRES_USER, user: process.env.DB_USER,
password: process.env.POSTGRES_PASSWORD, password: process.env.DB_PASSWORD,
host: process.env.POSTGRES_HOST, host: process.env.DB_HOST,
port: process.env.POSTGRES_PORT, port: process.env.DB_PORT,
max: 10, max: 10,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000 connectionTimeoutMillis: 5000
} }
const pools = { const pools = {
users: new Pool({ ...config, database: process.env.DATA_DB }), data: new Pool({ ...config, database: process.env.DATA_DB }),
references: new Pool({ ...config, database: process.env.REFERENCES_DB }), users: new Pool({ ...config, database: process.env.USERS_DB }),
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }) sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }),
rules: new Pool({ ...config, database: process.env.RULES_DB || 'rules' }),
ml: new Pool({ ...config, database: process.env.ML_DB || 'ml' }),
references: new Pool({ ...config, database: process.env.REFERENCES_DB || 'references' }),
// Le tabelle kiosktemplates / kioskelements vivono nel DB `sensors`.
// KIOSK_DB resta override-abile per environment legacy.
kiosk: new Pool({ ...config, database: process.env.KIOSK_DB || 'sensors' }),
} }
Object.entries(pools).forEach(([name, pool]) => { Object.entries(pools).forEach(([name, pool]) => {
@@ -30,6 +36,7 @@ Object.entries(pools).forEach(([name, pool]) => {
async function getClient(db) { async function getClient(db) {
const pool = pools[db]; const pool = pools[db];
if (!pool) throw new Error(`Database pool type ${db} does not exist`); if (!pool) throw new Error(`Database pool type ${db} does not exist`);
console.log(`Acquiring client for ${db} database... with config:`, config);
return await pool.connect(); return await pool.connect();
} }
@@ -71,13 +78,20 @@ async function remove(table, condition, params, type = 'users') {
return await query(sql, params, type); return await query(sql, params, type);
} }
// initKioskSchema rimosso: lo schema kiosktemplates/kioskelements vive nel DB
// `sensors` ed e' gestito dalla migration 007_kiosktemplates.sql. L'auto-create
// qui creava un schema legacy divergente (UUID + JSONB content) sul DB sbagliato.
async function checkPostgres() { async function checkPostgres() {
const status = {}; const status = {};
console.log("Checking PostgreSQL connections with config:", config);
for (const [name, pool] of Object.entries(pools)) { for (const [name, pool] of Object.entries(pools)) {
try { try {
await pool.query('SELECT NOW()'); await pool.query('SELECT NOW()');
console.log(`PostgreSQL connection check successful for ${name}`);
status[name] = 'connected'; status[name] = 'connected';
} catch (error) { } catch (error) {
console.log(`PostgreSQL connection check failed for ${name}:`, error.message);
status[name] = 'disconnected'; status[name] = 'disconnected';
} }
} }

View File

@@ -3,15 +3,17 @@ DB_HOST=
DB_PASSWORD= DB_PASSWORD=
DB_PORT= DB_PORT=
DATA_DB= USERS_DB=
REDIS_HOST=
REDIS_PORT=
REDIS_PASSWORD=
# In locale: lasciare vuoto (il cookie su localhost funziona su tutte le porte) # In locale: lasciare vuoto (il cookie su localhost funziona su tutte le porte)
# In produzione: .mebboat.it (condiviso tra auth.mebboat.it, console.mebboat.it, api.mebboat.it) # In produzione: .mebboat.it (condiviso tra auth.mebboat.it, console.mebboat.it, api.mebboat.it)
COOKIE_DOMAIN= COOKIE_DOMAIN=
COOKIE_NAME= COOKIE_NAME=
DB_NAME=
PORT=3006 PORT=3006
JWT_SECRET= JWT_SECRET=

View File

@@ -1,120 +1,166 @@
const query = require('../storage/database').query;
const track = require('../tools/tracking')
const { v4: uuid } = require('uuid'); const { v4: uuid } = require('uuid');
const security = require('../tools/security') const { query } = require('../storage/database');
const security = require('../tools/security');
const tracking = require('../tools/tracking');
// ─── ERRORI CUSTOM ──────────────────────────────────────────────────
class AuthError extends Error {
constructor(code, message) {
super(message || code);
this.code = code;
}
}
// ─── REGISTRAZIONE ──────────────────────────────────────────────────
/**
* Registra un nuovo utente
*/
async function register(username, password) { async function register(username, password) {
const userExists = await query('SELECT id FROM users WHERE username = $1', [username]); const exists = await query('SELECT id FROM users WHERE username = $1', [username]);
if (exists.rows.length) throw new AuthError('USER_EXISTS', 'Username già in uso');
if (userExists.rows.length > 0) { const hash = await security.hashPassword(password);
throw new Error('User already exists');
}
const hashedPassword = security.hashPassword(password);
const id = uuid(); const id = uuid();
await query(
await query('INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', [id, username, hashedPassword]); 'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)',
[id, username, hash]
return { );
success: true, return { id, username };
user: {
id,
username
}
};
} }
// ─── LOGIN ──────────────────────────────────────────────────────────
/**
* Esegue il login di un utente
*/
async function login(username, password) { async function login(username, password) {
const result = await query('SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', [username]); const { rows } = await query(
if (result.rows.length === 0) { 'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1',
throw new Error('No user matched') [username]
} );
if (!rows.length) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
const user = result.rows[0]; const user = rows[0];
const isValid = await security.verifyPassword(password, user.password_hash); if (!user.is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
if (!isValid) { const ok = await security.verifyPassword(password, user.password_hash);
throw new Error('Password mismatch') if (!ok) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
}
return { return { id: user.id, username: user.username, created_at: user.created_at };
id: user.id,
username: user.username,
created: user.created_at
}
} }
/** // ─── SESSIONI ───────────────────────────────────────────────────────
* Esegue il logout di un utente
*
*/
async function logout(sessionID) {
if (!sessionID) {
throw new Error('no sessio id passed');
}
const result = await query('UPDATE sessions SET is_revoked = TRUE WHERE id = $1', [sessionID]); async function createSession(userId, userAgent, ip) {
return result.rowCount > 0;
}
/**
* Crea una nuova sessione per un utente che ha appaena eseguito il login
*/
async function newSession(userId, userAgent, ip) {
const id = uuid(); const id = uuid();
const sessionCode = security.generateSessionCode(); const code = security.sessionCode();
const metadata = track.getBasicMetadata(userAgent); const meta = tracking.extract(userAgent);
await query( await query(
`INSERT INTO sessions (id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type) `INSERT INTO sessions
(id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[id, userId, sessionCode, '', ip, userAgent, metadata.browser, metadata.os, metadata.device_type] [id, userId, code, '', ip, userAgent, meta.browser, meta.os, meta.device_type]
);
return { id, code };
}
async function validateSession(sessionId) {
if (!sessionId || typeof sessionId !== 'string') {
throw new AuthError('INVALID_SESSION', 'Sessione non valida');
}
const { rows } = await query(
`SELECT s.id, s.is_revoked, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = $1`,
[sessionId]
); );
return { id, sessionCode }; if (!rows.length) throw new AuthError('INVALID_SESSION', 'Sessione non trovata');
if (rows[0].is_revoked) throw new AuthError('SESSION_REVOKED', 'Sessione revocata');
if (!rows[0].is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
// Aggiorna last_active in modo non bloccante
query('UPDATE sessions SET last_active = NOW() WHERE id = $1', [sessionId]).catch(() => {});
return true;
} }
/** async function revokeSession(sessionId, userId) {
* Valida una sessione if (userId) {
*/ const r = await query(
async function validateSession(token) { 'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE',
const parsed = security.parseSessionToken(token); [sessionId, userId]
);
if (!parsed) { return r.rowCount > 0;
throw new Error('Invalid token format');
}
const { code, username } = parsed;
const result = await query('SELECT s.id as session_id, s.user_id, u.username, u.is_active, u.created_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_code = $1 AND s.is_revoked = FALSE', [code]);
if (result.rows.length === 0) {
throw new Error('Session not found or revoked')
}
const session = result.rows[0];
if (session.username !== username) {
throw new Error('Session user mismatch');
}
if (!session.is_active) {
throw new Error('Session is not active');
} }
const r = await query(
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND is_revoked = FALSE',
[sessionId]
);
return r.rowCount > 0;
} }
async function listSessions(userId) {
const { rows } = await query(
`SELECT id, ip_address, browser, os, device_type,
location_country, location_city, created_at, last_active, is_revoked
FROM sessions
WHERE user_id = $1
ORDER BY last_active DESC`,
[userId]
);
return rows;
}
// ─── LOOKUP UTENTE ──────────────────────────────────────────────────
async function getUserById(userId) {
const { rows } = await query(
'SELECT id, username, is_active, created_at, telegram_id FROM users WHERE id = $1',
[userId]
);
return rows[0] || null;
}
async function getAllUsers() {
const { rows } = await query(
'SELECT id, username, is_active, created_at, telegram_id FROM users'
);
return rows;
}
async function getUsersToNotify() {
const { rows } = await query(
'SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL'
);
return rows;
}
async function updateUsername(userId, newUsername) {
const r = await query(
'UPDATE users SET username = $1 WHERE id = $2 RETURNING username',
[newUsername, userId]
);
return r.rowCount > 0 ? r.rows[0] : null;
}
async function updateTelegram(userId, telegramId) {
await query(
'UPDATE users SET telegram_id = $1 WHERE id = $2',
[telegramId, userId]
);
}
module.exports = { module.exports = {
AuthError,
register, register,
login, login,
logout, createSession,
newSession, validateSession,
validateSession revokeSession,
} listSessions,
getUserById,
getAllUsers,
getUsersToNotify,
updateUsername,
updateTelegram
};

View File

@@ -1,56 +0,0 @@
const query = require('../storage/database').query;
const { parseSessionToken } = require('../tools/security');
async function getSessions(username) {
const result = await query('SELECT s.id, s.session_code, s.browser, s.os, s.device_type, s.created_at, s.last_active, s.is_revoked FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE ORDER BY s.last_active DESC', [username]);
return result.rows.map(s => ({
id: s.id,
code: s.session_code,
browser: s.browser,
os: s.os,
deviceType: s.device_type,
createdAt: s.created_at?.toLocaleDateString('it-IT', {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
}),
lastActive: s.last_active?.toLocaleDateString('it-IT', {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
}),
isRevoked: s.is_revoked,
isCurrent: false
}));
};
async function getCurrentSessionID(token) {
const parsed = parseSessionToken(token);
if (!parsed) {
throw new Error('Invalid token');
}
const result = await query('SELECT id FROM sessions WHERE session_code = $1', [parsed.code]);
return result.rows[0]?.id || null;
}
async function revoke(id, username) {
const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.id = $1 AND s.user_id = u.id AND u.username = $2', [id, username]);
return result.rowCount > 0;
}
async function revokeOthers(username, current) {
const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.user_id = u.id AND u.username = $1 AND s.id != $2 AND s.is_revoked = FALSE', [username, current]);
return result.rowCount;
}
async function getCount(username) {
const result = await query('SELECT COUNT(*) as count FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE', [username]);
return parseInt(result.rows[0].count, 10);
}
module.exports = {
getSessions,
getCurrentSessionID,
revoke,
revokeOthers,
getCount
};

View File

@@ -61,10 +61,13 @@ const authRateLimit = createRateLimiter(RATE_LIMIT_AUTH_MAX);
app.use((req, res, next) => { app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '0'); // Disabilitato a favore di CSP res.setHeader('X-XSS-Protection', '0');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Rimuovi header che rivelano info sul server res.setHeader(
'Content-Security-Policy',
"default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
);
res.removeHeader('X-Powered-By'); res.removeHeader('X-Powered-By');
next(); next();
}); });
@@ -80,6 +83,7 @@ app.use(parser());
// ─── STATIC FILES ─────────────────────────────────────────────────── // ─── STATIC FILES ───────────────────────────────────────────────────
const staticFolder = path.join(__dirname, 'static'); const staticFolder = path.join(__dirname, 'static');
app.use('/static', express.static(staticFolder)); app.use('/static', express.static(staticFolder));
app.use('/api/static', express.static(staticFolder));
// ─── NUNJUCKS TEMPLATES ───────────────────────────────────────────── // ─── NUNJUCKS TEMPLATES ─────────────────────────────────────────────
const templatesFolder = path.join(__dirname, 'templates'); const templatesFolder = path.join(__dirname, 'templates');
@@ -112,14 +116,11 @@ app.use('/api/sessions', require('./routes/sessions'));
// ─── HEALTH CHECK ─────────────────────────────────────────────────── // ─── HEALTH CHECK ───────────────────────────────────────────────────
app.get('/health', async (req, res) => { app.get('/health', async (req, res) => {
const dbConnected = await database.checkPostgres(); const dbConnected = await database.checkPostgres();
const redisHelper = require('./storage/redis');
const redisConnected = await redisHelper.checkRedis();
res.json({ res.json({
status: dbConnected && redisConnected ? "ok" : "degraded", status: dbConnected ? "ok" : "degraded",
service: "auth", service: "auth",
database: dbConnected ? "connected" : "disconnected", database: dbConnected ? "connected" : "disconnected",
redis: redisConnected ? "connected" : "disconnected",
version: version, version: version,
build_number: vBuild, build_number: vBuild,
version_state: vState version_state: vState
@@ -133,7 +134,7 @@ app.use((req, res) => {
// ─── ERROR HANDLER GLOBALE ────────────────────────────────────────── // ─── ERROR HANDLER GLOBALE ──────────────────────────────────────────
app.use((err, req, res, _next) => { app.use((err, req, res, _next) => {
console.error('[AUTH] Errore non gestito:', err); console.error('[ERROR]', err.message, '| code:', err.code);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'Errore interno del server' });
}); });

View File

@@ -3,40 +3,30 @@ const crypto = require('crypto');
const API_KEY = process.env.INTERNAL_API_KEY; const API_KEY = process.env.INTERNAL_API_KEY;
/** /**
* Middleware di autenticazione per servizi interni (container-to-container). * Middleware: autentica chiamate service-to-service tramite header x-internal-api-key.
* Verifica l'header 'x-internal-api-key' contro INTERNAL_API_KEY nell'env. * Usa timing-safe comparison per prevenire timing attacks.
*
* SICUREZZA:
* - Se INTERNAL_API_KEY non è configurata, TUTTE le richieste vengono rifiutate
* - Usa timingSafeEqual per prevenire attacchi timing side-channel
*/ */
const internalAuth = (req, res, next) => { module.exports = function internalAuth(req, res, next) {
// Se la chiave non è configurata nel server, blocca tutto
if (!API_KEY) { if (!API_KEY) {
console.error('[SECURITY] INTERNAL_API_KEY absent! All internal requests blocked.'); console.error('[SECURITY] INTERNAL_API_KEY mancante, blocco tutte le richieste interne.');
return res.status(503).json({ error: 'Service not configured correctly' }); return res.status(503).json({ error: 'service_not_configured' });
} }
const internalToken = req.headers['x-internal-api-key']; const token = req.headers['x-internal-api-key'];
if (!token || typeof token !== 'string') {
if (!internalToken || typeof internalToken !== 'string') { return res.status(403).json({ error: 'forbidden' });
return res.status(403).json({ error: 'unauthorized' });
} }
// Confronto timing-safe per prevenire timing attacks
try { try {
const tokenBuffer = Buffer.from(internalToken, 'utf8'); const a = Buffer.from(token, 'utf8');
const keyBuffer = Buffer.from(API_KEY, 'utf8'); const b = Buffer.from(API_KEY, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
if (tokenBuffer.length !== keyBuffer.length || !crypto.timingSafeEqual(tokenBuffer, keyBuffer)) { return res.status(403).json({ error: 'forbidden' });
return res.status(403).json({ error: 'Accesso negato' });
} }
} catch { } catch {
return res.status(403).json({ error: 'Accesso negato' }); return res.status(403).json({ error: 'forbidden' });
} }
req.user = { id: 'system', role: 'internal_service' }; req.internal = true;
return next(); next();
}; };
module.exports = internalAuth;

View File

@@ -1,33 +1,37 @@
const jwt = require('../tools/jwt'); const jwt = require('../tools/jwt');
const { validateSession } = require('../core/auth.core');
/** /**
* Middleware di autenticazione per utenti finali. * Middleware: richiede un utente autenticato valido.
* Verifica il JWT dal cookie 'auth_token' o dall'header 'Authorization: Bearer <token>'. * Legge il token dal cookie `auth_token` o dall'header `Authorization: Bearer <token>`.
* *
* Se valido, inietta req.user con { user_id, username, session_id }. * Se la richiesta accetta HTML → redirect a /login con redirect-back URL.
* Altrimenti → 401 JSON.
*/ */
const userAuth = (req, res, next) => { module.exports = async function userAuth(req, res, next) {
const token = (req.cookies && req.cookies.auth_token) || jwt.getToken(req.headers['authorization']); const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
if (!token || typeof token !== 'string') { const unauthorized = (reason) => {
return res.status(401).json({ error: 'Accesso negato: token mancante' }); if (req.accepts('html') && !req.xhr) {
const r = encodeURIComponent(req.originalUrl);
return res.redirect(`/login?redirect=${r}`);
}
return res.status(401).json({ error: reason || 'unauthorized' });
};
if (!token || typeof token !== 'string' || token.length > 2048) {
return unauthorized('missing_token');
} }
// Limite ragionevole sulla lunghezza del token per evitare abusi const v = jwt.verify(token);
if (token.length > 2048) { if (!v.valid) return unauthorized(`token_${v.reason}`);
return res.status(400).json({ error: 'Token non valido' });
try {
await validateSession(v.payload.session_id);
} catch (err) {
return unauthorized(err.code || 'session_invalid');
} }
const verified = jwt.verifyToken(token); req.user = v.payload;
if (!verified.valid) {
return res.status(401).json({
error: 'Sessione non valida o scaduta',
reason: verified.reason
});
}
req.user = verified.payload;
next(); next();
}; };
module.exports = userAuth;

View File

@@ -4,120 +4,175 @@ const jwt = require('../tools/jwt');
const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004'; const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined; const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
const IS_PROD = process.env.NODE_ENV === 'production';
// Validazione input
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/; const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
const PASSWORD_MIN_LENGTH = 8; const MIN_PASSWORD = 8;
const PASSWORD_MAX_LENGTH = 128; const MAX_PASSWORD = 128;
const TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 giorni
/**
* Opzioni cookie condivise per auth_token.
* Domain = `.mebboat.it` in produzione → SSO cross-subdomain
* (console.mebboat.it, ml.mebboat.it, api.mebboat.it, ecc.)
*/
function authCookieOptions(withMaxAge = true) {
const opts = {
httpOnly: true,
secure: IS_PROD,
sameSite: 'lax',
path: '/'
};
if (withMaxAge) opts.maxAge = TOKEN_MAX_AGE_MS;
if (COOKIE_DOMAIN) opts.domain = COOKIE_DOMAIN;
return opts;
}
/**
* Valida un redirect URL per prevenire open-redirect.
* Accetta solo lo stesso dominio di CONSOLE_URL (o sottodomini di COOKIE_DOMAIN).
*/
function resolveSafeRedirect(redirect) {
if (!redirect || typeof redirect !== 'string') return CONSOLE_URL;
try {
const target = new URL(redirect);
const console_ = new URL(CONSOLE_URL);
const sameHost = target.hostname === console_.hostname;
const sameApex = COOKIE_DOMAIN
? target.hostname.endsWith(COOKIE_DOMAIN.replace(/^\./, ''))
: false;
const notApi = !target.pathname.startsWith('/api/');
if ((sameHost || sameApex) && notApi) return redirect;
} catch {
// URL invalido / relativo: fallback
}
return CONSOLE_URL;
}
// ─── POST /register ────────────────────────────────────────────────
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body || {};
if (!username || !password) { if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Username e password richiesti' }); return res.status(400).json({ success: false, error: 'username_and_password_required' });
} }
if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Formato dati non valido' });
}
if (!USERNAME_REGEX.test(username)) { if (!USERNAME_REGEX.test(username)) {
return res.status(400).json({ return res.status(400).json({ success: false, error: 'invalid_username' });
error: 'Username non valido. 3-50 caratteri alfanumerici, underscore, punto o trattino.'
});
} }
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) {
if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) { return res.status(400).json({ success: false, error: 'invalid_password_length' });
return res.status(400).json({
error: `Password deve essere tra ${PASSWORD_MIN_LENGTH} e ${PASSWORD_MAX_LENGTH} caratteri`
});
} }
try { try {
await auth.register(username, password); const user = await auth.register(username, password);
res.status(201).end(); return res.status(201).json({ success: true, user });
} catch (err) { } catch (err) {
console.error('[AUTH] Register failed:', err.message); if (err.code === 'USER_EXISTS') {
const status = err.message === 'User already exists' ? 409 : 500; return res.status(409).json({ success: false, error: 'user_exists' });
res.status(status).json({ error: err.message === 'User already exists' ? err.message : 'Errore interno' }); }
console.error('[AUTH] register:', err.message);
return res.status(500).json({ success: false, error: 'internal' });
} }
}); });
// ─── POST /login ───────────────────────────────────────────────────
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
const { username, password, redirect } = req.body; const { username, password, redirect, _csrf } = req.body || {};
// Validazione base // Verifica CSRF token
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { const csrfCookie = req.cookies && req.cookies._csrf;
return res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' }); if (!_csrf || !csrfCookie || _csrf !== csrfCookie) {
return res.status(400).json({
success: false, error: 'csrf', message: 'Richiesta non valida, riprova'
});
} }
// Limiti di lunghezza per prevenire abuse if (!username || !password || typeof username !== 'string' || typeof password !== 'string'
if (username.length > 50 || password.length > PASSWORD_MAX_LENGTH) { || username.length > 50 || password.length > MAX_PASSWORD) {
return res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' }); return res.status(400).json({
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
});
} }
// Validazione redirect URL per prevenire open redirect attacks const safeRedirect = resolveSafeRedirect(redirect);
if (redirect && typeof redirect === 'string') {
try {
const redirectUrl = new URL(redirect);
const consoleUrl = new URL(CONSOLE_URL);
// Permetti redirect solo allo stesso dominio del CONSOLE_URL
if (redirectUrl.hostname !== consoleUrl.hostname) {
return res.render('loginpage', { error: 'Redirect non autorizzato', redirect: '' });
}
} catch {
// URL relativo o non valido — ignora il redirect
}
}
try { try {
const user = await auth.login(username, password); const user = await auth.login(username, password);
const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip); const session = await auth.createSession(user.id, req.headers['user-agent'], req.ip);
const token = jwt.generateToken(user, session.id); const token = jwt.sign(user, session.id);
const cookieOptions = { // Imposta il cookie auth_token (condiviso tra sottodomini se COOKIE_DOMAIN è impostato)
httpOnly: true, res.cookie('auth_token', token, authCookieOptions(true));
secure: process.env.NODE_ENV === 'production', // Rimuove il cookie CSRF
sameSite: 'lax', res.clearCookie('_csrf', { httpOnly: true, sameSite: 'strict', path: '/' });
maxAge: 7 * 24 * 60 * 60 * 1000
};
if (COOKIE_DOMAIN) { return res.status(200).json({
cookieOptions.domain = COOKIE_DOMAIN; success: true,
} redirect_url: safeRedirect,
message: 'Login effettuato'
res.cookie('auth_token', token, cookieOptions); });
const destination = redirect || CONSOLE_URL;
res.redirect(destination);
} catch (err) { } catch (err) {
console.error('[AUTH] Login failed:', err.message); if (err.code === 'INVALID_CREDENTIALS') {
// Mai rivelare se è l'utente o la password ad essere sbagliati return res.status(401).json({
res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' }); success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
});
}
if (err.code === 'ACCOUNT_INACTIVE') {
return res.status(403).json({
success: false, error: 'account_inactive', message: 'Account disattivato'
});
}
console.error('[AUTH] login:', err.message);
return res.status(500).json({
success: false, error: 'internal', message: 'Errore interno'
});
} }
}); });
// ─── POST /logout ──────────────────────────────────────────────────
router.post('/logout', async (req, res) => { router.post('/logout', async (req, res) => {
const token = req.cookies && req.cookies.auth_token; const token = req.cookies && req.cookies.auth_token;
if (token) { if (token) {
try { const v = jwt.verify(token);
const verified = jwt.verifyToken(token); if (v.valid) {
if (verified.valid) { try {
await auth.logout(verified.payload.session_id); await auth.revokeSession(v.payload.session_id);
} catch (err) {
console.error('[AUTH] logout revoke:', err.message);
} }
} catch (err) {
console.error('[AUTH] Logout error:', err.message);
} }
} }
const clearOptions = { httpOnly: true, sameSite: 'lax' }; res.clearCookie('auth_token', authCookieOptions(false));
if (COOKIE_DOMAIN) {
clearOptions.domain = COOKIE_DOMAIN; // Form HTML tradizionale → redirect, altrimenti JSON
if (req.accepts('html') && !req.xhr && !req.headers['content-type']?.includes('json')) {
return res.redirect('/login');
}
return res.status(200).json({ success: true, redirect_url: '/login' });
});
// ─── GET /verify (introspection per altri servizi) ─────────────────
router.get('/verify', async (req, res) => {
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
if (!token) return res.status(401).json({ valid: false, error: 'no_token' });
const v = jwt.verify(token);
if (!v.valid) return res.status(401).json({ valid: false, error: `token_${v.reason}` });
try {
await auth.validateSession(v.payload.session_id);
} catch (err) {
return res.status(401).json({ valid: false, error: err.code || 'session_invalid' });
} }
res.clearCookie('auth_token', clearOptions); return res.status(200).json({ valid: true, user: v.payload });
res.redirect('/login');
}); });
module.exports = router; module.exports = router;

View File

@@ -1,55 +1,37 @@
// api.mebboat.it/api/sessions
const router = require('express').Router(); const router = require('express').Router();
const { query } = require('../storage/database'); const auth = require('../core/auth.core');
const userAuth = require('../middlewares/user.security'); const userAuth = require('../middlewares/user.security');
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Tutte le route richiedono autenticazione utente
router.use(userAuth); router.use(userAuth);
// Mostra SOLO le sessioni dell'utente autenticato (non di tutti!) // GET / — Lista sessioni dell'utente corrente
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const result = await query( const rows = await auth.listSessions(req.user.user_id);
`SELECT id, ip_address, browser, os, device_type, res.json(rows);
location_country, location_city, created_at, last_active, is_revoked
FROM sessions
WHERE user_id = $1
ORDER BY last_active DESC`,
[req.user.user_id]
);
res.json(result.rows);
} catch (err) { } catch (err) {
console.error('[SESSIONS] Errore recupero sessioni:', err); console.error('[SESSIONS] list:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
// Revoca una sessione specifica dell'utente // DELETE /:sessionId — Revoca una sessione specifica
router.delete('/:sessionId', async (req, res) => { router.delete('/:sessionId', async (req, res) => {
const { sessionId } = req.params; const { sessionId } = req.params;
// Validazione UUID
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(sessionId)) { if (!UUID_REGEX.test(sessionId)) {
return res.status(400).json({ error: 'ID sessione non valido' }); return res.status(400).json({ error: 'invalid_session_id' });
} }
try { try {
// Verifica che la sessione appartenga all'utente autenticato const revoked = await auth.revokeSession(sessionId, req.user.user_id);
const result = await query( if (!revoked) return res.status(404).json({ error: 'session_not_found' });
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE', res.json({ success: true });
[sessionId, req.user.user_id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Sessione non trovata o già revocata' });
}
res.json({ success: true, message: 'Sessione revocata' });
} catch (err) { } catch (err) {
console.error('[SESSIONS] Errore revoca sessione:', err); console.error('[SESSIONS] revoke:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
module.exports = router; module.exports = router;

View File

@@ -1,122 +1,76 @@
// api.mebboat.it/api/users
const router = require('express').Router(); const router = require('express').Router();
const { query } = require('../storage/database'); const auth = require('../core/auth.core');
const userAuth = require('../middlewares/user.security'); const userAuth = require('../middlewares/user.security');
const internalAuth = require('../middlewares/internal.security'); const internalAuth = require('../middlewares/internal.security');
// ─── VALIDAZIONE INPUT ──────────────────────────────────────────────
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/; const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
const TELEGRAM_ID_REGEX = /^[0-9]{5,15}$/; const TELEGRAM_REGEX = /^[0-9]{5,15}$/;
// ─── ROTTE INTERNAL (prima del router.use userAuth) ───────────────── // ─── SERVICE-TO-SERVICE (x-internal-api-key) ────────────────────────
router.get('/', internalAuth, async (req, res) => { router.get('/', internalAuth, async (req, res) => {
try { try {
const result = await query( const users = await auth.getAllUsers();
'SELECT id, username, is_active, created_at, telegram_id FROM users' res.json(users);
);
res.json(result.rows);
} catch (err) { } catch (err) {
console.error('[USERS] Errore lista utenti:', err); console.error('[USERS] list:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
router.get('/tonotify', internalAuth, async (req, res) => { router.get('/tonotify', internalAuth, async (req, res) => {
try { try {
const result = await query( const users = await auth.getUsersToNotify();
'SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL' res.json(users);
);
res.json(result.rows);
} catch (err) { } catch (err) {
console.error('[USERS] Errore lista notifiche:', err); console.error('[USERS] tonotify:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
// ─── ROTTE USER (tutte le rotte sotto usano userAuth) ─────────────── // ─── USER AUTH (cookie/JWT) ─────────────────────────────────────────
router.use(userAuth); router.use(userAuth);
router.get('/me', async (req, res) => { router.get('/me', async (req, res) => {
try { try {
const result = await query( const user = await auth.getUserById(req.user.user_id);
'SELECT id, username, is_active, created_at, telegram_id FROM users WHERE id = $1', if (!user) return res.status(404).json({ error: 'user_not_found' });
[req.user.user_id] res.json(user);
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Utente non trovato' });
}
res.json(result.rows[0]);
} catch (err) { } catch (err) {
console.error('[USERS] Errore recupero utente:', err); console.error('[USERS] me:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
router.put('/me/username', async (req, res) => { router.put('/me/username', async (req, res) => {
const newUsername = req.query.newUsername || req.body?.newUsername; const newUsername = req.body?.newUsername || req.query.newUsername;
if (!newUsername || typeof newUsername !== 'string' || !USERNAME_REGEX.test(newUsername)) {
if (!newUsername || typeof newUsername !== 'string') { return res.status(400).json({ error: 'invalid_username' });
return res.status(400).json({ error: 'Nuovo username richiesto' });
} }
// Validazione formato username
if (!USERNAME_REGEX.test(newUsername)) {
return res.status(400).json({
error: 'Username non valido. Deve contenere 3-50 caratteri alfanumerici, underscore, punto o trattino.'
});
}
try { try {
const result = await query( const updated = await auth.updateUsername(req.user.user_id, newUsername);
'UPDATE users SET username = $1 WHERE id = $2 RETURNING username', if (!updated) return res.status(404).json({ error: 'user_not_found' });
[newUsername, req.user.user_id] res.json({ success: true, username: updated.username });
);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Utente non trovato' });
}
res.json({ success: true, username: result.rows[0].username });
} catch (err) { } catch (err) {
if (err.code === '23505') { if (err.code === '23505') return res.status(409).json({ error: 'username_taken' });
return res.status(409).json({ error: 'Questo username è già in uso' }); console.error('[USERS] update username:', err.message);
} res.status(500).json({ error: 'internal' });
console.error('[USERS] Errore aggiornamento username:', err);
res.status(500).json({ error: 'Errore interno del server' });
} }
}); });
router.put('/me/telegram', async (req, res) => { router.put('/me/telegram', async (req, res) => {
const telegramId = req.query.telegramId || req.body?.telegramId; const telegramId = req.body?.telegramId || req.query.telegramId;
if (!telegramId || typeof telegramId !== 'string' || !TELEGRAM_REGEX.test(telegramId)) {
if (!telegramId || typeof telegramId !== 'string') { return res.status(400).json({ error: 'invalid_telegram_id' });
return res.status(400).json({ error: 'Telegram ID richiesto' });
} }
// Validazione formato Telegram ID (solo numeri, 5-15 cifre)
if (!TELEGRAM_ID_REGEX.test(telegramId)) {
return res.status(400).json({
error: 'Telegram ID non valido. Deve contenere solo numeri (5-15 cifre).'
});
}
try { try {
await query( await auth.updateTelegram(req.user.user_id, telegramId);
'UPDATE users SET telegram_id = $1 WHERE id = $2', res.json({ success: true });
[telegramId, req.user.user_id]
);
res.json({ success: true, message: 'Telegram ID aggiornato' });
} catch (err) { } catch (err) {
if (err.code === '23505') { if (err.code === '23505') return res.status(409).json({ error: 'telegram_taken' });
return res.status(409).json({ error: 'Questo Telegram ID è già associato a un altro account' }); console.error('[USERS] update telegram:', err.message);
} res.status(500).json({ error: 'internal' });
console.error('[USERS] Errore aggiornamento telegram:', err);
res.status(500).json({ error: 'Errore interno del server' });
} }
}); });

View File

@@ -1,8 +1,28 @@
const router = require('express').Router(); const router = require('express').Router();
const { csrfToken } = require('../../tools/security');
const ERROR_MESSAGES = {
invalid_credentials: 'Credenziali non valide',
csrf: 'Richiesta non valida, riprova',
account_inactive: 'Account disattivato',
session_expired: 'Sessione scaduta, effettua nuovamente il login'
};
router.get('/login', (req, res) => { router.get('/login', (req, res) => {
const redirect = req.query.redirect || ''; const redirect = typeof req.query.redirect === 'string' ? req.query.redirect : '';
res.render('loginpage', { error: null, redirect }); const errorKey = req.query.error;
const error = ERROR_MESSAGES[errorKey] || null;
const token = csrfToken();
res.cookie('_csrf', token, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 30 * 60 * 1000
});
res.render('loginpage', { error, redirect, csrf_token: token });
}); });
module.exports = router; module.exports = router;

View File

@@ -4,7 +4,7 @@ const userAuth = require('../../middlewares/user.security');
router.use(userAuth); router.use(userAuth);
router.get('/', (req, res) => { router.get('/', (req, res) => {
res.render('sessions'); res.render('sessions', { user: req.user });
}); });
module.exports = router; module.exports = router;

View File

@@ -1,7 +1,10 @@
const router = require('express').Router(); const router = require('express').Router();
const userAuth = require('../../middlewares/user.security');
router.use(userAuth);
router.get('/', (req, res) => { router.get('/', (req, res) => {
res.render('user'); res.render('user', { user: req.user });
}); });
module.exports = router; module.exports = router;

Binary file not shown.

View File

@@ -15,7 +15,8 @@
--header-border: #e2e8f0; --header-border: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 12px; --radius-lg: 12px;
} }
@@ -26,21 +27,21 @@
} }
@font-face { @font-face {
font-family: 'Normal'; font-family: "Normal";
src: url('../font/Quicksand-VariableFont_wght.ttf'); src: url("../font/sans-flex.ttf");
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: 'Bold'; font-family: "Bold";
src: url('../font/Quicksand-VariableFont_wght.ttf'); src: url("../font/sans-flex.ttf");
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
} }
body { body {
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: "Normal", Arial;
color: var(--text-primary); color: var(--text-primary);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -53,7 +54,7 @@ button {
color: var(--text-primary); color: var(--text-primary);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
font-family: 'Bold', inherit; font-family: "Bold", inherit;
cursor: pointer; cursor: pointer;
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1); transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
white-space: nowrap; white-space: nowrap;
@@ -79,13 +80,11 @@ button.prominent:hover {
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
button.prominent:active { button.prominent:active {
transform: translateY(1px); transform: translateY(1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
/* INFO PANEL */ /* INFO PANEL */
.info-panel { .info-panel {
@@ -111,9 +110,6 @@ button.prominent:active {
transition: transform 0.12s ease; transition: transform 0.12s ease;
} }
/* GRID & CARD ITEMS */ /* GRID & CARD ITEMS */
.grid { .grid {
@@ -132,7 +128,9 @@ button.prominent:active {
border-radius: 20px; border-radius: 20px;
text-decoration: none; text-decoration: none;
color: var(--text-primary); color: var(--text-primary);
transition: transform 0.12s ease, box-shadow 0.12s ease; transition:
transform 0.12s ease,
box-shadow 0.12s ease;
} }
.card h3 { .card h3 {
@@ -158,10 +156,6 @@ button.prominent:active {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* HEADER */ /* HEADER */
.header { .header {
@@ -179,7 +173,6 @@ button.prominent:active {
user-select: none; user-select: none;
} }
.header h1 { .header h1 {
color: var(--text-primary); color: var(--text-primary);
font-size: 1.25rem; font-size: 1.25rem;
@@ -198,4 +191,4 @@ button.prominent:active {
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
padding-inline: 5px; padding-inline: 5px;
} }

View File

@@ -5,58 +5,46 @@ const config = {
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT, port: process.env.DB_PORT,
database: process.env.USERS_DB || process.env.DB_NAME,
max: 10, max: 10,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000 connectionTimeoutMillis: 5000
} };
const pool = new Pool({ ...config, database: process.env.DB_NAME }); const pool = new Pool(config);
pool.on('error', (err) => { pool.on('error', (err) => {
console.error('Error in database', err); console.error('[DB] Pool error:', err.message);
}); });
/**
* Execute a query with parameters
* @param {string} text - SQL query
* @param {Array} params - Query parameters
* @returns {Promise<Object>} Query result
*/
async function query(text, params) { async function query(text, params) {
const start = Date.now(); const start = Date.now();
const result = await pool.query(text, params); try {
const duration = Date.now() - start; const result = await pool.query(text, params);
const duration = Date.now() - start;
if (duration > 100) { if (duration > 100) {
console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80)); console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80));
}
return result;
} catch (err) {
console.error('[DB] Query failed:', err.message, '| code:', err.code);
throw err;
} }
return result;
} }
/**
* Get a client from pool for transactions
* @returns {Promise<Object>} Pool client
*/
async function getClient() { async function getClient() {
return await pool.connect(); return await pool.connect();
} }
/**
* Initialize database and ensure tables exist
*/
async function initDb() { async function initDb() {
// Test connection
await pool.query('SELECT NOW()'); await pool.query('SELECT NOW()');
// Ensure pgcrypto extension (provides gen_random_uuid)
// Note: creating extensions requires proper DB permissions (usually superuser in PG)
try { try {
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`); await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
} catch (err) { } catch (err) {
console.warn('[DB] Could not create pgcrypto extension (may require superuser):', err.message); console.warn('[DB] Could not create pgcrypto extension:', err.message);
} }
// Ensure tables exist (UUID default generated by DB)
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -76,7 +64,7 @@ async function initDb() {
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_code VARCHAR(64) NOT NULL, session_code VARCHAR(64) NOT NULL,
encoded_username TEXT NOT NULL, encoded_username TEXT NOT NULL DEFAULT '',
ip_address INET, ip_address INET,
user_agent TEXT, user_agent TEXT,
browser VARCHAR(100), browser VARCHAR(100),
@@ -89,9 +77,6 @@ async function initDb() {
is_revoked BOOLEAN DEFAULT FALSE is_revoked BOOLEAN DEFAULT FALSE
); );
-- Altera colonna in base al nuovo standard token 32 byte - 64 url chars
ALTER TABLE sessions ALTER COLUMN session_code TYPE VARCHAR(64);
CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code); CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
`); `);
@@ -101,7 +86,7 @@ async function checkPostgres() {
try { try {
await pool.query('SELECT NOW()'); await pool.query('SELECT NOW()');
return true; return true;
} catch (error) { } catch {
return false; return false;
} }
} }

View File

@@ -3,6 +3,7 @@ const Redis = require('ioredis');
const redis = new Redis({ const redis = new Redis({
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT), port: parseInt(process.env.REDIS_PORT),
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: 3, maxRetriesPerRequest: 3,
lazyConnect: true, lazyConnect: true,
retryStrategy(times) { retryStrategy(times) {

View File

@@ -1,39 +1,108 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="it">
<html>
<head> <head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../static/style/style.css"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="../static/style/login.css" </head> <title>Login — Console MEB</title>
<link rel="stylesheet" href="/static/style/style.css">
<link rel="stylesheet" href="/static/style/login.css">
</head>
<body> <body>
<div class="container"> <div class="container">
<div class="login"> <div class="login">
<div class="header"> <div class="header">
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1> <h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
</div> </div>
{% if error %} {% if error %}
<p class="error">{{ error }}</p> <p class="error" id="errorMessage">{{ error }}</p>
{% endif %} {% endif %}
<form action="/login" method="post"> <form id="loginForm">
<input type="hidden" name="redirect" value="{{ redirect }}"> <input type="hidden" id="redirect" name="redirect" value="{{ redirect }}">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="group"> <div class="group">
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" id="username" name="username" required> <input type="text" id="username" name="username" required autocomplete="username">
</div> </div>
<div class="group"> <div class="group">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required autocomplete="current-password">
</div> </div>
<button type="submit">Login</button> <button type="submit" class="prominent" id="submitBtn">Login</button>
</form> </form>
</div> </div>
</div> </div>
<script>
const form = document.getElementById('loginForm');
const submitBtn = document.getElementById('submitBtn');
const errorMessage = document.getElementById('errorMessage');
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = 'Accesso in corso...';
if (errorMessage) errorMessage.style.display = 'none';
const formData = new FormData(form);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
username: formData.get('username'),
password: formData.get('password'),
redirect: formData.get('redirect'),
_csrf: formData.get('_csrf')
})
});
const data = await response.json().catch(() => ({}));
if (response.ok && data.success && data.redirect_url) {
window.location.href = data.redirect_url;
} else {
const errorMsg = data.message || 'Errore durante il login';
if (errorMessage) {
errorMessage.textContent = errorMsg;
errorMessage.style.display = 'block';
} else {
alert(errorMsg);
}
submitBtn.disabled = false;
submitBtn.textContent = 'Login';
// Ricarica la pagina per ottenere un nuovo CSRF token
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (err) {
const errorMsg = 'Errore di connessione. Riprova più tardi.';
if (errorMessage) {
errorMessage.textContent = errorMsg;
errorMessage.style.display = 'block';
} else {
alert(errorMsg);
}
submitBtn.disabled = false;
submitBtn.textContent = 'Login';
}
});
</script>
</body> </body>
</html> </html>

View File

@@ -1 +1,109 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sessioni — Console MEB</title>
<link rel="stylesheet" href="/static/style/style.css">
<style>
main { padding: 24px 30px; }
main h2 { font-size: 1.1rem; margin-bottom: 20px; color: var(--text-secondary); font-weight: 500; }
.session-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border: 1px solid var(--header-border);
border-radius: var(--radius-lg);
margin-bottom: 12px;
}
.session-card .info h3 { font-size: 0.95rem; margin-bottom: 4px; }
.session-card .info p { font-size: 0.8rem; color: var(--text-secondary); margin: 2px 0; }
.session-card button { font-size: 0.8rem; padding: 6px 14px; color: #dc2626; border-color: #fca5a5; }
.session-card button:hover { background-color: #fef2f2; border-color: #dc2626; color: #dc2626; }
#loading { color: var(--text-tertiary); font-size: 0.9rem; }
</style>
</head>
<body>
<div class="header">
<h1>Console MEB</h1>
<div class="profile">
<p>{{ user.username }}</p>
<form action="/api/auth/logout" method="post">
<button type="submit">Logout</button>
</form>
</div>
</div>
<main>
<h2>Sessioni attive</h2>
<div id="sessions-container">
<p id="loading">Caricamento...</p>
</div>
</main>
<script>
function escapeHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str || ''));
return d.innerHTML;
}
function formatDate(iso) {
if (!iso) return 'N/D';
return new Date(iso).toLocaleString('it-IT', {
day: 'numeric', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
async function loadSessions() {
try {
const res = await fetch('/api/sessions');
if (res.status === 401) { window.location.href = '/login'; return; }
if (!res.ok) throw new Error('Network error');
const sessions = await res.json();
const container = document.getElementById('sessions-container');
if (sessions.length === 0) {
container.innerHTML = '<p style="color:var(--text-tertiary)">Nessuna sessione attiva.</p>';
return;
}
container.innerHTML = sessions.map(s => `
<div class="session-card" id="session-${escapeHtml(s.id)}">
<div class="info">
<h3>${escapeHtml(s.browser || 'Browser sconosciuto')} su ${escapeHtml(s.os || 'OS sconosciuto')}</h3>
<p>${escapeHtml(s.device_type || '')}${s.ip_address ? ' — ' + escapeHtml(s.ip_address) : ''}</p>
<p>Ultima attività: ${formatDate(s.last_active)}</p>
</div>
<button onclick="revokeSession('${escapeHtml(s.id)}')">Revoca</button>
</div>
`).join('');
} catch {
document.getElementById('sessions-container').innerHTML =
'<p style="color:#dc2626">Errore nel caricamento delle sessioni.</p>';
}
}
async function revokeSession(id) {
if (!confirm('Revocare questa sessione?')) return;
try {
const res = await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
if (res.status === 401) { window.location.href = '/login'; return; }
if (!res.ok) throw new Error();
const el = document.getElementById('session-' + id);
if (el) el.remove();
} catch {
alert('Errore durante la revoca della sessione.');
}
}
loadSessions();
</script>
</body>
</html>

View File

@@ -1 +1,87 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profilo — Console MEB</title>
<link rel="stylesheet" href="/static/style/style.css">
<style>
main { padding: 24px 30px; max-width: 600px; }
main h2 { font-size: 1.1rem; margin-bottom: 20px; color: var(--text-secondary); font-weight: 500; }
.field { margin-bottom: 20px; }
.field label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
.field p { font-size: 0.95rem; padding: 10px 14px; border: 1px solid var(--header-border); border-radius: var(--radius-md); }
.field-empty { color: var(--text-tertiary); font-style: italic; }
</style>
</head>
<body>
<div class="header">
<h1>Console MEB</h1>
<div class="profile">
<p id="username-label">{{ user.username }}</p>
<form action="/api/auth/logout" method="post">
<button type="submit">Logout</button>
</form>
</div>
</div>
<main>
<h2>Profilo utente</h2>
<div id="user-info">
<p style="color:var(--text-tertiary)">Caricamento...</p>
</div>
</main>
<script>
function escapeHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str || ''));
return d.innerHTML;
}
function formatDate(iso) {
if (!iso) return 'N/D';
return new Date(iso).toLocaleDateString('it-IT', {
day: 'numeric', month: 'long', year: 'numeric'
});
}
async function loadUser() {
try {
const res = await fetch('/api/users/me');
if (res.status === 401) { window.location.href = '/login'; return; }
if (!res.ok) throw new Error();
const user = await res.json();
document.getElementById('username-label').textContent = user.username;
document.getElementById('user-info').innerHTML = `
<div class="field">
<label>Username</label>
<p>${escapeHtml(user.username)}</p>
</div>
<div class="field">
<label>Account creato il</label>
<p>${formatDate(user.created_at)}</p>
</div>
<div class="field">
<label>Telegram ID</label>
<p>${user.telegram_id ? escapeHtml(user.telegram_id) : '<span class="field-empty">Non configurato</span>'}</p>
</div>
<div class="field">
<label>Sessioni</label>
<p><a href="/sessions">Gestisci sessioni →</a></p>
</div>
`;
} catch {
document.getElementById('user-info').innerHTML =
'<p style="color:#dc2626">Errore nel caricamento del profilo.</p>';
}
}
loadUser();
</script>
</body>
</html>

View File

@@ -1,70 +1,52 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET; const SECRET = process.env.JWT_SECRET;
const expires_in = process.env.JWT_EXPIRES_IN; const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
/** /**
* Genera un JWT Token a partire dall'utente e crea una nuova sessione * Firma un JWT per l'utente e la sessione.
* * Payload: { sub: userId, username, session_id }
* Uso dell'algoritmo HS256 per firmare il token con JWT_SECRET
*
* @param {Object} user - Utente
* @param {string} sessionID - ID della sessione
* @returns {string} - JWT Token
*/ */
function generateToken(user, sessionID) { function sign(user, sessionId) {
const payload = { return jwt.sign(
sub: user.id, { sub: user.id, username: user.username, session_id: sessionId },
username: user.username, SECRET,
session_id: sessionID, { algorithm: 'HS256', expiresIn: EXPIRES_IN }
iat: Math.floor(Date.now() / 1000) );
};
return jwt.sign(payload, secret, { expiresIn: expires_in, algorithm: 'HS256' });
} }
/** /**
* Verifica e decodifica il token * Verifica e decodifica un token.
* @param {string} token - JWT Token * @returns {{ valid: boolean, payload?: Object, reason?: string }}
* @returns {{valid: boolean, payload?: Object, error?: string, reason?: string}} - Il risultato della verifica. Se fallisce restituisce errore e motivo, altrimenti restituisce una conferma e il payload completo
*/ */
function verifyToken(token) { function verify(token) {
try { try {
const payload = jwt.verify(token, secret, { const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
algorithms: ['HS256']
});
return { return {
valid: true, valid: true,
payload: { payload: {
user_id: payload.sub, user_id: p.sub,
username: payload.username, username: p.username,
session_id: payload.session_id, session_id: p.session_id,
iat: payload.iat, iat: p.iat,
exp: payload.exp exp: p.exp
} }
}; };
} catch (err) { } catch (err) {
const reason = err.name === 'TokenExpiredError' ? 'expired' : 'invalid';
return { return {
valid: false, valid: false,
error: err.message, reason: err.name === 'TokenExpiredError' ? 'expired' : 'invalid'
reason: `token ${reason}`
}; };
} }
} }
function getToken(header) { /**
if (!header) return null; * Estrae il token da un header Authorization: Bearer <token>.
*/
const parts = header.split(' '); function bearer(header) {
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') { if (!header || typeof header !== 'string') return null;
return parts[1]; const [scheme, token] = header.split(' ');
} return scheme && scheme.toLowerCase() === 'bearer' && token ? token : null;
//TODO: valutare se modificare in return null per accettare solo metodo Bearer Authorization Token,
return header;
} }
module.exports = { generateToken, verifyToken, getToken }; module.exports = { sign, verify, bearer };

View File

@@ -1,54 +1,22 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const saltRounds = 12; const SALT_ROUNDS = 12;
/** async function hashPassword(password) {
* Genera un hash di una password return bcrypt.hash(password, SALT_ROUNDS);
* @param {string} password - Password da hashare
* @returns {string} - Hash della password
*/
function hashPassword(password) {
return bcrypt.hashSync(password, saltRounds);
} }
/** async function verifyPassword(password, hash) {
* Verifica una password return bcrypt.compare(password, hash);
* @param {string} password - Password da verificare
* @param {string} hash - Hash della password
* @returns {boolean} - True se la password è corretta, false altrimenti
*/
function verifyPassword(password, hash) {
return bcrypt.compareSync(password, hash);
} }
/** function sessionCode() {
* Create a session token from code and username
* Format: XXXXXXXX-base64_username
* @param {string} sessionCode
* @param {string} username
* @returns {string} Session token
*/
function generateSessionCode() {
return crypto.randomBytes(32).toString('base64url'); return crypto.randomBytes(32).toString('base64url');
} }
/** function csrfToken() {
* Parse a session token return crypto.randomBytes(32).toString('hex');
* @param {string} token
* @returns {string|null} The session token itself if valid
*/
function parseSessionToken(token) {
if (!token || typeof token !== 'string' || token.length < 32 || token.length > 64) {
return null;
}
return token;
} }
module.exports = { module.exports = { hashPassword, verifyPassword, sessionCode, csrfToken };
hashPassword,
verifyPassword,
generateSessionCode,
parseSessionToken
};

View File

@@ -1,24 +1,15 @@
//TODO: Verfica se serve davvero prendere le info come ip e browser const { UAParser } = require('ua-parser-js');
const { UAParser: parser } = require('ua-parser-js');
/** /**
* Estrae le informazioni base su browser, sistema operativo e dispositivo per identificare meglio la sessione dell'utente * Estrae browser/os/device dal user-agent per identificare meglio la sessione.
* @param {*} userAgent
* @returns
*/ */
function getBasicMetadata(userAgent) { function extract(userAgent) {
const parsed = parser(userAgent); const r = new UAParser(userAgent || '').getResult();
return { return {
browser: parsed.browser.name, browser: r.browser.name || null,
os: parsed.os.name, os: r.os.name || null,
device_type: parsed.device.type, device_type: r.device.type || 'desktop'
};
}
}
module.exports = {
getBasicMetadata
} }
module.exports = { extract };

View File

@@ -7,6 +7,8 @@ VERSION_STATE=pre-release
REALTIME_URL= REALTIME_URL=
REALTIME_WS_URL= REALTIME_WS_URL=
API_URL=
JWT_SECRET= JWT_SECRET=
AUTH_LOGIN_URL= AUTH_LOGIN_URL=
COOKIE_DOMAIN= COOKIE_DOMAIN=

View File

@@ -1,10 +1,10 @@
const express = require('express'); const express = require('express');
const nunjucks = require('nunjucks'); const nunjucks = require('nunjucks');
const path = require('path'); const path = require('path');
const jwt = require('jsonwebtoken');
const parser = require('cookie-parser'); const parser = require('cookie-parser');
const { requireAuthHtml } = require('./middlewares/auth');
const app = express(); const app = express();
const PORT = process.env.PORT; const PORT = process.env.PORT;
@@ -47,39 +47,9 @@ const renderPage = (page, extra = {}) => (req, res) => {
res.render(page, {current_path: req.path, ...extra}) res.render(page, {current_path: req.path, ...extra})
} }
// Middleware di autenticazione per le pagine // Middleware di autenticazione per tutte le pagine protette
app.use((req, res, next) => { // Le route /health e /static sono già gestite sopra
if (req.path === '/health' || req.path.startsWith('/static')) { app.use(requireAuthHtml);
return next();
}
const authBase = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
// Costruisci l'URL di redirect-back: protocollo + host + path originale
const proto = req.protocol;
const host = req.get('host');
const redirectBack = `${proto}://${host}${req.originalUrl}`;
const loginUrl = `${authBase}?redirect=${encodeURIComponent(redirectBack)}`;
const token = req.cookies && req.cookies.auth_token;
if (!token) {
return res.redirect(loginUrl);
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
req.user = payload;
next();
} catch (err) {
const clearOptions = { httpOnly: true, sameSite: 'lax' };
if (process.env.COOKIE_DOMAIN) {
clearOptions.domain = process.env.COOKIE_DOMAIN;
}
res.clearCookie('auth_token', clearOptions);
return res.redirect(loginUrl);
}
});
app.get('/dashboard', renderPage('dashboard')); app.get('/dashboard', renderPage('dashboard'));
app.get('/live', renderPage('live', { app.get('/live', renderPage('live', {
@@ -87,6 +57,39 @@ app.get('/live', renderPage('live', {
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002' realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
})); }));
app.get('/rulesets', renderPage('rulesets', {
apiUrl: process.env.API_URL || 'http://localhost:3003'
}));
app.get('/sessions', renderPage('sessions', {
apiUrl: process.env.API_URL || 'http://localhost:3003',
mapboxToken: process.env.MAPBOX_TOKEN || ''
}));
app.get('/kioskedit', renderPage('kioskedit'));
app.get('/kiosklive', renderPage('kiosklive', {
apiUrl: process.env.API_URL || 'http://localhost:3003',
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
}));
app.get('/forecasts', renderPage('forecasts', {
apiUrl: process.env.API_URL || 'http://localhost:3003',
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
}));
app.get('/documentation', renderPage('documentation', {
apiUrl: process.env.API_URL || 'http://localhost:3003'
}));
// retro-compatibilità: il link della dashboard punta ancora a /documentations
app.get('/documentations', (req, res) => res.redirect(301, '/documentation'));
app.get('/marine', renderPage('marine', {
apiUrl: process.env.API_URL || 'http://localhost:3003',
marineUrl: process.env.MARINE_URL || (process.env.API_URL || 'http://localhost:3003') + '/marine'
}));
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Started on port ${PORT}`); console.log(`Started on port ${PORT}`);
}); });

View File

@@ -0,0 +1,74 @@
/**
* Middleware di autenticazione condiviso per la console.
* Usa il JWT in cookie `auth_token` (condiviso tra i sottodomini via COOKIE_DOMAIN = .mebboat.it)
* oppure il header `Authorization: Bearer <token>`.
*
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET; questo servizio lo verifica
* localmente usando lo stesso secret. Nessuna chiamata di rete richiesta.
*/
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const AUTH_LOGIN_URL = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
function extractToken(req) {
const header = req.headers.authorization;
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
return (req.cookies && req.cookies.auth_token) || bearer || null;
}
function verifyToken(token) {
if (!token || typeof token !== 'string' || token.length > 2048) return null;
try {
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
return {
user_id: p.sub,
username: p.username,
session_id: p.session_id,
iat: p.iat,
exp: p.exp
};
} catch {
return null;
}
}
function clearAuthCookie(res) {
const opts = { httpOnly: true, sameSite: 'lax', path: '/' };
if (COOKIE_DOMAIN) opts.domain = COOKIE_DOMAIN;
res.clearCookie('auth_token', opts);
}
function loginRedirectUrl(req) {
const back = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
return `${AUTH_LOGIN_URL}?redirect=${encodeURIComponent(back)}`;
}
/**
* Pagine HTML: su fallimento redirige all'auth service (SSO).
* Il redirect-back URL viene costruito automaticamente dalla richiesta corrente.
*/
function requireAuthHtml(req, res, next) {
const token = extractToken(req);
const user = verifyToken(token);
if (!user) {
if (token) clearAuthCookie(res);
return res.redirect(loginRedirectUrl(req));
}
req.user = user;
next();
}
/**
* API JSON: su fallimento risponde 401.
*/
function requireAuthApi(req, res, next) {
const user = verifyToken(extractToken(req));
if (!user) return res.status(401).json({ error: 'unauthorized' });
req.user = user;
next();
}
module.exports = { requireAuthHtml, requireAuthApi, clearAuthCookie, verifyToken, extractToken };

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Documentazione — MEB Console</title>
<link rel="stylesheet" href="../static/styles/style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/highlight.min.js"></script>
<style>
.doc-layout { display: grid; grid-template-columns: 280px 1fr; gap: 0; height: calc(100vh - 80px); }
.doc-sidebar { background: rgba(0,0,0,.15); border-right: 1px solid rgba(255,255,255,.05); padding: 1rem; overflow-y: auto; }
.doc-sidebar h3 { margin: 0 0 .75rem; font-size: .85rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
.doc-new { width: 100%; padding: .5rem; background: #3498db; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; margin-bottom: .75rem; }
.doc-new:hover { background: #2980b9; }
.doc-list { list-style: none; padding: 0; margin: 0; }
.doc-list li { padding: .5rem .6rem; cursor: pointer; border-radius: 6px; font-size: .88rem; display: flex; justify-content: space-between; align-items: center; gap: .4rem; }
.doc-list li:hover { background: rgba(255,255,255,.05); }
.doc-list li.active { background: rgba(52,152,219,.15); color: #5dade2; }
.doc-list li .del { opacity: 0; border: none; background: transparent; color: #e74c3c; cursor: pointer; font-size: 1.1rem; padding: 0 .3rem; }
.doc-list li:hover .del { opacity: .7; }
.doc-list li .del:hover { opacity: 1; }
.doc-main { display: flex; flex-direction: column; overflow: hidden; }
.doc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: .75rem 1.5rem; border-bottom: 1px solid rgba(255,255,255,.05); }
.doc-toolbar .name { font-weight: 600; font-size: 1rem; }
.doc-toolbar .actions { display: flex; gap: .5rem; align-items: center; }
.toggle { display: flex; background: rgba(255,255,255,.05); border-radius: 6px; padding: 2px; }
.toggle button { border: none; background: transparent; padding: .4rem .75rem; cursor: pointer; color: inherit; border-radius: 4px; display: flex; align-items: center; gap: .35rem; font-size: .85rem; }
.toggle button.active { background: #3498db; color: #fff; }
.btn-save { background: #27ae60; color: #fff; border: none; padding: .5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: .85rem; }
.btn-save:hover { background: #229954; }
.btn-save:disabled { opacity: .5; cursor: not-allowed; }
.doc-body { flex: 1; overflow: auto; }
.doc-viewer { padding: 2rem; max-width: 860px; margin: 0 auto; line-height: 1.7; }
.doc-viewer h1 { border-bottom: 1px solid rgba(255,255,255,.1); padding-bottom: .3rem; }
.doc-viewer h2 { border-bottom: 1px solid rgba(255,255,255,.05); padding-bottom: .2rem; }
.doc-viewer code { background: rgba(255,255,255,.08); padding: .15em .4em; border-radius: 3px; font-size: .9em; }
.doc-viewer pre { background: #0d1117; border-radius: 6px; padding: 1rem; overflow: auto; }
.doc-viewer pre code { background: transparent; padding: 0; }
.doc-viewer blockquote { border-left: 3px solid #3498db; margin: 1rem 0; padding: .2rem .5rem .2rem 1rem; background: rgba(52,152,219,.05); opacity: .85; }
.doc-viewer table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
.doc-viewer th, .doc-viewer td { border: 1px solid rgba(255,255,255,.1); padding: .4rem .7rem; }
.doc-viewer th { background: rgba(255,255,255,.03); }
.doc-viewer a { color: #5dade2; }
.doc-editor { width: 100%; height: 100%; border: none; padding: 1.5rem 2rem; background: #0d1117; color: #e6edf3; font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: .9rem; line-height: 1.55; resize: none; outline: none; }
.doc-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; opacity: .5; }
.doc-empty .icon { font-size: 3rem; margin-bottom: 1rem; }
.doc-toast { position: fixed; bottom: 1.5rem; right: 1.5rem; background: #27ae60; color: #fff; padding: .75rem 1.2rem; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.3); opacity: 0; transform: translateY(10px); transition: all .25s; }
.doc-toast.show { opacity: 1; transform: translateY(0); }
.doc-toast.err { background: #e74c3c; }
</style>
</head>
<body>
<div class="contnent" style="padding:0;">
<div class="header" style="padding: .5rem 1.5rem;">
<h1 style="font-size:1.2rem;">Documentazione</h1>
<div class="profile">
<a href="/dashboard">← Dashboard</a>
</div>
</div>
<div class="doc-layout">
<aside class="doc-sidebar">
<h3>File Markdown</h3>
<button class="doc-new" id="btnNew">+ Nuovo documento</button>
<ul class="doc-list" id="docList">
<li style="opacity:.6; cursor:default;">Carico…</li>
</ul>
</aside>
<main class="doc-main">
<div class="doc-toolbar">
<div class="name" id="currentName">Nessun documento selezionato</div>
<div class="actions">
<div class="toggle" id="modeToggle">
<button data-mode="view" class="active" title="Visualizza">👁️ <span>Visualizza</span></button>
<button data-mode="edit" title="Modifica">✏️ <span>Modifica</span></button>
</div>
<button class="btn-save" id="btnSave" disabled>💾 Salva</button>
</div>
</div>
<div class="doc-body">
<div id="viewerWrap" class="doc-viewer">
<div class="doc-empty">
<div class="icon">📄</div>
<div>Seleziona un documento dalla sidebar, o creane uno nuovo.</div>
</div>
</div>
<textarea id="editor" class="doc-editor" style="display:none;" spellcheck="false"></textarea>
</div>
</main>
</div>
<div id="toast" class="doc-toast"></div>
</div>
<script>
const API = "{{ apiUrl }}";
marked.setOptions({ breaks: true, gfm: true, highlight: (code, lang) => {
try { return hljs.highlight(code, { language: lang || 'plaintext' }).value; }
catch { return code; }
}});
let currentName = null;
let originalContent = '';
let mode = 'view';
const $ = (id) => document.getElementById(id);
function toast(msg, kind) {
const t = $('toast');
t.textContent = msg;
t.className = 'doc-toast show' + (kind === 'err' ? ' err' : '');
setTimeout(() => { t.className = 'doc-toast'; }, 2500);
}
async function api(path, opts = {}) {
const res = await fetch(`${API}${path}`, { credentials: 'include', ...opts });
if (!res.ok) {
const msg = await res.text().catch(() => 'errore');
throw new Error(`${res.status}: ${msg}`);
}
return res;
}
async function loadList() {
try {
const res = await api('/docs');
const files = await res.json();
const list = $('docList');
list.innerHTML = '';
if (!files.length) {
list.innerHTML = '<li style="opacity:.5; cursor:default;">Nessun documento. Creane uno.</li>';
return;
}
files.sort((a, b) => a.name.localeCompare(b.name));
for (const f of files) {
const li = document.createElement('li');
li.dataset.name = f.name;
li.innerHTML = `<span>${f.name}</span><button class="del" title="Elimina">×</button>`;
li.querySelector('span').addEventListener('click', () => openDoc(f.name));
li.addEventListener('click', (e) => { if (e.target.tagName !== 'BUTTON') openDoc(f.name); });
li.querySelector('.del').addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`Eliminare "${f.name}"?`)) return;
try { await api(`/docs/${encodeURIComponent(f.name)}`, { method: 'DELETE' }); await loadList(); if (currentName === f.name) resetView(); toast('Eliminato'); }
catch (err) { toast(err.message, 'err'); }
});
list.appendChild(li);
}
} catch (e) { toast('Errore caricamento lista: ' + e.message, 'err'); }
}
function resetView() {
currentName = null;
originalContent = '';
$('currentName').textContent = 'Nessun documento selezionato';
$('btnSave').disabled = true;
$('viewerWrap').innerHTML = '<div class="doc-empty"><div class="icon">📄</div><div>Seleziona un documento.</div></div>';
$('editor').value = '';
setMode('view');
}
async function openDoc(name) {
try {
const res = await api(`/docs/${encodeURIComponent(name)}`);
const content = await res.text();
currentName = name;
originalContent = content;
$('currentName').textContent = name;
$('editor').value = content;
render(content);
document.querySelectorAll('#docList li').forEach(li => li.classList.toggle('active', li.dataset.name === name));
$('btnSave').disabled = true;
setMode('view');
} catch (e) { toast(e.message, 'err'); }
}
function render(md) {
$('viewerWrap').innerHTML = marked.parse(md || '');
}
function setMode(m) {
mode = m;
document.querySelectorAll('#modeToggle button').forEach(b => b.classList.toggle('active', b.dataset.mode === m));
$('viewerWrap').style.display = m === 'view' ? '' : 'none';
$('editor').style.display = m === 'edit' ? 'block' : 'none';
if (m === 'view') render($('editor').value);
}
document.querySelectorAll('#modeToggle button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
$('editor').addEventListener('input', () => {
$('btnSave').disabled = ($('editor').value === originalContent) || !currentName;
});
$('btnSave').addEventListener('click', async () => {
if (!currentName) return;
try {
await api(`/docs/${encodeURIComponent(currentName)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: $('editor').value })
});
originalContent = $('editor').value;
$('btnSave').disabled = true;
toast('Salvato ✓');
render(originalContent);
} catch (e) { toast(e.message, 'err'); }
});
$('btnNew').addEventListener('click', async () => {
const name = prompt('Nome del nuovo documento (es. "guida-utente"):');
if (!name) return;
try {
await api('/docs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, content: `# ${name}\n\nScrivi qui…\n` })
});
await loadList();
await openDoc(name.endsWith('.md') ? name : name + '.md');
setMode('edit');
} catch (e) { toast(e.message, 'err'); }
});
loadList();
</script>
</body>
</html>

View File

@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Previsioni — MEB Console</title>
<link rel="stylesheet" href="../static/styles/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
.fc-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: .75rem; margin: 1rem 0; }
.fc-card { background: rgba(255,255,255,.04); border-radius: 10px; padding: 1rem; border: 1px solid rgba(255,255,255,.06); }
.fc-card h4 { margin: 0 0 .35rem; font-size: .78rem; opacity: .65; text-transform: uppercase; letter-spacing: .05em; }
.fc-card .val { font-size: 1.8rem; font-weight: 600; line-height: 1; }
.fc-card .unit { font-size: .9rem; opacity: .55; margin-left: .3rem; }
.fc-card .sub { margin-top: .4rem; font-size: .8rem; opacity: .7; }
.fc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
.fc-panel { background: rgba(255,255,255,.04); border-radius: 10px; padding: 1rem; border: 1px solid rgba(255,255,255,.06); }
.fc-panel h3 { margin: 0 0 .75rem; font-size: 1rem; }
canvas { max-height: 260px; }
.fc-status { display: inline-flex; align-items: center; gap: .4rem; font-size: .8rem; opacity: .8; }
.fc-status .dot { width: 8px; height: 8px; border-radius: 50%; background: #888; }
.fc-status.live .dot { background: #2ecc71; box-shadow: 0 0 8px #2ecc71; animation: pulse 2s infinite; }
.fc-status.stale .dot { background: #e67e22; }
@keyframes pulse { 50% { opacity: .4; } }
.windrose { display: flex; align-items: center; justify-content: center; height: 220px; position: relative; }
.windrose svg { width: 200px; height: 200px; }
.compass-label { position: absolute; font-size: .7rem; opacity: .6; }
.compass-label.n { top: 6px; left: 50%; transform: translateX(-50%); }
.compass-label.s { bottom: 6px; left: 50%; transform: translateX(-50%); }
.compass-label.e { right: 6px; top: 50%; transform: translateY(-50%); }
.compass-label.w { left: 6px; top: 50%; transform: translateY(-50%); }
.range-selector { display: flex; gap: .35rem; align-items: center; }
.range-selector button { padding: .3rem .75rem; border: 1px solid rgba(255,255,255,.15); background: transparent; color: inherit; border-radius: 6px; cursor: pointer; font-size: .8rem; }
.range-selector button.active { background: #3498db; border-color: transparent; color: #fff; }
</style>
</head>
<body>
<div class="contnent">
<div class="header">
<h1>Previsioni meteo-marine</h1>
<div class="profile">
<span id="fcStatus" class="fc-status"><span class="dot"></span><span id="fcStatusText">In attesa…</span></span>
<a href="/dashboard">Dashboard</a>
</div>
</div>
<div class="fc-summary">
<div class="fc-card">
<h4>Temperatura</h4>
<div><span class="val" id="sTemp">--</span><span class="unit">°C</span></div>
<div class="sub">Umidità: <span id="sHum">--</span>%</div>
</div>
<div class="fc-card">
<h4>Vento</h4>
<div><span class="val" id="sWind">--</span><span class="unit">m/s</span></div>
<div class="sub">Raffiche: <span id="sGust">--</span> m/s · Dir: <span id="sWindDir">--</span>°</div>
</div>
<div class="fc-card">
<h4>Pressione</h4>
<div><span class="val" id="sPressure">--</span><span class="unit">hPa</span></div>
<div class="sub">Nuvole: <span id="sCloud">--</span>% · Prob. pioggia: <span id="sRainProb">--</span>%</div>
</div>
<div class="fc-card">
<h4>Onde</h4>
<div><span class="val" id="sWaveH">--</span><span class="unit">m</span></div>
<div class="sub">Periodo: <span id="sWaveP">--</span>s · Dir: <span id="sWaveDir">--</span>°</div>
</div>
</div>
<div class="range-selector">
<span style="opacity:.6; margin-right:.5rem; font-size:.85rem;">Intervallo storico:</span>
<button data-range="1h">1h</button>
<button data-range="6h" class="active">6h</button>
<button data-range="24h">24h</button>
<button data-range="7d">7g</button>
</div>
<div class="fc-grid">
<div class="fc-panel">
<h3>Temperatura & Umidità</h3>
<canvas id="chartTemp"></canvas>
</div>
<div class="fc-panel">
<h3>Vento (velocità + raffiche)</h3>
<canvas id="chartWind"></canvas>
</div>
<div class="fc-panel">
<h3>Pressione & Copertura</h3>
<canvas id="chartPressure"></canvas>
</div>
<div class="fc-panel">
<h3>Precipitazioni</h3>
<canvas id="chartRain"></canvas>
</div>
<div class="fc-panel">
<h3>Onde — altezza & periodo</h3>
<canvas id="chartWaves"></canvas>
</div>
<div class="fc-panel">
<h3>Direzione (corrente)</h3>
<div class="windrose">
<span class="compass-label n">N</span><span class="compass-label s">S</span>
<span class="compass-label e">E</span><span class="compass-label w">W</span>
<svg viewBox="-100 -100 200 200">
<circle cx="0" cy="0" r="90" fill="none" stroke="rgba(255,255,255,.12)"/>
<circle cx="0" cy="0" r="60" fill="none" stroke="rgba(255,255,255,.08)"/>
<circle cx="0" cy="0" r="30" fill="none" stroke="rgba(255,255,255,.05)"/>
<line x1="0" y1="-90" x2="0" y2="90" stroke="rgba(255,255,255,.08)"/>
<line x1="-90" y1="0" x2="90" y2="0" stroke="rgba(255,255,255,.08)"/>
<g id="windArrow" transform="rotate(0)">
<polygon points="0,-70 -10,-40 0,-50 10,-40" fill="#3498db"/>
</g>
<g id="waveArrow" transform="rotate(0)">
<polygon points="0,-55 -6,-35 0,-42 6,-35" fill="#e67e22" opacity=".8"/>
</g>
</svg>
</div>
<div style="text-align:center; font-size:.75rem; opacity:.6;">
<span style="color:#3498db;"></span> Vento &nbsp; <span style="color:#e67e22;"></span> Onde
</div>
</div>
</div>
<p style="opacity:.5; font-size:.75rem; margin-top:1rem;">
Fonte: plugin SignalK → Open-Meteo (current ogni 5 min, hourly ogni 60 min) + dati marini.
Live via WebSocket; storico da InfluxDB.
</p>
</div>
<script>
const API_URL = "{{ apiUrl }}";
const WS_URL = "{{ realtimeWsUrl }}";
const MEASUREMENTS = {
temperature: 'meb.forecasts.temperature',
humidity: 'meb.forecast.humidity',
pressure: 'meb.forecast.pressure',
precipitation: 'meb.forecast.precipitation',
cloudCover: 'meb.forecast.cloudCover',
windSpeed: 'meb.forecast.wind.speed',
windDirection: 'meb.forecast.wind.direction',
windGusts: 'meb.forecast.wind.gusts',
waveHeight: 'meb.waves.height',
waveDirection: 'meb.waves.direction',
wavePeriod: 'meb.waves.period',
wavePeakPeriod: 'meb.waves.peakPeriod',
currentVelocity: 'meb.waves.currentVelocity',
currentDirection: 'meb.waves.currentDirection',
};
const current = {};
const series = {};
const MAX_POINTS = 500;
for (const k of Object.keys(MEASUREMENTS)) series[k] = [];
function pushPoint(key, ts, value) {
if (value == null || Number.isNaN(value)) return;
current[key] = value;
const arr = series[key];
arr.push({ x: ts, y: value });
if (arr.length > MAX_POINTS) arr.shift();
}
const fmt = (v, d = 1) => v == null ? '--' : Number(v).toFixed(d);
function refreshSummary() {
document.getElementById('sTemp').textContent = fmt(current.temperature);
document.getElementById('sHum').textContent = fmt(current.humidity, 0);
document.getElementById('sWind').textContent = fmt(current.windSpeed);
document.getElementById('sGust').textContent = fmt(current.windGusts);
document.getElementById('sWindDir').textContent = fmt(current.windDirection, 0);
document.getElementById('sPressure').textContent = fmt(current.pressure, 0);
document.getElementById('sCloud').textContent = fmt(current.cloudCover, 0);
document.getElementById('sWaveH').textContent = fmt(current.waveHeight, 2);
document.getElementById('sWaveP').textContent = fmt(current.wavePeriod, 1);
document.getElementById('sWaveDir').textContent = fmt(current.waveDirection, 0);
if (current.windDirection != null)
document.getElementById('windArrow').setAttribute('transform', `rotate(${current.windDirection})`);
if (current.waveDirection != null)
document.getElementById('waveArrow').setAttribute('transform', `rotate(${current.waveDirection})`);
}
const xScale = {
type: 'linear',
ticks: { color: '#888', maxTicksLimit: 6, callback: (v) => {
const d = new Date(v);
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
}},
grid: { color: 'rgba(255,255,255,.05)' }
};
const commonOpts = {
animation: false,
responsive: true,
maintainAspectRatio: false,
scales: { x: xScale, y: { ticks: { color: '#888' }, grid: { color: 'rgba(255,255,255,.05)' } } },
plugins: { legend: { labels: { color: '#bbb', boxWidth: 12 } } }
};
const mkChart = (id, datasets) => new Chart(document.getElementById(id), {
type: 'line', data: { datasets }, options: commonOpts
});
const charts = {
temp: mkChart('chartTemp', [
{ label: 'Temperatura (°C)', data: series.temperature, borderColor: '#e74c3c', backgroundColor: 'rgba(231,76,60,.15)', tension: .3, fill: true },
{ label: 'Umidità (%)', data: series.humidity, borderColor: '#3498db', tension: .3 }
]),
wind: mkChart('chartWind', [
{ label: 'Velocità (m/s)', data: series.windSpeed, borderColor: '#1abc9c', tension: .3 },
{ label: 'Raffiche (m/s)', data: series.windGusts, borderColor: '#9b59b6', borderDash: [4,4], tension: .3 }
]),
pressure: mkChart('chartPressure', [
{ label: 'Pressione (hPa)', data: series.pressure, borderColor: '#f39c12', tension: .3 },
{ label: 'Nuvole (%)', data: series.cloudCover, borderColor: '#95a5a6', tension: .3 }
]),
rain: mkChart('chartRain', [
{ label: 'Precipitazioni (mm)', data: series.precipitation, borderColor: '#2980b9', backgroundColor: 'rgba(41,128,185,.3)', fill: true, tension: .2 }
]),
waves: mkChart('chartWaves', [
{ label: 'Altezza (m)', data: series.waveHeight, borderColor: '#e67e22', tension: .3 },
{ label: 'Periodo (s)', data: series.wavePeriod, borderColor: '#16a085', tension: .3 }
]),
};
let redrawPending = false;
function scheduleRedraw() {
if (redrawPending) return;
redrawPending = true;
requestAnimationFrame(() => {
redrawPending = false;
refreshSummary();
for (const c of Object.values(charts)) c.update('none');
});
}
function setStatus(cls, text) {
document.getElementById('fcStatus').className = 'fc-status ' + cls;
document.getElementById('fcStatusText').textContent = text;
}
async function loadHistory(range = '6h') {
setStatus('', 'Carico storico…');
const measurements = Object.values(MEASUREMENTS);
try {
const res = await fetch(
`${API_URL}/data/history?range=${range}&measurements=${encodeURIComponent(measurements.join(','))}`,
{ credentials: 'include' }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
for (const [key, mName] of Object.entries(MEASUREMENTS)) {
const rows = payload[mName] || payload[key];
if (!Array.isArray(rows)) continue;
series[key].length = 0;
for (const row of rows.slice(-MAX_POINTS)) {
series[key].push({ x: new Date(row.ts).getTime(), y: Number(row.value) });
}
if (rows.length) current[key] = Number(rows[rows.length - 1].value);
}
scheduleRedraw();
setStatus('live', 'Storico caricato');
} catch (e) {
console.warn('[history]', e.message);
setStatus('stale', 'Storico non disponibile');
}
}
let ws, reconnectTimer;
function connect() {
try { ws = new WebSocket(`${WS_URL}/live`); }
catch { return setStatus('stale', 'Errore WS'); }
ws.onopen = () => setStatus('live', 'Live');
ws.onclose = () => { setStatus('stale', 'Disconnesso, riprovo…'); clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, 4000); };
ws.onerror = () => setStatus('stale', 'Errore WS');
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
const ts = new Date(msg.timestamp || Date.now()).getTime();
const key = Object.entries(MEASUREMENTS).find(([, v]) => v === msg.measurement)?.[0];
if (!key) return;
const value = msg.fields?.value ?? msg.value;
pushPoint(key, ts, Number(value));
scheduleRedraw();
} catch {}
};
}
document.querySelectorAll('.range-selector button').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.range-selector button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
loadHistory(b.dataset.range);
});
});
loadHistory('6h');
connect();
</script>
</body>
</html>

View File

@@ -0,0 +1,560 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kiosk Dashboard</title>
<script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
<link rel="stylesheet" href="../static/styles/style.css">
<link rel="stylesheet" href="../static/styles/kiosk.css">
</head>
<body>
<!-- Main Grid Canvas -->
<div id="canvas" class="canvas">
<div id="emptyState" class="empty-state">Trascina o aggiungi una card per iniziare</div>
</div>
<!-- UI Feedback & Overlays -->
<div id="tooltip" class="tooltip"></div>
<div id="unitBadge" class="unit-badge">1u = 0px</div>
<div id="toast" class="toast"></div>
<div id="modalOverlay" class="modal-overlay">
<div class="modal-content">
<h2 id="modalTitle">Configurazione</h2>
<textarea id="importArea" placeholder="JSON..."></textarea>
<div class="modal-actions">
<button id="modalCancel">Annulla</button>
<button id="modalApply" class="primary">Applica</button>
</div>
</div>
</div>
<!-- Toolbar -->
<div class="toolbar">
<p id="cardCount">0 cards</p>
<button id="editBtn">Edit</button>
<button id="addCardBtn" title="Aggiungi Widget">+</button>
<button id="addMapBtn" title="Aggiungi Mappa">Map</button>
<button id="importBtn">Import</button>
<button id="exportBtn">Export</button>
<button id="clearBtn">X</button>
</div>
<script src="canvas.js"></script>
<script src="core.js"></script>
</body>
<script>
const COLS = 24, ROWS = 18;
const SNAP = 0.5;
const SNAP_MAG = 0.3;
const MIN_GW = 2, MIN_GH = 1.5;
const MAX_GW = 20, MAX_GH = 16;
const DEF_GW = 6, DEF_GH = 5;
const canvasEl = document.getElementById('canvas');
const tooltipEl = document.getElementById('tooltip');
const emptyState = document.getElementById('emptyState');
const cardCountEl = document.getElementById('cardCount');
const unitBadge = document.getElementById('unitBadge');
const modalOvl = document.getElementById('modalOverlay');
const modalTitle = document.getElementById('modalTitle');
const importArea = document.getElementById('importArea');
const modalApply = document.getElementById('modalApply');
const toastEl = document.getElementById('toast');
let cards = [], cardIdCounter = 0, selectedCard = null, zCounter = 1;
let snapGuidesH = [], snapGuidesV = [];
let editMode = false;
const uw = () => canvasEl.clientWidth / COLS;
const uh = () => canvasEl.clientHeight / ROWS;
const gSnap = v => Math.round(v / SNAP) * SNAP;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function screenToGrid(cx, cy) {
const r = canvasEl.getBoundingClientRect();
return { gx: (cx - r.left) / uw(), gy: (cy - r.top) / uh() };
}
function renderCard(c) {
const u = uw(), h = uh();
c.el.style.left = (c.gx * u) + 'px';
c.el.style.top = (c.gy * h) + 'px';
c.el.style.width = (c.gw * u) + 'px';
c.el.style.height = (c.gh * h) + 'px';
if (editMode) c.el.classList.add('editable');
else c.el.classList.remove('editable', 'selected');
}
function renderAll() {
cards.forEach(renderCard);
unitBadge.textContent = `1u = ${Math.round(uw())}px`;
}
function updateCount() {
const n = cards.length;
cardCountEl.textContent = `${n} card${n !== 1 ? 's' : ''}`;
emptyState.classList.toggle('hidden', n > 0);
}
// Responsive re-render
let rafId = null;
window.addEventListener('resize', () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(renderAll);
});
// Toast
let toastT = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastT);
toastT = setTimeout(() => toastEl.classList.remove('show'), 2200);
}
// Guides
function ensureGuides() {
if (snapGuidesH.length) return;
for (let i = 0; i < 2; i++) {
let g = document.createElement('div'); g.className = 'guide snap-guide horizontal'; canvasEl.appendChild(g); snapGuidesH.push(g);
g = document.createElement('div'); g.className = 'guide snap-guide vertical'; canvasEl.appendChild(g); snapGuidesV.push(g);
}
}
function hideGuides() {
snapGuidesH.forEach(g => g.classList.remove('visible')); snapGuidesV.forEach(g => g.classList.remove('visible'));
}
function showGuide(type, gp, idx = 0) {
if (type === 'h' && idx < snapGuidesH.length) { snapGuidesH[idx].style.top = (gp * uh()) + 'px'; snapGuidesH[idx].classList.add('visible'); }
else if (type === 'v' && idx < snapGuidesV.length) { snapGuidesV[idx].style.left = (gp * uw()) + 'px'; snapGuidesV[idx].classList.add('visible'); }
}
// Magnetic snap
function magSnap(el, gx, gy, gw, gh) {
let sx = gx, sy = gy, gH = [], gV = [];
const others = cards.filter(c => c.el !== el);
let bH = SNAP_MAG + 1;
for (const o of others)
for (const [f, t] of [[gy, o.gy], [gy, o.gy + o.gh], [gy + gh, o.gy], [gy + gh, o.gy + o.gh]]) {
const d = Math.abs(f - t);
if (d < bH) { bH = d; sy = gy + (t - f); gH = [t]; }
}
let bV = SNAP_MAG + 1;
for (const o of others)
for (const [f, t] of [[gx, o.gx], [gx, o.gx + o.gw], [gx + gw, o.gx], [gx + gw, o.gx + o.gw]]) {
const d = Math.abs(f - t);
if (d < bV) { bV = d; sx = gx + (t - f); gV = [t]; }
}
if (bH > SNAP_MAG) sy = gSnap(gy);
if (bV > SNAP_MAG) sx = gSnap(gx);
return { gx: sx, gy: sy, guidesH: gH, guidesV: gV };
}
// Signal K data handling
function updateData(path, value) {
cards.filter(c => c.path === path).forEach(c => {
const body = c.el.querySelector('.card-body');
if (body) {
let displayVal = value;
if (path.includes('speed')) displayVal = (value * 1.94384).toFixed(1) + ' kn';
else if (path.includes('depth')) displayVal = value.toFixed(1) + ' m';
body.textContent = displayVal;
}
});
}
window.updateKioskData = updateData;
// Create card
function createCard(gx, gy, gw = DEF_GW, gh = DEF_GH, forceId = null, gz = null, type = 'widget', path = null) {
const id = forceId || (++cardIdCounter);
if (forceId && forceId >= cardIdCounter) cardIdCounter = forceId;
if (!forceId && id > cardIdCounter) cardIdCounter = id;
const skPaths = window.skPaths || [];
const finalPath = path || (type === 'widget' && skPaths.length ? skPaths[(id - 1) % skPaths.length] : null);
const el = document.createElement('div');
el.className = 'card spawning' + (editMode ? ' editable' : '');
el.dataset.id = id;
el.dataset.type = type;
const z = gz || (++zCounter);
el.style.zIndex = z;
if (gz && gz >= zCounter) zCounter = gz;
let headerHtml = `<span class="card-label">${type === 'map' ? 'Mappa' : (finalPath ? finalPath.split('.').pop() : 'Widget')}</span>`;
// Suggerimento menu path se widget
if (type === 'widget') {
let menuHtml = `<div class="path-menu">`;
skPaths.forEach(p => {
menuHtml += `<div class="path-option" data-path="${p}">${p.split('.').pop()}</div>`;
});
menuHtml += `</div>`;
headerHtml = `<div class="label-wrapper">${headerHtml}${menuHtml}</div>`;
}
el.innerHTML = `
<div class="card-header">
${headerHtml}
<button class="card-close" title="Rimuovi">ELIMINA</button>
</div>
<div class="card-body"></div>
<div class="rh corner nw"></div><div class="rh corner ne"></div>
<div class="rh corner se"></div><div class="rh corner sw"></div>
<div class="rh edge n"></div><div class="rh edge s"></div>
<div class="rh edge e"></div><div class="rh edge w"></div>`;
canvasEl.appendChild(el);
const c = { id, el, gx, gy, gw, gh, type, path: finalPath };
cards.push(c);
renderCard(c);
if (type === 'map') {
const mapDiv = document.createElement('div');
mapDiv.id = `map-container-${id}`;
mapDiv.className = 'card-map-canvas';
el.querySelector('.card-body').appendChild(mapDiv);
if (window.initMapInstance) window.initMapInstance(mapDiv.id);
} else {
updateBody(c);
// Listener per il cambio path
el.querySelectorAll('.path-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation();
c.path = opt.dataset.path;
el.querySelector('.card-label').textContent = c.path.split('.').pop();
toast(`Path aggiornato: ${c.path}`);
});
});
}
el.addEventListener('animationend', () => el.classList.remove('spawning'), { once: true });
el.querySelector('.card-close').addEventListener('click', ev => { ev.stopPropagation(); removeCard(c); });
el.addEventListener('mousedown', () => { if (editMode) selectCard(c); });
setupDrag(c);
setupResize(c);
updateCount();
return c;
}
function removeCard(c) {
c.el.classList.add('removing');
c.el.addEventListener('animationend', () => {
c.el.remove();
cards = cards.filter(x => x.id !== c.id);
if (selectedCard === c) selectedCard = null;
updateCount();
}, { once: true });
}
function selectCard(c) {
if (selectedCard?.el) selectedCard.el.classList.remove('selected');
selectedCard = c; c.el.classList.add('selected'); c.el.style.zIndex = ++zCounter;
}
function updateBody(c) {
if (c.type === 'map') {
if (window.resizeMapInstance) window.resizeMapInstance();
} else {
const b = c.el.querySelector('.card-body');
if (b && !b.textContent.trim()) {
b.textContent = `${c.gw.toFixed(1)} × ${c.gh.toFixed(1)} u`;
}
}
}
// Drag
function setupDrag(c) {
c.el.addEventListener('mousedown', e => {
if (!editMode) return;
if (e.target.classList.contains('rh') || e.target.classList.contains('card-close')) return;
e.preventDefault(); ensureGuides(); c.el.classList.add('dragging');
const start = screenToGrid(e.clientX, e.clientY);
const oGx = c.gx, oGy = c.gy;
const onMove = ev => {
const now = screenToGrid(ev.clientX, ev.clientY);
let nx = oGx + (now.gx - start.gx), ny = oGy + (now.gy - start.gy);
nx = clamp(nx, 0, COLS - c.gw); ny = clamp(ny, 0, ROWS - c.gh);
const s = magSnap(c.el, nx, ny, c.gw, c.gh);
c.gx = clamp(s.gx, 0, COLS - c.gw); c.gy = clamp(s.gy, 0, ROWS - c.gh);
hideGuides();
s.guidesH.forEach((p, i) => showGuide('h', p, i));
s.guidesV.forEach((p, i) => showGuide('v', p, i));
renderCard(c);
tooltipEl.textContent = `${c.gx.toFixed(1)}, ${c.gy.toFixed(1)}`;
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
tooltipEl.classList.add('visible');
};
const onUp = () => {
c.el.classList.remove('dragging'); hideGuides(); tooltipEl.classList.remove('visible');
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
if (c.type === 'map' && window.resizeMapInstance) window.resizeMapInstance();
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
}
// Resize
function setupResize(c) {
c.el.querySelectorAll('.rh').forEach(h => {
h.addEventListener('mousedown', e => {
if (!editMode) return;
e.preventDefault(); e.stopPropagation(); ensureGuides();
c.el.classList.add('resizing'); selectCard(c);
const start = screenToGrid(e.clientX, e.clientY);
const oGx = c.gx, oGy = c.gy, oGw = c.gw, oGh = c.gh;
const isN = h.classList.contains('nw') || h.classList.contains('ne') || h.classList.contains('n');
const isS = h.classList.contains('sw') || h.classList.contains('se') || h.classList.contains('s');
const isW = h.classList.contains('nw') || h.classList.contains('sw') || h.classList.contains('w');
const isE = h.classList.contains('ne') || h.classList.contains('se') || h.classList.contains('e');
const onMove = ev => {
const now = screenToGrid(ev.clientX, ev.clientY);
const dx = now.gx - start.gx, dy = now.gy - start.gy;
let nw = oGw, nh = oGh, nx = oGx, ny = oGy;
if (isE) nw = oGw + dx; if (isS) nh = oGh + dy;
if (isW) { nw = oGw - dx; nx = oGx + dx; }
if (isN) { nh = oGh - dy; ny = oGy + dy; }
nw = gSnap(clamp(nw, MIN_GW, MAX_GW)); nh = gSnap(clamp(nh, MIN_GH, MAX_GH));
if (isW) nx = oGx + oGw - nw; if (isN) ny = oGy + oGh - nh;
nx = gSnap(clamp(nx, 0, COLS - nw)); ny = gSnap(clamp(ny, 0, ROWS - nh));
c.gx = nx; c.gy = ny; c.gw = nw; c.gh = nh;
renderCard(c); updateBody(c);
tooltipEl.textContent = `${nw.toFixed(1)} × ${nh.toFixed(1)} u`;
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
tooltipEl.classList.add('visible');
};
const onUp = () => {
c.el.classList.remove('resizing'); tooltipEl.classList.remove('visible'); hideGuides();
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
}
// ═══════════════════════════════════════════════════════
// EXPORT / IMPORT
// ═══════════════════════════════════════════════════════
function exportConfig() {
return JSON.stringify({
canvas: { cols: COLS, rows: ROWS },
cards: cards.map(c => ({
id: c.id, type: c.type,
dimensions: {
x: Math.round(c.gx * 100) / 100, y: Math.round(c.gy * 100) / 100,
z: parseInt(c.el.style.zIndex) || 1,
width: Math.round(c.gw * 100) / 100, height: Math.round(c.gh * 100) / 100
}
}))
}, null, 2);
}
function importConfig(json) {
let data;
try { data = JSON.parse(json); } catch { toast('JSON non valido'); return false; }
if (!data.cards || !Array.isArray(data.cards)) { toast('Formato non valido: serve "cards" array'); return false; }
cards.forEach(c => c.el.remove());
cards = []; selectedCard = null; cardIdCounter = 0; zCounter = 1;
for (const entry of data.cards) {
const d = entry.dimensions || {};
createCard(
clamp(d.x ?? 0, 0, COLS - MIN_GW), clamp(d.y ?? 0, 0, ROWS - MIN_GH),
clamp(d.width ?? DEF_GW, MIN_GW, MAX_GW), clamp(d.height ?? DEF_GH, MIN_GH, MAX_GH),
entry.id ?? null, d.z ?? 1, entry.type ?? 'widget'
);
}
updateCount(); renderAll();
toast(`Importate ${data.cards.length} card`);
return true;
}
// ── Toolbar ─────────────────────────────────────────
document.getElementById('editBtn').addEventListener('click', (e) => {
editMode = !editMode;
e.target.classList.toggle('primary', editMode);
canvasEl.classList.toggle('edit-active', editMode);
document.body.classList.toggle('edit-mode', editMode);
renderAll();
toast(editMode ? 'Edit Mode Attiva' : 'Edit Mode Disattiva');
});
document.getElementById('addCardBtn').addEventListener('click', () => {
const off = (cards.length % 8) * SNAP * 2;
createCard(gSnap(1 + off), gSnap(1 + off));
});
document.getElementById('addMapBtn').addEventListener('click', () => {
createCard(gSnap(4), gSnap(4), 10, 8, null, null, 'map');
});
document.getElementById('exportBtn').addEventListener('click', () => {
const json = exportConfig();
modalTitle.textContent = 'Esporta configurazione JSON';
importArea.value = json; importArea.readOnly = true;
modalApply.textContent = 'Copia';
modalOvl.classList.add('open');
});
document.getElementById('importBtn').addEventListener('click', () => {
modalTitle.textContent = 'Importa configurazione JSON';
importArea.value = ''; importArea.readOnly = false;
modalApply.textContent = 'Applica';
modalOvl.classList.add('open');
setTimeout(() => importArea.focus(), 200);
});
document.getElementById('modalCancel').addEventListener('click', () => modalOvl.classList.remove('open'));
modalApply.addEventListener('click', () => {
if (modalTitle.textContent.includes('Esporta')) {
navigator.clipboard.writeText(importArea.value).then(() => toast('Copiato')).catch(() => toast('Errore copia'));
modalOvl.classList.remove('open');
} else {
if (importConfig(importArea.value)) modalOvl.classList.remove('open');
}
});
modalOvl.addEventListener('click', e => { if (e.target === modalOvl) modalOvl.classList.remove('open'); });
document.getElementById('clearBtn').addEventListener('click', () => {
[...cards].forEach((c, i) => setTimeout(() => removeCard(c), i * 40));
});
canvasEl.addEventListener('mousedown', e => {
if (e.target === canvasEl && selectedCard) { selectedCard.el.classList.remove('selected'); selectedCard = null; }
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { modalOvl.classList.remove('open'); return; }
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedCard) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
removeCard(selectedCard);
}
});
updateCount(); renderAll();
const paths = [
"navigation.speedOverGround",
"environment.depth.belowTransducer",
]
window.skPaths = paths;
mapboxgl.accessToken = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
let map = null;
let boatMark = null;
let followBoat = true;
window.initMapInstance = (containerId) => {
map = new mapboxgl.Map({
container: containerId,
style: {
"version": 8,
"sources": {
"osm": { "type": "raster", "tiles": ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"], "tileSize": 256 },
"openseamap": { "type": "raster", "tiles": ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"], "tileSize": 256 }
},
"layers": [
{ "id": "osm-layer", "type": "raster", "source": "osm", "minzoom": 0, "maxzoom": 22 },
{ "id": "openseamap-layer", "type": "raster", "source": "openseamap", "minzoom": 0, "maxzoom": 18 }
]
}
});
map.on('dragstart', () => {
followBoat = false;
});
boatMark = new mapboxgl.Marker({ color: 'red' })
.setLngLat([9, 9])
.addTo(map);
map.on('load', () => {
// Area Protetta mock
map.addSource('area-protetta', {
'type': 'geojson',
'data': {
'type': 'Feature',
'geometry': {
'type': 'Polygon',
'coordinates': [[[9.05, 45.05], [9.15, 45.05], [9.15, 45.15], [9.05, 45.15], [9.05, 45.05]]]
}
}
});
map.addLayer({
'id': 'area-layer',
'type': 'fill',
'source': 'area-protetta',
'paint': { 'fill-color': '#0080ff', 'fill-opacity': 0.3 }
});
});
};
window.resizeMapInstance = () => {
if (map) map.resize();
};
function movePosition(lng, lat) {
if (!followBoat || !map) return;
map.flyTo({ center: [lng, lat], zoom: 14, speed: 1.2 });
}
const host = window.location.host;
const connection = `ws://${host}/signalk/v1/stream?subscribe=all`;
const ws = new WebSocket(connection);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.updates) {
msg.updates.forEach(update => {
if (update.values) {
update.values.forEach(v => {
// Aggiorna le card nel dashboard tramite canvas.js
if (window.updateKioskData) {
window.updateKioskData(v.path, v.value);
}
if (v.path === "navigation.position" && boatMark) {
const lng = v.value.longitude;
const lat = v.value.latitude;
boatMark.setLngLat([lng, lat]);
movePosition(lng, lat);
}
});
}
});
}
};
ws.onerror = (err) => console.error("Errore WebSocket:", err);
ws.onclose = () => {
console.log("WebSocket chiuso. Riconnessione tra 5s...");
setTimeout(() => location.reload(), 5000);
};
// style: 'mapbox://styles/sesee3/cmn9767jg003l01qsbpmace1t/draft'
</script>
</html>

View File

@@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Kiosk Live</title>
<link rel="stylesheet" href="/static/styles/style.css">
<link rel="stylesheet" href="/static/styles/kiosk.css">
<style>
body { margin:0; display:flex; flex-direction:column; height:100vh; background:#0b1220; color:#fff; font-family:sans-serif; }
.topbar { display:flex; align-items:center; gap:12px; padding:8px 14px; background:#111827; border-bottom:1px solid #1f2937; }
.status { display:flex; align-items:center; gap:6px; font-size:13px; }
.dot { width:10px; height:10px; border-radius:50%; background:#6b7280; }
.dot.on { background:#10b981; } .dot.off { background:#ef4444; }
.topbar select, .topbar button, .topbar input { background:#1f2937; color:#fff; border:1px solid #374151; padding:6px 10px; border-radius:6px; font-size:13px; }
.topbar button { cursor:pointer; } .topbar button:hover { background:#374151; }
.topbar .spacer { flex:1; }
.main { flex:1; display:flex; min-height:0; }
.stage { flex:1; position:relative; background:#0b1220; overflow:hidden; }
.canvas { position:absolute; inset:0; }
.box { position:absolute; border-radius:8px; padding:10px; overflow:hidden; cursor:pointer; border:2px solid transparent; display:flex; flex-direction:column; }
.box:hover { border-color:#38bdf8; }
.box.selected { border-color:#f59e0b; }
.box .bt { font-size:12px; opacity:.7; text-transform:uppercase; letter-spacing:.05em; }
.box .bv { flex:1; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:2.5vw; }
.side { width:320px; background:#111827; border-left:1px solid #1f2937; padding:14px; overflow:auto; }
.side h3 { margin:0 0 10px; font-size:14px; }
.side label { display:block; font-size:12px; margin:8px 0 3px; opacity:.7; }
.side input, .side select { width:100%; box-sizing:border-box; background:#1f2937; color:#fff; border:1px solid #374151; padding:6px 8px; border-radius:4px; font-size:13px; }
.side .row { display:flex; gap:6px; }
.side .row > * { flex:1; }
.side .actions { display:flex; gap:6px; margin-top:14px; }
.side button { flex:1; padding:8px; background:#2563eb; color:#fff; border:0; border-radius:6px; cursor:pointer; }
.side button.danger { background:#dc2626; }
.modal { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; z-index:1000; }
.modal.open { display:flex; }
.modal .card { background:#111827; padding:20px; border-radius:8px; max-width:560px; width:90%; max-height:80vh; overflow:auto; }
.tlist { display:flex; flex-direction:column; gap:6px; margin:10px 0; }
.tlist .t { padding:10px; background:#1f2937; border-radius:6px; cursor:pointer; display:flex; justify-content:space-between; }
.tlist .t.active { border:1px solid #10b981; }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#1f2937; padding:8px 14px; border-radius:6px; opacity:0; transition:opacity .2s; }
.toast.show { opacity:1; }
</style>
</head>
<body>
<div class="topbar">
<div class="status"><span id="dot" class="dot"></span><span id="statusTxt">connecting…</span></div>
<label>Sensor: <input id="sensorInput" placeholder="sensor name" value=""></label>
<button id="connectBtn">Connect</button>
<div class="spacer"></div>
<button id="loadBtn">Load template…</button>
<button id="persistBtn">Save as new template</button>
<button id="reloadBtn">Reload kiosk</button>
</div>
<div class="main">
<div class="stage"><div id="canvas" class="canvas"></div></div>
<div class="side">
<h3 id="sideTitle">No box selected</h3>
<div id="form" style="display:none;">
<label>Title</label><input id="fTitle">
<label>SignalK path</label><input id="fPath">
<div class="row">
<div><label>Unit</label><input id="fUnit"></div>
<div><label>Decimals</label><input id="fDec" type="number" min="0" max="6"></div>
</div>
<label>Multiplier</label><input id="fMul" type="number" step="any">
<div class="row">
<div><label>Color</label><input id="fColor" type="color"></div>
<div><label>Text</label><input id="fText" type="color"></div>
</div>
<div class="row">
<div><label>X</label><input id="fX" type="number" step="0.5"></div>
<div><label>Y</label><input id="fY" type="number" step="0.5"></div>
</div>
<div class="row">
<div><label>W</label><input id="fW" type="number" step="0.5"></div>
<div><label>H</label><input id="fH" type="number" step="0.5"></div>
</div>
<div class="actions">
<button id="saveBox">Apply</button>
<button id="delBox" class="danger">Delete</button>
</div>
</div>
<div id="empty" style="opacity:.6;font-size:13px;">Click a box to edit it. Changes apply live to the kiosk.</div>
</div>
</div>
<div id="modal" class="modal">
<div class="card">
<h3>Templates</h3>
<div id="tlist" class="tlist"></div>
<div style="display:flex; justify-content:flex-end; gap:6px;">
<button id="modalClose">Close</button>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const API_URL = "{{ apiUrl }}";
const WS_URL = "{{ realtimeWsUrl }}";
const canvasEl = document.getElementById('canvas');
const dotEl = document.getElementById('dot');
const stEl = document.getElementById('statusTxt');
const toastEl = document.getElementById('toast');
const COLS = 24, ROWS = 18;
let template = null, boxes = [], selected = null, ws = null, cmdSeq = 0;
let sensorName = localStorage.getItem('kiosk_sensor') || '';
document.getElementById('sensorInput').value = sensorName;
function toast(m){ toastEl.textContent = m; toastEl.classList.add('show'); setTimeout(()=>toastEl.classList.remove('show'), 1800); }
function nextCmd(){ return 'c' + (++cmdSeq); }
function render() {
canvasEl.innerHTML = '';
if (!template) return;
const W = canvasEl.clientWidth, H = canvasEl.clientHeight;
const uw = W/COLS, uh = H/ROWS;
for (const b of boxes) {
const el = document.createElement('div');
el.className = 'box' + (selected?.id === b.id ? ' selected':'');
el.style.left = (b.x*uw)+'px'; el.style.top=(b.y*uh)+'px';
el.style.width=(b.w*uw)+'px'; el.style.height=(b.h*uh)+'px';
el.style.background = b.color || '#1e293b';
el.style.color = b.textColor || '#fff';
el.innerHTML = `<div class="bt">${b.title||b.path||''}</div><div class="bv">${b.path||''}${b.unit?' '+b.unit:''}</div>`;
el.onclick = () => selectBox(b);
canvasEl.appendChild(el);
}
}
window.addEventListener('resize', render);
function selectBox(b) {
selected = b;
document.getElementById('sideTitle').textContent = 'Box: ' + (b.title||b.id);
document.getElementById('form').style.display = 'block';
document.getElementById('empty').style.display = 'none';
for (const [id, key] of [['fTitle','title'],['fPath','path'],['fUnit','unit'],['fDec','decimals'],['fMul','multiplier'],['fColor','color'],['fText','textColor'],['fX','x'],['fY','y'],['fW','w'],['fH','h']]) {
document.getElementById(id).value = b[key] ?? '';
}
render();
}
document.getElementById('saveBox').onclick = () => {
if (!selected) return;
const patch = {
title: document.getElementById('fTitle').value,
path: document.getElementById('fPath').value,
unit: document.getElementById('fUnit').value,
decimals: +document.getElementById('fDec').value || 0,
multiplier: +document.getElementById('fMul').value || 1,
color: document.getElementById('fColor').value,
textColor: document.getElementById('fText').value,
x: +document.getElementById('fX').value,
y: +document.getElementById('fY').value,
w: +document.getElementById('fW').value,
h: +document.getElementById('fH').value,
};
Object.assign(selected, patch);
render();
sendCmd({ t:'patch_box', boxId: selected.id, patch });
};
document.getElementById('delBox').onclick = () => {
if (!selected) return;
const id = selected.id;
boxes = boxes.filter(b => b.id !== id);
selected = null;
document.getElementById('form').style.display = 'none';
document.getElementById('empty').style.display = '';
render();
sendCmd({ t:'remove_box', boxId: id });
};
function sendCmd(obj) {
if (!ws || ws.readyState !== 1) { toast('not connected'); return; }
const cmdId = nextCmd();
ws.send(JSON.stringify({ ...obj, cmdId }));
}
async function fetchActive() {
const r = await fetch(`${API_URL}/kiosk/template/active`, { credentials:'include' });
if (!r.ok) return null;
return r.json();
}
async function fetchTemplate(id) {
const r = await fetch(`${API_URL}/kiosk/templates/${id}`, { credentials:'include' });
if (!r.ok) return null;
return r.json();
}
async function fetchList() {
const r = await fetch(`${API_URL}/kiosk/templates`, { credentials:'include' });
return r.ok ? r.json() : [];
}
// ── Mapping fra il modello DB (kioskelements: label,x,y,width,height,color,font)
// e il modello UI interno "box" (id,title,x,y,w,h,color,font + campi runtime
// non persistiti come path/unit/decimals/multiplier/textColor che servono solo
// per il rendering live sul device).
function elementToBox(e) {
return {
id: String(e.id), // bigint dal DB → stringa per uso UI
title: e.label || '',
label: e.label || '',
x: e.x, y: e.y,
w: e.width, h: e.height,
color: e.color || '#1e293b',
font: e.font ?? 16,
// campi runtime-only (non persistiti)
path: e.sk_path || '',
unit: e.unit || '',
decimals: e.decimals ?? 1,
multiplier: e.multiplier ?? 1,
textColor: '#ffffff'
};
}
function boxToElement(b) {
// Solo i campi che esistono come colonne in kioskelements
return {
label: (b.title || b.label || '').slice(0, 100),
x: Math.max(0, Math.round(b.x || 0)),
y: Math.max(0, Math.round(b.y || 0)),
width: Math.max(1, Math.round(b.w || 1)),
height: Math.max(1, Math.round(b.h || 1)),
color: b.color || '#1e293b',
font: parseInt(b.font, 10) || 16
};
}
async function loadTemplate(tpl) {
template = tpl;
// Backward-compat: accetta sia il nuovo `elements` che il legacy `content.boxes`.
const els = Array.isArray(tpl.elements) ? tpl.elements
: (tpl.content?.boxes || []);
boxes = els.map(e => (e.label !== undefined || e.width !== undefined)
? elementToBox(e)
: { ...e });
selected = null;
document.getElementById('form').style.display='none';
document.getElementById('empty').style.display='';
render();
}
function connect() {
sensorName = document.getElementById('sensorInput').value.trim();
if (!sensorName) { toast('sensor name required'); return; }
localStorage.setItem('kiosk_sensor', sensorName);
if (ws) { try { ws.close(); } catch {} }
ws = new WebSocket(`${WS_URL}/kiosk?role=controller&sensor=${encodeURIComponent(sensorName)}`);
stEl.textContent = 'connecting…'; dotEl.className = 'dot';
ws.onopen = () => { stEl.textContent = 'connected'; };
ws.onclose = () => { dotEl.className='dot off'; stEl.textContent='disconnected'; };
ws.onerror = () => { stEl.textContent = 'error'; };
ws.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.t === 'kiosk_status') {
dotEl.className = 'dot ' + (m.online ? 'on':'off');
stEl.textContent = m.online ? `online (tpl ${m.templateId||'?'})` : 'kiosk offline';
} else if (m.t === 'ack') {
if (!m.ok) toast('cmd failed: ' + (m.err||''));
} else if (m.t === 'active_template_changed') {
fetchTemplate(m.templateId).then(t => t && loadTemplate(t));
}
};
}
document.getElementById('connectBtn').onclick = connect;
document.getElementById('loadBtn').onclick = async () => {
const list = await fetchList();
const wrap = document.getElementById('tlist');
wrap.innerHTML = '';
for (const t of list) {
const row = document.createElement('div');
row.className = 't' + (t.active?' active':'');
row.innerHTML = `<span>${t.name} ${t.active?'★':''}</span><span><button data-act data-id="${t.id}">Activate & send</button> <button data-prev data-id="${t.id}">Preview</button></span>`;
wrap.appendChild(row);
}
wrap.querySelectorAll('button[data-act]').forEach(b => b.onclick = async () => {
const id = b.dataset.id;
const r = await fetch(`${API_URL}/kiosk/templates/${id}/activate`, { method:'POST', credentials:'include' });
if (r.ok) { toast('activated'); document.getElementById('modal').classList.remove('open'); sendCmd({ t:'load_template', templateId: id }); const tpl = await fetchTemplate(id); if (tpl) loadTemplate(tpl); }
else toast('activate failed');
});
wrap.querySelectorAll('button[data-prev]').forEach(b => b.onclick = async () => {
const tpl = await fetchTemplate(b.dataset.id);
if (tpl) {
loadTemplate(tpl);
// Costruisci payload runtime per il device (boxes ricostruite).
const content = { grid:{cols:COLS, rows:ROWS}, boxes: boxes.map(x => ({...x})) };
sendCmd({ t:'apply_inline', content });
document.getElementById('modal').classList.remove('open');
}
});
document.getElementById('modal').classList.add('open');
};
document.getElementById('modalClose').onclick = () => document.getElementById('modal').classList.remove('open');
document.getElementById('persistBtn').onclick = async () => {
if (!template) return toast('no template loaded');
const name = prompt('New template name:', (template.name || 'Template') + ' (edited)');
if (!name) return;
const body = {
name: String(name).slice(0, 50),
tags: template.tags || [],
elements: boxes.map(boxToElement)
};
const r = await fetch(`${API_URL}/kiosk/templates`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) toast('saved'); else toast('save failed');
};
document.getElementById('reloadBtn').onclick = () => sendCmd({ t:'reload' });
// boot
(async () => {
const tpl = await fetchActive();
if (tpl) loadTemplate(tpl);
if (sensorName) connect();
})();
</script>
</body>
</html>

View File

@@ -28,6 +28,20 @@
</div> </div>
</div> </div>
<!-- Session Label Popup -->
<div class="session-label-overlay" id="sessionLabelOverlay" style="display:none">
<div class="session-popup">
<h2>Nome Sessione</h2>
<p class="popup-subtitle">I nuovi dati verranno taggati con questo nome</p>
<input type="text" id="sessionLabelInput" placeholder="es. Traversata Sardegna" />
<p style="font-size:0.8rem;color:#94a3b8;margin:8px 0;">Attuale: <span id="currentSessionLabel"></span></p>
<div style="display:flex;gap:8px;">
<button id="saveSessionLabelBtn">Salva</button>
<button id="cancelSessionLabelBtn" style="background:#334155;">Annulla</button>
</div>
</div>
</div>
<!-- Main Content --> <!-- Main Content -->
<div class="content" id="mainContent" style="display: none;"> <div class="content" id="mainContent" style="display: none;">
<div class="header"> <div class="header">
@@ -154,6 +168,10 @@
<div class="bar-sep"></div> <div class="bar-sep"></div>
<button id="sessionLabelBtn" title="Sessione di registrazione">Sessione</button>
<div class="bar-sep"></div>
<button id="downloadBtn" title="Scarica CSV">Scarica</button> <button id="downloadBtn" title="Scarica CSV">Scarica</button>
</div> </div>
@@ -240,31 +258,46 @@ function getColorForField(key) {
} }
const FIELD_DEFS = { const FIELD_DEFS = {
temp: { name: 'Temperatura', unit: '°C', category: 'weather' }, // Meteo (da openmeteo → SignalK → logs)
hum: { name: 'Umidita', unit: '%', category: 'weather' }, 'meb.forecasts.temperature': { name: 'Temperatura', unit: '°C', category: 'weather' },
pres: { name: 'Pressione', unit: 'hPa', category: 'weather' }, 'meb.forecast.wind.speed': { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
wSpd: { name: 'Velocita Vento', unit: 'km/h', category: 'weather' }, 'meb.forecast.wind.direction': { name: 'Direzione Vento', unit: '°', category: 'weather' },
wDir: { name: 'Direzione Vento', unit: '°', category: 'weather' }, 'meb.forecast.wind.gusts': { name: 'Raffiche', unit: 'km/h', category: 'weather' },
gust: { name: 'Raffiche', unit: 'km/h', category: 'weather' }, 'meb.forecast.humidity': { name: 'Umidita', unit: '%', category: 'weather' },
rain: { name: 'Pioggia', unit: 'mm', category: 'weather' }, 'meb.forecast.pressure': { name: 'Pressione', unit: 'hPa', category: 'weather' },
prec: { name: 'Precipitazioni', unit: 'mm', category: 'weather' }, 'meb.forecast.precipitation': { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
lat: { name: 'Latitudine', unit: '°', category: 'navigation' }, 'meb.forecast.rain': { name: 'Pioggia', unit: 'mm', category: 'weather' },
lon: { name: 'Longitudine', unit: '°', category: 'navigation' }, 'meb.forecast.cloudCover': { name: 'Copertura Nuvole', unit: '%', category: 'weather' },
hdg: { name: 'Heading', unit: '°', category: 'navigation' }, 'meb.forecast.precipitationProbability': { name: 'Prob. Precipitazioni', unit: '%', category: 'weather' },
sog: { name: 'Velocita SOG', unit: 'kn', category: 'navigation' }, // Marine
cog: { name: 'Rotta COG', unit: '°', category: 'navigation' }, 'meb.waves.height': { name: 'Altezza Onde', unit: 'm', category: 'weather' },
depth: { name: 'Profondita', unit: 'm', category: 'navigation' }, 'meb.waves.direction': { name: 'Direzione Onde', unit: '°', category: 'weather' },
engTemp: { name: 'Temp. Motore', unit: '°C', category: 'engine' }, 'meb.waves.period': { name: 'Periodo Onde', unit: 's', category: 'weather' },
wvH: { name: 'Altezza Onde', unit: 'm', category: 'weather' }, 'meb.waves.peakPeriod': { name: 'Periodo Picco', unit: 's', category: 'weather' },
wvP: { name: 'Periodo Onde', unit: 's', category: 'weather' }, 'meb.waves.currentVelocity': { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
wvD: { name: 'Direzione Onde', unit: '°', category: 'weather' }, 'meb.waves.currentDirection': { name: 'Dir. Corrente', unit: '°', category: 'weather' },
curD: { name: 'Dir. Corrente', unit: '°', category: 'weather' }, // Navigazione
curV: { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' }, 'navigation.position.latitude': { name: 'Latitudine', unit: '°', category: 'navigation' },
fTemp: { name: 'Prev. Temperatura', unit: C', category: 'weather' }, 'navigation.position.longitude': { name: 'Longitudine', unit: '°', category: 'navigation' },
fWSpd: { name: 'Prev. Vento', unit: 'km/h', category: 'weather' } 'navigation.headingTrue': { name: 'Heading', unit: '°', category: 'navigation' },
'navigation.speedOverGround': { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
'navigation.courseOverGroundTrue': { name: 'Rotta COG', unit: '°', category: 'navigation' },
// Elettrica
'electrical.batteries.service.Voltage': { name: 'Batteria Serv. V', unit: 'V', category: 'engine' },
'electrical.batteries.service.current': { name: 'Batteria Serv. A', unit: 'A', category: 'engine' },
'electrical.batteries.service.stateOfCharge': { name: 'Batteria Serv. SoC', unit: '%', category: 'engine' },
'electrical.batteries.traction.Voltage': { name: 'Batteria Traz. V', unit: 'V', category: 'engine' },
'electrical.batteries.traction.current': { name: 'Batteria Traz. A', unit: 'A', category: 'engine' },
'electrical.batteries.traction.stateOfCharge': { name: 'Batteria Traz. SoC', unit: '%', category: 'engine' },
'electrical.batteries.traction.temperature': { name: 'Batteria Traz. Temp', unit: '°C', category: 'engine' },
'electrical.batteries.traction.power': { name: 'Batteria Traz. W', unit: 'W', category: 'engine' },
// Motore
'propulsion.0.revolutions': { name: 'Giri Motore', unit: 'RPM', category: 'engine' },
// Sistema
'system.uptime': { name: 'Uptime', unit: 's', category: 'engine' }
}; };
const MEASUREMENT_CATEGORY = { weather: 'weather', navigation: 'navigation', engine: 'engine' }; const MEASUREMENT_CATEGORY = { weather: 'weather', navigation: 'navigation', logs: 'navigation', engine: 'engine' };
const ALWAYS_FILL_BOTTOM_FIELDS = ['lat', 'lon']; const ALWAYS_FILL_BOTTOM_FIELDS = ['navigation.position.latitude', 'navigation.position.longitude'];
async function loadSessions() { async function loadSessions() {
document.getElementById('sessionList').innerHTML = '<div class="session-loading">Caricamento...</div>'; document.getElementById('sessionList').innerHTML = '<div class="session-loading">Caricamento...</div>';
@@ -283,22 +316,27 @@ async function loadSessions() {
const meta = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta; const meta = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta;
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'session-item'; item.className = 'session-item';
const connTime = meta.connectedAt ? new Date(meta.connectedAt * 1000).toLocaleTimeString('it-IT') : '—'; const connTime = meta.connectedAt ? new Date(meta.connectedAt).toLocaleTimeString('it-IT') : '—';
item.innerHTML = `<div class="session-item-info"><strong>${meta.name || sId}</strong><span class="session-item-id">${sId}</span></div><div class="session-item-meta"><span class="session-item-time">Connesso: ${connTime}</span><div class="session-item-dot"></div></div>`; const sessId = meta.session || '—';
item.innerHTML = `<div class="session-item-info"><strong>${meta.name || sId}</strong><span class="session-item-id">${sessId}</span></div><div class="session-item-meta"><span class="session-item-time">Connesso: ${connTime}</span><div class="session-item-dot"></div></div>`;
item.onclick = () => selectSession(sId, meta); item.onclick = () => selectSession(sId, meta);
document.getElementById('sessionList').appendChild(item); document.getElementById('sessionList').appendChild(item);
} }
} catch (err) { } } catch (err) { }
} }
let currentSessionId = null; // InfluxDB session tag (es. s1234)
function selectSession(sId, meta) { function selectSession(sId, meta) {
currentSensorId = sId; currentSensorId = sId;
sessionStartTime = meta.connectedAt ? meta.connectedAt * 1000 : Date.now(); currentSessionId = meta.session || null;
sessionStartTime = meta.connectedAt ? new Date(meta.connectedAt).getTime() : Date.now();
document.getElementById('sessionOverlay').style.display = 'none'; document.getElementById('sessionOverlay').style.display = 'none';
document.getElementById('mainContent').style.display = ''; document.getElementById('mainContent').style.display = '';
document.getElementById('bottomBar').style.display = ''; document.getElementById('bottomBar').style.display = '';
document.getElementById('sensorName').textContent = meta.name || sId; document.getElementById('sensorName').textContent = meta.name || sId;
document.getElementById('sessionInfoTitle').textContent = `Sensore: ${meta.name || sId}`; document.getElementById('sessionInfoTitle').textContent = `Sensore: ${meta.name || sId}`;
document.getElementById('currentSessionLabel').textContent = currentSessionId || sId;
liveData = {}; liveData = {};
Object.values(miniCharts).forEach(c => c.destroy()); Object.values(miniCharts).forEach(c => c.destroy());
miniCharts = {}; miniCharts = {};
@@ -370,7 +408,11 @@ function handleSensorData(msg) {
if (redrawExpChart) updateExpandedChart(); if (redrawExpChart) updateExpandedChart();
if (redrawCompChart) updateCompChart(); if (redrawCompChart) updateCompChart();
if (measurement === 'logs' && fields.lat && fields.lon) updateMap(fields.lat, fields.lon, fields.hdg, fields.wDir, fields.wvD); const lat = fields['navigation.position.latitude'];
const lon = fields['navigation.position.longitude'];
if (lat != null && lon != null) {
updateMap(lat, lon, fields['navigation.headingTrue'], fields['meb.forecast.wind.direction'], fields['meb.waves.direction']);
}
} }
function createHybCard(key, def, val) { function createHybCard(key, def, val) {
@@ -662,7 +704,8 @@ document.getElementById('downloadBtn').onclick = async () => {
btn.textContent = '...'; btn.textContent = '...';
await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/flush`, { method: 'POST' }); await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/flush`, { method: 'POST' });
const csvUrl = `${REALTIME_URL}/sessions/${currentSensorId}/csv?from=${sessionStartTime}`; const sessionParam = currentSessionId ? `&session=${currentSessionId}` : '';
const csvUrl = `${REALTIME_URL}/sessions/${currentSensorId}/csv?from=${sessionStartTime}${sessionParam}`;
const res = await fetch(csvUrl); const res = await fetch(csvUrl);
const blob = await res.blob(); const blob = await res.blob();
@@ -704,4 +747,33 @@ function showToast(msg) {
}, 4500); }, 4500);
} }
// --- Session Label Popup ---
document.getElementById('sessionLabelBtn').onclick = () => {
document.getElementById('sessionLabelInput').value = '';
document.getElementById('sessionLabelOverlay').style.display = 'flex';
};
document.getElementById('cancelSessionLabelBtn').onclick = () => {
document.getElementById('sessionLabelOverlay').style.display = 'none';
};
document.getElementById('saveSessionLabelBtn').onclick = async () => {
const label = document.getElementById('sessionLabelInput').value.trim();
if (!label || !currentSensorId || !currentSessionId) return;
try {
const res = await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/details`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: currentSessionId, name: label })
});
if (res.ok) {
document.getElementById('currentSessionLabel').textContent = label;
showToast(`Sessione rinominata: ${label}`);
} else {
showToast('Errore nel salvataggio');
}
} catch (err) {
showToast('Errore di connessione');
}
document.getElementById('sessionLabelOverlay').style.display = 'none';
};
</script> </script>

View File

@@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Copernicus Marine — MEB Console</title>
<link rel="stylesheet" href="../static/styles/style.css">
<style>
.m-tabs { display: flex; gap: .5rem; padding: 0 1.5rem; border-bottom: 1px solid rgba(255,255,255,.06); }
.m-tabs button { padding: .6rem 1.2rem; background: transparent; border: none; color: inherit; cursor: pointer; border-bottom: 2px solid transparent; opacity: .6; }
.m-tabs button.active { opacity: 1; border-bottom-color: #3498db; color: #5dade2; }
.m-panel { padding: 1.5rem; }
.m-search { display: flex; gap: .5rem; margin-bottom: 1rem; }
.m-search input { flex: 1; padding: .6rem .9rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 6px; font-size: .9rem; }
.m-search button { padding: .6rem 1.2rem; background: #3498db; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
.m-results { display: grid; gap: .75rem; }
.m-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 8px; padding: 1rem; }
.m-card .title { font-weight: 600; margin-bottom: .35rem; }
.m-card .id { font-size: .75rem; opacity: .55; font-family: monospace; margin-bottom: .5rem; }
.m-card .desc { font-size: .85rem; opacity: .8; margin-bottom: .5rem; }
.m-card .meta { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .75rem; }
.m-card .chip { background: rgba(52,152,219,.15); color: #5dade2; padding: .15rem .5rem; border-radius: 10px; }
.m-card .actions { margin-top: .75rem; display: flex; gap: .5rem; }
.m-card .actions button { padding: .4rem .9rem; font-size: .8rem; background: rgba(255,255,255,.08); color: inherit; border: 1px solid rgba(255,255,255,.15); border-radius: 5px; cursor: pointer; }
.m-card .actions button.primary { background: #3498db; color: #fff; border-color: transparent; }
.m-pagination { display: flex; gap: .5rem; justify-content: center; margin-top: 1rem; align-items: center; font-size: .85rem; opacity: .8; }
.m-pagination button { padding: .3rem .7rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 4px; cursor: pointer; }
.m-pagination button:disabled { opacity: .4; cursor: not-allowed; }
.m-modal { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 100; }
.m-modal.show { display: flex; }
.m-modal .box { background: #1a1f2b; border-radius: 10px; padding: 1.5rem; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; }
.m-modal h3 { margin-top: 0; }
.m-form label { display: block; margin-top: .75rem; font-size: .85rem; opacity: .8; }
.m-form input, .m-form select, .m-form textarea { width: 100%; padding: .5rem .7rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 5px; margin-top: .25rem; font-size: .9rem; }
.m-form .row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.m-form .vars { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .35rem; }
.m-form .vars label { display: inline-flex; gap: .3rem; align-items: center; background: rgba(255,255,255,.05); padding: .25rem .5rem; border-radius: 4px; margin: 0; font-size: .8rem; }
.m-form .actions { margin-top: 1.2rem; display: flex; gap: .5rem; justify-content: flex-end; }
.m-form .actions button { padding: .5rem 1.2rem; border: none; border-radius: 5px; cursor: pointer; font-weight: 600; }
.btn-cancel { background: rgba(255,255,255,.1); color: inherit; }
.btn-go { background: #27ae60; color: #fff; }
.progress { height: 8px; background: rgba(255,255,255,.1); border-radius: 4px; overflow: hidden; margin-top: .5rem; }
.progress .bar { height: 100%; background: #3498db; transition: width .3s; }
.m-datasets-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.m-datasets-table th, .m-datasets-table td { text-align: left; padding: .6rem .5rem; border-bottom: 1px solid rgba(255,255,255,.05); }
.m-datasets-table th { font-size: .75rem; opacity: .6; text-transform: uppercase; letter-spacing: .04em; }
.m-datasets-table tr:hover { background: rgba(255,255,255,.02); }
.m-empty { text-align: center; padding: 3rem; opacity: .5; }
</style>
</head>
<body>
<div class="contnent" style="padding:0;">
<div class="header" style="padding: .5rem 1.5rem;">
<h1 style="font-size:1.2rem;">Copernicus Marine</h1>
<div class="profile"><a href="/dashboard">← Dashboard</a></div>
</div>
<div class="m-tabs">
<button class="active" data-tab="search">Ricerca catalogo</button>
<button data-tab="saved">I miei dataset</button>
</div>
<section id="tab-search" class="m-panel">
<div class="m-search">
<input type="search" id="searchInput" placeholder="Cerca dataset (es. 'currents', 'waves', 'mediterranean')…">
<button id="searchBtn">Cerca</button>
</div>
<div id="results" class="m-results"><p class="m-empty">Inserisci una query per cercare nel catalogo Copernicus Marine.</p></div>
<div class="m-pagination" id="pagination" style="display:none;">
<button id="prevPage"> Precedente</button>
<span id="pageInfo"></span>
<button id="nextPage">Successiva </button>
</div>
</section>
<section id="tab-saved" class="m-panel" style="display:none;">
<div id="saved" class="m-results"><p class="m-empty">Carico…</p></div>
</section>
<!-- Modal download -->
<div class="m-modal" id="downloadModal">
<div class="box">
<h3>Scarica dataset</h3>
<div id="dsInfo" style="font-size:.85rem; opacity:.7; margin-bottom:.75rem;"></div>
<form class="m-form" id="downloadForm" onsubmit="return false;">
<label>Nome del dataset salvato</label>
<input name="nome" required>
<label>Variabili</label>
<div class="vars" id="varsList"></div>
<div class="row">
<div><label>Min longitudine</label><input name="min_longitude" type="number" step="any" required></div>
<div><label>Max longitudine</label><input name="max_longitude" type="number" step="any" required></div>
<div><label>Min latitudine</label><input name="min_latitude" type="number" step="any" required></div>
<div><label>Max latitudine</label><input name="max_latitude" type="number" step="any" required></div>
<div><label>Data inizio</label><input name="start_date" type="date" required></div>
<div><label>Data fine</label><input name="end_date" type="date" required></div>
</div>
<label>Formato</label>
<select name="format"><option value="csv">CSV</option><option value="json">JSON</option></select>
<label>Tag (separati da virgola)</label>
<input name="tags" placeholder="marine, currents">
<label>Note</label>
<textarea name="notes" rows="2"></textarea>
<label><input type="checkbox" id="downloadLocal"> Scarica anche sul mio computer</label>
<div class="actions">
<button type="button" class="btn-cancel" id="cancelDl">Annulla</button>
<button type="button" class="btn-go" id="startDl">Scarica</button>
</div>
<div id="jobProgress" style="display:none; margin-top:1rem;">
<div id="jobMessage" style="font-size:.85rem;">In attesa…</div>
<div class="progress"><div class="bar" id="jobBar" style="width:0%"></div></div>
</div>
</form>
</div>
</div>
</div>
<script>
const API = "{{ apiUrl }}";
const MARINE = "{{ marineUrl }}";
const $ = (id) => document.getElementById(id);
let currentSearch = '', currentOffset = 0, pageLimit = 20, lastTotal = 0;
let currentDataset = null;
// ── Tabs ──
document.querySelectorAll('.m-tabs button').forEach(b => b.addEventListener('click', () => {
document.querySelectorAll('.m-tabs button').forEach(x => x.classList.toggle('active', x === b));
$('tab-search').style.display = b.dataset.tab === 'search' ? '' : 'none';
$('tab-saved').style.display = b.dataset.tab === 'saved' ? '' : 'none';
if (b.dataset.tab === 'saved') loadSaved();
}));
// ── Search ──
async function runSearch(offset = 0) {
const q = $('searchInput').value.trim();
if (!q) { $('results').innerHTML = '<p class="m-empty">Inserisci una query.</p>'; $('pagination').style.display='none'; return; }
currentSearch = q; currentOffset = offset;
$('results').innerHTML = '<p class="m-empty">Cerco…</p>';
try {
const res = await fetch(`${MARINE}/catalog?search=${encodeURIComponent(q)}&limit=${pageLimit}&offset=${offset}`, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
lastTotal = data.total || 0;
renderResults(data.datasets || []);
renderPagination();
} catch (e) { $('results').innerHTML = `<p class="m-empty">Errore: ${e.message}</p>`; }
}
function renderResults(list) {
if (!list.length) { $('results').innerHTML = '<p class="m-empty">Nessun risultato.</p>'; return; }
$('results').innerHTML = list.map(d => `
<div class="m-card">
<div class="title">${escapeHtml(d.title || d.dataset_id)}</div>
<div class="id">${escapeHtml(d.dataset_id)}</div>
<div class="desc">${escapeHtml(d.description || '')}</div>
<div class="meta">
${d.variables ? d.variables.slice(0,6).map(v => `<span class="chip">${escapeHtml(v.short_name)}</span>`).join('') : ''}
${d.variables && d.variables.length > 6 ? `<span class="chip">+${d.variables.length - 6}</span>` : ''}
${d.start_datetime ? `<span class="chip">${d.start_datetime}${d.end_datetime || 'oggi'}</span>` : ''}
</div>
<div class="actions">
<button class="primary" data-id="${escapeHtml(d.dataset_id)}">⇩ Scarica</button>
</div>
</div>
`).join('');
document.querySelectorAll('#results button[data-id]').forEach(b => {
b.addEventListener('click', () => openDownload(b.dataset.id, list.find(x => x.dataset_id === b.dataset.id)));
});
}
function renderPagination() {
const pag = $('pagination');
if (lastTotal <= pageLimit) { pag.style.display='none'; return; }
pag.style.display = 'flex';
$('pageInfo').textContent = `${currentOffset + 1}${Math.min(currentOffset + pageLimit, lastTotal)} di ${lastTotal}`;
$('prevPage').disabled = currentOffset === 0;
$('nextPage').disabled = currentOffset + pageLimit >= lastTotal;
}
$('searchBtn').addEventListener('click', () => runSearch(0));
$('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') runSearch(0); });
$('prevPage').addEventListener('click', () => runSearch(Math.max(0, currentOffset - pageLimit)));
$('nextPage').addEventListener('click', () => runSearch(currentOffset + pageLimit));
// ── Download modal ──
function openDownload(id, ds) {
currentDataset = ds;
$('dsInfo').innerHTML = `<strong>${escapeHtml(ds.title || id)}</strong><br><span style="font-family:monospace; font-size:.75rem;">${escapeHtml(id)}</span>`;
const form = $('downloadForm');
form.nome.value = (ds.title || id).slice(0, 60);
form.min_longitude.value = ds.min_longitude ?? '';
form.max_longitude.value = ds.max_longitude ?? '';
form.min_latitude.value = ds.min_latitude ?? '';
form.max_latitude.value = ds.max_latitude ?? '';
form.start_date.value = ds.start_datetime || '';
form.end_date.value = ds.end_datetime || '';
$('varsList').innerHTML = (ds.variables || []).map(v =>
`<label><input type="checkbox" value="${escapeHtml(v.short_name)}" checked>${escapeHtml(v.short_name)}</label>`
).join('');
$('jobProgress').style.display = 'none';
$('downloadModal').classList.add('show');
}
$('cancelDl').addEventListener('click', () => $('downloadModal').classList.remove('show'));
$('startDl').addEventListener('click', async () => {
const form = $('downloadForm');
const variables = [...$('varsList').querySelectorAll('input:checked')].map(x => x.value);
if (!variables.length) return alert('Seleziona almeno una variabile');
const payload = {
dataset_id: currentDataset.dataset_id,
variables,
min_longitude: parseFloat(form.min_longitude.value),
max_longitude: parseFloat(form.max_longitude.value),
min_latitude: parseFloat(form.min_latitude.value),
max_latitude: parseFloat(form.max_latitude.value),
start_date: form.start_date.value,
end_date: form.end_date.value,
format: form.format.value,
nome: form.nome.value,
tags: form.tags.value.split(',').map(s => s.trim()).filter(Boolean),
notes: form.notes.value,
};
try {
$('startDl').disabled = true;
const res = await fetch(`${MARINE}/jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const job = await res.json();
pollJob(job.job_id);
} catch (e) { alert('Errore: ' + e.message); $('startDl').disabled = false; }
});
async function pollJob(jobId) {
$('jobProgress').style.display = '';
const timer = setInterval(async () => {
try {
const res = await fetch(`${MARINE}/jobs/${jobId}`, { credentials: 'include' });
const j = await res.json();
$('jobMessage').textContent = j.message || j.status;
$('jobBar').style.width = (j.progress || 0) + '%';
if (j.status === 'done') {
clearInterval(timer);
$('startDl').disabled = false;
if ($('downloadLocal').checked && j.dataset_id) {
window.open(`${API}/marine/datasets/${j.dataset_id}/raw`, '_blank');
}
setTimeout(() => { $('downloadModal').classList.remove('show'); loadSaved(); }, 1000);
}
if (j.status === 'error') { clearInterval(timer); $('startDl').disabled = false; }
} catch (e) { clearInterval(timer); $('startDl').disabled = false; }
}, 2000);
}
// ── Saved datasets ──
async function loadSaved() {
$('saved').innerHTML = '<p class="m-empty">Carico…</p>';
try {
const res = await fetch(`${API}/marine/datasets`, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { datasets = [] } = await res.json();
if (!datasets.length) { $('saved').innerHTML = '<p class="m-empty">Nessun dataset salvato.</p>'; return; }
$('saved').innerHTML = `
<table class="m-datasets-table">
<thead><tr><th>Nome</th><th>Type</th><th>Formato</th><th>Size</th><th>Tag</th><th>Creato</th><th></th></tr></thead>
<tbody>
${datasets.map(d => `
<tr>
<td><strong>${escapeHtml(d.nome)}</strong></td>
<td>${escapeHtml(d.type)}</td>
<td>${escapeHtml(d.format)}</td>
<td>${fmtBytes(d.size_bytes)}</td>
<td>${(d.tags||[]).map(t => `<span class="chip">${escapeHtml(t)}</span>`).join(' ')}</td>
<td style="font-size:.8rem; opacity:.7;">${new Date(d.created_at).toLocaleDateString('it-IT')}</td>
<td>
<button onclick="window.open('${API}/marine/datasets/${d.id}/raw','_blank')">⇩</button>
<button onclick="deleteDs('${d.id}')">🗑</button>
</td>
</tr>
`).join('')}
</tbody>
</table>`;
} catch (e) { $('saved').innerHTML = `<p class="m-empty">Errore: ${e.message}</p>`; }
}
window.deleteDs = async (id) => {
if (!confirm('Eliminare il dataset?')) return;
await fetch(`${API}/marine/datasets/${id}`, { method: 'DELETE', credentials: 'include' });
loadSaved();
};
// ── utils ──
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmtBytes = (b) => { if (!b) return '0 B'; const u = ['B','KB','MB','GB']; let i = 0; while (b >= 1024 && i < 3) { b /= 1024; i++; } return b.toFixed(1) + ' ' + u[i]; };
</script>
</body>
</html>

View File

@@ -0,0 +1,736 @@
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="/static/styles/style.css">
<link rel="stylesheet" href="/static/styles/rulesets.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div class="rs-page">
<!-- Header -->
<div class="rs-header">
<div class="rs-header-left">
<a href="/dashboard" class="rs-back">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
<h1>Rulesets</h1>
<div class="rs-type-picker" id="typePicker">
<button class="active" data-type="logs">Logs</button>
<button data-type="forecast_current">Forecast · Current</button>
<button data-type="forecast_hourly">Forecast · Hourly</button>
<button data-type="marine_current">Marine · Current</button>
<button data-type="marine_hourly">Marine · Hourly</button>
</div>
</div>
<div class="rs-header-right">
<span class="rs-saving" id="savingIndicator">Salvato</span>
</div>
</div>
<!-- Toolbar -->
<div class="rs-toolbar">
<div class="rs-toolbar-left">
<button class="rs-filter-btn active" data-filter="all">Tutte</button>
<button class="rs-filter-btn" data-filter="active">Attive</button>
<button class="rs-filter-btn" data-filter="archived">Archiviate</button>
<select class="rs-sort-select" id="sortSelect">
<option value="created_at">Data creazione</option>
<option value="version">Versione</option>
</select>
</div>
<div class="rs-toolbar-right">
<button class="rs-new-btn" id="newRuleBtn">+ Nuova Versione</button>
</div>
</div>
<!-- Rules Grid -->
<div class="rs-grid" id="rulesGrid">
<div class="rs-empty">Caricamento...</div>
</div>
</div>
<!-- Rule Detail Popup -->
<div class="rs-overlay" id="ruleOverlay" style="display:none">
<div class="rs-popup">
<div class="rs-popup-header">
<div class="rs-popup-header-left">
<span class="rs-card-id" id="popupId"></span>
<span class="rs-saving" id="popupSaving">Salvato</span>
</div>
<button class="rs-popup-close" id="popupClose">&times;</button>
</div>
<div class="rs-popup-body">
<!-- Version + Description -->
<div class="rs-section">
<div class="rs-field-row">
<span class="rs-field-label">Versione</span>
<div class="rs-version-inputs">
<input class="rs-version-num" id="popupVMajor" type="number" min="1" max="100" placeholder="1" />
<span class="rs-version-dot">.</span>
<input class="rs-version-num" id="popupVBuild" type="number" min="0" max="100" placeholder="0" />
<span class="rs-version-dot">.</span>
<input class="rs-version-num" id="popupVPatch" type="number" min="0" max="100" placeholder="0" />
</div>
</div>
<div class="rs-field-row">
<span class="rs-field-label">Descrizione</span>
<textarea class="rs-field-textarea" id="popupDesc" placeholder="Descrizione opzionale..."></textarea>
</div>
</div>
<!-- Tags -->
<div class="rs-section">
<div class="rs-section-title">Tags</div>
<div class="rs-tags-wrap" id="popupTags">
<input class="rs-tag-input" id="tagInput" placeholder="Aggiungi tag..." />
</div>
</div>
<!-- Actions -->
<div class="rs-section">
<div class="rs-section-title">Azioni</div>
<div class="rs-actions">
<button class="rs-action-btn active-toggle" id="toggleActiveBtn">Attiva</button>
<button class="rs-action-btn archive-toggle" id="toggleArchiveBtn">Archivia</button>
<button class="rs-action-btn danger" id="deleteRuleBtn">Elimina</button>
</div>
</div>
<!-- Items -->
<div class="rs-section">
<div class="rs-items-header">
<div class="rs-section-title">Items</div>
<button class="rs-add-item-btn" id="addItemBtn">+ Aggiungi</button>
</div>
<div class="rs-item-labels" id="itemLabelsRow"></div>
<div id="itemsList"></div>
</div>
<!-- Deploy -->
<div class="rs-section">
<div class="rs-section-title">Deploy ai sensori</div>
<div class="rs-deploy-wrap">
<div id="deploySensorsList" class="rs-deploy-sensors">
<div class="rs-empty" style="padding:8px">Caricamento sensori...</div>
</div>
<div class="rs-deploy-actions">
<button class="rs-action-btn" id="deployBtn">Invia versione ai selezionati</button>
<span class="rs-deploy-result" id="deployResult"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Confirm Dialog -->
<div class="rs-confirm-overlay" id="confirmOverlay" style="display:none">
<div class="rs-confirm-box">
<h3 id="confirmTitle">Conferma</h3>
<p id="confirmText">Sei sicuro?</p>
<div class="rs-confirm-actions">
<button id="confirmCancel">Annulla</button>
<button class="confirm-danger" id="confirmOk">Conferma</button>
</div>
</div>
</div>
<script>
const API_URL = '{{ apiUrl }}';
// --- State ---
const TYPES = ['logs','forecast_current','forecast_hourly','marine_current','marine_hourly'];
let currentType = 'logs';
let currentFilter = 'all';
let currentSort = 'created_at';
let allRules = [];
let openRule = null;
let saveTimers = {};
let sensorsCache = [];
let deploymentsForOpen = []; // deployments relativi alla rule aperta
// --- Item field definitions per tipo ---
// Per ogni tipo, definiamo gli input visibili. Tutti finiscono nei campi
// { ref, path, enabled, meta: {...} } del JSONB dell'item.
//
// - logs: ref, path (SK path), meta.measurement, meta.unit
// - forecast_*: ref, path (codice openmeteo), meta.sk_path, meta.unit
// - marine_*: ref, path (codice openmeteo), meta.sk_path, meta.unit
const ITEM_SCHEMA = {
logs: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.measurement', label: 'Measurement', cls: 'medium' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
forecast_current: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
forecast_hourly: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
marine_current: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
marine_hourly: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
};
// ========== Helpers ==========
function esc(str) {
if (str === null || str === undefined) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
function getField(obj, dottedKey) {
if (!dottedKey.includes('.')) return obj?.[dottedKey];
const [a, b] = dottedKey.split('.');
return obj?.[a]?.[b];
}
function setField(obj, dottedKey, value) {
if (!dottedKey.includes('.')) { obj[dottedKey] = value; return; }
const [a, b] = dottedKey.split('.');
if (!obj[a] || typeof obj[a] !== 'object') obj[a] = {};
obj[a][b] = value;
}
function versionStr(v) {
if (!v) return '';
if (v.str) return v.str;
return `${v.major ?? 0}.${v.build ?? 0}.${v.patch ?? 0}`;
}
// ========== API helpers ==========
async function api(method, path, body) {
const opts = { method, headers: {}, credentials: 'include' };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(`${API_URL}${path}`, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
// ========== Load & Render Rules ==========
async function loadRules() {
try {
const data = await api('GET', '/rules');
allRules = data[currentType] || [];
renderGrid();
} catch (err) {
console.error('Error loading rules:', err);
document.getElementById('rulesGrid').innerHTML = '<div class="rs-empty">Errore nel caricamento</div>';
}
}
async function loadSensors() {
if (sensorsCache.length) return sensorsCache;
try {
sensorsCache = await api('GET', '/rules/-/sensors');
} catch (err) {
console.error('Error loading sensors:', err);
sensorsCache = [];
}
return sensorsCache;
}
function filterAndSort(rules) {
let filtered = rules;
if (currentFilter === 'active') filtered = rules.filter(r => r.active && !r.archived);
else if (currentFilter === 'archived') filtered = rules.filter(r => r.archived);
filtered.sort((a, b) => {
if (currentSort === 'version') {
const va = versionStr(a.version), vb = versionStr(b.version);
return vb.localeCompare(va, undefined, { numeric: true });
}
return new Date(b.created_at) - new Date(a.created_at);
});
return filtered;
}
function renderGrid() {
const grid = document.getElementById('rulesGrid');
const rules = filterAndSort(allRules);
if (rules.length === 0) {
grid.innerHTML = '<div class="rs-empty">Nessuna versione trovata</div>';
return;
}
grid.innerHTML = rules.map(r => {
const badges = [];
if (r.active) badges.push('<span class="rs-badge active">Attiva</span>');
else badges.push('<span class="rs-badge inactive">Inattiva</span>');
if (r.archived) badges.push('<span class="rs-badge archived">Archiviata</span>');
const tags = (r.tags || []).map(t => `<span class="rs-tag">${esc(t)}</span>`).join('');
const desc = r.description ? `<div class="rs-card-desc">${esc(r.description)}</div>` : '';
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '';
const itemsCount = (r.items_count !== undefined ? r.items_count : (r.items?.length || 0));
return `
<div class="rs-card" data-id="${esc(r.id)}" onclick="openRuleDetail('${esc(r.id)}')">
<div class="rs-card-header">
<div>
<div class="rs-card-version">v${esc(versionStr(r.version))}</div>
<span class="rs-card-id" title="${esc(r.id)}">${esc(String(r.id).slice(0,8))}…</span>
</div>
<div class="rs-card-badges">${badges.join('')}</div>
</div>
${desc}
${tags ? `<div class="rs-card-tags">${tags}</div>` : ''}
<div class="rs-card-footer">
<span class="rs-card-date">${date}</span>
<span class="rs-card-items">${itemsCount} items</span>
</div>
</div>`;
}).join('');
}
// ========== Type Picker ==========
document.querySelectorAll('#typePicker button').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('#typePicker button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentType = btn.dataset.type;
loadRules();
};
});
// ========== Filters ==========
document.querySelectorAll('.rs-filter-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.rs-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
renderGrid();
};
});
document.getElementById('sortSelect').onchange = (e) => {
currentSort = e.target.value;
renderGrid();
};
// ========== New Rule ==========
document.getElementById('newRuleBtn').onclick = async () => {
try {
// Calcola una versione libera: prendi la maggiore esistente e incrementa patch
let M = 1, B = 0, P = 0;
if (allRules.length) {
const sorted = [...allRules].sort((a,b) => {
const va = a.version, vb = b.version;
return (vb.major - va.major) || (vb.build - va.build) || (vb.patch - va.patch);
});
const top = sorted[0].version;
M = top.major; B = top.build; P = Math.min(100, top.patch + 1);
if (P === 100 && top.patch === 100) { P = 0; B = Math.min(100, B + 1); }
}
const rule = await api('POST', `/rules/${currentType}`, {
version_major: M, version_build: B, version_patch: P,
tags: [], description: '', items: []
});
allRules.unshift(rule);
renderGrid();
openRuleDetail(rule.id);
flash('Salvato');
} catch (err) {
console.error('Error creating rule:', err);
alert(`Errore: ${err.message}`);
}
};
// ========== Rule Detail Popup ==========
async function openRuleDetail(ruleId) {
try {
const data = await api('GET', `/rules/${currentType}/${ruleId}`);
openRule = data;
deploymentsForOpen = [];
try {
deploymentsForOpen = await api('GET', `/rules/${currentType}/${ruleId}/deployments`);
} catch {}
await loadSensors();
renderPopup();
document.getElementById('ruleOverlay').style.display = 'flex';
} catch (err) {
console.error('Error loading rule detail:', err);
alert(`Errore: ${err.message}`);
}
}
function closePopup() {
document.getElementById('ruleOverlay').style.display = 'none';
openRule = null;
loadRules();
}
document.getElementById('popupClose').onclick = closePopup;
document.getElementById('ruleOverlay').onclick = (e) => {
if (e.target === document.getElementById('ruleOverlay')) closePopup();
};
function renderPopup() {
const r = openRule;
document.getElementById('popupId').textContent = `${currentType} · ${String(r.id).slice(0,8)}`;
document.getElementById('popupVMajor').value = r.version?.major ?? 1;
document.getElementById('popupVBuild').value = r.version?.build ?? 0;
document.getElementById('popupVPatch').value = r.version?.patch ?? 0;
document.getElementById('popupDesc').value = r.description || '';
renderTags();
updateActionButtons();
renderItems();
renderDeploySensors();
document.getElementById('deployResult').textContent = '';
}
// --- Auto-save fields ---
['popupVMajor','popupVBuild','popupVPatch'].forEach(id => {
document.getElementById(id).oninput = () => debounceFieldSave('version');
});
document.getElementById('popupDesc').oninput = () => debounceFieldSave('description');
function debounceFieldSave(field) {
clearTimeout(saveTimers[field]);
saveTimers[field] = setTimeout(() => saveRuleField(field), 500);
}
async function saveRuleField(field) {
if (!openRule) return;
const body = {};
if (field === 'version') {
body.version_major = parseInt(document.getElementById('popupVMajor').value, 10) || 1;
body.version_build = parseInt(document.getElementById('popupVBuild').value, 10) || 0;
body.version_patch = parseInt(document.getElementById('popupVPatch').value, 10) || 0;
}
if (field === 'description') body.description = document.getElementById('popupDesc').value;
try {
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, body);
Object.assign(openRule, updated);
const idx = allRules.findIndex(r => r.id === openRule.id);
if (idx >= 0) Object.assign(allRules[idx], updated);
flash('Salvato', 'popupSaving');
} catch (err) {
console.error('Error saving field:', err);
flash('Errore: ' + err.message, 'popupSaving');
}
}
// --- Tags ---
function renderTags() {
const wrap = document.getElementById('popupTags');
const input = document.getElementById('tagInput');
wrap.querySelectorAll('.rs-tag-chip').forEach(c => c.remove());
(openRule.tags || []).forEach((tag, i) => {
const chip = document.createElement('span');
chip.className = 'rs-tag-chip';
chip.innerHTML = `${esc(tag)}<button data-idx="${i}">&times;</button>`;
chip.querySelector('button').onclick = () => removeTag(i);
wrap.insertBefore(chip, input);
});
}
document.getElementById('tagInput').onkeydown = async (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const val = e.target.value.trim().replace(/,$/, '');
if (!val || !openRule) return;
const tags = [...(openRule.tags || []), val];
e.target.value = '';
try {
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, { tags });
openRule.tags = updated.tags;
renderTags();
flash('Salvato', 'popupSaving');
} catch (err) { console.error(err); }
}
};
async function removeTag(idx) {
if (!openRule) return;
const tags = [...(openRule.tags || [])];
tags.splice(idx, 1);
try {
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, { tags });
openRule.tags = updated.tags;
renderTags();
flash('Salvato', 'popupSaving');
} catch (err) { console.error(err); }
}
// --- Action Buttons ---
function updateActionButtons() {
const r = openRule;
const activeBtn = document.getElementById('toggleActiveBtn');
const archiveBtn = document.getElementById('toggleArchiveBtn');
activeBtn.textContent = r.active ? 'Disattiva' : 'Attiva';
activeBtn.className = `rs-action-btn active-toggle${r.active ? '' : ' off'}`;
archiveBtn.textContent = r.archived ? 'Dearchivia' : 'Archivia';
archiveBtn.className = `rs-action-btn archive-toggle${r.archived ? ' on' : ''}`;
}
document.getElementById('toggleActiveBtn').onclick = async () => {
if (!openRule) return;
try {
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/active`);
openRule.active = res.active;
if (res.ruleset) Object.assign(openRule, res.ruleset);
updateActionButtons();
flash('Salvato', 'popupSaving');
} catch (err) {
console.error(err);
alert(`Errore: ${err.message}`);
}
};
document.getElementById('toggleArchiveBtn').onclick = async () => {
if (!openRule) return;
try {
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/archive`);
openRule.archived = res.archived;
if (res.ruleset) Object.assign(openRule, res.ruleset);
updateActionButtons();
flash('Salvato', 'popupSaving');
} catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
};
document.getElementById('deleteRuleBtn').onclick = () => {
if (!openRule) return;
showConfirm('Elimina Versione', `Eliminare la versione v${versionStr(openRule.version)}? Questa azione e irreversibile.`, async () => {
try {
await api('DELETE', `/rules/${currentType}/${openRule.id}`);
allRules = allRules.filter(r => r.id !== openRule.id);
closePopup();
} catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
});
};
// --- Items ---
function renderItems() {
const schema = ITEM_SCHEMA[currentType];
const items = openRule.items || [];
const labelsRow = document.getElementById('itemLabelsRow');
labelsRow.innerHTML = schema.map(f => `<span class="${f.cls}">${f.label}</span>`).join('') +
'<span class="toggle-space">On</span><span class="delete-space"></span>';
const list = document.getElementById('itemsList');
if (items.length === 0) {
list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi".</div>';
return;
}
list.innerHTML = items.map(item => {
const fields = schema.map(f =>
`<input class="rs-item-field ${f.cls}"
value="${esc(getField(item, f.key) ?? '')}"
data-field="${f.key}"
data-ref="${esc(item.ref)}"
onchange="saveItemField(this)" />`
).join('');
const toggleCls = item.enabled !== false ? 'on' : '';
return `<div class="rs-item" data-ref="${esc(item.ref)}">
${fields}
<div class="rs-toggle ${toggleCls}" onclick="toggleItem('${esc(item.ref)}')"></div>
<button class="rs-item-delete" onclick="deleteItem('${esc(item.ref)}')">&times;</button>
</div>`;
}).join('');
}
document.getElementById('addItemBtn').onclick = async () => {
if (!openRule) return;
const ref = prompt('Ref dell\'item (identificatore stabile, es. "batt_traction_soc"):');
if (!ref) return;
try {
const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, {
ref: ref.trim(), path: '', enabled: true, meta: {}
});
if (!openRule.items) openRule.items = [];
openRule.items.push(item);
renderItems();
flash('Salvato', 'popupSaving');
} catch (err) {
console.error(err);
alert(`Errore: ${err.message}`);
}
};
async function saveItemField(input) {
if (!openRule) return;
const ref = input.dataset.ref;
const field = input.dataset.field;
const value = input.value;
const item = (openRule.items || []).find(i => i.ref === ref);
if (!item) return;
// costruisci body rispettando ref/path/enabled oppure meta.<x>
const body = {};
if (field === 'ref') body.ref = value.trim();
else if (field === 'path') body.path = value;
else if (field === 'enabled') body.enabled = !!value;
else if (field.startsWith('meta.')) {
const metaKey = field.slice(5);
body.meta = { ...(item.meta || {}), [metaKey]: value };
}
try {
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`, body);
// replace item in place
const idx = openRule.items.findIndex(i => i.ref === ref);
if (idx >= 0) openRule.items[idx] = updated;
flash('Salvato', 'popupSaving');
} catch (err) {
console.error(err);
flash('Errore', 'popupSaving');
}
}
async function toggleItem(ref) {
if (!openRule) return;
try {
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}/toggle`);
const item = openRule.items.find(i => i.ref === ref);
if (item) item.enabled = res.enabled;
renderItems();
flash('Salvato', 'popupSaving');
} catch (err) { console.error(err); }
}
async function deleteItem(ref) {
if (!openRule) return;
try {
await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`);
openRule.items = openRule.items.filter(i => i.ref !== ref);
renderItems();
flash('Salvato', 'popupSaving');
} catch (err) { console.error(err); }
}
// --- Deploy ---
function renderDeploySensors() {
const wrap = document.getElementById('deploySensorsList');
if (!sensorsCache.length) {
wrap.innerHTML = '<div class="rs-empty" style="padding:8px">Nessun sensore registrato</div>';
return;
}
const byName = Object.fromEntries(deploymentsForOpen.map(d => [d.sensor_name, d]));
wrap.innerHTML = sensorsCache.map(s => {
const d = byName[s.name];
let status = '';
if (d) {
status = d.acked_at
? `<span class="rs-deploy-status ok">ACK ${new Date(d.acked_at).toLocaleString('it-IT')}</span>`
: `<span class="rs-deploy-status pending">In attesa…</span>`;
}
return `<label class="rs-deploy-item">
<input type="checkbox" class="rs-deploy-check" value="${esc(s.name)}" />
<span class="rs-deploy-name">${esc(s.name)}</span>
${status}
</label>`;
}).join('');
}
document.getElementById('deployBtn').onclick = async () => {
if (!openRule) return;
if (openRule.archived) { alert('Non puoi deployare una versione archiviata'); return; }
const checks = [...document.querySelectorAll('.rs-deploy-check:checked')];
const sensors = checks.map(c => c.value);
if (!sensors.length) { alert('Seleziona almeno un sensore'); return; }
const resultEl = document.getElementById('deployResult');
resultEl.textContent = 'Invio...';
try {
const res = await api('POST', `/rules/${currentType}/${openRule.id}/deploy`, { sensors });
const parts = [];
if (res.pushed?.length) parts.push(`${res.pushed.length} online`);
if (res.offline?.length) parts.push(`${res.offline.length} offline`);
if (res.errors?.length) parts.push(`${res.errors.length} errori`);
resultEl.textContent = parts.join(' · ') || 'OK';
// refresh deployments
try { deploymentsForOpen = await api('GET', `/rules/${currentType}/${openRule.id}/deployments`); renderDeploySensors(); } catch {}
} catch (err) {
console.error(err);
resultEl.textContent = `Errore: ${err.message}`;
}
};
// ========== Confirm Dialog ==========
let confirmCallback = null;
function showConfirm(title, text, onConfirm) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmText').textContent = text;
confirmCallback = onConfirm;
document.getElementById('confirmOverlay').style.display = 'flex';
}
document.getElementById('confirmCancel').onclick = () => {
document.getElementById('confirmOverlay').style.display = 'none';
confirmCallback = null;
};
document.getElementById('confirmOk').onclick = async () => {
document.getElementById('confirmOverlay').style.display = 'none';
if (confirmCallback) await confirmCallback();
confirmCallback = null;
};
// ========== Flash ==========
function flash(text, elId = 'savingIndicator') {
const el = document.getElementById(elId);
el.textContent = text;
el.classList.add('visible');
setTimeout(() => el.classList.remove('visible'), 1500);
}
// ========== Init ==========
document.addEventListener('DOMContentLoaded', () => {
loadRules();
loadSensors();
});
</script>
</body>

View File

@@ -0,0 +1,922 @@
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="/static/styles/style.css">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
/* === Layout === */
html, body { height: 100%; overflow: hidden; background: var(--surface); }
.page-wrap { display: flex; flex-direction: column; height: 100vh; }
/* === Header === */
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 24px;
background: var(--header-bg); border-bottom: 1px solid var(--header-border);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
flex-shrink: 0; z-index: 100;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-left h1 { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); }
#changeSessionBtn { display: none; padding: 8px 16px; font-size: 13px; }
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
#sessionNameDisplay { font-size: 0.95rem; font-weight: 700; color: var(--text-primary); display: none; }
#sessionMetaDisplay { font-size: 0.75rem; color: var(--text-secondary); display: none; }
/* === Session Popup Overlay === */
.session-overlay {
position: fixed; inset: 0; z-index: 2000;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
background: rgba(15,23,42,0.4);
}
.session-popup {
background: var(--header-bg, #fff); border: 1px solid var(--header-border);
border-radius: 20px; padding: 28px; width: 860px; max-width: 95vw;
max-height: 82vh; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.popup-head { margin-bottom: 16px; }
.popup-head h2 { font-size: 1.15rem; font-weight: 700; margin-bottom: 4px; }
.popup-head p { font-size: 0.82rem; color: var(--text-secondary); }
.popup-filters {
display: flex; gap: 10px; margin-bottom: 16px;
}
.popup-filters input {
flex: 1; padding: 9px 14px; border-radius: var(--radius-md);
border: 1px solid var(--header-border); background: var(--surface);
font-family: inherit; font-size: 13px; color: var(--text-primary);
outline: none;
}
.popup-filters input:focus { border-color: var(--accent-color); }
.popup-filters select {
padding: 9px 14px; border-radius: var(--radius-md);
border: 1px solid var(--header-border); background: var(--surface);
font-family: inherit; font-size: 13px; color: var(--text-primary);
outline: none; cursor: pointer;
}
.session-grid {
flex: 1; overflow-y: auto;
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 12px; padding-right: 4px;
}
.sess-card {
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
background: var(--surface); padding: 16px; cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
display: flex; flex-direction: column; gap: 6px;
}
.sess-card:hover {
border-color: var(--accent-color); transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(37,99,235,0.12);
}
.sess-card-name { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); }
.sess-card-id { font-size: 0.72rem; color: var(--text-tertiary); font-family: monospace; }
.sess-card-sensor { font-size: 0.78rem; color: var(--text-secondary); }
.sess-card-dates { font-size: 0.75rem; color: var(--text-secondary); }
.sess-card-duration { font-size: 0.78rem; font-weight: 600; color: var(--accent-color); }
.sess-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.sess-tag {
font-size: 0.68rem; padding: 2px 8px; border-radius: 20px;
background: var(--accent-light); color: var(--accent-color);
border: 1px solid var(--accent-border);
}
.sess-empty { grid-column: 1/-1; text-align: center; color: var(--text-secondary); padding: 40px; }
/* === Detail Panel === */
.detail-panel {
flex: 1; display: none; flex-direction: column;
overflow: hidden; position: relative;
}
.detail-panel.visible { display: flex; }
/* === Map === */
.map-section {
flex-shrink: 0; height: 280px; position: relative; border-bottom: 1px solid var(--header-border);
}
#sessionMap { width: 100%; height: 100%; }
.no-gps-msg {
display: none; position: absolute; inset: 0; align-items: center; justify-content: center;
background: var(--surface); color: var(--text-secondary); font-size: 0.9rem;
}
/* === Data section (scrollable) === */
.data-section {
flex: 1; overflow-y: auto; padding: 16px 24px 140px;
}
.data-controls {
display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center;
}
.search-field {
display: flex; align-items: center; gap: 8px;
background: var(--header-bg); border: 1px solid var(--header-border);
border-radius: var(--radius-lg); padding: 8px 14px; flex: 1; min-width: 140px;
}
.search-field input {
border: none; background: transparent; font-family: inherit; font-size: 13px;
color: var(--text-primary); outline: none; width: 100%;
}
.filter {
display: flex; gap: 4px; background: var(--header-bg);
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
padding: 4px;
}
.filter button { padding: 6px 12px; border-radius: 10px; font-size: 12px; border: none; }
.filter button.active {
background: var(--accent-color); color: #fff; border-color: transparent;
}
.data-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
/* === Data Cards === */
.data-card {
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
background: white; padding: 14px; display: flex; flex-direction: column; gap: 8px;
transition: border-color 0.2s;
}
.card-top { display: flex; align-items: flex-start; justify-content: space-between; }
.card-info h4 { font-size: 0.78rem; color: var(--text-secondary); font-weight: 600; }
.card-action-btn {
padding: 4px; border-radius: 6px; border: none; background: transparent;
color: var(--text-secondary); cursor: pointer; opacity: 0.6; transition: opacity 0.2s;
flex-shrink: 0;
}
.card-action-btn:hover { opacity: 1; background: var(--accent-light); color: var(--accent-color); }
.card-body { display: flex; flex-direction: column; gap: 6px; }
.card-values { display: flex; align-items: baseline; gap: 4px; }
.card-main-val { font-size: 1.4rem; font-weight: 700; color: var(--text-primary); }
.card-unit { font-size: 0.75rem; color: var(--text-secondary); }
.card-mini-chart { height: 44px; position: relative; }
.card-mini-chart canvas { width: 100% !important; height: 100% !important; }
/* === Expanded Chart === */
.expanded-chart-container {
display: none; position: fixed; top: 72px; left: 24px; right: 24px; bottom: 110px;
background: white; border: 1px solid var(--header-border);
border-radius: var(--radius-lg); z-index: 500;
flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
}
.expanded-chart-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--header-border);
}
.expanded-chart-header h3 { font-size: 0.95rem; font-weight: 700; }
.close-expanded-btn {
padding: 4px 10px; border-radius: 8px; border: none; font-size: 18px;
background: transparent; cursor: pointer; color: var(--text-secondary);
}
.expanded-chart-body { flex: 1; padding: 16px; position: relative; }
.expanded-chart-body canvas { width: 100% !important; height: 100% !important; }
/* === Timeline Bar === */
.timeline-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: var(--header-bg); border-top: 1px solid var(--header-border);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
display: none; align-items: center; gap: 16px;
padding: 12px 20px; min-height: 84px; flex-direction: column;
}
.timeline-bar.visible { display: flex; }
.tl-row { display: flex; align-items: center; gap: 12px; width: 100%; }
.tl-track-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.tl-track {
position: relative; height: 6px; border-radius: 3px;
background: #334155; cursor: pointer; user-select: none;
}
.tl-fill-inner {
position: absolute; top: 0; height: 100%; background: var(--accent-color);
border-radius: 3px; pointer-events: none;
}
.tl-handle {
position: absolute; top: 50%; width: 18px; height: 18px;
background: white; border: 2px solid var(--accent-color);
border-radius: 50%; transform: translate(-50%, -50%);
cursor: grab; box-shadow: 0 2px 6px rgba(0,0,0,0.2);
transition: box-shadow 0.15s; z-index: 2;
}
.tl-handle:active { cursor: grabbing; box-shadow: 0 3px 10px rgba(37,99,235,0.35); }
.tl-handle.hidden { display: none; }
.tl-label-wrap {
display: flex; justify-content: space-between; align-items: center;
}
.tl-label-start, .tl-label-end { font-size: 0.68rem; color: var(--text-secondary); }
.tl-label-current { font-size: 0.72rem; font-weight: 700; color: var(--accent-color); }
.tl-btn { padding: 8px 16px; font-size: 12px; flex-shrink: 0; }
#restrictBtn.active { background: var(--accent-color); color: white; border-color: transparent; }
.tl-loading { font-size: 0.8rem; color: var(--text-secondary); }
/* === Loading overlay === */
.loading-overlay {
display: none; position: fixed; inset: 0; z-index: 1500;
background: rgba(248,250,252,0.85); backdrop-filter: blur(4px);
align-items: center; justify-content: center; flex-direction: column; gap: 12px;
}
.loading-overlay.visible { display: flex; }
.loading-spinner {
width: 36px; height: 36px; border: 3px solid var(--accent-border);
border-top-color: var(--accent-color); border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-size: 0.9rem; color: var(--text-secondary); }
/* === Toast === */
#dl-toast {
position: fixed; bottom: 96px; right: 24px;
background: rgba(255,255,255,0.95); padding: 14px 18px;
border-radius: var(--radius-lg); border: 1px solid var(--header-border);
box-shadow: var(--shadow-md); color: var(--text-primary);
font-size: 13px; font-weight: 600; z-index: 9999;
backdrop-filter: blur(10px); transform: translateY(120px); opacity: 0;
transition: all 0.4s cubic-bezier(0.8,0,0.2,1); pointer-events: none;
}
/* === Map secondary bar === */
.map-bar {
position: absolute; bottom: 0; left: 0; right: 0;
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: rgba(15,23,42,0.6); backdrop-filter: blur(6px);
}
.map-bar .filter button { color: #cbd5e1; }
.map-bar .filter button.active { background: #3b82f6; color: #fff; }
</style>
</head>
<body>
<div class="page-wrap">
<!-- Loading overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">Caricamento dati sessione...</div>
</div>
<!-- Session picker popup -->
<div class="session-overlay" id="sessionOverlay">
<div class="session-popup">
<div class="popup-head">
<h2>Seleziona una sessione</h2>
<p>Scegli una sessione di registrazione da analizzare</p>
</div>
<div class="popup-filters">
<input type="text" id="popupSearch" placeholder="Cerca per nome o sensore...">
<select id="popupSensorFilter"><option value="">Tutti i sensori</option></select>
</div>
<div class="session-grid" id="sessionGrid">
<div class="sess-empty">Caricamento sessioni...</div>
</div>
</div>
</div>
<!-- Header -->
<div class="header">
<div class="header-left">
<button id="changeSessionBtn">← Cambia sessione</button>
<h1>Sessioni</h1>
</div>
<div class="header-right">
<span id="sessionNameDisplay"></span>
<span id="sessionMetaDisplay"></span>
</div>
</div>
<!-- Detail panel -->
<div class="detail-panel" id="detailPanel">
<!-- Map -->
<div class="map-section" id="mapSection">
<div id="sessionMap"></div>
<div class="no-gps-msg" id="noGpsMsg">Nessun dato GPS per questa sessione</div>
<div class="map-bar" id="mapBar" style="display:none">
<div class="filter" style="background:transparent;border:none;">
<button class="active" data-zoom="10" style="font-size:11px;padding:4px 10px;">10x</button>
<button data-zoom="5" style="font-size:11px;padding:4px 10px;">5x</button>
<button data-zoom="1" style="font-size:11px;padding:4px 10px;">1x</button>
</div>
</div>
</div>
<!-- Data section -->
<div class="data-section" id="dataSection">
<div class="data-controls">
<div class="search-field">
<svg width="14" height="14" viewBox="0 0 12 12" fill="none"><path d="M11.0835 11.0834L8.57516 8.57504M9.91683 5.25004C9.91683 7.82737 7.82749 9.91671 5.25016 9.91671C2.67283 9.91671 0.583496 7.82737 0.583496 5.25004C0.583496 2.67271 2.67283 0.583374 5.25016 0.583374C7.82749 0.583374 9.91683 2.67271 9.91683 5.25004Z" stroke="var(--text-secondary)" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/></svg>
<input type="text" id="dataSearch" placeholder="Cerca campo...">
</div>
<div class="filter" id="catFilter">
<button class="active" data-cat="all">Tutto</button>
<button data-cat="weather">Meteo</button>
<button data-cat="navigation">Navigazione</button>
<button data-cat="engine">Motore</button>
</div>
</div>
<div class="data-grid" id="dataGrid"></div>
</div>
</div>
<!-- Expanded chart -->
<div class="expanded-chart-container" id="expandedChartContainer">
<div class="expanded-chart-header">
<h3 id="expChartTitle">Dettaglio</h3>
<button class="close-expanded-btn" id="closeExpBtn">&times;</button>
</div>
<div class="expanded-chart-body">
<canvas id="expandedChartCanvas"></canvas>
</div>
</div>
<!-- Timeline bar -->
<div class="timeline-bar" id="timelineBar">
<div class="tl-row">
<button class="tl-btn" id="restrictBtn">Restringi</button>
<div class="tl-track-wrap">
<div class="tl-track" id="tlTrack">
<div class="tl-fill-inner" id="tlFill"></div>
<div class="tl-handle" id="tlHandleLeft" style="left:0%"></div>
<div class="tl-handle" id="tlHandleSingle" style="left:100%"></div>
<div class="tl-handle" id="tlHandleRight" style="left:100%"></div>
</div>
<div class="tl-label-wrap">
<span class="tl-label-start" id="tlLabelStart"></span>
<span class="tl-label-current" id="tlLabelCurrent"></span>
<span class="tl-label-end" id="tlLabelEnd"></span>
</div>
</div>
<button class="tl-btn" id="downloadBtn">Scarica</button>
</div>
</div>
</div>
<div id="dl-toast"></div>
<script>
// --- Config (injected by Nunjucks) ---
const API_URL = '{{ apiUrl }}';
mapboxgl.accessToken = '{{ mapboxToken }}';
// --- FIELD_DEFS (same as live.html) ---
const FIELD_DEFS = {
'meb.forecasts.temperature': { name: 'Temperatura', unit: '°C', category: 'weather' },
'meb.forecast.wind.speed': { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
'meb.forecast.wind.direction': { name: 'Direzione Vento', unit: '°', category: 'weather' },
'meb.forecast.wind.gusts': { name: 'Raffiche', unit: 'km/h', category: 'weather' },
'meb.forecast.humidity': { name: 'Umidita', unit: '%', category: 'weather' },
'meb.forecast.pressure': { name: 'Pressione', unit: 'hPa', category: 'weather' },
'meb.forecast.precipitation': { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
'meb.forecast.rain': { name: 'Pioggia', unit: 'mm', category: 'weather' },
'meb.forecast.cloudCover': { name: 'Copertura Nuvole', unit: '%', category: 'weather' },
'meb.forecast.precipitationProbability': { name: 'Prob. Precipitazioni', unit: '%', category: 'weather' },
'meb.waves.height': { name: 'Altezza Onde', unit: 'm', category: 'weather' },
'meb.waves.direction': { name: 'Direzione Onde', unit: '°', category: 'weather' },
'meb.waves.period': { name: 'Periodo Onde', unit: 's', category: 'weather' },
'meb.waves.peakPeriod': { name: 'Periodo Picco', unit: 's', category: 'weather' },
'meb.waves.currentVelocity': { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
'meb.waves.currentDirection': { name: 'Dir. Corrente', unit: '°', category: 'weather' },
'navigation.position.latitude': { name: 'Latitudine', unit: '°', category: 'navigation' },
'navigation.position.longitude': { name: 'Longitudine', unit: '°', category: 'navigation' },
'navigation.headingTrue': { name: 'Heading', unit: '°', category: 'navigation' },
'navigation.speedOverGround': { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
'navigation.courseOverGroundTrue': { name: 'Rotta COG', unit: '°', category: 'navigation' },
'electrical.batteries.service.Voltage': { name: 'Batteria Serv. V', unit: 'V', category: 'engine' },
'electrical.batteries.service.current': { name: 'Batteria Serv. A', unit: 'A', category: 'engine' },
'electrical.batteries.service.stateOfCharge': { name: 'Batteria Serv. SoC', unit: '%', category: 'engine' },
'electrical.batteries.traction.Voltage': { name: 'Batteria Traz. V', unit: 'V', category: 'engine' },
'electrical.batteries.traction.current': { name: 'Batteria Traz. A', unit: 'A', category: 'engine' },
'electrical.batteries.traction.stateOfCharge': { name: 'Batteria Traz. SoC', unit: '%', category: 'engine' },
'electrical.batteries.traction.temperature': { name: 'Batteria Traz. Temp', unit: '°C', category: 'engine' },
'electrical.batteries.traction.power': { name: 'Batteria Traz. W', unit: 'W', category: 'engine' },
'propulsion.0.revolutions': { name: 'Giri Motore', unit: 'RPM', category: 'engine' },
'system.uptime': { name: 'Uptime', unit: 's', category: 'engine' },
};
const CHART_COLORS = [
'rgba(59,130,246,1)', 'rgba(16,185,129,1)', 'rgba(245,158,11,1)',
'rgba(239,68,68,1)', 'rgba(139,92,246,1)', 'rgba(236,72,153,1)',
];
const TICK_COLOR = '#94a3b8';
const GRID_COLOR = 'rgba(148,163,184,0.08)';
// --- State ---
let sessionRows = []; // [{_time, field: val, ...}]
let sessionTimes = []; // ms timestamps (sorted)
let positionData = []; // [{ts, lat, lon}]
let fieldColorMap = {};
let colorIdx = 0;
let miniCharts = {};
let expChart = null;
let expActiveField = null;
let currentSensorId = null;
let currentSessionId = null;
let currentSessionMeta = null;
let tStart = 0, tEnd = 0;
let currentT = 0;
let restrictMode = false;
let restrictStart = 0, restrictEnd = 0;
let activeCategory = 'all';
let searchQuery = '';
let mapbox = null;
let mapDot = null;
// --- Helpers ---
function fmtDuration(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s/60)}m ${s%60}s`;
return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
}
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('it-IT', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
}
function fmtTime(ms) {
return new Date(ms).toLocaleTimeString('it-IT', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
}
function getFieldColor(key) {
if (!fieldColorMap[key]) {
fieldColorMap[key] = CHART_COLORS[colorIdx % CHART_COLORS.length];
colorIdx++;
}
return fieldColorMap[key];
}
function showToast(msg) {
const t = document.getElementById('dl-toast');
t.textContent = msg;
t.style.transform = 'translateY(0)'; t.style.opacity = '1';
setTimeout(() => { t.style.transform = 'translateY(120px)'; t.style.opacity = '0'; }, 4500);
}
// Binary search: nearest index in sessionTimes to ts
function nearestIdx(ts) {
if (!sessionTimes.length) return -1;
let lo = 0, hi = sessionTimes.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sessionTimes[mid] < ts) lo = mid + 1; else hi = mid;
}
if (lo > 0 && Math.abs(sessionTimes[lo-1]-ts) < Math.abs(sessionTimes[lo]-ts)) return lo - 1;
return lo;
}
// --- Load sessions list ---
async function loadSessionsList() {
try {
const res = await fetch(`${API_URL}/sessions/history`, { credentials: 'include' });
const sessions = await res.json();
renderSessionGrid(sessions);
} catch (err) {
document.getElementById('sessionGrid').innerHTML = '<div class="sess-empty">Errore nel caricamento sessioni.</div>';
}
}
let allSessions = [];
function renderSessionGrid(sessions) {
allSessions = sessions;
const sensors = [...new Set(sessions.map(s => s.sensor_name).filter(Boolean))];
const selEl = document.getElementById('popupSensorFilter');
selEl.innerHTML = '<option value="">Tutti i sensori</option>';
sensors.forEach(s => { const o = document.createElement('option'); o.value = s; o.textContent = s; selEl.appendChild(o); });
filterSessionGrid();
}
function filterSessionGrid() {
const q = document.getElementById('popupSearch').value.toLowerCase();
const sensor = document.getElementById('popupSensorFilter').value;
const filtered = allSessions.filter(s => {
const name = (s.name || s.session_id || '').toLowerCase();
const sname = (s.sensor_name || '').toLowerCase();
const matchQ = !q || name.includes(q) || sname.includes(q);
const matchSensor = !sensor || s.sensor_name === sensor;
return matchQ && matchSensor;
});
const grid = document.getElementById('sessionGrid');
if (!filtered.length) { grid.innerHTML = '<div class="sess-empty">Nessuna sessione trovata.</div>'; return; }
grid.innerHTML = '';
filtered.forEach(s => {
const start = s.startTime ? new Date(s.startTime).getTime() : null;
const end = s.endTime ? new Date(s.endTime).getTime() : null;
const dur = start && end ? fmtDuration(end - start) : (start ? 'In corso' : '—');
const tags = Array.isArray(s.tags) ? s.tags : [];
const card = document.createElement('div');
card.className = 'sess-card';
card.innerHTML = `
<div class="sess-card-name">${s.name || s.session_id || '—'}</div>
<div class="sess-card-id">${s.session_id || ''}</div>
<div class="sess-card-sensor">${s.sensor_name || '—'}</div>
<div class="sess-card-dates">${fmtDate(s.startTime)}</div>
${end ? `<div class="sess-card-dates">${fmtDate(s.endTime)}</div>` : ''}
<div class="sess-card-duration">${dur}</div>
${tags.length ? `<div class="sess-tags">${tags.map(t=>`<span class="sess-tag">${t}</span>`).join('')}</div>` : ''}
`;
card.onclick = () => selectSession(s);
grid.appendChild(card);
});
}
document.getElementById('popupSearch').oninput = filterSessionGrid;
document.getElementById('popupSensorFilter').onchange = filterSessionGrid;
// --- Select session ---
async function selectSession(meta) {
currentSensorId = meta.sensor_name;
currentSessionId = meta.session_id;
currentSessionMeta = meta;
document.getElementById('sessionOverlay').style.display = 'none';
document.getElementById('changeSessionBtn').style.display = '';
document.getElementById('sessionNameDisplay').style.display = '';
document.getElementById('sessionMetaDisplay').style.display = '';
document.getElementById('sessionNameDisplay').textContent = meta.name || meta.session_id;
document.getElementById('sessionMetaDisplay').textContent = `${meta.sensor_name || ''}${meta.session_id}`;
document.getElementById('detailPanel').classList.add('visible');
document.getElementById('timelineBar').classList.add('visible');
// Reset state
sessionRows = []; sessionTimes = []; positionData = [];
fieldColorMap = {}; colorIdx = 0;
Object.values(miniCharts).forEach(c => c.destroy());
miniCharts = {};
if (expChart) { expChart.destroy(); expChart = null; expActiveField = null; }
document.getElementById('expandedChartContainer').style.display = 'none';
document.getElementById('dataGrid').innerHTML = '';
await loadSessionData(meta);
}
async function loadSessionData(meta) {
document.getElementById('loadingText').textContent = 'Caricamento dati sessione...';
document.getElementById('loadingOverlay').classList.add('visible');
try {
const from = meta.startTime ? new Date(meta.startTime).toISOString() : null;
const params = new URLSearchParams({ session: meta.session_id });
if (from) params.set('from', from);
const res = await fetch(`${API_URL}/sessions/${encodeURIComponent(meta.sensor_name)}/data?${params}`, { credentials: 'include' });
sessionRows = await res.json();
if (!sessionRows.length) { showToast('Nessun dato trovato per questa sessione'); return; }
sessionTimes = sessionRows.map(r => new Date(r._time).getTime());
tStart = sessionTimes[0]; tEnd = sessionTimes[sessionTimes.length - 1];
currentT = tEnd;
restrictStart = tStart; restrictEnd = tEnd;
positionData = sessionRows
.map((r, i) => ({ ts: sessionTimes[i], lat: r['navigation.position.latitude'], lon: r['navigation.position.longitude'] }))
.filter(p => p.lat != null && p.lon != null);
buildGrid();
initMap();
initTimeline();
updateGrid(currentT);
} catch (err) {
showToast('Errore nel caricamento dei dati');
} finally {
document.getElementById('loadingOverlay').classList.remove('visible');
}
}
// --- Grid ---
function getFieldsFromRows() {
const meta = new Set(['result', 'table', '_start', '_stop', '_measurement', '_time', 'sensor', 'session', '']);
const fields = new Set();
sessionRows.forEach(r => Object.keys(r).forEach(k => { if (!meta.has(k)) fields.add(k); }));
return [...fields];
}
function buildGrid() {
const grid = document.getElementById('dataGrid');
grid.innerHTML = '';
const fields = getFieldsFromRows();
fields.forEach(key => {
const def = FIELD_DEFS[key] || { name: key, unit: '', category: 'engine' };
const numericVals = sessionRows.map(r => r[key]).filter(v => typeof v === 'number');
if (!numericVals.length) return;
const col = getFieldColor(key);
const bgCol = col.replace(', 1)', ', 0.12)');
const card = document.createElement('div');
card.className = 'data-card';
card.dataset.key = key;
card.dataset.category = def.category;
card.dataset.name = def.name.toLowerCase();
card.innerHTML = `
<div class="card-top">
<div class="card-info"><h4>${def.name}</h4></div>
<div class="card-actions">
<button class="card-action-btn enlarge-btn" title="Espandi">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
</button>
</div>
</div>
<div class="card-body">
<div class="card-values">
<span class="card-main-val">—</span>
<span class="card-unit">${def.unit}</span>
</div>
<div class="card-mini-chart"><canvas id="mini-${CSS.escape(key)}"></canvas></div>
</div>
`;
card.querySelector('.enlarge-btn').onclick = e => { e.stopPropagation(); openExpandedChart(key); };
grid.appendChild(card);
const ctx = document.getElementById(`mini-${CSS.escape(key)}`).getContext('2d');
miniCharts[key] = new Chart(ctx, {
type: 'line',
data: {
labels: sessionRows.map(() => ''),
datasets: [{ data: sessionRows.map(r => r[key] ?? null), borderColor: col, backgroundColor: bgCol, fill: false, tension: 0.3, pointRadius: 0, borderWidth: 2 }]
},
options: {
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { type: 'category', display: false },
y: { display: false }
}
}
});
});
applyFilters();
}
function updateGrid(ts) {
const idx = nearestIdx(ts);
if (idx < 0) return;
const row = sessionRows[idx];
document.querySelectorAll('.data-card').forEach(card => {
const key = card.dataset.key;
const v = row[key];
const el = card.querySelector('.card-main-val');
if (typeof v === 'number') el.textContent = v.toFixed(2);
else el.textContent = v != null ? String(v) : '—';
});
if (expChart && expActiveField) updateExpandedChartLine(idx);
}
function applyFilters() {
document.querySelectorAll('.data-card').forEach(c => {
const matchCat = activeCategory === 'all' || c.dataset.category === activeCategory;
const matchStr = !searchQuery || c.dataset.name.includes(searchQuery);
c.style.display = matchCat && matchStr ? '' : 'none';
});
}
document.getElementById('dataSearch').oninput = e => { searchQuery = e.target.value.toLowerCase(); applyFilters(); };
document.querySelectorAll('#catFilter button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('#catFilter button').forEach(x => x.classList.remove('active'));
b.classList.add('active'); activeCategory = b.dataset.cat; applyFilters();
};
});
// --- Expanded Chart ---
function openExpandedChart(key) {
expActiveField = key;
const def = FIELD_DEFS[key] || { name: key, unit: '' };
document.getElementById('expChartTitle').textContent = `Dettaglio: ${def.name}`;
document.getElementById('expandedChartContainer').style.display = 'flex';
const col = getFieldColor(key);
if (!expChart) {
const ctx = document.getElementById('expandedChartCanvas').getContext('2d');
expChart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [
{ label: def.name, data: [], borderColor: col, backgroundColor: col.replace(',1)',',0.12)'), fill: false, tension: 0.3, pointRadius: 0, borderWidth: 2 },
{ type: 'line', data: [], borderColor: 'rgba(239,68,68,0.8)', borderWidth: 1, borderDash: [3,3], pointRadius: 0, fill: false }
]},
options: {
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
interaction: { intersect: false, mode: 'index' },
scales: {
x: { type: 'category', ticks: { maxTicksLimit: 8, color: TICK_COLOR, font: { size: 10 } }, grid: { display: false } },
y: { ticks: { color: TICK_COLOR, font: { size: 10 }, maxTicksLimit: 5 }, grid: { color: GRID_COLOR } }
},
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `${ctx.parsed.y?.toFixed(3)} ${def.unit}` } } }
}
});
}
const data = sessionRows.map(r => r[key] ?? null);
const labels = sessionTimes.map(t => fmtTime(t));
expChart.data.labels = labels;
expChart.data.datasets[0].data = data;
expChart.data.datasets[0].borderColor = col;
expChart.data.datasets[0].backgroundColor = col.replace(',1)',',0.12)');
expChart.update('none');
updateExpandedChartLine(nearestIdx(currentT));
}
function updateExpandedChartLine(idx) {
if (!expChart) return;
const key = expActiveField;
const nulls = sessionRows.map(() => null);
if (idx >= 0) nulls[idx] = sessionRows[idx][key];
expChart.data.datasets[1].data = nulls;
expChart.update('none');
}
document.getElementById('closeExpBtn').onclick = () => {
document.getElementById('expandedChartContainer').style.display = 'none';
expActiveField = null;
};
// --- Mapbox ---
function initMap() {
if (mapbox) { mapbox.remove(); mapbox = null; mapDot = null; }
document.getElementById('sessionMap').innerHTML = '';
if (!positionData.length) {
document.getElementById('noGpsMsg').style.display = 'flex';
return;
}
document.getElementById('noGpsMsg').style.display = 'none';
mapbox = new mapboxgl.Map({
container: 'sessionMap',
style: 'mapbox://styles/mapbox/dark-v11',
center: [positionData[0].lon, positionData[0].lat],
zoom: 12
});
mapbox.on('load', () => {
const coordinates = positionData.map(p => [p.lon, p.lat]);
mapbox.addSource('route', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates } }
});
mapbox.addLayer({ id: 'route', type: 'line', source: 'route', paint: { 'line-color': '#3b82f6', 'line-width': 3 } });
// Wider hit area for hover
mapbox.addLayer({ id: 'route-hover', type: 'line', source: 'route', paint: { 'line-color': 'transparent', 'line-width': 16 } });
mapbox.addSource('dot', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [positionData[positionData.length-1].lon, positionData[positionData.length-1].lat] } }
});
mapbox.addLayer({ id: 'dot', type: 'circle', source: 'dot', paint: { 'circle-radius': 8, 'circle-color': '#fff', 'circle-stroke-color': '#3b82f6', 'circle-stroke-width': 3 } });
mapbox.fitBounds(coordinates.reduce((b, c) => b.extend(c), new mapboxgl.LngLatBounds(coordinates[0], coordinates[0])), { padding: 30 });
mapbox.on('mousemove', 'route-hover', e => {
if (!e.features.length) return;
const pt = e.lngLat;
let nearest = positionData[0], minD = Infinity;
positionData.forEach(p => {
const d = Math.hypot(p.lon - pt.lng, p.lat - pt.lat);
if (d < minD) { minD = d; nearest = p; }
});
seekTo(nearest.ts);
});
mapbox.on('mouseleave', 'route-hover', () => {});
document.getElementById('mapBar').style.display = 'flex';
});
}
function updateMapDot(ts) {
if (!mapbox || !positionData.length) return;
const idx = nearestIdx(ts);
if (idx < 0) return;
const row = sessionRows[idx];
const lat = row['navigation.position.latitude'];
const lon = row['navigation.position.longitude'];
if (lat == null || lon == null) return;
mapbox.getSource('dot')?.setData({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] } });
}
// --- Timeline ---
function initTimeline() {
document.getElementById('tlLabelStart').textContent = fmtTime(tStart);
document.getElementById('tlLabelEnd').textContent = fmtTime(tEnd);
setTimelineMode(false);
setHandlePos('single', 1.0);
}
function setHandlePos(which, frac) {
const el = which === 'single' ? document.getElementById('tlHandleSingle')
: which === 'left' ? document.getElementById('tlHandleLeft')
: document.getElementById('tlHandleRight');
el.style.left = `${(frac * 100).toFixed(2)}%`;
}
function posToFrac(px) {
const track = document.getElementById('tlTrack');
const rect = track.getBoundingClientRect();
return Math.max(0, Math.min(1, (px - rect.left) / rect.width));
}
function fracToTs(frac) { return tStart + frac * (tEnd - tStart); }
function tsToFrac(ts) { return (tEnd === tStart) ? 0 : (ts - tStart) / (tEnd - tStart); }
function updateFill() {
const fill = document.getElementById('tlFill');
if (!restrictMode) {
const f = tsToFrac(currentT);
fill.style.left = '0%';
fill.style.width = `${f * 100}%`;
} else {
const fl = tsToFrac(restrictStart);
const fr = tsToFrac(restrictEnd);
fill.style.left = `${fl * 100}%`;
fill.style.width = `${(fr - fl) * 100}%`;
}
}
function setTimelineMode(restrict) {
restrictMode = restrict;
document.getElementById('restrictBtn').classList.toggle('active', restrict);
document.getElementById('tlHandleSingle').classList.toggle('hidden', restrict);
document.getElementById('tlHandleLeft').classList.toggle('hidden', !restrict);
document.getElementById('tlHandleRight').classList.toggle('hidden', !restrict);
if (restrict) {
restrictStart = tStart; restrictEnd = tEnd;
setHandlePos('left', 0); setHandlePos('right', 1);
}
updateFill();
}
function seekTo(ts) {
currentT = Math.max(tStart, Math.min(tEnd, ts));
setHandlePos('single', tsToFrac(currentT));
document.getElementById('tlLabelCurrent').textContent = fmtTime(currentT);
updateFill();
updateGrid(currentT);
updateMapDot(currentT);
}
// Drag logic
let dragging = null;
function startDrag(id, e) {
dragging = id;
e.preventDefault();
const onMove = ev => {
const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
const frac = posToFrac(clientX);
const ts = fracToTs(frac);
if (id === 'single') {
seekTo(ts);
} else if (id === 'left') {
restrictStart = Math.max(tStart, Math.min(restrictEnd - 1000, ts));
setHandlePos('left', tsToFrac(restrictStart));
updateFill();
} else if (id === 'right') {
restrictEnd = Math.min(tEnd, Math.max(restrictStart + 1000, ts));
setHandlePos('right', tsToFrac(restrictEnd));
updateFill();
}
};
const onUp = () => { dragging = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
document.getElementById('tlHandleSingle').addEventListener('mousedown', e => startDrag('single', e));
document.getElementById('tlHandleLeft').addEventListener('mousedown', e => startDrag('left', e));
document.getElementById('tlHandleRight').addEventListener('mousedown', e => startDrag('right', e));
document.getElementById('tlTrack').addEventListener('click', e => {
if (dragging) return;
const frac = posToFrac(e.clientX);
if (!restrictMode) seekTo(fracToTs(frac));
});
document.getElementById('restrictBtn').onclick = () => setTimelineMode(!restrictMode);
// --- Download ---
document.getElementById('downloadBtn').onclick = async () => {
if (!currentSensorId || !currentSessionId) return;
const btn = document.getElementById('downloadBtn');
const orig = btn.textContent; btn.textContent = '...';
try {
const params = new URLSearchParams({ session: currentSessionId, from: String(tStart) });
if (restrictMode) params.set('to', String(restrictEnd));
const res = await fetch(`${API_URL}/sessions/${encodeURIComponent(currentSensorId)}/csv?${params}`, { credentials: 'include' });
if (!res.ok) { showToast('Errore durante il download'); return; }
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `session_${currentSessionId}_${currentSensorId}.csv`; a.click();
URL.revokeObjectURL(url);
const sizeStr = blob.size < 1024 ? `${blob.size} B` : blob.size < 1048576 ? `${(blob.size/1024).toFixed(1)} KB` : `${(blob.size/1048576).toFixed(1)} MB`;
const text = await blob.text();
const rows = Math.max(0, text.split('\n').length - 2);
const dur = restrictMode ? fmtDuration(restrictEnd - restrictStart) : fmtDuration(tEnd - tStart);
showToast(`${rows} righe • ${sizeStr}${dur}`);
} catch { showToast('Errore durante il download'); }
finally { btn.textContent = orig; }
};
// --- Change session ---
document.getElementById('changeSessionBtn').onclick = () => {
document.getElementById('sessionOverlay').style.display = 'flex';
};
// --- Init ---
document.addEventListener('DOMContentLoaded', loadSessionsList);
</script>
</body>
</html>

Binary file not shown.

Binary file not shown.

View File

@@ -23,4 +23,26 @@
.card[title="Live"]:hover::before { .card[title="Live"]:hover::before {
opacity: 0.2; opacity: 0.2;
}
.category {
padding-bottom: 8%;
}
.category h2 {
display: block;
text-align: center;
padding: 20px 20px;
margin: 0 0 0.75rem;
font-size: 1rem;
opacity: 0.7;
margin-bottom: 10px;
font-weight:900
}
.category section {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-inline: 30px;
} }

View File

@@ -0,0 +1,634 @@
:root {
--card-bg: #101415;
--card-border: #2a2d2e;
--card-border-active: #3a9bff;
--danger: #ff4d4d;
--success: #34d399;
--grid-dot: rgba(255, 255, 255, 0.04);
--snap-line: rgba(50, 152, 255, 0.25);
--cols: 24;
--rows: 18
}
@font-face {
font-family: 'hyperlegible';
src: url('../fonts/atkinson-regular.ttf');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'hyperlegible';
src: url('../fonts/atkinson-bold.ttf');
font-weight: bold;
font-style: normal;
}
body {
font-family: 'hyperlegible', sans-serif;
background-color: black;
color: white
}
.data-card {
border: 1px solid #ccc;
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
width: 300px;
}
.value {
font-size: 24px;
font-weight: bold;
color: #007bff;
}
/* Toolbar */
.toolbar {
background: rgba(16, 20, 21, 0.88);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
display: flex;
align-items: center;
padding: 8px 16px;
margin: 20px;
gap: 12px;
z-index: 1000;
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
border-radius: 12px;
border: 1px solid var(--card-border);
}
.toolbar button {
height: 32px;
padding: 0 13px;
border: none;
border-radius: 7px;
font-family: var(--font);
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
white-space: nowrap;
}
.toolbar button.primary {
background-color: rgba(255, 255, 255, 0.103);
}
.toolbar button.primary:hover {
background: #4da8ff;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
}
.toolbar button.primary:active {
transform: translateY(0);
}
/** CAMVAS!!! */
.canvas {
width: 100%;
height: 70%;
position: absolute;
background:
radial-gradient(circle 1px, #393b3c 0.8px, transparent 0.8px);
background-size: calc(100% / var(--cols)) calc(100% / var(--rows));
}
/* ── Snap guides ───────────────────────────────────── */
.guide {
position: absolute;
background: var(--snap-line);
z-index: 500;
pointer-events: none;
opacity: 0;
transition: opacity 0.12s ease;
}
.guide.visible {
opacity: 1;
}
.guide.horizontal {
height: 1px;
left: 0;
right: 0;
}
.guide.vertical {
width: 1px;
top: 0;
bottom: 0;
}
/* CARDS */
.card {
position: absolute;
background-color: #101415;
border: 2px dashed var(--card-border);
border-radius: 15px;
cursor: grab;
transition: box-shadow 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94), border-color 0.25s ease;
will-change: left, top, width, height;
overflow: visible;
/* Necessario per vedere i manigli di resize fuori bordo se necessario */
}
/* Stili Header Card */
.card-header {
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
user-select: none;
}
.card-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ── Path Picker Menu ────────────────────────────── */
.label-wrapper {
position: relative;
height: 100%;
display: flex;
align-items: center;
}
.path-menu {
display: none;
position: absolute;
top: 28px;
left: -8px;
background: rgba(26, 30, 31, 0.95);
backdrop-filter: blur(10px);
border: 1px solid var(--card-border-active);
border-radius: 8px;
z-index: 2000;
min-width: 180px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 5px 0;
overflow: hidden;
}
.card.editable .label-wrapper:hover .path-menu {
display: block;
animation: spawnIn 0.2s ease-out;
}
.path-option {
padding: 10px 15px;
color: #aaa;
cursor: pointer;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
transition: all 0.2s;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.path-option:last-child {
border-bottom: none;
}
.path-option:hover {
background: var(--card-border-active);
color: white;
}
.card-close {
background: none;
border: none;
color: rgba(255, 0, 0, 0.404);
font-size: 10px;
font-weight: 700;
cursor: pointer;
line-height: 1;
padding: 0 4px;
transition: all 0.2s;
}
.card-close:hover {
color: var(--danger);
text-decoration: underline;
}
.card:not(.editable) .card-close {
display: none;
}
.card-body {
padding: 12px;
font-size: 70px;
font-weight: bold;
color: #ffffff;
height: calc(100% - 33px);
display: flex;
align-items: center;
justify-content: center;
}
/* RESIZE HANDLERS (.rh) */
.rh {
position: absolute;
z-index: 10;
transition: opacity 0.2s;
border-radius: 40px;
}
.rh.corner {
width: 12px;
height: 12px;
background: var(--card-border-active);
border: 2px solid #101415;
border-radius: 10px;
}
.rh.edge {
background: transparent;
}
/* Corner alignment */
.rh.nw {
top: -6px;
left: -6px;
cursor: nwse-resize;
}
.rh.ne {
top: -6px;
right: -6px;
cursor: nesw-resize;
}
.rh.se {
bottom: -6px;
right: -6px;
cursor: nwse-resize;
}
.rh.sw {
bottom: -6px;
left: -6px;
cursor: nesw-resize;
}
/* Edge alignment */
.rh.n {
top: -4px;
left: 10px;
right: 10px;
height: 8px;
cursor: ns-resize;
}
.rh.s {
bottom: -4px;
left: 10px;
right: 10px;
height: 8px;
cursor: ns-resize;
}
.rh.e {
right: -4px;
top: 10px;
bottom: 10px;
width: 8px;
cursor: ew-resize;
}
.rh.w {
left: -4px;
top: 10px;
bottom: 10px;
width: 8px;
cursor: ew-resize;
}
.card:not(.selected) .rh.corner {
opacity: 0;
}
.card.selected .rh.corner {
opacity: 1;
}
@keyframes cardSpawn {
0% {
opacity: 0;
transform: scale(0.92);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.card.spawning {
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes cardRemove {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.88);
}
}
.card.removing {
animation: cardRemove 0.2s ease forwards;
pointer-events: none;
}
/* Stili per le classi dinamiche delle card */
.card.selected {
border-color: var(--card-border-active);
box-shadow: 0 0 15px rgba(50, 152, 255, 0.5);
}
.card.dragging,
.card.resizing {
cursor: grabbing;
opacity: 0.8;
}
/* Stili per gli elementi aggiunti da canvas.js */
.tooltip {
position: fixed;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s ease;
z-index: 2000;
}
.tooltip.visible {
opacity: 1;
}
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #aaa;
font-size: 1.2em;
text-align: center;
pointer-events: none;
z-index: 1;
}
.empty-state.hidden {
display: none;
}
.unit-badge {
position: fixed;
top: 10px;
right: 10px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
z-index: 1000;
}
.toast {
position: fixed;
bottom: 60px;
/* Regola in base all'altezza della toolbar */
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 20px;
border-radius: 8px;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
z-index: 2000;
}
.toast.show {
opacity: 1;
visibility: visible;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 3000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal-overlay.open {
opacity: 1;
visibility: visible;
}
.modal-content {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 15px;
padding: 20px;
width: 90%;
max-width: 600px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}
.modal-content h2 {
margin-top: 0;
color: white;
}
.modal-content textarea {
width: calc(100% - 20px);
min-height: 200px;
background-color: #2a2d2e;
border: 1px solid #3a3d3e;
color: white;
padding: 10px;
border-radius: 8px;
margin-bottom: 15px;
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-actions button {
height: 32px;
padding: 0 13px;
border: none;
border-radius: 7px;
font-family: var(--font);
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
white-space: nowrap;
background-color: rgba(255, 255, 255, 0.103);
color: white;
}
.modal-actions button.primary {
background-color: #4da8ff;
}
.modal-actions button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
}
/* ── Edit Mode & Animations ──────────────────────── */
@keyframes cardSpawn {
0% { opacity: 0; transform: scale(0.92); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes cardRemove {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.88); }
}
.card.spawning {
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.card.removing {
animation: cardRemove 0.2s ease forwards;
pointer-events: none;
}
/* Canvas state during editing */
.canvas.edit-active {
outline: 2px dashed rgba(58, 155, 255, 0.3);
outline-offset: -10px;
background-color: rgba(58, 155, 255, 0.02);
}
.card.editable {
border-style: dashed;
}
.card.editable:not(.selected) {
border-color: rgba(255, 255, 255, 0.1);
}
/* Hide handlers when not editing */
.card:not(.editable) .rh {
display: none !important;
}
.card:not(.editable) {
cursor: default;
border-style: solid;
}
/* Rimuovi padding se la card contiene la mappa per farla aderire ai bordi */
.card[data-type="map"] .card-body {
padding: 0;
overflow: hidden;
}
.card-map-canvas {
width: 100%;
height: 100%;
border-radius: 0 0 15px 15px;
overflow: hidden;
}
/* Fix per Mapbox canvas che a volte non prende il 100% se il container è flex */
.card-map-canvas .mapboxgl-canvas-container,
.card-map-canvas .mapboxgl-canvas {
width: 100% !important;
height: 100% !important;
}
.hidden {
display: none !important;
}
.toolbar button.primary {
background-color: var(--card-border-active) !important;
color: white;
}
/* ── Global Edit Mode Overrides ──────────────────── */
body.edit-mode {
background-color: #0a0e0f;
transition: background-color 0.4s ease;
}
body.edit-mode .toolbar {
background: rgba(58, 155, 255, 0.15) !important;
backdrop-filter: blur(25px);
border: 2px dashed var(--card-border-active) !important;
box-shadow: 0 0 20px rgba(58, 155, 255, 0.2);
}
body.edit-mode .toolbar p#cardCount {
color: var(--card-border-active);
}
@keyframes editPulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
body.edit-mode .canvas.edit-active::after {
content: "DASHBOARD EDITING";
position: fixed;
top: 20px;
right: 20px;
font-size: 10px;
font-weight: 800;
color: var(--card-border-active);
letter-spacing: 2px;
animation: editPulse 2s infinite ease-in-out;
pointer-events: none;
}

View File

@@ -0,0 +1,849 @@
/* --- Rulesets Page --- */
.rs-page {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px 120px;
}
/* Header */
.rs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 0 16px;
position: sticky;
top: 0;
z-index: 10;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
margin-bottom: 20px;
}
.rs-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.rs-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.rs-header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* Type Picker */
.rs-type-picker {
display: flex;
gap: 4px;
background: rgba(241, 245, 249, 0.8);
border-radius: 12px;
padding: 4px;
}
.rs-type-picker button {
padding: 6px 16px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.rs-type-picker button.active {
background: white;
color: var(--accent-color);
box-shadow: var(--shadow-sm);
}
.rs-type-picker button:hover:not(.active) {
color: var(--text-primary);
}
/* Toolbar */
.rs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.rs-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.rs-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.rs-filter-btn {
padding: 6px 14px;
border: 1px solid var(--header-border);
border-radius: 8px;
background: white;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.rs-filter-btn.active {
background: var(--accent-light);
color: var(--accent-color);
border-color: var(--accent-border);
}
.rs-filter-btn:hover:not(.active) {
border-color: var(--accent-border);
color: var(--text-primary);
}
.rs-sort-select {
padding: 6px 12px;
border: 1px solid var(--header-border);
border-radius: 8px;
background: white;
color: var(--text-secondary);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
padding-right: 28px;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
.rs-new-btn {
padding: 8px 20px;
border: none;
border-radius: 10px;
background: var(--accent-color);
color: white;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25);
}
.rs-new-btn:hover {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35);
}
/* Rules Grid */
.rs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.rs-empty {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
font-size: 0.95rem;
}
/* Rule Card */
.rs-card {
background: white;
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 20px;
padding: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.rs-card:hover {
border-color: var(--accent-border);
box-shadow: 0 8px 30px rgba(191, 219, 254, 0.3);
transform: translateY(-2px);
}
.rs-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.rs-card-id {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.8rem;
color: var(--text-tertiary);
background: var(--surface);
padding: 2px 8px;
border-radius: 6px;
}
.rs-card-version {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.rs-card-desc {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 12px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rs-card-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.rs-badge {
padding: 3px 10px;
border-radius: 20px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rs-badge.active {
background: #dcfce7;
color: #16a34a;
}
.rs-badge.archived {
background: #fef3c7;
color: #d97706;
}
.rs-badge.inactive {
background: var(--surface);
color: var(--text-tertiary);
}
.rs-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(226, 232, 240, 0.4);
}
.rs-card-items-count {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.rs-card-date {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.rs-card-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.rs-tag {
padding: 2px 8px;
border-radius: 6px;
font-size: 0.7rem;
background: rgba(241, 245, 249, 0.8);
color: var(--text-secondary);
}
/* ===== POPUP OVERLAY ===== */
.rs-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.rs-popup {
background: white;
border-radius: 24px;
width: 700px;
max-width: 95vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
border: 1px solid var(--header-border);
}
.rs-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 28px 16px;
border-bottom: 1px solid var(--header-border);
position: sticky;
top: 0;
background: white;
border-radius: 24px 24px 0 0;
z-index: 1;
}
.rs-popup-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.rs-popup-close {
width: 32px;
height: 32px;
border: none;
background: var(--surface);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 1.2rem;
transition: all 0.2s ease;
}
.rs-popup-close:hover {
background: #fee2e2;
color: #ef4444;
}
.rs-popup-body {
padding: 20px 28px 28px;
}
/* Popup sections */
.rs-section {
margin-bottom: 20px;
}
.rs-section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-tertiary);
margin-bottom: 8px;
}
/* Inline editable fields */
.rs-field-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.rs-field-label {
font-size: 0.8rem;
color: var(--text-secondary);
min-width: 80px;
font-weight: 500;
}
.rs-field-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--header-border);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
color: var(--text-primary);
transition: border-color 0.2s ease;
background: white;
}
.rs-field-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.rs-field-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--header-border);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
color: var(--text-primary);
resize: vertical;
min-height: 60px;
transition: border-color 0.2s ease;
}
.rs-field-textarea:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Action buttons row */
.rs-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.rs-action-btn {
padding: 6px 14px;
border: 1px solid var(--header-border);
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
background: white;
color: var(--text-secondary);
}
.rs-action-btn:hover {
border-color: var(--accent-border);
color: var(--text-primary);
}
.rs-action-btn.active-toggle {
background: #dcfce7;
color: #16a34a;
border-color: #bbf7d0;
}
.rs-action-btn.active-toggle.off {
background: white;
color: var(--text-secondary);
border-color: var(--header-border);
}
.rs-action-btn.archive-toggle.on {
background: #fef3c7;
color: #d97706;
border-color: #fde68a;
}
.rs-action-btn.danger {
color: #ef4444;
border-color: #fecaca;
}
.rs-action-btn.danger:hover {
background: #fef2f2;
border-color: #fca5a5;
}
/* Items section */
.rs-items-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.rs-add-item-btn {
padding: 4px 12px;
border: 1px dashed var(--accent-border);
border-radius: 8px;
background: var(--accent-light);
color: var(--accent-color);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.rs-add-item-btn:hover {
background: var(--accent-color);
color: white;
border-style: solid;
}
/* Item row */
.rs-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--surface);
border-radius: 10px;
margin-bottom: 6px;
transition: all 0.2s ease;
}
.rs-item:hover {
background: rgba(241, 245, 249, 1);
}
.rs-item-field {
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 0.8rem;
font-family: inherit;
color: var(--text-primary);
background: transparent;
transition: all 0.2s ease;
min-width: 0;
}
.rs-item-field:focus {
border-color: var(--accent-color);
background: white;
outline: none;
}
.rs-item-field.narrow {
width: 60px;
flex-shrink: 0;
}
.rs-item-field.medium {
width: 100px;
flex-shrink: 0;
}
.rs-item-field.wide {
flex: 1;
min-width: 80px;
}
/* Toggle switch for item enabled */
.rs-toggle {
width: 36px;
height: 20px;
background: #cbd5e1;
border-radius: 10px;
position: relative;
cursor: pointer;
transition: background 0.2s ease;
flex-shrink: 0;
}
.rs-toggle.on {
background: #22c55e;
}
.rs-toggle::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.rs-toggle.on::after {
transform: translateX(16px);
}
.rs-item-delete {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
transition: all 0.2s ease;
}
.rs-item-delete:hover {
background: #fef2f2;
color: #ef4444;
}
/* Tags input */
.rs-tags-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 6px;
border: 1px solid var(--header-border);
border-radius: 8px;
min-height: 38px;
background: white;
}
.rs-tags-wrap:focus-within {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.rs-tag-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: var(--accent-light);
color: var(--accent-color);
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
}
.rs-tag-chip button {
border: none;
background: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 0.85rem;
opacity: 0.6;
line-height: 1;
}
.rs-tag-chip button:hover {
opacity: 1;
}
.rs-tag-input {
border: none;
outline: none;
font-size: 0.8rem;
font-family: inherit;
flex: 1;
min-width: 80px;
padding: 2px 4px;
background: transparent;
color: var(--text-primary);
}
/* Saving indicator */
.rs-saving {
font-size: 0.75rem;
color: var(--accent-color);
opacity: 0;
transition: opacity 0.2s ease;
}
.rs-saving.visible {
opacity: 1;
}
/* Item field labels header */
.rs-item-labels {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px 4px;
font-size: 0.7rem;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.rs-item-labels span.narrow { width: 60px; flex-shrink: 0; }
.rs-item-labels span.medium { width: 100px; flex-shrink: 0; }
.rs-item-labels span.wide { flex: 1; min-width: 80px; }
.rs-item-labels span.toggle-space { width: 36px; flex-shrink: 0; }
.rs-item-labels span.delete-space { width: 24px; flex-shrink: 0; }
/* Confirm dialog */
.rs-confirm-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(4px);
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
}
.rs-confirm-box {
background: white;
border-radius: 16px;
padding: 24px;
width: 360px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
text-align: center;
}
.rs-confirm-box h3 {
margin-bottom: 8px;
font-size: 1rem;
color: var(--text-primary);
}
.rs-confirm-box p {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 20px;
}
.rs-confirm-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.rs-confirm-actions button {
padding: 8px 20px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
border: 1px solid var(--header-border);
background: white;
color: var(--text-secondary);
transition: all 0.2s ease;
}
.rs-confirm-actions button.confirm-danger {
background: #ef4444;
color: white;
border-color: #ef4444;
}
.rs-confirm-actions button.confirm-danger:hover {
background: #dc2626;
}
/* Back link */
.rs-back {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
transition: color 0.2s ease;
}
.rs-back:hover {
color: var(--accent-color);
}
/* ── Version number inputs (major.build.patch) ── */
.rs-version-inputs {
display: inline-flex;
align-items: center;
gap: 4px;
}
.rs-version-num {
width: 48px;
padding: 6px 8px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
background: var(--input-bg, #fff);
color: var(--text-primary);
-moz-appearance: textfield;
}
.rs-version-num::-webkit-outer-spin-button,
.rs-version-num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.rs-version-dot {
color: var(--text-tertiary, #94a3b8);
font-weight: 600;
}
.rs-card-items {
font-size: 0.75rem;
color: var(--text-tertiary, #94a3b8);
}
/* ── Deploy section ── */
.rs-deploy-wrap {
display: flex;
flex-direction: column;
gap: 12px;
}
.rs-deploy-sensors {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
padding: 8px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
background: var(--input-bg, #fafafa);
}
.rs-deploy-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s ease;
}
.rs-deploy-item:hover { background: rgba(0,0,0,0.03); }
.rs-deploy-name { flex: 1; font-weight: 500; color: var(--text-primary); }
.rs-deploy-check { width: 16px; height: 16px; cursor: pointer; }
.rs-deploy-status {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.rs-deploy-status.ok { background: #dcfce7; color: #166534; }
.rs-deploy-status.pending { background: #fef3c7; color: #92400e; }
.rs-deploy-actions {
display: flex;
align-items: center;
gap: 12px;
}
.rs-deploy-result {
font-size: 0.8rem;
color: var(--text-secondary);
}

View File

@@ -1,17 +1,16 @@
:root { :root {
--accent-color: #2563eb; --accent-color: #2563eb;
--accent-hover: #1d4ed8; --accent-hover: #1d4ed8;
--accent-light: #eff6ff; --accent-light: #dce6f3;
--accent-border: #bfdbfe; --accent-border: #bfdbfe;
--text-primary: #0f172a; --text-primary: #000000;
--text-secondary: #4755698f; --text-secondary: #4755698f;
--text-tertiary: #94a3b8c0; --text-tertiary: #94a3b8c0;
--surface: #f8fafc; --surface: #ffffff;
--header-bg: rgba(255, 255, 255, 0.85); --header-bg: rgba(255, 255, 255, 0.85);
/* For Glassmorphism */
--header-border: #e2e8f0; --header-border: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
@@ -20,6 +19,33 @@
--radius-lg: 12px; --radius-lg: 12px;
} }
/* DARK MODE */
@media (prefers-color-scheme: dark) {
:root {
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--accent-light: #1e3a8a;
--accent-border: #1e40af;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-tertiary: #94a3b8;
--surface: #000000;
--header-bg: rgba(15, 23, 42, 0.85);
--header-border: #334155;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
}
/* Smooth transition for dark mode */
body {
transition: background-color 0.3s ease, color 0.3s ease;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -42,6 +68,7 @@
body { body {
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-primary); color: var(--text-primary);
background-color: var(--surface);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -49,7 +76,7 @@ button {
padding: 10px 24px; padding: 10px 24px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--header-border); border: 1px solid var(--header-border);
background-color: var(--bg-surface); background-color: var(--surface);
color: var(--text-primary); color: var(--text-primary);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
@@ -144,7 +171,6 @@ button.prominent:active {
.card p { .card p {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
color: var(--text-secondary); color: var(--text-secondary);
opacity: 0.4;
font-size: 0.8rem; font-size: 0.8rem;
text-align: left; text-align: left;
} }
@@ -158,7 +184,34 @@ button.prominent:active {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.card.disabled {
pointer-events: none;
cursor: default;
/* No global opacity on the container to allow badge to remain fully visible */
}
/* Dim specific internals to 50% while keeping the badge fully opaque */
.card.disabled h3,
.card.disabled p,
.card.disabled .page-icon {
opacity: 0.5;
}
.card.disabled .badge {
opacity: 1 !important;
}
.card .badge {
background-color: #ef4444;
color: #fff;
font-size: 0.6rem;
padding: 2px 10px;
font-weight: 700;
margin-bottom: 2px;
margin-right: 6px;
vertical-align: middle;
display: inline-block;
}

View File

@@ -1,125 +1,292 @@
""" """
Redis Keys: Cache two-tier per il servizio Marine.
- marine:catalog:full → lista dei dataset completo (TTL 1h)
- marine:catalog:search:{hash} → risultati ricerca (TTL 30min) L1 = Redis (RAM): scadenza 2 ore, velocissima, condivisa tra processi.
- marine:job:{session_id} → stato job download (TTL 48h) L2 = SQLite+disco: persistente (200GB), fallback quando Redis non c'è
o quando L1 è scaduta. Scadenza configurabile (default 30 giorni).
Flusso lettura:
1. Prova L1 (Redis). Se hit → ritorna.
2. Prova L2 (SQLite). Se hit non scaduta → ritorna E ripopola L1 (re-warm).
3. Miss totale → None.
Flusso scrittura:
Scrive in entrambi i tier contemporaneamente.
Chiavi standard:
- marine:catalog:full → lista completa dataset Copernicus
- marine:catalog:search:{hash} → risultati ricerca utente
- marine:job:{session_id} → stato job download (solo Redis, ephemeri)
""" """
import gzip
import json import json
import os
import logging import logging
import os
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import redis import redis
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Configurazione Redis da variabili ambiente # ── Config ───────────────────────────────────────────────────────────────
REDIS_HOST = os.getenv("REDIS_HOST", "meb-redis") REDIS_HOST = os.getenv("REDIS_HOST", "meb-redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
# Pool di connessioni condiviso (thread-safe, riutilizzabile) # Il volume persistente è montato dal container, default /app/cache
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/app/cache"))
CACHE_DB = CACHE_DIR / "catalog.sqlite"
BLOB_DIR = CACHE_DIR / "blobs"
# TTL default
DEFAULT_REDIS_TTL = 2 * 3600 # 2 ore (L1)
DEFAULT_DISK_TTL = 30 * 24 * 3600 # 30 giorni (L2)
# Soglia sopra la quale il valore va in un file su disco invece che in sqlite
BLOB_THRESHOLD_BYTES = 64 * 1024 # 64 KB
# ── Stato globale ────────────────────────────────────────────────────────
_pool: Optional[redis.ConnectionPool] = None _pool: Optional[redis.ConnectionPool] = None
_client: Optional[redis.Redis] = None _client: Optional[redis.Redis] = None
_redis_disabled = False
_sqlite_lock = threading.Lock()
_sqlite_initialized = False
def _get_client() -> Optional[redis.Redis]: # ── Redis (L1) ───────────────────────────────────────────────────────────
"""Restituisce il client Redis singleton con connection pool. def _get_redis() -> Optional[redis.Redis]:
Ritorna None se Redis non è raggiungibile.""" global _pool, _client, _redis_disabled
global _pool, _client if _redis_disabled:
return None
if _client is not None: if _client is not None:
return _client return _client
try: try:
_pool = redis.ConnectionPool( _pool = redis.ConnectionPool(
host=REDIS_HOST, host=REDIS_HOST,
port=REDIS_PORT, port=REDIS_PORT,
# Decodifica automatica delle risposte in stringhe UTF-8 decode_responses=False, # tratto blob binari (gzip)
decode_responses=True,
# Massimo 5 connessioni nel pool (VPS 1-core, non serve di più)
max_connections=5, max_connections=5,
# Timeout connessione e socket per evitare blocchi
socket_connect_timeout=3, socket_connect_timeout=3,
socket_timeout=3, socket_timeout=3,
# Riprova automaticamente se la connessione viene interrotta
retry_on_timeout=True, retry_on_timeout=True,
) )
_client = redis.Redis(connection_pool=_pool) _client = redis.Redis(connection_pool=_pool)
# Test connessione
_client.ping() _client.ping()
logger.info("[Redis] Connessione stabilita per il servizio Marine") logger.info("[Cache] Redis L1 connesso")
return _client return _client
except Exception as e: except Exception as e:
logger.warning(f"[Redis] Non disponibile, la cache è disabilitata: {e}") logger.warning(f"[Cache] Redis non disponibile, uso solo disco: {e}")
_redis_disabled = True
_client = None _client = None
return None return None
def cache_get(key: str) -> Optional[Any]: # ── SQLite (L2) ──────────────────────────────────────────────────────────
"""Legge un valore dalla cache Redis. def _ensure_sqlite() -> sqlite3.Connection:
"""Apre/crea il db SQLite su disco. Crea anche la dir blob."""
global _sqlite_initialized
CACHE_DIR.mkdir(parents=True, exist_ok=True)
BLOB_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(CACHE_DB), timeout=5.0, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
if not _sqlite_initialized:
conn.execute("""
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL,
is_blob INTEGER NOT NULL DEFAULT 0,
value BLOB,
blob_path TEXT,
size_bytes INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at)")
_sqlite_initialized = True
return conn
Args:
key: Chiave Redis (es. 'marine:catalog:full')
Returns: def _blob_path(key: str) -> Path:
Il valore deserializzato da JSON, oppure None se non trovato o errore # Nome file safe: solo caratteri alfanumerici + hash per unicità
""" safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in key)
return BLOB_DIR / f"{safe}.json.gz"
def _disk_get(key: str) -> Optional[Any]:
try: try:
client = _get_client() with _sqlite_lock:
if client is None: conn = _ensure_sqlite()
row = conn.execute(
"SELECT expires_at, is_blob, value, blob_path FROM cache WHERE key = ?",
(key,)
).fetchone()
if row is None:
return None return None
expires_at, is_blob, value, blob_path = row
data = client.get(key) if expires_at < int(time.time()):
if data is None: # Scaduta: la elimino in lazy
_disk_delete(key)
return None return None
if is_blob:
return json.loads(data) data = Path(blob_path).read_bytes()
else:
data = value
return json.loads(gzip.decompress(data).decode("utf-8"))
except Exception as e: except Exception as e:
logger.warning(f"[Redis] Errore lettura chiave '{key}': {e}") logger.warning(f"[Cache] Errore lettura disco '{key}': {e}")
return None return None
def cache_set(key: str, value: Any, ttl: int = 3600) -> bool: def _disk_set(key: str, raw_gz: bytes, ttl: int) -> None:
"""Scrive un valore nella cache Redis con TTL.
Args:
key: Chiave Redis
value: Valore da serializzare in JSON
ttl: Tempo di vita in secondi (default: 1 ora)
Returns:
True se scritto con successo, False altrimenti
"""
try: try:
client = _get_client() expires_at = int(time.time()) + ttl
if client is None: updated_at = int(time.time())
return False size = len(raw_gz)
if size > BLOB_THRESHOLD_BYTES:
serialized = json.dumps(value) path = _blob_path(key)
client.setex(key, ttl, serialized) path.write_bytes(raw_gz)
return True with _sqlite_lock:
conn = _ensure_sqlite()
conn.execute(
"INSERT OR REPLACE INTO cache(key, expires_at, is_blob, value, blob_path, size_bytes, updated_at) "
"VALUES(?,?,?,?,?,?,?)",
(key, expires_at, 1, None, str(path), size, updated_at)
)
else:
with _sqlite_lock:
conn = _ensure_sqlite()
conn.execute(
"INSERT OR REPLACE INTO cache(key, expires_at, is_blob, value, blob_path, size_bytes, updated_at) "
"VALUES(?,?,?,?,?,?,?)",
(key, expires_at, 0, raw_gz, None, size, updated_at)
)
except Exception as e: except Exception as e:
logger.warning(f"[Redis] Errore scrittura chiave '{key}': {e}") logger.warning(f"[Cache] Errore scrittura disco '{key}': {e}")
def _disk_delete(key: str) -> None:
try:
with _sqlite_lock:
conn = _ensure_sqlite()
row = conn.execute("SELECT blob_path FROM cache WHERE key = ?", (key,)).fetchone()
conn.execute("DELETE FROM cache WHERE key = ?", (key,))
if row and row[0]:
try:
Path(row[0]).unlink(missing_ok=True)
except Exception:
pass
except Exception as e:
logger.warning(f"[Cache] Errore delete disco '{key}': {e}")
# ── API pubblica ─────────────────────────────────────────────────────────
def cache_get(key: str) -> Optional[Any]:
"""Legge L1 → L2. Se L2 hit, ripopola L1 (re-warm)."""
# L1
client = _get_redis()
if client is not None:
try:
raw = client.get(key)
if raw is not None:
return json.loads(gzip.decompress(raw).decode("utf-8"))
except Exception as e:
logger.warning(f"[Cache] Errore Redis '{key}': {e}")
# L2
value = _disk_get(key)
if value is not None and client is not None:
# Re-warm L1 con TTL standard
try:
raw_gz = gzip.compress(json.dumps(value).encode("utf-8"))
client.setex(key, DEFAULT_REDIS_TTL, raw_gz)
except Exception:
pass
return value
def cache_set(key: str, value: Any, ttl: int = DEFAULT_REDIS_TTL, disk_ttl: Optional[int] = None) -> bool:
"""Scrive in L1 (ttl) e L2 (disk_ttl, default 30 giorni).
Per chiavi ephemere (es. job state) passa disk_ttl=0 per saltare il disco."""
if disk_ttl is None:
disk_ttl = DEFAULT_DISK_TTL
try:
serialized = json.dumps(value).encode("utf-8")
raw_gz = gzip.compress(serialized)
except Exception as e:
logger.warning(f"[Cache] Errore serializzazione '{key}': {e}")
return False return False
ok = False
# L1
client = _get_redis()
if client is not None:
try:
client.setex(key, ttl, raw_gz)
ok = True
except Exception as e:
logger.warning(f"[Cache] Errore scrittura Redis '{key}': {e}")
# L2
if disk_ttl > 0:
_disk_set(key, raw_gz, disk_ttl)
ok = True
return ok
def cache_delete(key: str) -> bool: def cache_delete(key: str) -> bool:
"""Elimina una chiave dalla cache Redis. client = _get_redis()
if client is not None:
try:
client.delete(key)
except Exception:
pass
_disk_delete(key)
return True
Args:
key: Chiave Redis da eliminare
Returns: def cache_stats() -> dict:
True se eliminata, False altrimenti """Ritorna statistiche della cache: utile per /health e debug."""
""" stats = {"redis": False, "disk": {"entries": 0, "bytes": 0, "blobs": 0}}
if _get_redis() is not None:
stats["redis"] = True
try: try:
client = _get_client() with _sqlite_lock:
if client is None: conn = _ensure_sqlite()
return False row = conn.execute(
"SELECT COUNT(*), COALESCE(SUM(size_bytes),0), COALESCE(SUM(is_blob),0) FROM cache"
).fetchone()
stats["disk"]["entries"] = row[0]
stats["disk"]["bytes"] = row[1]
stats["disk"]["blobs"] = row[2]
except Exception:
pass
return stats
client.delete(key)
return True def cache_sweep() -> int:
"""Rimuove voci scadute su disco (da chiamare periodicamente). Ritorna numero eliminate."""
try:
now = int(time.time())
with _sqlite_lock:
conn = _ensure_sqlite()
rows = conn.execute(
"SELECT key, blob_path FROM cache WHERE expires_at < ?", (now,)
).fetchall()
conn.execute("DELETE FROM cache WHERE expires_at < ?", (now,))
for _, path in rows:
if path:
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
return len(rows)
except Exception as e: except Exception as e:
logger.warning(f"[Redis] Errore eliminazione chiave '{key}': {e}") logger.warning(f"[Cache] Errore sweep: {e}")
return False return 0

View File

@@ -2,6 +2,7 @@ import hashlib
import io import io
import logging import logging
import os import os
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, List, Optional from typing import Callable, List, Optional
@@ -11,13 +12,20 @@ from core.cache import cache_get, cache_set
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Chiavi Redis e TTL ──────────────────────────────────────────────── # Lock di "single-flight" per il fetch del catalogo Copernicus.
# Senza questo, N richieste concorrenti con cache miss farebbero N chiamate
# all'SDK (10-30s ciascuna, ~200MB di response). Con il lock, solo la prima
# scarica e popola la cache; le altre attendono e leggono da cache.
_catalog_fetch_lock = threading.Lock()
# ── Chiavi cache e TTL ────────────────────────────────────────────────
# Chiave per il catalogo completo Copernicus # Chiave per il catalogo completo Copernicus
_CATALOG_KEY = "marine:catalog:full" _CATALOG_KEY = "marine:catalog:full"
# TTL del catalogo: 1 ora (il catalogo Copernicus cambia raramente) # TTL L1 (Redis): 2 ore. L2 (disco) usa il default 30 giorni.
_CATALOG_TTL = 3600 # Il catalogo Copernicus cambia raramente, ha senso tenerlo a lungo su disco.
# TTL per i risultati di ricerca: 30 minuti _CATALOG_TTL = 2 * 3600
_SEARCH_TTL = 1800 # TTL L1 per le ricerche utente: 2 ore. Su disco 30 giorni.
_SEARCH_TTL = 2 * 3600
def _fmt_description(name: Optional[str]) -> Optional[str]: def _fmt_description(name: Optional[str]) -> Optional[str]:
@@ -44,10 +52,17 @@ def _get_raw_catalog() -> dict:
logger.debug("[Catalogo] Servito da cache Redis") logger.debug("[Catalogo] Servito da cache Redis")
return cached return cached
# Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s) # Single-flight: solo un thread alla volta scarica il catalogo. Gli altri
logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...") # attendono il lock e poi leggono il valore appena messo in cache.
import copernicusmarine with _catalog_fetch_lock:
catalog = copernicusmarine.describe(disable_progress_bar=True) cached = cache_get(_CATALOG_KEY)
if cached is not None:
return cached
# Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s)
logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...")
import copernicusmarine
catalog = copernicusmarine.describe(disable_progress_bar=True)
# Serializza la risposta SDK in un dizionario standard # Serializza la risposta SDK in un dizionario standard
if hasattr(catalog, "model_dump"): if hasattr(catalog, "model_dump"):
@@ -57,11 +72,11 @@ def _get_raw_catalog() -> dict:
else: else:
result = catalog result = catalog
# Salva in Redis per le prossime richieste (TTL 1 ora) # Salva in Redis per le prossime richieste (TTL 1 ora)
cache_set(_CATALOG_KEY, result, _CATALOG_TTL) cache_set(_CATALOG_KEY, result, _CATALOG_TTL)
logger.info("[Catalogo] Salvato in cache Redis") logger.info("[Catalogo] Salvato in cache Redis")
return result return result
def _get_dataset_reqs(ds: dict) -> tuple: def _get_dataset_reqs(ds: dict) -> tuple:

View File

@@ -12,11 +12,16 @@ from fastapi.middleware.cors import CORSMiddleware
load_dotenv() load_dotenv()
from routers import catalog, datasets, jobs from routers import catalog, datasets, jobs
from core.cache import cache_stats, cache_sweep
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
api_url = os.getenv("API_SERVICE_URL", "http://api:3003") api_url = os.getenv("API_SERVICE_URL", "http://api:3003")
# Pulizia voci scadute della cache su disco all'avvio
removed = cache_sweep()
if removed:
print(f"[Cache] Rimosse {removed} voci scadute dal disco")
yield yield
@@ -50,4 +55,9 @@ async def root():
@app.get("/health", tags=["health"]) @app.get("/health", tags=["health"])
async def health(): async def health():
return {"status": "healthy"} return {"status": "healthy", "cache": cache_stats()}
@app.post("/cache/sweep", tags=["health"])
async def sweep():
return {"removed": cache_sweep()}

View File

@@ -7,6 +7,7 @@ Flusso:
import json import json
import os import os
import threading
import uuid import uuid
from typing import Any, Dict from typing import Any, Dict
@@ -24,6 +25,13 @@ API_URL = os.getenv("API_SERVICE_URL", "http://api:3003")
# TTL per lo stato dei job: 48 ore (i job completati vengono puliti automaticamente) # TTL per lo stato dei job: 48 ore (i job completati vengono puliti automaticamente)
_JOB_TTL = 48 * 3600 _JOB_TTL = 48 * 3600
# Limite di download Copernicus concorrenti. Le subset() dell'SDK sono
# CPU + memoria intensive (xarray + netCDF + pandas conversion) e sul server
# le risorse sono limitate. Senza semaforo, N utenti che cliccano insieme
# saturano la RAM e fanno OOM-kill del processo.
_DOWNLOAD_CONCURRENCY = int(os.getenv("MARINE_DOWNLOAD_CONCURRENCY", "2"))
_download_semaphore = threading.BoundedSemaphore(_DOWNLOAD_CONCURRENCY)
def _job_key(session_id: str) -> str: def _job_key(session_id: str) -> str:
"""Genera la chiave Redis per un job.""" """Genera la chiave Redis per un job."""
@@ -42,7 +50,7 @@ def _set_job(session_id: str, **kwargs):
if job is None: if job is None:
return return
job.update(kwargs) job.update(kwargs)
cache_set(_job_key(session_id), job, _JOB_TTL) cache_set(_job_key(session_id), job, _JOB_TTL, disk_ttl=0)
def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_token: str): def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_token: str):
@@ -55,20 +63,26 @@ def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_
_set_job(session_id, progress=pct, message=msg) _set_job(session_id, progress=pct, message=msg)
try: try:
_set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...") _set_job(session_id, status="queued", progress=2, message="In coda (max concorrenti raggiunto)...")
# Scarica dati dal catalogo Copernicus # Acquisisce uno slot di download (blocca se già al limite). Garantisce
df = copernicus.download_dataset( # che il numero di chiamate Copernicus simultanee non superi
dataset_id=req.dataset_id, # MARINE_DOWNLOAD_CONCURRENCY, proteggendo CPU/RAM del server.
variables=req.variables, with _download_semaphore:
min_longitude=req.min_longitude, _set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...")
max_longitude=req.max_longitude,
min_latitude=req.min_latitude, # Scarica dati dal catalogo Copernicus
max_latitude=req.max_latitude, df = copernicus.download_dataset(
start_datetime=req.start_date, dataset_id=req.dataset_id,
end_datetime=req.end_date, variables=req.variables,
progress_callback=progress, min_longitude=req.min_longitude,
) max_longitude=req.max_longitude,
min_latitude=req.min_latitude,
max_latitude=req.max_latitude,
start_datetime=req.start_date,
end_datetime=req.end_date,
progress_callback=progress,
)
_set_job(session_id, status="converting", progress=80, message="Creo il file...") _set_job(session_id, status="converting", progress=80, message="Creo il file...")
@@ -85,7 +99,7 @@ def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_
"created_by": username, "created_by": username,
"type": req.format, "type": req.format,
"notes": req.notes, "notes": req.notes,
"copernicus_dataset_id": req.dataset_id, "copernicus_id": req.dataset_id,
"variables": req.variables, "variables": req.variables,
"variable_renames": req.variable_renames, "variable_renames": req.variable_renames,
"bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude], "bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude],
@@ -129,7 +143,7 @@ async def new_download_session(
"message": "In coda", "message": "In coda",
"dataset_id": None, "dataset_id": None,
} }
cache_set(_job_key(session_id), initial_state, _JOB_TTL) cache_set(_job_key(session_id), initial_state, _JOB_TTL, disk_ttl=0)
# Avvia il download in background # Avvia il download in background
background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"]) background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"])

View File

@@ -65,7 +65,7 @@ class DatasetMeta(BaseModel):
notes: str = "" notes: str = ""
version: int = 1 version: int = 1
filename: str filename: str
copernicus_dataset_id: str copernicus_id: str
variables: List[str] = [] variables: List[str] = []
bbox: List[float] = [] # [min_lon, min_lat, max_lon, max_lat] bbox: List[float] = [] # [min_lon, min_lat, max_lon, max_lat]
start_date: str start_date: str

View File

@@ -17,16 +17,16 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
networks: networks:
- meb-proxy-net - meb-public
- meb-internal - meb-private
ports: ports:
- "3006:3006" - "3006:3006"
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.auth.rule=Host(`auth.${URL_DOMAIN}`)" - "traefik.http.routers.auth.rule=Host(`auth.${DOMAIN:-mebboat.it}`)"
- "traefik.http.routers.auth.entrypoints=web" - "traefik.http.routers.auth.entrypoints=websecure"
- "traefik.http.services.auth.loadbalancer.server.port=3006" - "traefik.http.services.auth.loadbalancer.server.port=3006"
- "traefik.docker.network=meb-proxy-net" - "traefik.docker.network=meb-public"
api: api:
container_name: api-services container_name: api-services
@@ -37,16 +37,20 @@ services:
command: npm run dev command: npm run dev
volumes: volumes:
- ./api/src:/app/src - ./api/src:/app/src
- /app/node_modules
- ./ml:/ml-source - ./ml:/ml-source
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
env_file: env_file:
- ./api/.env - ./api/.env
networks: networks:
- meb-proxy-net - meb-public
- meb-internal - meb-private
ports: labels:
- "3003:3003" - "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.${DOMAIN:-mebboat.it}`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.services.api.loadbalancer.server.port=3003"
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
- "traefik.docker.network=meb-public"
console: console:
build: build:
@@ -60,10 +64,15 @@ services:
env_file: env_file:
- ./console/.env - ./console/.env
networks: networks:
- meb-proxy-net - meb-public
- meb-internal - meb-private
ports: labels:
- "3004:3004" - "traefik.enable=true"
- "traefik.http.routers.console.rule=Host(`console.${DOMAIN:-mebboat.it}`)"
- "traefik.http.routers.console.entrypoints=websecure"
- "traefik.http.services.console.loadbalancer.server.port=3004"
- "traefik.http.routers.console.tls.certresolver=letsencrypt"
- "traefik.docker.network=meb-public"
realtime: realtime:
build: build:
@@ -71,58 +80,84 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
command: npm run dev command: npm run dev
ports:
- "3002:3002"
- "3102:3102"
volumes: volumes:
- ./realtime:/app - ./realtime:/app
- /app/node_modules - /app/node_modules
env_file: env_file:
- ./realtime/.env - ./realtime/.env
networks: networks:
- meb-proxy-net - meb-private
- meb-internal - meb-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.realtime.rule=Host(`realtime.${DOMAIN:-mebboat.it}`)"
- "traefik.http.routers.realtime.entrypoints=websecure"
- "traefik.http.services.realtime.loadbalancer.server.port=3000"
- "traefik.http.routers.realtime.tls.certresolver=letsencrypt"
- "traefik.docker.network=meb-public"
# ml: - "traefik.http.services.realtime.loadbalancer.sticky.cookie=true"
# container_name: ml-service - "traefik.http.services.realtime.loadbalancer.sticky.cookie.name=realtime-ws"
# build: - "traefik.http.services.realtime.loadbalancer.sticky.cookie.secure=true"
# context: ./ml
# dockerfile: Dockerfile
# restart: unless-stopped
# volumes:
# - ./ml:/app
# env_file:
# - ./ml/.env
# ports:
# - "3005:3005"
# networks:
# - meb-proxy-net
# - meb-internal
# marine: ml:
# container_name: marine-service container_name: ml-service
# build: build:
# context: ./marine context: ./ml
# dockerfile: Dockerfile dockerfile: Dockerfile
# restart: unless-stopped restart: unless-stopped
# volumes: command: uvicorn main:app --host 0.0.0.0 --port 3007 --reload
# - ./marine:/app volumes:
# env_file: - ./ml:/app
# - ./marine/.env - /var/run/docker.sock:/var/run/docker.sock
# environment: - ml_tmp:/var/ml/tmp
# - REDIS_HOST=meb-redis - ml_gitcache:/var/ml/gitcache
# - REDIS_PORT=6379 env_file:
# networks: - ./ml/.env
# - meb-proxy-net networks:
# - meb-internal - meb-private
# labels: - meb-public
# - "traefik.enable=true" labels:
# - "traefik.http.routers.marine.rule=Host(`api.${URL_DOMAIN}`) && PathPrefix(`/marine`)" - "traefik.enable=true"
# - "traefik.http.routers.marine.entrypoints=web" - "traefik.http.routers.ml.rule=Host(`ml.${DOMAIN:-mebboat.it}`)"
# - "traefik.http.services.marine.loadbalancer.server.port=8001" - "traefik.http.routers.ml.entrypoints=websecure"
# - "traefik.docker.network=meb-proxy-net" - "traefik.http.services.ml.loadbalancer.server.port=3007"
# - "traefik.http.middlewares.marine-strip.stripprefix.prefixes=/marine" - "traefik.http.routers.ml.tls.certresolver=letsencrypt"
# - "traefik.http.routers.marine.middlewares=marine-strip" - "traefik.docker.network=meb-public"
copernicus:
container_name: copernicus-service
build:
context: ./copernicus
dockerfile: Dockerfile
restart: unless-stopped
volumes:
- ./copernicus:/app
- copernicus_cache:/app/cache
env_file:
- ./copernicus/.env
environment:
- REDIS_HOST=meb-redis
- REDIS_PORT=6379
- API_SERVICE_URL=http://api:3003
- CACHE_DIR=/app/cache
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
networks:
- meb-public
- meb-private
labels:
- "traefik.enable=true"
# Esponi sotto api.mebboat.it/marine/* (Traefik strippa "/marine")
- "traefik.http.routers.copernicus.rule=Host(`api.${DOMAIN:-mebboat.it}`) && PathPrefix(`/marine`)"
- "traefik.http.routers.copernicus.entrypoints=websecure"
- "traefik.http.routers.copernicus.tls.certresolver=letsencrypt"
- "traefik.http.services.copernicus.loadbalancer.server.port=8000"
- "traefik.docker.network=meb-public"
- "traefik.http.middlewares.copernicus-strip.stripprefix.prefixes=/marine"
- "traefik.http.routers.copernicus.middlewares=copernicus-strip"
# Priorità alta: la regola col PathPrefix deve vincere su quella generica api.
- "traefik.http.routers.copernicus.priority=100"
# circuits: # circuits:
# container_name: meb-circuits # container_name: meb-circuits
@@ -133,8 +168,8 @@ services:
# environment: # environment:
# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits # - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits
# - AUTH_SERVICE_URL=http://auth:3001 # - AUTH_SERVICE_URL=http://auth:3001
# - AUTH_URL=http://auth.${URL_DOMAIN:-localhost} # - AUTH_URL=http://auth.${DOMAIN:-localhost}
# - API_URL=http://api.${URL_DOMAIN:-localhost} # - API_URL=http://api.${DOMAIN:-localhost}
# - NODE_ENV=${NODE_ENV:-development} # - NODE_ENV=${NODE_ENV:-development}
# volumes: # volumes:
# - ./circuits/src:/app/src # - ./circuits/src:/app/src
@@ -151,14 +186,19 @@ services:
# - meb-internal # - meb-internal
# labels: # labels:
# - "traefik.enable=true" # - "traefik.enable=true"
# - "traefik.http.routers.circuits.rule=Host(`circuits.${URL_DOMAIN}`)" # - "traefik.http.routers.circuits.rule=Host(`circuits.${DOMAIN:-mebboat.it}`)"
# - "traefik.http.routers.circuits.entrypoints=web" # - "traefik.http.routers.circuits.entrypoints=web"
# - "traefik.http.services.circuits.loadbalancer.server.port=3005" # - "traefik.http.services.circuits.loadbalancer.server.port=3005"
# - "traefik.docker.network=meb-proxy-net" # - "traefik.docker.network=meb-proxy-net"
# - "traefik.http.routers.circuits.middlewares=cors-ignore" # - "traefik.http.routers.circuits.middlewares=cors-ignore"
networks: networks:
meb-proxy-net: meb-public:
external: true external: true
meb-internal: meb-private:
external: true external: true
volumes:
copernicus_cache:
ml_tmp:
ml_gitcache:

View File

@@ -0,0 +1,45 @@
PORT=3007
# Auth condiviso
JWT_SECRET=change-me
INTERNAL_API_KEY=change-me
AUTH_LOGIN_URL=https://auth.mebboat.it/login
# Postgres (db ml)
PG_HOST=meb-postgres
PG_PORT=5432
DB_USER=meb
DB_PASSWORD=meb
ML_DB=ml
# Redis
REDIS_HOST=meb-redis
REDIS_PORT=6379
# MinIO (bucket unico)
MINIO_ENDPOINT=minio
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_BUCKET=ml
# InfluxDB
INFLUX_URL=http://meb-influx:8086
INFLUX_TOKEN=
INFLUX_ORG=meb
INFLUX_BUCKET=ml_metrics
# Gitea (self-hosted esterno)
GITEA_URL=https://git.mebboat.it
GITEA_TOKEN=
# API service
API_URL=http://api:3003
# Training runtime
ML_TRAIN_CONCURRENCY=1
ML_RUNNER_IMAGE=meb-ml-runner:latest
ML_RUNNER_TMP=/var/ml/tmp
ML_GITCACHE_DIR=/var/ml/gitcache
ML_MAX_UPLOAD_MB=500

View File

@@ -3,6 +3,9 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
COPY ./requirements.txt . COPY ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

72
ml/core/api_client.py Normal file
View File

@@ -0,0 +1,72 @@
"""Client HTTP verso l'api-service (service-to-service via x-api-key).
Espone accesso a:
/jobs ciclo di vita job
/queue stato coda
/pageconnections registro sessioni di pagina (enforcement /test max 2)
"""
from __future__ import annotations
from typing import Any, Optional
import httpx
from core.config import settings
def _headers() -> dict:
return {"x-api-key": settings.internal_api_key, "Content-Type": "application/json"}
async def _req(method: str, path: str, json: Optional[dict] = None, params: Optional[dict] = None) -> Any:
url = f"{settings.api_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.request(method, url, json=json, params=params, headers=_headers())
r.raise_for_status()
if r.status_code == 204 or not r.content:
return None
return r.json()
# ── jobs ────────────────────────────────────────────────────────────────────
async def create_job(type_: str, created_by: str, payload: dict) -> dict:
return await _req("POST", "/jobs", json={"type": type_, "created_by": created_by, "payload": payload})
async def update_job(job_id: str, **fields) -> dict:
return await _req("PATCH", f"/jobs/{job_id}", json=fields)
async def get_job(job_id: str) -> dict:
return await _req("GET", f"/jobs/{job_id}")
async def list_jobs(type_: Optional[str] = None, status: Optional[str] = None, limit: int = 50) -> list:
params = {"limit": str(limit)}
if type_:
params["type"] = type_
if status:
params["status"] = status
return await _req("GET", "/jobs", params=params) or []
# ── queue ───────────────────────────────────────────────────────────────────
async def queue_status(type_: str = "train") -> dict:
return await _req("GET", "/queue", params={"type": type_})
# ── page connections ───────────────────────────────────────────────────────
async def page_connect(page: str, user_id: str, session_id: str) -> dict:
return await _req("POST", "/pageconnections", json={"page": page, "user_id": user_id, "session_id": session_id})
async def page_ping(session_id: str) -> dict:
return await _req("POST", f"/pageconnections/{session_id}/ping")
async def page_disconnect(session_id: str) -> None:
await _req("DELETE", f"/pageconnections/{session_id}")
async def page_count(page: str) -> dict:
return await _req("GET", f"/pageconnections/{page}")

85
ml/core/auth.py Normal file
View File

@@ -0,0 +1,85 @@
"""
Middleware / dependency di autenticazione per FastAPI (servizio ML).
Verifica il JWT firmato da auth.mebboat.it (JWT_SECRET condiviso).
Supporta cookie `auth_token` (SSO via .mebboat.it) e header Authorization: Bearer <jwt>.
Il cookie auth_token è condiviso tra i sottodomini grazie a domain=.mebboat.it:
- console.mebboat.it imposta il cookie al login
- ml.mebboat.it lo riceve automaticamente dal browser
Uso:
from core.auth import require_auth, require_internal
@app.get("/protected")
async def protected_route(user = Depends(require_auth)):
return {"user": user}
"""
import os
from typing import Optional
import jwt
from fastapi import Cookie, Header, HTTPException, Request, status
SECRET = os.environ.get("JWT_SECRET")
INTERNAL_KEY = os.environ.get("INTERNAL_API_KEY")
def _verify(token: Optional[str]):
"""Verifica e decodifica un JWT. Ritorna il payload o None."""
if not token or not isinstance(token, str) or len(token) > 2048:
return None
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
return {
"user_id": payload.get("sub"),
"username": payload.get("username"),
"session_id": payload.get("session_id"),
"iat": payload.get("iat"),
"exp": payload.get("exp"),
}
except jwt.PyJWTError:
return None
async def require_auth(
request: Request,
auth_token: Optional[str] = Cookie(default=None),
authorization: Optional[str] = Header(default=None),
x_api_key: Optional[str] = Header(default=None),
):
"""
FastAPI dependency: accetta utente loggato (cookie/bearer) o chiamata interna.
Uso: `user = Depends(require_auth)`.
Il cookie auth_token arriva automaticamente dal browser se l'utente
ha effettuato il login su auth.mebboat.it (dominio .mebboat.it).
"""
# Service-to-service
if x_api_key and INTERNAL_KEY and x_api_key == INTERNAL_KEY:
request.state.internal = True
return {"internal": True}
# Bearer token
bearer = None
if authorization and authorization.startswith("Bearer "):
bearer = authorization[7:]
user = _verify(auth_token or bearer)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
)
request.state.user = user
return user
async def require_internal(x_api_key: Optional[str] = Header(default=None)):
"""FastAPI dependency: solo chiamate service-to-service con x-api-key."""
if not INTERNAL_KEY or x_api_key != INTERNAL_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return True

64
ml/core/config.py Normal file
View File

@@ -0,0 +1,64 @@
"""Configurazione centralizzata del servizio ML, letta da env."""
from __future__ import annotations
import os
from dataclasses import dataclass
def _b(name: str, default: bool = False) -> bool:
return os.environ.get(name, str(default)).lower() in ("1", "true", "yes", "on")
@dataclass(frozen=True)
class Settings:
# Postgres (db "ml")
pg_host: str = os.environ.get("PG_HOST", "meb-postgres")
pg_port: int = int(os.environ.get("PG_PORT", "5432"))
pg_user: str = os.environ.get("DB_USER", "meb")
pg_password: str = os.environ.get("DB_PASSWORD", "meb")
pg_db: str = os.environ.get("ML_DB", "ml")
# Redis
redis_host: str = os.environ.get("REDIS_HOST", "meb-redis")
redis_port: int = int(os.environ.get("REDIS_PORT", "6379"))
# MinIO (bucket unico)
minio_endpoint: str = os.environ.get("MINIO_ENDPOINT", "minio")
minio_port: int = int(os.environ.get("MINIO_PORT", "9000"))
minio_use_ssl: bool = _b("MINIO_USE_SSL", False)
minio_access_key: str = os.environ.get("MINIO_ACCESS_KEY", "")
minio_secret_key: str = os.environ.get("MINIO_SECRET_KEY", "")
minio_bucket: str = os.environ.get("MINIO_BUCKET", "ml")
# InfluxDB — accetta sia INFLUX_* che INFLX_* per allinearsi alle var già
# usate dagli altri servizi (realtime, api) senza dover duplicare la config.
influx_url: str = os.environ.get("INFLUX_URL") or os.environ.get("INFLX_URL", "http://meb-influx:8086")
influx_token: str = os.environ.get("INFLUX_TOKEN") or os.environ.get("INFLX_TOKEN", "")
influx_org: str = os.environ.get("INFLUX_ORG") or os.environ.get("INFLX_ORG", "meb")
# Bucket dedicato alle metriche di training/test ML, separato dai logs e
# dai dati meteo. Sovrascrivibile via INFLUX_BUCKET o ML_INFLUX_BUCKET.
influx_bucket: str = os.environ.get("ML_INFLUX_BUCKET") or os.environ.get("INFLUX_BUCKET", "ml_metrics")
# Gitea (installato esternamente)
gitea_url: str = os.environ.get("GITEA_URL", "")
gitea_token: str = os.environ.get("GITEA_TOKEN", "")
# API service (per jobs/queue/pageconnections)
api_url: str = os.environ.get("API_URL", "http://api:3003")
internal_api_key: str = os.environ.get("INTERNAL_API_KEY", "")
# Auth (condiviso)
jwt_secret: str = os.environ.get("JWT_SECRET", "")
auth_login_url: str = os.environ.get("AUTH_LOGIN_URL", "https://auth.mebboat.it/login")
# Esecuzione training
train_concurrency: int = int(os.environ.get("ML_TRAIN_CONCURRENCY", "1"))
runner_image: str = os.environ.get("ML_RUNNER_IMAGE", "meb-ml-runner:latest")
runner_tmp_dir: str = os.environ.get("ML_RUNNER_TMP", "/var/ml/tmp")
gitcache_dir: str = os.environ.get("ML_GITCACHE_DIR", "/var/ml/gitcache")
# Limiti runtime
max_upload_mb: int = int(os.environ.get("ML_MAX_UPLOAD_MB", "500"))
settings = Settings()

53
ml/core/db.py Normal file
View File

@@ -0,0 +1,53 @@
"""Connessione asyncpg al database ml. Pool singleton."""
from __future__ import annotations
import asyncpg
from typing import Optional
from core.config import settings
_pool: Optional[asyncpg.Pool] = None
async def init_pool() -> asyncpg.Pool:
global _pool
if _pool is None:
_pool = await asyncpg.create_pool(
host=settings.pg_host,
port=settings.pg_port,
user=settings.pg_user,
password=settings.pg_password,
database=settings.pg_db,
min_size=1,
max_size=10,
command_timeout=30,
)
return _pool
async def close_pool() -> None:
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def pool() -> asyncpg.Pool:
if _pool is None:
raise RuntimeError("DB pool not initialized — call init_pool() at startup")
return _pool
async def fetch(sql: str, *args):
async with pool().acquire() as c:
return await c.fetch(sql, *args)
async def fetchrow(sql: str, *args):
async with pool().acquire() as c:
return await c.fetchrow(sql, *args)
async def execute(sql: str, *args):
async with pool().acquire() as c:
return await c.execute(sql, *args)

439
ml/core/docker_runner.py Normal file
View File

@@ -0,0 +1,439 @@
"""Runner Docker per train e test.
train:
- clone repo Gitea @ sha
- prepara workdir /var/ml/tmp/{training_id}
- scarica dataset da MinIO in workdir/data.<ext>
- docker run meb-ml-runner con mount tmp, env, limits da model.yml
- legge stdout JSON → Redis stream + Influx; docker stats ogni 5s
- a fine: collect outputs, upload su MinIO prefix artifacts_prefix
- UPDATE trainings
test:
- analogo ma sincrono, stdin JSON → stdout JSON
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shutil
import subprocess
import time
import uuid
from pathlib import Path
from typing import Any, Optional
import docker
from influxdb_client import Point
from core import db, gitea, influx_client, minio_client, redis_client
from core.config import settings
from core.model_spec import fetch_and_parse_spec
log = logging.getLogger(__name__)
_docker = None
def _docker_client():
global _docker
if _docker is None:
_docker = docker.from_env()
return _docker
async def _emit(stream_key: str, payload: dict) -> None:
try:
await redis_client.client().xadd(stream_key, {"payload": json.dumps(payload)}, maxlen=10_000)
except Exception as e:
log.warning("xadd failed: %s", e)
async def _clone_repo(owner_repo: str, sha: str, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
url = gitea.clone_url(owner_repo)
# clone shallow del branch/sha specifico
# per evitare leak del token nei log, logghiamo solo host
proc = await asyncio.create_subprocess_exec(
"git", "clone", "--depth", "50", url, str(dest),
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
_, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"git clone failed: {err.decode(errors='replace')[:400]}")
# checkout sha
proc = await asyncio.create_subprocess_exec(
"git", "-C", str(dest), "checkout", sha,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
_, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"git checkout failed: {err.decode(errors='replace')[:400]}")
async def _download_dataset(dataset_id: str, dest: Path) -> str:
row = await db.fetchrow(
"SELECT file_key, format FROM datasets WHERE id = $1", uuid.UUID(dataset_id)
)
if not row:
raise RuntimeError("dataset not found")
data = minio_client.get_bytes(row["file_key"], bucket="ml.datasets")
ext = {"csv": "csv", "json": "json", "netcdf": "nc"}.get(row["format"], "bin")
out = dest / f"data.{ext}"
out.write_bytes(data)
return str(out)
def _stats_loop_sync(container, training_id: str, model_id: str, samples: list, stop_evt: asyncio.Event, loop: asyncio.AbstractEventLoop):
"""Sincrono, eseguito in thread. Ogni 5s legge docker stats → Influx + samples."""
while not stop_evt.is_set():
try:
stats = container.stats(stream=False)
# CPU%
cpu_delta = stats["cpu_stats"]["cpu_usage"]["total_usage"] - stats["precpu_stats"]["cpu_usage"]["total_usage"]
sys_delta = stats["cpu_stats"].get("system_cpu_usage", 0) - stats["precpu_stats"].get("system_cpu_usage", 0)
online = stats["cpu_stats"].get("online_cpus") or len(stats["cpu_stats"]["cpu_usage"].get("percpu_usage") or [1])
cpu_pct = (cpu_delta / sys_delta) * online * 100.0 if sys_delta > 0 else 0.0
mem_mb = (stats["memory_stats"].get("usage") or 0) / (1024 * 1024)
samples.append((cpu_pct, mem_mb))
point = (
Point("ml_training")
.tag("training_id", training_id)
.tag("model_id", model_id)
.field("cpu_pct", float(cpu_pct))
.field("mem_mb", float(mem_mb))
)
asyncio.run_coroutine_threadsafe(influx_client.write_points([point]), loop)
except Exception as e:
log.warning("stats loop error: %s", e)
time.sleep(5)
async def _stream_container_logs(container, training_id: str, model_id: str, stream_key: str):
"""Legge stdout del container, pubblica righe JSON su Redis stream e Influx."""
def _iter():
return container.logs(stream=True, follow=True, stdout=True, stderr=True)
loop = asyncio.get_event_loop()
it = await loop.run_in_executor(None, _iter)
while True:
line = await loop.run_in_executor(None, next, it, None)
if line is None:
break
try:
text = line.decode("utf-8", errors="replace").rstrip("\n")
except Exception:
continue
if not text:
continue
# righe non-JSON → log
payload: dict
if text.startswith("{") and text.endswith("}"):
try:
payload = json.loads(text)
except json.JSONDecodeError:
payload = {"type": "log", "level": "info", "message": text}
else:
payload = {"type": "log", "level": "info", "message": text}
await _emit(stream_key, payload)
if payload.get("type") == "metric":
p = Point("ml_training").tag("training_id", training_id).tag("model_id", model_id)
for k, v in payload.items():
if k == "type":
continue
if isinstance(v, (int, float)):
p = p.field(k, float(v))
try:
await influx_client.write_points([p])
except Exception as e:
log.warning("influx write metric failed: %s", e)
async def run_training_job(training_id: str) -> None:
"""Esegue un job di training end-to-end. Aggiorna Postgres e Redis state."""
r = redis_client.client()
state_key = f"ml:train:{training_id}"
stream_key = f"ml:train:{training_id}:events"
tr = await db.fetchrow("SELECT * FROM trainings WHERE id = $1", uuid.UUID(training_id))
if not tr:
log.error("training %s not found", training_id)
return
model = await db.fetchrow("SELECT * FROM models WHERE id = $1", tr["model_id"])
if not model:
await db.execute(
"UPDATE trainings SET status='failed', error=$2 WHERE id=$1",
uuid.UUID(training_id), "model not found",
)
return
await db.execute(
"UPDATE trainings SET status='running', started_at=NOW() WHERE id=$1",
uuid.UUID(training_id),
)
await r.hset(state_key, mapping={"status": "running", "progress": "0", "message": "starting"})
workdir = Path(settings.runner_tmp_dir) / training_id
artifacts_prefix = f"models/{tr['model_id']}/{tr['version']}/{tr['patch']}"
error: Optional[str] = None
samples: list[tuple[float, float]] = []
try:
workdir.mkdir(parents=True, exist_ok=True)
await _emit(stream_key, {"type": "log", "level": "info", "message": "cloning repo"})
await _clone_repo(model["gitea_repo"], tr["patch"], workdir / "repo")
await _emit(stream_key, {"type": "log", "level": "info", "message": "parsing model.yml"})
spec = await fetch_and_parse_spec(model["gitea_repo"], tr["patch"]) or {}
train_spec = spec.get("train", {})
entrypoint = train_spec.get("entrypoint") or "python -m src.train"
resources = spec.get("resources", {}) or {}
await _emit(stream_key, {"type": "log", "level": "info", "message": "downloading dataset"})
dataset_path = await _download_dataset(str(tr["dataset_id"]), workdir)
out_dir = workdir / "out"
out_dir.mkdir(exist_ok=True)
# docker run
dc = _docker_client()
await _emit(stream_key, {"type": "log", "level": "info", "message": "starting container"})
container = dc.containers.run(
settings.runner_image,
command=["sh", "-c", f"cd /workdir/repo && pip install -q -r requirements.txt 2>&1 || true && {entrypoint}"],
detach=True,
working_dir="/workdir/repo",
environment={
"MEB_DATASET_PATH": f"/workdir/{Path(dataset_path).name}",
"MEB_ARTIFACTS_DIR": "/workdir/out",
"MEB_TRAINING_ID": training_id,
},
volumes={str(workdir): {"bind": "/workdir", "mode": "rw"}},
network_mode="none",
mem_limit=f"{int(resources.get('mem_mb', 2048))}m",
nano_cpus=int(float(resources.get("cpu", 1)) * 1e9),
read_only=False,
tty=False,
detach_mode=None,
)
loop = asyncio.get_event_loop()
stop_evt = asyncio.Event()
stats_task = loop.run_in_executor(
None, _stats_loop_sync, container, training_id, str(tr["model_id"]), samples, stop_evt, loop
)
log_task = asyncio.create_task(
_stream_container_logs(container, training_id, str(tr["model_id"]), stream_key)
)
# attendi exit
exit_code = await loop.run_in_executor(None, lambda: container.wait()["StatusCode"])
stop_evt.set()
await log_task
try:
stats_task.cancel()
except Exception:
pass
if exit_code != 0:
error = f"container exited with code {exit_code}"
# raccogli outputs
results: dict = {}
final_metrics_path = out_dir / "metrics.json"
if final_metrics_path.exists():
try:
results = json.loads(final_metrics_path.read_text())
except Exception:
results = {"raw": final_metrics_path.read_text()[:10000]}
# upload artefatti (tutta la cartella out/)
for p in out_dir.rglob("*"):
if p.is_file():
rel = p.relative_to(out_dir).as_posix()
key = f"{artifacts_prefix}/{rel}"
minio_client.put_bytes(key, p.read_bytes())
# upload logs jsonl dallo stream redis (copia su minio per persistenza)
try:
entries = await r.xrange(stream_key, min="-", max="+")
lines = "\n".join(json.dumps({"id": i, **({"payload": json.loads(f.get("payload", "{}"))} if "payload" in f else f)}) for i, f in entries)
minio_client.put_bytes(f"trainings/{training_id}/logs.jsonl", lines.encode("utf-8"), "application/x-ndjson")
except Exception as e:
log.warning("log archive failed: %s", e)
cpu_avg = sum(s[0] for s in samples) / len(samples) if samples else 0.0
cpu_peak = max((s[0] for s in samples), default=0.0)
mem_avg = sum(s[1] for s in samples) / len(samples) if samples else 0.0
mem_peak = max((s[1] for s in samples), default=0.0)
resource_summary = {
"cpu_avg": round(cpu_avg, 2),
"cpu_peak": round(cpu_peak, 2),
"mem_avg_mb": round(mem_avg, 2),
"mem_peak_mb": round(mem_peak, 2),
"samples": len(samples),
}
status = "failed" if error else "succeeded"
await db.execute(
"""
UPDATE trainings SET
status=$2,
finished_at=NOW(),
duration_ms=EXTRACT(EPOCH FROM (NOW() - started_at))*1000,
artifacts_prefix=$3,
results=$4::jsonb,
resource_summary=$5::jsonb,
error=$6
WHERE id=$1
""",
uuid.UUID(training_id),
status,
artifacts_prefix,
json.dumps(results),
json.dumps(resource_summary),
error,
)
await r.hset(state_key, mapping={"status": status, "progress": "100", "message": error or "done"})
await _emit(stream_key, {"type": "end", "status": status, "error": error})
# Flush dei punti Influx accumulati durante il training (batched).
await influx_client.flush()
try:
container.remove(force=True)
except Exception:
pass
except Exception as e:
log.exception("training %s failed: %s", training_id, e)
await db.execute(
"UPDATE trainings SET status='failed', finished_at=NOW(), error=$2 WHERE id=$1",
uuid.UUID(training_id), str(e)[:1000],
)
await r.hset(state_key, mapping={"status": "failed", "message": str(e)[:200]})
await _emit(stream_key, {"type": "end", "status": "failed", "error": str(e)[:400]})
finally:
# cleanup workdir
try:
shutil.rmtree(workdir, ignore_errors=True)
except Exception:
pass
async def run_test_once(training_id: str, inputs: dict) -> dict:
"""Esegue una singola predizione via container spawn."""
tr = await db.fetchrow(
"SELECT t.*, m.gitea_repo FROM trainings t JOIN models m ON t.model_id = m.id WHERE t.id=$1",
uuid.UUID(training_id),
)
if not tr:
raise RuntimeError("training not found")
spec = await fetch_and_parse_spec(tr["gitea_repo"], tr["patch"]) or {}
test_spec = spec.get("test") or {}
entrypoint = test_spec.get("entrypoint") or "python -m src.predict"
workdir = Path(settings.runner_tmp_dir) / f"test-{uuid.uuid4()}"
workdir.mkdir(parents=True, exist_ok=True)
try:
await _clone_repo(tr["gitea_repo"], tr["patch"], workdir / "repo")
# scarica artefatti
if tr["artifacts_prefix"]:
art_dir = workdir / "artifacts"
art_dir.mkdir(exist_ok=True)
for obj in minio_client.list_prefix(tr["artifacts_prefix"] + "/"):
rel = obj["name"][len(tr["artifacts_prefix"]) + 1:]
out_path = art_dir / rel
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(minio_client.get_bytes(obj["name"]))
dc = _docker_client()
payload = json.dumps({"inputs": inputs}).encode()
container = dc.containers.run(
settings.runner_image,
command=["sh", "-c", f"cd /workdir/repo && pip install -q -r requirements.txt 2>&1 >/dev/null || true && {entrypoint}"],
detach=True,
working_dir="/workdir/repo",
environment={
"MEB_ARTIFACTS_DIR": "/workdir/artifacts",
"MEB_TRAINING_ID": training_id,
},
volumes={str(workdir): {"bind": "/workdir", "mode": "ro"}},
network_mode="none",
mem_limit="2048m",
nano_cpus=int(1e9),
stdin_open=True,
tty=False,
)
# scrivi input su stdin via attach socket
sock = container.attach_socket(params={"stdin": 1, "stream": 1})
try:
sock._sock.sendall(payload + b"\n")
except Exception:
pass
try:
sock.close()
except Exception:
pass
loop = asyncio.get_event_loop()
# stats peak
peak_cpu = 0.0
peak_mem = 0.0
stop = False
def _stats():
nonlocal peak_cpu, peak_mem, stop
for st in container.stats(stream=True, decode=True):
if stop:
return
try:
cpu_delta = st["cpu_stats"]["cpu_usage"]["total_usage"] - st["precpu_stats"]["cpu_usage"]["total_usage"]
sys_delta = st["cpu_stats"].get("system_cpu_usage", 0) - st["precpu_stats"].get("system_cpu_usage", 0)
online = st["cpu_stats"].get("online_cpus") or 1
cpu_pct = (cpu_delta / sys_delta) * online * 100 if sys_delta > 0 else 0
mem_mb = (st["memory_stats"].get("usage") or 0) / (1024 * 1024)
peak_cpu = max(peak_cpu, cpu_pct)
peak_mem = max(peak_mem, mem_mb)
except Exception:
pass
stats_fut = loop.run_in_executor(None, _stats)
exit_info = await loop.run_in_executor(None, container.wait)
stop = True
logs = container.logs(stdout=True, stderr=False).decode("utf-8", errors="replace")
try:
container.remove(force=True)
except Exception:
pass
outputs: dict = {}
for line in logs.strip().splitlines():
line = line.strip()
if line.startswith("{") and line.endswith("}"):
try:
obj = json.loads(line)
if "outputs" in obj:
outputs = obj["outputs"]
break
except json.JSONDecodeError:
continue
return {
"outputs": outputs,
"exit_code": exit_info.get("StatusCode"),
"cpu_peak": round(peak_cpu, 2),
"mem_peak_mb": round(peak_mem, 2),
"raw_log": logs[-2000:],
}
finally:
shutil.rmtree(workdir, ignore_errors=True)

57
ml/core/gitea.py Normal file
View File

@@ -0,0 +1,57 @@
"""Client Gitea: browse repo, branches, commits, file raw, clone URL autenticato."""
from __future__ import annotations
from typing import Optional
import httpx
from core.config import settings
def _headers() -> dict:
h = {"Accept": "application/json"}
if settings.gitea_token:
h["Authorization"] = f"token {settings.gitea_token}"
return h
def clone_url(owner_repo: str) -> str:
"""URL https://oauth2:TOKEN@<host>/owner/repo.git — usato SOLO lato server."""
if not settings.gitea_url:
raise RuntimeError("GITEA_URL not configured")
base = settings.gitea_url.rstrip("/")
if settings.gitea_token:
base = base.replace("https://", f"https://oauth2:{settings.gitea_token}@").replace(
"http://", f"http://oauth2:{settings.gitea_token}@"
)
return f"{base}/{owner_repo}.git"
async def _get(path: str, params: Optional[dict] = None) -> list | dict:
url = f"{settings.gitea_url.rstrip('/')}/api/v1{path}"
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(url, params=params, headers=_headers())
r.raise_for_status()
return r.json()
async def list_repos(limit: int = 50) -> list[dict]:
data = await _get("/repos/search", params={"limit": str(limit)})
return data.get("data", []) if isinstance(data, dict) else []
async def list_branches(owner_repo: str) -> list[dict]:
return await _get(f"/repos/{owner_repo}/branches")
async def list_commits(owner_repo: str, branch: str = "main", limit: int = 50) -> list[dict]:
return await _get(f"/repos/{owner_repo}/commits", params={"sha": branch, "limit": str(limit)})
async def get_file_raw(owner_repo: str, ref: str, path: str) -> bytes:
"""Scarica il file raw alla revisione indicata."""
url = f"{settings.gitea_url.rstrip('/')}/api/v1/repos/{owner_repo}/raw/{path}"
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(url, params={"ref": ref}, headers=_headers())
r.raise_for_status()
return r.content

75
ml/core/influx_client.py Normal file
View File

@@ -0,0 +1,75 @@
"""Client InfluxDB (influxdb-client sync wrapper in thread-pool per async).
Le scritture usano il batching async dell'SDK invece di SYNCHRONOUS.
Le metriche di training arrivano in burst (logs container, stats loop ogni 5s):
con SYNCHRONOUS ogni write era una HTTP request bloccante. Con WriteOptions
batched, l'SDK accumula i Point e fa flush periodico in background, senza
perdere durabilità (flush forzato a fine training).
"""
from __future__ import annotations
import asyncio
from typing import Iterable, Optional
from influxdb_client import InfluxDBClient, Point, WriteOptions
from core.config import settings
_client: Optional[InfluxDBClient] = None
_write_api = None
def client() -> InfluxDBClient:
global _client, _write_api
if _client is None:
_client = InfluxDBClient(
url=settings.influx_url, token=settings.influx_token, org=settings.influx_org
)
_write_api = _client.write_api(write_options=WriteOptions(
batch_size=200,
flush_interval=2_000,
jitter_interval=200,
retry_interval=2_000,
max_retries=3,
))
return _client
def _wa():
client()
return _write_api
async def write_points(points: Iterable[Point]) -> None:
wa = _wa()
pts = list(points)
await asyncio.to_thread(wa.write, settings.influx_bucket, settings.influx_org, pts)
async def flush() -> None:
"""Forza il flush del buffer batched. Da chiamare a fine training per
garantire che tutte le metriche raccolte siano persistite."""
if _write_api is None:
return
try:
await asyncio.to_thread(_write_api.flush)
except Exception:
pass
async def query_flux(flux: str) -> list[dict]:
c = client()
def _q():
tables = c.query_api().query(flux, org=settings.influx_org)
out = []
for table in tables:
for r in table.records:
out.append({
"time": r.get_time().isoformat() if r.get_time() else None,
"measurement": r.get_measurement(),
"field": r.get_field(),
"value": r.get_value(),
"tags": {k: v for k, v in r.values.items() if k.startswith("_") is False and k not in ("result", "table")},
})
return out
return await asyncio.to_thread(_q)

118
ml/core/minio_client.py Normal file
View File

@@ -0,0 +1,118 @@
"""Wrapper MinIO: bucket unico (settings.minio_bucket) con prefissi logici.
Prefissi usati:
datasets/<uuid>.<ext>
models/<model_id>/spec.yml
models/<model_id>/<version>/<patch>/... (artefatti training)
trainings/<training_id>/logs.jsonl
"""
from __future__ import annotations
import io
from datetime import timedelta
from typing import Iterable, Optional
from minio import Minio
from minio.error import S3Error
from core.config import settings
_client: Optional[Minio] = None
def client() -> Minio:
global _client
if _client is None:
_client = Minio(
f"{settings.minio_endpoint}:{settings.minio_port}",
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
)
return _client
def _bucket(b: Optional[str] = None) -> str:
return b or settings.minio_bucket
def ensure_bucket(bucket: Optional[str] = None) -> None:
name = _bucket(bucket)
c = client()
if not c.bucket_exists(name):
c.make_bucket(name)
def put_bytes(key: str, data: bytes, content_type: str = "application/octet-stream",
bucket: Optional[str] = None) -> None:
ensure_bucket(bucket)
client().put_object(
_bucket(bucket),
key,
io.BytesIO(data),
length=len(data),
content_type=content_type,
)
def put_stream(key: str, stream, length: int, content_type: str = "application/octet-stream",
bucket: Optional[str] = None) -> None:
ensure_bucket(bucket)
client().put_object(
_bucket(bucket), key, stream, length=length, content_type=content_type
)
def get_bytes(key: str, bucket: Optional[str] = None) -> bytes:
r = client().get_object(_bucket(bucket), key)
try:
return r.read()
finally:
r.close()
r.release_conn()
def remove(key: str, bucket: Optional[str] = None) -> None:
try:
client().remove_object(_bucket(bucket), key)
except S3Error:
pass
def remove_prefix(prefix: str, bucket: Optional[str] = None) -> int:
name = _bucket(bucket)
n = 0
for obj in client().list_objects(name, prefix=prefix, recursive=True):
try:
client().remove_object(name, obj.object_name)
n += 1
except S3Error:
pass
return n
def presigned_get(key: str, expires_seconds: int = 3600, bucket: Optional[str] = None) -> str:
return client().presigned_get_object(
_bucket(bucket), key, expires=timedelta(seconds=expires_seconds)
)
def list_prefix(prefix: str, bucket: Optional[str] = None) -> list[dict]:
out = []
for obj in client().list_objects(_bucket(bucket), prefix=prefix, recursive=True):
out.append({
"name": obj.object_name,
"size": obj.size,
"last_modified": obj.last_modified.isoformat() if obj.last_modified else None,
"etag": obj.etag,
})
return out
def check() -> bool:
try:
client().list_buckets()
return True
except Exception:
return False

90
ml/core/model_spec.py Normal file
View File

@@ -0,0 +1,90 @@
"""Parse e validazione del contratto `model.yml` nelle repo utente.
Schema sintetico (vedi piano):
name, type, version, python
train: {entrypoint, inputs, outputs, metrics}
test: {entrypoint, io, input_schema[], output_schema[]}
resources: {cpu, mem_mb, gpu}
"""
from __future__ import annotations
from typing import Any, Optional
import yaml
from pydantic import BaseModel, ValidationError
from core import gitea, redis_client
class _FieldSpec(BaseModel):
name: str
dtype: str
min: Optional[float] = None
max: Optional[float] = None
unit: Optional[str] = None
class _Train(BaseModel):
entrypoint: str
inputs: dict = {}
outputs: dict = {}
metrics: dict = {}
class _Test(BaseModel):
entrypoint: str
io: str = "stdio_json"
input_schema: list[_FieldSpec] = []
output_schema: list[_FieldSpec] = []
class ModelSpec(BaseModel):
name: str
type: str
version: str = "0.1.0"
python: str = "3.11"
train: _Train
test: Optional[_Test] = None
resources: dict = {}
def parse_yaml(content: bytes | str) -> dict:
"""Parsa stringa YAML → dict validato. Solleva ValueError su errore."""
if isinstance(content, bytes):
content = content.decode("utf-8")
try:
raw = yaml.safe_load(content) or {}
spec = ModelSpec(**raw)
return spec.model_dump()
except (yaml.YAMLError, ValidationError) as e:
raise ValueError(f"invalid model.yml: {e}") from e
async def fetch_and_parse_spec(owner_repo: str, ref: str) -> Optional[dict]:
"""Recupera model.yml dalla repo alla revisione e lo parsa.
Cache Redis `ml:modelspec:{repo}:{ref}` TTL 1h.
"""
cache_key = f"ml:modelspec:{owner_repo}:{ref}"
try:
cached = await redis_client.client().get(cache_key)
if cached:
import json
return json.loads(cached)
except Exception:
pass
try:
raw = await gitea.get_file_raw(owner_repo, ref, "model.yml")
except Exception:
try:
raw = await gitea.get_file_raw(owner_repo, ref, "model.yaml")
except Exception:
return None
spec = parse_yaml(raw)
try:
import json
await redis_client.client().set(cache_key, json.dumps(spec), ex=3600)
except Exception:
pass
return spec

29
ml/core/redis_client.py Normal file
View File

@@ -0,0 +1,29 @@
"""Client Redis asincrono (redis-py asyncio). Singleton semplice."""
from __future__ import annotations
from typing import Optional
import redis.asyncio as redis
from core.config import settings
_client: Optional[redis.Redis] = None
def client() -> redis.Redis:
global _client
if _client is None:
_client = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
decode_responses=True,
health_check_interval=30,
)
return _client
async def close() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None

54
ml/core/worker.py Normal file
View File

@@ -0,0 +1,54 @@
"""Worker loop: BRPOP da ml:queue:train e dispatch al docker_runner.
Parte N task asincroni concorrenti (settings.train_concurrency).
"""
from __future__ import annotations
import asyncio
import logging
from core import redis_client
from core.config import settings
from core.docker_runner import run_training_job
log = logging.getLogger(__name__)
_tasks: list[asyncio.Task] = []
async def _worker_loop(idx: int):
r = redis_client.client()
log.info("ml worker[%d] started", idx)
while True:
try:
res = await r.brpop("ml:queue:train", timeout=10)
except Exception as e:
log.warning("brpop error: %s", e)
await asyncio.sleep(2)
continue
if res is None:
continue
_, training_id = res
log.info("worker[%d] picked training %s", idx, training_id)
try:
await run_training_job(training_id)
except Exception:
log.exception("worker[%d] training %s crashed", idx, training_id)
def start_workers() -> None:
global _tasks
n = max(1, settings.train_concurrency)
for i in range(n):
_tasks.append(asyncio.create_task(_worker_loop(i)))
async def stop_workers() -> None:
for t in _tasks:
t.cancel()
for t in _tasks:
try:
await t
except Exception:
pass
_tasks.clear()

View File

@@ -1,19 +1,90 @@
from fastapi import FastAPI, Request, Response, Header """ml-service — FastAPI entrypoint.
from fastapi.responses import HTMLResponse, JSONResponse
import time Monta:
/ → RedirectResponse
/datasets /models /train /test /results → pagine Jinja
/api/datasets /api/models /api/repos /api/trainings /api/tests /api/results → JSON
/api/trainings/{id}/events → SSE
/health → check
/static/* → file statici
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from core import db, minio_client, redis_client, worker
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger(__name__)
STATIC_DIR = Path(__file__).resolve().parent / "static"
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("ml-service starting")
await db.init_pool()
try:
minio_client.ensure_bucket()
except Exception as e:
log.warning("minio bucket ensure failed: %s", e)
worker.start_workers()
yield
log.info("ml-service stopping")
await worker.stop_workers()
await db.close_pool()
await redis_client.close()
app = FastAPI(title="MEB ML Service", lifespan=lifespan)
# static
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app = FastAPI()
@app.get("/health") @app.get("/health")
def health(): async def health():
pg_ok = True
try:
await db.fetchrow("SELECT 1")
except Exception:
pg_ok = False
redis_ok = True
try:
await redis_client.client().ping()
except Exception:
redis_ok = False
return { return {
"status": "ok", "status": "ok" if (pg_ok and redis_ok) else "degraded",
"service": "ml", "service": "ml",
"version": "1.0.0", "postgres": "connected" if pg_ok else "disconnected",
"build_number": "1", "redis": "connected" if redis_ok else "disconnected",
"version_state": "dev" "minio": "connected" if minio_client.check() else "disconnected",
"version": "2.0.0",
} }
@app.get("/")
def root(): from routers import ( # noqa: E402
return {"message": "ML Service"} datasets,
models,
pages,
repos,
results,
tests,
trainings,
trainings_stream,
)
app.include_router(pages.router)
app.include_router(datasets.router)
app.include_router(models.router)
app.include_router(repos.router)
app.include_router(trainings.router)
app.include_router(trainings_stream.router)
app.include_router(tests.router)
app.include_router(results.router)

View File

@@ -1,2 +1,15 @@
fastapi fastapi
uvicorn uvicorn[standard]
PyJWT
asyncpg
redis>=5
minio
influxdb-client
docker
PyYAML
pydantic>=2
python-multipart
jinja2
aiofiles
httpx
sse-starlette

160
ml/routers/datasets.py Normal file
View File

@@ -0,0 +1,160 @@
"""API datasets (ml.mebboat.it/api/datasets).
Upload/list/get/download/delete. Storage:
MinIO bucket "ml" con key "datasets/<uuid>.<ext>"
Postgres db "ml" tabella "datasets"
"""
from __future__ import annotations
import json
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from core import db, minio_client
from core.auth import require_auth
router = APIRouter(prefix="/api/datasets", tags=["datasets"])
# Bucket MinIO fisso per tutti i dataset (no prefix nelle key).
BUCKET = "ml.datasets"
_EXT = {"csv": "csv", "json": "json", "netcdf": "nc"}
def _row(r) -> dict:
if r is None:
return None
d = dict(r)
# asyncpg ritorna JSONB come dict già; date/time come datetime
for k in ("created_at", "updated_at", "start_date", "end_date"):
if d.get(k) is not None and hasattr(d[k], "isoformat"):
d[k] = d[k].isoformat()
return d
@router.get("")
async def list_datasets(
type: Optional[str] = Query(None),
tags: Optional[str] = Query(None),
mine: Optional[int] = Query(None),
search: Optional[str] = Query(None),
user=Depends(require_auth),
):
where = []
args: list = []
if type:
args.append(type)
where.append(f"type = ${len(args)}")
if tags:
tag_arr = [t.strip() for t in tags.split(",") if t.strip()]
if tag_arr:
args.append(tag_arr)
where.append(f"tags && ${len(args)}")
if mine and user.get("username"):
args.append(user["username"])
where.append(f"created_by = ${len(args)}")
if search:
args.append(f"%{search}%")
where.append(f"(nome ILIKE ${len(args)} OR description ILIKE ${len(args)})")
sql = "SELECT * FROM datasets"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY created_at DESC LIMIT 500"
rows = await db.fetch(sql, *args)
return {"count": len(rows), "datasets": [_row(r) for r in rows]}
@router.post("", status_code=201)
async def upload_dataset(
file: UploadFile = File(...),
metadata: str = Form("{}"),
user=Depends(require_auth),
):
try:
meta = json.loads(metadata or "{}")
except json.JSONDecodeError:
raise HTTPException(400, "metadata must be valid JSON")
fmt = meta.get("format") or meta.get("type") or "csv"
if fmt not in ("csv", "json", "netcdf"):
fmt = "csv"
ext = _EXT[fmt]
ds_id = str(uuid.uuid4())
file_key = f"{ds_id}.{ext}"
data = await file.read()
minio_client.put_bytes(file_key, data, content_type=file.content_type or "application/octet-stream", bucket=BUCKET)
created_by = user.get("username") or meta.get("created_by") or "unknown"
row = await db.fetchrow(
"""
INSERT INTO datasets (
id, file_key, nome, description, tags, type, format, notes,
created_by, size_bytes, copernicus_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *
""",
uuid.UUID(ds_id),
file_key,
meta.get("nome") or file.filename or file_key,
meta.get("description"),
meta.get("tags") or [],
meta.get("dataset_type") or "custom",
fmt,
meta.get("notes"),
created_by,
len(data),
meta.get("copernicus_id") or meta.get("copernicus_dataset_id"),
)
return _row(row)
@router.get("/{dataset_id}")
async def get_dataset(dataset_id: str, user=Depends(require_auth)):
row = await db.fetchrow("SELECT * FROM datasets WHERE id = $1", uuid.UUID(dataset_id))
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.get("/{dataset_id}/download")
async def download_dataset(dataset_id: str, user=Depends(require_auth)):
row = await db.fetchrow("SELECT file_key FROM datasets WHERE id = $1", uuid.UUID(dataset_id))
if not row:
raise HTTPException(404, "not found")
url = minio_client.presigned_get(row["file_key"], 3600, bucket=BUCKET)
return {"url": url, "expires_in": 3600}
@router.patch("/{dataset_id}")
async def patch_dataset(dataset_id: str, body: dict, user=Depends(require_auth)):
allowed = {"nome", "description", "tags", "notes"}
sets = []
args: list = []
for k, v in body.items():
if k in allowed:
args.append(v)
sets.append(f"{k} = ${len(args)}")
if not sets:
raise HTTPException(400, "no fields to update")
# Trigger updated_at non presente nel DB: lo aggiorniamo manualmente.
sets.append("updated_at = NOW()")
args.append(uuid.UUID(dataset_id))
row = await db.fetchrow(
f"UPDATE datasets SET {', '.join(sets)} WHERE id = ${len(args)} RETURNING *",
*args,
)
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.delete("/{dataset_id}", status_code=204)
async def delete_dataset(dataset_id: str, user=Depends(require_auth)):
row = await db.fetchrow("SELECT file_key FROM datasets WHERE id = $1", uuid.UUID(dataset_id))
if not row:
raise HTTPException(404, "not found")
minio_client.remove(row["file_key"], bucket=BUCKET)
await db.execute("DELETE FROM datasets WHERE id = $1", uuid.UUID(dataset_id))
return None

131
ml/routers/models.py Normal file
View File

@@ -0,0 +1,131 @@
"""API /api/models — registro modelli (repo Gitea + metadata)."""
from __future__ import annotations
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from core import db
from core.auth import require_auth
from core.model_spec import fetch_and_parse_spec
router = APIRouter(prefix="/api/models", tags=["models"])
def _row(r) -> Optional[dict]:
if r is None:
return None
d = dict(r)
for k in ("created_at", "updated_at"):
if d.get(k) is not None and hasattr(d[k], "isoformat"):
d[k] = d[k].isoformat()
return d
@router.get("")
async def list_models(user=Depends(require_auth)):
rows = await db.fetch("SELECT * FROM models ORDER BY created_at DESC LIMIT 500")
return {"count": len(rows), "models": [_row(r) for r in rows]}
@router.post("", status_code=201)
async def create_model(body: dict, user=Depends(require_auth)):
required = ("name", "type", "gitea_repo")
for k in required:
if not body.get(k):
raise HTTPException(400, f"missing field: {k}")
# prova a pre-caricare model.yml dal default branch (non fatale)
spec = None
try:
spec = await fetch_and_parse_spec(body["gitea_repo"], body.get("default_branch") or "main")
except Exception:
spec = None
row = await db.fetchrow(
"""
INSERT INTO models (name, type, gitea_repo, default_branch, spec, created_by)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING *
""",
body["name"],
body["type"],
body["gitea_repo"],
body.get("default_branch") or "main",
spec,
user.get("username") or "unknown",
)
return _row(row)
@router.get("/{model_id}")
async def get_model(model_id: str, user=Depends(require_auth)):
row = await db.fetchrow("SELECT * FROM models WHERE id = $1", uuid.UUID(model_id))
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.patch("/{model_id}")
async def patch_model(model_id: str, body: dict, user=Depends(require_auth)):
allowed = {"name", "type", "default_branch"}
sets = []
args: list = []
for k, v in body.items():
if k in allowed:
args.append(v)
sets.append(f"{k} = ${len(args)}")
if not sets:
raise HTTPException(400, "no fields to update")
args.append(uuid.UUID(model_id))
row = await db.fetchrow(
f"UPDATE models SET {', '.join(sets)} WHERE id = ${len(args)} RETURNING *",
*args,
)
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.delete("/{model_id}", status_code=204)
async def delete_model(model_id: str, user=Depends(require_auth)):
await db.execute("DELETE FROM models WHERE id = $1", uuid.UUID(model_id))
return None
# ── Notes ──────────────────────────────────────────────────────────────────
@router.get("/{model_id}/notes")
async def list_notes(model_id: str, user=Depends(require_auth)):
rows = await db.fetch(
"SELECT id, author, text, created_at FROM model_notes WHERE model_id = $1 ORDER BY created_at DESC",
uuid.UUID(model_id),
)
return [
{
"id": str(r["id"]),
"author": r["author"],
"text": r["text"],
"created_at": r["created_at"].isoformat(),
}
for r in rows
]
@router.post("/{model_id}/notes", status_code=201)
async def add_note(model_id: str, body: dict, user=Depends(require_auth)):
text = (body.get("text") or "").strip()
if not text:
raise HTTPException(400, "text required")
row = await db.fetchrow(
"INSERT INTO model_notes (model_id, author, text) VALUES ($1, $2, $3) RETURNING *",
uuid.UUID(model_id),
user.get("username") or "unknown",
text,
)
return {
"id": str(row["id"]),
"author": row["author"],
"text": row["text"],
"created_at": row["created_at"].isoformat(),
}

75
ml/routers/pages.py Normal file
View File

@@ -0,0 +1,75 @@
"""Pagine HTML servite direttamente da ml.mebboat.it.
Layout:
/ redirect a /datasets (o landing console)
/datasets lista/upload dataset
/models registro modelli
/train avvia training
/test esegue test su modello trainato
/results storico e confronto risultati
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from core.auth import _verify
from core.config import settings
router = APIRouter(tags=["pages"])
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
def _user_or_redirect(request: Request):
"""Per le pagine, se non autenticato redirect al login. Ritorna user dict o RedirectResponse."""
token = request.cookies.get("auth_token")
auth = request.headers.get("authorization")
if not token and auth and auth.startswith("Bearer "):
token = auth[7:]
user = _verify(token)
if not user:
target = str(request.url)
return RedirectResponse(url=f"{settings.auth_login_url}?redirect={target}", status_code=302)
return user
def _render(request: Request, template: str, **ctx):
user = _user_or_redirect(request)
if isinstance(user, RedirectResponse):
return user
return templates.TemplateResponse(template, {"request": request, "user": user, **ctx})
@router.get("/", response_class=HTMLResponse)
async def home(request: Request):
return RedirectResponse(url="/datasets")
@router.get("/datasets", response_class=HTMLResponse)
async def page_datasets(request: Request):
return _render(request, "datasets.html", page="datasets")
@router.get("/models", response_class=HTMLResponse)
async def page_models(request: Request):
return _render(request, "models.html", page="models")
@router.get("/train", response_class=HTMLResponse)
async def page_train(request: Request):
return _render(request, "train.html", page="train")
@router.get("/test", response_class=HTMLResponse)
async def page_test(request: Request):
return _render(request, "test.html", page="test")
@router.get("/results", response_class=HTMLResponse)
async def page_results(request: Request):
return _render(request, "results.html", page="results")

51
ml/routers/repos.py Normal file
View File

@@ -0,0 +1,51 @@
"""API /api/repos — proxy autenticato verso Gitea."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from core import gitea
from core.auth import require_auth
from core.model_spec import fetch_and_parse_spec
router = APIRouter(prefix="/api/repos", tags=["repos"])
@router.get("")
async def list_repos(user=Depends(require_auth)):
try:
return await gitea.list_repos()
except Exception as e:
raise HTTPException(502, f"gitea: {e}")
@router.get("/{owner}/{repo}/branches")
async def branches(owner: str, repo: str, user=Depends(require_auth)):
try:
return await gitea.list_branches(f"{owner}/{repo}")
except Exception as e:
raise HTTPException(502, f"gitea: {e}")
@router.get("/{owner}/{repo}/commits")
async def commits(owner: str, repo: str, branch: str = Query("main"), user=Depends(require_auth)):
try:
return await gitea.list_commits(f"{owner}/{repo}", branch)
except Exception as e:
raise HTTPException(502, f"gitea: {e}")
@router.get("/{owner}/{repo}/file")
async def file_raw(owner: str, repo: str, ref: str, path: str, user=Depends(require_auth)):
try:
raw = await gitea.get_file_raw(f"{owner}/{repo}", ref, path)
return {"content": raw.decode("utf-8", errors="replace"), "size": len(raw)}
except Exception as e:
raise HTTPException(404, f"file not found: {e}")
@router.get("/{owner}/{repo}/spec")
async def spec(owner: str, repo: str, ref: str = Query("main"), user=Depends(require_auth)):
s = await fetch_and_parse_spec(f"{owner}/{repo}", ref)
if s is None:
raise HTTPException(404, "model.yml not found at ref")
return s

89
ml/routers/results.py Normal file
View File

@@ -0,0 +1,89 @@
"""API /api/results — lista trainings/tests + compare multi-training."""
from __future__ import annotations
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from core import db, influx_client
from core.auth import require_auth
from core.config import settings
router = APIRouter(prefix="/api/results", tags=["results"])
def _row(r):
if r is None:
return None
d = dict(r)
for k in ("queued_at", "started_at", "finished_at", "started_at", "ended_at"):
if d.get(k) is not None and hasattr(d[k], "isoformat"):
d[k] = d[k].isoformat()
return d
@router.get("")
async def list_results(
model_id: Optional[str] = Query(None),
user=Depends(require_auth),
):
where = []
args: list = []
if model_id:
args.append(uuid.UUID(model_id))
where.append(f"model_id = ${len(args)}")
sql = "SELECT * FROM trainings"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY finished_at DESC NULLS LAST, queued_at DESC LIMIT 200"
rows = await db.fetch(sql, *args)
return {"count": len(rows), "trainings": [_row(r) for r in rows]}
@router.get("/{training_id}")
async def get_result(training_id: str, user=Depends(require_auth)):
row = await db.fetchrow("SELECT * FROM trainings WHERE id = $1", uuid.UUID(training_id))
if not row:
raise HTTPException(404, "not found")
# timeseries via Influx: loss per iter + cpu/mem
flux = (
f'from(bucket:"{settings.influx_bucket}") '
f'|> range(start:-90d) '
f'|> filter(fn: (r) => r._measurement == "ml_training" and r.training_id == "{training_id}")'
)
try:
ts = await influx_client.query_flux(flux)
except Exception:
ts = []
return {"training": _row(row), "timeseries": ts}
@router.get("/compare")
async def compare(
trainings: str = Query(..., description="comma-separated training IDs"),
user=Depends(require_auth),
):
ids = [s.strip() for s in trainings.split(",") if s.strip()]
if len(ids) < 2:
raise HTTPException(400, "at least 2 training IDs required")
out = []
for tid in ids:
try:
tid_uuid = uuid.UUID(tid)
except ValueError:
continue
row = await db.fetchrow("SELECT * FROM trainings WHERE id = $1", tid_uuid)
if not row:
continue
flux = (
f'from(bucket:"{settings.influx_bucket}") '
f'|> range(start:-90d) '
f'|> filter(fn: (r) => r._measurement == "ml_training" and r.training_id == "{tid}")'
)
try:
ts = await influx_client.query_flux(flux)
except Exception:
ts = []
out.append({"training": _row(row), "timeseries": ts})
return {"results": out}

109
ml/routers/tests.py Normal file
View File

@@ -0,0 +1,109 @@
"""API /api/tests — sessioni di test su training esistente (max 2 utenti simultanei)."""
from __future__ import annotations
import json
import time
import uuid
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from core import api_client, db, minio_client
from core.auth import require_auth
from core.docker_runner import run_test_once
router = APIRouter(prefix="/api/tests", tags=["tests"])
def _row(r):
if r is None:
return None
d = dict(r)
for k in ("started_at", "ended_at"):
if d.get(k) is not None and hasattr(d[k], "isoformat"):
d[k] = d[k].isoformat()
return d
@router.post("/sessions", status_code=201)
async def start_session(body: dict, user=Depends(require_auth)):
training_id = body.get("training_id")
if not training_id:
raise HTTPException(400, "training_id required")
tr = await db.fetchrow(
"SELECT id, status FROM trainings WHERE id = $1", uuid.UUID(training_id)
)
if not tr:
raise HTTPException(404, "training not found")
if tr["status"] != "succeeded":
raise HTTPException(409, "training not completed")
sid = str(uuid.uuid4())
try:
await api_client.page_connect("test", user.get("username") or "unknown", sid)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(429, "test slots full (max 2 users)")
raise HTTPException(502, f"api: {e}")
row = await db.fetchrow(
"INSERT INTO tests (id, training_id, user_id) VALUES ($1,$2,$3) RETURNING *",
uuid.UUID(sid),
uuid.UUID(training_id),
user.get("username") or "unknown",
)
return _row(row)
@router.post("/sessions/{session_id}/ping")
async def ping_session(session_id: str, user=Depends(require_auth)):
try:
await api_client.page_ping(session_id)
except httpx.HTTPStatusError as e:
raise HTTPException(e.response.status_code, e.response.text)
return {"ok": True}
@router.post("/sessions/{session_id}/runs", status_code=201)
async def run_test(session_id: str, body: dict, user=Depends(require_auth)):
row = await db.fetchrow("SELECT * FROM tests WHERE id = $1", uuid.UUID(session_id))
if not row:
raise HTTPException(404, "session not found")
inputs = body.get("inputs") or {}
t0 = time.monotonic()
try:
result = await run_test_once(str(row["training_id"]), inputs)
except Exception as e:
raise HTTPException(500, f"test run failed: {e}")
dt_ms = int((time.monotonic() - t0) * 1000)
run = {
"inputs": inputs,
"outputs": result.get("outputs", {}),
"duration_ms": dt_ms,
"cpu_peak": result.get("cpu_peak"),
"mem_peak_mb": result.get("mem_peak_mb"),
"ts": time.time(),
}
await db.execute(
"UPDATE tests SET runs = runs || $1::jsonb WHERE id = $2",
json.dumps([run]),
uuid.UUID(session_id),
)
return run
@router.delete("/sessions/{session_id}", status_code=204)
async def end_session(session_id: str, user=Depends(require_auth)):
await db.execute(
"UPDATE tests SET ended_at = NOW() WHERE id = $1 AND ended_at IS NULL",
uuid.UUID(session_id),
)
try:
await api_client.page_disconnect(session_id)
except Exception:
pass
return None

129
ml/routers/trainings.py Normal file
View File

@@ -0,0 +1,129 @@
"""API /api/trainings — enqueue, list, get, artifacts."""
from __future__ import annotations
import json
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from core import db, minio_client, redis_client, api_client
from core.auth import require_auth
router = APIRouter(prefix="/api/trainings", tags=["trainings"])
def _row(r) -> Optional[dict]:
if r is None:
return None
d = dict(r)
for k in ("queued_at", "started_at", "finished_at"):
if d.get(k) is not None and hasattr(d[k], "isoformat"):
d[k] = d[k].isoformat()
return d
@router.get("")
async def list_trainings(
model_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
limit: int = Query(100, le=500),
user=Depends(require_auth),
):
where = []
args: list = []
if model_id:
args.append(uuid.UUID(model_id))
where.append(f"model_id = ${len(args)}")
if status:
args.append(status)
where.append(f"status = ${len(args)}")
sql = "SELECT * FROM trainings"
if where:
sql += " WHERE " + " AND ".join(where)
args.append(limit)
sql += f" ORDER BY queued_at DESC LIMIT ${len(args)}"
rows = await db.fetch(sql, *args)
return {"count": len(rows), "trainings": [_row(r) for r in rows]}
@router.post("", status_code=202)
async def enqueue_training(body: dict, user=Depends(require_auth)):
for k in ("model_id", "version", "patch", "dataset_id"):
if not body.get(k):
raise HTTPException(400, f"missing field: {k}")
model_row = await db.fetchrow("SELECT * FROM models WHERE id = $1", uuid.UUID(body["model_id"]))
if not model_row:
raise HTTPException(404, "model not found")
ds_row = await db.fetchrow("SELECT id FROM datasets WHERE id = $1", uuid.UUID(body["dataset_id"]))
if not ds_row:
raise HTTPException(404, "dataset not found")
try:
training_row = await db.fetchrow(
"""
INSERT INTO trainings (model_id, version, patch, dataset_id, started_by, status)
VALUES ($1,$2,$3,$4,$5,'queued')
RETURNING *
""",
uuid.UUID(body["model_id"]),
body["version"],
body["patch"],
uuid.UUID(body["dataset_id"]),
user.get("username") or "unknown",
)
except Exception as e:
raise HTTPException(409, f"training already exists or invalid: {e}")
training_id = str(training_row["id"])
# crea job lato api-service (cross-service registry)
try:
await api_client.create_job(
"train",
created_by=user.get("username") or "unknown",
payload={
"training_id": training_id,
"model_id": body["model_id"],
"version": body["version"],
"patch": body["patch"],
"dataset_id": body["dataset_id"],
},
)
except Exception as e:
# non-fatale: il worker locale può comunque procedere; logghiamo e continuiamo
import logging
logging.warning("create_job failed: %s", e)
# enqueue in Redis (il worker locale lo raccoglie)
await redis_client.client().lpush("ml:queue:train", training_id)
await redis_client.client().hset(
f"ml:train:{training_id}",
mapping={"status": "queued", "progress": "0", "message": "queued"},
)
await redis_client.client().expire(f"ml:train:{training_id}", 48 * 3600)
return _row(training_row)
@router.get("/{training_id}")
async def get_training(training_id: str, user=Depends(require_auth)):
row = await db.fetchrow("SELECT * FROM trainings WHERE id = $1", uuid.UUID(training_id))
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.get("/{training_id}/artifacts")
async def list_artifacts(training_id: str, user=Depends(require_auth)):
row = await db.fetchrow(
"SELECT artifacts_prefix FROM trainings WHERE id = $1", uuid.UUID(training_id)
)
if not row or not row["artifacts_prefix"]:
raise HTTPException(404, "no artifacts")
objs = minio_client.list_prefix(row["artifacts_prefix"] + "/")
for o in objs:
o["url"] = minio_client.presigned_get(o["name"], 3600)
return objs

View File

@@ -0,0 +1,64 @@
"""SSE endpoint per live progress del training.
GET /api/trainings/{id}/events
Streamma eventi dal Redis stream `ml:train:{id}:events` via Server-Sent Events.
Termina quando lo stato del training è terminale (succeeded/failed/cancelled).
"""
from __future__ import annotations
import asyncio
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sse_starlette.sse import EventSourceResponse
from core import db, redis_client
from core.auth import require_auth
router = APIRouter(prefix="/api/trainings", tags=["trainings-sse"])
_TERMINAL = {"succeeded", "failed", "cancelled"}
@router.get("/{training_id}/events")
async def training_events(training_id: str, user=Depends(require_auth)):
# verifica esistenza
row = await db.fetchrow("SELECT status FROM trainings WHERE id = $1", uuid.UUID(training_id))
if not row:
raise HTTPException(404, "not found")
stream_key = f"ml:train:{training_id}:events"
status_key = f"ml:train:{training_id}"
async def gen():
last_id = "0-0"
r = redis_client.client()
while True:
try:
# XREAD block 5s per non tenere la connessione idle troppo a lungo
resp = await r.xread({stream_key: last_id}, count=50, block=5000)
except Exception as e:
yield {"event": "error", "data": json.dumps({"error": str(e)})}
await asyncio.sleep(1)
continue
if resp:
for _stream, entries in resp:
for entry_id, fields in entries:
last_id = entry_id
yield {"event": "message", "id": entry_id, "data": json.dumps(fields)}
# controlla stato terminale
state = await r.hget(status_key, "status")
if not state:
# fallback su db se redis scaduto
db_row = await db.fetchrow(
"SELECT status FROM trainings WHERE id = $1", uuid.UUID(training_id)
)
state = db_row["status"] if db_row else "unknown"
if state in _TERMINAL:
yield {"event": "end", "data": json.dumps({"status": state})}
return
return EventSourceResponse(gen())

18
ml/runner/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir \
numpy pandas scikit-learn \
xgboost \
matplotlib \
pyyaml
COPY sdk.py /opt/meb/meb_ml.py
ENV PYTHONPATH=/opt/meb
WORKDIR /workdir
CMD ["bash"]

80
ml/runner/sdk.py Normal file
View File

@@ -0,0 +1,80 @@
"""meb_ml — SDK importabile dal codice utente dentro il container runner.
API:
from meb_ml import emit_metric, emit_series, emit_matrix, emit_log, save_artifact
emit_metric(iter=10, loss=0.23)
emit_series("roc_curve", x=fpr, y=tpr, kind="line")
emit_matrix("confusion", labels=[...], values=[[...],[...]])
emit_log("info", "epoch done")
Scrive righe JSON su stdout; il parent (ml-service) le inoltra su Redis/Influx.
Per risultati finali scrivere `out/metrics.json` con:
{"metrics": {...}, "plots": {"loss_curve": {"x": [...], "y": [...]}, ...}}
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any, Iterable, Sequence
def _print(obj: dict) -> None:
sys.stdout.write(json.dumps(obj, default=float) + "\n")
sys.stdout.flush()
def emit_metric(**fields: Any) -> None:
_print({"type": "metric", **fields})
def emit_series(name: str, x: Sequence, y: Sequence, kind: str = "line") -> None:
_print({
"type": "series",
"name": name,
"kind": kind,
"x": list(x),
"y": list(y),
})
def emit_matrix(name: str, labels: Sequence, values: Sequence[Sequence]) -> None:
_print({
"type": "matrix",
"name": name,
"labels": list(labels),
"values": [list(row) for row in values],
})
def emit_log(level: str, message: str) -> None:
_print({"type": "log", "level": level, "message": message})
def save_artifact(path: str) -> str:
"""Copia `path` nella cartella artefatti (MEB_ARTIFACTS_DIR). Ritorna la dest."""
dest_dir = Path(os.environ.get("MEB_ARTIFACTS_DIR", "/workdir/out"))
dest_dir.mkdir(parents=True, exist_ok=True)
src = Path(path)
dest = dest_dir / src.name
dest.write_bytes(src.read_bytes())
return str(dest)
def dataset_path() -> str:
return os.environ["MEB_DATASET_PATH"]
def artifacts_dir() -> str:
return os.environ.get("MEB_ARTIFACTS_DIR", "/workdir/out")
def read_test_input() -> dict:
"""Legge un singolo JSON da stdin (per script di test)."""
return json.loads(sys.stdin.readline())
def write_test_output(outputs: dict) -> None:
_print({"type": "result", "outputs": outputs})

146
ml/static/styles/ml.css Normal file
View File

@@ -0,0 +1,146 @@
.ml-nav {
display: flex;
gap: 16px;
align-items: center;
}
.ml-nav a {
text-decoration: none;
color: var(--text-secondary);
font-weight: 600;
padding: 8px 12px;
border-radius: var(--radius-md);
transition: all 0.2s ease;
}
.ml-nav a:hover { background: var(--accent-light); color: var(--accent-color); }
.ml-nav a.active { background: var(--accent-light); color: var(--accent-color); }
.container {
max-width: 1200px;
margin: 24px auto;
padding: 0 24px;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-head h2 { font-size: 1.5rem; }
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.list .item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border: 1px solid var(--header-border);
border-radius: var(--radius-lg);
background: #fff;
transition: box-shadow 0.12s ease;
}
.list .item:hover { box-shadow: var(--shadow-md); }
.list .meta { color: var(--text-secondary); font-size: 0.85rem; }
.form-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
margin-bottom: 20px;
}
.form-row label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.form-row input, .form-row select, .form-row textarea {
padding: 8px 12px;
border: 1px solid var(--header-border);
border-radius: var(--radius-md);
font-family: inherit;
}
.hidden { display: none !important; }
.queue-info {
font-size: 0.9rem;
color: var(--text-secondary);
padding: 6px 12px;
background: var(--accent-light);
border-radius: var(--radius-md);
}
.charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin: 16px 0;
}
.logs {
background: #0f172a;
color: #cbd5e1;
padding: 12px;
border-radius: var(--radius-md);
font-family: ui-monospace, monospace;
font-size: 0.8rem;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
}
.detail {
border: 1px solid var(--header-border);
border-radius: var(--radius-lg);
padding: 16px;
margin-top: 16px;
background: #fff;
position: relative;
}
.detail #btn-close-detail {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 10px;
}
dialog {
border: 1px solid var(--header-border);
border-radius: var(--radius-lg);
padding: 24px;
width: min(500px, 90vw);
}
dialog form { display: flex; flex-direction: column; gap: 12px; }
dialog label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; padding: 0; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
th, td { padding: 8px 12px; border-bottom: 1px solid var(--header-border); text-align: left; font-size: 0.9rem; }
code {
font-family: ui-monospace, monospace;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
}
pre {
background: #f8fafc;
padding: 12px;
border-radius: var(--radius-md);
overflow: auto;
font-family: ui-monospace, monospace;
font-size: 0.8rem;
}

Some files were not shown because too many files have changed in this diff Show More