3 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
97 changed files with 8193 additions and 968 deletions

2
.gitignore vendored
View File

@@ -19,3 +19,5 @@ Thumbs.db
.eslintcache .eslintcache
.venv/ .venv/
.claude/

193
api/package-lock.json generated
View File

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

View File

@@ -18,6 +18,7 @@
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"minio": "^8.0.7", "minio": "^8.0.7",
"multer": "^1.4.5-lts.1",
"pg": "^8.20.0" "pg": "^8.20.0"
} }
} }

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;
@@ -56,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);
@@ -99,6 +81,27 @@ app.use('/settings', settingsRoutes)
const sessionsRoutes = require('./routes/sessions') const sessionsRoutes = require('./routes/sessions')
app.use('/sessions', sessionsRoutes) app.use('/sessions', sessionsRoutes)
const docsRoutes = require('./routes/docs')
app.use('/docs', docsRoutes)
const marineDatasetsRoutes = require('./routes/marine.datasets')
app.use('/marine/datasets', marineDatasetsRoutes)
const jobsRoutes = require('./routes/jobs')
app.use('/jobs', jobsRoutes)
const queueRoutes = require('./routes/queue')
app.use('/queue', queueRoutes)
const pageconnectionsRoutes = require('./routes/pageconnections')
app.use('/pageconnections', pageconnectionsRoutes)
const kioskRoutes = require('./routes/kiosk')
app.use('/kiosk', kioskRoutes)
const rulesRoutes = require('./routes/rules')
app.use('/rules', rulesRoutes)
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Started on port ${PORT}`); console.log(`Started on port ${PORT}`);
}); });

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;

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;

View File

@@ -60,7 +60,9 @@ async function query(bucket, relativeTime, measurement, sensor, field) {
} }
const sessionBucket = 'boat'; // Sorgente di verità per i logs di sessione: stesso bucket usato da
// realtime/store/influx.js. Sovrascrivibile via env per ambiente.
const sessionBucket = process.env.INFLX_BUCKET_LOGS || process.env.INFLX_BUCKET || 'logs';
/** /**
* Query storica per una sessione di registrazione. * Query storica per una sessione di registrazione.

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

@@ -15,6 +15,11 @@ const pools = {
users: new Pool({ ...config, database: process.env.USERS_DB }), users: new Pool({ ...config, database: process.env.USERS_DB }),
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }), sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }),
rules: new Pool({ ...config, database: process.env.RULES_DB || 'rules' }), rules: new Pool({ ...config, database: process.env.RULES_DB || 'rules' }),
ml: new Pool({ ...config, database: process.env.ML_DB || 'ml' }),
references: new Pool({ ...config, database: process.env.REFERENCES_DB || 'references' }),
// Le tabelle kiosktemplates / kioskelements vivono nel DB `sensors`.
// KIOSK_DB resta override-abile per environment legacy.
kiosk: new Pool({ ...config, database: process.env.KIOSK_DB || 'sensors' }),
} }
Object.entries(pools).forEach(([name, pool]) => { Object.entries(pools).forEach(([name, pool]) => {
@@ -73,6 +78,10 @@ async function remove(table, condition, params, type = 'users') {
return await query(sql, params, type); return await query(sql, params, type);
} }
// initKioskSchema rimosso: lo schema kiosktemplates/kioskelements vive nel DB
// `sensors` ed e' gestito dalla migration 007_kiosktemplates.sql. L'auto-create
// qui creava un schema legacy divergente (UUID + JSONB content) sul DB sbagliato.
async function checkPostgres() { async function checkPostgres() {
const status = {}; const status = {};
console.log("Checking PostgreSQL connections with config:", config); console.log("Checking PostgreSQL connections with config:", config);

View File

@@ -1,101 +1,166 @@
const query = require('../storage/database').query;
const track = require('../tools/tracking');
const { v4: uuid } = require('uuid'); const { v4: uuid } = require('uuid');
const { query } = require('../storage/database');
const security = require('../tools/security'); 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 ──────────────────────────────────────────────────
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 = await security.hashPassword(password);
const id = uuid(); const id = uuid();
await query( await query(
'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', 'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)',
[id, username, hashedPassword] [id, username, hash]
); );
return { id, username };
return { success: true, user: { id, username } };
} }
// ─── LOGIN ──────────────────────────────────────────────────────────
async function login(username, password) { async function login(username, password) {
const result = await query( const { rows } = await query(
'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', 'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1',
[username] [username]
); );
if (!rows.length) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
if (result.rows.length === 0) { const user = rows[0];
throw new Error('No user matched'); if (!user.is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
const ok = await security.verifyPassword(password, user.password_hash);
if (!ok) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
return { id: user.id, username: user.username, created_at: user.created_at };
} }
const user = result.rows[0]; // ─── SESSIONI ───────────────────────────────────────────────────────
if (!user.is_active) { async function createSession(userId, userAgent, ip) {
throw new Error('User account is not active');
}
const isValid = await security.verifyPassword(password, user.password_hash);
if (!isValid) {
throw new Error('Password mismatch');
}
return {
id: user.id,
username: user.username,
created: user.created_at
};
}
async function logout(sessionID) {
if (!sessionID) {
throw new Error('No session ID provided');
}
const result = await query('UPDATE sessions SET is_revoked = TRUE WHERE id = $1', [sessionID]);
return result.rowCount > 0;
}
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 };
return { id, sessionCode };
} }
async function validateSession(sessionId) { async function validateSession(sessionId) {
if (!sessionId || typeof sessionId !== 'string') { if (!sessionId || typeof sessionId !== 'string') {
throw new Error('Invalid session ID'); throw new AuthError('INVALID_SESSION', 'Sessione non valida');
} }
const result = await query( const { rows } = await query(
'SELECT s.id, u.is_active FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = $1 AND s.is_revoked = FALSE', `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] [sessionId]
); );
if (result.rows.length === 0) { if (!rows.length) throw new AuthError('INVALID_SESSION', 'Sessione non trovata');
throw new Error('Session not found or revoked'); 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;
} }
if (!result.rows[0].is_active) { async function revokeSession(sessionId, userId) {
throw new Error('User account is not active'); if (userId) {
const r = await query(
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE',
[sessionId, userId]
);
return r.rowCount > 0;
} }
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]);
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

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

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,34 +1,37 @@
const jwt = require('../tools/jwt'); const jwt = require('../tools/jwt');
const { validateSession } = require('../core/auth.core'); const { validateSession } = require('../core/auth.core');
const userAuth = async (req, res, next) => { /**
const token = (req.cookies && req.cookies.auth_token) || jwt.getToken(req.headers['authorization']); * Middleware: richiede un utente autenticato valido.
* Legge il token dal cookie `auth_token` o dall'header `Authorization: Bearer <token>`.
*
* Se la richiesta accetta HTML → redirect a /login con redirect-back URL.
* Altrimenti → 401 JSON.
*/
module.exports = async function userAuth(req, res, next) {
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
const unauthorized = (reason) => { const unauthorized = (reason) => {
if (req.accepts('html')) { if (req.accepts('html') && !req.xhr) {
const redirect = encodeURIComponent(req.originalUrl); const r = encodeURIComponent(req.originalUrl);
return res.redirect(`/login?redirect=${redirect}`); return res.redirect(`/login?redirect=${r}`);
} }
return res.status(401).json({ error: reason || 'Non autorizzato' }); return res.status(401).json({ error: reason || 'unauthorized' });
}; };
if (!token || typeof token !== 'string' || token.length > 2048) { if (!token || typeof token !== 'string' || token.length > 2048) {
return unauthorized('Token mancante o non valido'); return unauthorized('missing_token');
} }
const verified = jwt.verifyToken(token); const v = jwt.verify(token);
if (!verified.valid) { if (!v.valid) return unauthorized(`token_${v.reason}`);
return unauthorized(`Sessione non valida (${verified.reason})`);
}
try { try {
await validateSession(verified.payload.session_id); await validateSession(v.payload.session_id);
} catch (err) { } catch (err) {
return unauthorized('Sessione non valida o revocata'); return unauthorized(err.code || 'session_invalid');
} }
req.user = verified.payload; req.user = v.payload;
next(); next();
}; };
module.exports = userAuth;

View File

@@ -4,146 +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';
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
const ERROR_RESPONSES = {
csrf: { success: false, error: 'csrf', message: 'Richiesta non valida, riprova' },
invalid_credentials: { success: false, error: 'invalid_credentials', message: 'Credenziali non valide' },
internal: { success: false, error: 'internal', message: 'Errore interno, riprova più tardi' }
};
/** /**
* Restituisce un redirect sicuro, scartando URL che puntano ad API * Opzioni cookie condivise per auth_token.
* o ad host diversi da CONSOLE_URL. * 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) { function resolveSafeRedirect(redirect) {
if (!redirect || typeof redirect !== 'string') return CONSOLE_URL; if (!redirect || typeof redirect !== 'string') return CONSOLE_URL;
try { try {
const redirectUrl = new URL(redirect); const target = new URL(redirect);
const consoleUrl = new URL(CONSOLE_URL); const console_ = new URL(CONSOLE_URL);
const sameHost = redirectUrl.hostname === consoleUrl.hostname; const sameHost = target.hostname === console_.hostname;
const notApi = !redirectUrl.pathname.startsWith('/api/'); const sameApex = COOKIE_DOMAIN
? target.hostname.endsWith(COOKIE_DOMAIN.replace(/^\./, ''))
: false;
const notApi = !target.pathname.startsWith('/api/');
if (sameHost && notApi) return redirect; if ((sameHost || sameApex) && notApi) return redirect;
} catch { } catch {
// URL non valido / relativo: fallback a CONSOLE_URL // URL invalido / relativo: fallback
} }
return CONSOLE_URL; 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 || typeof username !== 'string' || typeof password !== 'string') { if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ success: false, error: 'Username e password richiesti' }); return res.status(400).json({ success: false, error: 'username_and_password_required' });
} }
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' });
success: false,
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({
success: false,
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);
return res.status(201).json({ success: true }); return res.status(201).json({ success: true, user });
} catch (err) { } catch (err) {
if (err.message === 'User already exists') { if (err.code === 'USER_EXISTS') {
return res.status(409).json({ success: false, error: 'User already exists' }); return res.status(409).json({ success: false, error: 'user_exists' });
} }
console.error('[AUTH] Register failed:', err.message); console.error('[AUTH] register:', err.message);
return res.status(500).json({ success: false, error: 'Errore interno' }); return res.status(500).json({ success: false, error: 'internal' });
} }
}); });
router.post('/login', async (req, res) => { // ─── POST /login ───────────────────────────────────────────────────
const { username, password, redirect, _csrf } = req.body;
router.post('/login', async (req, res) => {
const { username, password, redirect, _csrf } = req.body || {};
// Verifica CSRF token
const csrfCookie = req.cookies && req.cookies._csrf; const csrfCookie = req.cookies && req.cookies._csrf;
if (!_csrf || !csrfCookie || _csrf !== csrfCookie) { if (!_csrf || !csrfCookie || _csrf !== csrfCookie) {
return res.status(400).json(ERROR_RESPONSES.csrf); return res.status(400).json({
success: false, error: 'csrf', message: 'Richiesta non valida, riprova'
});
} }
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { if (!username || !password || typeof username !== 'string' || typeof password !== 'string'
return res.status(400).json(ERROR_RESPONSES.invalid_credentials); || username.length > 50 || password.length > MAX_PASSWORD) {
} return res.status(400).json({
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
if (username.length > 50 || password.length > PASSWORD_MAX_LENGTH) { });
return res.status(400).json(ERROR_RESPONSES.invalid_credentials);
} }
const safeRedirect = resolveSafeRedirect(redirect); const safeRedirect = resolveSafeRedirect(redirect);
try { try {
const user = await auth.login(username, password); const user = await auth.login(username, password);
const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip); const session = await auth.createSession(user.id, req.headers['user-agent'], req.ip);
const token = jwt.generateToken(user, session.id); const token = jwt.sign(user, session.id);
const cookieOptions = { // Imposta il cookie auth_token (condiviso tra sottodomini se COOKIE_DOMAIN è impostato)
httpOnly: true, res.cookie('auth_token', token, authCookieOptions(true));
secure: process.env.NODE_ENV === 'production', // Rimuove il cookie CSRF
sameSite: 'lax', res.clearCookie('_csrf', { httpOnly: true, sameSite: 'strict', path: '/' });
maxAge: 7 * 24 * 60 * 60 * 1000
};
if (COOKIE_DOMAIN) cookieOptions.domain = COOKIE_DOMAIN;
res.cookie('auth_token', token, cookieOptions);
res.clearCookie('_csrf');
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
redirect_url: safeRedirect, redirect_url: safeRedirect,
message: 'Login effettuato con successo' message: 'Login effettuato'
}); });
} catch (err) { } catch (err) {
if (err.message === 'No user matched' || err.message === 'Password mismatch') { if (err.code === 'INVALID_CREDENTIALS') {
return res.status(401).json(ERROR_RESPONSES.invalid_credentials); return res.status(401).json({
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
});
} }
if (err.message === 'User account is not active') { if (err.code === 'ACCOUNT_INACTIVE') {
return res.status(403).json({ return res.status(403).json({
success: false, success: false, error: 'account_inactive', message: 'Account disattivato'
error: 'account_inactive',
message: 'Account disattivato'
}); });
} }
console.error('[AUTH] Login error:', err.message); console.error('[AUTH] login:', err.message);
return res.status(500).json(ERROR_RESPONSES.internal); return res.status(500).json({
success: false, error: 'internal', message: 'Errore interno'
});
} }
}); });
// ─── POST /logout ──────────────────────────────────────────────────
router.post('/logout', async (req, res) => { router.post('/logout', async (req, res) => {
const token = req.cookies && req.cookies.auth_token; const token = req.cookies && req.cookies.auth_token;
if (token) { if (token) {
const v = jwt.verify(token);
if (v.valid) {
try { try {
const verified = jwt.verifyToken(token); await auth.revokeSession(v.payload.session_id);
if (verified.valid) {
await auth.logout(verified.payload.session_id);
}
} catch (err) { } catch (err) {
console.error('[AUTH] Logout error:', err.message); console.error('[AUTH] logout revoke:', err.message);
}
} }
} }
const clearOptions = { httpOnly: true, sameSite: 'lax' }; res.clearCookie('auth_token', authCookieOptions(false));
if (COOKIE_DOMAIN) clearOptions.domain = COOKIE_DOMAIN;
res.clearCookie('auth_token', clearOptions); // 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' }); 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' });
}
return res.status(200).json({ valid: true, user: v.payload });
});
module.exports = router; module.exports = router;

View File

@@ -1,54 +1,36 @@
// 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' });
} }
}); });

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,25 +1,28 @@
const router = require('express').Router(); const router = require('express').Router();
const crypto = require('crypto'); const { csrfToken } = require('../../tools/security');
const ERROR_MESSAGES = { const ERROR_MESSAGES = {
invalid_credentials: 'Credenziali non valide', invalid_credentials: 'Credenziali non valide',
invalid_redirect: 'Redirect non autorizzato', csrf: 'Richiesta non valida, riprova',
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 : '';
const errorKey = req.query.error; const errorKey = req.query.error;
const error = ERROR_MESSAGES[errorKey] || null; const error = ERROR_MESSAGES[errorKey] || null;
const csrfToken = crypto.randomBytes(32).toString('hex'); const token = csrfToken();
res.cookie('_csrf', csrfToken, { res.cookie('_csrf', token, {
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',
secure: process.env.NODE_ENV === 'production' secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 30 * 60 * 1000
}); });
res.render('loginpage', { error, redirect, csrf_token: csrfToken }); res.render('loginpage', { error, redirect, csrf_token: token });
}); });
module.exports = router; module.exports = router;

View File

@@ -1,69 +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;
} }
return null; module.exports = { sign, verify, bearer };
}
module.exports = { generateToken, verifyToken, getToken };

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;
/**
* Genera un hash di una password
* @param {string} password - Password da hashare
* @returns {string} - Hash della password
*/
async function hashPassword(password) { async function hashPassword(password) {
return bcrypt.hash(password, saltRounds); return bcrypt.hash(password, SALT_ROUNDS);
} }
/**
* Verifica una password
* @param {string} password - Password da verificare
* @param {string} hash - Hash della password
* @returns {boolean} - True se la password è corretta, false altrimenti
*/
async function verifyPassword(password, hash) { async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash); return bcrypt.compare(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 = { hashPassword, verifyPassword, sessionCode, csrfToken };
}
module.exports = {
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

@@ -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', {
@@ -96,6 +66,30 @@ app.get('/sessions', renderPage('sessions', {
mapboxToken: process.env.MAPBOX_TOKEN || '' mapboxToken: process.env.MAPBOX_TOKEN || ''
})); }));
app.get('/kioskedit', renderPage('kioskedit'));
app.get('/kiosklive', renderPage('kiosklive', {
apiUrl: process.env.API_URL || 'http://localhost:3003',
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
}));
app.get('/forecasts', renderPage('forecasts', {
apiUrl: process.env.API_URL || 'http://localhost:3003',
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
}));
app.get('/documentation', renderPage('documentation', {
apiUrl: process.env.API_URL || 'http://localhost:3003'
}));
// retro-compatibilità: il link della dashboard punta ancora a /documentations
app.get('/documentations', (req, res) => res.redirect(301, '/documentation'));
app.get('/marine', renderPage('marine', {
apiUrl: process.env.API_URL || 'http://localhost:3003',
marineUrl: process.env.MARINE_URL || (process.env.API_URL || 'http://localhost:3003') + '/marine'
}));
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Started on port ${PORT}`); console.log(`Started on port ${PORT}`);
}); });

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

@@ -7,6 +7,7 @@
<script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script> <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 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"> <link rel="stylesheet" href="../static/styles/kiosk.css">
</head> </head>
@@ -555,4 +556,5 @@ ws.onclose = () => {
</script> </script>
</html> </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

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

@@ -17,10 +17,11 @@
</a> </a>
<h1>Rulesets</h1> <h1>Rulesets</h1>
<div class="rs-type-picker" id="typePicker"> <div class="rs-type-picker" id="typePicker">
<button class="active" data-type="weather">Weather</button> <button class="active" data-type="logs">Logs</button>
<button data-type="laterforecasts">Forecasts</button> <button data-type="forecast_current">Forecast · Current</button>
<button data-type="data">Data</button> <button data-type="forecast_hourly">Forecast · Hourly</button>
<button data-type="logs">Logs</button> <button data-type="marine_current">Marine · Current</button>
<button data-type="marine_hourly">Marine · Hourly</button>
</div> </div>
</div> </div>
<div class="rs-header-right"> <div class="rs-header-right">
@@ -40,7 +41,7 @@
</select> </select>
</div> </div>
<div class="rs-toolbar-right"> <div class="rs-toolbar-right">
<button class="rs-new-btn" id="newRuleBtn">+ Nuova Rule</button> <button class="rs-new-btn" id="newRuleBtn">+ Nuova Versione</button>
</div> </div>
</div> </div>
@@ -67,9 +68,15 @@
<div class="rs-section"> <div class="rs-section">
<div class="rs-field-row"> <div class="rs-field-row">
<span class="rs-field-label">Versione</span> <span class="rs-field-label">Versione</span>
<input class="rs-field-input" id="popupVersion" placeholder="1.0.0" /> <div class="rs-version-inputs">
<input class="rs-version-num" id="popupVMajor" type="number" min="1" max="100" placeholder="1" />
<span class="rs-version-dot">.</span>
<input class="rs-version-num" id="popupVBuild" type="number" min="0" max="100" placeholder="0" />
<span class="rs-version-dot">.</span>
<input class="rs-version-num" id="popupVPatch" type="number" min="0" max="100" placeholder="0" />
</div> </div>
<div class="rs-field-row" id="popupDescRow" style="display:none"> </div>
<div class="rs-field-row">
<span class="rs-field-label">Descrizione</span> <span class="rs-field-label">Descrizione</span>
<textarea class="rs-field-textarea" id="popupDesc" placeholder="Descrizione opzionale..."></textarea> <textarea class="rs-field-textarea" id="popupDesc" placeholder="Descrizione opzionale..."></textarea>
</div> </div>
@@ -102,6 +109,21 @@
<div class="rs-item-labels" id="itemLabelsRow"></div> <div class="rs-item-labels" id="itemLabelsRow"></div>
<div id="itemsList"></div> <div id="itemsList"></div>
</div> </div>
<!-- Deploy -->
<div class="rs-section">
<div class="rs-section-title">Deploy ai sensori</div>
<div class="rs-deploy-wrap">
<div id="deploySensorsList" class="rs-deploy-sensors">
<div class="rs-empty" style="padding:8px">Caricamento sensori...</div>
</div>
<div class="rs-deploy-actions">
<button class="rs-action-btn" id="deployBtn">Invia versione ai selezionati</button>
<span class="rs-deploy-result" id="deployResult"></span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -122,47 +144,89 @@
const API_URL = '{{ apiUrl }}'; const API_URL = '{{ apiUrl }}';
// --- State --- // --- State ---
let currentType = 'weather'; const TYPES = ['logs','forecast_current','forecast_hourly','marine_current','marine_hourly'];
let currentType = 'logs';
let currentFilter = 'all'; let currentFilter = 'all';
let currentSort = 'created_at'; let currentSort = 'created_at';
let allRules = []; let allRules = [];
let openRule = null; // rule attualmente aperta nel popup let openRule = null;
let saveTimers = {}; let saveTimers = {};
let sensorsCache = [];
let deploymentsForOpen = []; // deployments relativi alla rule aperta
// --- Item field definitions per tipo --- // --- Item field definitions per tipo ---
// Per ogni tipo, definiamo gli input visibili. Tutti finiscono nei campi
// { ref, path, enabled, meta: {...} } del JSONB dell'item.
//
// - logs: ref, path (SK path), meta.measurement, meta.unit
// - forecast_*: ref, path (codice openmeteo), meta.sk_path, meta.unit
// - marine_*: ref, path (codice openmeteo), meta.sk_path, meta.unit
const ITEM_SCHEMA = { const ITEM_SCHEMA = {
weather: [
{ key: 'group_name', label: 'Gruppo', cls: 'medium' },
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'name', label: 'Nome', cls: 'wide' },
{ key: 'unit', label: 'Unita', cls: 'narrow' },
],
laterforecasts: [
{ key: 'group_name', label: 'Gruppo', cls: 'medium' },
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'name', label: 'Nome', cls: 'wide' },
{ key: 'unit', label: 'Unita', cls: 'narrow' },
],
data: [
{ key: 'category', label: 'Categoria', cls: 'medium' },
{ key: 'path', label: 'Path', cls: 'wide' },
{ key: 'unit', label: 'Unita', cls: 'narrow' },
],
logs: [ logs: [
{ key: 'path', label: 'Path', cls: 'wide' }, { key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'ref', label: 'Ref', cls: 'narrow' }, { key: 'path', label: 'SK Path', cls: 'wide' },
{ key: 'unit', label: 'Unita', cls: 'narrow' }, { key: 'meta.measurement', label: 'Measurement', cls: 'medium' },
{ key: 'measurement', label: 'Measurement', cls: 'medium' }, { key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
forecast_current: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
forecast_hourly: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
marine_current: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
],
marine_hourly: [
{ key: 'ref', label: 'Ref', cls: 'medium' },
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
], ],
}; };
const HAS_DESC = { weather: true, laterforecasts: true, data: false, logs: true }; // ========== Helpers ==========
function esc(str) {
if (str === null || str === undefined) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
function getField(obj, dottedKey) {
if (!dottedKey.includes('.')) return obj?.[dottedKey];
const [a, b] = dottedKey.split('.');
return obj?.[a]?.[b];
}
function setField(obj, dottedKey, value) {
if (!dottedKey.includes('.')) { obj[dottedKey] = value; return; }
const [a, b] = dottedKey.split('.');
if (!obj[a] || typeof obj[a] !== 'object') obj[a] = {};
obj[a][b] = value;
}
function versionStr(v) {
if (!v) return '';
if (v.str) return v.str;
return `${v.major ?? 0}.${v.build ?? 0}.${v.patch ?? 0}`;
}
// ========== API helpers ========== // ========== API helpers ==========
async function api(method, path, body) { async function api(method, path, body) {
const opts = { method, headers: {}, credentials: 'include' }; const opts = { method, headers: {}, credentials: 'include' };
if (body) { if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
} }
@@ -187,13 +251,27 @@ async function loadRules() {
} }
} }
async function loadSensors() {
if (sensorsCache.length) return sensorsCache;
try {
sensorsCache = await api('GET', '/rules/-/sensors');
} catch (err) {
console.error('Error loading sensors:', err);
sensorsCache = [];
}
return sensorsCache;
}
function filterAndSort(rules) { function filterAndSort(rules) {
let filtered = rules; let filtered = rules;
if (currentFilter === 'active') filtered = rules.filter(r => r.active && !r.archived); if (currentFilter === 'active') filtered = rules.filter(r => r.active && !r.archived);
else if (currentFilter === 'archived') filtered = rules.filter(r => r.archived); else if (currentFilter === 'archived') filtered = rules.filter(r => r.archived);
filtered.sort((a, b) => { filtered.sort((a, b) => {
if (currentSort === 'version') return (b.version || '').localeCompare(a.version || '', undefined, { numeric: true }); if (currentSort === 'version') {
const va = versionStr(a.version), vb = versionStr(b.version);
return vb.localeCompare(va, undefined, { numeric: true });
}
return new Date(b.created_at) - new Date(a.created_at); return new Date(b.created_at) - new Date(a.created_at);
}); });
return filtered; return filtered;
@@ -204,7 +282,7 @@ function renderGrid() {
const rules = filterAndSort(allRules); const rules = filterAndSort(allRules);
if (rules.length === 0) { if (rules.length === 0) {
grid.innerHTML = '<div class="rs-empty">Nessuna rule trovata</div>'; grid.innerHTML = '<div class="rs-empty">Nessuna versione trovata</div>';
return; return;
} }
@@ -217,13 +295,14 @@ function renderGrid() {
const tags = (r.tags || []).map(t => `<span class="rs-tag">${esc(t)}</span>`).join(''); const tags = (r.tags || []).map(t => `<span class="rs-tag">${esc(t)}</span>`).join('');
const desc = r.description ? `<div class="rs-card-desc">${esc(r.description)}</div>` : ''; const desc = r.description ? `<div class="rs-card-desc">${esc(r.description)}</div>` : '';
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : ''; const date = r.created_at ? new Date(r.created_at).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '';
const itemsCount = (r.items_count !== undefined ? r.items_count : (r.items?.length || 0));
return ` return `
<div class="rs-card" data-id="${r.id}" onclick="openRuleDetail('${r.id}')"> <div class="rs-card" data-id="${esc(r.id)}" onclick="openRuleDetail('${esc(r.id)}')">
<div class="rs-card-header"> <div class="rs-card-header">
<div> <div>
<div class="rs-card-version">v${esc(r.version)}</div> <div class="rs-card-version">v${esc(versionStr(r.version))}</div>
<span class="rs-card-id">${esc(r.id)}</span> <span class="rs-card-id" title="${esc(r.id)}">${esc(String(r.id).slice(0,8))}</span>
</div> </div>
<div class="rs-card-badges">${badges.join('')}</div> <div class="rs-card-badges">${badges.join('')}</div>
</div> </div>
@@ -231,18 +310,12 @@ function renderGrid() {
${tags ? `<div class="rs-card-tags">${tags}</div>` : ''} ${tags ? `<div class="rs-card-tags">${tags}</div>` : ''}
<div class="rs-card-footer"> <div class="rs-card-footer">
<span class="rs-card-date">${date}</span> <span class="rs-card-date">${date}</span>
<span class="rs-card-items">${itemsCount} items</span>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
} }
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
// ========== Type Picker ========== // ========== Type Picker ==========
document.querySelectorAll('#typePicker button').forEach(btn => { document.querySelectorAll('#typePicker button').forEach(btn => {
@@ -274,10 +347,20 @@ document.getElementById('sortSelect').onchange = (e) => {
document.getElementById('newRuleBtn').onclick = async () => { document.getElementById('newRuleBtn').onclick = async () => {
try { try {
// Calcola una versione libera: prendi la maggiore esistente e incrementa patch
let M = 1, B = 0, P = 0;
if (allRules.length) {
const sorted = [...allRules].sort((a,b) => {
const va = a.version, vb = b.version;
return (vb.major - va.major) || (vb.build - va.build) || (vb.patch - va.patch);
});
const top = sorted[0].version;
M = top.major; B = top.build; P = Math.min(100, top.patch + 1);
if (P === 100 && top.patch === 100) { P = 0; B = Math.min(100, B + 1); }
}
const rule = await api('POST', `/rules/${currentType}`, { const rule = await api('POST', `/rules/${currentType}`, {
version: '1.0.0', version_major: M, version_build: B, version_patch: P,
tags: [], tags: [], description: '', items: []
description: HAS_DESC[currentType] ? '' : undefined
}); });
allRules.unshift(rule); allRules.unshift(rule);
renderGrid(); renderGrid();
@@ -285,6 +368,7 @@ document.getElementById('newRuleBtn').onclick = async () => {
flash('Salvato'); flash('Salvato');
} catch (err) { } catch (err) {
console.error('Error creating rule:', err); console.error('Error creating rule:', err);
alert(`Errore: ${err.message}`);
} }
}; };
@@ -294,17 +378,23 @@ async function openRuleDetail(ruleId) {
try { try {
const data = await api('GET', `/rules/${currentType}/${ruleId}`); const data = await api('GET', `/rules/${currentType}/${ruleId}`);
openRule = data; openRule = data;
deploymentsForOpen = [];
try {
deploymentsForOpen = await api('GET', `/rules/${currentType}/${ruleId}/deployments`);
} catch {}
await loadSensors();
renderPopup(); renderPopup();
document.getElementById('ruleOverlay').style.display = 'flex'; document.getElementById('ruleOverlay').style.display = 'flex';
} catch (err) { } catch (err) {
console.error('Error loading rule detail:', err); console.error('Error loading rule detail:', err);
alert(`Errore: ${err.message}`);
} }
} }
function closePopup() { function closePopup() {
document.getElementById('ruleOverlay').style.display = 'none'; document.getElementById('ruleOverlay').style.display = 'none';
openRule = null; openRule = null;
loadRules(); // refresh grid loadRules();
} }
document.getElementById('popupClose').onclick = closePopup; document.getElementById('popupClose').onclick = closePopup;
@@ -314,31 +404,24 @@ document.getElementById('ruleOverlay').onclick = (e) => {
function renderPopup() { function renderPopup() {
const r = openRule; const r = openRule;
document.getElementById('popupId').textContent = r.id; document.getElementById('popupId').textContent = `${currentType} · ${String(r.id).slice(0,8)}`;
document.getElementById('popupVersion').value = r.version || ''; document.getElementById('popupVMajor').value = r.version?.major ?? 1;
document.getElementById('popupVBuild').value = r.version?.build ?? 0;
// Description document.getElementById('popupVPatch').value = r.version?.patch ?? 0;
const descRow = document.getElementById('popupDescRow');
if (HAS_DESC[currentType]) {
descRow.style.display = 'flex';
document.getElementById('popupDesc').value = r.description || ''; document.getElementById('popupDesc').value = r.description || '';
} else {
descRow.style.display = 'none';
}
// Tags
renderTags(); renderTags();
// Action buttons state
updateActionButtons(); updateActionButtons();
// Items
renderItems(); renderItems();
renderDeploySensors();
document.getElementById('deployResult').textContent = '';
} }
// --- Auto-save fields --- // --- Auto-save fields ---
document.getElementById('popupVersion').oninput = () => debounceFieldSave('version'); ['popupVMajor','popupVBuild','popupVPatch'].forEach(id => {
document.getElementById(id).oninput = () => debounceFieldSave('version');
});
document.getElementById('popupDesc').oninput = () => debounceFieldSave('description'); document.getElementById('popupDesc').oninput = () => debounceFieldSave('description');
function debounceFieldSave(field) { function debounceFieldSave(field) {
@@ -349,18 +432,22 @@ function debounceFieldSave(field) {
async function saveRuleField(field) { async function saveRuleField(field) {
if (!openRule) return; if (!openRule) return;
const body = {}; const body = {};
if (field === 'version') body.version = document.getElementById('popupVersion').value.trim(); if (field === 'version') {
if (field === 'description') body.description = document.getElementById('popupDesc').value.trim(); body.version_major = parseInt(document.getElementById('popupVMajor').value, 10) || 1;
body.version_build = parseInt(document.getElementById('popupVBuild').value, 10) || 0;
body.version_patch = parseInt(document.getElementById('popupVPatch').value, 10) || 0;
}
if (field === 'description') body.description = document.getElementById('popupDesc').value;
try { try {
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, body); const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, body);
Object.assign(openRule, updated); Object.assign(openRule, updated);
// Update in allRules too
const idx = allRules.findIndex(r => r.id === openRule.id); const idx = allRules.findIndex(r => r.id === openRule.id);
if (idx >= 0) Object.assign(allRules[idx], updated); if (idx >= 0) Object.assign(allRules[idx], updated);
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { } catch (err) {
console.error('Error saving field:', err); console.error('Error saving field:', err);
flash('Errore: ' + err.message, 'popupSaving');
} }
} }
@@ -369,9 +456,7 @@ async function saveRuleField(field) {
function renderTags() { function renderTags() {
const wrap = document.getElementById('popupTags'); const wrap = document.getElementById('popupTags');
const input = document.getElementById('tagInput'); const input = document.getElementById('tagInput');
// Remove old chips
wrap.querySelectorAll('.rs-tag-chip').forEach(c => c.remove()); wrap.querySelectorAll('.rs-tag-chip').forEach(c => c.remove());
// Re-add chips before input
(openRule.tags || []).forEach((tag, i) => { (openRule.tags || []).forEach((tag, i) => {
const chip = document.createElement('span'); const chip = document.createElement('span');
chip.className = 'rs-tag-chip'; chip.className = 'rs-tag-chip';
@@ -393,9 +478,7 @@ document.getElementById('tagInput').onkeydown = async (e) => {
openRule.tags = updated.tags; openRule.tags = updated.tags;
renderTags(); renderTags();
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { } catch (err) { console.error(err); }
console.error('Error adding tag:', err);
}
} }
}; };
@@ -408,9 +491,7 @@ async function removeTag(idx) {
openRule.tags = updated.tags; openRule.tags = updated.tags;
renderTags(); renderTags();
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { } catch (err) { console.error(err); }
console.error('Error removing tag:', err);
}
} }
// --- Action Buttons --- // --- Action Buttons ---
@@ -419,10 +500,8 @@ function updateActionButtons() {
const r = openRule; const r = openRule;
const activeBtn = document.getElementById('toggleActiveBtn'); const activeBtn = document.getElementById('toggleActiveBtn');
const archiveBtn = document.getElementById('toggleArchiveBtn'); const archiveBtn = document.getElementById('toggleArchiveBtn');
activeBtn.textContent = r.active ? 'Disattiva' : 'Attiva'; activeBtn.textContent = r.active ? 'Disattiva' : 'Attiva';
activeBtn.className = `rs-action-btn active-toggle${r.active ? '' : ' off'}`; activeBtn.className = `rs-action-btn active-toggle${r.active ? '' : ' off'}`;
archiveBtn.textContent = r.archived ? 'Dearchivia' : 'Archivia'; archiveBtn.textContent = r.archived ? 'Dearchivia' : 'Archivia';
archiveBtn.className = `rs-action-btn archive-toggle${r.archived ? ' on' : ''}`; archiveBtn.className = `rs-action-btn archive-toggle${r.archived ? ' on' : ''}`;
} }
@@ -432,10 +511,12 @@ document.getElementById('toggleActiveBtn').onclick = async () => {
try { try {
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/active`); const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/active`);
openRule.active = res.active; openRule.active = res.active;
if (res.ruleset) Object.assign(openRule, res.ruleset);
updateActionButtons(); updateActionButtons();
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { } catch (err) {
console.error('Error toggling active:', err); console.error(err);
alert(`Errore: ${err.message}`);
} }
}; };
@@ -444,23 +525,20 @@ document.getElementById('toggleArchiveBtn').onclick = async () => {
try { try {
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/archive`); const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/archive`);
openRule.archived = res.archived; openRule.archived = res.archived;
if (res.ruleset) Object.assign(openRule, res.ruleset);
updateActionButtons(); updateActionButtons();
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { } catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
console.error('Error toggling archive:', err);
}
}; };
document.getElementById('deleteRuleBtn').onclick = () => { document.getElementById('deleteRuleBtn').onclick = () => {
if (!openRule) return; if (!openRule) return;
showConfirm('Elimina Rule', `Vuoi eliminare la rule ${openRule.id}? Questa azione e irreversibile.`, async () => { showConfirm('Elimina Versione', `Eliminare la versione v${versionStr(openRule.version)}? Questa azione e irreversibile.`, async () => {
try { try {
await api('DELETE', `/rules/${currentType}/${openRule.id}`); await api('DELETE', `/rules/${currentType}/${openRule.id}`);
allRules = allRules.filter(r => r.id !== openRule.id); allRules = allRules.filter(r => r.id !== openRule.id);
closePopup(); closePopup();
} catch (err) { } catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
console.error('Error deleting rule:', err);
}
}); });
}; };
@@ -470,94 +548,151 @@ function renderItems() {
const schema = ITEM_SCHEMA[currentType]; const schema = ITEM_SCHEMA[currentType];
const items = openRule.items || []; const items = openRule.items || [];
// Labels row
const labelsRow = document.getElementById('itemLabelsRow'); const labelsRow = document.getElementById('itemLabelsRow');
labelsRow.innerHTML = schema.map(f => `<span class="${f.cls}">${f.label}</span>`).join('') + labelsRow.innerHTML = schema.map(f => `<span class="${f.cls}">${f.label}</span>`).join('') +
'<span class="toggle-space">On</span><span class="delete-space"></span>'; '<span class="toggle-space">On</span><span class="delete-space"></span>';
// Items list
const list = document.getElementById('itemsList'); const list = document.getElementById('itemsList');
if (items.length === 0) { if (items.length === 0) {
list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi" per crearne uno.</div>'; list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi".</div>';
return; return;
} }
list.innerHTML = items.map(item => { list.innerHTML = items.map(item => {
const fields = schema.map(f => const fields = schema.map(f =>
`<input class="rs-item-field ${f.cls}" value="${esc(String(item[f.key] || ''))}" data-field="${f.key}" data-item-id="${item.id}" onchange="saveItemField(this)" />` `<input class="rs-item-field ${f.cls}"
value="${esc(getField(item, f.key) ?? '')}"
data-field="${f.key}"
data-ref="${esc(item.ref)}"
onchange="saveItemField(this)" />`
).join(''); ).join('');
const toggleCls = item.enabled ? 'on' : ''; const toggleCls = item.enabled !== false ? 'on' : '';
return `<div class="rs-item" data-item-id="${item.id}"> return `<div class="rs-item" data-ref="${esc(item.ref)}">
${fields} ${fields}
<div class="rs-toggle ${toggleCls}" onclick="toggleItem(${item.id})"></div> <div class="rs-toggle ${toggleCls}" onclick="toggleItem('${esc(item.ref)}')"></div>
<button class="rs-item-delete" onclick="deleteItem(${item.id})">&times;</button> <button class="rs-item-delete" onclick="deleteItem('${esc(item.ref)}')">&times;</button>
</div>`; </div>`;
}).join(''); }).join('');
} }
document.getElementById('addItemBtn').onclick = async () => { document.getElementById('addItemBtn').onclick = async () => {
if (!openRule) return; if (!openRule) return;
const schema = ITEM_SCHEMA[currentType]; const ref = prompt('Ref dell\'item (identificatore stabile, es. "batt_traction_soc"):');
const body = {}; if (!ref) return;
// Fill with empty/default values
schema.forEach(f => { body[f.key] = ''; });
// Need at least non-empty values — open with placeholders
// For now, create with placeholder values
schema.forEach(f => { body[f.key] = f.key === 'enabled' ? true : '-'; });
try { try {
const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, body); const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, {
ref: ref.trim(), path: '', enabled: true, meta: {}
});
if (!openRule.items) openRule.items = []; if (!openRule.items) openRule.items = [];
openRule.items.push(item); openRule.items.push(item);
renderItems(); renderItems();
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { } catch (err) {
console.error('Error adding item:', err); console.error(err);
alert(`Errore: ${err.message}`);
} }
}; };
async function saveItemField(input) { async function saveItemField(input) {
if (!openRule) return; if (!openRule) return;
const itemId = input.dataset.itemId; const ref = input.dataset.ref;
const field = input.dataset.field; const field = input.dataset.field;
const value = input.value.trim(); const value = input.value;
const item = (openRule.items || []).find(i => i.ref === ref);
if (!item) return;
// costruisci body rispettando ref/path/enabled oppure meta.<x>
const body = {};
if (field === 'ref') body.ref = value.trim();
else if (field === 'path') body.path = value;
else if (field === 'enabled') body.enabled = !!value;
else if (field.startsWith('meta.')) {
const metaKey = field.slice(5);
body.meta = { ...(item.meta || {}), [metaKey]: value };
}
try { try {
await api('PUT', `/rules/${currentType}/${openRule.id}/items/${itemId}`, { [field]: value }); const updated = await api('PUT', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`, body);
// Update local // replace item in place
const item = openRule.items.find(i => String(i.id) === String(itemId)); const idx = openRule.items.findIndex(i => i.ref === ref);
if (item) item[field] = value; if (idx >= 0) openRule.items[idx] = updated;
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { } catch (err) {
console.error('Error saving item field:', err); console.error(err);
flash('Errore', 'popupSaving');
} }
} }
async function toggleItem(itemId) { async function toggleItem(ref) {
if (!openRule) return; if (!openRule) return;
try { try {
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${itemId}/toggle`); const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}/toggle`);
const item = openRule.items.find(i => i.id === itemId); const item = openRule.items.find(i => i.ref === ref);
if (item) item.enabled = res.enabled; if (item) item.enabled = res.enabled;
renderItems(); renderItems();
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { } catch (err) { console.error(err); }
console.error('Error toggling item:', err);
}
} }
async function deleteItem(itemId) { async function deleteItem(ref) {
if (!openRule) return; if (!openRule) return;
try { try {
await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${itemId}`); await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`);
openRule.items = openRule.items.filter(i => i.id !== itemId); openRule.items = openRule.items.filter(i => i.ref !== ref);
renderItems(); renderItems();
flash('Salvato', 'popupSaving'); flash('Salvato', 'popupSaving');
} catch (err) { 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) { } catch (err) {
console.error('Error deleting item:', err); console.error(err);
} resultEl.textContent = `Errore: ${err.message}`;
} }
};
// ========== Confirm Dialog ========== // ========== Confirm Dialog ==========
@@ -581,7 +716,7 @@ document.getElementById('confirmOk').onclick = async () => {
confirmCallback = null; confirmCallback = null;
}; };
// ========== Flash "Salvato" indicator ========== // ========== Flash ==========
function flash(text, elId = 'savingIndicator') { function flash(text, elId = 'savingIndicator') {
const el = document.getElementById(elId); const el = document.getElementById(elId);
@@ -592,7 +727,10 @@ function flash(text, elId = 'savingIndicator') {
// ========== Init ========== // ========== Init ==========
document.addEventListener('DOMContentLoaded', () => loadRules()); document.addEventListener('DOMContentLoaded', () => {
loadRules();
loadSensors();
});
</script> </script>
</body> </body>

View File

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

View File

@@ -769,3 +769,81 @@
.rs-back:hover { .rs-back:hover {
color: var(--accent-color); color: var(--accent-color);
} }
/* ── Version number inputs (major.build.patch) ── */
.rs-version-inputs {
display: inline-flex;
align-items: center;
gap: 4px;
}
.rs-version-num {
width: 48px;
padding: 6px 8px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
background: var(--input-bg, #fff);
color: var(--text-primary);
-moz-appearance: textfield;
}
.rs-version-num::-webkit-outer-spin-button,
.rs-version-num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.rs-version-dot {
color: var(--text-tertiary, #94a3b8);
font-weight: 600;
}
.rs-card-items {
font-size: 0.75rem;
color: var(--text-tertiary, #94a3b8);
}
/* ── Deploy section ── */
.rs-deploy-wrap {
display: flex;
flex-direction: column;
gap: 12px;
}
.rs-deploy-sensors {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
padding: 8px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
background: var(--input-bg, #fafafa);
}
.rs-deploy-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s ease;
}
.rs-deploy-item:hover { background: rgba(0,0,0,0.03); }
.rs-deploy-name { flex: 1; font-weight: 500; color: var(--text-primary); }
.rs-deploy-check { width: 16px; height: 16px; cursor: pointer; }
.rs-deploy-status {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.rs-deploy-status.ok { background: #dcfce7; color: #166534; }
.rs-deploy-status.pending { background: #fef3c7; color: #92400e; }
.rs-deploy-actions {
display: flex;
align-items: center;
gap: 12px;
}
.rs-deploy-result {
font-size: 0.8rem;
color: var(--text-secondary);
}

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
# ── SQLite (L2) ──────────────────────────────────────────────────────────
def _ensure_sqlite() -> sqlite3.Connection:
"""Apre/crea il db SQLite su disco. Crea anche la dir blob."""
global _sqlite_initialized
CACHE_DIR.mkdir(parents=True, exist_ok=True)
BLOB_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(CACHE_DB), timeout=5.0, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
if not _sqlite_initialized:
conn.execute("""
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL,
is_blob INTEGER NOT NULL DEFAULT 0,
value BLOB,
blob_path TEXT,
size_bytes INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at)")
_sqlite_initialized = True
return conn
def _blob_path(key: str) -> Path:
# Nome file safe: solo caratteri alfanumerici + hash per unicità
safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in key)
return BLOB_DIR / f"{safe}.json.gz"
def _disk_get(key: str) -> Optional[Any]:
try:
with _sqlite_lock:
conn = _ensure_sqlite()
row = conn.execute(
"SELECT expires_at, is_blob, value, blob_path FROM cache WHERE key = ?",
(key,)
).fetchone()
if row is None:
return None
expires_at, is_blob, value, blob_path = row
if expires_at < int(time.time()):
# Scaduta: la elimino in lazy
_disk_delete(key)
return None
if is_blob:
data = Path(blob_path).read_bytes()
else:
data = value
return json.loads(gzip.decompress(data).decode("utf-8"))
except Exception as e:
logger.warning(f"[Cache] Errore lettura disco '{key}': {e}")
return None
def _disk_set(key: str, raw_gz: bytes, ttl: int) -> None:
try:
expires_at = int(time.time()) + ttl
updated_at = int(time.time())
size = len(raw_gz)
if size > BLOB_THRESHOLD_BYTES:
path = _blob_path(key)
path.write_bytes(raw_gz)
with _sqlite_lock:
conn = _ensure_sqlite()
conn.execute(
"INSERT OR REPLACE INTO cache(key, expires_at, is_blob, value, blob_path, size_bytes, updated_at) "
"VALUES(?,?,?,?,?,?,?)",
(key, expires_at, 1, None, str(path), size, updated_at)
)
else:
with _sqlite_lock:
conn = _ensure_sqlite()
conn.execute(
"INSERT OR REPLACE INTO cache(key, expires_at, is_blob, value, blob_path, size_bytes, updated_at) "
"VALUES(?,?,?,?,?,?,?)",
(key, expires_at, 0, raw_gz, None, size, updated_at)
)
except Exception as e:
logger.warning(f"[Cache] Errore scrittura disco '{key}': {e}")
def _disk_delete(key: str) -> None:
try:
with _sqlite_lock:
conn = _ensure_sqlite()
row = conn.execute("SELECT blob_path FROM cache WHERE key = ?", (key,)).fetchone()
conn.execute("DELETE FROM cache WHERE key = ?", (key,))
if row and row[0]:
try:
Path(row[0]).unlink(missing_ok=True)
except Exception:
pass
except Exception as e:
logger.warning(f"[Cache] Errore delete disco '{key}': {e}")
# ── API pubblica ─────────────────────────────────────────────────────────
def cache_get(key: str) -> Optional[Any]: def cache_get(key: str) -> Optional[Any]:
"""Legge un valore dalla cache Redis. """Legge L1 → L2. Se L2 hit, ripopola L1 (re-warm)."""
# L1
Args: client = _get_redis()
key: Chiave Redis (es. 'marine:catalog:full') if client is not None:
Returns:
Il valore deserializzato da JSON, oppure None se non trovato o errore
"""
try: try:
client = _get_client() raw = client.get(key)
if client is None: if raw is not None:
return None return json.loads(gzip.decompress(raw).decode("utf-8"))
data = client.get(key)
if data is None:
return None
return json.loads(data)
except Exception as e: except Exception as e:
logger.warning(f"[Redis] Errore lettura chiave '{key}': {e}") logger.warning(f"[Cache] Errore Redis '{key}': {e}")
return None
# L2
def cache_set(key: str, value: Any, ttl: int = 3600) -> bool: value = _disk_get(key)
"""Scrive un valore nella cache Redis con TTL. if value is not None and client is not None:
# Re-warm L1 con TTL standard
Args:
key: Chiave Redis
value: Valore da serializzare in JSON
ttl: Tempo di vita in secondi (default: 1 ora)
Returns:
True se scritto con successo, False altrimenti
"""
try: try:
client = _get_client() raw_gz = gzip.compress(json.dumps(value).encode("utf-8"))
if client is None: client.setex(key, DEFAULT_REDIS_TTL, raw_gz)
except Exception:
pass
return value
def cache_set(key: str, value: Any, ttl: int = DEFAULT_REDIS_TTL, disk_ttl: Optional[int] = None) -> bool:
"""Scrive in L1 (ttl) e L2 (disk_ttl, default 30 giorni).
Per chiavi ephemere (es. job state) passa disk_ttl=0 per saltare il disco."""
if disk_ttl is None:
disk_ttl = DEFAULT_DISK_TTL
try:
serialized = json.dumps(value).encode("utf-8")
raw_gz = gzip.compress(serialized)
except Exception as e:
logger.warning(f"[Cache] Errore serializzazione '{key}': {e}")
return False return False
serialized = json.dumps(value) ok = False
client.setex(key, ttl, serialized) # L1
return True client = _get_redis()
if client is not None:
try:
client.setex(key, ttl, raw_gz)
ok = True
except Exception as e: except Exception as e:
logger.warning(f"[Redis] Errore scrittura chiave '{key}': {e}") logger.warning(f"[Cache] Errore scrittura Redis '{key}': {e}")
return False
# L2
if disk_ttl > 0:
_disk_set(key, raw_gz, disk_ttl)
ok = True
return ok
def cache_delete(key: str) -> bool: def cache_delete(key: str) -> bool:
"""Elimina una chiave dalla cache Redis. client = _get_redis()
if client is not None:
Args:
key: Chiave Redis da eliminare
Returns:
True se eliminata, False altrimenti
"""
try: try:
client = _get_client()
if client is None:
return False
client.delete(key) client.delete(key)
except Exception:
pass
_disk_delete(key)
return True return True
def cache_stats() -> dict:
"""Ritorna statistiche della cache: utile per /health e debug."""
stats = {"redis": False, "disk": {"entries": 0, "bytes": 0, "blobs": 0}}
if _get_redis() is not None:
stats["redis"] = True
try:
with _sqlite_lock:
conn = _ensure_sqlite()
row = conn.execute(
"SELECT COUNT(*), COALESCE(SUM(size_bytes),0), COALESCE(SUM(is_blob),0) FROM cache"
).fetchone()
stats["disk"]["entries"] = row[0]
stats["disk"]["bytes"] = row[1]
stats["disk"]["blobs"] = row[2]
except Exception:
pass
return stats
def cache_sweep() -> int:
"""Rimuove voci scadute su disco (da chiamare periodicamente). Ritorna numero eliminate."""
try:
now = int(time.time())
with _sqlite_lock:
conn = _ensure_sqlite()
rows = conn.execute(
"SELECT key, blob_path FROM cache WHERE expires_at < ?", (now,)
).fetchall()
conn.execute("DELETE FROM cache WHERE expires_at < ?", (now,))
for _, path in rows:
if path:
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
return len(rows)
except Exception as e: except Exception as e:
logger.warning(f"[Redis] Errore eliminazione chiave '{key}': {e}") logger.warning(f"[Cache] Errore sweep: {e}")
return False return 0

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,6 +52,13 @@ def _get_raw_catalog() -> dict:
logger.debug("[Catalogo] Servito da cache Redis") logger.debug("[Catalogo] Servito da cache Redis")
return cached return cached
# Single-flight: solo un thread alla volta scarica il catalogo. Gli altri
# attendono il lock e poi leggono il valore appena messo in cache.
with _catalog_fetch_lock:
cached = cache_get(_CATALOG_KEY)
if cached is not None:
return cached
# Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s) # Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s)
logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...") logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...")
import copernicusmarine import copernicusmarine

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,6 +63,12 @@ def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_
_set_job(session_id, progress=pct, message=msg) _set_job(session_id, progress=pct, message=msg)
try: try:
_set_job(session_id, status="queued", progress=2, message="In coda (max concorrenti raggiunto)...")
# Acquisisce uno slot di download (blocca se già al limite). Garantisce
# che il numero di chiamate Copernicus simultanee non superi
# MARINE_DOWNLOAD_CONCURRENCY, proteggendo CPU/RAM del server.
with _download_semaphore:
_set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...") _set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...")
# Scarica dati dal catalogo Copernicus # Scarica dati dal catalogo Copernicus
@@ -85,7 +99,7 @@ def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_
"created_by": username, "created_by": username,
"type": req.format, "type": req.format,
"notes": req.notes, "notes": req.notes,
"copernicus_dataset_id": req.dataset_id, "copernicus_id": req.dataset_id,
"variables": req.variables, "variables": req.variables,
"variable_renames": req.variable_renames, "variable_renames": req.variable_renames,
"bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude], "bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude],
@@ -129,7 +143,7 @@ async def new_download_session(
"message": "In coda", "message": "In coda",
"dataset_id": None, "dataset_id": None,
} }
cache_set(_job_key(session_id), initial_state, _JOB_TTL) cache_set(_job_key(session_id), initial_state, _JOB_TTL, disk_ttl=0)
# Avvia il download in background # Avvia il download in background
background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"]) background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"])

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

@@ -106,8 +106,12 @@ services:
context: ./ml context: ./ml
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
command: uvicorn main:app --host 0.0.0.0 --port 3007 --reload
volumes: volumes:
- ./ml:/app - ./ml:/app
- /var/run/docker.sock:/var/run/docker.sock
- ml_tmp:/var/ml/tmp
- ml_gitcache:/var/ml/gitcache
env_file: env_file:
- ./ml/.env - ./ml/.env
networks: networks:
@@ -117,34 +121,43 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.ml.rule=Host(`ml.${DOMAIN:-mebboat.it}`)" - "traefik.http.routers.ml.rule=Host(`ml.${DOMAIN:-mebboat.it}`)"
- "traefik.http.routers.ml.entrypoints=websecure" - "traefik.http.routers.ml.entrypoints=websecure"
- "traefik.http.services.ml.loadbalancer.server.port=8000" - "traefik.http.services.ml.loadbalancer.server.port=3007"
- "traefik.http.routers.ml.tls.certresolver=letsencrypt" - "traefik.http.routers.ml.tls.certresolver=letsencrypt"
- "traefik.docker.network=meb-public" - "traefik.docker.network=meb-public"
# marine: copernicus:
# container_name: marine-service container_name: copernicus-service
# build: build:
# context: ./marine context: ./copernicus
# dockerfile: Dockerfile dockerfile: Dockerfile
# restart: unless-stopped restart: unless-stopped
# volumes: volumes:
# - ./marine:/app - ./copernicus:/app
# env_file: - copernicus_cache:/app/cache
# - ./marine/.env env_file:
# environment: - ./copernicus/.env
# - REDIS_HOST=meb-redis environment:
# - REDIS_PORT=6379 - REDIS_HOST=meb-redis
# networks: - REDIS_PORT=6379
# - meb-proxy-net - API_SERVICE_URL=http://api:3003
# - meb-internal - CACHE_DIR=/app/cache
# labels: - MINIO_ENDPOINT=minio
# - "traefik.enable=true" - MINIO_PORT=9000
# - "traefik.http.routers.marine.rule=Host(`api.${DOMAIN:-mebboat.it}`) && PathPrefix(`/marine`)" networks:
# - "traefik.http.routers.marine.entrypoints=web" - meb-public
# - "traefik.http.services.marine.loadbalancer.server.port=8001" - meb-private
# - "traefik.docker.network=meb-proxy-net" labels:
# - "traefik.http.middlewares.marine-strip.stripprefix.prefixes=/marine" - "traefik.enable=true"
# - "traefik.http.routers.marine.middlewares=marine-strip" # Esponi sotto api.mebboat.it/marine/* (Traefik strippa "/marine")
- "traefik.http.routers.copernicus.rule=Host(`api.${DOMAIN:-mebboat.it}`) && PathPrefix(`/marine`)"
- "traefik.http.routers.copernicus.entrypoints=websecure"
- "traefik.http.routers.copernicus.tls.certresolver=letsencrypt"
- "traefik.http.services.copernicus.loadbalancer.server.port=8000"
- "traefik.docker.network=meb-public"
- "traefik.http.middlewares.copernicus-strip.stripprefix.prefixes=/marine"
- "traefik.http.routers.copernicus.middlewares=copernicus-strip"
# Priorità alta: la regola col PathPrefix deve vincere su quella generica api.
- "traefik.http.routers.copernicus.priority=100"
# circuits: # circuits:
# container_name: meb-circuits # container_name: meb-circuits
@@ -184,3 +197,8 @@ networks:
external: true external: true
meb-private: meb-private:
external: true external: true
volumes:
copernicus_cache:
ml_tmp:
ml_gitcache:

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

33
ml/templates/_layout.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ML — {% block title %}{{ page|capitalize }}{% endblock %}</title>
<link href="/static/styles/style.css" rel="stylesheet">
<link href="/static/styles/ml.css" rel="stylesheet">
</head>
<body>
<div class="header">
<h1>Modelli ML</h1>
<nav class="ml-nav">
<a href="/datasets" class="{% if page=='datasets' %}active{% endif %}">Datasets</a>
<a href="/models" class="{% if page=='models' %}active{% endif %}">Modelli</a>
<a href="/train" class="{% if page=='train' %}active{% endif %}">Train</a>
<a href="/test" class="{% if page=='test' %}active{% endif %}">Test</a>
<a href="/results" class="{% if page=='results' %}active{% endif %}">Results</a>
</nav>
<div class="profile">
<p id="username">{{ user.username }}</p>
<button id="logout-btn">Logout</button>
</div>
</div>
<div class="container">
{% block content %}{% endblock %}
</div>
<script src="/static/js/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,39 @@
{% extends "_layout.html" %}
{% block title %}Datasets{% endblock %}
{% block content %}
<div class="page-head">
<h2>Datasets</h2>
<button class="prominent" id="btn-upload">+ Carica CSV</button>
</div>
<div id="datasets-list" class="list"></div>
<dialog id="upload-dlg">
<form id="upload-form" method="dialog">
<h3>Carica dataset</h3>
<label>Nome<input type="text" name="nome" required></label>
<label>Tipo
<select name="dataset_type">
<option value="custom">custom</option>
<option value="imported">imported</option>
</select>
</label>
<label>Formato
<select name="format">
<option value="csv">csv</option>
<option value="json">json</option>
</select>
</label>
<label>Tags (virgola)<input type="text" name="tags"></label>
<label>Descrizione<textarea name="description"></textarea></label>
<label>File<input type="file" name="file" required></label>
<menu>
<button type="button" id="upload-cancel">Annulla</button>
<button type="submit" class="prominent">Carica</button>
</menu>
</form>
</dialog>
{% endblock %}
{% block scripts %}
<script src="/static/js/datasets.js"></script>
{% endblock %}

57
ml/templates/models.html Normal file
View File

@@ -0,0 +1,57 @@
{% extends "_layout.html" %}
{% block title %}Modelli{% endblock %}
{% block content %}
<div class="page-head">
<h2>Modelli</h2>
<button class="prominent" id="btn-add-model">+ Aggiungi modello</button>
</div>
<div id="models-list" class="list"></div>
<div id="model-detail" class="detail hidden">
<button id="btn-close-detail">×</button>
<h3 id="md-name"></h3>
<p id="md-meta"></p>
<section>
<h4>Branch / Commits</h4>
<select id="md-branch"></select>
<ul id="md-commits"></ul>
</section>
<section>
<h4>model.yml</h4>
<pre id="md-spec"></pre>
</section>
<section>
<h4>Note</h4>
<ul id="md-notes"></ul>
<form id="md-note-form">
<textarea name="text" placeholder="Nuova nota"></textarea>
<button type="submit" class="prominent">Aggiungi</button>
</form>
</section>
</div>
<dialog id="add-model-dlg">
<form id="add-model-form" method="dialog">
<h3>Nuovo modello</h3>
<label>Nome<input type="text" name="name" required></label>
<label>Tipo
<select name="type">
<option>xgboost</option>
<option>lstm</option>
<option>sklearn</option>
<option>other</option>
</select>
</label>
<label>Repo Gitea (owner/repo)<input type="text" name="gitea_repo" required></label>
<label>Branch<input type="text" name="default_branch" value="main"></label>
<menu>
<button type="button" id="add-model-cancel">Annulla</button>
<button type="submit" class="prominent">Crea</button>
</menu>
</form>
</dialog>
{% endblock %}
{% block scripts %}
<script src="/static/js/models.js"></script>
{% endblock %}

View File

@@ -1,89 +1,33 @@
<!DOCTYPE html> {% extends "_layout.html" %}
{% block title %}Risultati{% endblock %}
<html> {% block content %}
<head> <div class="page-head">
<title>Risultati</title> <h2>Risultati training</h2>
<link href="../static/styles/style.css" rel="stylesheet"> <button id="btn-compare" class="prominent">Confronta selezionati</button>
<style>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.picker {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.picker .header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
</head>
<body>
<div class="header">
<h1>Risultati</h1>
<div class="profile">
<p>Utente</p>
<button>Logout</button>
</div>
</div> </div>
<div class="container"> <div id="results-list" class="list"></div>
<div class="picker"> <section id="compare-panel" class="hidden">
<h3>Confronto</h3>
<div class="header"> <div class="charts">
<h2> <canvas id="cmp-loss"></canvas>
Seleziona
</h2>
<p>
una sessione di training eseguita per visualizzarne i risultati
</p>
</div> </div>
<table id="cmp-table"></table>
<div id="cmp-plots"></div>
</section>
<div class="grid"> <section id="detail-panel" class="hidden">
<h3>Dettaglio training <code id="dt-id"></code></h3>
<div class="card"> <div id="dt-meta"></div>
<h3>sessione 1</h3> <div class="charts">
<div class="train-info"> <canvas id="dt-loss"></canvas>
<p>24/03/2026</p> <canvas id="dt-res"></canvas>
<p>12:00</p>
<p>dataset: d-1</p>
</div> </div>
<div id="dt-plots"></div>
</div> </section>
{% endblock %}
<div class="card"> {% block scripts %}
<h3>sessione 2</h3> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<p>24/03/2026</p> <script src="/static/js/results.js"></script>
{% endblock %}
</div>
</div>
</div>
</div>
</body>
<script>
</script>
</html>

View File

@@ -0,0 +1,33 @@
{% extends "_layout.html" %}
{% block title %}Test{% endblock %}
{% block content %}
<div class="page-head">
<h2>Test modello</h2>
<div id="slot-info" class="queue-info">Slot: <span id="slot-count"></span>/2</div>
</div>
<div id="slot-full" class="info-panel hidden">
<div class="icon">🚧</div>
<h3>Slot test pieni</h3>
<p>Massimo 2 utenti possono eseguire test contemporaneamente. Riprova tra qualche minuto.</p>
</div>
<form id="test-start" class="form-row">
<label>Modello<select id="t-model"></select></label>
<label>Training<select id="t-training"></select></label>
<button type="submit" class="prominent">Avvia sessione</button>
</form>
<section id="test-session" class="hidden">
<h3>Sessione <code id="ts-id"></code></h3>
<form id="inputs-form"></form>
<button id="btn-run" class="prominent">Esegui test</button>
<button id="btn-end">Chiudi sessione</button>
<h4>Risultati</h4>
<div id="runs-list"></div>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/test.js"></script>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "_layout.html" %}
{% block title %}Train{% endblock %}
{% block content %}
<div class="page-head">
<h2>Avvia training</h2>
<div class="queue-info">Coda: <span id="queue-count"></span></div>
</div>
<form id="train-form" class="form-row">
<label>Modello<select name="model_id" id="f-model"></select></label>
<label>Branch<select name="branch" id="f-branch"></select></label>
<label>Commit<select name="patch" id="f-patch"></select></label>
<label>Versione<input type="text" name="version" placeholder="1.0.0" required></label>
<label>Dataset<select name="dataset_id" id="f-dataset"></select></label>
<button type="submit" class="prominent">Avvia</button>
</form>
<section id="live-panel" class="hidden">
<h3>Training <code id="live-id"></code><span id="live-status">queued</span></h3>
<div class="charts">
<canvas id="chart-loss"></canvas>
<canvas id="chart-cpu"></canvas>
</div>
<pre id="live-logs" class="logs"></pre>
</section>
<section>
<h3>Recenti</h3>
<div id="recent-trainings" class="list"></div>
</section>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="/static/js/train.js"></script>
{% endblock %}

View File

@@ -11,8 +11,10 @@
"@influxdata/influxdb-client": "^1.35.0", "@influxdata/influxdb-client": "^1.35.0",
"@influxdata/influxdb-client-apis": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0",
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"cookie-parser": "^1.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"pg": "^8.20.0", "pg": "^8.20.0",
"ws": "^8.19.0" "ws": "^8.19.0"
} }
@@ -84,6 +86,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -162,6 +170,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -220,6 +247,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -525,18 +561,103 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": { "node_modules/lodash.isarguments": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -876,12 +997,44 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",

View File

@@ -11,8 +11,10 @@
"@influxdata/influxdb-client": "^1.35.0", "@influxdata/influxdb-client": "^1.35.0",
"@influxdata/influxdb-client-apis": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0",
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"cookie-parser": "^1.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"pg": "^8.20.0", "pg": "^8.20.0",
"ws": "^8.19.0" "ws": "^8.19.0"
} }

View File

@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const parser = require('cookie-parser');
const app = express(); const app = express();
const db = require('./store/db') const db = require('./store/db')
@@ -7,6 +8,7 @@ const redis = require('./store/redis');
const wsHandler = require('./ws/handler'); const wsHandler = require('./ws/handler');
app.use(express.json()); app.use(express.json());
app.use(parser());
// CORS — consenti richieste dalla console e altri client browser // CORS — consenti richieste dalla console e altri client browser
app.use((req, res, next) => { app.use((req, res, next) => {
@@ -43,9 +45,13 @@ app.get('/health', (req, res) => {
app.use('/connect', require('./routes/connect')); app.use('/connect', require('./routes/connect'));
app.use('/sensors', require('./routes/sensors')); app.use('/sensors', require('./routes/sensors'));
app.use('/sessions', require('./routes/sessions')); app.use('/sessions', require('./routes/sessions'));
app.use('/rules', require('./routes/rules'));
const server = app.listen(3000, '0.0.0.0', () => { const server = app.listen(3000, '0.0.0.0', () => {
console.log(`Realtime started`); console.log(`Realtime started`);
}); });
wsHandler.setup(server); wsHandler.setup(server);
// deve essere caricato DOPO setup per avere kioskRelay pronto
app.use('/kiosk', require('./routes/kiosk'));

View File

@@ -0,0 +1,51 @@
/**
* Middleware di autenticazione per il servizio realtime.
* Usa il JWT condiviso via cookie .mebboat.it o Authorization Bearer.
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET e verificato localmente.
*/
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).
*/
function requireAuth(req, res, next) {
// Service-to-service
const apiKey = req.headers['x-api-key'];
if (apiKey && INTERNAL_KEY && apiKey === INTERNAL_KEY) {
req.internal = true;
return next();
}
// User auth
const user = verifyToken(extractToken(req));
if (!user) return res.status(401).json({ error: 'unauthorized' });
req.user = user;
next();
}
module.exports = { requireAuth, verifyToken, extractToken };

View File

@@ -1,12 +1,29 @@
const router = require('express').Router(); const router = require('express').Router();
const db = require('../store/db'); const { kioskRelay } = require('../ws/handler');
// Endpoint per ricevere dati dal kiosk const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
router.post('/data', async (req, res) => {
const { session_id, sensor_code, value, timestamp } = req.body; function requireInternal(req, res, next) {
if (!session_id || !sensor_code || value === undefined) { if (!INTERNAL_KEY || req.headers['x-api-key'] !== INTERNAL_KEY)
return res.status(400).json({ error: 'Missing required fields' }); return res.status(403).json({ error: 'forbidden' });
next();
} }
// Chiamato dall'API quando cambia il template attivo
router.post('/notify-active', requireInternal, (req, res) => {
const { template } = req.body || {};
if (!template || !template.id) return res.status(400).json({ error: 'template.id required' });
kioskRelay.notifyActiveTemplateChange(template);
res.json({ ok: true });
});
// Stato dispositivi connessi (diagnostica)
router.get('/status', requireInternal, (req, res) => {
const list = [];
for (const [name, ws] of kioskRelay.devices) {
list.push({ sensor: name, templateId: ws.templateId || null, lastSeen: ws.lastSeen || null });
}
res.json({ devices: list });
}); });
module.exports = router; module.exports = router;

View File

@@ -0,0 +1,57 @@
/**
* Relay HTTP → WS per il push dei rulesets ai sensori.
* Chiamato SOLO dal servizio api (internal, x-api-key).
*
* POST /rules/push
* Body: { sensors: [name, ...], type, ruleset }
* -> invia msgpack { _t: 'ruleset_update', type, ruleset } ad ogni sensore
* online tramite la connessione WS gia' stabilita.
*/
const router = require('express').Router();
const { encode } = require('@msgpack/msgpack');
const { connectedSensors } = require('../ws/handler');
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
function requireInternal(req, res, next) {
const k = req.headers['x-api-key'];
if (!INTERNAL_KEY || !k || k !== INTERNAL_KEY) {
return res.status(403).json({ error: 'forbidden' });
}
next();
}
router.post('/push', requireInternal, (req, res) => {
const { sensors, type, ruleset } = req.body || {};
if (!Array.isArray(sensors) || !sensors.length) return res.status(400).json({ error: 'sensors array required' });
if (!type || !ruleset) return res.status(400).json({ error: 'type and ruleset required' });
const payload = { _t: 'ruleset_update', type, ruleset };
let encoded;
try {
encoded = encode(payload);
} catch (err) {
return res.status(500).json({ error: `encode error: ${err.message}` });
}
const pushed = [], offline = [], errors = [];
for (const name of sensors) {
const ws = connectedSensors.get(name);
if (!ws || ws.readyState !== ws.OPEN) {
offline.push(name);
continue;
}
try {
ws.send(encoded);
pushed.push(name);
} catch (err) {
errors.push({ sensor: name, error: err.message });
}
}
console.log(`[RULES] push type=${type} v=${ruleset?.version?.str || '?'} → pushed=${pushed.length} offline=${offline.length} err=${errors.length}`);
res.json({ pushed, offline, errors });
});
module.exports = router;

View File

@@ -5,16 +5,40 @@ const client = new InfluxDB({
token: process.env.INFLX_TOKEN, token: process.env.INFLX_TOKEN,
}); });
const bucket = process.env.INFLX_BUCKET || 'logs';
const org = process.env.INFLX_ORG; const org = process.env.INFLX_ORG;
const writeApi = client.getWriteApi(org, bucket, 'ms', { // Bucket dedicati per dominio. Il default per i logs viene mantenuto su
flushInterval: 100, // INFLX_BUCKET per retro-compatibilità con la configurazione esistente.
batchSize: 50, // Per i dati meteo current e forecast usiamo bucket separati: sono dati
// indipendenti dai logs (frequenze e retention diverse) e tenerli separati
// permette policy di retention più aggressive per i forecast (timestamp
// futuri sovrascritti spesso) senza toccare il volume dei logs.
const BUCKETS = {
logs: process.env.INFLX_BUCKET_LOGS || process.env.INFLX_BUCKET || 'logs',
weather: process.env.INFLX_BUCKET_WEATHER || 'weather_current',
weather_forecast: process.env.INFLX_BUCKET_FORECAST || 'weather_forecast',
};
const writeApis = {};
function getWriteApi(bucket) {
if (!writeApis[bucket]) {
writeApis[bucket] = client.getWriteApi(org, bucket, 'ms', {
flushInterval: 1000,
batchSize: 200,
maxRetries: 3,
}); });
}
return writeApis[bucket];
}
function bucketFor(measurement) {
if (measurement === 'weather') return BUCKETS.weather;
if (measurement === 'weather_forecast') return BUCKETS.weather_forecast;
return BUCKETS.logs;
}
/** /**
* Scrive dati generici su InfluxDB senza mapping. * Scrive dati generici su InfluxDB nel bucket appropriato per il measurement.
* @param {string} measurement - nome della measurement (es. 'logs', 'weather') * @param {string} measurement - nome della measurement (es. 'logs', 'weather')
* @param {Object} fields - campi { key: value } * @param {Object} fields - campi { key: value }
* @param {string} sensor - nome del sensore * @param {string} sensor - nome del sensore
@@ -36,11 +60,12 @@ function writeGenericData(measurement, fields, sensor, session, timestamp) {
} }
} }
writeApi.writePoint(point); getWriteApi(bucketFor(measurement)).writePoint(point);
} }
/** /**
* Scrive un batch di punti forecast (previsioni orarie). * Scrive un batch di punti forecast (previsioni orarie).
* Usa il bucket weather_forecast (non i logs).
* @param {Array} points - array di [timestamp_ms, { key: value, ... }] * @param {Array} points - array di [timestamp_ms, { key: value, ... }]
* @param {string} sensor - nome del sensore * @param {string} sensor - nome del sensore
* @param {string} session - id sessione * @param {string} session - id sessione
@@ -52,14 +77,14 @@ function writeForecastBatch(points, sensor, session) {
} }
/** /**
* Forza il flush del buffer di scrittura. * Forza il flush dei buffer di scrittura su tutti i bucket.
*/ */
async function flush() { async function flush() {
try { await Promise.all(Object.values(writeApis).map(async (wa) => {
await writeApi.flush(); try { await wa.flush(); } catch (err) {
} catch (err) {
console.error('[INFLUX] Flush error:', err.message); console.error('[INFLUX] Flush error:', err.message);
} }
}));
} }
/** /**
@@ -72,7 +97,7 @@ async function flush() {
async function queryHistory(sensor, session, since) { async function queryHistory(sensor, session, since) {
const queryApi = client.getQueryApi(org); const queryApi = client.getQueryApi(org);
const fluxQuery = ` const fluxQuery = `
from(bucket: "${bucket}") from(bucket: "${BUCKETS.logs}")
|> range(start: ${since}) |> range(start: ${since})
|> filter(fn: (r) => r._measurement == "logs") |> filter(fn: (r) => r._measurement == "logs")
|> filter(fn: (r) => r.sensor == "${sensor}") |> filter(fn: (r) => r.sensor == "${sensor}")
@@ -95,10 +120,6 @@ async function queryHistory(sensor, session, since) {
/** /**
* Esporta tutti i dati di una sessione come CSV. * Esporta tutti i dati di una sessione come CSV.
* @param {string} sensor - nome sensore
* @param {string} session - session_id
* @param {string} since - ISO timestamp inizio (opzionale, default -30d)
* @returns {string} CSV content
*/ */
async function exportSessionCSV(sensor, session, since) { async function exportSessionCSV(sensor, session, since) {
const start = since || '-30d'; const start = since || '-30d';
@@ -106,7 +127,6 @@ async function exportSessionCSV(sensor, session, since) {
if (rows.length === 0) return ''; if (rows.length === 0) return '';
// Raccogli tutti i field names (esclusi meta InfluxDB)
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']); const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
const fieldNames = new Set(); const fieldNames = new Set();
for (const row of rows) { for (const row of rows) {
@@ -133,4 +153,4 @@ async function exportSessionCSV(sensor, session, since) {
return header + '\n' + csvRows.join('\n') + '\n'; return header + '\n' + csvRows.join('\n') + '\n';
} }
module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV }; module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV, BUCKETS };

View File

@@ -3,6 +3,7 @@ const { decode } = require('@msgpack/msgpack');
const { consumeConnectionToken, appendAsConnection, query, hset, del } = require('../store/redis'); const { consumeConnectionToken, appendAsConnection, query, hset, del } = require('../store/redis');
const { writeGenericData, writeForecastBatch } = require('../store/influx'); const { writeGenericData, writeForecastBatch } = require('../store/influx');
const db = require('../store/db'); const db = require('../store/db');
const kioskRelay = require('./kiosk');
// In-memory registries // In-memory registries
const sensorWatchers = new Map(); // sensorName → Set<WebSocket> (watchers) const sensorWatchers = new Map(); // sensorName → Set<WebSocket> (watchers)
@@ -42,6 +43,9 @@ function setup(server) {
handleSensorConnection(ws); handleSensorConnection(ws);
}); });
} else if (path === '/kiosk') {
await kioskRelay.handleUpgrade(wss, req, socket, head, url);
} else if (path === '/live') { } else if (path === '/live') {
wss.handleUpgrade(req, socket, head, (ws) => { wss.handleUpgrade(req, socket, head, (ws) => {
handleWatcherConnection(ws); handleWatcherConnection(ws);
@@ -95,6 +99,58 @@ async function handleSensorConnection(ws) {
return; return;
} }
// Reset sessione richiesto dal plugin (es. dopo un nuovo ruleset
// di logs/meteo). La connessione WS persiste: cambiamo solo il
// sessionId, marchiamo la vecchia come disconnessa e creiamo la
// nuova in sessiondataref. I dati successivi useranno il nuovo tag.
if (packet._t === 'session_reset') {
const prev = ws.sessionId;
const next = generateSessionId();
ws.sessionId = next;
console.log(`[${sensorName}] session_reset ${prev}${next} (reason: ${packet.reason || 'n/a'})`);
try {
await db.query('sensors',
`UPDATE sessiondataref SET disconnected_at = NOW() WHERE session_id = $1 AND disconnected_at IS NULL`,
[prev]
);
await db.query('sensors',
`INSERT INTO sessiondataref (session_id, sensor_name, name, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (session_id) DO NOTHING`,
[next, sensorName, next]
);
} catch (err) {
console.error(`[${sensorName}] session_reset DB error:`, err.message);
}
hset(`sensors:${sensorName}`, 'session', next);
try {
const { encode } = require('@msgpack/msgpack');
ws.send(encode({ _t: 'session_id', sessionId: next, prev }));
} catch (err) {
console.error(`[${sensorName}] session_reset reply error:`, err.message);
}
return;
}
// ACK di un ruleset ricevuto e applicato: il plugin ci dice
// che la versione X del tipo Y e' ora attiva sul device.
if (packet._t === 'ruleset_ack') {
const { type, ruleset_id } = packet;
if (type && ruleset_id) {
const API = process.env.API_URL || 'http://meb-api:3000';
const KEY = process.env.INTERNAL_API_KEY;
if (KEY) {
fetch(`${API}/rules/${type}/${ruleset_id}/ack`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
body: JSON.stringify({ sensor: sensorName })
}).catch(err => console.error(`[${sensorName}] ruleset_ack forward error:`, err.message));
}
console.log(`[${sensorName}] ruleset_ack type=${type} id=${ruleset_id}`);
}
return;
}
const { ts, _m, ...fields } = packet; const { ts, _m, ...fields } = packet;
// InfluxDB: usa SEMPRE sessionId come tag (non cambia mai) // InfluxDB: usa SEMPRE sessionId come tag (non cambia mai)
@@ -203,4 +259,4 @@ function handleWatcherConnection(ws) {
}); });
} }
module.exports = { setup, connectedSensors }; module.exports = { setup, connectedSensors, kioskRelay };

167
realtime/src/ws/kiosk.js Normal file
View File

@@ -0,0 +1,167 @@
/**
* Kiosk realtime relay.
* Due ruoli:
* - device: il plugin kiosk sulla barca (uno per sensorName)
* - controller: la pagina kiosklive.html (N per sensorName)
* Messaggi JSON (no msgpack, canale leggero).
*/
const jwt = require('jsonwebtoken');
const { consumeConnectionToken } = require('../store/redis');
const devices = new Map(); // sensorName → ws
const controllers = new Map(); // sensorName → Set<ws>
const JWT_SECRET = process.env.JWT_SECRET;
function verifyJwt(token) {
if (!token || !JWT_SECRET) return null;
try { return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); } catch { return null; }
}
function extractCookie(req, name) {
const raw = req.headers.cookie || '';
const m = raw.split(';').map(s => s.trim()).find(s => s.startsWith(name + '='));
return m ? decodeURIComponent(m.slice(name.length + 1)) : null;
}
function send(ws, obj) {
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
}
function broadcastControllers(sensorName, obj) {
const set = controllers.get(sensorName);
if (!set) return;
const msg = JSON.stringify(obj);
for (const c of set) if (c.readyState === c.OPEN) c.send(msg);
}
function deviceStatus(sensorName) {
const d = devices.get(sensorName);
return {
t: 'kiosk_status',
online: !!d,
templateId: d?.templateId || null,
lastSeen: d?.lastSeen || null
};
}
/**
* Gestisce l'upgrade per /kiosk?role=device|controller&sensor=<name>
* @returns true se gestito
*/
async function handleUpgrade(wss, req, socket, head, url) {
const role = url.searchParams.get('role');
const sensorName = url.searchParams.get('sensor');
if (!role || !sensorName) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return true;
}
if (role === 'device') {
const token = url.searchParams.get('token');
const sensor = token ? await consumeConnectionToken(token) : null;
if (!sensor || sensor !== sensorName) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return true;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.sensorName = sensorName;
ws.role = 'device';
attachDevice(ws);
});
return true;
}
if (role === 'controller') {
const token = extractCookie(req, 'auth_token') || url.searchParams.get('token');
const payload = verifyJwt(token);
if (!payload) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return true;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.sensorName = sensorName;
ws.role = 'controller';
ws.user = payload.sub;
attachController(ws);
});
return true;
}
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return true;
}
function attachDevice(ws) {
const name = ws.sensorName;
const prev = devices.get(name);
if (prev && prev.readyState === prev.OPEN) prev.close(4000, 'replaced');
devices.set(name, ws);
ws.lastSeen = Date.now();
broadcastControllers(name, deviceStatus(name));
console.log(`[kiosk] device online: ${name}`);
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw.toString()); } catch { return; }
ws.lastSeen = Date.now();
switch (m.t) {
case 'hello':
ws.templateId = m.templateId || null;
broadcastControllers(name, deviceStatus(name));
break;
case 'ack':
broadcastControllers(name, m);
break;
case 'heartbeat':
break;
default:
// echo diagnostici opzionali
break;
}
});
const hb = setInterval(() => { if (ws.readyState === ws.OPEN) ws.ping(); }, 25000);
ws.on('close', () => {
clearInterval(hb);
if (devices.get(name) === ws) devices.delete(name);
broadcastControllers(name, deviceStatus(name));
console.log(`[kiosk] device offline: ${name}`);
});
ws.on('error', () => {});
}
function attachController(ws) {
const name = ws.sensorName;
if (!controllers.has(name)) controllers.set(name, new Set());
controllers.get(name).add(ws);
send(ws, deviceStatus(name));
console.log(`[kiosk] controller connected on ${name} (total=${controllers.get(name).size})`);
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw.toString()); } catch { return; }
const allowed = ['patch_box','add_box','remove_box','load_template','apply_inline','persist','reload'];
if (!allowed.includes(m.t)) return;
const device = devices.get(name);
if (!device || device.readyState !== device.OPEN) {
send(ws, { t: 'ack', cmdId: m.cmdId, ok: false, err: 'device offline' });
return;
}
send(device, m);
});
ws.on('close', () => {
const set = controllers.get(name);
if (set) { set.delete(ws); if (!set.size) controllers.delete(name); }
});
ws.on('error', () => {});
}
/** HTTP notify usato dall'API quando cambia template attivo */
function notifyActiveTemplateChange(template) {
for (const [name, ws] of devices) {
send(ws, { t: 'load_template', templateId: template.id });
}
for (const name of controllers.keys()) {
broadcastControllers(name, { t: 'active_template_changed', templateId: template.id });
}
}
module.exports = { handleUpgrade, notifyActiveTemplateChange, devices };