feat: initialize microservice architecture with auth, api, realtime, copernicus, ml, and console modules

This commit is contained in:
Giuseppe Raffa
2026-03-28 15:29:34 +01:00
commit bcfce32adb
89 changed files with 12025 additions and 0 deletions

0
.env.example Normal file
View File

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
node_modules

2
api/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

14
api/.env.example Normal file
View File

@@ -0,0 +1,14 @@
PORT=3003
VERSION=2.0.0
VERSION_BUILD=1.0
VERSION_STATE=pre-release
INFLX_URL=
INFLX_TOKEN=
INFLX_ORG=
MINIO_ENDPOINT=
MINIO_PORT=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=

13
api/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
EXPOSE 3003
CMD ["node", "src/index.js"]

1541
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
api/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "meb-api-service",
"version": "2.0.0",
"description": "API microservice for MEB console - Node.js",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"generate-docs": "node -e \"console.log(JSON.stringify(require('./swagger-specs-logic')))\" > openapi.json"
},
"dependencies": {
"@influxdata/influxdb-client": "^1.35.0",
"@influxdata/influxdb-client-apis": "^1.35.0",
"cookie-parser": "^1.4.7",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"minio": "^8.0.7",
"pg": "^8.20.0"
}
}

73
api/src/index.js Normal file
View File

@@ -0,0 +1,73 @@
const express = require('express');
const parser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const app = express();
const PORT = process.env.PORT;
const version = process.env.VERSION;
const vBuild = process.env.VERSION_BUILD;
const vState = process.env.VERSION_STATE;
app.use(express.json());
app.use(parser());
app.get('/', (req, res) => {
res.redirect('/health');
});
app.get('/health', (req, res) => {
res.json({
status: "ok",
service: "api",
version: version,
build_number: vBuild,
version_state: vState
});
});
// Route pubblica: autenticazione tramite SENSOR_CODE (per il plugin)
const paramsSensorRoutes = require('./routes/params.sensor');
app.use('/params/sensor', paramsSensorRoutes);
// Middleware di autenticazione per le API
app.use((req, res, next) => {
if (req.path === '/health' || req.path === '/') return next();
// 1. Service-to-service: x-api-key header
const apiKey = req.headers['x-api-key'];
if (apiKey && apiKey === process.env.INTERNAL_API_KEY) {
req.internal = true;
return next();
}
// 2. User auth: cookie o Authorization header
const token = req.cookies?.auth_token
|| (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');
app.use('/data', dataRoutes);
const storageRoutes = require('./routes/storage')
app.use('/storage', storageRoutes)
const paramsRoutes = require('./routes/params')
app.use('/params', paramsRoutes)
// Avvio del server
app.listen(PORT, () => {
console.log(`Started on port ${PORT}`);
});

27
api/src/routes/data.js Normal file
View File

@@ -0,0 +1,27 @@
// Collezione di tutte le api che usando influxdb e la telemetria della barca
//api.mebboat.it/data
const router = require('express').Router();
const { write, query, writeBatch } = require('../storage/influx');
router.post('/write', async (req, res) => {
const { measurement, sensor, data } = req.body;
await write(measurement, sensor, data);
res.json({ status: "ok" });
});
router.post('/write/batch', async (req, res) => {
const { points } = req.body;
await writeBatch(points);
res.json({ result: "added" });
});
router.get('/query', async (req, res) => {
const { collection, timeFromNow, measurement, sensor, field } = req.query;
const result = await query(collection, timeFromNow, measurement, sensor, field);
res.json(result);
});
module.exports = router;

216
api/src/routes/params.js Normal file
View File

@@ -0,0 +1,216 @@
const router = require('express').Router();
const { query } = require('../storage/postgres');
const sets = ['forecasts', 'marine', 'sensors', 'telemetry'];
/* ─── READS ─── */
/**
* GET /params/active?set=sensors
* Restituisce il set attivo per il tipo richiesto
*/
router.get('/active', async (req, res) => {
const { set } = req.query;
if (!set || !sets.includes(set))
return res.status(400).json({ error: 'SET parameter invalid' });
try {
const result = await query(`SELECT * FROM ${set} WHERE active = true LIMIT 1`, [], 'references');
res.json(result.rows[0] || null);
} catch (err) {
console.error('[PARAMS] Error reading active set:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /params/sets?type=sensors
* Lista tutti i set di un tipo (con paginazione)
*/
router.get('/sets', async (req, res) => {
const { type } = req.query;
if (!type || !sets.includes(type))
return res.status(400).json({ error: 'type parameter invalid' });
try {
const result = await query(
`SELECT id, name, description, mayor, minor, patch, state, active, tags, created_at, updated_at
FROM ${type} ORDER BY updated_at DESC`,
[], 'references'
);
res.json(result.rows);
} catch (err) {
console.error('[PARAMS] Error listing sets:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /params/sets/:id?type=sensors
* Restituisce un singolo set completo (con content)
*/
router.get('/sets/:id', async (req, res) => {
const { type } = req.query;
const { id } = req.params;
if (!type || !sets.includes(type))
return res.status(400).json({ error: 'type parameter invalid' });
try {
const result = await query(`SELECT * FROM ${type} WHERE id = $1`, [id], 'references');
if (!result.rows[0]) return res.status(404).json({ error: 'Set not found' });
res.json(result.rows[0]);
} catch (err) {
console.error('[PARAMS] Error reading set:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
/* ─── WRITES ─── */
/**
* POST /params/sets?type=sensors
* Crea un nuovo set di regole
*/
router.post('/sets', async (req, res) => {
const { type } = req.query;
if (!type || !sets.includes(type))
return res.status(400).json({ error: 'type parameter invalid' });
const { name, description, content, tags } = req.body;
if (!name || !content) return res.status(400).json({ error: 'name and content required' });
try {
const result = await query(
`INSERT INTO ${type} (name, description, mayor, minor, patch, state, active, tags, content)
VALUES ($1, $2, 1, 0, 0, 'draft', false, $3, $4) RETURNING *`,
[name, description || '', tags || [], JSON.stringify(content)],
'references'
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error('[PARAMS] Error creating set:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* PUT /params/sets/:id?type=sensors
* Aggiorna un set esistente
*/
router.put('/sets/:id', async (req, res) => {
const { type } = req.query;
const { id } = req.params;
if (!type || !sets.includes(type))
return res.status(400).json({ error: 'type parameter invalid' });
const { name, description, content, tags, state } = req.body;
try {
const fields = [];
const values = [];
let idx = 1;
if (name !== undefined) { fields.push(`name = $${idx++}`); values.push(name); }
if (description !== undefined) { fields.push(`description = $${idx++}`); values.push(description); }
if (content !== undefined) { fields.push(`content = $${idx++}`); values.push(JSON.stringify(content)); }
if (tags !== undefined) { fields.push(`tags = $${idx++}`); values.push(tags); }
if (state !== undefined) { fields.push(`state = $${idx++}`); values.push(state); }
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
fields.push(`updated_at = NOW()`);
values.push(id);
const result = await query(
`UPDATE ${type} SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values, 'references'
);
if (!result.rows[0]) return res.status(404).json({ error: 'Set not found' });
res.json(result.rows[0]);
} catch (err) {
console.error('[PARAMS] Error updating set:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST /params/sets/:id/activate?type=sensors
* Attiva un set (disattiva tutti gli altri dello stesso tipo)
*/
router.post('/sets/:id/activate', async (req, res) => {
const { type } = req.query;
const { id } = req.params;
if (!type || !sets.includes(type))
return res.status(400).json({ error: 'type parameter invalid' });
try {
// Disattiva tutti
await query(`UPDATE ${type} SET active = false WHERE active = true`, [], 'references');
// Attiva quello richiesto
const result = await query(
`UPDATE ${type} SET active = true, state = 'active', updated_at = NOW() WHERE id = $1 RETURNING *`,
[id], 'references'
);
if (!result.rows[0]) return res.status(404).json({ error: 'Set not found' });
res.json(result.rows[0]);
} catch (err) {
console.error('[PARAMS] Error activating set:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* DELETE /params/sets/:id?type=sensors
* Elimina un set (solo se non attivo)
*/
router.delete('/sets/:id', async (req, res) => {
const { type } = req.query;
const { id } = req.params;
if (!type || !sets.includes(type))
return res.status(400).json({ error: 'type parameter invalid' });
try {
// Controlla che non sia attivo
const check = await query(`SELECT active FROM ${type} WHERE id = $1`, [id], 'references');
if (!check.rows[0]) return res.status(404).json({ error: 'Set not found' });
if (check.rows[0].active) return res.status(409).json({ error: 'Cannot delete an active set' });
await query(`DELETE FROM ${type} WHERE id = $1`, [id], 'references');
res.json({ deleted: true });
} catch (err) {
console.error('[PARAMS] Error deleting set:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* PUT /params/sets/:id/version?type=sensors
* Incrementa la versione (bump)
*/
router.put('/sets/:id/version', async (req, res) => {
const { type } = req.query;
const { id } = req.params;
const { bump } = req.body; // 'major', 'minor', 'patch'
if (!type || !sets.includes(type))
return res.status(400).json({ error: 'type parameter invalid' });
const field = bump === 'major' ? 'mayor' : bump === 'minor' ? 'minor' : 'patch';
const resets = bump === 'major' ? ', minor = 0, patch = 0' : bump === 'minor' ? ', patch = 0' : '';
try {
const result = await query(
`UPDATE ${type} SET ${field} = ${field} + 1 ${resets}, updated_at = NOW() WHERE id = $1 RETURNING *`,
[id], 'references'
);
if (!result.rows[0]) return res.status(404).json({ error: 'Set not found' });
res.json(result.rows[0]);
} catch (err) {
console.error('[PARAMS] Error bumping version:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

View File

@@ -0,0 +1,51 @@
const router = require('express').Router();
const crypto = require('crypto');
const { query } = require('../storage/postgres');
const sets = ['forecasts', 'sensors'];
function hashSensorCode(code) {
return crypto.createHash('sha256').update(code).digest('hex');
}
/**
* GET /params/sensor/:sensorCode/active?set=sensors
* Autenticazione tramite SENSOR_CODE (stesso meccanismo di realtime)
*/
router.get('/:sensorCode/active', async (req, res) => {
const { sensorCode } = req.params;
const { set } = req.query;
if (!set || !sets.includes(set))
return res.status(400).json({ error: 'SET parameter invalid' });
try {
const hashed = hashSensorCode(sensorCode);
const sensor = await query(
'SELECT id, is_active FROM sensors WHERE code_hash = $1',
[hashed],
'sensors'
);
if (!sensor.rows[0]) {
return res.status(401).json({ error: 'Sensor code not valid' });
}
if (!sensor.rows[0].is_active) {
return res.status(403).json({ error: 'Sensor is not active' });
}
const result = await query(
`SELECT * FROM ${set} WHERE active = true LIMIT 1`,
[],
'references'
);
res.json(result.rows[0] || null);
} catch (err) {
console.error('[PARAMS/SENSOR] Error:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

59
api/src/routes/storage.js Normal file
View File

@@ -0,0 +1,59 @@
// Collezzione di tutte le api che prendono i dati da minio
//api.mebboat.it/storage
const express = require('express');
const { getBuckets, getBucket, getObject, getObjects, getFileStream } = require('../storage/minio');
const router = express.Router();
/**
* Restituisce una lista con tutti i bucket del database,
* altrimenti restituisce i file del bucket passato come parametro
*
* @param {string} bucket - il bucket
*/
router.get('/', async (req, res) => {
const { bucket } = req.query;
if (bucket == undefined) {
const buckets = await getBuckets();
res.status(200).json(buckets);
} else {
const returnBucket = await getBucket(bucket);
res.status(200).json(returnBucket);
}
})
router.get('/files', async (req, res) => {
const { bucket, fileID } = req.query;
if (bucket == undefined) {
res.status(400).json({ error: "No bucket name in the request" });
} else {
if (fileID == undefined) {
const files = await getObjects(bucket);
res.status(200).json(files);
} else {
const file = await getObject(bucket, fileID);
res.status(200).json(file);
}
}
})
router.get('/file', async (req, res) => {
const { bucket, fileID } = req.query;
const stream = await getFileStream(bucket, fileID);
res.setHeader('Content-Type', 'application/octet-stream');
stream.pipe(res);
})
router.post('/upload', async (req, res) => {
const { bucket } = req.query;
const files = await getObjects(bucket);
res.status(200).json(files);
})
router.get('/download', async (req, res) => {
})
module.exports = router;

67
api/src/storage/influx.js Normal file
View File

@@ -0,0 +1,67 @@
const { InfluxDB, Point } = require('@influxdata/influxdb-client');
const url = process.env.INFLX_URL;
const token = process.env.INFLX_TOKEN;
const org = process.env.INFLX_ORG;
const boatTelemetry = "boat"
const client = new InfluxDB({ url, token })
const write = client.getWriteApi(org, boatTelemetry);
const querying = client.getQueryApi(org);
async function append(measurement, sensor, data) {
const point = new Point(measurement)
.tag("sensor", sensor)
.floatField('temperature', data.temperature)
.floatField('humidity', data.humidity)
write.writePoint(point);
await write.flush();
}
async function writeBatch(datas) {
datas.forEach(data => {
append(data.measurement, data.sensor, data.data);
})
}
async function query(bucket, relativeTime, measurement, sensor, field) {
const fluxTimeRange = relativeTime || "-1h";
let fluxQuery = `
from(bucket: "${bucket}")
|> range(start: ${fluxTimeRange})
|> filter(fn: (r) => r._measurement == "${measurement}")`;
if (sensor) {
fluxQuery += `\n |> filter(fn: (r) => r.sensor == "${sensor}")`;
}
if (field) {
fluxQuery += `\n |> filter(fn: (r) => r._field == "${field}")`;
}
fluxQuery += `\n |> yield(name: "data")`;
try {
const data = [];
for await (const { values, tableMeta } of querying.iterateRows(fluxQuery)) {
data.push(tableMeta.toObject(values));
}
return data;
} catch (error) {
console.error("Error in query:", error);
return [];
}
}
module.exports = {
write:append,
writeBatch,
query
}

136
api/src/storage/minio.js Normal file
View File

@@ -0,0 +1,136 @@
const Minio = require('minio');
const client = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT) || 9000,
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY
})
// Buckets
/**
*
* @param {String} bucket - Il nome del bucket
* @returns {String} - restituisce il nome del bucket creato
*/
async function bucketExists(bucket) {
const exists = await client.bucketExists(bucket);
if(!exists) {
await client.makeBucket(bucket);
}
return bucket
}
/**
* Restituisce un array con tutti i bucket sul server
* @returns {Array} - i diversi bucket presenti sul server
*/
async function getBuckets() {
const buckets = await client.listBuckets();
return buckets;
}
/**
* Restituisce i metadata del bucket passato come parametro
* @param {String} bucket - il nome del bucket
* @returns {Object} - i metadata del bucket
*/
async function getBucket(bucket) {
const buckets = await client.listBuckets();
return buckets.filter(b => b.name === bucket);
}
/**
* Restituisce un array con tutti gli oggetti presenti nel bucket passato come parametro
* @param {String} bucket - il nome del bucket
* @returns {Promise<Array>} - i file del bucket
*/
async function getObjects(bucket) {
return new Promise((resolve, reject) => {
const objects = [];
const stream = client.listObjects(bucket, '', true);
stream.on('data', obj => objects.push(obj));
stream.on('error', err => reject(err));
stream.on('end', () => resolve(objects));
});
}
/**
* Restituisce i metadata del file con id passato come parametro presente nel bucket
* @param {String} bucket - il nome del bucket
* @param {String} objectName - il nome dell'oggetto
* @returns {Object} - i metadata del file
*/
async function getObject(bucket, objectName) {
const item = await client.statObject(bucket, objectName);
return item;
}
/**
* Elimina il file con l'id passato a parametro dal bucket
* @param {String} bucket - il nome del bucket
* @param {String} objectName - il nome dell'oggetto
*/
async function removeObject(bucket, objectName) {
await client.removeObject(bucket, objectName);
}
//Upload - Download
/**
* Carica un file nel bucket come buffer
* @param {String} bucket - il nome del bucket
* @param {String} objectName - il nome che avrà il file in Minio
* @param {Buffer} fileBuffer - il file caricato
* @param {Number} size - dimensione del file
* @param {String} contentType - mimetype (es. 'image/png')
*/
async function upload(bucket, objectName, fileBuffer, size, contentType) {
await bucketExists(bucket);
const metaData = {
'Content-Type': contentType || 'application/octet-stream',
}
const result = await client.putObject(bucket, objectName, fileBuffer, size, metaData);
return result;
}
/**
* Genera un URL temporaneo di download
* @param {String} bucket - il nome del bucket
* @param {String} objectName - il nome del file
* @param {Number} expiry - quanto dura il link in secondi (default: 24h = 86400)
*
* @returns {String} url - url di download del file
*/
async function download(bucket, objectName, expiry = 86400) {
const url = await client.presignedGetObject(bucket, objectName, expiry);
return url;
}
/**
* Recupera e ritorna lo stream dei dati dal server Minio (per leggere il contenuto via API)
* @param {String} bucket - il nome del bucket
* @param {String} objectName - il nome dell'oggetto
*/
async function getFileStream(bucket, objectName) {
const dataStream = await client.getObject(bucket, objectName);
return dataStream;
}
module.exports = {
bucketExists,
getBuckets,
getBucket,
getObjects,
getObject,
removeObject,
upload,
download,
getFileStream
}

View File

@@ -0,0 +1,79 @@
const { Pool } = require('pg');
const config = {
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
host: process.env.PG_HOST,
port: process.env.PG_PORT,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
}
const pools = {
users: new Pool({ ...config, database: process.env.DATA_DB }),
references: new Pool({ ...config, database: process.env.REFERENCES_DB }),
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' })
}
Object.entries(pools).forEach(([name, pool]) => {
pool.on('error', (err) => {
console.error(`Error in ${name} pool`, err);
})
});
/**
*
* @param {'users' | 'references'} db - the name of the database
* @returns {Promise<import('pg').PoolClient>}
*/
async function getClient(db) {
const pool = pools[db];
if (!pool) throw new Error(`Database pool type ${db} does not exist`);
return await pool.connect();
}
/**
* Esegue una query sul database specificato
* @param {string} text - Query SQL
* @param {any[]} params - Parametri
* @param {'users' | 'references'} name - Quale DB usare
*/
async function query(text, params, name = 'users') {
const client = await getClient(name);
try {
return await client.query(text, params);
} catch (error) {
console.error(`[DB Query Error on ${name}]`, error.message);
throw error;
} finally {
client.release();
}
}
/**
* Inserisce una riga in una tabella
*/
async function append(table, data, type = 'users') {
const keys = Object.keys(data);
const values = Object.values(data);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const columns = keys.join(', ');
const sql = `INSERT INTO ${table} (${columns}) VALUES (${placeholders}) RETURNING *`;
return await query(sql, values, type);
}
/**
* Rimuove una riga
*/
async function remove(table, condition, params, type = 'users') {
const sql = `DELETE FROM ${table} WHERE ${condition}`;
return await query(sql, params, type);
}
module.exports = {
query,
append,
remove,
getClient,
pools
};

13
auth/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
EXPOSE 3006
CMD ["node", "src/index.js"]

1402
auth/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
auth/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "meb-auth-service",
"version": "1.2.0",
"description": "Servizio di Sicurezza e Autenticazione per il server MEB",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"generate-docs": "node -e \"console.log(JSON.stringify(require('./swagger-specs-logic')))\" > openapi.json"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"nunjucks": "^3.2.4",
"pg": "^8.20.0",
"ua-parser-js": "^2.0.9",
"uuid": "^13.0.0"
}
}

120
auth/src/core/auth.core.js Normal file
View File

@@ -0,0 +1,120 @@
const query = require('../storage/database').query;
const track = require('../tools/tracking')
const { v4: uuid } = require('uuid');
const security = require('../tools/security')
/**
* Registra un nuovo utente
*/
async function register(username, password) {
const userExists = await query('SELECT id FROM users WHERE username = $1', [username]);
if (userExists.rows.length > 0) {
throw new Error('User already exists');
}
const hashedPassword = security.hashPassword(password);
const id = uuid();
await query('INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', [id, username, hashedPassword]);
return {
success: true,
user: {
id,
username
}
};
}
/**
* Esegue il login di un utente
*/
async function login(username, password) {
const result = await query('SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', [username]);
if (result.rows.length === 0) {
throw new Error('No user matched')
}
const user = result.rows[0];
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
}
}
/**
* Esegue il logout di un utente
*
*/
async function logout(sessionID) {
if (!sessionID) {
throw new Error('no sessio id passed');
}
const result = await query('UPDATE sessions SET is_revoked = TRUE WHERE id = $1', [sessionID]);
return result.rowCount > 0;
}
/**
* Crea una nuova sessione per un utente che ha appaena eseguito il login
*/
async function newSession(userId, userAgent, ip) {
const id = uuid();
const sessionCode = security.generateSessionCode();
const metadata = track.getBasicMetadata(userAgent);
await query(
`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)`,
[id, userId, sessionCode, '', ip, userAgent, metadata.browser, metadata.os, metadata.device_type]
);
return { id, sessionCode };
}
/**
* Valida una sessione
*/
async function validateSession(token) {
const parsed = security.parseSessionToken(token);
if (!parsed) {
throw new Error('Invalid token format');
}
const { code, username } = parsed;
const result = await query('SELECT s.id as session_id, s.user_id, u.username, u.is_active, u.created_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_code = $1 AND s.is_revoked = FALSE', [code]);
if (result.rows.length === 0) {
throw new Error('Session not found or revoked')
}
const session = result.rows[0];
if (session.username !== username) {
throw new Error('Session user mismatch');
}
if (!session.is_active) {
throw new Error('Session is not active');
}
}
module.exports = {
register,
login,
logout,
newSession,
validateSession
}

View File

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

49
auth/src/index.js Normal file
View File

@@ -0,0 +1,49 @@
const express = require('express');
const nunjucks = require('nunjucks');
const path = require('path');
const parser = require('cookie-parser');
const database = require('./storage/database');
const app = express();
const PORT = process.env.PORT || 3006;
const version = process.env.VERSION;
const vBuild = process.env.VERSION_BUILD;
const vState = process.env.VERSION_STATE;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(parser());
// Static files
const staticFolder = path.join(__dirname, 'static');
app.use('/static', express.static(staticFolder));
// Nunjucks templates
const templatesFolder = path.join(__dirname, 'templates');
nunjucks.configure(templatesFolder, {
autoescape: true,
express: app,
noCache: true,
watch: false
});
app.set('views', templatesFolder);
app.set('view engine', 'html');
// Routes
const authRoutes = require('./routes/auth');
app.use('/', authRoutes);
// Startup
async function start() {
await database.initDb();
app.listen(PORT, () => {
console.log(`[AUTH] Started on port ${PORT}`);
});
}
start().catch(err => {
console.error('[AUTH] Failed to start:', err);
process.exit(1);
});

97
auth/src/routes/auth.js Normal file
View File

@@ -0,0 +1,97 @@
const router = require('express').Router();
const auth = require('../core/auth.core');
const jwt = require('../tools/jwt');
const version = process.env.VERSION;
const vBuild = process.env.VERSION_BUILD;
const vState = process.env.VERSION_STATE;
const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
router.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'auth',
version: version,
build_number: vBuild,
version_state: vState
});
});
router.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username e password richiesti' });
}
try {
await auth.register(username, password);
res.status(201).end();
} catch (err) {
console.error('[AUTH] Register failed:', err.message);
const status = err.message === 'User already exists' ? 409 : 500;
res.status(status).json({ error: err.message });
}
});
router.get('/login', (req, res) => {
const redirect = req.query.redirect || '';
res.render('loginpage', { error: null, redirect });
});
router.post('/login', async (req, res) => {
const { username, password, redirect } = req.body;
try {
const user = await auth.login(username, password);
const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip);
const token = jwt.generateToken(user, session.id);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 giorni
};
if (COOKIE_DOMAIN) {
cookieOptions.domain = COOKIE_DOMAIN;
}
res.cookie('auth_token', token, cookieOptions);
// Redirect alla pagina da cui l'utente e' arrivato, o alla console
const destination = redirect || CONSOLE_URL;
res.redirect(destination);
} catch (err) {
console.error('[AUTH] Login failed:', err.message, err.stack);
res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
}
});
router.post('/logout', async (req, res) => {
const token = req.cookies && req.cookies.auth_token;
if (token) {
try {
const verified = jwt.verifyToken(token);
if (verified.valid) {
await auth.logout(verified.payload.session_id);
}
} catch (err) {
console.error('[AUTH] Logout error:', err.message);
}
}
const clearOptions = { httpOnly: true, sameSite: 'lax' };
if (COOKIE_DOMAIN) {
clearOptions.domain = COOKIE_DOMAIN;
}
res.clearCookie('auth_token', clearOptions);
res.redirect('/login');
});
module.exports = router;

View File

2
auth/src/routes/users.js Normal file
View File

@@ -0,0 +1,2 @@
const router = require('express').Router();

BIN
auth/src/static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,99 @@
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
user-select: none;
}
/* Fix size even if the title gets bigger when hovered*/
.login {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 30px;
padding: 50px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
span.prominent-title {
color: var(--accent-color);
font-weight: 700;
transition: all 0.3s ease;
}
span.prominent-title:hover {
font-size: 26px;
/* Animated color gradient transitioning with all the colors of the rainbow, animated when hovere*/
background: linear-gradient(90deg, #002bff, #7a00ff, #ff00c8);
background-size: 200% 200%;
animation: gradient 5s ease infinite;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.login header {
padding: 40px;
font-size: 24px;
font-weight: 700;
}
.login form {
padding: 20px;
align-items: center;
align-self: center;
align-content: center;
}
.login form .group {
display: flex;
flex-direction: column;
justify-content: left;
align-items: left;
margin-bottom: 20px;
}
.login form .group label {
font-size: 10px;
font-weight: 600;
align-items: left;
color: var(--text-secondary);
margin-bottom: 5px;
}
.login form .group input {
padding: 10px;
border-radius: 15px;
border: 1px solid #ccc;
font-size: 14px;
font-weight: 600;
margin-bottom: 5px;
width: 100%;
transition: all 0.3s ease;
}
.login form .group input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,201 @@
:root {
--accent-color: #2563eb;
--accent-hover: #1d4ed8;
--accent-light: #eff6ff;
--accent-border: #bfdbfe;
--text-primary: #0f172a;
--text-secondary: #4755698f;
--text-tertiary: #94a3b8c0;
--surface: #f8fafc;
--header-bg: rgba(255, 255, 255, 0.85);
/* For Glassmorphism */
--header-border: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--radius-md: 8px;
--radius-lg: 12px;
}
* {
margin: 0;
padding: 0;
}
@font-face {
font-family: 'Normal';
src: url('../font/Quicksand-VariableFont_wght.ttf');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Bold';
src: url('../font/Quicksand-VariableFont_wght.ttf');
font-weight: 700;
font-style: normal;
}
body {
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
button {
padding: 10px 24px;
border-radius: var(--radius-lg);
border: 1px solid var(--header-border);
background-color: var(--bg-surface);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
font-family: 'Bold', inherit;
cursor: pointer;
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
white-space: nowrap;
flex-shrink: 0;
}
button:hover {
background-color: var(--accent-light);
color: var(--accent-color);
border-color: var(--accent-border);
}
button.prominent {
background-color: var(--accent-color);
color: #ffffff;
border: 1px solid transparent;
box-shadow: var(--shadow-sm);
}
button.prominent:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
button.prominent:active {
transform: translateY(1px);
box-shadow: var(--shadow-sm);
}
/* INFO PANEL */
.info-panel {
display: block;
text-align: center;
align-content: center;
padding: 60px 20px;
user-select: none;
}
.info-panel h3 {
margin-bottom: 10px;
}
.info-panel p {
margin-bottom: 25px;
}
.info-panel .icon {
font-size: 48px;
margin-bottom: 20px;
align-self: center;
transition: transform 0.12s ease;
}
/* GRID & CARD ITEMS */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-inline: 30px;
}
.card {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 1rem;
border: 1px solid var(--header-border);
border-radius: 20px;
text-decoration: none;
color: var(--text-primary);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.card h3 {
margin: 0;
font-size: 1rem;
text-align: left;
}
.card p {
margin: 0.25rem 0 0;
color: var(--text-secondary);
opacity: 0.4;
font-size: 0.8rem;
text-align: left;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px #bfdbfe30;
}
.card.standalone {
grid-column: 1 / -1;
}
/* HEADER */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background-color: var(--header-bg);
border-bottom: 1px solid var(--header-border);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 10;
user-select: none;
}
.header h1 {
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.header .profile {
display: flex;
align-items: center;
gap: 16px;
}
.header .profile p {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
padding-inline: 5px;
}

View File

@@ -0,0 +1,105 @@
const { Pool } = require('pg');
const config = {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
port: process.env.DB_PORT,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
}
const pool = new Pool({ ...config, database: process.env.DB_NAME });
pool.on('error', (err) => {
console.error('Error in database', err);
});
/**
* Execute a query with parameters
* @param {string} text - SQL query
* @param {Array} params - Query parameters
* @returns {Promise<Object>} Query result
*/
async function query(text, params) {
const start = Date.now();
const result = await pool.query(text, params);
const duration = Date.now() - start;
if (duration > 100) {
console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80));
}
return result;
}
/**
* Get a client from pool for transactions
* @returns {Promise<Object>} Pool client
*/
async function getClient() {
return await pool.connect();
}
/**
* Initialize database and ensure tables exist
*/
async function initDb() {
// Test connection
await pool.query('SELECT NOW()');
// Ensure pgcrypto extension (provides gen_random_uuid)
// Note: creating extensions requires proper DB permissions (usually superuser in PG)
try {
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
} catch (err) {
console.warn('[DB] Could not create pgcrypto extension (may require superuser):', err.message);
}
// Ensure tables exist (UUID default generated by DB)
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
telegram_id VARCHAR(50) UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_code VARCHAR(64) NOT NULL,
encoded_username TEXT NOT NULL,
ip_address INET,
user_agent TEXT,
browser VARCHAR(100),
os VARCHAR(100),
device_type VARCHAR(50),
location_country VARCHAR(100),
location_city VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
last_active TIMESTAMP DEFAULT NOW(),
is_revoked BOOLEAN DEFAULT FALSE
);
-- Altera colonna in base al nuovo standard token 32 byte - 64 url chars
ALTER TABLE sessions ALTER COLUMN session_code TYPE VARCHAR(64);
CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
`);
}
module.exports = {
pool,
query,
getClient,
initDb
};

36
auth/src/storage/redis.js Normal file
View File

@@ -0,0 +1,36 @@
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
maxRetriesPerRequest: 3,
lazyConnect: true,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
}
})
redis.on('error', (error) => {
console.error('redis error', error);
})
redis.on('connect', () => {})
redis.on('reconnecting', () => {
console.log('reconnecting to redis')
})
async function configure() {
try {
await redis.connect();
await redis.ping();
} catch (err) {
console.error('Redis error', err);
}
}
function connected() {
return redis.status === 'ready';
}
module.exports = { redis, configure, connected };

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="../static/style/style.css">
<link rel="stylesheet" href="../static/style/login.css" </head>
<body>
<div class="container">
<div class="login">
<div class="header">
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
</div>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form action="/login" method="post">
<input type="hidden" name="redirect" value="{{ redirect }}">
<div class="group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

70
auth/src/tools/jwt.js Normal file
View File

@@ -0,0 +1,70 @@
const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET;
const expires_in = process.env.JWT_EXPIRES_IN;
/**
* Genera un JWT Token a partire dall'utente e crea una nuova sessione
*
* 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) {
const payload = {
sub: user.id,
username: user.username,
session_id: sessionID,
iat: Math.floor(Date.now() / 1000)
};
return jwt.sign(payload, secret, { expiresIn: expires_in, algorithm: 'HS256' });
}
/**
* Verifica e decodifica il token
* @param {string} token - JWT Token
* @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) {
try {
const payload = jwt.verify(token, secret, {
algorithms: ['HS256']
});
return {
valid: true,
payload: {
user_id: payload.sub,
username: payload.username,
session_id: payload.session_id,
iat: payload.iat,
exp: payload.exp
}
};
} catch (err) {
const reason = err.name === 'TokenExpiredError' ? 'expired' : 'invalid';
return {
valid: false,
error: err.message,
reason: `token ${reason}`
};
}
}
function getToken(header) {
if (!header) return null;
const parts = header.split(' ');
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
return parts[1];
}
//TODO: valutare se modificare in return null per accettare solo metodo Bearer Authorization Token,
return header;
}
module.exports = { generateToken, verifyToken, getToken };

View File

@@ -0,0 +1,54 @@
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const saltRounds = 12;
/**
* Genera un hash di una password
* @param {string} password - Password da hashare
* @returns {string} - Hash della password
*/
function hashPassword(password) {
return bcrypt.hashSync(password, saltRounds);
}
/**
* Verifica una password
* @param {string} password - Password da verificare
* @param {string} hash - Hash della password
* @returns {boolean} - True se la password è corretta, false altrimenti
*/
function verifyPassword(password, hash) {
return bcrypt.compareSync(password, hash);
}
/**
* 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');
}
/**
* Parse a session token
* @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,
generateSessionCode,
parseSessionToken
};

View File

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

2
console/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

13
console/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
EXPOSE 3004
CMD ["node", "src/index.js"]

1127
console/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
console/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "meb-console-service",
"version": "1.4.0",
"description": "Console service for meb",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"generate-docs": "node -e \"console.log(JSON.stringify(require('./swagger-specs-logic')))\" > openapi.json"
},
"dependencies": {
"cookie-parser": "^1.4.7",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"nunjucks": "^3.2.4"
}
}

92
console/src/index.js Normal file
View File

@@ -0,0 +1,92 @@
const express = require('express');
const nunjucks = require('nunjucks');
const path = require('path');
const jwt = require('jsonwebtoken');
const parser = require('cookie-parser');
const app = express();
const PORT = process.env.PORT;
const version = process.env.VERSION;
const vBuild = process.env.VERSION_BUILD;
const vState = process.env.VERSION_STATE;
app.use(express.json());
app.use(parser());
// Set up static files serving
const staticFolder = path.join(__dirname, 'static');
app.use('/static', express.static(staticFolder));
app.get('/', (req, res) => {
res.redirect(301, "/dashboard")
});
app.get('/health', (req, res) => {
res.json({
status: "ok",
service: "console",
version: version,
build_number: vBuild,
version_state: vState
});
});
const pagesFolder = path.join(__dirname, 'pages');
nunjucks.configure(pagesFolder, {
autoescape: true,
express: app,
noCache: true,
watch: false
})
app.set('views', pagesFolder);
app.set('view engine', 'html');
const renderPage = (page, extra = {}) => (req, res) => {
res.render(page, {current_path: req.path, ...extra})
}
// Middleware di autenticazione per le pagine
app.use((req, res, next) => {
if (req.path === '/health' || req.path.startsWith('/static')) {
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('/live', renderPage('live', {
realtimeUrl: process.env.REALTIME_URL || 'http://localhost:3002',
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
}));
app.listen(PORT, () => {
console.log(`Started on port ${PORT}`);
});

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="/static/styles/style.css">
<link rel="stylesheet" href="/static/styles/dashboard.css">
</head>
<body>
<div class="contnent">
<div class="header">
<h1></h1>
<div class="profile">
<p id="username">username</p>
<button>Impostazioni</button>
</div>
</div>
<div class="info-panel">
<div class="icon">🚤</div>
<h3>Benvenuto nella MEB Console</h3>
<p>Usa uno dei tool a disposizione per visualizzare i dati dalla barca in tempo reale, creare nuovi dataset condivisi e </p>
</div>
<section class="grid">
<a class="card standalone" href="/live" title="Live">
<div>
<h3>Live</h3>
<p>Visualizza i dati dei sensori sulla barca in tempo reale.</p>
</div>
</a>
<a class="card" href="/forecasts" title="Previsioni">
<div>
<h3>Previsioni</h3>
<p>Visualizza le condizioni meteo attuali e le previsioni future.</p>
</div>
</a>
<a class="card" href="/forecasts" title="Previsioni">
<div>
<h3>Previsioni</h3>
<p>Visualizza le condizioni meteo attuali e le previsioni future.</p>
</div>
</a>
</section>
</div>
</body>
<script>
</script>

707
console/src/pages/live.html Normal file
View File

@@ -0,0 +1,707 @@
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="/static/styles/live.css">
<link rel="stylesheet" href="/static/styles/style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
.map-container { display: none; }
.expanded-chart-container { display: none; }
.comparison-sidebar { display: none; }
</style>
</head>
<body>
<div class="container">
<!-- Session Picker Popup -->
<div class="session-overlay" id="sessionOverlay">
<div class="session-popup">
<h2>Sessioni Attive</h2>
<p class="popup-subtitle">Seleziona un sensore per visualizzare i dati in tempo reale</p>
<div class="session-list" id="sessionList">
<div class="session-loading">Caricamento sessioni...</div>
</div>
<button id="refreshSessionsBtn">Aggiorna</button>
</div>
</div>
<!-- Main Content -->
<div class="content" id="mainContent" style="display: none;">
<div class="header">
<div>
<h1>Live</h1>
<p class="last-update" id="lastUpdateText">In attesa di dati...</p>
</div>
<div class="profile">
<p id="sensorName">Sensore</p>
<button id="changeSessionBtn">Cambia sessione</button>
</div>
</div>
<div class="info-panel" style="margin-bottom:20px;">
<h3 id="sessionInfoTitle"></h3>
<p id="sessionInfoDesc">Visualizza i dati dei sensori sulla barca in tempo reale.</p>
</div>
<!-- Dashboard Layout (Main + Sidebar) -->
<div class="dashboard-layout">
<div class="main-column">
<!-- Sticky Area (Mappa + Expanded Chart) -->
<div class="sticky-area">
<!-- Mappa -->
<div class="map-container" id="mapContainer">
<div id="liveMap"></div>
</div>
<!-- Expanded Chart (Mostrato cliccando Focus/Lente) -->
<div class="expanded-chart-container" id="expandedChartContainer">
<div class="expanded-chart-header">
<h3 id="expChartTitle">Grafico Dettagliato</h3>
<button class="close-expanded-btn" id="closeExpChartBtn">&times;</button>
</div>
<div class="expanded-chart-body">
<canvas id="expandedChartCanvas"></canvas>
</div>
</div>
</div>
<!-- Griglia Card (Ibride Dato + Minigrafico) -->
<div class="grid" id="dataGrid"></div>
</div>
<!-- Confronto Sidebar -->
<div class="comparison-sidebar" id="compSidebar">
<div class="comp-header">
<h3>Modalità Confronto</h3>
<button class="comp-close" id="compCloseBtn">&times;</button>
</div>
<div class="comp-list" id="compLabelsList">
<!-- Selezionati finiscono qui come bottoncini (pill) -->
<div style="font-size: 0.8rem; color: #94a3b8; padding: 10px;">Clicca sulle card per aggiungere dati al confronto.</div>
</div>
<div class="comp-chart-area">
<canvas id="compChartCanvas"></canvas>
</div>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="bottom-bar" id="bottomBar" style="display: none;">
<div class="search-field">
<svg width="16" height="16" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.0835 11.0834L8.57516 8.57504M9.91683 5.25004C9.91683 7.82737 7.82749 9.91671 5.25016 9.91671C2.67283 9.91671 0.583496 7.82737 0.583496 5.25004C0.583496 2.67271 2.67283 0.583374 5.25016 0.583374C7.82749 0.583374 9.91683 2.67271 9.91683 5.25004Z"
stroke="var(--text-secondary)" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<input type="text" placeholder="Cerca" id="searchInput">
</div>
<div class="filter" id="categoryFilter">
<button class="active" data-cat="all">Tutto</button>
<button data-cat="weather">Meteo</button>
<button data-cat="navigation">Navigazione</button>
<button data-cat="engine">Motore</button>
</div>
<!-- Chart fill toggle (line vs area) -->
<div class="filter boxed" id="chartFillToggle">
<button class="active" data-fill="false" title="Solo linea">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0.583496 0.583374V9.91671C0.583496 10.2261 0.706412 10.5229 0.925205 10.7417C1.144 10.9605 1.44074 11.0834 1.75016 11.0834H11.0835M9.91683 4.08337L7.00016 7.00004L4.66683 4.66671L2.91683 6.41671"
stroke="currentColor" stroke-width="1.16667" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<button data-fill="true" title="Area colorata">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0.583496 0.583374V9.91671C0.583496 10.2261 0.706412 10.5229 0.925205 10.7417C1.144 10.9605 1.44074 11.0834 1.75016 11.0834H11.0835M2.91683 5.37079C2.91685 5.29358 2.94747 5.21954 3.002 5.16487L4.16866 3.99821C4.19576 3.97105 4.22794 3.9495 4.26338 3.93479C4.29881 3.92009 4.3368 3.91252 4.37516 3.91252C4.41353 3.91252 4.45151 3.92009 4.48695 3.93479C4.52238 3.9495 4.55457 3.97105 4.58166 3.99821L6.502 5.91854C6.52909 5.9457 6.56128 5.96725 6.59671 5.98196C6.63214 5.99666 6.67013 6.00423 6.7085 6.00423C6.74686 6.00423 6.78485 5.99666 6.82028 5.98196C6.85572 5.96725 6.8879 5.9457 6.915 5.91854L9.41866 3.41487C9.45942 3.37401 9.51138 3.34616 9.56798 3.33485C9.62457 3.32353 9.68324 3.32926 9.73658 3.35131C9.78991 3.37335 9.83551 3.41073 9.8676 3.4587C9.89968 3.50667 9.91682 3.56308 9.91683 3.62079V8.16671C9.91683 8.32142 9.85537 8.46979 9.74598 8.57919C9.63658 8.68858 9.48821 8.75004 9.3335 8.75004H3.50016C3.34545 8.75004 3.19708 8.68858 3.08768 8.57919C2.97829 8.46979 2.91683 8.32142 2.91683 8.16671V5.37079Z"
stroke="currentColor" stroke-width="1.16667" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
</div>
<!-- Comparison toggle -->
<div class="filter boxed" id="compToggleWrap">
<button id="compToggleBtn" title="Mostra/nascondi confronto">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 12L5 4L9 9L15 2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M1 14L5 8L9 11L15 5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"
stroke-linejoin="round" opacity="0.4" />
</svg>
</button>
</div>
<!-- Map toggle -->
<div class="filter boxed" id="mapToggleWrap">
<button id="mapToggleBtn" title="Mostra/nascondi mappa">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 3.5L5.5 1.5L10.5 3.5L15 1.5V12.5L10.5 14.5L5.5 12.5L1 14.5V3.5Z"
stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" />
<path d="M5.5 1.5V12.5M10.5 3.5V14.5" stroke="currentColor" stroke-width="1.2" />
</svg>
</button>
</div>
<div class="bar-sep"></div>
<button id="downloadBtn" title="Scarica CSV">Scarica</button>
</div>
<div class="map-bar" id="mapSecondaryBar">
<div class="filter" id="mapZoomSection">
<button class="active" data-zoom="1">1x</button>
<button data-zoom="5">5x</button>
<button data-zoom="10">10x</button>
<button data-zoom="50">50x</button>
</div>
<div class="bar-sep"></div>
<div class="map-toggles-group" id="mapLayersSection">
<div class="bb-toggle on" data-layer="heading" title="Heading barca">
<div class="toggle-pip"></div>
<span>HDG</span>
</div>
<div class="bb-toggle on" data-layer="wind" title="Direzione vento">
<div class="toggle-pip"></div>
<span>Vento</span>
</div>
<div class="bb-toggle on" data-layer="waves" title="Direzione onde">
<div class="toggle-pip"></div>
<span>Onde</span>
</div>
</div>
</div>
</div>
</body>
<script>
// --- Config ---
const REALTIME_URL = '{{ realtimeUrl }}';
const REALTIME_WS_URL = '{{ realtimeWsUrl }}';
// --- State ---
let ws = null;
let currentSensorId = null;
let sessionStartTime = null;
let lastDataTime = 0;
let lastUpdateInterval = null;
let liveData = {};
let miniCharts = {}; // key -> Chart
let expChart = null; // Ingrandimento chart
let compChart = null; // Confronto chart
let expActiveField = null;
let leafletMap = null;
let boatMarker = null;
let headingLayer = null;
let windLayer = null;
let wavesLayer = null;
let mapActive = false;
let compActive = false;
let selectedForComp = new Set();
let activeCategory = 'all';
let searchQuery = '';
const TICK_COLOR = '#94a3b8';
const GRID_COLOR = 'rgba(148, 163, 184, 0.08)';
let chartFill = false;
const CHART_COLORS = [
'rgba(59, 130, 246, 1)', // blue
'rgba(16, 185, 129, 1)', // green
'rgba(245, 158, 11, 1)', // amber
'rgba(239, 68, 68, 1)', // red
'rgba(139, 92, 246, 1)', // purple
'rgba(236, 72, 153, 1)', // pink
];
let colorIdx = 0;
function getColorForField(key) {
if(!liveData[key].color) {
liveData[key].color = CHART_COLORS[colorIdx % CHART_COLORS.length];
colorIdx++;
}
return liveData[key].color;
}
const FIELD_DEFS = {
temp: { name: 'Temperatura', unit: '°C', category: 'weather' },
hum: { name: 'Umidita', unit: '%', category: 'weather' },
pres: { name: 'Pressione', unit: 'hPa', category: 'weather' },
wSpd: { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
wDir: { name: 'Direzione Vento', unit: '°', category: 'weather' },
gust: { name: 'Raffiche', unit: 'km/h', category: 'weather' },
rain: { name: 'Pioggia', unit: 'mm', category: 'weather' },
prec: { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
lat: { name: 'Latitudine', unit: '°', category: 'navigation' },
lon: { name: 'Longitudine', unit: '°', category: 'navigation' },
hdg: { name: 'Heading', unit: '°', category: 'navigation' },
sog: { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
cog: { name: 'Rotta COG', unit: '°', category: 'navigation' },
depth: { name: 'Profondita', unit: 'm', category: 'navigation' },
engTemp: { name: 'Temp. Motore', unit: '°C', category: 'engine' },
wvH: { name: 'Altezza Onde', unit: 'm', category: 'weather' },
wvP: { name: 'Periodo Onde', unit: 's', category: 'weather' },
wvD: { name: 'Direzione Onde', unit: '°', category: 'weather' },
curD: { name: 'Dir. Corrente', unit: '°', category: 'weather' },
curV: { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
fTemp: { name: 'Prev. Temperatura', unit: '°C', category: 'weather' },
fWSpd: { name: 'Prev. Vento', unit: 'km/h', category: 'weather' }
};
const MEASUREMENT_CATEGORY = { weather: 'weather', navigation: 'navigation', engine: 'engine' };
const ALWAYS_FILL_BOTTOM_FIELDS = ['lat', 'lon'];
async function loadSessions() {
document.getElementById('sessionList').innerHTML = '<div class="session-loading">Caricamento...</div>';
try {
const res = await fetch(`${REALTIME_URL}/sessions`);
const sessions = await res.json();
const entries = Object.entries(sessions);
if (entries.length === 0) {
document.getElementById('sessionList').innerHTML = '<div class="session-empty">Nessun sensore connesso</div>';
return;
}
document.getElementById('sessionList').innerHTML = '';
for (const [sId, rawMeta] of entries) {
const meta = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta;
const item = document.createElement('div');
item.className = 'session-item';
const connTime = meta.connectedAt ? new Date(meta.connectedAt * 1000).toLocaleTimeString('it-IT') : '—';
item.innerHTML = `<div class="session-item-info"><strong>${meta.name || sId}</strong><span class="session-item-id">${sId}</span></div><div class="session-item-meta"><span class="session-item-time">Connesso: ${connTime}</span><div class="session-item-dot"></div></div>`;
item.onclick = () => selectSession(sId, meta);
document.getElementById('sessionList').appendChild(item);
}
} catch (err) { }
}
function selectSession(sId, meta) {
currentSensorId = sId;
sessionStartTime = meta.connectedAt ? meta.connectedAt * 1000 : Date.now();
document.getElementById('sessionOverlay').style.display = 'none';
document.getElementById('mainContent').style.display = '';
document.getElementById('bottomBar').style.display = '';
document.getElementById('sensorName').textContent = meta.name || sId;
document.getElementById('sessionInfoTitle').textContent = `Sensore: ${meta.name || sId}`;
liveData = {};
Object.values(miniCharts).forEach(c => c.destroy());
miniCharts = {};
if(expChart) { expChart.destroy(); expChart = null; }
if(compChart) { compChart.destroy(); compChart = null; }
document.getElementById('dataGrid').innerHTML = '';
selectedForComp.clear();
lastDataTime = 0;
clearInterval(lastUpdateInterval);
lastUpdateInterval = setInterval(updateLastUpdateText, 1000);
connectWebSocket(sId);
}
function updateLastUpdateText() {
if(!lastDataTime) return;
const diff = Math.floor((Date.now() - lastDataTime)/1000);
const txt = diff === 0 ? "Ultimo aggiornamento, adesso" : `Ultimo aggiornamento, ${diff} secondi fa`;
document.getElementById('lastUpdateText').textContent = txt;
}
function connectWebSocket(sId) {
if (ws) { ws.send(JSON.stringify({ action: 'unwatch' })); ws.close(); }
ws = new WebSocket(`${REALTIME_WS_URL}/live`);
ws.onopen = () => ws.send(JSON.stringify({ action: 'watch', sensorId: sId }));
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.timestamp && msg.measurement && msg.fields) {
lastDataTime = Date.now();
updateLastUpdateText();
handleSensorData(msg);
}
};
ws.onclose = () => {
if (currentSensorId === sId) setTimeout(() => connectWebSocket(sId), 2000);
};
}
function handleSensorData(msg) {
const { timestamp, measurement, fields } = msg;
const t = new Date(timestamp);
const cat = MEASUREMENT_CATEGORY[measurement] || measurement;
let redrawExpChart = false;
let redrawCompChart = false;
for (const [k, v] of Object.entries(fields)) {
if (v == null) continue;
const key = `${measurement}:${k}`;
const def = FIELD_DEFS[k] || { name: k, unit: '', category: cat };
if (!liveData[key]) {
liveData[key] = { value: v, history: [], measurement, category: def.category || cat, def, k, color: null };
getColorForField(key);
createHybCard(key, def, v);
}
liveData[key].value = v;
liveData[key].history.push({ t, v: typeof v === 'number' ? v : null });
if (liveData[key].history.length > 200) liveData[key].history.shift();
updateHybCard(key, v);
if (expActiveField === key) redrawExpChart = true;
if (compActive && selectedForComp.has(key)) redrawCompChart = true;
}
if (redrawExpChart) updateExpandedChart();
if (redrawCompChart) updateCompChart();
if (measurement === 'logs' && fields.lat && fields.lon) updateMap(fields.lat, fields.lon, fields.hdg, fields.wDir, fields.wvD);
}
function createHybCard(key, def, val) {
if(typeof val !== 'number') return;
const grid = document.getElementById('dataGrid');
const card = document.createElement('div');
card.className = 'data-card';
card.dataset.key = key;
card.dataset.category = def.category;
card.dataset.name = def.name.toLowerCase();
card.innerHTML = `
<div class="card-top">
<div class="card-title-group">
<div class="card-info">
<h4>${def.name}</h4>
</div>
</div>
<div class="card-actions">
<button class="card-action-btn enlarge-btn" title="Focus">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
</button>
</div>
</div>
<div class="card-body">
<div class="card-values">
<span class="card-main-val">${val.toFixed(1)}</span>
<span class="card-unit">${def.unit}</span>
</div>
<div class="card-mini-chart">
<canvas id="mini-${key}"></canvas>
</div>
</div>
`;
card.querySelector('.enlarge-btn').onclick = (e) => {
e.stopPropagation();
openExpandedChart(key);
};
card.onclick = () => {
if(compActive) toggleCompItem(key);
};
grid.appendChild(card);
const ctx = document.getElementById(`mini-${key}`).getContext('2d');
const col = liveData[key].color;
const bgColor = col.replace(', 1)', ', 0.15)');
const fillRule = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(liveData[key].k) ? 'start' : true) : false;
miniCharts[key] = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [{ data: [], borderColor: col, backgroundColor: bgColor, fill: fillRule, tension: 0.3, pointRadius: 0, borderWidth: 2 }] },
options: {
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
plugins: { legend: { display:false }, tooltip: { enabled:false } },
scales: { x: { type: 'category', display:false }, y: { display:false, ticks: { maxTicksLimit: 5 } } }
}
});
applyFilters();
}
function updateHybCard(key, val) {
const card = document.querySelector(`.data-card[data-key="${key}"]`);
if(!card) return;
card.querySelector('.card-main-val').textContent = val.toFixed(1);
const h = liveData[key].history;
const chart = miniCharts[key];
if(chart && (!compActive || !selectedForComp.has(key))) {
chart.data.labels = h.map(x=>'');
chart.data.datasets[0].data = h.map(x=>x.v);
chart.update('none');
}
}
function openExpandedChart(key) {
expActiveField = key;
document.getElementById('expandedChartContainer').style.display = 'flex';
document.getElementById('expChartTitle').textContent = `Dettaglio: ${liveData[key].def.name}`;
if(!expChart) {
const ctx = document.getElementById('expandedChartCanvas').getContext('2d');
const fillRule = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(liveData[expActiveField].k) ? 'start' : true) : false;
expChart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [{ label:'', data: [], borderColor: 'rgba(59, 130, 246, 1)', tension:0.3, fill: fillRule, backgroundColor:'rgba(59, 130, 246, 0.15)', pointRadius:0, borderWidth: 2 }] },
options: {
responsive:true, maintainAspectRatio:false, animation:false, resizeDelay: 100, interaction: { intersect: false, mode: 'index' },
scales: {
x: { type: 'category', ticks:{ maxTicksLimit: 6, color: TICK_COLOR, font:{size:10} }, grid:{display:false} },
y: { ticks:{ color: TICK_COLOR, font:{size:10}, maxTicksLimit: 5 }, grid:{color:GRID_COLOR} }
},
plugins:{ legend:{display:false}, tooltip: { callbacks: { label: (tipCtx) => `${tipCtx.parsed.y?.toFixed(2)}` } } }
}
});
}
updateExpandedChart();
}
function updateExpandedChart() {
if(!expChart || !expActiveField) return;
const dt = liveData[expActiveField];
expChart.data.datasets[0].borderColor = dt.color;
expChart.data.datasets[0].backgroundColor = dt.color.replace(', 1)', ', 0.15)');
expChart.data.datasets[0].fill = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(dt.k) ? 'start' : true) : false;
expChart.data.labels = dt.history.map(h => h.t.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit',second:'2-digit'}));
expChart.data.datasets[0].data = dt.history.map(h => h.v);
expChart.update('none');
}
document.getElementById('closeExpChartBtn').onclick = () => {
document.getElementById('expandedChartContainer').style.display = 'none';
expActiveField = null;
};
function toggleCompItem(key) {
if(selectedForComp.has(key)) selectedForComp.delete(key);
else selectedForComp.add(key);
renderCompSidebar();
}
function renderCompSidebar() {
const labelsDiv = document.getElementById('compLabelsList');
labelsDiv.innerHTML = '';
document.querySelectorAll('.data-card').forEach(c => {
const k = c.dataset.key;
if(selectedForComp.has(k)) c.classList.add('selected-for-comp');
else c.classList.remove('selected-for-comp');
});
if(selectedForComp.size === 0) {
labelsDiv.innerHTML = '<div style="font-size:0.8rem;color:#94a3b8;padding:10px;">Clicca sulle card per aggiungere dati al confronto.</div>';
} else {
selectedForComp.forEach(k => {
const dt = liveData[k];
const p = document.createElement('div');
p.className = 'comp-pill';
p.innerHTML = `<div class="color-dot" style="background:${dt.color}"></div><span>${dt.def.name}</span><button>&times;</button>`;
p.querySelector('button').onclick = () => toggleCompItem(k);
labelsDiv.appendChild(p);
});
}
initOrUpdateCompChart();
}
function initOrUpdateCompChart() {
if(!compChart) {
const ctx = document.getElementById('compChartCanvas').getContext('2d');
compChart = new Chart(ctx, {
type: 'line',
data: { labels:[], datasets:[] },
options: {
responsive:true, maintainAspectRatio:false, animation:false, resizeDelay: 100,
interaction: { intersect: false, mode: 'index' },
scales: {
x: { type: 'category', ticks:{maxTicksLimit:6, color: TICK_COLOR, font:{size:10}}, grid:{display:false} },
y: { ticks:{color: TICK_COLOR, font:{size:10}, maxTicksLimit: 5}, grid:{color:GRID_COLOR} }
},
plugins:{ legend:{display:false} }
}
});
}
updateCompChart();
}
function updateCompChart() {
if(!compChart) return;
const datasets = [];
let longestHistory = [];
selectedForComp.forEach(k => {
const dt = liveData[k];
if(dt.history.length > longestHistory.length) longestHistory = dt.history;
datasets.push({
label: dt.def.name,
data: dt.history.map(h=>h.v),
borderColor: dt.color,
borderWidth: 2, tension:0.3, pointRadius:0, fill:false
});
});
compChart.data.labels = longestHistory.map(h=>h.t.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit',second:'2-digit'}));
compChart.data.datasets = datasets;
compChart.update('none');
}
document.getElementById('compToggleBtn').onclick = () => {
compActive = !compActive;
document.getElementById('compToggleBtn').style.background = compActive ? "var(--accent-color)" : "";
document.getElementById('compToggleBtn').style.color = compActive ? "#fff" : "";
if(compActive) {
document.getElementById('compSidebar').style.display = 'flex';
renderCompSidebar();
} else {
document.getElementById('compSidebar').style.display = 'none';
selectedForComp.clear();
document.querySelectorAll('.data-card').forEach(c=>c.classList.remove('selected-for-comp'));
}
};
document.getElementById('compCloseBtn').onclick = () => {
document.getElementById('compToggleBtn').click();
};
document.querySelectorAll('#chartFillToggle button').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('#chartFillToggle button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
chartFill = btn.dataset.fill === 'true';
Object.entries(miniCharts).forEach(([k, chart]) => {
chart.data.datasets[0].fill = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(liveData[k].k) ? 'start' : true) : false;
chart.update('none');
});
if(expChart && expActiveField) {
expChart.data.datasets[0].fill = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(liveData[expActiveField].k) ? 'start' : true) : false;
expChart.update('none');
}
};
});
document.getElementById('mapToggleBtn').onclick = () => {
mapActive = !mapActive;
document.getElementById('mapToggleBtn').style.background = mapActive ? "var(--accent-color)" : "";
document.getElementById('mapToggleBtn').style.color = mapActive ? "#fff" : "";
document.getElementById('mapContainer').style.display = mapActive ? 'block' : 'none';
if(mapActive) {
document.getElementById('mapSecondaryBar').classList.add('visible');
if(!leafletMap) {
leafletMap = L.map('liveMap').setView([42.0, 12.5], 10);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(leafletMap);
boatMarker = L.circleMarker([42.0, 12.5], { radius:8, fillColor:'#3b82f6', color:'#fff', weight:2, fillOpacity:0.9 }).addTo(leafletMap);
headingLayer = L.layerGroup().addTo(leafletMap);
windLayer = L.layerGroup().addTo(leafletMap);
wavesLayer = L.layerGroup().addTo(leafletMap);
}
setTimeout(()=> leafletMap.invalidateSize(), 200);
} else {
document.getElementById('mapSecondaryBar').classList.remove('visible');
}
};
function updateMap(lat, lon, hdg, wDir, wvD) {
if(!leafletMap || !mapActive) return;
const p = [lat, lon];
boatMarker.setLatLng(p);
leafletMap.setView(p, leafletMap.getZoom());
headingLayer.clearLayers(); windLayer.clearLayers(); wavesLayer.clearLayers();
const len = 0.005*(leafletMap.getZoom()>12?1:3);
if(hdg!=null) { const r = hdg*Math.PI/180; L.polyline([p,[lat+len*Math.cos(r),lon+len*Math.sin(r)]],{color:'#3b82f6'}).addTo(headingLayer); }
if(wDir!=null) { const r = wDir*Math.PI/180; L.polyline([p,[lat+len*Math.cos(r),lon+len*Math.sin(r)]],{color:'#10b981'}).addTo(windLayer); }
if(wvD!=null) { const r = wvD*Math.PI/180; L.polyline([p,[lat+len*Math.cos(r),lon+len*Math.sin(r)]],{color:'#f59e0b'}).addTo(wavesLayer); }
}
function applyFilters() {
document.querySelectorAll('.data-card').forEach(c => {
const mCat = activeCategory === 'all' || c.dataset.category === activeCategory;
const mStr = !searchQuery || c.dataset.name.includes(searchQuery);
c.style.display = (mCat && mStr) ? '' : 'none';
});
}
document.getElementById('searchInput').oninput = e => { searchQuery = e.target.value.toLowerCase(); applyFilters(); };
document.querySelectorAll('#categoryFilter button').forEach(b => {
b.onclick = () => { document.querySelectorAll('#categoryFilter button').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeCategory = b.dataset.cat; applyFilters(); };
});
document.querySelectorAll('#mapZoomSection button').forEach(b => {
b.onclick = () => { document.querySelectorAll('#mapZoomSection button').forEach(x=>x.classList.remove('active')); b.classList.add('active'); const zm = {1:14, 5:12, 10:10, 50:7}[b.dataset.zoom]; if(leafletMap) leafletMap.setZoom(zm); };
});
document.querySelectorAll('.bb-toggle').forEach(b => {
b.onclick = () => {
b.classList.toggle('on'); const id = b.dataset.layer; const on = b.classList.contains('on');
if(!leafletMap) return;
if(id==='heading') on?leafletMap.addLayer(headingLayer):leafletMap.removeLayer(headingLayer);
if(id==='wind') on?leafletMap.addLayer(windLayer):leafletMap.removeLayer(windLayer);
if(id==='waves') on?leafletMap.addLayer(wavesLayer):leafletMap.removeLayer(wavesLayer);
};
});
document.addEventListener('DOMContentLoaded', () => loadSessions());
document.getElementById('downloadBtn').onclick = async () => {
if (!currentSensorId || !sessionStartTime) return;
try {
const btn = document.getElementById('downloadBtn');
const oldText = btn.textContent;
btn.textContent = '...';
await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/flush`, { method: 'POST' });
const csvUrl = `${REALTIME_URL}/sessions/${currentSensorId}/csv?from=${sessionStartTime}`;
const res = await fetch(csvUrl);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `session_${currentSensorId}.csv`;
a.click();
URL.revokeObjectURL(url);
const sizeStr = blob.size < 1024 ? `${blob.size} B` : (blob.size < 1024*1024 ? `${(blob.size/1024).toFixed(1)} KB` : `${(blob.size/(1024*1024)).toFixed(1)} MB`);
const text = await blob.text();
const rows = Math.max(0, text.split('\n').length - 2);
const deltaS = Math.floor((Date.now() - sessionStartTime)/1000);
const timeStr = deltaS < 60 ? `${deltaS} secondi` : (deltaS < 3600 ? `${Math.floor(deltaS/60)} minuti` : `${(deltaS/3600).toFixed(1)} ore`);
showToast(`Scarico sessione avviata ${timeStr} fa, ${rows} dati, ${sizeStr}`);
btn.textContent = oldText;
} catch (err) {
showToast('Errore durante il download');
document.getElementById('downloadBtn').textContent = 'Scarica';
}
};
function showToast(msg) {
let t = document.getElementById('dl-toast');
if(!t) {
t = document.createElement('div');
t.id = 'dl-toast';
t.style = 'position:fixed; bottom: 84px; right: 24px; background: rgba(255, 255, 255, 0.9); padding: 16px 20px; border-radius: var(--radius-lg); border: 1px solid var(--header-border); box-shadow: var(--shadow-md); color: var(--text-primary); font-size: 14px; font-weight: 600; z-index: 9999; backdrop-filter: blur(10px); transform: translateY(100px); opacity: 0; transition: all 0.4s cubic-bezier(0.8, 0, 0.2, 1); pointer-events: none;';
document.body.appendChild(t);
}
t.textContent = msg;
t.style.transform = 'translateY(0)';
t.style.opacity = '1';
setTimeout(() => {
t.style.transform = 'translateY(100px)';
t.style.opacity = '0';
}, 4500);
}
</script>

View File

@@ -0,0 +1,26 @@
.content {
width: 100%;
height: 100%;
}
.card[title="Live"] {
position: relative;
z-index: 1;
}
.card[title="Live"]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(135deg, #ff00d0, #0026ff);
filter: blur(40px);
opacity: 0;
transition: opacity 0.7s ease;
border-radius: inherit;
}
.card[title="Live"]:hover::before {
opacity: 0.2;
}

View File

@@ -0,0 +1,669 @@
/* === Session Overlay Popup === */
.session-overlay {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.session-popup {
background: var(--header-bg, #fff);
border: 1px solid var(--header-border);
border-radius: 24px;
padding: 32px;
width: 420px;
max-width: 90vw;
max-height: 70vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg, 0 20px 60px rgba(0,0,0,0.3));
user-select: none;
}
.session-popup h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.popup-subtitle {
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0 0 20px;
}
.session-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--header-border);
background: var(--surface, #f8fafc);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.session-item:hover {
border-color: var(--accent-light, #bfdbfe);
box-shadow: 0 0 0 2px var(--accent-light, #bfdbfe);
transform: translateY(-1px);
}
.session-item-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.session-item-info strong {
font-size: 0.95rem;
color: var(--text-primary);
}
.session-item-id {
font-size: 0.75rem;
color: var(--text-tertiary);
font-family: monospace;
}
.session-item-meta {
display: flex;
align-items: center;
gap: 8px;
}
.session-item-time {
font-size: 0.8rem;
color: var(--text-secondary);
}
.session-item-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 8px #10b981;
flex-shrink: 0;
}
.session-loading,
.session-empty,
.session-error {
text-align: center;
padding: 32px 16px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.session-error {
color: #ef4444;
}
/* === Top Info (Last Update) === */
.last-update {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 4px;
}
/* === Layout Wrappers === */
.dashboard-layout {
display: flex;
align-items: flex-start;
gap: 24px;
margin: 0 30px 20px;
padding-bottom: 140px; /* Evita che la bottom bar copra i dati */
}
.main-column {
flex: 1;
min-width: 0;
}
/* === Sticky Map & Expanded Chart === */
.sticky-area {
position: sticky;
top: 20px;
z-index: 100;
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 20px;
}
.map-container {
border-radius: 24px;
overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04);
background: var(--surface);
}
.map-container #liveMap {
width: 100%;
height: 300px; /* Ridotto un po' per far spazio */
}
.expanded-chart-container {
background: var(--surface, #f8fafc);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 24px;
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04);
position: relative;
display: flex;
flex-direction: column;
height: 300px;
}
.expanded-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.expanded-chart-header h3 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
.close-expanded-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
}
.close-expanded-btn:hover {
color: var(--text-primary);
}
.expanded-chart-body {
flex: 1;
min-height: 0;
position: relative;
}
.expanded-chart-body canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
}
/* === Card ibrida (Dati + Minigrafico) === */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.data-card {
background: var(--surface, #ffffff);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 24px;
padding: 20px;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.data-card:hover {
box-shadow: 0 12px 32px rgba(0,0,0,0.06);
border-color: var(--accent-light);
transform: translateY(-2px);
}
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title-group {
display: flex;
align-items: center;
gap: 10px;
}
.card-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
}
.card-info {
margin-left: 6px;
}
.card-info h4 {
margin: 0;
font-size: 0.9rem;
color: var(--text-primary);
font-weight: 600;
}
.card-info span {
font-size: 0.75rem;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.data-card:hover .card-actions {
opacity: 1;
}
.card-action-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.card-action-btn:hover {
background: #f1f5f9;
color: var(--text-primary);
}
.card-body {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 0 6px 6px;
}
.card-values {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 4px;
min-width: 80px;
}
.card-main-val {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.card-unit {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
margin-bottom: 2px;
}
.card-mini-chart {
flex: 1;
height: 50px;
position: relative;
min-width: 0;
}
.card-mini-chart canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
}
/* Modalità Confronto disabilita il minigrafico se selezionato, ma lascio a JS questo controllo visivo */
/* === Sidebar Confronto === */
.comparison-sidebar {
width: 400px;
background: var(--surface);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 24px;
padding: 24px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04);
position: sticky;
top: 20px;
height: calc(100vh - 120px);
overflow: hidden;
}
.comp-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
border-bottom: 1px solid var(--header-border);
padding-bottom: 12px;
}
.comp-header h3 {
margin: 0;
font-size: 1.1rem;
}
.comp-close {
background: transparent;
border: none;
cursor: pointer;
font-size: 1.2rem;
color: var(--text-secondary);
}
.comp-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.comp-pill {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 6px;
background: #f1f5f9;
border: 1px solid var(--header-border);
}
.comp-pill .color-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.comp-pill button {
background: none;
border: none;
cursor: pointer;
line-height: 1;
font-size: 1rem;
color: var(--text-secondary);
}
.comp-chart-area {
flex: 1;
min-height: 0;
position: relative;
}
.comp-chart-area canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
}
/* Quando la modalita confronto è attiva le card cambiano aspetto al click? Lo gestisce JS (es. classe .selected-for-comp) */
.data-card.selected-for-comp {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.data-card.selected-for-comp .card-mini-chart {
opacity: 0.2; /* Nasconde o sbiadisce il mini chart per far capire che è in confronto */
}
/* === Bottom Bar & secondary ... same as before mostly === */
.bottom-bar {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
display: flex;
width: max-content;
max-width: 95vw;
height: 56px;
justify-content: center;
align-items: center;
gap: 12px;
padding: 0 16px;
font-size: 15px;
user-select: none;
background-color: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(24px) saturate(1.5);
-webkit-backdrop-filter: blur(24px) saturate(1.5);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 28px;
z-index: 1000;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.02);
overflow-x: auto;
scrollbar-width: none;
color: var(--text-primary);
}
.bottom-bar::-webkit-scrollbar {
display: none;
}
.bottom-bar .search-field {
height: 38px;
width: 200px;
padding-right: 10px;
border-radius: 14px;
border: 1px solid rgba(226, 232, 240, 0.8);
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
flex-shrink: 0;
}
.bottom-bar .search-field:focus-within {
background: rgba(255, 255, 255, 0.9);
border-color: var(--accent-light, #bfdbfe);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.bottom-bar .search-field input {
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 14px;
padding: 5px;
width: 100%;
}
.bottom-bar .filter {
display: flex;
height: 38px;
padding: 0 4px;
align-items: center;
gap: 4px;
flex-shrink: 0;
border-radius: 14px;
border: 1px solid rgba(226, 232, 240, 0.8);
background: rgba(255, 255, 255, 0.6);
}
.bottom-bar .filter.boxed button {
display: flex;
width: 30px;
height: 30px;
padding: 0;
justify-content: center;
align-items: center;
}
.bottom-bar .filter button {
display: flex;
padding: 6px 12px;
justify-content: center;
align-items: center;
border-radius: 8px;
border: none;
background: transparent;
color: var(--text-secondary);
transition: color 0.15s ease, background 0.15s ease;
cursor: pointer;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
}
.bottom-bar .filter button.active {
background: var(--accent-color);
color: #ffffff;
}
.bottom-bar > button#downloadBtn {
display: flex;
padding: 6px 16px;
justify-content: center;
align-items: center;
border-radius: 12px;
background: var(--surface, #f8fafc);
border: 1px solid var(--header-border);
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.bar-sep {
width: 1px;
height: 24px;
background-color: var(--header-border);
margin: 0 4px;
flex-shrink: 0;
}
.map-bar {
position: fixed;
bottom: 6rem;
left: 50%;
transform: translateX(-50%) translateY(20px);
display: flex;
width: max-content;
height: 48px;
justify-content: center;
align-items: center;
gap: 12px;
padding: 0 16px;
background-color: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(24px) saturate(1.5);
-webkit-backdrop-filter: blur(24px) saturate(1.5);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 24px;
z-index: 1000;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.map-bar.visible {
opacity: 1;
pointer-events: all;
transform: translateX(-50%) translateY(0);
}
.map-bar .filter {
display: flex;
height: 36px;
padding: 0 4px;
align-items: center;
gap: 4px;
flex-shrink: 0;
border-radius: 12px;
border: 1px solid var(--header-border);
background: var(--surface, #f8fafc);
}
.map-bar .filter button {
display: flex;
padding: 6px 12px;
justify-content: center;
align-items: center;
border-radius: 8px;
border: none;
background: transparent;
color: var(--text-secondary);
}
.map-bar .filter button.active {
background: var(--accent-color);
color: #ffffff;
}
.map-toggles-group {
display: flex;
gap: 8px;
align-items: center;
}
.bb-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--surface, #f8fafc);
border: 1px solid var(--header-border);
border-radius: 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
}
.bb-toggle.on {
border-color: var(--accent-border, #bfdbfe);
background: var(--accent-light, #eff6ff);
color: var(--accent-color, #2563eb);
}
@media (max-width: 1100px) {
.dashboard-layout {
flex-direction: column;
}
.comparison-sidebar {
width: 100%;
height: 400px;
position: static;
}
}
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,201 @@
:root {
--accent-color: #2563eb;
--accent-hover: #1d4ed8;
--accent-light: #eff6ff;
--accent-border: #bfdbfe;
--text-primary: #0f172a;
--text-secondary: #4755698f;
--text-tertiary: #94a3b8c0;
--surface: #f8fafc;
--header-bg: rgba(255, 255, 255, 0.85);
/* For Glassmorphism */
--header-border: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--radius-md: 8px;
--radius-lg: 12px;
}
* {
margin: 0;
padding: 0;
}
@font-face {
font-family: 'Normal';
src: url('../font/Quicksand-VariableFont_wght.ttf');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Bold';
src: url('../font/Quicksand-VariableFont_wght.ttf');
font-weight: 700;
font-style: normal;
}
body {
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
button {
padding: 10px 24px;
border-radius: var(--radius-lg);
border: 1px solid var(--header-border);
background-color: var(--bg-surface);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
font-family: 'Bold', inherit;
cursor: pointer;
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
white-space: nowrap;
flex-shrink: 0;
}
button:hover {
background-color: var(--accent-light);
color: var(--accent-color);
border-color: var(--accent-border);
}
button.prominent {
background-color: var(--accent-color);
color: #ffffff;
border: 1px solid transparent;
box-shadow: var(--shadow-sm);
}
button.prominent:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
button.prominent:active {
transform: translateY(1px);
box-shadow: var(--shadow-sm);
}
/* INFO PANEL */
.info-panel {
display: block;
text-align: center;
align-content: center;
padding: 60px 20px;
user-select: none;
}
.info-panel h3 {
margin-bottom: 10px;
}
.info-panel p {
margin-bottom: 25px;
}
.info-panel .icon {
font-size: 48px;
margin-bottom: 20px;
align-self: center;
transition: transform 0.12s ease;
}
/* GRID & CARD ITEMS */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-inline: 30px;
}
.card {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 1rem;
border: 1px solid var(--header-border);
border-radius: 20px;
text-decoration: none;
color: var(--text-primary);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.card h3 {
margin: 0;
font-size: 1rem;
text-align: left;
}
.card p {
margin: 0.25rem 0 0;
color: var(--text-secondary);
opacity: 0.4;
font-size: 0.8rem;
text-align: left;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px #bfdbfe30;
}
.card.standalone {
grid-column: 1 / -1;
}
/* HEADER */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background-color: var(--header-bg);
border-bottom: 1px solid var(--header-border);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 10;
user-select: none;
}
.header h1 {
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.header .profile {
display: flex;
align-items: center;
gap: 16px;
}
.header .profile p {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
padding-inline: 5px;
}

0
copernicus/Dockerfile Normal file
View File

125
copernicus/core/cache.py Normal file
View File

@@ -0,0 +1,125 @@
"""
Redis Keys:
- marine:catalog:full → lista dei dataset completo (TTL 1h)
- marine:catalog:search:{hash} → risultati ricerca (TTL 30min)
- marine:job:{session_id} → stato job download (TTL 48h)
"""
import json
import os
import logging
from typing import Any, Optional
import redis
logger = logging.getLogger(__name__)
# Configurazione Redis da variabili ambiente
REDIS_HOST = os.getenv("REDIS_HOST", "meb-redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
# Pool di connessioni condiviso (thread-safe, riutilizzabile)
_pool: Optional[redis.ConnectionPool] = None
_client: Optional[redis.Redis] = None
def _get_client() -> Optional[redis.Redis]:
"""Restituisce il client Redis singleton con connection pool.
Ritorna None se Redis non è raggiungibile."""
global _pool, _client
if _client is not None:
return _client
try:
_pool = redis.ConnectionPool(
host=REDIS_HOST,
port=REDIS_PORT,
# Decodifica automatica delle risposte in stringhe UTF-8
decode_responses=True,
# Massimo 5 connessioni nel pool (VPS 1-core, non serve di più)
max_connections=5,
# Timeout connessione e socket per evitare blocchi
socket_connect_timeout=3,
socket_timeout=3,
# Riprova automaticamente se la connessione viene interrotta
retry_on_timeout=True,
)
_client = redis.Redis(connection_pool=_pool)
# Test connessione
_client.ping()
logger.info("[Redis] Connessione stabilita per il servizio Marine")
return _client
except Exception as e:
logger.warning(f"[Redis] Non disponibile, la cache è disabilitata: {e}")
_client = None
return None
def cache_get(key: str) -> Optional[Any]:
"""Legge un valore dalla cache Redis.
Args:
key: Chiave Redis (es. 'marine:catalog:full')
Returns:
Il valore deserializzato da JSON, oppure None se non trovato o errore
"""
try:
client = _get_client()
if client is None:
return None
data = client.get(key)
if data is None:
return None
return json.loads(data)
except Exception as e:
logger.warning(f"[Redis] Errore lettura chiave '{key}': {e}")
return None
def cache_set(key: str, value: Any, ttl: int = 3600) -> bool:
"""Scrive un valore nella cache Redis con TTL.
Args:
key: Chiave Redis
value: Valore da serializzare in JSON
ttl: Tempo di vita in secondi (default: 1 ora)
Returns:
True se scritto con successo, False altrimenti
"""
try:
client = _get_client()
if client is None:
return False
serialized = json.dumps(value)
client.setex(key, ttl, serialized)
return True
except Exception as e:
logger.warning(f"[Redis] Errore scrittura chiave '{key}': {e}")
return False
def cache_delete(key: str) -> bool:
"""Elimina una chiave dalla cache Redis.
Args:
key: Chiave Redis da eliminare
Returns:
True se eliminata, False altrimenti
"""
try:
client = _get_client()
if client is None:
return False
client.delete(key)
return True
except Exception as e:
logger.warning(f"[Redis] Errore eliminazione chiave '{key}': {e}")
return False

View File

@@ -0,0 +1,310 @@
import hashlib
import io
import logging
import os
from datetime import datetime, timezone
from typing import Callable, List, Optional
import pandas as pd
from core.cache import cache_get, cache_set
logger = logging.getLogger(__name__)
# ── Chiavi Redis e TTL ────────────────────────────────────────────────
# Chiave per il catalogo completo Copernicus
_CATALOG_KEY = "marine:catalog:full"
# TTL del catalogo: 1 ora (il catalogo Copernicus cambia raramente)
_CATALOG_TTL = 3600
# TTL per i risultati di ricerca: 30 minuti
_SEARCH_TTL = 1800
def _fmt_description(name: Optional[str]) -> Optional[str]:
"""Formatta meglio il titolo del dataset"""
if not name:
return None
return name.replace("_", " ").title()
def _get_raw_catalog() -> dict:
"""Interroga le API di Copernicus per ottenere la lista completa dei dataset.
Strategia cache Redis:
1. Cerca in Redis (chiave marine:catalog:full)
2. Se non trovato → chiama Copernicus SDK → salva in Redis con TTL 1h
3. Se Redis non disponibile → chiama sempre l'SDK (nessuna cache)
Il catalogo in Redis sopravvive al restart del servizio grazie
alla persistenza RDB+AOF configurata in redis.conf.
"""
# Cerca in Redis prima di chiamare l'SDK Copernicus
cached = cache_get(_CATALOG_KEY)
if cached is not None:
logger.debug("[Catalogo] Servito da cache Redis")
return cached
# Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s)
logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...")
import copernicusmarine
catalog = copernicusmarine.describe(disable_progress_bar=True)
# Serializza la risposta SDK in un dizionario standard
if hasattr(catalog, "model_dump"):
result = catalog.model_dump()
elif hasattr(catalog, "__dict__"):
result = catalog.__dict__
else:
result = catalog
# Salva in Redis per le prossime richieste (TTL 1 ora)
cache_set(_CATALOG_KEY, result, _CATALOG_TTL)
logger.info("[Catalogo] Salvato in cache Redis")
return result
def _get_dataset_reqs(ds: dict) -> tuple:
"""
Ottieni dalla risposta del dataset le variabili disponibili e le coordinate dell'area disponibile.
Attualmente è implementato Copernicus SDK v2, le variabili sono in::
dataset -> versions[-1] -> parts[] -> services[] -> variables[]
Le coordinate sono disponibili in variable.bbox = [min_lon, min_lat, max_lon, max_lat].
La finestra temporale disponibile è nel servizio "arco-time-series"
dove coordinate_id == 'time' (i valori sono in millisecondi, usando Unix epoch).
"""
variables = []
seen: set = set()
bounds = {
"min_longitude": None, "max_longitude": None,
"min_latitude": None, "max_latitude": None,
"start_datetime": None, "end_datetime": None,
}
versions = ds.get("versions", [])
if not versions:
return variables, bounds
for part in versions[-1].get("parts", []):
for service in part.get("services", []):
service_name = service.get("service_name", "")
for var in service.get("variables", []):
short_name = var.get("short_name", "")
if not short_name or short_name in seen:
continue
seen.add(short_name)
std = var.get("standard_name")
variables.append({
"short_name": short_name,
"standard_name": std,
"units": var.get("units"),
"description": _fmt_description(std),
})
# Ottieni la box delle coordinate
if bounds["min_longitude"] is None:
bbox = var.get("bbox")
if bbox and len(bbox) >= 4:
# [min_lon, min_lat, max_lon, max_lat]
bounds["min_longitude"] = bbox[0]
bounds["min_latitude"] = bbox[1]
bounds["max_longitude"] = bbox[2]
bounds["max_latitude"] = bbox[3]
# Ottieni la finestra temporale del dataset dal servizio "arco-time-series"
if bounds["start_datetime"] is None and "arco-time" in service_name:
for coord in var.get("coordinates", []):
if coord.get("coordinate_id") == "time":
min_ms = coord.get("minimum_value")
max_ms = coord.get("maximum_value")
if min_ms is not None:
bounds["start_datetime"] = datetime.fromtimestamp(
min_ms / 1000, tz=timezone.utc
).strftime("%Y-%m-%d")
if max_ms is not None:
bounds["end_datetime"] = datetime.fromtimestamp(
max_ms / 1000, tz=timezone.utc
).strftime("%Y-%m-%d")
break
return variables, bounds
def get_catalog(search: Optional[str] = None, limit: int = 50, offset: int = 0) -> dict:
"""Ottieni dataset dal catalogo Copernicus Marine, filtrabili per nome o ID.
Cache Redis per le ricerche:
- Chiave: marine:catalog:search:{md5(search|limit|offset)}
- TTL: 30 minuti
- La cache ricerca viene invalidata quando il catalogo scade (1h)
"""
# Genera chiave cache unica per questa combinazione di parametri
cache_key = None
if search:
query_hash = hashlib.md5(f"{search}|{limit}|{offset}".encode()).hexdigest()[:12]
cache_key = f"marine:catalog:search:{query_hash}"
# Cerca risultato in cache Redis
cached_result = cache_get(cache_key)
if cached_result is not None:
logger.debug(f"[Catalogo] Ricerca '{search}' servita da cache Redis")
return cached_result
raw = _get_raw_catalog()
# Gestisce formati diversi della risposta SDK (lista o dizionario)
if isinstance(raw, list):
products = raw
else:
products = raw.get("products", [])
results = []
for product in products:
title = product.get("title", "")
description = product.get("description", "")
for ds in product.get("datasets", []):
dataset_id = ds.get("dataset_id", "")
if search:
needle = search.lower()
if needle not in dataset_id.lower() and needle not in title.lower():
continue
variables, bounds = _get_dataset_reqs(ds)
results.append({
"dataset_id": dataset_id,
"title": title,
"description": description[:200] if description else "",
"variables": variables,
**bounds,
})
total = len(results)
page = results[offset: offset + limit]
response = {"total": total, "offset": offset, "limit": limit, "datasets": page}
# Salva risultato ricerca in cache Redis (solo se c'è un filtro di ricerca)
if cache_key:
cache_set(cache_key, response, _SEARCH_TTL)
return response
def get_dataset_info(dataset_id: str) -> Optional[dict]:
"""Return detailed info for a single dataset (variables, bounds, time range)."""
raw = _get_raw_catalog()
if isinstance(raw, list):
products = raw
else:
products = raw.get("products", [])
for product in products:
for ds in product.get("datasets", []):
if ds.get("dataset_id") == dataset_id:
variables, bounds = _get_dataset_reqs(ds)
return {
"dataset_id": dataset_id,
"title": product.get("title", ""),
"description": product.get("description", ""),
"variables": variables,
**bounds,
}
return None
def download_dataset(
dataset_id: str,
variables: List[str],
min_longitude: float,
max_longitude: float,
min_latitude: float,
max_latitude: float,
start_datetime: str,
end_datetime: str,
progress_callback: Optional[Callable[[int, str], None]] = None
) -> pd.DataFrame:
"""
Scarica i dati di un dataset da Copernicus Marine. L'SDK ufficiale di Copernicus,
restituisce i dati del download sotto forma di pandas Dataframe.
"""
import tempfile
import copernicusmarine
if progress_callback:
progress_callback(5, "Avvio dowload...")
# l'SDK di copernicus richiede l'autenticazione di un utente
if not os.getenv("COPERNICUS_USERNAME") or not os.getenv("COPERNICUS_PASSWORD"):
raise ValueError("non sono presenti username e password per copernicus.")
with tempfile.TemporaryDirectory() as tmpdir:
try:
copernicusmarine.subset(
dataset_id=dataset_id,
variables=variables,
minimum_longitude=min_longitude,
maximum_longitude=max_longitude,
minimum_latitude=min_latitude,
maximum_latitude=max_latitude,
start_datetime=start_datetime,
end_datetime=end_datetime,
username=os.getenv("COPERNICUS_USERNAME"),
password=os.getenv("COPERNICUS_PASSWORD"),
output_directory=tmpdir,
output_filename="data.nc",
force_download=True,
overwrite_output_data=True,
disable_progress_bar=True,
)
except TypeError:
# Fallback for older versions of copernicusmarine
copernicusmarine.subset(
dataset_id=dataset_id,
variables=variables,
minimum_longitude=min_longitude,
maximum_longitude=max_longitude,
minimum_latitude=min_latitude,
maximum_latitude=max_latitude,
start_datetime=start_datetime,
end_datetime=end_datetime,
username=os.getenv("COPERNICUS_USERNAME"),
password=os.getenv("COPERNICUS_PASSWORD"),
output_directory=tmpdir,
output_filename="data.nc",
overwrite=True,
disable_progress_bar=True,
)
if progress_callback:
progress_callback(50, "Download completato, elaboro i dati...")
import xarray as xr
ds = xr.open_dataset(os.path.join(tmpdir, "data.nc"))
df = ds.to_dataframe().reset_index()
ds.close()
if df is None or df.empty:
raise ValueError("Nessun dato disponibile. errore nel download")
if progress_callback:
progress_callback(75, "Elaborazione completata, formatto i dati...")
return df
def dataframe_to_bytes(df: pd.DataFrame, fmt: str, variable_renames: dict = None) -> tuple:
"""
Converte i dati in memorie sottoforma di DataFrame scaircati da Copernicus in byte per migliorarne l'elaborazione e la formattazione in file CSV o JSON."""
if variable_renames:
df = df.rename(columns=variable_renames)
if fmt == "csv":
buf = io.StringIO()
df.to_csv(buf, index=True)
return buf.getvalue().encode("utf-8"), "text/csv"
else:
buf = io.StringIO()
df.to_json(buf, orient="records", date_format="iso", indent=2)
return buf.getvalue().encode("utf-8"), "application/json"

112
copernicus/core/storage.py Normal file
View File

@@ -0,0 +1,112 @@
import io
import json
import os
from typing import Any, Optional
from minio.error import S3Error
from minio import Minio
_minio_host = os.getenv("MINIO_ENDPOINT", "minio")
_minio_port = os.getenv("MINIO_PORT", "9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "meb-admin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "meb-cloud")
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
DATASETS_BUCKET = "datasets"
METADATA_FILE = "metadata.json"
_client: Optional[Minio] = None
def get_client() -> Minio:
global _client
if _client is None:
_client = Minio(
f"{_minio_host}:{_minio_port}",
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=MINIO_SECURE
)
return _client
def bucket_exists(bucket: str = DATASETS_BUCKET) -> bool:
try:
client = get_client()
if not client.bucket_exists(bucket):
client.make_bucket(bucket)
return True
except Exception as e:
print(f"[Storage] Error in '{bucket}': {e}")
return False
def fetch_metadata() -> dict:
"""Il bucket datasets contiene un file JSON di metadata valido per tutti i file dataset salvati, che questi siano JSON, csv o
un altro formato. I metadata per ogni file sono salvati come oggetti nel file metadata.json. """
try:
client = get_client()
response = client.get_object(DATASETS_BUCKET, METADATA_FILE)
data = json.loads(response.read().decode("utf-8"))
response.close()
return data
except S3Error as e:
if e.code == "NoSuchKey":
return {"datasets": []}
raise
except Exception:
return {"datasets": []}
def write_metadata(data: dict) -> None:
"""Aggiunge al file metadata.json un nuovo oggetto con l'id del nuovo file caricato dall'utente"""
client = get_client()
raw = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
client.put_object(
DATASETS_BUCKET,
METADATA_FILE,
io.BytesIO(raw),
length=len(raw),
content_type="application/json"
)
def upload_file(data: bytes, filename: str, content_type: str) -> None:
"""Carica un nuovo file di qualsiasi formato nel bucket dataset."""
client = get_client()
client.put_object(
DATASETS_BUCKET,
filename,
io.BytesIO(data),
length=len(data),
content_type=content_type
)
def delete_file(filename: str) -> None:
"""Elimina un file dal bucket dataset."""
client = get_client()
client.remove_object(DATASETS_BUCKET, filename)
def get_presigned_url(filename: str, expires_hours: int = 1) -> str:
"""Genera un URL temporaneo per scaricare un file dal bucket dataset"""
from datetime import timedelta
client = get_client()
return client.presigned_get_object(
DATASETS_BUCKET,
filename,
expires=timedelta(hours=expires_hours)
)
def file_exists(filename: str) -> bool:
"""Verifica se un file esiste."""
try:
client = get_client()
client.stat_object(DATASETS_BUCKET, filename)
return True
except S3Error:
return False

53
copernicus/main.py Normal file
View File

@@ -0,0 +1,53 @@
"""
Servizio reindirizzato: api.{domain}/marine/* (Traefik strips /marine prefix)
"""
import os
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
from routers import catalog, datasets, jobs
@asynccontextmanager
async def lifespan(app: FastAPI):
api_url = os.getenv("API_SERVICE_URL", "http://api:3003")
yield
app = FastAPI(
title="MEB Marine Service",
description="Copernicus Marine data download and dataset management for the MEB platform",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
root_path=""
)
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https?://.*\.(localhost|mebboat\.it)(:\d+)?$",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(catalog.router)
app.include_router(jobs.router)
app.include_router(datasets.router)
@app.get("/", tags=["health"])
async def root():
return {"service": "MEB Marine Service", "version": "1.0.0", "docs": "/docs"}
@app.get("/health", tags=["health"])
async def health():
return {"status": "healthy"}

View File

@@ -0,0 +1,13 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
httpx>=0.27.0
python-dotenv>=1.0.0
copernicusmarine>=2.0.0
xarray>=2024.0.0
pandas>=2.2.0
numpy>=1.26.0
pydantic>=2.6.0
redis>=5.0.0
python-multipart>=0.0.9
h5py
h5netcdf

View File

@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, Query, HTTPException
from typing import Optional
from middleware.auth import require_auth
from core import copernicus
"""
api.mebboat.it/marine/...
"""
router = APIRouter(prefix="/catalog", tags=["Copernicus Marine Database"])
@router.get("")
async def list_catalog(
search: Optional[str] = Query(None, description="Cerca per nome o ID"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(require_auth)
):
"""
[API] Ottieni una lista di dataset corrispondeti alla query di ricerca, con paginazione.
Ogni dataset include ID, titolo, descrizione, variabili, posizione e finestra di tempo.
I risultati rimangono salvati nella cache del server per un ora.
"""
try:
return copernicus.get_catalog(search=search, limit=limit, offset=offset)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Catalog unavailable: {str(e)}")
@router.get("/{dataset_id}")
async def get_dataset(
dataset_id: str,
user=Depends(require_auth)
):
"""
[API] Ottieni i dati di un dataset dal catalogo di Copernics Marine.
"""
try:
info = copernicus.get_dataset_info(dataset_id)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Catalog unavailable: {str(e)}")
if info is None:
raise HTTPException(status_code=404, detail=f"Dataset '{dataset_id}' not found in catalog")
return info

View File

@@ -0,0 +1,57 @@
"""
api.mebboat.it/marine/datasets/*
"""
import os
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from middleware.auth import require_auth
router = APIRouter(prefix="/datasets", tags=["datasets"])
API_URL = os.getenv("API_SERVICE_URL", "http://api-service:3003")
def _auth_headers(user: dict) -> dict:
return {"Authorization": f"Bearer {user['token']}"}
@router.get("")
async def list_datasets(
tags: Optional[str] = Query(None),
user=Depends(require_auth)
):
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(
f"{API_URL}/marine/datasets",
params={"tags": tags} if tags else {},
headers=_auth_headers(user),
)
if not r.is_success:
raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error"))
return r.json()
@router.get("/{dataset_id}/download")
async def download_dataset(dataset_id: str, user=Depends(require_auth)):
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(
f"{API_URL}/marine/datasets/{dataset_id}/download",
headers=_auth_headers(user),
)
if not r.is_success:
raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error"))
return r.json()
@router.delete("/{dataset_id}")
async def delete_dataset(dataset_id: str, user=Depends(require_auth)):
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.delete(
f"{API_URL}/marine/datasets/{dataset_id}",
headers=_auth_headers(user),
)
if not r.is_success:
raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error"))
return r.json()

145
copernicus/routers/jobs.py Normal file
View File

@@ -0,0 +1,145 @@
"""
Flusso:
1. POST /jobs → crea job in Redis con stato "pending"
2. Background task: scarica dati → aggiorna stato in Redis
3. GET /jobs/{id} → legge stato da Redis
"""
import json
import os
import uuid
from typing import Any, Dict
import httpx
from core import copernicus
from core.cache import cache_get, cache_set, cache_delete
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from middleware.auth import require_auth
from schemas import DownloadJobRequest, JobStatus
router = APIRouter(prefix="/jobs", tags=["sessions"])
API_URL = os.getenv("API_SERVICE_URL", "http://api:3003")
# TTL per lo stato dei job: 48 ore (i job completati vengono puliti automaticamente)
_JOB_TTL = 48 * 3600
def _job_key(session_id: str) -> str:
"""Genera la chiave Redis per un job."""
return f"marine:job:{session_id}"
def _get_job(session_id: str) -> Dict[str, Any] | None:
"""Legge lo stato di un job da Redis."""
return cache_get(_job_key(session_id))
def _set_job(session_id: str, **kwargs):
"""Aggiorna lo stato di un job in Redis.
Legge lo stato corrente, applica le modifiche, e riscrive."""
job = cache_get(_job_key(session_id))
if job is None:
return
job.update(kwargs)
cache_set(_job_key(session_id), job, _JOB_TTL)
def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_token: str):
"""Download in background: Copernicus → conversione → upload via API service.
Ad ogni cambio di fase, lo stato viene aggiornato in Redis
così il frontend può fare polling su GET /jobs/{id}.
"""
def progress(pct: int, msg: str):
_set_job(session_id, progress=pct, message=msg)
try:
_set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...")
# Scarica dati dal catalogo Copernicus
df = copernicus.download_dataset(
dataset_id=req.dataset_id,
variables=req.variables,
min_longitude=req.min_longitude,
max_longitude=req.max_longitude,
min_latitude=req.min_latitude,
max_latitude=req.max_latitude,
start_datetime=req.start_date,
end_datetime=req.end_date,
progress_callback=progress,
)
_set_job(session_id, status="converting", progress=80, message="Creo il file...")
# Converte il DataFrame in bytes (CSV o JSON)
data_bytes, content_type = copernicus.dataframe_to_bytes(df, req.format, req.variable_renames)
filename = f"upload.{req.format}"
_set_job(session_id, status="saving", progress=90, message="Carico su storage...")
# Metadati del dataset per l'API service
metadata = {
"nome": req.nome,
"tags": req.tags,
"created_by": username,
"type": req.format,
"notes": req.notes,
"copernicus_dataset_id": req.dataset_id,
"variables": req.variables,
"variable_renames": req.variable_renames,
"bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude],
"start_date": req.start_date,
"end_date": req.end_date,
}
# Upload al servizio API che gestisce MinIO
with httpx.Client(timeout=None) as client:
r = client.post(
f"{API_URL}/marine/datasets/upload",
headers={"Authorization": f"Bearer {user_token}"},
files={"file": (filename, data_bytes, content_type)},
data={"metadata": json.dumps(metadata)},
)
if not r.is_success:
raise RuntimeError(f"API upload failed ({r.status_code}): {r.text}")
entry = r.json()
_set_job(session_id, status="done", progress=100, message="Dataset salvato.", dataset_id=entry["id"])
except Exception as e:
_set_job(session_id, status="error", progress=0, message=str(e))
@router.post("", response_model=JobStatus, status_code=202)
async def new_download_session(
req: DownloadJobRequest,
background_tasks: BackgroundTasks,
user=Depends(require_auth)
):
"""Crea un nuovo job di download e lo avvia in background."""
session_id = str(uuid.uuid4())
# Stato iniziale del job salvato in Redis
initial_state = {
"job_id": session_id,
"status": "pending",
"progress": 0,
"message": "In coda",
"dataset_id": None,
}
cache_set(_job_key(session_id), initial_state, _JOB_TTL)
# Avvia il download in background
background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"])
return initial_state
@router.get("/{session_id}", response_model=JobStatus)
async def get_download_session(session_id: str, user=Depends(require_auth)):
"""Legge lo stato di un job di download da Redis."""
session = _get_job(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Job not found")
return session

77
copernicus/schemas.py Normal file
View File

@@ -0,0 +1,77 @@
from pydantic import BaseModel, Field
from typing import Dict, List, Optional, Any
from datetime import datetime
# ── Catalog ──────────────────────────────────────────────────────────────────
class DatasetVariable(BaseModel):
short_name: str
standard_name: Optional[str] = None
units: Optional[str] = None
description: Optional[str] = None # human-readable label derived from standard_name
class CatalogDataset(BaseModel):
dataset_id: str
title: Optional[str] = None
description: Optional[str] = None
variables: List[DatasetVariable] = []
min_longitude: Optional[float] = None
max_longitude: Optional[float] = None
min_latitude: Optional[float] = None
max_latitude: Optional[float] = None
start_datetime: Optional[str] = None
end_datetime: Optional[str] = None
# ── Jobs ─────────────────────────────────────────────────────────────────────
class DownloadJobRequest(BaseModel):
dataset_id: str
variables: List[str] = Field(..., min_length=1)
min_longitude: float
max_longitude: float
min_latitude: float
max_latitude: float
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
format: str = Field("json", pattern="^(json|csv)$")
nome: str = Field(..., min_length=1)
tags: List[str] = Field(default_factory=lambda: ["marine"])
notes: str = ""
variable_renames: Dict[str, str] = Field(default_factory=dict) # {original: custom}
class JobStatus(BaseModel):
job_id: str
status: str # pending | downloading | converting | saving | done | error
progress: int = 0 # 0-100
message: str = ""
dataset_id: Optional[str] = None # filled on done
# ── Saved Datasets ────────────────────────────────────────────────────────────
class DatasetMeta(BaseModel):
id: str
nome: str
tags: List[str] = []
created_date: str
created_by: str
used_last_date: Optional[str] = None
type: str # json | csv
size: int
notes: str = ""
version: int = 1
filename: str
copernicus_dataset_id: str
variables: List[str] = []
bbox: List[float] = [] # [min_lon, min_lat, max_lon, max_lat]
start_date: str
end_date: str
class DatasetListResponse(BaseModel):
datasets: List[DatasetMeta]
count: int

581
copernicus/static/script.js Normal file
View File

@@ -0,0 +1,581 @@
const MARINE_API = API_URL + '/marine';
const MAPBOX_TOKEN = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
// ── State ──────────────────────────────────────────────────────────────────
let selectedDatasetId = null;
let selectedVariables = new Set();
let datasetDateRange = { min: null, max: null };
let tags = ['marine'];
let currentBbox = null;
let currentStep = 0;
let map = null;
let isDrawMode = false;
let isDrawing = false;
let drawStart = null;
let pollInterval = null;
const TOTAL_STEPS = 6;
// Variable renames: { originalName: customName }
let variableRenames = {};
let _currentRenaming = null;
// ── Init ───────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initMap();
renderTags();
setupTagInput();
renderDots();
showStep(0, false);
document.getElementById('catalogSearch').addEventListener('keydown', e => {
if (e.key === 'Enter') searchCatalog();
});
document.getElementById('renameInput').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); saveRename(); }
if (e.key === 'Escape') closeRenameModal();
});
['startDate','endDate','datasetName','outputFormat'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', () => { markDone(); updateSummary(); refreshNext(); });
});
});
// ── Stepper ────────────────────────────────────────────────────────────────
function showStep(i, smooth = true) {
const steps = Array.from(document.querySelectorAll('.step'));
currentStep = Math.max(0, Math.min(i, TOTAL_STEPS - 1));
steps.forEach((el, idx) => {
const active = idx === currentStep;
el.classList.toggle('active', active);
if (active) el.removeAttribute('disabled');
else el.setAttribute('disabled', '');
});
document.getElementById('prevBtn').disabled = currentStep === 0;
document.getElementById('nextBtn').textContent = currentStep === TOTAL_STEPS - 1 ? 'Fine' : 'Prossimo';
refreshNext();
updateDots();
updateSummary();
markDone();
if (smooth) {
const active = steps[currentStep];
if (active) setTimeout(() => active.scrollIntoView({ behavior: 'smooth', block: 'center' }), 60);
}
}
function refreshNext() {
document.getElementById('nextBtn').disabled = !canAdvance(currentStep);
}
function nextStep() {
if (!canAdvance(currentStep)) { showToast('Completa questo passo per continuare', 'error'); return; }
if (currentStep < TOTAL_STEPS - 1) showStep(currentStep + 1);
}
function prevStep() {
if (currentStep > 0) showStep(currentStep - 1);
}
function canAdvance(i) {
switch (i) {
case 0: return !!selectedDatasetId;
case 1: return selectedVariables.size > 0;
case 2: return !!currentBbox;
case 3: {
const s = document.getElementById('startDate').value;
const e = document.getElementById('endDate').value;
return !!(s && e && s <= e);
}
case 4: return !!document.getElementById('datasetName').value.trim();
default: return true;
}
}
// ── Dots ───────────────────────────────────────────────────────────────────
function renderDots() {
const c = document.getElementById('progressDots');
c.innerHTML = '';
for (let i = 0; i < TOTAL_STEPS; i++) {
const d = document.createElement('button');
d.className = 'progress-dot';
d.setAttribute('aria-label', `Passo ${i + 1}`);
d.addEventListener('click', () => { if (i <= currentStep) showStep(i); });
c.appendChild(d);
}
updateDots();
}
function updateDots() {
Array.from(document.getElementById('progressDots').children)
.forEach((d, i) => d.classList.toggle('active', i === currentStep));
}
// ── Done badges ────────────────────────────────────────────────────────────
function markDone() {
const steps = document.querySelectorAll('.step');
const checks = [
!!selectedDatasetId,
selectedVariables.size > 0,
!!currentBbox,
canAdvance(3),
!!document.getElementById('datasetName').value.trim(),
false,
];
steps.forEach((el, i) => el.classList.toggle('done', checks[i] === true));
}
// ── Catalog ────────────────────────────────────────────────────────────────
async function searchCatalog() {
const q = document.getElementById('catalogSearch').value.trim();
const btn = document.getElementById('searchBtn');
const box = document.getElementById('catalogResults');
box.innerHTML = '<div class="catalog-empty"><span class="spin"></span>Ricerca in corso...</div>';
btn.disabled = true;
try {
const params = q ? `?search=${encodeURIComponent(q)}&limit=30` : '?limit=30';
const res = await MEB_AUTH.fetch(`${MARINE_API}/catalog${params}`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Errore catalogo');
if (!data.datasets?.length) {
box.innerHTML = '<div class="catalog-empty">Nessun dataset trovato</div>';
return;
}
box.innerHTML = '';
data.datasets.forEach(ds => {
const item = document.createElement('div');
item.className = 'catalog-item';
item.innerHTML = `<div class="ds-id">${ds.dataset_id}</div><div class="ds-title">${ds.title || ''}</div>`;
item.addEventListener('click', () => selectDataset(ds, item));
box.appendChild(item);
});
} catch (e) {
box.innerHTML = `<div class="catalog-empty" style="color:var(--danger)">Errore: ${e.message}</div>`;
} finally {
btn.disabled = false;
}
}
async function selectDataset(ds, itemEl) {
document.querySelectorAll('.catalog-item').forEach(i => i.classList.remove('selected'));
itemEl.classList.add('selected');
selectedDatasetId = ds.dataset_id;
const badge = document.getElementById('selectedDsBadge');
badge.textContent = ds.dataset_id;
badge.style.display = 'block';
// Reset dependent steps
selectedVariables.clear();
const vBox = document.getElementById('variablesContainer');
vBox.innerHTML = '<span class="spin"></span><span style="color:var(--text-secondary);font-size:0.85rem;">Caricamento variabili...</span>';
try {
const res = await MEB_AUTH.fetch(`${MARINE_API}/catalog/${encodeURIComponent(ds.dataset_id)}`);
const info = await res.json();
renderVariables(info.variables || ds.variables || []);
if (info.min_longitude != null) {
setBboxAndFit(info.min_longitude, info.max_longitude, info.min_latitude, info.max_latitude);
}
if (info.start_datetime) {
prefillDates(info.start_datetime, info.end_datetime);
}
} catch {
renderVariables(ds.variables || []);
}
markDone();
refreshNext();
// Auto-advance to variables step
setTimeout(() => showStep(1), 700);
}
// ── Variables ──────────────────────────────────────────────────────────────
function renderVariables(vars) {
const c = document.getElementById('variablesContainer');
if (!vars?.length) {
c.innerHTML = '<span style="color:var(--text-secondary);font-size:0.85rem;">Nessuna variabile disponibile</span>';
updateVarCount();
return;
}
c.innerHTML = '';
vars.forEach(v => {
const name = typeof v === 'string' ? v : v.short_name;
const desc = typeof v === 'object' ? (v.description || v.standard_name || '') : '';
const units = typeof v === 'object' && v.units ? v.units : '';
const chip = document.createElement('div');
chip.className = 'var-chip';
chip.dataset.name = name;
chip.innerHTML = `
<span class="var-name">${esc(name)}</span>
${desc ? `<span class="var-desc" title="${esc(desc)}">${esc(desc)}</span>` : ''}
${units ? `<span class="var-units">[${esc(units)}]</span>` : ''}
<button class="rename-btn" onclick="event.stopPropagation(); openRenameModal(this.closest('.var-chip').dataset.name)">✏ Rinomina</button>
<span class="rename-badge">${variableRenames[name] ? '→ ' + esc(variableRenames[name]) : ''}</span>
`;
chip.addEventListener('click', () => toggleVar(chip, name));
c.appendChild(chip);
});
updateVarCount();
}
function toggleVar(chip, name) {
if (selectedVariables.has(name)) { selectedVariables.delete(name); chip.classList.remove('selected'); }
else { selectedVariables.add(name); chip.classList.add('selected'); }
updateVarCount(); markDone(); refreshNext();
}
function updateVarCount() {
const n = selectedVariables.size;
document.getElementById('varCount').textContent =
n === 0 ? 'Nessuna selezionata' : `${n} selezionat${n === 1 ? 'a' : 'e'}`;
}
function selectAllVars() {
document.querySelectorAll('.var-chip').forEach(chip => {
chip.classList.add('selected');
selectedVariables.add(chip.dataset.name);
});
updateVarCount(); markDone(); refreshNext();
}
function deselectAllVars() {
document.querySelectorAll('.var-chip').forEach(chip => chip.classList.remove('selected'));
selectedVariables.clear();
updateVarCount(); markDone(); refreshNext();
}
// ── Dates ──────────────────────────────────────────────────────────────────
function prefillDates(minDate, maxDate) {
datasetDateRange = { min: minDate, max: maxDate };
const s = document.getElementById('startDate');
const e = document.getElementById('endDate');
if (minDate) { s.min = minDate; s.value = minDate; e.min = minDate; }
if (maxDate) { e.max = maxDate; e.value = maxDate; s.max = maxDate; }
const hint = document.getElementById('dateRangeHint');
if (minDate && maxDate) hint.textContent = `Dati disponibili: ${minDate}${maxDate}`;
markDone(); updateSummary();
}
// ── Map ────────────────────────────────────────────────────────────────────
function initMap() {
mapboxgl.accessToken = MAPBOX_TOKEN;
map = new mapboxgl.Map({
container: 'mapContainer',
style: 'mapbox://styles/mapbox/dark-v11',
center: [14, 42], zoom: 3.5,
attributionControl: false,
});
map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right');
map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right');
map.on('load', () => {
map.addSource('bbox-rect', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'bbox-fill', type: 'fill', source: 'bbox-rect', paint: { 'fill-color': '#00a7f5', 'fill-opacity': 0.13 } });
map.addLayer({ id: 'bbox-line', type: 'line', source: 'bbox-rect', paint: { 'line-color': '#00a7f5', 'line-width': 2, 'line-dasharray': [4, 2] } });
});
map.getCanvas().addEventListener('mousedown', onCanvasMouseDown, true);
window.addEventListener('mousemove', onWindowMouseMove);
window.addEventListener('mouseup', onWindowMouseUp);
}
function startDraw() {
if (!map) return;
isDrawMode = true;
map.dragPan.disable(); map.boxZoom.disable();
document.getElementById('mapContainer').classList.add('draw-mode');
const btn = document.getElementById('drawBtn');
btn.textContent = 'Clicca e trascina…';
btn.classList.replace('secondary', 'primary');
}
function exitDrawMode() {
isDrawMode = isDrawing = false; drawStart = null;
map.dragPan.enable(); map.boxZoom.enable();
document.getElementById('mapContainer').classList.remove('draw-mode', 'drawing');
const btn = document.getElementById('drawBtn');
btn.textContent = 'Disegna area';
btn.classList.replace('primary', 'secondary');
}
function onCanvasMouseDown(e) {
if (!isDrawMode) return;
e.preventDefault(); e.stopPropagation();
isDrawing = true;
drawStart = map.unproject([e.offsetX, e.offsetY]);
document.getElementById('mapContainer').classList.add('drawing');
}
function onWindowMouseMove(e) {
if (!isDrawing || !drawStart) return;
const rc = map.getCanvas().getBoundingClientRect();
_drawRect(drawStart, map.unproject([e.clientX - rc.left, e.clientY - rc.top]));
}
function onWindowMouseUp(e) {
if (!isDrawing || !drawStart) return;
const rc = map.getCanvas().getBoundingClientRect();
const end = map.unproject([e.clientX - rc.left, e.clientY - rc.top]);
setBbox(
Math.min(drawStart.lng, end.lng), Math.max(drawStart.lng, end.lng),
Math.min(drawStart.lat, end.lat), Math.max(drawStart.lat, end.lat)
);
exitDrawMode();
}
function _drawRect(a, b) {
if (!map.getSource('bbox-rect')) return;
map.getSource('bbox-rect').setData({
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [[[a.lng,a.lat],[b.lng,a.lat],[b.lng,b.lat],[a.lng,b.lat],[a.lng,a.lat]]] },
});
}
function setBbox(minLon, maxLon, minLat, maxLat) {
currentBbox = { minLon, maxLon, minLat, maxLat };
document.getElementById('minLon').value = minLon.toFixed(4);
document.getElementById('maxLon').value = maxLon.toFixed(4);
document.getElementById('minLat').value = minLat.toFixed(4);
document.getElementById('maxLat').value = maxLat.toFixed(4);
document.getElementById('bboxReadout').textContent =
`${minLon.toFixed(2)}°/${minLat.toFixed(2)}° → ${maxLon.toFixed(2)}°/${maxLat.toFixed(2)}°`;
_drawRect({ lng: minLon, lat: minLat }, { lng: maxLon, lat: maxLat });
markDone(); refreshNext();
}
function setBboxAndFit(minLon, maxLon, minLat, maxLat) {
const doIt = () => {
setBbox(minLon, maxLon, minLat, maxLat);
map.fitBounds([[minLon, minLat], [maxLon, maxLat]], { padding: 40, maxZoom: 10, duration: 600 });
};
if (!map) return;
if (map.isStyleLoaded()) doIt(); else map.once('load', doIt);
}
function clearBbox() {
currentBbox = null;
['minLon','maxLon','minLat','maxLat'].forEach(id => document.getElementById(id).value = '');
document.getElementById('bboxReadout').textContent = '';
if (map?.getSource('bbox-rect')) map.getSource('bbox-rect').setData({ type: 'FeatureCollection', features: [] });
markDone(); refreshNext();
}
// ── Tags ───────────────────────────────────────────────────────────────────
function setupTagInput() {
const inp = document.getElementById('tagInput');
inp.addEventListener('keydown', e => {
if ((e.key === 'Enter' || e.key === ',') && inp.value.trim()) {
e.preventDefault();
const t = inp.value.trim().replace(/,/g,'').toLowerCase();
if (t && !tags.includes(t)) { tags.push(t); renderTags(); }
inp.value = '';
} else if (e.key === 'Backspace' && !inp.value && tags.length) {
tags.pop(); renderTags();
}
});
}
function renderTags() {
const wrap = document.getElementById('tagsWrap');
const inp = document.getElementById('tagInput');
wrap.innerHTML = '';
tags.forEach(t => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `${t} <span class="rm" onclick="removeTag('${t}')">×</span>`;
wrap.appendChild(chip);
});
wrap.appendChild(inp);
}
function removeTag(t) { tags = tags.filter(x => x !== t); renderTags(); }
// ── Download ───────────────────────────────────────────────────────────────
async function startDownload() {
if (!selectedDatasetId) return showToast('Seleziona un dataset', 'error');
if (!selectedVariables.size) return showToast('Seleziona almeno una variabile', 'error');
if (!currentBbox) return showToast("Disegna un'area sulla mappa", 'error');
if (!document.getElementById('startDate').value ||
!document.getElementById('endDate').value) return showToast('Inserisci le date', 'error');
if (!document.getElementById('datasetName').value.trim()) return showToast('Inserisci un nome', 'error');
const body = {
dataset_id: selectedDatasetId,
variables: Array.from(selectedVariables),
min_longitude: parseFloat(document.getElementById('minLon').value),
max_longitude: parseFloat(document.getElementById('maxLon').value),
min_latitude: parseFloat(document.getElementById('minLat').value),
max_latitude: parseFloat(document.getElementById('maxLat').value),
start_date: document.getElementById('startDate').value,
end_date: document.getElementById('endDate').value,
format: document.getElementById('outputFormat').value,
nome: document.getElementById('datasetName').value.trim(),
tags: [...tags],
notes: document.getElementById('datasetNotes').value.trim(),
variable_renames: { ...variableRenames },
};
const btn = document.getElementById('downloadBtn');
const prog = document.getElementById('downloadProgress');
btn.disabled = true;
prog.style.display = 'block';
setProgress(0, 'Avvio download...');
try {
const res = await MEB_AUTH.fetch(`${MARINE_API}/jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Errore avvio job');
pollJob(data.job_id, btn, prog);
} catch (e) {
showToast(`Errore: ${e.message}`, 'error');
btn.disabled = false;
prog.style.display = 'none';
}
}
function pollJob(jobId, btn, prog) {
clearInterval(pollInterval);
pollInterval = setInterval(async () => {
try {
const res = await MEB_AUTH.fetch(`${MARINE_API}/jobs/${jobId}`);
const job = await res.json();
setProgress(job.progress, job.message);
if (job.status === 'done') {
clearInterval(pollInterval);
btn.disabled = false;
showToast('Dataset salvato con successo!', 'success');
setTimeout(resetAll, 1800);
} else if (job.status === 'error') {
clearInterval(pollInterval);
btn.disabled = false;
showToast(`Errore: ${job.message}`, 'error');
prog.style.display = 'none';
}
} catch { /* transient */ }
}, 2000);
}
function setProgress(pct, msg) {
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressMsg').textContent = msg;
}
// ── Reset ──────────────────────────────────────────────────────────────────
function resetAll() {
selectedDatasetId = null;
selectedVariables.clear();
datasetDateRange = { min: null, max: null };
variableRenames = {};
tags = ['marine'];
['startDate','endDate','datasetName','datasetNotes'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.value = ''; el.min = ''; el.max = ''; }
});
document.getElementById('catalogResults').innerHTML =
'<div class="catalog-empty">Cerca un dataset Copernicus per iniziare</div>';
document.getElementById('selectedDsBadge').style.display = 'none';
document.getElementById('variablesContainer').innerHTML =
'<span style="color:var(--text-secondary);font-size:0.85rem;">Seleziona un dataset per vedere le variabili</span>';
document.getElementById('dateRangeHint').textContent = '';
document.getElementById('downloadProgress').style.display = 'none';
clearBbox();
renderTags();
showStep(0);
}
// ── Summary ────────────────────────────────────────────────────────────────
function updateSummary() {
if (currentStep !== 5) return;
const s = document.getElementById('startDate').value || '-';
const e = document.getElementById('endDate').value || '-';
const name = document.getElementById('datasetName').value || '-';
const fmt = document.getElementById('outputFormat').value || '-';
const vars = Array.from(selectedVariables).join(', ') || '-';
const bbox = currentBbox
? `${currentBbox.minLon.toFixed(2)},${currentBbox.minLat.toFixed(2)}${currentBbox.maxLon.toFixed(2)},${currentBbox.maxLat.toFixed(2)}`
: '-';
document.getElementById('summaryContent').innerHTML = `
<div><strong>Dataset:</strong> ${esc(selectedDatasetId || '-')}</div>
<div><strong>Variabili (${selectedVariables.size}):</strong> ${esc(vars)}</div>
<div><strong>Area:</strong> ${esc(bbox)}</div>
<div><strong>Periodo:</strong> ${esc(s)}${esc(e)}</div>
<div><strong>Formato:</strong> ${esc(fmt)}</div>
<div><strong>Nome:</strong> ${esc(name)}</div>
<div><strong>Tags:</strong> ${esc(tags.join(', '))}</div>
`;
}
// ── Rename modal ───────────────────────────────────────────────────────────
function openRenameModal(varName) {
_currentRenaming = varName;
document.getElementById('renameVarLabel').textContent = varName;
document.getElementById('renameInput').value = variableRenames[varName] || '';
document.getElementById('renameDeleteBtn').style.display = variableRenames[varName] ? 'inline-flex' : 'none';
document.getElementById('renameModal').classList.add('visible');
setTimeout(() => document.getElementById('renameInput').select(), 50);
}
function saveRename() {
if (!_currentRenaming) return;
const val = document.getElementById('renameInput').value.trim();
if (!val) { deleteRename(); return; }
variableRenames[_currentRenaming] = val;
_updateRenameBadge(_currentRenaming, val);
closeRenameModal();
}
function deleteRename() {
if (!_currentRenaming) return;
delete variableRenames[_currentRenaming];
_updateRenameBadge(_currentRenaming, '');
closeRenameModal();
}
function closeRenameModal() {
document.getElementById('renameModal').classList.remove('visible');
_currentRenaming = null;
}
function _updateRenameBadge(varName, text) {
const chip = [...document.querySelectorAll('.var-chip')].find(c => c.dataset.name === varName);
if (!chip) return;
chip.querySelector('.rename-badge').textContent = text ? '→ ' + text : '';
}
// ── Helpers ────────────────────────────────────────────────────────────────
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.className = `${type} show`;
t.textContent = msg;
setTimeout(() => t.classList.remove('show'), 3500);
}

View File

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html>
<head>
<title>Copernicus Marine</title>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.js"></script>
<link rel="stylesheet" href="../static/style.css">
<script src="../static/script.js"></script>
</head>
<body>
<div class="marine-body">
<div class="page-actions">
<a href="/datasets">Dataset salvati →</a>
</div>
<!-- Step 1 -->
<div class="step" id="step-1">
<div class="step-header">
<span class="step-num">1</span>
<h2 class="step-title">Cerca un dataset</h2>
<span class="step-done-badge">✓ Selezionato</span>
</div>
<div class="search-bar">
<input type="text" id="catalogSearch" placeholder="Es. Mediterranean Sea, Physical Oceanography ...">
<button class="btn secondary" id="searchBtn" onclick="searchCatalog()">Cerca</button>
</div>
<div id="catalogResults">
<div class="catalog-empty"></div>
</div>
<div class="selected-ds-badge" id="selectedDsBadge"></div>
</div>
<!-- Step 2 -->
<div class="step" id="step-2">
<div class="step-header">
<span class="step-num">2</span>
<h2 class="step-title">Variabili</h2>
<span class="step-done-badge">✓ Selezionate</span>
</div>
<div class="var-toolbar">
<span id="varCount">Nessuna selezionata</span>
<!-- <button onclick="selectAllVars()">Seleziona tutte</button>
<button onclick="deselectAllVars()">Deseleziona tutte</button> -->
</div>
<div id="variablesContainer">
<span style="color:var(--text-secondary);font-size:0.85rem;"></span>
</div>
</div>
<!-- Step 3 -->
<div class="step" id="step-3">
<div class="step-header">
<span class="step-num">3</span>
<h2 class="step-title">Area</h2>
<span class="step-done-badge">✓ Impostata</span>
</div>
<div id="mapContainer"></div>
<div class="map-toolbar">
<button type="button" class="btn secondary small" id="drawBtn" onclick="startDraw()">Disegna
area</button>
<button type="button" class="btn secondary small" onclick="clearBbox()">Cancella</button>
<span id="bboxReadout" class="bbox-readout"></span>
</div>
<input type="hidden" id="minLon">
<input type="hidden" id="maxLon">
<input type="hidden" id="minLat">
<input type="hidden" id="maxLat">
</div>
<!-- Step 4 -->
<div class="step" id="step-4">
<div class="step-header">
<span class="step-num">4</span>
<h2 class="step-title">Finestra temporale</h2>
<span class="step-done-badge"></span>
</div>
<div class="form-row form-group">
<input type="date" id="startDate">
<input type="date" id="endDate">
</div>
<div class="field-hint" id="dateRangeHint"></div>
</div>
<!-- Step 5 -->
<div class="step" id="step-5">
<div class="step-header">
<span class="step-num">5</span>
<h2 class="step-title">Dettagli</h2>
<span class="step-done-badge">Completo</span>
</div>
<div class="form-group">
<select id="outputFormat">
<option value="json">JSON</option>
<option value="csv">CSV</option>
</select>
</div>
<div class="form-group">
<input type="text" id="datasetName" placeholder="Nome">
</div>
<div class="form-group">
<div class="tags-input-wrap" id="tagsWrap" onclick="document.getElementById('tagInput').focus()">
<input type="text" id="tagInput" placeholder="Tag (Invio per aggiungere)">
</div>
</div>
<div class="form-group">
<textarea id="datasetNotes" rows="2" placeholder="Aggiungi ulteriori dettagli (opzionale)"></textarea>
</div>
</div>
<!-- Step 6 -->
<div class="step" id="step-6">
<div class="step-header">
<span class="step-num">6</span>
<h2 class="step-title">Scarica</h2>
</div>
<div id="summaryContent"></div>
<div style="display:flex;gap:0.75rem;align-items:center;">
<button class="btn primary" id="downloadBtn" onclick="startDownload()">Avvia download</button>
<button class="btn secondary" onclick="resetAll()">Reset</button>
</div>
<div id="downloadProgress">
<div class="progress-bar-wrap">
<div class="progress-bar-fill" id="progressFill"></div>
</div>
<div class="progress-msg" id="progressMsg"></div>
</div>
</div>
</div>
<!-- Toolbar fissa -->
<div class="step-toolbar">
<div class="progress-dots" id="progressDots"></div>
<div class="step-actions">
<button class="btn secondary" id="prevBtn" onclick="prevStep()">Indietro</button>
<button class="btn primary" id="nextBtn" onclick="nextStep()">Prossimo</button>
</div>
</div>
<div id="toast"></div>
<!-- Rename variable modal -->
<div id="renameModal">
<div id="renameBackdrop" onclick="closeRenameModal()"></div>
<div id="renameDialog">
<h4>Rinomina variabile</h4>
<div id="renameVarLabel"></div>
<input type="text" id="renameInput" placeholder="Nome personalizzato">
<div class="rename-actions">
<button class="btn primary" style="flex:1;" onclick="saveRename()">Salva</button>
<button class="btn secondary" id="renameDeleteBtn" onclick="deleteRename()">Elimina</button>
</div>
</div>
</div>
</body>
</html>

164
docker-compose.yml Normal file
View File

@@ -0,0 +1,164 @@
services:
auth:
container_name: auth
build:
context: ./auth
dockerfile: Dockerfile
restart: unless-stopped
command: npm run dev
volumes:
- ./auth:/app
- /app/node_modules
env_file:
- ./auth/.env
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3006/health').then(r => r.ok ? process.exit(0) : process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
networks:
- meb-proxy-net
- meb-internal
ports:
- "3006:3006"
labels:
- "traefik.enable=true"
- "traefik.http.routers.auth.rule=Host(`auth.${URL_DOMAIN}`)"
- "traefik.http.routers.auth.entrypoints=web"
- "traefik.http.services.auth.loadbalancer.server.port=3006"
- "traefik.docker.network=meb-proxy-net"
api:
container_name: api-services
build:
context: ./api
dockerfile: Dockerfile
restart: unless-stopped
command: npm run dev
volumes:
- ./api/src:/app/src
- /app/node_modules
- ./ml:/ml-source
- /var/run/docker.sock:/var/run/docker.sock
env_file:
- ./api/.env
networks:
- meb-proxy-net
- meb-internal
ports:
- "3003:3003"
console:
build:
context: ./console
dockerfile: Dockerfile
restart: unless-stopped
command: npm run dev
volumes:
- ./console:/app
- /app/node_modules
env_file:
- ./console/.env
networks:
- meb-proxy-net
- meb-internal
ports:
- "3004:3004"
realtime:
build:
context: ./realtime
dockerfile: Dockerfile
restart: unless-stopped
command: npm run dev
ports:
- "3002:3002"
- "3102:3102"
volumes:
- ./realtime:/app
- /app/node_modules
env_file:
- ./realtime/.env
networks:
- meb-proxy-net
- meb-internal
# ml:
# container_name: ml-service
# build:
# context: ./ml
# dockerfile: Dockerfile
# restart: unless-stopped
# volumes:
# - ./ml:/app
# env_file:
# - ./ml/.env
# ports:
# - "3005:3005"
# networks:
# - meb-proxy-net
# - meb-internal
# marine:
# container_name: marine-service
# build:
# context: ./marine
# dockerfile: Dockerfile
# restart: unless-stopped
# volumes:
# - ./marine:/app
# env_file:
# - ./marine/.env
# environment:
# - REDIS_HOST=meb-redis
# - REDIS_PORT=6379
# networks:
# - meb-proxy-net
# - meb-internal
# labels:
# - "traefik.enable=true"
# - "traefik.http.routers.marine.rule=Host(`api.${URL_DOMAIN}`) && PathPrefix(`/marine`)"
# - "traefik.http.routers.marine.entrypoints=web"
# - "traefik.http.services.marine.loadbalancer.server.port=8001"
# - "traefik.docker.network=meb-proxy-net"
# - "traefik.http.middlewares.marine-strip.stripprefix.prefixes=/marine"
# - "traefik.http.routers.marine.middlewares=marine-strip"
# circuits:
# container_name: meb-circuits
# build:
# context: ./circuits
# dockerfile: Dockerfile
# restart: unless-stopped
# environment:
# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits
# - AUTH_SERVICE_URL=http://auth:3001
# - AUTH_URL=http://auth.${URL_DOMAIN:-localhost}
# - API_URL=http://api.${URL_DOMAIN:-localhost}
# - NODE_ENV=${NODE_ENV:-development}
# volumes:
# - ./circuits/src:/app/src
# - /app/node_modules
# healthcheck:
# test: ["CMD", "node", "-e", "fetch('http://localhost:3005/health').then(r => r.ok ? process.exit(0) : process.exit(1))"]
# interval: 30s
# timeout: 5s
# retries: 3
# depends_on:
# - auth
# networks:
# - meb-proxy-net
# - meb-internal
# labels:
# - "traefik.enable=true"
# - "traefik.http.routers.circuits.rule=Host(`circuits.${URL_DOMAIN}`)"
# - "traefik.http.routers.circuits.entrypoints=web"
# - "traefik.http.services.circuits.loadbalancer.server.port=3005"
# - "traefik.docker.network=meb-proxy-net"
# - "traefik.http.routers.circuits.middlewares=cors-ignore"
networks:
meb-proxy-net:
external: true
meb-internal:
external: true

1
ml/.dockerignore Normal file
View File

@@ -0,0 +1 @@
__pycache__

0
ml/.env.example Normal file
View File

13
ml/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
COPY ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
EXPOSE 3007
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3007"]

19
ml/main.py Normal file
View File

@@ -0,0 +1,19 @@
from fastapi import FastAPI, Request, Response, Header
from fastapi.responses import HTMLResponse, JSONResponse
import time
app = FastAPI()
@app.get("/health")
def health():
return {
"status": "ok",
"service": "ml",
"version": "1.0.0",
"build_number": "1",
"version_state": "dev"
}
@app.get("/")
def root():
return {"message": "ML Service"}

2
ml/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
fastapi
uvicorn

Binary file not shown.

201
ml/static/styles/style.css Normal file
View File

@@ -0,0 +1,201 @@
:root {
--accent-color: #2563eb;
--accent-hover: #1d4ed8;
--accent-light: #eff6ff;
--accent-border: #bfdbfe;
--text-primary: #0f172a;
--text-secondary: #4755698f;
--text-tertiary: #94a3b8c0;
--surface: #f8fafc;
--header-bg: rgba(255, 255, 255, 0.85);
/* For Glassmorphism */
--header-border: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--radius-md: 8px;
--radius-lg: 12px;
}
* {
margin: 0;
padding: 0;
}
@font-face {
font-family: 'Normal';
src: url('../font/Quicksand-VariableFont_wght.ttf');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Bold';
src: url('../font/Quicksand-VariableFont_wght.ttf');
font-weight: 700;
font-style: normal;
}
body {
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
button {
padding: 10px 24px;
border-radius: var(--radius-lg);
border: 1px solid var(--header-border);
background-color: var(--bg-surface);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
font-family: 'Bold', inherit;
cursor: pointer;
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
white-space: nowrap;
flex-shrink: 0;
}
button:hover {
background-color: var(--accent-light);
color: var(--accent-color);
border-color: var(--accent-border);
}
button.prominent {
background-color: var(--accent-color);
color: #ffffff;
border: 1px solid transparent;
box-shadow: var(--shadow-sm);
}
button.prominent:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
button.prominent:active {
transform: translateY(1px);
box-shadow: var(--shadow-sm);
}
/* INFO PANEL */
.info-panel {
display: block;
text-align: center;
align-content: center;
padding: 60px 20px;
user-select: none;
}
.info-panel h3 {
margin-bottom: 10px;
}
.info-panel p {
margin-bottom: 25px;
}
.info-panel .icon {
font-size: 48px;
margin-bottom: 20px;
align-self: center;
transition: transform 0.12s ease;
}
/* GRID & CARD ITEMS */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-inline: 30px;
}
.card {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 1rem;
border: 1px solid var(--header-border);
border-radius: 20px;
text-decoration: none;
color: var(--text-primary);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.card h3 {
margin: 0;
font-size: 1rem;
text-align: left;
}
.card p {
margin: 0.25rem 0 0;
color: var(--text-secondary);
opacity: 0.4;
font-size: 0.8rem;
text-align: left;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px #bfdbfe30;
}
.card.standalone {
grid-column: 1 / -1;
}
/* HEADER */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background-color: var(--header-bg);
border-bottom: 1px solid var(--header-border);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 10;
user-select: none;
}
.header h1 {
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.header .profile {
display: flex;
align-items: center;
gap: 16px;
}
.header .profile p {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
padding-inline: 5px;
}

28
ml/templates/console.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>ML</title>
<link href="../static/styles/style.css" rel="stylesheet">
</head>
<body>
<div class="header">
<h1>Modelli ML</h1>
<div class="profile">
<p>Utente</p>
<button>Logout</button>
</div>
</div>
<div class="container">
</div>
</body>
<script>
</script>
</html>

View File

89
ml/templates/results.html Normal file
View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<title>Risultati</title>
<link href="../static/styles/style.css" rel="stylesheet">
<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 class="container">
<div class="picker">
<div class="header">
<h2>
Seleziona
</h2>
<p>
una sessione di training eseguita per visualizzarne i risultati
</p>
</div>
<div class="grid">
<div class="card">
<h3>sessione 1</h3>
<div class="train-info">
<p>24/03/2026</p>
<p>12:00</p>
<p>dataset: d-1</p>
</div>
</div>
<div class="card">
<h3>sessione 2</h3>
<p>24/03/2026</p>
</div>
</div>
</div>
</div>
</body>
<script>
</script>
</html>

0
ml/templates/test.html Normal file
View File

0
ml/templates/train.html Normal file
View File

179
package-lock.json generated Normal file
View File

@@ -0,0 +1,179 @@
{
"name": "meb-custom-server",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"cookie-parser": "^1.4.7",
"jsonwebtoken": "^9.0.3"
}
},
"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/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"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-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/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/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.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.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/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/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/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"
}
}
}
}

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"cookie-parser": "^1.4.7",
"jsonwebtoken": "^9.0.3"
}
}

1
realtime/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

9
realtime/.env.example Normal file
View File

@@ -0,0 +1,9 @@
PORT=3004
VERSION=1.0.0
VERSION_BUILD=1.0
VERSION_STATE=beta
INFLX_URL=
INFLX_TOKEN=
INFLX_ORG=

12
realtime/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
EXPOSE 3002
CMD ["node", "src/index.js"]

1110
realtime/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
realtime/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "meb-realtime-service",
"version": "1.4.0",
"description": "Realtime service for sensor connections - Node.js",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@influxdata/influxdb-client": "^1.35.0",
"@influxdata/influxdb-client-apis": "^1.35.0",
"@msgpack/msgpack": "^3.1.3",
"express": "^5.2.1",
"ioredis": "^5.10.0",
"pg": "^8.20.0",
"ws": "^8.19.0"
}
}

View File

@@ -0,0 +1,84 @@
const { Pool } = require('pg');
const { hash, generateShortId } = require('./cryptoUtils');
const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT,
})
async function checkDB() {
try {
await pool.query('SELECT NOW()');
} catch (error) {
console.error('Database connection failed:', error);
}
}
/**
* Restituisce i dati del sensore in base al token ricevuto.
* Il token viene hashato prima della comparazione con il database.
* @param {string} token - il codice segreto del sensore (raw)
*/
async function getSensor(token) {
const hashed = hash(token);
const result = await pool.query('SELECT id, is_active, name, last_seen, created_at FROM sensors WHERE code_hash = $1', [hashed]);
return result.rows[0];
}
async function createSensor(name, code) {
const hashedCode = hash(code);
// Verifica se l'hash esiste già
const result = await pool.query('SELECT id FROM sensors WHERE code_hash = $1', [hashedCode]);
if (result.rows.length > 0) {
throw new Error('Sensor with this code already exists');
}
// Genera un ID casuale di 8 caratteri (ottimizzato per spazio, non solo alfanumerico)
const sensorId = generateShortId(8);
await pool.query('INSERT INTO sensors (id, name, code_hash, is_active, last_seen, created_at) VALUES ($1, $2, $3, $4, $5, $6)',
[sensorId, name, hashedCode, true, new Date(), new Date()]);
}
/**
* Aggiorna l'ultima attività del sensore.
* @param {*} id - l'id del sensore
* @returns {Promise<void>}
*/
async function updateLastSeen(id) {
await pool.query('UPDATE sensors SET last_seen = NOW() WHERE id = $1', [id]);
}
/**
* Modifica la disponibilità del sensore.
* @param {*} id - l'id del sensore
* @param {*} is_active - la disponibilità del sensore
* @returns {Promise<void>}
*/
async function setSensorActivity(id, is_active) {
await pool.query('UPDATE sensors SET is_active = $1 WHERE id = $2', [is_active, id]);
}
async function sensorsExists(id) {
const result = await pool.query('SELECT id FROM sensors WHERE id = $1', [id]);
return result.rows.length > 0;
}
async function getSensors() {
const resutls = await pool.query('SELECT id, is_active, name, last_seen, created_at FROM sensors');
return resutls.rows;
}
module.exports = {
checkDB,
getSensor,
updateLastSeen,
setSensorActivity,
getSensors,
sensorsExists,
createSensor
}

View File

@@ -0,0 +1,35 @@
const crypto = require('crypto');
/**
* Genera un hash SHA256 in formato esadecimale da una stringa.
* Utilizzato per rendere compatibili authdb.js e tokenStore.js.
*/
function hash(text) {
if (!text) return null;
return crypto.createHash('sha256').update(text).digest('hex');
}
/**
* Genera una stringa casuale di lunghezza 'length'.
* Ottimizzata per risparmiare spazio (8 caratteri).
* Include lettere, numeri e simboli per massimizzare l'entropia (non solo alfanumerico).
*/
function generateShortId(length = 8) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
let result = '';
while (result.length < length) {
const bytes = crypto.randomBytes(length);
for (let i = 0; i < bytes.length && result.length < length; i++) {
// Selezioniamo solo i byte che rientrano nel range del charset per evitare bias
if (bytes[i] < 256 - (256 % charset.length)) {
result += charset[bytes[i] % charset.length];
}
}
}
return result;
}
module.exports = {
hash,
generateShortId
};

View File

@@ -0,0 +1,75 @@
const { InfluxDB } = require('@influxdata/influxdb-client');
const url = process.env.INFLX_URL;
const token = process.env.INFLX_TOKEN;
const org = process.env.INFLX_ORG;
const bucket = 'boat';
const client = new InfluxDB({ url, token });
const queryApi = client.getQueryApi(org);
/**
* Query tutti i dati di una sessione sensore da un timestamp di inizio.
* Ritorna array di righe { _time, _measurement, _field, _value, sensor }.
*/
async function querySessionData(sensorId, fromTimestamp) {
const from = new Date(fromTimestamp).toISOString();
const query = `
from(bucket: "${bucket}")
|> range(start: ${from})
|> filter(fn: (r) => r["sensor"] == "${sensorId}")
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
`;
const rows = [];
return new Promise((resolve, reject) => {
queryApi.queryRows(query, {
next(row, tableMeta) {
const obj = tableMeta.toObject(row);
rows.push(obj);
},
error(err) {
console.error(`[INFLUX] Query error:`, err.message);
reject(err);
},
complete() {
resolve(rows);
}
});
});
}
/**
* Formatta i risultati della query in CSV.
*/
function formatCSV(rows) {
if (rows.length === 0) return '';
// Raccogli tutte le colonne uniche escludendo meta-campi InfluxDB
const excludeKeys = new Set(['result', 'table', '_start', '_stop', '']);
const allKeys = new Set();
for (const row of rows) {
for (const key of Object.keys(row)) {
if (!excludeKeys.has(key)) allKeys.add(key);
}
}
const columns = ['_time', '_measurement', 'sensor',
...Array.from(allKeys).filter(k => !['_time', '_measurement', 'sensor'].includes(k)).sort()
];
const header = columns.join(',');
const lines = rows.map(row =>
columns.map(col => {
const val = row[col];
if (val == null) return '';
if (typeof val === 'string' && val.includes(',')) return `"${val}"`;
return val;
}).join(',')
);
return header + '\n' + lines.join('\n');
}
module.exports = { querySessionData, formatCSV };

View File

@@ -0,0 +1,156 @@
const { InfluxDB, Point } = require('@influxdata/influxdb-client');
const url = process.env.INFLX_URL;
const token = process.env.INFLX_TOKEN;
const org = process.env.INFLX_ORG;
const boatTelemetry = 'boat';
const client = new InfluxDB({ url, token });
const writeApi = client.getWriteApi(org, boatTelemetry);
const FIELD_MAP = {
logs: {
lat: 'latitude', lon: 'longitude', hdg: 'heading',
sog: 'speed_over_ground', cog: 'course_over_ground',
depth: 'depth', engTemp: 'engine_temperature',
fTemp: 'forecast_temperature', fHum: 'forecast_humidity', fPres: 'forecast_pressure',
fWSpd: 'forecast_wind_speed', fWDir: 'forecast_wind_direction',
wvH: 'wave_height', wvP: 'wave_period', wvD: 'wave_direction',
curD: 'current_direction', curV: 'current_velocity'
},
weather: {
temp: 'temperature', hum: 'humidity', pres: 'pressure',
wSpd: 'wind_speed', wDir: 'wind_direction', gust: 'wind_gusts',
rain: 'rain', prec: 'precipitation',
wvH: 'wave_height', wvP: 'wave_period', wvD: 'wave_direction',
wvPkP: 'wave_peak_period', curD: 'current_direction', curV: 'current_velocity'
},
forecast: {
temp: 'temperature', hum: 'humidity', pres: 'pressure',
wSpd: 'wind_speed', wDir: 'wind_direction',
precProb: 'precipitation_probability', prec: 'precipitation',
rain: 'rain', cloud: 'cloud_cover',
wvH: 'wave_height', wvP: 'wave_period', wvD: 'wave_direction',
curD: 'current_direction', curV: 'current_velocity'
}
};
async function writePoint(sensorId, timestamp, measurement, fields) {
try {
const map = FIELD_MAP[measurement] || {};
const point = new Point(measurement)
.tag('sensor', sensorId)
.timestamp(new Date(timestamp));
for (const [key, value] of Object.entries(fields)) {
if (value == null) continue;
const fieldName = map[key] || key;
if (typeof value === 'number') {
point.floatField(fieldName, value);
} else if (typeof value === 'string') {
point.stringField(fieldName, value);
} else if (typeof value === 'boolean') {
point.booleanField(fieldName, value);
}
}
writeApi.writePoint(point);
await writeApi.flush();
} catch (error) {
console.error(`[INFLUX] Errore writePoint (${measurement}):`, error.message);
}
}
async function writeForecastBatch(sensorId, points) {
try {
const map = FIELD_MAP.forecast || {};
for (const [ts, fields] of points) {
const point = new Point('forecast')
.tag('sensor', sensorId)
.timestamp(new Date(ts));
for (const [key, value] of Object.entries(fields)) {
if (value == null) continue;
const fieldName = map[key] || key;
if (typeof value === 'number') {
point.floatField(fieldName, value);
} else if (typeof value === 'string') {
point.stringField(fieldName, value);
}
}
writeApi.writePoint(point);
}
await writeApi.flush();
console.log(`[INFLUX] Scritti ${points.length} punti forecast per sensore ${sensorId}`);
} catch (error) {
console.error(`[INFLUX] Errore writeForecastBatch:`, error.message);
}
}
// --- Batch buffer per watchers ---
const BATCH_SIZE = 10;
const batchBuffers = new Map(); // sensorId → [{timestamp, measurement, fields}, ...]
function bufferPoint(sensorId, timestamp, measurement, fields) {
if (!batchBuffers.has(sensorId)) {
batchBuffers.set(sensorId, []);
}
const buffer = batchBuffers.get(sensorId);
buffer.push({ timestamp, measurement, fields });
if (buffer.length >= BATCH_SIZE) {
const batch = buffer.splice(0, BATCH_SIZE);
writeBatch(sensorId, batch);
}
}
async function writeBatch(sensorId, batch) {
try {
for (const { timestamp, measurement, fields } of batch) {
const map = FIELD_MAP[measurement] || {};
const point = new Point(measurement)
.tag('sensor', sensorId)
.timestamp(new Date(timestamp));
for (const [key, value] of Object.entries(fields)) {
if (value == null) continue;
const fieldName = map[key] || key;
if (typeof value === 'number') {
point.floatField(fieldName, value);
} else if (typeof value === 'string') {
point.stringField(fieldName, value);
} else if (typeof value === 'boolean') {
point.booleanField(fieldName, value);
}
}
writeApi.writePoint(point);
}
await writeApi.flush();
console.log(`[INFLUX] Batch scritto: ${batch.length} punti per sensore ${sensorId}`);
} catch (error) {
console.error(`[INFLUX] Errore writeBatch:`, error.message);
}
}
async function flushBuffer(sensorId) {
const buffer = batchBuffers.get(sensorId);
if (!buffer || buffer.length === 0) return [];
const remaining = buffer.splice(0);
await writeBatch(sensorId, remaining);
return remaining;
}
function getBufferedPoints(sensorId) {
return batchBuffers.get(sensorId) || [];
}
function clearBuffer(sensorId) {
batchBuffers.delete(sensorId);
}
module.exports = { writePoint, writeForecastBatch, bufferPoint, flushBuffer, getBufferedPoints, clearBuffer, FIELD_MAP };

View File

@@ -0,0 +1,79 @@
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
});
// Client dedicato per subscribe (ioredis richiede client separato)
const redisSub = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
});
redis.on('error', (error) => {
console.error('Redis error:', error);
});
redis.on('connect', () => {
console.log('Server connected to Redis DB');
});
redisSub.on('error', (error) => {
console.error('Redis sub error:', error);
});
const sensors_hash_map = 'sensors:sessions';
async function setSession(sensorID, metadata) {
await redis.hset(sensors_hash_map, sensorID, JSON.stringify(metadata));
}
async function getSession(sensorID) {
return await redis.hget(sensors_hash_map, sensorID);
}
async function deleteSession(sensorID) {
await redis.hdel(sensors_hash_map, sensorID);
}
async function getSessions() {
return await redis.hgetall(sensors_hash_map);
}
// --- Pub/Sub per live watchers ---
async function publishSensorData(sensorId, data) {
await redis.publish(`sensor:data:${sensorId}`, JSON.stringify(data));
}
async function addWatcher(sensorId) {
return await redis.incr(`sensor:watchers:${sensorId}`);
}
async function removeWatcher(sensorId) {
const count = await redis.decr(`sensor:watchers:${sensorId}`);
if (count <= 0) {
await redis.del(`sensor:watchers:${sensorId}`);
return 0;
}
return count;
}
async function getWatcherCount(sensorId) {
const count = await redis.get(`sensor:watchers:${sensorId}`);
return parseInt(count) || 0;
}
module.exports = {
setSession,
getSession,
deleteSession,
getSessions,
publishSensorData,
addWatcher,
removeWatcher,
getWatcherCount,
redis,
redisSub
};

View File

@@ -0,0 +1,52 @@
const { redis } = require('./redis');
const { generateShortId } = require('./cryptoUtils');
const TOKEN_PREFIX = 'token:pending:';
/**
* Genera un nuovo token effimero valido per i prossimi 5 secondi.
*/
async function setToken(sensorId, metadata = {}, duration = 5) {
const token = generateShortId(8);
const key = `${TOKEN_PREFIX}${token}`;
const payload = JSON.stringify({
sensorId,
metadata,
createdAt: Date.now()
});
await redis.set(key, payload, 'EX', duration);
return token;
}
/**
* Consuma (valida e rimuove) un token.
* @returns {Object|null} - I dati della sessione se valida, altrimenti null
*/
async function consumeToken(token) {
const key = `${TOKEN_PREFIX}${token}`;
// Recupera il token
const rawData = await redis.get(key);
if (!rawData) {
return null;
}
// Il token è monouso: lo cancelliamo subito dopo la lettura
await redis.del(key);
try {
const data = JSON.parse(rawData);
return data; // Ritorna l'intero oggetto (sensorId, metadata, ecc.)
} catch (e) {
console.error('Error parsing token data:', e);
return null;
}
}
module.exports = {
setToken,
consumeToken
};

149
realtime/src/index.js Normal file
View File

@@ -0,0 +1,149 @@
const express = require('express');
const WebSocket = require('ws');
const Redis = require('ioredis');
const redisHelper = require('./helper/redis');
const influxWriter = require('./helper/influxWriter');
const influxReader = require('./helper/influxReader');
const app = express();
app.use(express.json());
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
require('./socket');
app.get('/', (req, res) => {
res.redirect('/health');
});
app.get('/health', (req, res) => {
res.status(200).send({
status: 'OK',
service: 'realtime',
version: process.env.VERSION,
build: process.env.VERSION_BUILD,
state: process.env.VERSION_STATE,
port: process.env.PORT
});
});
app.use('/sensors', require('./routes/sensors'));
app.use('/connect', require('./routes/connect'));
app.use('/sessions', require('./routes/sessions'));
// --- Flush buffer e CSV export ---
app.post('/sessions/:sensorId/flush', async (req, res) => {
try {
const { sensorId } = req.params;
const flushed = await influxWriter.flushBuffer(sensorId);
res.status(200).json({ flushed: flushed.length });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/sessions/:sensorId/csv', async (req, res) => {
try {
const { sensorId } = req.params;
const { from } = req.query;
if (!from) return res.status(400).json({ error: 'from timestamp required' });
// Flusha prima il buffer residuo
await influxWriter.flushBuffer(sensorId);
const rows = await influxReader.querySessionData(sensorId, parseInt(from));
const csv = influxReader.formatCSV(rows);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="session_${sensorId}.csv"`);
res.send(csv);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- HTTP server + WebSocket per watchers live ---
const server = app.listen(process.env.PORT, () => {
console.log(`Realtime on port ${process.env.PORT}`);
});
const wss = new WebSocket.Server({ server, path: '/live' });
wss.on('connection', (client) => {
let watchedSensor = null;
let subscriber = null;
client.on('message', async (raw) => {
try {
const msg = JSON.parse(raw);
if (msg.action === 'watch' && msg.sensorId) {
// Rimuovi watch precedente se esiste
if (watchedSensor) {
await redisHelper.removeWatcher(watchedSensor);
if (subscriber) {
subscriber.unsubscribe();
subscriber.quit();
subscriber = null;
}
}
watchedSensor = msg.sensorId;
await redisHelper.addWatcher(watchedSensor);
// Subscriber Redis dedicato per questo client
subscriber = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
});
subscriber.subscribe(`sensor:data:${watchedSensor}`);
subscriber.on('message', (channel, message) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message); // Dati gia' JSON
}
});
client.send(JSON.stringify({ type: 'watching', sensorId: watchedSensor }));
}
if (msg.action === 'unwatch') {
if (watchedSensor) {
await redisHelper.removeWatcher(watchedSensor);
watchedSensor = null;
}
if (subscriber) {
subscriber.unsubscribe();
subscriber.quit();
subscriber = null;
}
client.send(JSON.stringify({ type: 'unwatched' }));
}
} catch (err) {
console.error('[WS-LIVE] Message error:', err);
}
});
client.on('close', async () => {
if (watchedSensor) {
await redisHelper.removeWatcher(watchedSensor);
}
if (subscriber) {
subscriber.unsubscribe();
subscriber.quit();
}
});
client.on('error', (err) => {
console.error('[WS-LIVE] Client error:', err);
});
});

View File

@@ -0,0 +1,74 @@
const express = require('express');
const db = require('../helper/authdb');
const tokenStore = require('../helper/tokenStore');
const redis = require('../helper/redis');
const router = express.Router();
/**
* POST /connect
* Il sensore invia il suo codice segreto (token) e metadati opzionali.
* Se autentica, riceve un token effimero per la connessione WebSocket.
*/
router.post('/', async (req, res) => {
try {
const { token, metadata } = req.body;
if (!token) {
return res.status(400).send({ error: 'Token is required' });
}
const sensor = await db.getSensor(token);
if (!sensor) {
return res.status(401).send({ error: 'token not valid' });
}
if (!sensor.is_active) {
return res.status(403).send({ error: 'token not valid' });
}
// Genera il token effimero valido per max 5 secondi
const socketToken = await tokenStore.setToken(sensor.id, metadata, 5);
return res.status(200).send({
socketToken,
sensorId: sensor.id,
expiresIn: 5
});
} catch (error) {
return res.status(500).send({ error: `${error}` });
}
});
/**
* DELETE /connect/:sensorId
* Disconnette forzatamente un sensore rimuovendo la sua sessione da Redis.
*/
router.delete('/:sensorId', async (req, res) => {
const { sensorId } = req.params;
try {
await redis.deleteSession(sensorId);
return res.status(200).send({ result: 'disconnected' });
} catch (error) {
return res.status(500).send({ error: `${error}` });
}
});
/**
* POST /connect/new
* Crea un nuovo sensore nel database.
*/
router.post('/new', async (req, res) => {
const { name, code } = req.body;
if (!name || !code) {
return res.status(400).send({ error: 'Name and code are required' });
}
try {
await db.createSensor(name, code);
return res.status(200).send({ result: 'created' });
} catch (error) {
return res.status(500).send({ error: `${error}` });
}
});
module.exports = router;

View File

@@ -0,0 +1,36 @@
const express = require('express');
const db = require('../helper/authdb');
router = express.Router();
router.get('/', async (req, res) => {
const sensors = await db.getSensors();
res.status(200).json(sensors);
});
router.post('/:id/:activity', async (req, res) => {
const { id, activity } = req.params;
let isActive;
if (activity === 'active') {
isActive = true;
} else if (activity === 'inactive') {
isActive = false;
} else {
return res.status(400).json({ error: 'Invalid activity' });
}
try {
const exists = await db.sensorsExists(id);
if (!exists) {
return res.status(404).json({ error: `Sensor with id ${id} not found` });
}
await db.setSensorActivity(id, isActive);
res.status(200).json({ status: `Sensor ${activity}` });
} catch (error) {
console.error('Error updating sensor ID:', id, error);
res.status(500).json({ error: 'Database error' });
}
})
module.exports = router

View File

@@ -0,0 +1,36 @@
const express = require('express');
const redis = require('../helper/redis');
const router = express.Router();
/**
* GET /sessions
* Ritorna tutti i sensori attualmente connessi con i loro metadati.
* Se viene passato un parametro ?sensor=ID, restituisce solo quello.
*/
router.get('/', async (req, res) => {
const { sensor } = req.query;
// Se viene passato un parametro ?sensor=ID, restituiamo solo quello
if (sensor) {
try {
const session = await redis.getSession(sensor);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
return res.status(200).json(JSON.parse(session));
} catch (error) {
return res.status(500).json({ error: `${error}` });
}
}
// Altrimenti restituiamo tutta la lista
try {
const sessions = await redis.getSessions();
res.status(200).json(sessions);
} catch (error) {
res.status(500).json({ error: `${error}` });
}
});
module.exports = router;

110
realtime/src/socket.js Normal file
View File

@@ -0,0 +1,110 @@
const WebSocket = require('ws');
const { encode, decode } = require('@msgpack/msgpack');
const url = require('url');
const tokenStore = require('./helper/tokenStore');
const redisHelper = require('./helper/redis');
const influxWriter = require('./helper/influxWriter');
const ws = new WebSocket.Server({
port: process.env.SOCKET_PORT,
perMessageDeflate: false,
verifyClient: async (info, callback) => {
const { query } = url.parse(info.req.url, true);
const token = query.token;
if (!token) {
return callback(false, 401, 'token not passed');
}
try {
const sessionData = await tokenStore.consumeToken(token);
if (!sessionData) {
return callback(false, 401, 'token not valid or expired');
}
info.req.sensorSession = sessionData;
callback(true);
} catch (error) {
callback(false, 500, `internal server error: ${error}`);
}
}
});
ws.on('connection', async (client, req) => {
const session = req.sensorSession;
const sensorId = session.sensorId;
client.sensorId = sensorId;
// Registra la sessione su Redis
try {
await redisHelper.setSession(sensorId, {
...session.metadata,
connectedAt: Math.floor(Date.now() / 1000)
});
} catch (err) {
console.error(`[WS] Redis setSession error for ${sensorId}:`, err);
}
/**
* Gestione messaggi in arrivo dal sensore.
*
* Il sensore invia dati codificati in MessagePack (binario).
* Il formato atteso è un array compatto (per risparmiare spazio):
*
* [timestamp, measurement, { field1: value1, field2: value2, ... }]
*
* Esempio pratico (dati meteo da barca):
* [1710681000, "weather", { t: 22.5, h: 65, p: 1013.2, w: 12.3 }]
*
* Dove le chiavi abbreviate sono:
* t = temperature, h = humidity, p = pressure, w = windSpeed ...
*
* Il server decodifica il messaggio e prepara il punto per InfluxDB.
*/
client.on('message', async (raw) => {
try {
const msg = decode(raw);
// Rispondi con ACK minimale in msgpack: { a: 1 } = acknowledged
client.send(encode({ a: 1 }));
const [timestamp, measurement, fields] = msg;
// Pubblica su Redis per i watchers live
redisHelper.publishSensorData(sensorId, { timestamp, measurement, fields });
// Controlla se ci sono watchers attivi
const watchers = await redisHelper.getWatcherCount(sensorId);
if (measurement === 'forecast_batch') {
influxWriter.writeForecastBatch(sensorId, fields);
} else if (watchers > 0) {
// Con watchers: accumula nel buffer, flusha ogni 10 punti
influxWriter.bufferPoint(sensorId, timestamp, measurement, fields);
} else {
// Senza watchers: scrivi immediatamente (comportamento originale)
influxWriter.writePoint(sensorId, timestamp, measurement, fields);
}
} catch (err) {
console.error(`[WS|${sensorId}] decode error:`, err);
// Rispondi con errore in msgpack: { e: 1 } = error
client.send(encode({ e: 1 }));
}
});
client.on('error', (err) => {
console.error(`[WS|${sensorId}] error:`, err);
});
client.on('close', async () => {
try {
await redisHelper.deleteSession(sensorId);
} catch (err) {
console.error(`[WS] Redis deleteSession error for ${sensorId}:`, err);
}
});
});
console.log(`[WS] Realtime websocket server on port: ${process.env.SOCKET_PORT}`);