feat: initialize microservice architecture with auth, api, realtime, copernicus, ml, and console modules
This commit is contained in:
0
.env.example
Normal file
0
.env.example
Normal file
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
node_modules
|
||||||
2
api/.dockerignore
Normal file
2
api/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
14
api/.env.example
Normal file
14
api/.env.example
Normal 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
13
api/Dockerfile
Normal 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
1541
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
api/package.json
Normal file
22
api/package.json
Normal 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
73
api/src/index.js
Normal 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
27
api/src/routes/data.js
Normal 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
216
api/src/routes/params.js
Normal 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;
|
||||||
51
api/src/routes/params.sensor.js
Normal file
51
api/src/routes/params.sensor.js
Normal 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
59
api/src/routes/storage.js
Normal 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
67
api/src/storage/influx.js
Normal 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
136
api/src/storage/minio.js
Normal 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
|
||||||
|
}
|
||||||
79
api/src/storage/postgres.js
Normal file
79
api/src/storage/postgres.js
Normal 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
13
auth/Dockerfile
Normal 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
1402
auth/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
auth/package.json
Normal file
22
auth/package.json
Normal 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
120
auth/src/core/auth.core.js
Normal 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
|
||||||
|
}
|
||||||
56
auth/src/core/session.core.js
Normal file
56
auth/src/core/session.core.js
Normal 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
49
auth/src/index.js
Normal 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
97
auth/src/routes/auth.js
Normal 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;
|
||||||
0
auth/src/routes/sessions.js
Normal file
0
auth/src/routes/sessions.js
Normal file
2
auth/src/routes/users.js
Normal file
2
auth/src/routes/users.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
|
||||||
BIN
auth/src/static/icon.png
Normal file
BIN
auth/src/static/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
99
auth/src/static/style/login.css
Normal file
99
auth/src/static/style/login.css
Normal 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);
|
||||||
|
}
|
||||||
201
auth/src/static/style/style.css
Normal file
201
auth/src/static/style/style.css
Normal 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;
|
||||||
|
}
|
||||||
105
auth/src/storage/database.js
Normal file
105
auth/src/storage/database.js
Normal 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
36
auth/src/storage/redis.js
Normal 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 };
|
||||||
39
auth/src/templates/loginpage.html
Normal file
39
auth/src/templates/loginpage.html
Normal 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>
|
||||||
1
auth/src/templates/sessions.html
Normal file
1
auth/src/templates/sessions.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
auth/src/templates/user.html
Normal file
1
auth/src/templates/user.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
70
auth/src/tools/jwt.js
Normal file
70
auth/src/tools/jwt.js
Normal 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 };
|
||||||
54
auth/src/tools/security.js
Normal file
54
auth/src/tools/security.js
Normal 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
|
||||||
|
};
|
||||||
24
auth/src/tools/tracking.js
Normal file
24
auth/src/tools/tracking.js
Normal 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
2
console/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
13
console/Dockerfile
Normal file
13
console/Dockerfile
Normal 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
1127
console/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
console/package.json
Normal file
19
console/package.json
Normal 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
92
console/src/index.js
Normal 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}`);
|
||||||
|
});
|
||||||
55
console/src/pages/dashboard.html
Normal file
55
console/src/pages/dashboard.html
Normal 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
707
console/src/pages/live.html
Normal 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">×</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">×</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>×</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>
|
||||||
26
console/src/static/styles/dashboard.css
Normal file
26
console/src/static/styles/dashboard.css
Normal 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;
|
||||||
|
}
|
||||||
669
console/src/static/styles/live.css
Normal file
669
console/src/static/styles/live.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
201
console/src/static/styles/style.css
Normal file
201
console/src/static/styles/style.css
Normal 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
0
copernicus/Dockerfile
Normal file
125
copernicus/core/cache.py
Normal file
125
copernicus/core/cache.py
Normal 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
|
||||||
310
copernicus/core/copernicus.py
Normal file
310
copernicus/core/copernicus.py
Normal 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
112
copernicus/core/storage.py
Normal 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
53
copernicus/main.py
Normal 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"}
|
||||||
13
copernicus/requirements.txt
Normal file
13
copernicus/requirements.txt
Normal 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
|
||||||
46
copernicus/routers/catalog.py
Normal file
46
copernicus/routers/catalog.py
Normal 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
|
||||||
57
copernicus/routers/datasets.py
Normal file
57
copernicus/routers/datasets.py
Normal 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
145
copernicus/routers/jobs.py
Normal 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
77
copernicus/schemas.py
Normal 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
581
copernicus/static/script.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, type = 'success') {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.className = `${type} show`;
|
||||||
|
t.textContent = msg;
|
||||||
|
setTimeout(() => t.classList.remove('show'), 3500);
|
||||||
|
}
|
||||||
0
copernicus/static/style.css
Normal file
0
copernicus/static/style.css
Normal file
163
copernicus/templates/coprncs.html
Normal file
163
copernicus/templates/coprncs.html
Normal 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
164
docker-compose.yml
Normal 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
1
ml/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__pycache__
|
||||||
0
ml/.env.example
Normal file
0
ml/.env.example
Normal file
13
ml/Dockerfile
Normal file
13
ml/Dockerfile
Normal 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
19
ml/main.py
Normal 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
2
ml/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
BIN
ml/static/font/quicksand.ttf
Normal file
BIN
ml/static/font/quicksand.ttf
Normal file
Binary file not shown.
201
ml/static/styles/style.css
Normal file
201
ml/static/styles/style.css
Normal 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
28
ml/templates/console.html
Normal 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>
|
||||||
0
ml/templates/datasets.html
Normal file
0
ml/templates/datasets.html
Normal file
89
ml/templates/results.html
Normal file
89
ml/templates/results.html
Normal 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
0
ml/templates/test.html
Normal file
0
ml/templates/train.html
Normal file
0
ml/templates/train.html
Normal file
179
package-lock.json
generated
Normal file
179
package-lock.json
generated
Normal 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
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"jsonwebtoken": "^9.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
realtime/.dockerignore
Normal file
1
realtime/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
9
realtime/.env.example
Normal file
9
realtime/.env.example
Normal 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
12
realtime/Dockerfile
Normal 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
1110
realtime/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
realtime/package.json
Normal file
19
realtime/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
realtime/src/helper/authdb.js
Normal file
84
realtime/src/helper/authdb.js
Normal 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
|
||||||
|
}
|
||||||
35
realtime/src/helper/cryptoUtils.js
Normal file
35
realtime/src/helper/cryptoUtils.js
Normal 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
|
||||||
|
};
|
||||||
75
realtime/src/helper/influxReader.js
Normal file
75
realtime/src/helper/influxReader.js
Normal 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 };
|
||||||
156
realtime/src/helper/influxWriter.js
Normal file
156
realtime/src/helper/influxWriter.js
Normal 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 };
|
||||||
79
realtime/src/helper/redis.js
Normal file
79
realtime/src/helper/redis.js
Normal 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
|
||||||
|
};
|
||||||
52
realtime/src/helper/tokenStore.js
Normal file
52
realtime/src/helper/tokenStore.js
Normal 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
149
realtime/src/index.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
realtime/src/routes/connect.js
Normal file
74
realtime/src/routes/connect.js
Normal 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;
|
||||||
36
realtime/src/routes/sensors.js
Normal file
36
realtime/src/routes/sensors.js
Normal 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
|
||||||
36
realtime/src/routes/sessions.js
Normal file
36
realtime/src/routes/sessions.js
Normal 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
110
realtime/src/socket.js
Normal 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}`);
|
||||||
Reference in New Issue
Block a user