Compare commits
52 Commits
14c29b1434
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d25010d3c | |||
|
|
924c2b5367 | ||
|
|
69012029ad | ||
|
|
5433529ffd | ||
|
|
e43c330594 | ||
|
|
974cbe93cd | ||
|
|
c8668920a6 | ||
|
|
9e6bb26a2c | ||
|
|
ba0dbe6baf | ||
|
|
b6c2a7e904 | ||
|
|
ef62bb5da0 | ||
|
|
981f498eb7 | ||
|
|
5912c00a82 | ||
|
|
edd7226966 | ||
|
|
c0be21a718 | ||
|
|
370f911063 | ||
|
|
b4182c5c94 | ||
|
|
3094c06467 | ||
|
|
c9402de2e4 | ||
|
|
bf66845528 | ||
|
|
137c6131c3 | ||
|
|
a34048ae6b | ||
|
|
81e6e1960d | ||
|
|
59f7135b61 | ||
|
|
d17c78f42a | ||
|
|
ea4af13840 | ||
|
|
a19c6988f4 | ||
|
|
2bbc5e0320 | ||
|
|
b6b1ed7a2b | ||
|
|
a79ab2af38 | ||
|
|
d79c12b6e9 | ||
|
|
c478f5c13c | ||
|
|
c597d4a414 | ||
|
|
82310a521f | ||
|
|
73675ddfff | ||
|
|
40dd392696 | ||
|
|
32de4b1441 | ||
|
|
8fe514ed14 | ||
|
|
8b5937fa19 | ||
|
|
ccd6143253 | ||
|
|
acb6b39dcf | ||
|
|
1044837080 | ||
|
|
0ae64d0c5b | ||
|
|
3032dbcc96 | ||
|
|
e13bbe3d02 | ||
|
|
063fccfaea | ||
|
|
e003770187 | ||
|
|
1ef9160361 | ||
|
|
dcf1c47328 | ||
|
|
7d61d6361c | ||
|
|
1f161270ef | ||
|
|
c3bc6dabc0 |
@@ -0,0 +1,4 @@
|
|||||||
|
DOMAIN=
|
||||||
|
|
||||||
|
#production= mebboat.it
|
||||||
|
#development= localhost
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
**/tsconfig.tsbuildinfo
|
**/tsconfig.tsbuildinfo
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
.venv/
|
||||||
10
api/package-lock.json
generated
10
api/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.35.0",
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||||
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
@@ -40,6 +41,15 @@
|
|||||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpack/msgpack": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.35.0",
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||||
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const parser = require('cookie-parser');
|
const parser = require('cookie-parser');
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
|
const { requireAuth } = require('./middlewares/auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT;
|
const PORT = process.env.PORT;
|
||||||
@@ -12,6 +13,21 @@ const vState = process.env.VERSION_STATE;
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(parser());
|
app.use(parser());
|
||||||
|
|
||||||
|
// CORS per permettere chiamate cross-origin dalla console
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
// Accetta origini nella whitelist, oppure tutte in dev
|
||||||
|
if (allowed.length === 0 || allowed.includes(origin)) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key');
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.redirect('/health');
|
res.redirect('/health');
|
||||||
});
|
});
|
||||||
@@ -23,6 +39,8 @@ app.get('/health', async (req, res) => {
|
|||||||
|
|
||||||
const allOk = Object.values(postgres).every(s => s === 'connected') && influx && minio;
|
const allOk = Object.values(postgres).every(s => s === 'connected') && influx && minio;
|
||||||
|
|
||||||
|
console.log("Health check results:", { postgres, influx: influx ? 'connected' : 'disconnected', minio: minio ? 'connected' : 'disconnected' });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: allOk ? "ok" : "degraded",
|
status: allOk ? "ok" : "degraded",
|
||||||
service: "api",
|
service: "api",
|
||||||
@@ -39,33 +57,8 @@ app.get('/health', async (req, res) => {
|
|||||||
const paramsSensorRoutes = require('./routes/params.sensor');
|
const paramsSensorRoutes = require('./routes/params.sensor');
|
||||||
app.use('/params/sensor', paramsSensorRoutes);
|
app.use('/params/sensor', paramsSensorRoutes);
|
||||||
|
|
||||||
// Middleware di autenticazione per le API
|
// Middleware di autenticazione per tutte le API protette
|
||||||
app.use((req, res, next) => {
|
app.use(requireAuth);
|
||||||
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');
|
const dataRoutes = require('./routes/data');
|
||||||
app.use('/data', dataRoutes);
|
app.use('/data', dataRoutes);
|
||||||
@@ -79,7 +72,9 @@ app.use('/params', paramsRoutes)
|
|||||||
const settingsRoutes = require('./routes/settings')
|
const settingsRoutes = require('./routes/settings')
|
||||||
app.use('/settings', settingsRoutes)
|
app.use('/settings', settingsRoutes)
|
||||||
|
|
||||||
// Avvio del server
|
const sessionsRoutes = require('./routes/sessions')
|
||||||
|
app.use('/sessions', sessionsRoutes)
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Started on port ${PORT}`);
|
console.log(`Started on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
70
api/src/middlewares/auth.js
Normal file
70
api/src/middlewares/auth.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Middleware di autenticazione per API REST.
|
||||||
|
* Supporta tre modalità:
|
||||||
|
* - x-api-key (service-to-service, INTERNAL_API_KEY)
|
||||||
|
* - cookie auth_token (utenti loggati dal browser, SSO via .mebboat.it)
|
||||||
|
* - Authorization: Bearer <jwt>
|
||||||
|
*
|
||||||
|
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET e verificato localmente.
|
||||||
|
* Il cookie è condiviso tra i sottodomini grazie a domain=.mebboat.it
|
||||||
|
*/
|
||||||
|
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET;
|
||||||
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
|
||||||
|
function extractToken(req) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
return (req.cookies && req.cookies.auth_token) || bearer || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
if (!token || typeof token !== 'string' || token.length > 2048) return null;
|
||||||
|
try {
|
||||||
|
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
|
||||||
|
return {
|
||||||
|
user_id: p.sub,
|
||||||
|
username: p.username,
|
||||||
|
session_id: p.session_id,
|
||||||
|
iat: p.iat,
|
||||||
|
exp: p.exp
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accetta utente loggato (cookie/bearer) o chiamata interna (x-api-key).
|
||||||
|
* Imposta req.user con i dati dell'utente, oppure req.internal = true.
|
||||||
|
*/
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
// 1. Service-to-service
|
||||||
|
const apiKey = req.headers['x-api-key'];
|
||||||
|
if (apiKey && INTERNAL_KEY && apiKey === INTERNAL_KEY) {
|
||||||
|
req.internal = true;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. User auth (cookie o Bearer)
|
||||||
|
const user = verifyToken(extractToken(req));
|
||||||
|
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solo service-to-service (x-api-key).
|
||||||
|
*/
|
||||||
|
function requireInternal(req, res, next) {
|
||||||
|
const apiKey = req.headers['x-api-key'];
|
||||||
|
if (!INTERNAL_KEY || !apiKey || apiKey !== INTERNAL_KEY) {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
req.internal = true;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuth, requireInternal, verifyToken, extractToken };
|
||||||
@@ -2,27 +2,22 @@ const router = require('express').Router();
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { query } = require('../storage/postgres');
|
const { query } = require('../storage/postgres');
|
||||||
|
|
||||||
const sets = ['forecasts', 'sensors'];
|
const sets = ['forecasts', 'sensors', 'marine'];
|
||||||
|
|
||||||
function hashSensorCode(code) {
|
function hashSensorCode(code) {
|
||||||
return crypto.createHash('sha256').update(code).digest('hex');
|
return crypto.createHash('sha256').update(code).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /params/sensor/:sensorCode/active?set=sensors
|
* Middleware: valida sensor code e verifica che il sensore sia attivo.
|
||||||
* Autenticazione tramite SENSOR_CODE (stesso meccanismo di realtime)
|
* Salva sensor.id in req.sensorId.
|
||||||
*/
|
*/
|
||||||
router.get('/:sensorCode/active', async (req, res) => {
|
async function authenticateSensor(req, res, next) {
|
||||||
const { sensorCode } = req.params;
|
const { sensorCode } = req.params;
|
||||||
const { set } = req.query;
|
|
||||||
|
|
||||||
if (!set || !sets.includes(set))
|
|
||||||
return res.status(400).json({ error: 'SET parameter invalid' });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hashed = hashSensorCode(sensorCode);
|
const hashed = hashSensorCode(sensorCode);
|
||||||
const sensor = await query(
|
const sensor = await query(
|
||||||
'SELECT id, is_active FROM sensors WHERE code_hash = $1',
|
'SELECT id, active FROM sensors WHERE code_hash = $1',
|
||||||
[hashed],
|
[hashed],
|
||||||
'sensors'
|
'sensors'
|
||||||
);
|
);
|
||||||
@@ -30,11 +25,29 @@ router.get('/:sensorCode/active', async (req, res) => {
|
|||||||
if (!sensor.rows[0]) {
|
if (!sensor.rows[0]) {
|
||||||
return res.status(401).json({ error: 'Sensor code not valid' });
|
return res.status(401).json({ error: 'Sensor code not valid' });
|
||||||
}
|
}
|
||||||
|
if (!sensor.rows[0].active) {
|
||||||
if (!sensor.rows[0].is_active) {
|
|
||||||
return res.status(403).json({ error: 'Sensor is not active' });
|
return res.status(403).json({ error: 'Sensor is not active' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.sensorId = sensor.rows[0].id;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PARAMS/SENSOR] Auth error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /params/sensor/:sensorCode/active?set=sensors
|
||||||
|
* Ritorna il set di parametri attivo (forecasts, sensors, marine)
|
||||||
|
*/
|
||||||
|
router.get('/:sensorCode/active', authenticateSensor, async (req, res) => {
|
||||||
|
const { set } = req.query;
|
||||||
|
|
||||||
|
if (!set || !sets.includes(set))
|
||||||
|
return res.status(400).json({ error: 'SET parameter invalid' });
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT * FROM ${set} WHERE active = true LIMIT 1`,
|
`SELECT * FROM ${set} WHERE active = true LIMIT 1`,
|
||||||
[],
|
[],
|
||||||
|
|||||||
107
api/src/routes/sessions.js
Normal file
107
api/src/routes/sessions.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const { query: dbQuery } = require('../storage/postgres');
|
||||||
|
const { listInfluxSessions, querySessionHistory, exportSessionCSV } = require('../storage/influx');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/history
|
||||||
|
* Fonte primaria: InfluxDB (tag sensor + session sul measurement "logs").
|
||||||
|
* Arricchisce con i metadati opzionali da PostgreSQL (sessiondataref): nome, tags, descrizione.
|
||||||
|
*/
|
||||||
|
router.get('/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessions = await listInfluxSessions();
|
||||||
|
|
||||||
|
let pgMap = {};
|
||||||
|
try {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT * FROM sessiondataref`,
|
||||||
|
[],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
result.rows.forEach(r => { pgMap[r.session_id] = r; });
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const enriched = sessions.map(s => ({
|
||||||
|
session_id: s.session,
|
||||||
|
sensor_name: s.sensor,
|
||||||
|
startTime: s.startTime,
|
||||||
|
endTime: s.endTime,
|
||||||
|
// campi opzionali da PostgreSQL
|
||||||
|
name: pgMap[s.session]?.name || null,
|
||||||
|
description: pgMap[s.session]?.description || null,
|
||||||
|
tags: pgMap[s.session]?.tags || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(enriched);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] history error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/:sensorId/data?session=sXXXX&from=ISO&to=ISO
|
||||||
|
* Restituisce i dati storici di una sessione come JSON (righe pivotate da InfluxDB).
|
||||||
|
*/
|
||||||
|
router.get('/:sensorId/data', async (req, res) => {
|
||||||
|
const { sensorId } = req.params;
|
||||||
|
const { session, from, to } = req.query;
|
||||||
|
|
||||||
|
if (!session) return res.status(400).json({ error: 'session param required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let since = from || null;
|
||||||
|
if (!since) {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
|
||||||
|
[session],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
since = result.rows[0]?.created_at?.toISOString() || '-30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = to ? new Date(parseInt(to)).toISOString() : null;
|
||||||
|
const rows = await querySessionHistory(sensorId, session, since, until);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] data error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/:sensorId/csv?session=sXXXX&from=ms&to=ms
|
||||||
|
* Esporta i dati di una sessione come CSV (supporta intervallo opzionale).
|
||||||
|
*/
|
||||||
|
router.get('/:sensorId/csv', async (req, res) => {
|
||||||
|
const { sensorId } = req.params;
|
||||||
|
const { session, from, to } = req.query;
|
||||||
|
|
||||||
|
if (!session) return res.status(400).json({ error: 'session param required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let since = from ? new Date(parseInt(from)).toISOString() : null;
|
||||||
|
if (!since) {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
|
||||||
|
[session],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
since = result.rows[0]?.created_at?.toISOString() || '-30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = to ? new Date(parseInt(to)).toISOString() : null;
|
||||||
|
const csv = await exportSessionCSV(sensorId, session, since, until);
|
||||||
|
|
||||||
|
if (!csv) return res.status(404).json({ error: 'No data found for this session' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="session_${session}_${sensorId}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] csv error:', err.message);
|
||||||
|
res.status(500).json({ error: 'CSV export failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -10,6 +10,7 @@ const client = new InfluxDB({ url, token })
|
|||||||
const write = client.getWriteApi(org, boatTelemetry);
|
const write = client.getWriteApi(org, boatTelemetry);
|
||||||
const querying = client.getQueryApi(org);
|
const querying = client.getQueryApi(org);
|
||||||
|
|
||||||
|
console.log("InfluxDB client initialized with config:", { url, org, token });
|
||||||
|
|
||||||
async function append(measurement, sensor, data) {
|
async function append(measurement, sensor, data) {
|
||||||
const point = new Point(measurement)
|
const point = new Point(measurement)
|
||||||
@@ -59,12 +60,122 @@ async function query(bucket, relativeTime, measurement, sensor, field) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionBucket = 'boat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query storica per una sessione di registrazione.
|
||||||
|
* @param {string} sensor - nome sensore
|
||||||
|
* @param {string} session - session_id (tag InfluxDB)
|
||||||
|
* @param {string} since - ISO timestamp o duration (es. "-30d")
|
||||||
|
* @param {string|null} until - ISO timestamp fine (opzionale)
|
||||||
|
* @returns {Promise<Array<Object>>}
|
||||||
|
*/
|
||||||
|
async function querySessionHistory(sensor, session, since, until = null) {
|
||||||
|
const rangeStr = until ? `start: ${since}, stop: ${until}` : `start: ${since}`;
|
||||||
|
const fluxQuery = `
|
||||||
|
from(bucket: "${sessionBucket}")
|
||||||
|
|> range(${rangeStr})
|
||||||
|
|> filter(fn: (r) => r._measurement == "logs")
|
||||||
|
|> filter(fn: (r) => r.sensor == "${sensor}")
|
||||||
|
|> filter(fn: (r) => r.session == "${session}")
|
||||||
|
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||||
|
|> sort(columns: ["_time"])
|
||||||
|
`;
|
||||||
|
const rows = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
querying.queryRows(fluxQuery, {
|
||||||
|
next(row, tableMeta) { rows.push(tableMeta.toObject(row)); },
|
||||||
|
error: reject,
|
||||||
|
complete() { resolve(rows); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esporta i dati di una sessione come stringa CSV.
|
||||||
|
* @param {string} sensor
|
||||||
|
* @param {string} session
|
||||||
|
* @param {string} since
|
||||||
|
* @param {string|null} until
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function exportSessionCSV(sensor, session, since, until = null) {
|
||||||
|
const rows = await querySessionHistory(sensor, session, since, until);
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
|
||||||
|
const fieldNames = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (!metaKeys.has(key) && key !== '_time') fieldNames.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fields = Array.from(fieldNames).sort();
|
||||||
|
const header = ['timestamp', ...fields].join(',');
|
||||||
|
const csvRows = rows.map(row => {
|
||||||
|
const values = fields.map(f => { const v = row[f]; return (v == null) ? '' : v; });
|
||||||
|
return [row._time || '', ...values].join(',');
|
||||||
|
});
|
||||||
|
return header + '\n' + csvRows.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility interna: esegue una Flux query e restituisce le righe come array di oggetti.
|
||||||
|
*/
|
||||||
|
function runFlux(fluxQuery) {
|
||||||
|
const rows = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
querying.queryRows(fluxQuery, {
|
||||||
|
next(row, tableMeta) { rows.push(tableMeta.toObject(row)); },
|
||||||
|
error: reject,
|
||||||
|
complete() { resolve(rows); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenca tutte le sessioni presenti in InfluxDB, con primo e ultimo timestamp.
|
||||||
|
* Sorgente di verità: tag sensor + session sul measurement "logs".
|
||||||
|
* @param {string} [lookback='-5y'] - range di ricerca (es. '-365d', '-5y')
|
||||||
|
* @returns {Promise<Array<{session, sensor, startTime, endTime}>>}
|
||||||
|
*/
|
||||||
|
async function listInfluxSessions(lookback = '-5y') {
|
||||||
|
const base = `
|
||||||
|
from(bucket: "${sessionBucket}")
|
||||||
|
|> range(start: ${lookback})
|
||||||
|
|> filter(fn: (r) => r._measurement == "logs")
|
||||||
|
|> group(columns: ["sensor", "session"])
|
||||||
|
`;
|
||||||
|
const [firstRows, lastRows] = await Promise.all([
|
||||||
|
runFlux(base + '|> first() |> keep(columns: ["_time", "sensor", "session"])'),
|
||||||
|
runFlux(base + '|> last() |> keep(columns: ["_time", "sensor", "session"])'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const map = {};
|
||||||
|
firstRows.forEach(r => {
|
||||||
|
if (!r.session) return;
|
||||||
|
map[r.session] = { session: r.session, sensor: r.sensor, startTime: r._time };
|
||||||
|
});
|
||||||
|
lastRows.forEach(r => {
|
||||||
|
if (map[r.session]) map[r.session].endTime = r._time;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(map).sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
|
||||||
|
}
|
||||||
|
|
||||||
async function checkInflux() {
|
async function checkInflux() {
|
||||||
try {
|
try {
|
||||||
await querying.rows(`from(bucket: "boat") |> range(start: -1s) |> limit(n:1)`).next();
|
const result = await querying.collectRows(
|
||||||
return true;
|
`from(bucket: "boat") |> range(start: -1s) |> limit(n:1)`
|
||||||
|
);
|
||||||
|
console.log('InfluxDB: OK');
|
||||||
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
console.error('InfluxDB check failed:', {
|
||||||
|
message: error.message,
|
||||||
|
statusCode: error.statusCode ?? 'N/A',
|
||||||
|
body: error.body ?? 'N/A'
|
||||||
|
});
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +183,9 @@ module.exports = {
|
|||||||
write: append,
|
write: append,
|
||||||
writeBatch,
|
writeBatch,
|
||||||
query,
|
query,
|
||||||
|
listInfluxSessions,
|
||||||
|
querySessionHistory,
|
||||||
|
exportSessionCSV,
|
||||||
checkInflux
|
checkInflux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
user: process.env.POSTGRES_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.POSTGRES_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
host: process.env.POSTGRES_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: process.env.POSTGRES_PORT,
|
port: process.env.DB_PORT,
|
||||||
max: 10,
|
max: 10,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000
|
connectionTimeoutMillis: 5000
|
||||||
}
|
}
|
||||||
|
|
||||||
const pools = {
|
const pools = {
|
||||||
users: new Pool({ ...config, database: process.env.DATA_DB }),
|
data: new Pool({ ...config, database: process.env.DATA_DB }),
|
||||||
references: new Pool({ ...config, database: process.env.REFERENCES_DB }),
|
users: new Pool({ ...config, database: process.env.USERS_DB }),
|
||||||
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' })
|
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }),
|
||||||
|
rules: new Pool({ ...config, database: process.env.RULES_DB || 'rules' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(pools).forEach(([name, pool]) => {
|
Object.entries(pools).forEach(([name, pool]) => {
|
||||||
@@ -30,6 +31,7 @@ Object.entries(pools).forEach(([name, pool]) => {
|
|||||||
async function getClient(db) {
|
async function getClient(db) {
|
||||||
const pool = pools[db];
|
const pool = pools[db];
|
||||||
if (!pool) throw new Error(`Database pool type ${db} does not exist`);
|
if (!pool) throw new Error(`Database pool type ${db} does not exist`);
|
||||||
|
console.log(`Acquiring client for ${db} database... with config:`, config);
|
||||||
return await pool.connect();
|
return await pool.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +75,14 @@ async function remove(table, condition, params, type = 'users') {
|
|||||||
|
|
||||||
async function checkPostgres() {
|
async function checkPostgres() {
|
||||||
const status = {};
|
const status = {};
|
||||||
|
console.log("Checking PostgreSQL connections with config:", config);
|
||||||
for (const [name, pool] of Object.entries(pools)) {
|
for (const [name, pool] of Object.entries(pools)) {
|
||||||
try {
|
try {
|
||||||
await pool.query('SELECT NOW()');
|
await pool.query('SELECT NOW()');
|
||||||
|
console.log(`PostgreSQL connection check successful for ${name}`);
|
||||||
status[name] = 'connected';
|
status[name] = 'connected';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(`PostgreSQL connection check failed for ${name}:`, error.message);
|
||||||
status[name] = 'disconnected';
|
status[name] = 'disconnected';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ DB_HOST=
|
|||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_PORT=
|
DB_PORT=
|
||||||
|
|
||||||
DATA_DB=
|
USERS_DB=
|
||||||
|
|
||||||
|
REDIS_HOST=
|
||||||
|
REDIS_PORT=
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
# In locale: lasciare vuoto (il cookie su localhost funziona su tutte le porte)
|
# In locale: lasciare vuoto (il cookie su localhost funziona su tutte le porte)
|
||||||
# In produzione: .mebboat.it (condiviso tra auth.mebboat.it, console.mebboat.it, api.mebboat.it)
|
# In produzione: .mebboat.it (condiviso tra auth.mebboat.it, console.mebboat.it, api.mebboat.it)
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
COOKIE_NAME=
|
COOKIE_NAME=
|
||||||
|
|
||||||
DB_NAME=
|
|
||||||
|
|
||||||
PORT=3006
|
PORT=3006
|
||||||
|
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|||||||
@@ -1,120 +1,166 @@
|
|||||||
const query = require('../storage/database').query;
|
|
||||||
const track = require('../tools/tracking')
|
|
||||||
const { v4: uuid } = require('uuid');
|
const { v4: uuid } = require('uuid');
|
||||||
const security = require('../tools/security')
|
const { query } = require('../storage/database');
|
||||||
|
const security = require('../tools/security');
|
||||||
|
const tracking = require('../tools/tracking');
|
||||||
|
|
||||||
|
// ─── ERRORI CUSTOM ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AuthError extends Error {
|
||||||
|
constructor(code, message) {
|
||||||
|
super(message || code);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── REGISTRAZIONE ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Registra un nuovo utente
|
|
||||||
*/
|
|
||||||
async function register(username, password) {
|
async function register(username, password) {
|
||||||
const userExists = await query('SELECT id FROM users WHERE username = $1', [username]);
|
const exists = await query('SELECT id FROM users WHERE username = $1', [username]);
|
||||||
|
if (exists.rows.length) throw new AuthError('USER_EXISTS', 'Username già in uso');
|
||||||
|
|
||||||
if (userExists.rows.length > 0) {
|
const hash = await security.hashPassword(password);
|
||||||
throw new Error('User already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = security.hashPassword(password);
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
await query(
|
||||||
await query('INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', [id, username, hashedPassword]);
|
'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)',
|
||||||
|
[id, username, hash]
|
||||||
return {
|
);
|
||||||
success: true,
|
return { id, username };
|
||||||
user: {
|
|
||||||
id,
|
|
||||||
username
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── LOGIN ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Esegue il login di un utente
|
|
||||||
*/
|
|
||||||
async function login(username, password) {
|
async function login(username, password) {
|
||||||
const result = await query('SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', [username]);
|
const { rows } = await query(
|
||||||
if (result.rows.length === 0) {
|
'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1',
|
||||||
throw new Error('No user matched')
|
[username]
|
||||||
|
);
|
||||||
|
if (!rows.length) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user.is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
|
||||||
|
|
||||||
|
const ok = await security.verifyPassword(password, user.password_hash);
|
||||||
|
if (!ok) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
|
||||||
|
|
||||||
|
return { id: user.id, username: user.username, created_at: user.created_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = result.rows[0];
|
// ─── SESSIONI ───────────────────────────────────────────────────────
|
||||||
const isValid = await security.verifyPassword(password, user.password_hash);
|
|
||||||
|
|
||||||
if (!isValid) {
|
async function createSession(userId, userAgent, ip) {
|
||||||
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 id = uuid();
|
||||||
const sessionCode = security.generateSessionCode();
|
const code = security.sessionCode();
|
||||||
const metadata = track.getBasicMetadata(userAgent);
|
const meta = tracking.extract(userAgent);
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO sessions (id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type)
|
`INSERT INTO sessions
|
||||||
|
(id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
[id, userId, sessionCode, '', ip, userAgent, metadata.browser, metadata.os, metadata.device_type]
|
[id, userId, code, '', ip, userAgent, meta.browser, meta.os, meta.device_type]
|
||||||
|
);
|
||||||
|
return { id, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSession(sessionId) {
|
||||||
|
if (!sessionId || typeof sessionId !== 'string') {
|
||||||
|
throw new AuthError('INVALID_SESSION', 'Sessione non valida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT s.id, s.is_revoked, u.is_active
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
[sessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { id, sessionCode };
|
if (!rows.length) throw new AuthError('INVALID_SESSION', 'Sessione non trovata');
|
||||||
|
if (rows[0].is_revoked) throw new AuthError('SESSION_REVOKED', 'Sessione revocata');
|
||||||
|
if (!rows[0].is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
|
||||||
|
|
||||||
|
// Aggiorna last_active in modo non bloccante
|
||||||
|
query('UPDATE sessions SET last_active = NOW() WHERE id = $1', [sessionId]).catch(() => {});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function revokeSession(sessionId, userId) {
|
||||||
* Valida una sessione
|
if (userId) {
|
||||||
*/
|
const r = await query(
|
||||||
async function validateSession(token) {
|
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE',
|
||||||
const parsed = security.parseSessionToken(token);
|
[sessionId, userId]
|
||||||
|
);
|
||||||
if (!parsed) {
|
return r.rowCount > 0;
|
||||||
throw new Error('Invalid token format');
|
}
|
||||||
|
const r = await query(
|
||||||
|
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND is_revoked = FALSE',
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
return r.rowCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code, username } = parsed;
|
async function listSessions(userId) {
|
||||||
|
const { rows } = await query(
|
||||||
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]);
|
`SELECT id, ip_address, browser, os, device_type,
|
||||||
if (result.rows.length === 0) {
|
location_country, location_city, created_at, last_active, is_revoked
|
||||||
throw new Error('Session not found or revoked')
|
FROM sessions
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY last_active DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = result.rows[0];
|
// ─── LOOKUP UTENTE ──────────────────────────────────────────────────
|
||||||
|
|
||||||
if (session.username !== username) {
|
async function getUserById(userId) {
|
||||||
throw new Error('Session user mismatch');
|
const { rows } = await query(
|
||||||
|
'SELECT id, username, is_active, created_at, telegram_id FROM users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.is_active) {
|
async function getAllUsers() {
|
||||||
throw new Error('Session is not active');
|
const { rows } = await query(
|
||||||
}
|
'SELECT id, username, is_active, created_at, telegram_id FROM users'
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getUsersToNotify() {
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL'
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUsername(userId, newUsername) {
|
||||||
|
const r = await query(
|
||||||
|
'UPDATE users SET username = $1 WHERE id = $2 RETURNING username',
|
||||||
|
[newUsername, userId]
|
||||||
|
);
|
||||||
|
return r.rowCount > 0 ? r.rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTelegram(userId, telegramId) {
|
||||||
|
await query(
|
||||||
|
'UPDATE users SET telegram_id = $1 WHERE id = $2',
|
||||||
|
[telegramId, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
AuthError,
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
logout,
|
createSession,
|
||||||
newSession,
|
validateSession,
|
||||||
validateSession
|
revokeSession,
|
||||||
}
|
listSessions,
|
||||||
|
getUserById,
|
||||||
|
getAllUsers,
|
||||||
|
getUsersToNotify,
|
||||||
|
updateUsername,
|
||||||
|
updateTelegram
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
const query = require('../storage/database').query;
|
|
||||||
const { parseSessionToken } = require('../tools/security');
|
|
||||||
|
|
||||||
async function getSessions(username) {
|
|
||||||
const result = await query('SELECT s.id, s.session_code, s.browser, s.os, s.device_type, s.created_at, s.last_active, s.is_revoked FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE ORDER BY s.last_active DESC', [username]);
|
|
||||||
|
|
||||||
return result.rows.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
code: s.session_code,
|
|
||||||
browser: s.browser,
|
|
||||||
os: s.os,
|
|
||||||
deviceType: s.device_type,
|
|
||||||
createdAt: s.created_at?.toLocaleDateString('it-IT', {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
||||||
}),
|
|
||||||
lastActive: s.last_active?.toLocaleDateString('it-IT', {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
||||||
}),
|
|
||||||
isRevoked: s.is_revoked,
|
|
||||||
isCurrent: false
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getCurrentSessionID(token) {
|
|
||||||
const parsed = parseSessionToken(token);
|
|
||||||
if (!parsed) {
|
|
||||||
throw new Error('Invalid token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query('SELECT id FROM sessions WHERE session_code = $1', [parsed.code]);
|
|
||||||
return result.rows[0]?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revoke(id, username) {
|
|
||||||
const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.id = $1 AND s.user_id = u.id AND u.username = $2', [id, username]);
|
|
||||||
return result.rowCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeOthers(username, current) {
|
|
||||||
const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.user_id = u.id AND u.username = $1 AND s.id != $2 AND s.is_revoked = FALSE', [username, current]);
|
|
||||||
return result.rowCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCount(username) {
|
|
||||||
const result = await query('SELECT COUNT(*) as count FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE', [username]);
|
|
||||||
return parseInt(result.rows[0].count, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getSessions,
|
|
||||||
getCurrentSessionID,
|
|
||||||
revoke,
|
|
||||||
revokeOthers,
|
|
||||||
getCount
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -61,10 +61,13 @@ const authRateLimit = createRateLimiter(RATE_LIMIT_AUTH_MAX);
|
|||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('X-Frame-Options', 'DENY');
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
res.setHeader('X-XSS-Protection', '0'); // Disabilitato a favore di CSP
|
res.setHeader('X-XSS-Protection', '0');
|
||||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
// Rimuovi header che rivelano info sul server
|
res.setHeader(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||||
|
);
|
||||||
res.removeHeader('X-Powered-By');
|
res.removeHeader('X-Powered-By');
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -80,6 +83,7 @@ app.use(parser());
|
|||||||
// ─── STATIC FILES ───────────────────────────────────────────────────
|
// ─── STATIC FILES ───────────────────────────────────────────────────
|
||||||
const staticFolder = path.join(__dirname, 'static');
|
const staticFolder = path.join(__dirname, 'static');
|
||||||
app.use('/static', express.static(staticFolder));
|
app.use('/static', express.static(staticFolder));
|
||||||
|
app.use('/api/static', express.static(staticFolder));
|
||||||
|
|
||||||
// ─── NUNJUCKS TEMPLATES ─────────────────────────────────────────────
|
// ─── NUNJUCKS TEMPLATES ─────────────────────────────────────────────
|
||||||
const templatesFolder = path.join(__dirname, 'templates');
|
const templatesFolder = path.join(__dirname, 'templates');
|
||||||
@@ -112,14 +116,11 @@ app.use('/api/sessions', require('./routes/sessions'));
|
|||||||
// ─── HEALTH CHECK ───────────────────────────────────────────────────
|
// ─── HEALTH CHECK ───────────────────────────────────────────────────
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbConnected = await database.checkPostgres();
|
const dbConnected = await database.checkPostgres();
|
||||||
const redisHelper = require('./storage/redis');
|
|
||||||
const redisConnected = await redisHelper.checkRedis();
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: dbConnected && redisConnected ? "ok" : "degraded",
|
status: dbConnected ? "ok" : "degraded",
|
||||||
service: "auth",
|
service: "auth",
|
||||||
database: dbConnected ? "connected" : "disconnected",
|
database: dbConnected ? "connected" : "disconnected",
|
||||||
redis: redisConnected ? "connected" : "disconnected",
|
|
||||||
version: version,
|
version: version,
|
||||||
build_number: vBuild,
|
build_number: vBuild,
|
||||||
version_state: vState
|
version_state: vState
|
||||||
@@ -133,7 +134,7 @@ app.use((req, res) => {
|
|||||||
|
|
||||||
// ─── ERROR HANDLER GLOBALE ──────────────────────────────────────────
|
// ─── ERROR HANDLER GLOBALE ──────────────────────────────────────────
|
||||||
app.use((err, req, res, _next) => {
|
app.use((err, req, res, _next) => {
|
||||||
console.error('[AUTH] Errore non gestito:', err);
|
console.error('[ERROR]', err.message, '| code:', err.code);
|
||||||
res.status(500).json({ error: 'Errore interno del server' });
|
res.status(500).json({ error: 'Errore interno del server' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,40 +3,30 @@ const crypto = require('crypto');
|
|||||||
const API_KEY = process.env.INTERNAL_API_KEY;
|
const API_KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware di autenticazione per servizi interni (container-to-container).
|
* Middleware: autentica chiamate service-to-service tramite header x-internal-api-key.
|
||||||
* Verifica l'header 'x-internal-api-key' contro INTERNAL_API_KEY nell'env.
|
* Usa timing-safe comparison per prevenire timing attacks.
|
||||||
*
|
|
||||||
* SICUREZZA:
|
|
||||||
* - Se INTERNAL_API_KEY non è configurata, TUTTE le richieste vengono rifiutate
|
|
||||||
* - Usa timingSafeEqual per prevenire attacchi timing side-channel
|
|
||||||
*/
|
*/
|
||||||
const internalAuth = (req, res, next) => {
|
module.exports = function internalAuth(req, res, next) {
|
||||||
// Se la chiave non è configurata nel server, blocca tutto
|
|
||||||
if (!API_KEY) {
|
if (!API_KEY) {
|
||||||
console.error('[SECURITY] INTERNAL_API_KEY absent! All internal requests blocked.');
|
console.error('[SECURITY] INTERNAL_API_KEY mancante, blocco tutte le richieste interne.');
|
||||||
return res.status(503).json({ error: 'Service not configured correctly' });
|
return res.status(503).json({ error: 'service_not_configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const internalToken = req.headers['x-internal-api-key'];
|
const token = req.headers['x-internal-api-key'];
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
if (!internalToken || typeof internalToken !== 'string') {
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
return res.status(403).json({ error: 'unauthorized' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confronto timing-safe per prevenire timing attacks
|
|
||||||
try {
|
try {
|
||||||
const tokenBuffer = Buffer.from(internalToken, 'utf8');
|
const a = Buffer.from(token, 'utf8');
|
||||||
const keyBuffer = Buffer.from(API_KEY, 'utf8');
|
const b = Buffer.from(API_KEY, 'utf8');
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
||||||
if (tokenBuffer.length !== keyBuffer.length || !crypto.timingSafeEqual(tokenBuffer, keyBuffer)) {
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
return res.status(403).json({ error: 'Accesso negato' });
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(403).json({ error: 'Accesso negato' });
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = { id: 'system', role: 'internal_service' };
|
req.internal = true;
|
||||||
return next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = internalAuth;
|
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
const jwt = require('../tools/jwt');
|
const jwt = require('../tools/jwt');
|
||||||
|
const { validateSession } = require('../core/auth.core');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware di autenticazione per utenti finali.
|
* Middleware: richiede un utente autenticato valido.
|
||||||
* Verifica il JWT dal cookie 'auth_token' o dall'header 'Authorization: Bearer <token>'.
|
* Legge il token dal cookie `auth_token` o dall'header `Authorization: Bearer <token>`.
|
||||||
*
|
*
|
||||||
* Se valido, inietta req.user con { user_id, username, session_id }.
|
* Se la richiesta accetta HTML → redirect a /login con redirect-back URL.
|
||||||
|
* Altrimenti → 401 JSON.
|
||||||
*/
|
*/
|
||||||
const userAuth = (req, res, next) => {
|
module.exports = async function userAuth(req, res, next) {
|
||||||
const token = (req.cookies && req.cookies.auth_token) || jwt.getToken(req.headers['authorization']);
|
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
|
||||||
|
|
||||||
if (!token || typeof token !== 'string') {
|
const unauthorized = (reason) => {
|
||||||
return res.status(401).json({ error: 'Accesso negato: token mancante' });
|
if (req.accepts('html') && !req.xhr) {
|
||||||
|
const r = encodeURIComponent(req.originalUrl);
|
||||||
|
return res.redirect(`/login?redirect=${r}`);
|
||||||
}
|
}
|
||||||
|
return res.status(401).json({ error: reason || 'unauthorized' });
|
||||||
// Limite ragionevole sulla lunghezza del token per evitare abusi
|
|
||||||
if (token.length > 2048) {
|
|
||||||
return res.status(400).json({ error: 'Token non valido' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const verified = jwt.verifyToken(token);
|
|
||||||
if (!verified.valid) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'Sessione non valida o scaduta',
|
|
||||||
reason: verified.reason
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = verified.payload;
|
|
||||||
next();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = userAuth;
|
if (!token || typeof token !== 'string' || token.length > 2048) {
|
||||||
|
return unauthorized('missing_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = jwt.verify(token);
|
||||||
|
if (!v.valid) return unauthorized(`token_${v.reason}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await validateSession(v.payload.session_id);
|
||||||
|
} catch (err) {
|
||||||
|
return unauthorized(err.code || 'session_invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = v.payload;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,120 +4,175 @@ const jwt = require('../tools/jwt');
|
|||||||
|
|
||||||
const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004';
|
const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004';
|
||||||
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
|
||||||
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
// Validazione input
|
|
||||||
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
|
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
|
||||||
const PASSWORD_MIN_LENGTH = 8;
|
const MIN_PASSWORD = 8;
|
||||||
const PASSWORD_MAX_LENGTH = 128;
|
const MAX_PASSWORD = 128;
|
||||||
|
const TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 giorni
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opzioni cookie condivise per auth_token.
|
||||||
|
* Domain = `.mebboat.it` in produzione → SSO cross-subdomain
|
||||||
|
* (console.mebboat.it, ml.mebboat.it, api.mebboat.it, ecc.)
|
||||||
|
*/
|
||||||
|
function authCookieOptions(withMaxAge = true) {
|
||||||
|
const opts = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: IS_PROD,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/'
|
||||||
|
};
|
||||||
|
if (withMaxAge) opts.maxAge = TOKEN_MAX_AGE_MS;
|
||||||
|
if (COOKIE_DOMAIN) opts.domain = COOKIE_DOMAIN;
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un redirect URL per prevenire open-redirect.
|
||||||
|
* Accetta solo lo stesso dominio di CONSOLE_URL (o sottodomini di COOKIE_DOMAIN).
|
||||||
|
*/
|
||||||
|
function resolveSafeRedirect(redirect) {
|
||||||
|
if (!redirect || typeof redirect !== 'string') return CONSOLE_URL;
|
||||||
|
try {
|
||||||
|
const target = new URL(redirect);
|
||||||
|
const console_ = new URL(CONSOLE_URL);
|
||||||
|
|
||||||
|
const sameHost = target.hostname === console_.hostname;
|
||||||
|
const sameApex = COOKIE_DOMAIN
|
||||||
|
? target.hostname.endsWith(COOKIE_DOMAIN.replace(/^\./, ''))
|
||||||
|
: false;
|
||||||
|
const notApi = !target.pathname.startsWith('/api/');
|
||||||
|
|
||||||
|
if ((sameHost || sameApex) && notApi) return redirect;
|
||||||
|
} catch {
|
||||||
|
// URL invalido / relativo: fallback
|
||||||
|
}
|
||||||
|
return CONSOLE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /register ────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/register', async (req, res) => {
|
router.post('/register', async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body || {};
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
|
||||||
return res.status(400).json({ error: 'Username e password richiesti' });
|
return res.status(400).json({ success: false, error: 'username_and_password_required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof username !== 'string' || typeof password !== 'string') {
|
|
||||||
return res.status(400).json({ error: 'Formato dati non valido' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!USERNAME_REGEX.test(username)) {
|
if (!USERNAME_REGEX.test(username)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, error: 'invalid_username' });
|
||||||
error: 'Username non valido. 3-50 caratteri alfanumerici, underscore, punto o trattino.'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) {
|
||||||
if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) {
|
return res.status(400).json({ success: false, error: 'invalid_password_length' });
|
||||||
return res.status(400).json({
|
|
||||||
error: `Password deve essere tra ${PASSWORD_MIN_LENGTH} e ${PASSWORD_MAX_LENGTH} caratteri`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.register(username, password);
|
const user = await auth.register(username, password);
|
||||||
res.status(201).end();
|
return res.status(201).json({ success: true, user });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] Register failed:', err.message);
|
if (err.code === 'USER_EXISTS') {
|
||||||
const status = err.message === 'User already exists' ? 409 : 500;
|
return res.status(409).json({ success: false, error: 'user_exists' });
|
||||||
res.status(status).json({ error: err.message === 'User already exists' ? err.message : 'Errore interno' });
|
}
|
||||||
|
console.error('[AUTH] register:', err.message);
|
||||||
|
return res.status(500).json({ success: false, error: 'internal' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── POST /login ───────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { username, password, redirect } = req.body;
|
const { username, password, redirect, _csrf } = req.body || {};
|
||||||
|
|
||||||
// Validazione base
|
// Verifica CSRF token
|
||||||
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
|
const csrfCookie = req.cookies && req.cookies._csrf;
|
||||||
return res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
|
if (!_csrf || !csrfCookie || _csrf !== csrfCookie) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false, error: 'csrf', message: 'Richiesta non valida, riprova'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limiti di lunghezza per prevenire abuse
|
if (!username || !password || typeof username !== 'string' || typeof password !== 'string'
|
||||||
if (username.length > 50 || password.length > PASSWORD_MAX_LENGTH) {
|
|| username.length > 50 || password.length > MAX_PASSWORD) {
|
||||||
return res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
|
return res.status(400).json({
|
||||||
|
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validazione redirect URL per prevenire open redirect attacks
|
const safeRedirect = resolveSafeRedirect(redirect);
|
||||||
if (redirect && typeof redirect === 'string') {
|
|
||||||
try {
|
|
||||||
const redirectUrl = new URL(redirect);
|
|
||||||
const consoleUrl = new URL(CONSOLE_URL);
|
|
||||||
// Permetti redirect solo allo stesso dominio del CONSOLE_URL
|
|
||||||
if (redirectUrl.hostname !== consoleUrl.hostname) {
|
|
||||||
return res.render('loginpage', { error: 'Redirect non autorizzato', redirect: '' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// URL relativo o non valido — ignora il redirect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await auth.login(username, password);
|
const user = await auth.login(username, password);
|
||||||
const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip);
|
const session = await auth.createSession(user.id, req.headers['user-agent'], req.ip);
|
||||||
const token = jwt.generateToken(user, session.id);
|
const token = jwt.sign(user, session.id);
|
||||||
|
|
||||||
const cookieOptions = {
|
// Imposta il cookie auth_token (condiviso tra sottodomini se COOKIE_DOMAIN è impostato)
|
||||||
httpOnly: true,
|
res.cookie('auth_token', token, authCookieOptions(true));
|
||||||
secure: process.env.NODE_ENV === 'production',
|
// Rimuove il cookie CSRF
|
||||||
sameSite: 'lax',
|
res.clearCookie('_csrf', { httpOnly: true, sameSite: 'strict', path: '/' });
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000
|
|
||||||
};
|
|
||||||
|
|
||||||
if (COOKIE_DOMAIN) {
|
return res.status(200).json({
|
||||||
cookieOptions.domain = COOKIE_DOMAIN;
|
success: true,
|
||||||
}
|
redirect_url: safeRedirect,
|
||||||
|
message: 'Login effettuato'
|
||||||
res.cookie('auth_token', token, cookieOptions);
|
});
|
||||||
|
|
||||||
const destination = redirect || CONSOLE_URL;
|
|
||||||
res.redirect(destination);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] Login failed:', err.message);
|
if (err.code === 'INVALID_CREDENTIALS') {
|
||||||
// Mai rivelare se è l'utente o la password ad essere sbagliati
|
return res.status(401).json({
|
||||||
res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
|
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err.code === 'ACCOUNT_INACTIVE') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false, error: 'account_inactive', message: 'Account disattivato'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error('[AUTH] login:', err.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false, error: 'internal', message: 'Errore interno'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── POST /logout ──────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/logout', async (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
const token = req.cookies && req.cookies.auth_token;
|
const token = req.cookies && req.cookies.auth_token;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
const v = jwt.verify(token);
|
||||||
|
if (v.valid) {
|
||||||
try {
|
try {
|
||||||
const verified = jwt.verifyToken(token);
|
await auth.revokeSession(v.payload.session_id);
|
||||||
if (verified.valid) {
|
|
||||||
await auth.logout(verified.payload.session_id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] Logout error:', err.message);
|
console.error('[AUTH] logout revoke:', err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearOptions = { httpOnly: true, sameSite: 'lax' };
|
res.clearCookie('auth_token', authCookieOptions(false));
|
||||||
if (COOKIE_DOMAIN) {
|
|
||||||
clearOptions.domain = COOKIE_DOMAIN;
|
// Form HTML tradizionale → redirect, altrimenti JSON
|
||||||
|
if (req.accepts('html') && !req.xhr && !req.headers['content-type']?.includes('json')) {
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
return res.status(200).json({ success: true, redirect_url: '/login' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /verify (introspection per altri servizi) ─────────────────
|
||||||
|
|
||||||
|
router.get('/verify', async (req, res) => {
|
||||||
|
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
|
||||||
|
if (!token) return res.status(401).json({ valid: false, error: 'no_token' });
|
||||||
|
|
||||||
|
const v = jwt.verify(token);
|
||||||
|
if (!v.valid) return res.status(401).json({ valid: false, error: `token_${v.reason}` });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.validateSession(v.payload.session_id);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ valid: false, error: err.code || 'session_invalid' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.clearCookie('auth_token', clearOptions);
|
return res.status(200).json({ valid: true, user: v.payload });
|
||||||
res.redirect('/login');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,54 +1,36 @@
|
|||||||
// api.mebboat.it/api/sessions
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { query } = require('../storage/database');
|
const auth = require('../core/auth.core');
|
||||||
const userAuth = require('../middlewares/user.security');
|
const userAuth = require('../middlewares/user.security');
|
||||||
|
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
// Tutte le route richiedono autenticazione utente
|
||||||
router.use(userAuth);
|
router.use(userAuth);
|
||||||
|
|
||||||
// Mostra SOLO le sessioni dell'utente autenticato (non di tutti!)
|
// GET / — Lista sessioni dell'utente corrente
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await query(
|
const rows = await auth.listSessions(req.user.user_id);
|
||||||
`SELECT id, ip_address, browser, os, device_type,
|
res.json(rows);
|
||||||
location_country, location_city, created_at, last_active, is_revoked
|
|
||||||
FROM sessions
|
|
||||||
WHERE user_id = $1
|
|
||||||
ORDER BY last_active DESC`,
|
|
||||||
[req.user.user_id]
|
|
||||||
);
|
|
||||||
res.json(result.rows);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[SESSIONS] Errore recupero sessioni:', err);
|
console.error('[SESSIONS] list:', err.message);
|
||||||
res.status(500).json({ error: 'Errore interno del server' });
|
res.status(500).json({ error: 'internal' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revoca una sessione specifica dell'utente
|
// DELETE /:sessionId — Revoca una sessione specifica
|
||||||
router.delete('/:sessionId', async (req, res) => {
|
router.delete('/:sessionId', async (req, res) => {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
// Validazione UUID
|
|
||||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
if (!UUID_REGEX.test(sessionId)) {
|
if (!UUID_REGEX.test(sessionId)) {
|
||||||
return res.status(400).json({ error: 'ID sessione non valido' });
|
return res.status(400).json({ error: 'invalid_session_id' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verifica che la sessione appartenga all'utente autenticato
|
const revoked = await auth.revokeSession(sessionId, req.user.user_id);
|
||||||
const result = await query(
|
if (!revoked) return res.status(404).json({ error: 'session_not_found' });
|
||||||
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE',
|
res.json({ success: true });
|
||||||
[sessionId, req.user.user_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
|
||||||
return res.status(404).json({ error: 'Sessione non trovata o già revocata' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, message: 'Sessione revocata' });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[SESSIONS] Errore revoca sessione:', err);
|
console.error('[SESSIONS] revoke:', err.message);
|
||||||
res.status(500).json({ error: 'Errore interno del server' });
|
res.status(500).json({ error: 'internal' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +1,76 @@
|
|||||||
// api.mebboat.it/api/users
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { query } = require('../storage/database');
|
const auth = require('../core/auth.core');
|
||||||
const userAuth = require('../middlewares/user.security');
|
const userAuth = require('../middlewares/user.security');
|
||||||
const internalAuth = require('../middlewares/internal.security');
|
const internalAuth = require('../middlewares/internal.security');
|
||||||
|
|
||||||
// ─── VALIDAZIONE INPUT ──────────────────────────────────────────────
|
|
||||||
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
|
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
|
||||||
const TELEGRAM_ID_REGEX = /^[0-9]{5,15}$/;
|
const TELEGRAM_REGEX = /^[0-9]{5,15}$/;
|
||||||
|
|
||||||
// ─── ROTTE INTERNAL (prima del router.use userAuth) ─────────────────
|
// ─── SERVICE-TO-SERVICE (x-internal-api-key) ────────────────────────
|
||||||
|
|
||||||
router.get('/', internalAuth, async (req, res) => {
|
router.get('/', internalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await query(
|
const users = await auth.getAllUsers();
|
||||||
'SELECT id, username, is_active, created_at, telegram_id FROM users'
|
res.json(users);
|
||||||
);
|
|
||||||
res.json(result.rows);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[USERS] Errore lista utenti:', err);
|
console.error('[USERS] list:', err.message);
|
||||||
res.status(500).json({ error: 'Errore interno del server' });
|
res.status(500).json({ error: 'internal' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/tonotify', internalAuth, async (req, res) => {
|
router.get('/tonotify', internalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await query(
|
const users = await auth.getUsersToNotify();
|
||||||
'SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL'
|
res.json(users);
|
||||||
);
|
|
||||||
res.json(result.rows);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[USERS] Errore lista notifiche:', err);
|
console.error('[USERS] tonotify:', err.message);
|
||||||
res.status(500).json({ error: 'Errore interno del server' });
|
res.status(500).json({ error: 'internal' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── ROTTE USER (tutte le rotte sotto usano userAuth) ───────────────
|
// ─── USER AUTH (cookie/JWT) ─────────────────────────────────────────
|
||||||
|
|
||||||
router.use(userAuth);
|
router.use(userAuth);
|
||||||
|
|
||||||
router.get('/me', async (req, res) => {
|
router.get('/me', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await query(
|
const user = await auth.getUserById(req.user.user_id);
|
||||||
'SELECT id, username, is_active, created_at, telegram_id FROM users WHERE id = $1',
|
if (!user) return res.status(404).json({ error: 'user_not_found' });
|
||||||
[req.user.user_id]
|
res.json(user);
|
||||||
);
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Utente non trovato' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[USERS] Errore recupero utente:', err);
|
console.error('[USERS] me:', err.message);
|
||||||
res.status(500).json({ error: 'Errore interno del server' });
|
res.status(500).json({ error: 'internal' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/me/username', async (req, res) => {
|
router.put('/me/username', async (req, res) => {
|
||||||
const newUsername = req.query.newUsername || req.body?.newUsername;
|
const newUsername = req.body?.newUsername || req.query.newUsername;
|
||||||
|
if (!newUsername || typeof newUsername !== 'string' || !USERNAME_REGEX.test(newUsername)) {
|
||||||
if (!newUsername || typeof newUsername !== 'string') {
|
return res.status(400).json({ error: 'invalid_username' });
|
||||||
return res.status(400).json({ error: 'Nuovo username richiesto' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validazione formato username
|
|
||||||
if (!USERNAME_REGEX.test(newUsername)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Username non valido. Deve contenere 3-50 caratteri alfanumerici, underscore, punto o trattino.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await query(
|
const updated = await auth.updateUsername(req.user.user_id, newUsername);
|
||||||
'UPDATE users SET username = $1 WHERE id = $2 RETURNING username',
|
if (!updated) return res.status(404).json({ error: 'user_not_found' });
|
||||||
[newUsername, req.user.user_id]
|
res.json({ success: true, username: updated.username });
|
||||||
);
|
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
|
||||||
return res.status(404).json({ error: 'Utente non trovato' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, username: result.rows[0].username });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') return res.status(409).json({ error: 'username_taken' });
|
||||||
return res.status(409).json({ error: 'Questo username è già in uso' });
|
console.error('[USERS] update username:', err.message);
|
||||||
}
|
res.status(500).json({ error: 'internal' });
|
||||||
console.error('[USERS] Errore aggiornamento username:', err);
|
|
||||||
res.status(500).json({ error: 'Errore interno del server' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/me/telegram', async (req, res) => {
|
router.put('/me/telegram', async (req, res) => {
|
||||||
const telegramId = req.query.telegramId || req.body?.telegramId;
|
const telegramId = req.body?.telegramId || req.query.telegramId;
|
||||||
|
if (!telegramId || typeof telegramId !== 'string' || !TELEGRAM_REGEX.test(telegramId)) {
|
||||||
if (!telegramId || typeof telegramId !== 'string') {
|
return res.status(400).json({ error: 'invalid_telegram_id' });
|
||||||
return res.status(400).json({ error: 'Telegram ID richiesto' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validazione formato Telegram ID (solo numeri, 5-15 cifre)
|
|
||||||
if (!TELEGRAM_ID_REGEX.test(telegramId)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Telegram ID non valido. Deve contenere solo numeri (5-15 cifre).'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await query(
|
await auth.updateTelegram(req.user.user_id, telegramId);
|
||||||
'UPDATE users SET telegram_id = $1 WHERE id = $2',
|
res.json({ success: true });
|
||||||
[telegramId, req.user.user_id]
|
|
||||||
);
|
|
||||||
res.json({ success: true, message: 'Telegram ID aggiornato' });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') return res.status(409).json({ error: 'telegram_taken' });
|
||||||
return res.status(409).json({ error: 'Questo Telegram ID è già associato a un altro account' });
|
console.error('[USERS] update telegram:', err.message);
|
||||||
}
|
res.status(500).json({ error: 'internal' });
|
||||||
console.error('[USERS] Errore aggiornamento telegram:', err);
|
|
||||||
res.status(500).json({ error: 'Errore interno del server' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
|
const { csrfToken } = require('../../tools/security');
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
invalid_credentials: 'Credenziali non valide',
|
||||||
|
csrf: 'Richiesta non valida, riprova',
|
||||||
|
account_inactive: 'Account disattivato',
|
||||||
|
session_expired: 'Sessione scaduta, effettua nuovamente il login'
|
||||||
|
};
|
||||||
|
|
||||||
router.get('/login', (req, res) => {
|
router.get('/login', (req, res) => {
|
||||||
const redirect = req.query.redirect || '';
|
const redirect = typeof req.query.redirect === 'string' ? req.query.redirect : '';
|
||||||
res.render('loginpage', { error: null, redirect });
|
const errorKey = req.query.error;
|
||||||
|
const error = ERROR_MESSAGES[errorKey] || null;
|
||||||
|
|
||||||
|
const token = csrfToken();
|
||||||
|
res.cookie('_csrf', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 30 * 60 * 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
res.render('loginpage', { error, redirect, csrf_token: token });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -4,7 +4,7 @@ const userAuth = require('../../middlewares/user.security');
|
|||||||
router.use(userAuth);
|
router.use(userAuth);
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.render('sessions');
|
res.render('sessions', { user: req.user });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
|
const userAuth = require('../../middlewares/user.security');
|
||||||
|
|
||||||
|
router.use(userAuth);
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.render('user');
|
res.render('user', { user: req.user });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
BIN
auth/src/static/font/sans-flex.ttf
Normal file
BIN
auth/src/static/font/sans-flex.ttf
Normal file
Binary file not shown.
@@ -15,7 +15,8 @@
|
|||||||
--header-border: #e2e8f0;
|
--header-border: #e2e8f0;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
--shadow-md:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
--radius-md: 8px;
|
--radius-md: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
}
|
}
|
||||||
@@ -26,21 +27,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Normal';
|
font-family: "Normal";
|
||||||
src: url('../font/Quicksand-VariableFont_wght.ttf');
|
src: url("../font/sans-flex.ttf");
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Bold';
|
font-family: "Bold";
|
||||||
src: url('../font/Quicksand-VariableFont_wght.ttf');
|
src: url("../font/sans-flex.ttf");
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: "Normal", Arial;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ button {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'Bold', inherit;
|
font-family: "Bold", inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
|
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -79,13 +80,11 @@ button.prominent:hover {
|
|||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
button.prominent:active {
|
button.prominent:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* INFO PANEL */
|
/* INFO PANEL */
|
||||||
|
|
||||||
.info-panel {
|
.info-panel {
|
||||||
@@ -111,9 +110,6 @@ button.prominent:active {
|
|||||||
transition: transform 0.12s ease;
|
transition: transform 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* GRID & CARD ITEMS */
|
/* GRID & CARD ITEMS */
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -132,7 +128,9 @@ button.prominent:active {
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
transition:
|
||||||
|
transform 0.12s ease,
|
||||||
|
box-shadow 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h3 {
|
.card h3 {
|
||||||
@@ -158,10 +156,6 @@ button.prominent:active {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* HEADER */
|
/* HEADER */
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -179,7 +173,6 @@ button.prominent:active {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
|||||||
@@ -5,58 +5,46 @@ const config = {
|
|||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: process.env.DB_PORT,
|
port: process.env.DB_PORT,
|
||||||
|
database: process.env.USERS_DB || process.env.DB_NAME,
|
||||||
max: 10,
|
max: 10,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000
|
connectionTimeoutMillis: 5000
|
||||||
}
|
};
|
||||||
|
|
||||||
const pool = new Pool({ ...config, database: process.env.DB_NAME });
|
const pool = new Pool(config);
|
||||||
|
|
||||||
pool.on('error', (err) => {
|
pool.on('error', (err) => {
|
||||||
console.error('Error in database', err);
|
console.error('[DB] Pool error:', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a query with parameters
|
|
||||||
* @param {string} text - SQL query
|
|
||||||
* @param {Array} params - Query parameters
|
|
||||||
* @returns {Promise<Object>} Query result
|
|
||||||
*/
|
|
||||||
async function query(text, params) {
|
async function query(text, params) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
try {
|
||||||
const result = await pool.query(text, params);
|
const result = await pool.query(text, params);
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
if (duration > 100) {
|
if (duration > 100) {
|
||||||
console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80));
|
console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB] Query failed:', err.message, '| code:', err.code);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a client from pool for transactions
|
|
||||||
* @returns {Promise<Object>} Pool client
|
|
||||||
*/
|
|
||||||
async function getClient() {
|
async function getClient() {
|
||||||
return await pool.connect();
|
return await pool.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize database and ensure tables exist
|
|
||||||
*/
|
|
||||||
async function initDb() {
|
async function initDb() {
|
||||||
// Test connection
|
|
||||||
await pool.query('SELECT NOW()');
|
await pool.query('SELECT NOW()');
|
||||||
// Ensure pgcrypto extension (provides gen_random_uuid)
|
|
||||||
// Note: creating extensions requires proper DB permissions (usually superuser in PG)
|
|
||||||
try {
|
try {
|
||||||
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
|
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[DB] Could not create pgcrypto extension (may require superuser):', err.message);
|
console.warn('[DB] Could not create pgcrypto extension:', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure tables exist (UUID default generated by DB)
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -76,7 +64,7 @@ async function initDb() {
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
session_code VARCHAR(64) NOT NULL,
|
session_code VARCHAR(64) NOT NULL,
|
||||||
encoded_username TEXT NOT NULL,
|
encoded_username TEXT NOT NULL DEFAULT '',
|
||||||
ip_address INET,
|
ip_address INET,
|
||||||
user_agent TEXT,
|
user_agent TEXT,
|
||||||
browser VARCHAR(100),
|
browser VARCHAR(100),
|
||||||
@@ -89,9 +77,6 @@ async function initDb() {
|
|||||||
is_revoked BOOLEAN DEFAULT FALSE
|
is_revoked BOOLEAN DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Altera colonna in base al nuovo standard token 32 byte - 64 url chars
|
|
||||||
ALTER TABLE sessions ALTER COLUMN session_code TYPE VARCHAR(64);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code);
|
CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
`);
|
`);
|
||||||
@@ -101,7 +86,7 @@ async function checkPostgres() {
|
|||||||
try {
|
try {
|
||||||
await pool.query('SELECT NOW()');
|
await pool.query('SELECT NOW()');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const Redis = require('ioredis');
|
|||||||
const redis = new Redis({
|
const redis = new Redis({
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT),
|
port: parseInt(process.env.REDIS_PORT),
|
||||||
|
password: process.env.REDIS_PASSWORD,
|
||||||
maxRetriesPerRequest: 3,
|
maxRetriesPerRequest: 3,
|
||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
retryStrategy(times) {
|
retryStrategy(times) {
|
||||||
|
|||||||
@@ -1,39 +1,108 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" href="../static/style/style.css">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="../static/style/login.css" </head>
|
<title>Login — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/style/login.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
|
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<p class="error">{{ error }}</p>
|
<p class="error" id="errorMessage">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form action="/login" method="post">
|
<form id="loginForm">
|
||||||
<input type="hidden" name="redirect" value="{{ redirect }}">
|
<input type="hidden" id="redirect" name="redirect" value="{{ redirect }}">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" id="username" name="username" required>
|
<input type="text" id="username" name="username" required autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" name="password" required>
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Login</button>
|
<button type="submit" class="prominent" id="submitBtn">Login</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Accesso in corso...';
|
||||||
|
if (errorMessage) errorMessage.style.display = 'none';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.get('username'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
redirect: formData.get('redirect'),
|
||||||
|
_csrf: formData.get('_csrf')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (response.ok && data.success && data.redirect_url) {
|
||||||
|
window.location.href = data.redirect_url;
|
||||||
|
} else {
|
||||||
|
const errorMsg = data.message || 'Errore durante il login';
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.textContent = errorMsg;
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Login';
|
||||||
|
|
||||||
|
// Ricarica la pagina per ottenere un nuovo CSRF token
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = 'Errore di connessione. Riprova più tardi.';
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.textContent = errorMsg;
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Login';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sessioni — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<style>
|
||||||
|
main { padding: 24px 30px; }
|
||||||
|
main h2 { font-size: 1.1rem; margin-bottom: 20px; color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.session-card .info h3 { font-size: 0.95rem; margin-bottom: 4px; }
|
||||||
|
.session-card .info p { font-size: 0.8rem; color: var(--text-secondary); margin: 2px 0; }
|
||||||
|
.session-card button { font-size: 0.8rem; padding: 6px 14px; color: #dc2626; border-color: #fca5a5; }
|
||||||
|
.session-card button:hover { background-color: #fef2f2; border-color: #dc2626; color: #dc2626; }
|
||||||
|
#loading { color: var(--text-tertiary); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Console MEB</h1>
|
||||||
|
<div class="profile">
|
||||||
|
<p>{{ user.username }}</p>
|
||||||
|
<form action="/api/auth/logout" method="post">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2>Sessioni attive</h2>
|
||||||
|
<div id="sessions-container">
|
||||||
|
<p id="loading">Caricamento...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(str || ''));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return 'N/D';
|
||||||
|
return new Date(iso).toLocaleString('it-IT', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sessions');
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error('Network error');
|
||||||
|
|
||||||
|
const sessions = await res.json();
|
||||||
|
const container = document.getElementById('sessions-container');
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--text-tertiary)">Nessuna sessione attiva.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = sessions.map(s => `
|
||||||
|
<div class="session-card" id="session-${escapeHtml(s.id)}">
|
||||||
|
<div class="info">
|
||||||
|
<h3>${escapeHtml(s.browser || 'Browser sconosciuto')} su ${escapeHtml(s.os || 'OS sconosciuto')}</h3>
|
||||||
|
<p>${escapeHtml(s.device_type || '')}${s.ip_address ? ' — ' + escapeHtml(s.ip_address) : ''}</p>
|
||||||
|
<p>Ultima attività: ${formatDate(s.last_active)}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="revokeSession('${escapeHtml(s.id)}')">Revoca</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch {
|
||||||
|
document.getElementById('sessions-container').innerHTML =
|
||||||
|
'<p style="color:#dc2626">Errore nel caricamento delle sessioni.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSession(id) {
|
||||||
|
if (!confirm('Revocare questa sessione?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const el = document.getElementById('session-' + id);
|
||||||
|
if (el) el.remove();
|
||||||
|
} catch {
|
||||||
|
alert('Errore durante la revoca della sessione.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSessions();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Profilo — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<style>
|
||||||
|
main { padding: 24px 30px; max-width: 600px; }
|
||||||
|
main h2 { font-size: 1.1rem; margin-bottom: 20px; color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.field { margin-bottom: 20px; }
|
||||||
|
.field label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.field p { font-size: 0.95rem; padding: 10px 14px; border: 1px solid var(--header-border); border-radius: var(--radius-md); }
|
||||||
|
.field-empty { color: var(--text-tertiary); font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Console MEB</h1>
|
||||||
|
<div class="profile">
|
||||||
|
<p id="username-label">{{ user.username }}</p>
|
||||||
|
<form action="/api/auth/logout" method="post">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2>Profilo utente</h2>
|
||||||
|
<div id="user-info">
|
||||||
|
<p style="color:var(--text-tertiary)">Caricamento...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(str || ''));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return 'N/D';
|
||||||
|
return new Date(iso).toLocaleDateString('it-IT', {
|
||||||
|
day: 'numeric', month: 'long', year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUser() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/me');
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
|
const user = await res.json();
|
||||||
|
document.getElementById('username-label').textContent = user.username;
|
||||||
|
document.getElementById('user-info').innerHTML = `
|
||||||
|
<div class="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<p>${escapeHtml(user.username)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Account creato il</label>
|
||||||
|
<p>${formatDate(user.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Telegram ID</label>
|
||||||
|
<p>${user.telegram_id ? escapeHtml(user.telegram_id) : '<span class="field-empty">Non configurato</span>'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Sessioni</label>
|
||||||
|
<p><a href="/sessions">Gestisci sessioni →</a></p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch {
|
||||||
|
document.getElementById('user-info').innerHTML =
|
||||||
|
'<p style="color:#dc2626">Errore nel caricamento del profilo.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUser();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1,70 +1,52 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
const secret = process.env.JWT_SECRET;
|
const SECRET = process.env.JWT_SECRET;
|
||||||
const expires_in = process.env.JWT_EXPIRES_IN;
|
const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Genera un JWT Token a partire dall'utente e crea una nuova sessione
|
* Firma un JWT per l'utente e la sessione.
|
||||||
*
|
* Payload: { sub: userId, username, session_id }
|
||||||
* Uso dell'algoritmo HS256 per firmare il token con JWT_SECRET
|
|
||||||
*
|
|
||||||
* @param {Object} user - Utente
|
|
||||||
* @param {string} sessionID - ID della sessione
|
|
||||||
* @returns {string} - JWT Token
|
|
||||||
*/
|
*/
|
||||||
function generateToken(user, sessionID) {
|
function sign(user, sessionId) {
|
||||||
const payload = {
|
return jwt.sign(
|
||||||
sub: user.id,
|
{ sub: user.id, username: user.username, session_id: sessionId },
|
||||||
username: user.username,
|
SECRET,
|
||||||
session_id: sessionID,
|
{ algorithm: 'HS256', expiresIn: EXPIRES_IN }
|
||||||
iat: Math.floor(Date.now() / 1000)
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return jwt.sign(payload, secret, { expiresIn: expires_in, algorithm: 'HS256' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica e decodifica il token
|
* Verifica e decodifica un token.
|
||||||
* @param {string} token - JWT Token
|
* @returns {{ valid: boolean, payload?: Object, reason?: string }}
|
||||||
* @returns {{valid: boolean, payload?: Object, error?: string, reason?: string}} - Il risultato della verifica. Se fallisce restituisce errore e motivo, altrimenti restituisce una conferma e il payload completo
|
|
||||||
*/
|
*/
|
||||||
function verifyToken(token) {
|
function verify(token) {
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, secret, {
|
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
|
||||||
algorithms: ['HS256']
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
payload: {
|
payload: {
|
||||||
user_id: payload.sub,
|
user_id: p.sub,
|
||||||
username: payload.username,
|
username: p.username,
|
||||||
session_id: payload.session_id,
|
session_id: p.session_id,
|
||||||
iat: payload.iat,
|
iat: p.iat,
|
||||||
exp: payload.exp
|
exp: p.exp
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
||||||
const reason = err.name === 'TokenExpiredError' ? 'expired' : 'invalid';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: err.message,
|
reason: err.name === 'TokenExpiredError' ? 'expired' : 'invalid'
|
||||||
reason: `token ${reason}`
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(header) {
|
/**
|
||||||
if (!header) return null;
|
* Estrae il token da un header Authorization: Bearer <token>.
|
||||||
|
*/
|
||||||
const parts = header.split(' ');
|
function bearer(header) {
|
||||||
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
|
if (!header || typeof header !== 'string') return null;
|
||||||
return parts[1];
|
const [scheme, token] = header.split(' ');
|
||||||
|
return scheme && scheme.toLowerCase() === 'bearer' && token ? token : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: valutare se modificare in return null per accettare solo metodo Bearer Authorization Token,
|
module.exports = { sign, verify, bearer };
|
||||||
return header;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { generateToken, verifyToken, getToken };
|
|
||||||
|
|||||||
@@ -1,54 +1,22 @@
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const saltRounds = 12;
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
/**
|
async function hashPassword(password) {
|
||||||
* Genera un hash di una password
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
* @param {string} password - Password da hashare
|
|
||||||
* @returns {string} - Hash della password
|
|
||||||
*/
|
|
||||||
function hashPassword(password) {
|
|
||||||
return bcrypt.hashSync(password, saltRounds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function verifyPassword(password, hash) {
|
||||||
* Verifica una password
|
return bcrypt.compare(password, hash);
|
||||||
* @param {string} password - Password da verificare
|
|
||||||
* @param {string} hash - Hash della password
|
|
||||||
* @returns {boolean} - True se la password è corretta, false altrimenti
|
|
||||||
*/
|
|
||||||
function verifyPassword(password, hash) {
|
|
||||||
return bcrypt.compareSync(password, hash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function sessionCode() {
|
||||||
* Create a session token from code and username
|
|
||||||
* Format: XXXXXXXX-base64_username
|
|
||||||
* @param {string} sessionCode
|
|
||||||
* @param {string} username
|
|
||||||
* @returns {string} Session token
|
|
||||||
*/
|
|
||||||
function generateSessionCode() {
|
|
||||||
return crypto.randomBytes(32).toString('base64url');
|
return crypto.randomBytes(32).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function csrfToken() {
|
||||||
* Parse a session token
|
return crypto.randomBytes(32).toString('hex');
|
||||||
* @param {string} token
|
|
||||||
* @returns {string|null} The session token itself if valid
|
|
||||||
*/
|
|
||||||
function parseSessionToken(token) {
|
|
||||||
if (!token || typeof token !== 'string' || token.length < 32 || token.length > 64) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
module.exports = { hashPassword, verifyPassword, sessionCode, csrfToken };
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
hashPassword,
|
|
||||||
verifyPassword,
|
|
||||||
generateSessionCode,
|
|
||||||
parseSessionToken
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
//TODO: Verfica se serve davvero prendere le info come ip e browser
|
const { UAParser } = require('ua-parser-js');
|
||||||
|
|
||||||
const { UAParser: parser } = require('ua-parser-js');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estrae le informazioni base su browser, sistema operativo e dispositivo per identificare meglio la sessione dell'utente
|
* Estrae browser/os/device dal user-agent per identificare meglio la sessione.
|
||||||
* @param {*} userAgent
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
function getBasicMetadata(userAgent) {
|
function extract(userAgent) {
|
||||||
const parsed = parser(userAgent);
|
const r = new UAParser(userAgent || '').getResult();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
browser: parsed.browser.name,
|
browser: r.browser.name || null,
|
||||||
os: parsed.os.name,
|
os: r.os.name || null,
|
||||||
device_type: parsed.device.type,
|
device_type: r.device.type || 'desktop'
|
||||||
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getBasicMetadata
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports = { extract };
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ VERSION_STATE=pre-release
|
|||||||
REALTIME_URL=
|
REALTIME_URL=
|
||||||
REALTIME_WS_URL=
|
REALTIME_WS_URL=
|
||||||
|
|
||||||
|
API_URL=
|
||||||
|
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
AUTH_LOGIN_URL=
|
AUTH_LOGIN_URL=
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const nunjucks = require('nunjucks');
|
const nunjucks = require('nunjucks');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
|
|
||||||
const parser = require('cookie-parser');
|
const parser = require('cookie-parser');
|
||||||
|
|
||||||
|
const { requireAuthHtml } = require('./middlewares/auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT;
|
const PORT = process.env.PORT;
|
||||||
|
|
||||||
@@ -47,39 +47,9 @@ const renderPage = (page, extra = {}) => (req, res) => {
|
|||||||
res.render(page, {current_path: req.path, ...extra})
|
res.render(page, {current_path: req.path, ...extra})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware di autenticazione per le pagine
|
// Middleware di autenticazione per tutte le pagine protette
|
||||||
app.use((req, res, next) => {
|
// Le route /health e /static sono già gestite sopra
|
||||||
if (req.path === '/health' || req.path.startsWith('/static')) {
|
app.use(requireAuthHtml);
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const authBase = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
|
|
||||||
|
|
||||||
// Costruisci l'URL di redirect-back: protocollo + host + path originale
|
|
||||||
const proto = req.protocol;
|
|
||||||
const host = req.get('host');
|
|
||||||
const redirectBack = `${proto}://${host}${req.originalUrl}`;
|
|
||||||
const loginUrl = `${authBase}?redirect=${encodeURIComponent(redirectBack)}`;
|
|
||||||
|
|
||||||
const token = req.cookies && req.cookies.auth_token;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
||||||
req.user = payload;
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
const clearOptions = { httpOnly: true, sameSite: 'lax' };
|
|
||||||
if (process.env.COOKIE_DOMAIN) {
|
|
||||||
clearOptions.domain = process.env.COOKIE_DOMAIN;
|
|
||||||
}
|
|
||||||
res.clearCookie('auth_token', clearOptions);
|
|
||||||
return res.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/dashboard', renderPage('dashboard'));
|
app.get('/dashboard', renderPage('dashboard'));
|
||||||
app.get('/live', renderPage('live', {
|
app.get('/live', renderPage('live', {
|
||||||
@@ -87,6 +57,15 @@ app.get('/live', renderPage('live', {
|
|||||||
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get('/rulesets', renderPage('rulesets', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003'
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/sessions', renderPage('sessions', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||||
|
mapboxToken: process.env.MAPBOX_TOKEN || ''
|
||||||
|
}));
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Started on port ${PORT}`);
|
console.log(`Started on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
74
console/src/middlewares/auth.js
Normal file
74
console/src/middlewares/auth.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Middleware di autenticazione condiviso per la console.
|
||||||
|
* Usa il JWT in cookie `auth_token` (condiviso tra i sottodomini via COOKIE_DOMAIN = .mebboat.it)
|
||||||
|
* oppure il header `Authorization: Bearer <token>`.
|
||||||
|
*
|
||||||
|
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET; questo servizio lo verifica
|
||||||
|
* localmente usando lo stesso secret. Nessuna chiamata di rete richiesta.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET;
|
||||||
|
const AUTH_LOGIN_URL = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
|
||||||
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
|
||||||
|
|
||||||
|
function extractToken(req) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
return (req.cookies && req.cookies.auth_token) || bearer || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
if (!token || typeof token !== 'string' || token.length > 2048) return null;
|
||||||
|
try {
|
||||||
|
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
|
||||||
|
return {
|
||||||
|
user_id: p.sub,
|
||||||
|
username: p.username,
|
||||||
|
session_id: p.session_id,
|
||||||
|
iat: p.iat,
|
||||||
|
exp: p.exp
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthCookie(res) {
|
||||||
|
const opts = { httpOnly: true, sameSite: 'lax', path: '/' };
|
||||||
|
if (COOKIE_DOMAIN) opts.domain = COOKIE_DOMAIN;
|
||||||
|
res.clearCookie('auth_token', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginRedirectUrl(req) {
|
||||||
|
const back = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
||||||
|
return `${AUTH_LOGIN_URL}?redirect=${encodeURIComponent(back)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagine HTML: su fallimento redirige all'auth service (SSO).
|
||||||
|
* Il redirect-back URL viene costruito automaticamente dalla richiesta corrente.
|
||||||
|
*/
|
||||||
|
function requireAuthHtml(req, res, next) {
|
||||||
|
const token = extractToken(req);
|
||||||
|
const user = verifyToken(token);
|
||||||
|
if (!user) {
|
||||||
|
if (token) clearAuthCookie(res);
|
||||||
|
return res.redirect(loginRedirectUrl(req));
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API JSON: su fallimento risponde 401.
|
||||||
|
*/
|
||||||
|
function requireAuthApi(req, res, next) {
|
||||||
|
const user = verifyToken(extractToken(req));
|
||||||
|
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuthHtml, requireAuthApi, clearAuthCookie, verifyToken, extractToken };
|
||||||
@@ -31,10 +31,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="card" href="/forecasts" title="Previsioni">
|
<!-- <a class="card" href="/rulesets" title="Rulesets">
|
||||||
<div>
|
<div>
|
||||||
<h3>Previsioni</h3>
|
<h3>Rulesets</h3>
|
||||||
<p>Visualizza le condizioni meteo attuali e le previsioni future.</p>
|
<p>Gestisci i template di configurazione per weather, data e logs.</p>
|
||||||
|
</div>
|
||||||
|
</a> -->
|
||||||
|
|
||||||
|
<a class="card" href="/sessions" title="Previsioni">
|
||||||
|
<div>
|
||||||
|
<h3>Analisi Dati</h3>
|
||||||
|
<p>Analizza i dati raccolti sulle performance e sulla navigazione.</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
558
console/src/pages/kioskedit.html
Normal file
558
console/src/pages/kioskedit.html
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Kiosk Dashboard</title>
|
||||||
|
|
||||||
|
<script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script>
|
||||||
|
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
|
||||||
|
<link rel="stylesheet" href="../static/styles/kiosk.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- Main Grid Canvas -->
|
||||||
|
<div id="canvas" class="canvas">
|
||||||
|
<div id="emptyState" class="empty-state">Trascina o aggiungi una card per iniziare</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UI Feedback & Overlays -->
|
||||||
|
<div id="tooltip" class="tooltip"></div>
|
||||||
|
<div id="unitBadge" class="unit-badge">1u = 0px</div>
|
||||||
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
|
<div id="modalOverlay" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modalTitle">Configurazione</h2>
|
||||||
|
<textarea id="importArea" placeholder="JSON..."></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="modalCancel">Annulla</button>
|
||||||
|
<button id="modalApply" class="primary">Applica</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<p id="cardCount">0 cards</p>
|
||||||
|
|
||||||
|
<button id="editBtn">Edit</button>
|
||||||
|
<button id="addCardBtn" title="Aggiungi Widget">+</button>
|
||||||
|
<button id="addMapBtn" title="Aggiungi Mappa">Map</button>
|
||||||
|
|
||||||
|
<button id="importBtn">Import</button>
|
||||||
|
<button id="exportBtn">Export</button>
|
||||||
|
|
||||||
|
<button id="clearBtn">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="canvas.js"></script>
|
||||||
|
<script src="core.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const COLS = 24, ROWS = 18;
|
||||||
|
const SNAP = 0.5;
|
||||||
|
const SNAP_MAG = 0.3;
|
||||||
|
const MIN_GW = 2, MIN_GH = 1.5;
|
||||||
|
const MAX_GW = 20, MAX_GH = 16;
|
||||||
|
const DEF_GW = 6, DEF_GH = 5;
|
||||||
|
|
||||||
|
const canvasEl = document.getElementById('canvas');
|
||||||
|
const tooltipEl = document.getElementById('tooltip');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const cardCountEl = document.getElementById('cardCount');
|
||||||
|
const unitBadge = document.getElementById('unitBadge');
|
||||||
|
const modalOvl = document.getElementById('modalOverlay');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const importArea = document.getElementById('importArea');
|
||||||
|
const modalApply = document.getElementById('modalApply');
|
||||||
|
const toastEl = document.getElementById('toast');
|
||||||
|
|
||||||
|
let cards = [], cardIdCounter = 0, selectedCard = null, zCounter = 1;
|
||||||
|
let snapGuidesH = [], snapGuidesV = [];
|
||||||
|
let editMode = false;
|
||||||
|
|
||||||
|
const uw = () => canvasEl.clientWidth / COLS;
|
||||||
|
const uh = () => canvasEl.clientHeight / ROWS;
|
||||||
|
const gSnap = v => Math.round(v / SNAP) * SNAP;
|
||||||
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
|
|
||||||
|
function screenToGrid(cx, cy) {
|
||||||
|
const r = canvasEl.getBoundingClientRect();
|
||||||
|
return { gx: (cx - r.left) / uw(), gy: (cy - r.top) / uh() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(c) {
|
||||||
|
const u = uw(), h = uh();
|
||||||
|
c.el.style.left = (c.gx * u) + 'px';
|
||||||
|
c.el.style.top = (c.gy * h) + 'px';
|
||||||
|
c.el.style.width = (c.gw * u) + 'px';
|
||||||
|
c.el.style.height = (c.gh * h) + 'px';
|
||||||
|
|
||||||
|
if (editMode) c.el.classList.add('editable');
|
||||||
|
else c.el.classList.remove('editable', 'selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
cards.forEach(renderCard);
|
||||||
|
unitBadge.textContent = `1u = ${Math.round(uw())}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
const n = cards.length;
|
||||||
|
cardCountEl.textContent = `${n} card${n !== 1 ? 's' : ''}`;
|
||||||
|
emptyState.classList.toggle('hidden', n > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive re-render
|
||||||
|
let rafId = null;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(renderAll);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
let toastT = null;
|
||||||
|
function toast(msg) {
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
clearTimeout(toastT);
|
||||||
|
toastT = setTimeout(() => toastEl.classList.remove('show'), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guides
|
||||||
|
function ensureGuides() {
|
||||||
|
if (snapGuidesH.length) return;
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
let g = document.createElement('div'); g.className = 'guide snap-guide horizontal'; canvasEl.appendChild(g); snapGuidesH.push(g);
|
||||||
|
g = document.createElement('div'); g.className = 'guide snap-guide vertical'; canvasEl.appendChild(g); snapGuidesV.push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideGuides() {
|
||||||
|
snapGuidesH.forEach(g => g.classList.remove('visible')); snapGuidesV.forEach(g => g.classList.remove('visible'));
|
||||||
|
}
|
||||||
|
function showGuide(type, gp, idx = 0) {
|
||||||
|
if (type === 'h' && idx < snapGuidesH.length) { snapGuidesH[idx].style.top = (gp * uh()) + 'px'; snapGuidesH[idx].classList.add('visible'); }
|
||||||
|
else if (type === 'v' && idx < snapGuidesV.length) { snapGuidesV[idx].style.left = (gp * uw()) + 'px'; snapGuidesV[idx].classList.add('visible'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magnetic snap
|
||||||
|
function magSnap(el, gx, gy, gw, gh) {
|
||||||
|
let sx = gx, sy = gy, gH = [], gV = [];
|
||||||
|
const others = cards.filter(c => c.el !== el);
|
||||||
|
|
||||||
|
let bH = SNAP_MAG + 1;
|
||||||
|
for (const o of others)
|
||||||
|
for (const [f, t] of [[gy, o.gy], [gy, o.gy + o.gh], [gy + gh, o.gy], [gy + gh, o.gy + o.gh]]) {
|
||||||
|
const d = Math.abs(f - t);
|
||||||
|
if (d < bH) { bH = d; sy = gy + (t - f); gH = [t]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let bV = SNAP_MAG + 1;
|
||||||
|
for (const o of others)
|
||||||
|
for (const [f, t] of [[gx, o.gx], [gx, o.gx + o.gw], [gx + gw, o.gx], [gx + gw, o.gx + o.gw]]) {
|
||||||
|
const d = Math.abs(f - t);
|
||||||
|
if (d < bV) { bV = d; sx = gx + (t - f); gV = [t]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bH > SNAP_MAG) sy = gSnap(gy);
|
||||||
|
if (bV > SNAP_MAG) sx = gSnap(gx);
|
||||||
|
return { gx: sx, gy: sy, guidesH: gH, guidesV: gV };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal K data handling
|
||||||
|
function updateData(path, value) {
|
||||||
|
cards.filter(c => c.path === path).forEach(c => {
|
||||||
|
const body = c.el.querySelector('.card-body');
|
||||||
|
if (body) {
|
||||||
|
let displayVal = value;
|
||||||
|
if (path.includes('speed')) displayVal = (value * 1.94384).toFixed(1) + ' kn';
|
||||||
|
else if (path.includes('depth')) displayVal = value.toFixed(1) + ' m';
|
||||||
|
body.textContent = displayVal;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.updateKioskData = updateData;
|
||||||
|
|
||||||
|
// Create card
|
||||||
|
function createCard(gx, gy, gw = DEF_GW, gh = DEF_GH, forceId = null, gz = null, type = 'widget', path = null) {
|
||||||
|
const id = forceId || (++cardIdCounter);
|
||||||
|
if (forceId && forceId >= cardIdCounter) cardIdCounter = forceId;
|
||||||
|
if (!forceId && id > cardIdCounter) cardIdCounter = id;
|
||||||
|
|
||||||
|
const skPaths = window.skPaths || [];
|
||||||
|
const finalPath = path || (type === 'widget' && skPaths.length ? skPaths[(id - 1) % skPaths.length] : null);
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'card spawning' + (editMode ? ' editable' : '');
|
||||||
|
el.dataset.id = id;
|
||||||
|
el.dataset.type = type;
|
||||||
|
const z = gz || (++zCounter);
|
||||||
|
el.style.zIndex = z;
|
||||||
|
if (gz && gz >= zCounter) zCounter = gz;
|
||||||
|
|
||||||
|
let headerHtml = `<span class="card-label">${type === 'map' ? 'Mappa' : (finalPath ? finalPath.split('.').pop() : 'Widget')}</span>`;
|
||||||
|
|
||||||
|
// Suggerimento menu path se widget
|
||||||
|
if (type === 'widget') {
|
||||||
|
let menuHtml = `<div class="path-menu">`;
|
||||||
|
skPaths.forEach(p => {
|
||||||
|
menuHtml += `<div class="path-option" data-path="${p}">${p.split('.').pop()}</div>`;
|
||||||
|
});
|
||||||
|
menuHtml += `</div>`;
|
||||||
|
headerHtml = `<div class="label-wrapper">${headerHtml}${menuHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card-header">
|
||||||
|
${headerHtml}
|
||||||
|
<button class="card-close" title="Rimuovi">ELIMINA</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body"></div>
|
||||||
|
<div class="rh corner nw"></div><div class="rh corner ne"></div>
|
||||||
|
<div class="rh corner se"></div><div class="rh corner sw"></div>
|
||||||
|
<div class="rh edge n"></div><div class="rh edge s"></div>
|
||||||
|
<div class="rh edge e"></div><div class="rh edge w"></div>`;
|
||||||
|
|
||||||
|
canvasEl.appendChild(el);
|
||||||
|
const c = { id, el, gx, gy, gw, gh, type, path: finalPath };
|
||||||
|
cards.push(c);
|
||||||
|
renderCard(c);
|
||||||
|
|
||||||
|
if (type === 'map') {
|
||||||
|
const mapDiv = document.createElement('div');
|
||||||
|
mapDiv.id = `map-container-${id}`;
|
||||||
|
mapDiv.className = 'card-map-canvas';
|
||||||
|
el.querySelector('.card-body').appendChild(mapDiv);
|
||||||
|
if (window.initMapInstance) window.initMapInstance(mapDiv.id);
|
||||||
|
} else {
|
||||||
|
updateBody(c);
|
||||||
|
// Listener per il cambio path
|
||||||
|
el.querySelectorAll('.path-option').forEach(opt => {
|
||||||
|
opt.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
c.path = opt.dataset.path;
|
||||||
|
el.querySelector('.card-label').textContent = c.path.split('.').pop();
|
||||||
|
toast(`Path aggiornato: ${c.path}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('animationend', () => el.classList.remove('spawning'), { once: true });
|
||||||
|
el.querySelector('.card-close').addEventListener('click', ev => { ev.stopPropagation(); removeCard(c); });
|
||||||
|
el.addEventListener('mousedown', () => { if (editMode) selectCard(c); });
|
||||||
|
|
||||||
|
setupDrag(c);
|
||||||
|
setupResize(c);
|
||||||
|
updateCount();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCard(c) {
|
||||||
|
c.el.classList.add('removing');
|
||||||
|
c.el.addEventListener('animationend', () => {
|
||||||
|
c.el.remove();
|
||||||
|
cards = cards.filter(x => x.id !== c.id);
|
||||||
|
if (selectedCard === c) selectedCard = null;
|
||||||
|
updateCount();
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCard(c) {
|
||||||
|
if (selectedCard?.el) selectedCard.el.classList.remove('selected');
|
||||||
|
selectedCard = c; c.el.classList.add('selected'); c.el.style.zIndex = ++zCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBody(c) {
|
||||||
|
if (c.type === 'map') {
|
||||||
|
if (window.resizeMapInstance) window.resizeMapInstance();
|
||||||
|
} else {
|
||||||
|
const b = c.el.querySelector('.card-body');
|
||||||
|
if (b && !b.textContent.trim()) {
|
||||||
|
b.textContent = `${c.gw.toFixed(1)} × ${c.gh.toFixed(1)} u`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag
|
||||||
|
function setupDrag(c) {
|
||||||
|
c.el.addEventListener('mousedown', e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
if (e.target.classList.contains('rh') || e.target.classList.contains('card-close')) return;
|
||||||
|
e.preventDefault(); ensureGuides(); c.el.classList.add('dragging');
|
||||||
|
const start = screenToGrid(e.clientX, e.clientY);
|
||||||
|
const oGx = c.gx, oGy = c.gy;
|
||||||
|
|
||||||
|
const onMove = ev => {
|
||||||
|
const now = screenToGrid(ev.clientX, ev.clientY);
|
||||||
|
let nx = oGx + (now.gx - start.gx), ny = oGy + (now.gy - start.gy);
|
||||||
|
nx = clamp(nx, 0, COLS - c.gw); ny = clamp(ny, 0, ROWS - c.gh);
|
||||||
|
const s = magSnap(c.el, nx, ny, c.gw, c.gh);
|
||||||
|
c.gx = clamp(s.gx, 0, COLS - c.gw); c.gy = clamp(s.gy, 0, ROWS - c.gh);
|
||||||
|
hideGuides();
|
||||||
|
s.guidesH.forEach((p, i) => showGuide('h', p, i));
|
||||||
|
s.guidesV.forEach((p, i) => showGuide('v', p, i));
|
||||||
|
renderCard(c);
|
||||||
|
tooltipEl.textContent = `${c.gx.toFixed(1)}, ${c.gy.toFixed(1)}`;
|
||||||
|
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
|
||||||
|
tooltipEl.classList.add('visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
c.el.classList.remove('dragging'); hideGuides(); tooltipEl.classList.remove('visible');
|
||||||
|
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
|
||||||
|
if (c.type === 'map' && window.resizeMapInstance) window.resizeMapInstance();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize
|
||||||
|
function setupResize(c) {
|
||||||
|
c.el.querySelectorAll('.rh').forEach(h => {
|
||||||
|
h.addEventListener('mousedown', e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
e.preventDefault(); e.stopPropagation(); ensureGuides();
|
||||||
|
c.el.classList.add('resizing'); selectCard(c);
|
||||||
|
const start = screenToGrid(e.clientX, e.clientY);
|
||||||
|
const oGx = c.gx, oGy = c.gy, oGw = c.gw, oGh = c.gh;
|
||||||
|
const isN = h.classList.contains('nw') || h.classList.contains('ne') || h.classList.contains('n');
|
||||||
|
const isS = h.classList.contains('sw') || h.classList.contains('se') || h.classList.contains('s');
|
||||||
|
const isW = h.classList.contains('nw') || h.classList.contains('sw') || h.classList.contains('w');
|
||||||
|
const isE = h.classList.contains('ne') || h.classList.contains('se') || h.classList.contains('e');
|
||||||
|
|
||||||
|
const onMove = ev => {
|
||||||
|
const now = screenToGrid(ev.clientX, ev.clientY);
|
||||||
|
const dx = now.gx - start.gx, dy = now.gy - start.gy;
|
||||||
|
let nw = oGw, nh = oGh, nx = oGx, ny = oGy;
|
||||||
|
if (isE) nw = oGw + dx; if (isS) nh = oGh + dy;
|
||||||
|
if (isW) { nw = oGw - dx; nx = oGx + dx; }
|
||||||
|
if (isN) { nh = oGh - dy; ny = oGy + dy; }
|
||||||
|
nw = gSnap(clamp(nw, MIN_GW, MAX_GW)); nh = gSnap(clamp(nh, MIN_GH, MAX_GH));
|
||||||
|
if (isW) nx = oGx + oGw - nw; if (isN) ny = oGy + oGh - nh;
|
||||||
|
nx = gSnap(clamp(nx, 0, COLS - nw)); ny = gSnap(clamp(ny, 0, ROWS - nh));
|
||||||
|
c.gx = nx; c.gy = ny; c.gw = nw; c.gh = nh;
|
||||||
|
renderCard(c); updateBody(c);
|
||||||
|
tooltipEl.textContent = `${nw.toFixed(1)} × ${nh.toFixed(1)} u`;
|
||||||
|
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
|
||||||
|
tooltipEl.classList.add('visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
c.el.classList.remove('resizing'); tooltipEl.classList.remove('visible'); hideGuides();
|
||||||
|
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// EXPORT / IMPORT
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
function exportConfig() {
|
||||||
|
return JSON.stringify({
|
||||||
|
canvas: { cols: COLS, rows: ROWS },
|
||||||
|
cards: cards.map(c => ({
|
||||||
|
id: c.id, type: c.type,
|
||||||
|
dimensions: {
|
||||||
|
x: Math.round(c.gx * 100) / 100, y: Math.round(c.gy * 100) / 100,
|
||||||
|
z: parseInt(c.el.style.zIndex) || 1,
|
||||||
|
width: Math.round(c.gw * 100) / 100, height: Math.round(c.gh * 100) / 100
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importConfig(json) {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(json); } catch { toast('JSON non valido'); return false; }
|
||||||
|
if (!data.cards || !Array.isArray(data.cards)) { toast('Formato non valido: serve "cards" array'); return false; }
|
||||||
|
|
||||||
|
cards.forEach(c => c.el.remove());
|
||||||
|
cards = []; selectedCard = null; cardIdCounter = 0; zCounter = 1;
|
||||||
|
|
||||||
|
for (const entry of data.cards) {
|
||||||
|
const d = entry.dimensions || {};
|
||||||
|
createCard(
|
||||||
|
clamp(d.x ?? 0, 0, COLS - MIN_GW), clamp(d.y ?? 0, 0, ROWS - MIN_GH),
|
||||||
|
clamp(d.width ?? DEF_GW, MIN_GW, MAX_GW), clamp(d.height ?? DEF_GH, MIN_GH, MAX_GH),
|
||||||
|
entry.id ?? null, d.z ?? 1, entry.type ?? 'widget'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updateCount(); renderAll();
|
||||||
|
toast(`Importate ${data.cards.length} card`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toolbar ─────────────────────────────────────────
|
||||||
|
document.getElementById('editBtn').addEventListener('click', (e) => {
|
||||||
|
editMode = !editMode;
|
||||||
|
e.target.classList.toggle('primary', editMode);
|
||||||
|
canvasEl.classList.toggle('edit-active', editMode);
|
||||||
|
document.body.classList.toggle('edit-mode', editMode);
|
||||||
|
renderAll();
|
||||||
|
toast(editMode ? 'Edit Mode Attiva' : 'Edit Mode Disattiva');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('addCardBtn').addEventListener('click', () => {
|
||||||
|
const off = (cards.length % 8) * SNAP * 2;
|
||||||
|
createCard(gSnap(1 + off), gSnap(1 + off));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('addMapBtn').addEventListener('click', () => {
|
||||||
|
createCard(gSnap(4), gSnap(4), 10, 8, null, null, 'map');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exportBtn').addEventListener('click', () => {
|
||||||
|
const json = exportConfig();
|
||||||
|
modalTitle.textContent = 'Esporta configurazione JSON';
|
||||||
|
importArea.value = json; importArea.readOnly = true;
|
||||||
|
modalApply.textContent = 'Copia';
|
||||||
|
modalOvl.classList.add('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('importBtn').addEventListener('click', () => {
|
||||||
|
modalTitle.textContent = 'Importa configurazione JSON';
|
||||||
|
importArea.value = ''; importArea.readOnly = false;
|
||||||
|
modalApply.textContent = 'Applica';
|
||||||
|
modalOvl.classList.add('open');
|
||||||
|
setTimeout(() => importArea.focus(), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modalCancel').addEventListener('click', () => modalOvl.classList.remove('open'));
|
||||||
|
|
||||||
|
modalApply.addEventListener('click', () => {
|
||||||
|
if (modalTitle.textContent.includes('Esporta')) {
|
||||||
|
navigator.clipboard.writeText(importArea.value).then(() => toast('Copiato')).catch(() => toast('Errore copia'));
|
||||||
|
modalOvl.classList.remove('open');
|
||||||
|
} else {
|
||||||
|
if (importConfig(importArea.value)) modalOvl.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modalOvl.addEventListener('click', e => { if (e.target === modalOvl) modalOvl.classList.remove('open'); });
|
||||||
|
|
||||||
|
document.getElementById('clearBtn').addEventListener('click', () => {
|
||||||
|
[...cards].forEach((c, i) => setTimeout(() => removeCard(c), i * 40));
|
||||||
|
});
|
||||||
|
|
||||||
|
canvasEl.addEventListener('mousedown', e => {
|
||||||
|
if (e.target === canvasEl && selectedCard) { selectedCard.el.classList.remove('selected'); selectedCard = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') { modalOvl.classList.remove('open'); return; }
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedCard) {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
removeCard(selectedCard);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCount(); renderAll();
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
"navigation.speedOverGround",
|
||||||
|
"environment.depth.belowTransducer",
|
||||||
|
]
|
||||||
|
window.skPaths = paths;
|
||||||
|
|
||||||
|
mapboxgl.accessToken = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
|
||||||
|
|
||||||
|
let map = null;
|
||||||
|
let boatMark = null;
|
||||||
|
let followBoat = true;
|
||||||
|
|
||||||
|
window.initMapInstance = (containerId) => {
|
||||||
|
map = new mapboxgl.Map({
|
||||||
|
container: containerId,
|
||||||
|
style: {
|
||||||
|
"version": 8,
|
||||||
|
"sources": {
|
||||||
|
"osm": { "type": "raster", "tiles": ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"], "tileSize": 256 },
|
||||||
|
"openseamap": { "type": "raster", "tiles": ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"], "tileSize": 256 }
|
||||||
|
},
|
||||||
|
"layers": [
|
||||||
|
{ "id": "osm-layer", "type": "raster", "source": "osm", "minzoom": 0, "maxzoom": 22 },
|
||||||
|
{ "id": "openseamap-layer", "type": "raster", "source": "openseamap", "minzoom": 0, "maxzoom": 18 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('dragstart', () => {
|
||||||
|
followBoat = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
boatMark = new mapboxgl.Marker({ color: 'red' })
|
||||||
|
.setLngLat([9, 9])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
// Area Protetta mock
|
||||||
|
map.addSource('area-protetta', {
|
||||||
|
'type': 'geojson',
|
||||||
|
'data': {
|
||||||
|
'type': 'Feature',
|
||||||
|
'geometry': {
|
||||||
|
'type': 'Polygon',
|
||||||
|
'coordinates': [[[9.05, 45.05], [9.15, 45.05], [9.15, 45.15], [9.05, 45.15], [9.05, 45.05]]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
'id': 'area-layer',
|
||||||
|
'type': 'fill',
|
||||||
|
'source': 'area-protetta',
|
||||||
|
'paint': { 'fill-color': '#0080ff', 'fill-opacity': 0.3 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resizeMapInstance = () => {
|
||||||
|
if (map) map.resize();
|
||||||
|
};
|
||||||
|
|
||||||
|
function movePosition(lng, lat) {
|
||||||
|
if (!followBoat || !map) return;
|
||||||
|
map.flyTo({ center: [lng, lat], zoom: 14, speed: 1.2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = window.location.host;
|
||||||
|
const connection = `ws://${host}/signalk/v1/stream?subscribe=all`;
|
||||||
|
const ws = new WebSocket(connection);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.updates) {
|
||||||
|
msg.updates.forEach(update => {
|
||||||
|
if (update.values) {
|
||||||
|
update.values.forEach(v => {
|
||||||
|
// Aggiorna le card nel dashboard tramite canvas.js
|
||||||
|
if (window.updateKioskData) {
|
||||||
|
window.updateKioskData(v.path, v.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.path === "navigation.position" && boatMark) {
|
||||||
|
const lng = v.value.longitude;
|
||||||
|
const lat = v.value.latitude;
|
||||||
|
boatMark.setLngLat([lng, lat]);
|
||||||
|
movePosition(lng, lat);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => console.error("Errore WebSocket:", err);
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log("WebSocket chiuso. Riconnessione tra 5s...");
|
||||||
|
setTimeout(() => location.reload(), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// style: 'mapbox://styles/sesee3/cmn9767jg003l01qsbpmace1t/draft'
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -28,6 +28,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Label Popup -->
|
||||||
|
<div class="session-label-overlay" id="sessionLabelOverlay" style="display:none">
|
||||||
|
<div class="session-popup">
|
||||||
|
<h2>Nome Sessione</h2>
|
||||||
|
<p class="popup-subtitle">I nuovi dati verranno taggati con questo nome</p>
|
||||||
|
<input type="text" id="sessionLabelInput" placeholder="es. Traversata Sardegna" />
|
||||||
|
<p style="font-size:0.8rem;color:#94a3b8;margin:8px 0;">Attuale: <span id="currentSessionLabel">—</span></p>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button id="saveSessionLabelBtn">Salva</button>
|
||||||
|
<button id="cancelSessionLabelBtn" style="background:#334155;">Annulla</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="content" id="mainContent" style="display: none;">
|
<div class="content" id="mainContent" style="display: none;">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@@ -154,6 +168,10 @@
|
|||||||
|
|
||||||
<div class="bar-sep"></div>
|
<div class="bar-sep"></div>
|
||||||
|
|
||||||
|
<button id="sessionLabelBtn" title="Sessione di registrazione">Sessione</button>
|
||||||
|
|
||||||
|
<div class="bar-sep"></div>
|
||||||
|
|
||||||
<button id="downloadBtn" title="Scarica CSV">Scarica</button>
|
<button id="downloadBtn" title="Scarica CSV">Scarica</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -240,31 +258,46 @@ function getColorForField(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FIELD_DEFS = {
|
const FIELD_DEFS = {
|
||||||
temp: { name: 'Temperatura', unit: '°C', category: 'weather' },
|
// Meteo (da openmeteo → SignalK → logs)
|
||||||
hum: { name: 'Umidita', unit: '%', category: 'weather' },
|
'meb.forecasts.temperature': { name: 'Temperatura', unit: '°C', category: 'weather' },
|
||||||
pres: { name: 'Pressione', unit: 'hPa', category: 'weather' },
|
'meb.forecast.wind.speed': { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
|
||||||
wSpd: { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
|
'meb.forecast.wind.direction': { name: 'Direzione Vento', unit: '°', category: 'weather' },
|
||||||
wDir: { name: 'Direzione Vento', unit: '°', category: 'weather' },
|
'meb.forecast.wind.gusts': { name: 'Raffiche', unit: 'km/h', category: 'weather' },
|
||||||
gust: { name: 'Raffiche', unit: 'km/h', category: 'weather' },
|
'meb.forecast.humidity': { name: 'Umidita', unit: '%', category: 'weather' },
|
||||||
rain: { name: 'Pioggia', unit: 'mm', category: 'weather' },
|
'meb.forecast.pressure': { name: 'Pressione', unit: 'hPa', category: 'weather' },
|
||||||
prec: { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
|
'meb.forecast.precipitation': { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
|
||||||
lat: { name: 'Latitudine', unit: '°', category: 'navigation' },
|
'meb.forecast.rain': { name: 'Pioggia', unit: 'mm', category: 'weather' },
|
||||||
lon: { name: 'Longitudine', unit: '°', category: 'navigation' },
|
'meb.forecast.cloudCover': { name: 'Copertura Nuvole', unit: '%', category: 'weather' },
|
||||||
hdg: { name: 'Heading', unit: '°', category: 'navigation' },
|
'meb.forecast.precipitationProbability': { name: 'Prob. Precipitazioni', unit: '%', category: 'weather' },
|
||||||
sog: { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
|
// Marine
|
||||||
cog: { name: 'Rotta COG', unit: '°', category: 'navigation' },
|
'meb.waves.height': { name: 'Altezza Onde', unit: 'm', category: 'weather' },
|
||||||
depth: { name: 'Profondita', unit: 'm', category: 'navigation' },
|
'meb.waves.direction': { name: 'Direzione Onde', unit: '°', category: 'weather' },
|
||||||
engTemp: { name: 'Temp. Motore', unit: '°C', category: 'engine' },
|
'meb.waves.period': { name: 'Periodo Onde', unit: 's', category: 'weather' },
|
||||||
wvH: { name: 'Altezza Onde', unit: 'm', category: 'weather' },
|
'meb.waves.peakPeriod': { name: 'Periodo Picco', unit: 's', category: 'weather' },
|
||||||
wvP: { name: 'Periodo Onde', unit: 's', category: 'weather' },
|
'meb.waves.currentVelocity': { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
|
||||||
wvD: { name: 'Direzione Onde', unit: '°', category: 'weather' },
|
'meb.waves.currentDirection': { name: 'Dir. Corrente', unit: '°', category: 'weather' },
|
||||||
curD: { name: 'Dir. Corrente', unit: '°', category: 'weather' },
|
// Navigazione
|
||||||
curV: { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
|
'navigation.position.latitude': { name: 'Latitudine', unit: '°', category: 'navigation' },
|
||||||
fTemp: { name: 'Prev. Temperatura', unit: '°C', category: 'weather' },
|
'navigation.position.longitude': { name: 'Longitudine', unit: '°', category: 'navigation' },
|
||||||
fWSpd: { name: 'Prev. Vento', unit: 'km/h', category: 'weather' }
|
'navigation.headingTrue': { name: 'Heading', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.speedOverGround': { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
|
||||||
|
'navigation.courseOverGroundTrue': { name: 'Rotta COG', unit: '°', category: 'navigation' },
|
||||||
|
// Elettrica
|
||||||
|
'electrical.batteries.service.Voltage': { name: 'Batteria Serv. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.service.current': { name: 'Batteria Serv. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.service.stateOfCharge': { name: 'Batteria Serv. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.Voltage': { name: 'Batteria Traz. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.current': { name: 'Batteria Traz. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.stateOfCharge': { name: 'Batteria Traz. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.temperature': { name: 'Batteria Traz. Temp', unit: '°C', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.power': { name: 'Batteria Traz. W', unit: 'W', category: 'engine' },
|
||||||
|
// Motore
|
||||||
|
'propulsion.0.revolutions': { name: 'Giri Motore', unit: 'RPM', category: 'engine' },
|
||||||
|
// Sistema
|
||||||
|
'system.uptime': { name: 'Uptime', unit: 's', category: 'engine' }
|
||||||
};
|
};
|
||||||
const MEASUREMENT_CATEGORY = { weather: 'weather', navigation: 'navigation', engine: 'engine' };
|
const MEASUREMENT_CATEGORY = { weather: 'weather', navigation: 'navigation', logs: 'navigation', engine: 'engine' };
|
||||||
const ALWAYS_FILL_BOTTOM_FIELDS = ['lat', 'lon'];
|
const ALWAYS_FILL_BOTTOM_FIELDS = ['navigation.position.latitude', 'navigation.position.longitude'];
|
||||||
|
|
||||||
async function loadSessions() {
|
async function loadSessions() {
|
||||||
document.getElementById('sessionList').innerHTML = '<div class="session-loading">Caricamento...</div>';
|
document.getElementById('sessionList').innerHTML = '<div class="session-loading">Caricamento...</div>';
|
||||||
@@ -283,22 +316,27 @@ async function loadSessions() {
|
|||||||
const meta = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta;
|
const meta = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta;
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'session-item';
|
item.className = 'session-item';
|
||||||
const connTime = meta.connectedAt ? new Date(meta.connectedAt * 1000).toLocaleTimeString('it-IT') : '—';
|
const connTime = meta.connectedAt ? new Date(meta.connectedAt).toLocaleTimeString('it-IT') : '—';
|
||||||
item.innerHTML = `<div class="session-item-info"><strong>${meta.name || sId}</strong><span class="session-item-id">${sId}</span></div><div class="session-item-meta"><span class="session-item-time">Connesso: ${connTime}</span><div class="session-item-dot"></div></div>`;
|
const sessId = meta.session || '—';
|
||||||
|
item.innerHTML = `<div class="session-item-info"><strong>${meta.name || sId}</strong><span class="session-item-id">${sessId}</span></div><div class="session-item-meta"><span class="session-item-time">Connesso: ${connTime}</span><div class="session-item-dot"></div></div>`;
|
||||||
item.onclick = () => selectSession(sId, meta);
|
item.onclick = () => selectSession(sId, meta);
|
||||||
document.getElementById('sessionList').appendChild(item);
|
document.getElementById('sessionList').appendChild(item);
|
||||||
}
|
}
|
||||||
} catch (err) { }
|
} catch (err) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentSessionId = null; // InfluxDB session tag (es. s1234)
|
||||||
|
|
||||||
function selectSession(sId, meta) {
|
function selectSession(sId, meta) {
|
||||||
currentSensorId = sId;
|
currentSensorId = sId;
|
||||||
sessionStartTime = meta.connectedAt ? meta.connectedAt * 1000 : Date.now();
|
currentSessionId = meta.session || null;
|
||||||
|
sessionStartTime = meta.connectedAt ? new Date(meta.connectedAt).getTime() : Date.now();
|
||||||
document.getElementById('sessionOverlay').style.display = 'none';
|
document.getElementById('sessionOverlay').style.display = 'none';
|
||||||
document.getElementById('mainContent').style.display = '';
|
document.getElementById('mainContent').style.display = '';
|
||||||
document.getElementById('bottomBar').style.display = '';
|
document.getElementById('bottomBar').style.display = '';
|
||||||
document.getElementById('sensorName').textContent = meta.name || sId;
|
document.getElementById('sensorName').textContent = meta.name || sId;
|
||||||
document.getElementById('sessionInfoTitle').textContent = `Sensore: ${meta.name || sId}`;
|
document.getElementById('sessionInfoTitle').textContent = `Sensore: ${meta.name || sId}`;
|
||||||
|
document.getElementById('currentSessionLabel').textContent = currentSessionId || sId;
|
||||||
liveData = {};
|
liveData = {};
|
||||||
Object.values(miniCharts).forEach(c => c.destroy());
|
Object.values(miniCharts).forEach(c => c.destroy());
|
||||||
miniCharts = {};
|
miniCharts = {};
|
||||||
@@ -370,7 +408,11 @@ function handleSensorData(msg) {
|
|||||||
if (redrawExpChart) updateExpandedChart();
|
if (redrawExpChart) updateExpandedChart();
|
||||||
if (redrawCompChart) updateCompChart();
|
if (redrawCompChart) updateCompChart();
|
||||||
|
|
||||||
if (measurement === 'logs' && fields.lat && fields.lon) updateMap(fields.lat, fields.lon, fields.hdg, fields.wDir, fields.wvD);
|
const lat = fields['navigation.position.latitude'];
|
||||||
|
const lon = fields['navigation.position.longitude'];
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
updateMap(lat, lon, fields['navigation.headingTrue'], fields['meb.forecast.wind.direction'], fields['meb.waves.direction']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHybCard(key, def, val) {
|
function createHybCard(key, def, val) {
|
||||||
@@ -662,7 +704,8 @@ document.getElementById('downloadBtn').onclick = async () => {
|
|||||||
btn.textContent = '...';
|
btn.textContent = '...';
|
||||||
|
|
||||||
await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/flush`, { method: 'POST' });
|
await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/flush`, { method: 'POST' });
|
||||||
const csvUrl = `${REALTIME_URL}/sessions/${currentSensorId}/csv?from=${sessionStartTime}`;
|
const sessionParam = currentSessionId ? `&session=${currentSessionId}` : '';
|
||||||
|
const csvUrl = `${REALTIME_URL}/sessions/${currentSensorId}/csv?from=${sessionStartTime}${sessionParam}`;
|
||||||
const res = await fetch(csvUrl);
|
const res = await fetch(csvUrl);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
|
|
||||||
@@ -704,4 +747,33 @@ function showToast(msg) {
|
|||||||
}, 4500);
|
}, 4500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Session Label Popup ---
|
||||||
|
document.getElementById('sessionLabelBtn').onclick = () => {
|
||||||
|
document.getElementById('sessionLabelInput').value = '';
|
||||||
|
document.getElementById('sessionLabelOverlay').style.display = 'flex';
|
||||||
|
};
|
||||||
|
document.getElementById('cancelSessionLabelBtn').onclick = () => {
|
||||||
|
document.getElementById('sessionLabelOverlay').style.display = 'none';
|
||||||
|
};
|
||||||
|
document.getElementById('saveSessionLabelBtn').onclick = async () => {
|
||||||
|
const label = document.getElementById('sessionLabelInput').value.trim();
|
||||||
|
if (!label || !currentSensorId || !currentSessionId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/details`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session: currentSessionId, name: label })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('currentSessionLabel').textContent = label;
|
||||||
|
showToast(`Sessione rinominata: ${label}`);
|
||||||
|
} else {
|
||||||
|
showToast('Errore nel salvataggio');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Errore di connessione');
|
||||||
|
}
|
||||||
|
document.getElementById('sessionLabelOverlay').style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
598
console/src/pages/rulesets.html
Normal file
598
console/src/pages/rulesets.html
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="/static/styles/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/styles/rulesets.css">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="rs-page">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="rs-header">
|
||||||
|
<div class="rs-header-left">
|
||||||
|
<a href="/dashboard" class="rs-back">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</a>
|
||||||
|
<h1>Rulesets</h1>
|
||||||
|
<div class="rs-type-picker" id="typePicker">
|
||||||
|
<button class="active" data-type="weather">Weather</button>
|
||||||
|
<button data-type="laterforecasts">Forecasts</button>
|
||||||
|
<button data-type="data">Data</button>
|
||||||
|
<button data-type="logs">Logs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rs-header-right">
|
||||||
|
<span class="rs-saving" id="savingIndicator">Salvato</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="rs-toolbar">
|
||||||
|
<div class="rs-toolbar-left">
|
||||||
|
<button class="rs-filter-btn active" data-filter="all">Tutte</button>
|
||||||
|
<button class="rs-filter-btn" data-filter="active">Attive</button>
|
||||||
|
<button class="rs-filter-btn" data-filter="archived">Archiviate</button>
|
||||||
|
<select class="rs-sort-select" id="sortSelect">
|
||||||
|
<option value="created_at">Data creazione</option>
|
||||||
|
<option value="version">Versione</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="rs-toolbar-right">
|
||||||
|
<button class="rs-new-btn" id="newRuleBtn">+ Nuova Rule</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rules Grid -->
|
||||||
|
<div class="rs-grid" id="rulesGrid">
|
||||||
|
<div class="rs-empty">Caricamento...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rule Detail Popup -->
|
||||||
|
<div class="rs-overlay" id="ruleOverlay" style="display:none">
|
||||||
|
<div class="rs-popup">
|
||||||
|
<div class="rs-popup-header">
|
||||||
|
<div class="rs-popup-header-left">
|
||||||
|
<span class="rs-card-id" id="popupId"></span>
|
||||||
|
<span class="rs-saving" id="popupSaving">Salvato</span>
|
||||||
|
</div>
|
||||||
|
<button class="rs-popup-close" id="popupClose">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="rs-popup-body">
|
||||||
|
|
||||||
|
<!-- Version + Description -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-field-row">
|
||||||
|
<span class="rs-field-label">Versione</span>
|
||||||
|
<input class="rs-field-input" id="popupVersion" placeholder="1.0.0" />
|
||||||
|
</div>
|
||||||
|
<div class="rs-field-row" id="popupDescRow" style="display:none">
|
||||||
|
<span class="rs-field-label">Descrizione</span>
|
||||||
|
<textarea class="rs-field-textarea" id="popupDesc" placeholder="Descrizione opzionale..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-section-title">Tags</div>
|
||||||
|
<div class="rs-tags-wrap" id="popupTags">
|
||||||
|
<input class="rs-tag-input" id="tagInput" placeholder="Aggiungi tag..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-section-title">Azioni</div>
|
||||||
|
<div class="rs-actions">
|
||||||
|
<button class="rs-action-btn active-toggle" id="toggleActiveBtn">Attiva</button>
|
||||||
|
<button class="rs-action-btn archive-toggle" id="toggleArchiveBtn">Archivia</button>
|
||||||
|
<button class="rs-action-btn danger" id="deleteRuleBtn">Elimina</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div class="rs-section">
|
||||||
|
<div class="rs-items-header">
|
||||||
|
<div class="rs-section-title">Items</div>
|
||||||
|
<button class="rs-add-item-btn" id="addItemBtn">+ Aggiungi</button>
|
||||||
|
</div>
|
||||||
|
<div class="rs-item-labels" id="itemLabelsRow"></div>
|
||||||
|
<div id="itemsList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Dialog -->
|
||||||
|
<div class="rs-confirm-overlay" id="confirmOverlay" style="display:none">
|
||||||
|
<div class="rs-confirm-box">
|
||||||
|
<h3 id="confirmTitle">Conferma</h3>
|
||||||
|
<p id="confirmText">Sei sicuro?</p>
|
||||||
|
<div class="rs-confirm-actions">
|
||||||
|
<button id="confirmCancel">Annulla</button>
|
||||||
|
<button class="confirm-danger" id="confirmOk">Conferma</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_URL = '{{ apiUrl }}';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let currentType = 'weather';
|
||||||
|
let currentFilter = 'all';
|
||||||
|
let currentSort = 'created_at';
|
||||||
|
let allRules = [];
|
||||||
|
let openRule = null; // rule attualmente aperta nel popup
|
||||||
|
let saveTimers = {};
|
||||||
|
|
||||||
|
// --- Item field definitions per tipo ---
|
||||||
|
const ITEM_SCHEMA = {
|
||||||
|
weather: [
|
||||||
|
{ key: 'group_name', label: 'Gruppo', cls: 'medium' },
|
||||||
|
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||||
|
{ key: 'name', label: 'Nome', cls: 'wide' },
|
||||||
|
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
],
|
||||||
|
laterforecasts: [
|
||||||
|
{ key: 'group_name', label: 'Gruppo', cls: 'medium' },
|
||||||
|
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||||
|
{ key: 'name', label: 'Nome', cls: 'wide' },
|
||||||
|
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
],
|
||||||
|
data: [
|
||||||
|
{ key: 'category', label: 'Categoria', cls: 'medium' },
|
||||||
|
{ key: 'path', label: 'Path', cls: 'wide' },
|
||||||
|
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
],
|
||||||
|
logs: [
|
||||||
|
{ key: 'path', label: 'Path', cls: 'wide' },
|
||||||
|
{ key: 'ref', label: 'Ref', cls: 'narrow' },
|
||||||
|
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
||||||
|
{ key: 'measurement', label: 'Measurement', cls: 'medium' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const HAS_DESC = { weather: true, laterforecasts: true, data: false, logs: true };
|
||||||
|
|
||||||
|
// ========== API helpers ==========
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = { method, headers: {}, credentials: 'include' };
|
||||||
|
if (body) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_URL}${path}`, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Load & Render Rules ==========
|
||||||
|
|
||||||
|
async function loadRules() {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', '/rules');
|
||||||
|
allRules = data[currentType] || [];
|
||||||
|
renderGrid();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading rules:', err);
|
||||||
|
document.getElementById('rulesGrid').innerHTML = '<div class="rs-empty">Errore nel caricamento</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAndSort(rules) {
|
||||||
|
let filtered = rules;
|
||||||
|
if (currentFilter === 'active') filtered = rules.filter(r => r.active && !r.archived);
|
||||||
|
else if (currentFilter === 'archived') filtered = rules.filter(r => r.archived);
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
if (currentSort === 'version') return (b.version || '').localeCompare(a.version || '', undefined, { numeric: true });
|
||||||
|
return new Date(b.created_at) - new Date(a.created_at);
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('rulesGrid');
|
||||||
|
const rules = filterAndSort(allRules);
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="rs-empty">Nessuna rule trovata</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = rules.map(r => {
|
||||||
|
const badges = [];
|
||||||
|
if (r.active) badges.push('<span class="rs-badge active">Attiva</span>');
|
||||||
|
else badges.push('<span class="rs-badge inactive">Inattiva</span>');
|
||||||
|
if (r.archived) badges.push('<span class="rs-badge archived">Archiviata</span>');
|
||||||
|
|
||||||
|
const tags = (r.tags || []).map(t => `<span class="rs-tag">${esc(t)}</span>`).join('');
|
||||||
|
const desc = r.description ? `<div class="rs-card-desc">${esc(r.description)}</div>` : '';
|
||||||
|
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rs-card" data-id="${r.id}" onclick="openRuleDetail('${r.id}')">
|
||||||
|
<div class="rs-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="rs-card-version">v${esc(r.version)}</div>
|
||||||
|
<span class="rs-card-id">${esc(r.id)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rs-card-badges">${badges.join('')}</div>
|
||||||
|
</div>
|
||||||
|
${desc}
|
||||||
|
${tags ? `<div class="rs-card-tags">${tags}</div>` : ''}
|
||||||
|
<div class="rs-card-footer">
|
||||||
|
<span class="rs-card-date">${date}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = str;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Type Picker ==========
|
||||||
|
|
||||||
|
document.querySelectorAll('#typePicker button').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
document.querySelectorAll('#typePicker button').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentType = btn.dataset.type;
|
||||||
|
loadRules();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Filters ==========
|
||||||
|
|
||||||
|
document.querySelectorAll('.rs-filter-btn').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
document.querySelectorAll('.rs-filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentFilter = btn.dataset.filter;
|
||||||
|
renderGrid();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sortSelect').onchange = (e) => {
|
||||||
|
currentSort = e.target.value;
|
||||||
|
renderGrid();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== New Rule ==========
|
||||||
|
|
||||||
|
document.getElementById('newRuleBtn').onclick = async () => {
|
||||||
|
try {
|
||||||
|
const rule = await api('POST', `/rules/${currentType}`, {
|
||||||
|
version: '1.0.0',
|
||||||
|
tags: [],
|
||||||
|
description: HAS_DESC[currentType] ? '' : undefined
|
||||||
|
});
|
||||||
|
allRules.unshift(rule);
|
||||||
|
renderGrid();
|
||||||
|
openRuleDetail(rule.id);
|
||||||
|
flash('Salvato');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating rule:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Rule Detail Popup ==========
|
||||||
|
|
||||||
|
async function openRuleDetail(ruleId) {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', `/rules/${currentType}/${ruleId}`);
|
||||||
|
openRule = data;
|
||||||
|
renderPopup();
|
||||||
|
document.getElementById('ruleOverlay').style.display = 'flex';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading rule detail:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopup() {
|
||||||
|
document.getElementById('ruleOverlay').style.display = 'none';
|
||||||
|
openRule = null;
|
||||||
|
loadRules(); // refresh grid
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('popupClose').onclick = closePopup;
|
||||||
|
document.getElementById('ruleOverlay').onclick = (e) => {
|
||||||
|
if (e.target === document.getElementById('ruleOverlay')) closePopup();
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderPopup() {
|
||||||
|
const r = openRule;
|
||||||
|
document.getElementById('popupId').textContent = r.id;
|
||||||
|
document.getElementById('popupVersion').value = r.version || '';
|
||||||
|
|
||||||
|
// Description
|
||||||
|
const descRow = document.getElementById('popupDescRow');
|
||||||
|
if (HAS_DESC[currentType]) {
|
||||||
|
descRow.style.display = 'flex';
|
||||||
|
document.getElementById('popupDesc').value = r.description || '';
|
||||||
|
} else {
|
||||||
|
descRow.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
renderTags();
|
||||||
|
|
||||||
|
// Action buttons state
|
||||||
|
updateActionButtons();
|
||||||
|
|
||||||
|
// Items
|
||||||
|
renderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto-save fields ---
|
||||||
|
|
||||||
|
document.getElementById('popupVersion').oninput = () => debounceFieldSave('version');
|
||||||
|
document.getElementById('popupDesc').oninput = () => debounceFieldSave('description');
|
||||||
|
|
||||||
|
function debounceFieldSave(field) {
|
||||||
|
clearTimeout(saveTimers[field]);
|
||||||
|
saveTimers[field] = setTimeout(() => saveRuleField(field), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRuleField(field) {
|
||||||
|
if (!openRule) return;
|
||||||
|
const body = {};
|
||||||
|
if (field === 'version') body.version = document.getElementById('popupVersion').value.trim();
|
||||||
|
if (field === 'description') body.description = document.getElementById('popupDesc').value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, body);
|
||||||
|
Object.assign(openRule, updated);
|
||||||
|
// Update in allRules too
|
||||||
|
const idx = allRules.findIndex(r => r.id === openRule.id);
|
||||||
|
if (idx >= 0) Object.assign(allRules[idx], updated);
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving field:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tags ---
|
||||||
|
|
||||||
|
function renderTags() {
|
||||||
|
const wrap = document.getElementById('popupTags');
|
||||||
|
const input = document.getElementById('tagInput');
|
||||||
|
// Remove old chips
|
||||||
|
wrap.querySelectorAll('.rs-tag-chip').forEach(c => c.remove());
|
||||||
|
// Re-add chips before input
|
||||||
|
(openRule.tags || []).forEach((tag, i) => {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'rs-tag-chip';
|
||||||
|
chip.innerHTML = `${esc(tag)}<button data-idx="${i}">×</button>`;
|
||||||
|
chip.querySelector('button').onclick = () => removeTag(i);
|
||||||
|
wrap.insertBefore(chip, input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tagInput').onkeydown = async (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = e.target.value.trim().replace(/,$/, '');
|
||||||
|
if (!val || !openRule) return;
|
||||||
|
const tags = [...(openRule.tags || []), val];
|
||||||
|
e.target.value = '';
|
||||||
|
try {
|
||||||
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, { tags });
|
||||||
|
openRule.tags = updated.tags;
|
||||||
|
renderTags();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding tag:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function removeTag(idx) {
|
||||||
|
if (!openRule) return;
|
||||||
|
const tags = [...(openRule.tags || [])];
|
||||||
|
tags.splice(idx, 1);
|
||||||
|
try {
|
||||||
|
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, { tags });
|
||||||
|
openRule.tags = updated.tags;
|
||||||
|
renderTags();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error removing tag:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action Buttons ---
|
||||||
|
|
||||||
|
function updateActionButtons() {
|
||||||
|
const r = openRule;
|
||||||
|
const activeBtn = document.getElementById('toggleActiveBtn');
|
||||||
|
const archiveBtn = document.getElementById('toggleArchiveBtn');
|
||||||
|
|
||||||
|
activeBtn.textContent = r.active ? 'Disattiva' : 'Attiva';
|
||||||
|
activeBtn.className = `rs-action-btn active-toggle${r.active ? '' : ' off'}`;
|
||||||
|
|
||||||
|
archiveBtn.textContent = r.archived ? 'Dearchivia' : 'Archivia';
|
||||||
|
archiveBtn.className = `rs-action-btn archive-toggle${r.archived ? ' on' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('toggleActiveBtn').onclick = async () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
try {
|
||||||
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/active`);
|
||||||
|
openRule.active = res.active;
|
||||||
|
updateActionButtons();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling active:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('toggleArchiveBtn').onclick = async () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
try {
|
||||||
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/archive`);
|
||||||
|
openRule.archived = res.archived;
|
||||||
|
updateActionButtons();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling archive:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('deleteRuleBtn').onclick = () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
showConfirm('Elimina Rule', `Vuoi eliminare la rule ${openRule.id}? Questa azione e irreversibile.`, async () => {
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/rules/${currentType}/${openRule.id}`);
|
||||||
|
allRules = allRules.filter(r => r.id !== openRule.id);
|
||||||
|
closePopup();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting rule:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Items ---
|
||||||
|
|
||||||
|
function renderItems() {
|
||||||
|
const schema = ITEM_SCHEMA[currentType];
|
||||||
|
const items = openRule.items || [];
|
||||||
|
|
||||||
|
// Labels row
|
||||||
|
const labelsRow = document.getElementById('itemLabelsRow');
|
||||||
|
labelsRow.innerHTML = schema.map(f => `<span class="${f.cls}">${f.label}</span>`).join('') +
|
||||||
|
'<span class="toggle-space">On</span><span class="delete-space"></span>';
|
||||||
|
|
||||||
|
// Items list
|
||||||
|
const list = document.getElementById('itemsList');
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi" per crearne uno.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = items.map(item => {
|
||||||
|
const fields = schema.map(f =>
|
||||||
|
`<input class="rs-item-field ${f.cls}" value="${esc(String(item[f.key] || ''))}" data-field="${f.key}" data-item-id="${item.id}" onchange="saveItemField(this)" />`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const toggleCls = item.enabled ? 'on' : '';
|
||||||
|
return `<div class="rs-item" data-item-id="${item.id}">
|
||||||
|
${fields}
|
||||||
|
<div class="rs-toggle ${toggleCls}" onclick="toggleItem(${item.id})"></div>
|
||||||
|
<button class="rs-item-delete" onclick="deleteItem(${item.id})">×</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addItemBtn').onclick = async () => {
|
||||||
|
if (!openRule) return;
|
||||||
|
const schema = ITEM_SCHEMA[currentType];
|
||||||
|
const body = {};
|
||||||
|
// Fill with empty/default values
|
||||||
|
schema.forEach(f => { body[f.key] = ''; });
|
||||||
|
|
||||||
|
// Need at least non-empty values — open with placeholders
|
||||||
|
// For now, create with placeholder values
|
||||||
|
schema.forEach(f => { body[f.key] = f.key === 'enabled' ? true : '-'; });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, body);
|
||||||
|
if (!openRule.items) openRule.items = [];
|
||||||
|
openRule.items.push(item);
|
||||||
|
renderItems();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding item:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function saveItemField(input) {
|
||||||
|
if (!openRule) return;
|
||||||
|
const itemId = input.dataset.itemId;
|
||||||
|
const field = input.dataset.field;
|
||||||
|
const value = input.value.trim();
|
||||||
|
try {
|
||||||
|
await api('PUT', `/rules/${currentType}/${openRule.id}/items/${itemId}`, { [field]: value });
|
||||||
|
// Update local
|
||||||
|
const item = openRule.items.find(i => String(i.id) === String(itemId));
|
||||||
|
if (item) item[field] = value;
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving item field:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleItem(itemId) {
|
||||||
|
if (!openRule) return;
|
||||||
|
try {
|
||||||
|
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${itemId}/toggle`);
|
||||||
|
const item = openRule.items.find(i => i.id === itemId);
|
||||||
|
if (item) item.enabled = res.enabled;
|
||||||
|
renderItems();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling item:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(itemId) {
|
||||||
|
if (!openRule) return;
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${itemId}`);
|
||||||
|
openRule.items = openRule.items.filter(i => i.id !== itemId);
|
||||||
|
renderItems();
|
||||||
|
flash('Salvato', 'popupSaving');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting item:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Confirm Dialog ==========
|
||||||
|
|
||||||
|
let confirmCallback = null;
|
||||||
|
|
||||||
|
function showConfirm(title, text, onConfirm) {
|
||||||
|
document.getElementById('confirmTitle').textContent = title;
|
||||||
|
document.getElementById('confirmText').textContent = text;
|
||||||
|
confirmCallback = onConfirm;
|
||||||
|
document.getElementById('confirmOverlay').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('confirmCancel').onclick = () => {
|
||||||
|
document.getElementById('confirmOverlay').style.display = 'none';
|
||||||
|
confirmCallback = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('confirmOk').onclick = async () => {
|
||||||
|
document.getElementById('confirmOverlay').style.display = 'none';
|
||||||
|
if (confirmCallback) await confirmCallback();
|
||||||
|
confirmCallback = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Flash "Salvato" indicator ==========
|
||||||
|
|
||||||
|
function flash(text, elId = 'savingIndicator') {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.add('visible');
|
||||||
|
setTimeout(() => el.classList.remove('visible'), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Init ==========
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => loadRules());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
922
console/src/pages/sessions.html
Normal file
922
console/src/pages/sessions.html
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="/static/styles/style.css">
|
||||||
|
<link href="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.css" rel="stylesheet">
|
||||||
|
<script src="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
<style>
|
||||||
|
/* === Layout === */
|
||||||
|
html, body { height: 100%; overflow: hidden; background: var(--surface); }
|
||||||
|
.page-wrap { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
|
||||||
|
/* === Header === */
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--header-bg); border-bottom: 1px solid var(--header-border);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
flex-shrink: 0; z-index: 100;
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.header-left h1 { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
#changeSessionBtn { display: none; padding: 8px 16px; font-size: 13px; }
|
||||||
|
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||||
|
#sessionNameDisplay { font-size: 0.95rem; font-weight: 700; color: var(--text-primary); display: none; }
|
||||||
|
#sessionMetaDisplay { font-size: 0.75rem; color: var(--text-secondary); display: none; }
|
||||||
|
|
||||||
|
/* === Session Popup Overlay === */
|
||||||
|
.session-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 2000;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
background: rgba(15,23,42,0.4);
|
||||||
|
}
|
||||||
|
.session-popup {
|
||||||
|
background: var(--header-bg, #fff); border: 1px solid var(--header-border);
|
||||||
|
border-radius: 20px; padding: 28px; width: 860px; max-width: 95vw;
|
||||||
|
max-height: 82vh; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.popup-head { margin-bottom: 16px; }
|
||||||
|
.popup-head h2 { font-size: 1.15rem; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.popup-head p { font-size: 0.82rem; color: var(--text-secondary); }
|
||||||
|
.popup-filters {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.popup-filters input {
|
||||||
|
flex: 1; padding: 9px 14px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--header-border); background: var(--surface);
|
||||||
|
font-family: inherit; font-size: 13px; color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.popup-filters input:focus { border-color: var(--accent-color); }
|
||||||
|
.popup-filters select {
|
||||||
|
padding: 9px 14px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--header-border); background: var(--surface);
|
||||||
|
font-family: inherit; font-size: 13px; color: var(--text-primary);
|
||||||
|
outline: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.session-grid {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 12px; padding-right: 4px;
|
||||||
|
}
|
||||||
|
.sess-card {
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface); padding: 16px; cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.sess-card:hover {
|
||||||
|
border-color: var(--accent-color); transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(37,99,235,0.12);
|
||||||
|
}
|
||||||
|
.sess-card-name { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.sess-card-id { font-size: 0.72rem; color: var(--text-tertiary); font-family: monospace; }
|
||||||
|
.sess-card-sensor { font-size: 0.78rem; color: var(--text-secondary); }
|
||||||
|
.sess-card-dates { font-size: 0.75rem; color: var(--text-secondary); }
|
||||||
|
.sess-card-duration { font-size: 0.78rem; font-weight: 600; color: var(--accent-color); }
|
||||||
|
.sess-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||||
|
.sess-tag {
|
||||||
|
font-size: 0.68rem; padding: 2px 8px; border-radius: 20px;
|
||||||
|
background: var(--accent-light); color: var(--accent-color);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
}
|
||||||
|
.sess-empty { grid-column: 1/-1; text-align: center; color: var(--text-secondary); padding: 40px; }
|
||||||
|
|
||||||
|
/* === Detail Panel === */
|
||||||
|
.detail-panel {
|
||||||
|
flex: 1; display: none; flex-direction: column;
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.detail-panel.visible { display: flex; }
|
||||||
|
|
||||||
|
/* === Map === */
|
||||||
|
.map-section {
|
||||||
|
flex-shrink: 0; height: 280px; position: relative; border-bottom: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
#sessionMap { width: 100%; height: 100%; }
|
||||||
|
.no-gps-msg {
|
||||||
|
display: none; position: absolute; inset: 0; align-items: center; justify-content: center;
|
||||||
|
background: var(--surface); color: var(--text-secondary); font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Data section (scrollable) === */
|
||||||
|
.data-section {
|
||||||
|
flex: 1; overflow-y: auto; padding: 16px 24px 140px;
|
||||||
|
}
|
||||||
|
.data-controls {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center;
|
||||||
|
}
|
||||||
|
.search-field {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: var(--header-bg); border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg); padding: 8px 14px; flex: 1; min-width: 140px;
|
||||||
|
}
|
||||||
|
.search-field input {
|
||||||
|
border: none; background: transparent; font-family: inherit; font-size: 13px;
|
||||||
|
color: var(--text-primary); outline: none; width: 100%;
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
display: flex; gap: 4px; background: var(--header-bg);
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.filter button { padding: 6px 12px; border-radius: 10px; font-size: 12px; border: none; }
|
||||||
|
.filter button.active {
|
||||||
|
background: var(--accent-color); color: #fff; border-color: transparent;
|
||||||
|
}
|
||||||
|
.data-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Data Cards === */
|
||||||
|
.data-card {
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
background: white; padding: 14px; display: flex; flex-direction: column; gap: 8px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.card-top { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||||
|
.card-info h4 { font-size: 0.78rem; color: var(--text-secondary); font-weight: 600; }
|
||||||
|
.card-action-btn {
|
||||||
|
padding: 4px; border-radius: 6px; border: none; background: transparent;
|
||||||
|
color: var(--text-secondary); cursor: pointer; opacity: 0.6; transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.card-action-btn:hover { opacity: 1; background: var(--accent-light); color: var(--accent-color); }
|
||||||
|
.card-body { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.card-values { display: flex; align-items: baseline; gap: 4px; }
|
||||||
|
.card-main-val { font-size: 1.4rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.card-unit { font-size: 0.75rem; color: var(--text-secondary); }
|
||||||
|
.card-mini-chart { height: 44px; position: relative; }
|
||||||
|
.card-mini-chart canvas { width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* === Expanded Chart === */
|
||||||
|
.expanded-chart-container {
|
||||||
|
display: none; position: fixed; top: 72px; left: 24px; right: 24px; bottom: 110px;
|
||||||
|
background: white; border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg); z-index: 500;
|
||||||
|
flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.expanded-chart-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 18px; border-bottom: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
.expanded-chart-header h3 { font-size: 0.95rem; font-weight: 700; }
|
||||||
|
.close-expanded-btn {
|
||||||
|
padding: 4px 10px; border-radius: 8px; border: none; font-size: 18px;
|
||||||
|
background: transparent; cursor: pointer; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.expanded-chart-body { flex: 1; padding: 16px; position: relative; }
|
||||||
|
.expanded-chart-body canvas { width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* === Timeline Bar === */
|
||||||
|
.timeline-bar {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
|
||||||
|
background: var(--header-bg); border-top: 1px solid var(--header-border);
|
||||||
|
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||||
|
display: none; align-items: center; gap: 16px;
|
||||||
|
padding: 12px 20px; min-height: 84px; flex-direction: column;
|
||||||
|
}
|
||||||
|
.timeline-bar.visible { display: flex; }
|
||||||
|
.tl-row { display: flex; align-items: center; gap: 12px; width: 100%; }
|
||||||
|
.tl-track-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.tl-track {
|
||||||
|
position: relative; height: 6px; border-radius: 3px;
|
||||||
|
background: #334155; cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.tl-fill-inner {
|
||||||
|
position: absolute; top: 0; height: 100%; background: var(--accent-color);
|
||||||
|
border-radius: 3px; pointer-events: none;
|
||||||
|
}
|
||||||
|
.tl-handle {
|
||||||
|
position: absolute; top: 50%; width: 18px; height: 18px;
|
||||||
|
background: white; border: 2px solid var(--accent-color);
|
||||||
|
border-radius: 50%; transform: translate(-50%, -50%);
|
||||||
|
cursor: grab; box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
transition: box-shadow 0.15s; z-index: 2;
|
||||||
|
}
|
||||||
|
.tl-handle:active { cursor: grabbing; box-shadow: 0 3px 10px rgba(37,99,235,0.35); }
|
||||||
|
.tl-handle.hidden { display: none; }
|
||||||
|
.tl-label-wrap {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.tl-label-start, .tl-label-end { font-size: 0.68rem; color: var(--text-secondary); }
|
||||||
|
.tl-label-current { font-size: 0.72rem; font-weight: 700; color: var(--accent-color); }
|
||||||
|
.tl-btn { padding: 8px 16px; font-size: 12px; flex-shrink: 0; }
|
||||||
|
#restrictBtn.active { background: var(--accent-color); color: white; border-color: transparent; }
|
||||||
|
.tl-loading { font-size: 0.8rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* === Loading overlay === */
|
||||||
|
.loading-overlay {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 1500;
|
||||||
|
background: rgba(248,250,252,0.85); backdrop-filter: blur(4px);
|
||||||
|
align-items: center; justify-content: center; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.loading-overlay.visible { display: flex; }
|
||||||
|
.loading-spinner {
|
||||||
|
width: 36px; height: 36px; border: 3px solid var(--accent-border);
|
||||||
|
border-top-color: var(--accent-color); border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.loading-text { font-size: 0.9rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* === Toast === */
|
||||||
|
#dl-toast {
|
||||||
|
position: fixed; bottom: 96px; right: 24px;
|
||||||
|
background: rgba(255,255,255,0.95); padding: 14px 18px;
|
||||||
|
border-radius: var(--radius-lg); border: 1px solid var(--header-border);
|
||||||
|
box-shadow: var(--shadow-md); color: var(--text-primary);
|
||||||
|
font-size: 13px; font-weight: 600; z-index: 9999;
|
||||||
|
backdrop-filter: blur(10px); transform: translateY(120px); opacity: 0;
|
||||||
|
transition: all 0.4s cubic-bezier(0.8,0,0.2,1); pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Map secondary bar === */
|
||||||
|
.map-bar {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||||
|
background: rgba(15,23,42,0.6); backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.map-bar .filter button { color: #cbd5e1; }
|
||||||
|
.map-bar .filter button.active { background: #3b82f6; color: #fff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page-wrap">
|
||||||
|
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-text" id="loadingText">Caricamento dati sessione...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session picker popup -->
|
||||||
|
<div class="session-overlay" id="sessionOverlay">
|
||||||
|
<div class="session-popup">
|
||||||
|
<div class="popup-head">
|
||||||
|
<h2>Seleziona una sessione</h2>
|
||||||
|
<p>Scegli una sessione di registrazione da analizzare</p>
|
||||||
|
</div>
|
||||||
|
<div class="popup-filters">
|
||||||
|
<input type="text" id="popupSearch" placeholder="Cerca per nome o sensore...">
|
||||||
|
<select id="popupSensorFilter"><option value="">Tutti i sensori</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="session-grid" id="sessionGrid">
|
||||||
|
<div class="sess-empty">Caricamento sessioni...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button id="changeSessionBtn">← Cambia sessione</button>
|
||||||
|
<h1>Sessioni</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span id="sessionNameDisplay"></span>
|
||||||
|
<span id="sessionMetaDisplay"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail panel -->
|
||||||
|
<div class="detail-panel" id="detailPanel">
|
||||||
|
|
||||||
|
<!-- Map -->
|
||||||
|
<div class="map-section" id="mapSection">
|
||||||
|
<div id="sessionMap"></div>
|
||||||
|
<div class="no-gps-msg" id="noGpsMsg">Nessun dato GPS per questa sessione</div>
|
||||||
|
<div class="map-bar" id="mapBar" style="display:none">
|
||||||
|
<div class="filter" style="background:transparent;border:none;">
|
||||||
|
<button class="active" data-zoom="10" style="font-size:11px;padding:4px 10px;">10x</button>
|
||||||
|
<button data-zoom="5" style="font-size:11px;padding:4px 10px;">5x</button>
|
||||||
|
<button data-zoom="1" style="font-size:11px;padding:4px 10px;">1x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data section -->
|
||||||
|
<div class="data-section" id="dataSection">
|
||||||
|
<div class="data-controls">
|
||||||
|
<div class="search-field">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 12 12" fill="none"><path d="M11.0835 11.0834L8.57516 8.57504M9.91683 5.25004C9.91683 7.82737 7.82749 9.91671 5.25016 9.91671C2.67283 9.91671 0.583496 7.82737 0.583496 5.25004C0.583496 2.67271 2.67283 0.583374 5.25016 0.583374C7.82749 0.583374 9.91683 2.67271 9.91683 5.25004Z" stroke="var(--text-secondary)" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<input type="text" id="dataSearch" placeholder="Cerca campo...">
|
||||||
|
</div>
|
||||||
|
<div class="filter" id="catFilter">
|
||||||
|
<button class="active" data-cat="all">Tutto</button>
|
||||||
|
<button data-cat="weather">Meteo</button>
|
||||||
|
<button data-cat="navigation">Navigazione</button>
|
||||||
|
<button data-cat="engine">Motore</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-grid" id="dataGrid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded chart -->
|
||||||
|
<div class="expanded-chart-container" id="expandedChartContainer">
|
||||||
|
<div class="expanded-chart-header">
|
||||||
|
<h3 id="expChartTitle">Dettaglio</h3>
|
||||||
|
<button class="close-expanded-btn" id="closeExpBtn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="expanded-chart-body">
|
||||||
|
<canvas id="expandedChartCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline bar -->
|
||||||
|
<div class="timeline-bar" id="timelineBar">
|
||||||
|
<div class="tl-row">
|
||||||
|
<button class="tl-btn" id="restrictBtn">Restringi</button>
|
||||||
|
<div class="tl-track-wrap">
|
||||||
|
<div class="tl-track" id="tlTrack">
|
||||||
|
<div class="tl-fill-inner" id="tlFill"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleLeft" style="left:0%"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleSingle" style="left:100%"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleRight" style="left:100%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-label-wrap">
|
||||||
|
<span class="tl-label-start" id="tlLabelStart"></span>
|
||||||
|
<span class="tl-label-current" id="tlLabelCurrent"></span>
|
||||||
|
<span class="tl-label-end" id="tlLabelEnd"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="tl-btn" id="downloadBtn">Scarica</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dl-toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Config (injected by Nunjucks) ---
|
||||||
|
const API_URL = '{{ apiUrl }}';
|
||||||
|
mapboxgl.accessToken = '{{ mapboxToken }}';
|
||||||
|
|
||||||
|
// --- FIELD_DEFS (same as live.html) ---
|
||||||
|
const FIELD_DEFS = {
|
||||||
|
'meb.forecasts.temperature': { name: 'Temperatura', unit: '°C', category: 'weather' },
|
||||||
|
'meb.forecast.wind.speed': { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
|
||||||
|
'meb.forecast.wind.direction': { name: 'Direzione Vento', unit: '°', category: 'weather' },
|
||||||
|
'meb.forecast.wind.gusts': { name: 'Raffiche', unit: 'km/h', category: 'weather' },
|
||||||
|
'meb.forecast.humidity': { name: 'Umidita', unit: '%', category: 'weather' },
|
||||||
|
'meb.forecast.pressure': { name: 'Pressione', unit: 'hPa', category: 'weather' },
|
||||||
|
'meb.forecast.precipitation': { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
|
||||||
|
'meb.forecast.rain': { name: 'Pioggia', unit: 'mm', category: 'weather' },
|
||||||
|
'meb.forecast.cloudCover': { name: 'Copertura Nuvole', unit: '%', category: 'weather' },
|
||||||
|
'meb.forecast.precipitationProbability': { name: 'Prob. Precipitazioni', unit: '%', category: 'weather' },
|
||||||
|
'meb.waves.height': { name: 'Altezza Onde', unit: 'm', category: 'weather' },
|
||||||
|
'meb.waves.direction': { name: 'Direzione Onde', unit: '°', category: 'weather' },
|
||||||
|
'meb.waves.period': { name: 'Periodo Onde', unit: 's', category: 'weather' },
|
||||||
|
'meb.waves.peakPeriod': { name: 'Periodo Picco', unit: 's', category: 'weather' },
|
||||||
|
'meb.waves.currentVelocity': { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
|
||||||
|
'meb.waves.currentDirection': { name: 'Dir. Corrente', unit: '°', category: 'weather' },
|
||||||
|
'navigation.position.latitude': { name: 'Latitudine', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.position.longitude': { name: 'Longitudine', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.headingTrue': { name: 'Heading', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.speedOverGround': { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
|
||||||
|
'navigation.courseOverGroundTrue': { name: 'Rotta COG', unit: '°', category: 'navigation' },
|
||||||
|
'electrical.batteries.service.Voltage': { name: 'Batteria Serv. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.service.current': { name: 'Batteria Serv. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.service.stateOfCharge': { name: 'Batteria Serv. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.Voltage': { name: 'Batteria Traz. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.current': { name: 'Batteria Traz. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.stateOfCharge': { name: 'Batteria Traz. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.temperature': { name: 'Batteria Traz. Temp', unit: '°C', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.power': { name: 'Batteria Traz. W', unit: 'W', category: 'engine' },
|
||||||
|
'propulsion.0.revolutions': { name: 'Giri Motore', unit: 'RPM', category: 'engine' },
|
||||||
|
'system.uptime': { name: 'Uptime', unit: 's', category: 'engine' },
|
||||||
|
};
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'rgba(59,130,246,1)', 'rgba(16,185,129,1)', 'rgba(245,158,11,1)',
|
||||||
|
'rgba(239,68,68,1)', 'rgba(139,92,246,1)', 'rgba(236,72,153,1)',
|
||||||
|
];
|
||||||
|
const TICK_COLOR = '#94a3b8';
|
||||||
|
const GRID_COLOR = 'rgba(148,163,184,0.08)';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let sessionRows = []; // [{_time, field: val, ...}]
|
||||||
|
let sessionTimes = []; // ms timestamps (sorted)
|
||||||
|
let positionData = []; // [{ts, lat, lon}]
|
||||||
|
let fieldColorMap = {};
|
||||||
|
let colorIdx = 0;
|
||||||
|
let miniCharts = {};
|
||||||
|
let expChart = null;
|
||||||
|
let expActiveField = null;
|
||||||
|
let currentSensorId = null;
|
||||||
|
let currentSessionId = null;
|
||||||
|
let currentSessionMeta = null;
|
||||||
|
let tStart = 0, tEnd = 0;
|
||||||
|
let currentT = 0;
|
||||||
|
let restrictMode = false;
|
||||||
|
let restrictStart = 0, restrictEnd = 0;
|
||||||
|
let activeCategory = 'all';
|
||||||
|
let searchQuery = '';
|
||||||
|
let mapbox = null;
|
||||||
|
let mapDot = null;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
function fmtDuration(ms) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s/60)}m ${s%60}s`;
|
||||||
|
return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
|
||||||
|
}
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString('it-IT', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||||
|
}
|
||||||
|
function fmtTime(ms) {
|
||||||
|
return new Date(ms).toLocaleTimeString('it-IT', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
||||||
|
}
|
||||||
|
function getFieldColor(key) {
|
||||||
|
if (!fieldColorMap[key]) {
|
||||||
|
fieldColorMap[key] = CHART_COLORS[colorIdx % CHART_COLORS.length];
|
||||||
|
colorIdx++;
|
||||||
|
}
|
||||||
|
return fieldColorMap[key];
|
||||||
|
}
|
||||||
|
function showToast(msg) {
|
||||||
|
const t = document.getElementById('dl-toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.style.transform = 'translateY(0)'; t.style.opacity = '1';
|
||||||
|
setTimeout(() => { t.style.transform = 'translateY(120px)'; t.style.opacity = '0'; }, 4500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary search: nearest index in sessionTimes to ts
|
||||||
|
function nearestIdx(ts) {
|
||||||
|
if (!sessionTimes.length) return -1;
|
||||||
|
let lo = 0, hi = sessionTimes.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (sessionTimes[mid] < ts) lo = mid + 1; else hi = mid;
|
||||||
|
}
|
||||||
|
if (lo > 0 && Math.abs(sessionTimes[lo-1]-ts) < Math.abs(sessionTimes[lo]-ts)) return lo - 1;
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load sessions list ---
|
||||||
|
async function loadSessionsList() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/sessions/history`, { credentials: 'include' });
|
||||||
|
const sessions = await res.json();
|
||||||
|
renderSessionGrid(sessions);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('sessionGrid').innerHTML = '<div class="sess-empty">Errore nel caricamento sessioni.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allSessions = [];
|
||||||
|
function renderSessionGrid(sessions) {
|
||||||
|
allSessions = sessions;
|
||||||
|
const sensors = [...new Set(sessions.map(s => s.sensor_name).filter(Boolean))];
|
||||||
|
const selEl = document.getElementById('popupSensorFilter');
|
||||||
|
selEl.innerHTML = '<option value="">Tutti i sensori</option>';
|
||||||
|
sensors.forEach(s => { const o = document.createElement('option'); o.value = s; o.textContent = s; selEl.appendChild(o); });
|
||||||
|
filterSessionGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSessionGrid() {
|
||||||
|
const q = document.getElementById('popupSearch').value.toLowerCase();
|
||||||
|
const sensor = document.getElementById('popupSensorFilter').value;
|
||||||
|
const filtered = allSessions.filter(s => {
|
||||||
|
const name = (s.name || s.session_id || '').toLowerCase();
|
||||||
|
const sname = (s.sensor_name || '').toLowerCase();
|
||||||
|
const matchQ = !q || name.includes(q) || sname.includes(q);
|
||||||
|
const matchSensor = !sensor || s.sensor_name === sensor;
|
||||||
|
return matchQ && matchSensor;
|
||||||
|
});
|
||||||
|
const grid = document.getElementById('sessionGrid');
|
||||||
|
if (!filtered.length) { grid.innerHTML = '<div class="sess-empty">Nessuna sessione trovata.</div>'; return; }
|
||||||
|
grid.innerHTML = '';
|
||||||
|
filtered.forEach(s => {
|
||||||
|
const start = s.startTime ? new Date(s.startTime).getTime() : null;
|
||||||
|
const end = s.endTime ? new Date(s.endTime).getTime() : null;
|
||||||
|
const dur = start && end ? fmtDuration(end - start) : (start ? 'In corso' : '—');
|
||||||
|
const tags = Array.isArray(s.tags) ? s.tags : [];
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'sess-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="sess-card-name">${s.name || s.session_id || '—'}</div>
|
||||||
|
<div class="sess-card-id">${s.session_id || ''}</div>
|
||||||
|
<div class="sess-card-sensor">${s.sensor_name || '—'}</div>
|
||||||
|
<div class="sess-card-dates">${fmtDate(s.startTime)}</div>
|
||||||
|
${end ? `<div class="sess-card-dates">${fmtDate(s.endTime)}</div>` : ''}
|
||||||
|
<div class="sess-card-duration">${dur}</div>
|
||||||
|
${tags.length ? `<div class="sess-tags">${tags.map(t=>`<span class="sess-tag">${t}</span>`).join('')}</div>` : ''}
|
||||||
|
`;
|
||||||
|
card.onclick = () => selectSession(s);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('popupSearch').oninput = filterSessionGrid;
|
||||||
|
document.getElementById('popupSensorFilter').onchange = filterSessionGrid;
|
||||||
|
|
||||||
|
// --- Select session ---
|
||||||
|
async function selectSession(meta) {
|
||||||
|
currentSensorId = meta.sensor_name;
|
||||||
|
currentSessionId = meta.session_id;
|
||||||
|
currentSessionMeta = meta;
|
||||||
|
document.getElementById('sessionOverlay').style.display = 'none';
|
||||||
|
document.getElementById('changeSessionBtn').style.display = '';
|
||||||
|
document.getElementById('sessionNameDisplay').style.display = '';
|
||||||
|
document.getElementById('sessionMetaDisplay').style.display = '';
|
||||||
|
document.getElementById('sessionNameDisplay').textContent = meta.name || meta.session_id;
|
||||||
|
document.getElementById('sessionMetaDisplay').textContent = `${meta.sensor_name || ''} • ${meta.session_id}`;
|
||||||
|
document.getElementById('detailPanel').classList.add('visible');
|
||||||
|
document.getElementById('timelineBar').classList.add('visible');
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
sessionRows = []; sessionTimes = []; positionData = [];
|
||||||
|
fieldColorMap = {}; colorIdx = 0;
|
||||||
|
Object.values(miniCharts).forEach(c => c.destroy());
|
||||||
|
miniCharts = {};
|
||||||
|
if (expChart) { expChart.destroy(); expChart = null; expActiveField = null; }
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'none';
|
||||||
|
document.getElementById('dataGrid').innerHTML = '';
|
||||||
|
|
||||||
|
await loadSessionData(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionData(meta) {
|
||||||
|
document.getElementById('loadingText').textContent = 'Caricamento dati sessione...';
|
||||||
|
document.getElementById('loadingOverlay').classList.add('visible');
|
||||||
|
try {
|
||||||
|
const from = meta.startTime ? new Date(meta.startTime).toISOString() : null;
|
||||||
|
const params = new URLSearchParams({ session: meta.session_id });
|
||||||
|
if (from) params.set('from', from);
|
||||||
|
const res = await fetch(`${API_URL}/sessions/${encodeURIComponent(meta.sensor_name)}/data?${params}`, { credentials: 'include' });
|
||||||
|
sessionRows = await res.json();
|
||||||
|
|
||||||
|
if (!sessionRows.length) { showToast('Nessun dato trovato per questa sessione'); return; }
|
||||||
|
|
||||||
|
sessionTimes = sessionRows.map(r => new Date(r._time).getTime());
|
||||||
|
tStart = sessionTimes[0]; tEnd = sessionTimes[sessionTimes.length - 1];
|
||||||
|
currentT = tEnd;
|
||||||
|
restrictStart = tStart; restrictEnd = tEnd;
|
||||||
|
|
||||||
|
positionData = sessionRows
|
||||||
|
.map((r, i) => ({ ts: sessionTimes[i], lat: r['navigation.position.latitude'], lon: r['navigation.position.longitude'] }))
|
||||||
|
.filter(p => p.lat != null && p.lon != null);
|
||||||
|
|
||||||
|
buildGrid();
|
||||||
|
initMap();
|
||||||
|
initTimeline();
|
||||||
|
updateGrid(currentT);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Errore nel caricamento dei dati');
|
||||||
|
} finally {
|
||||||
|
document.getElementById('loadingOverlay').classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grid ---
|
||||||
|
function getFieldsFromRows() {
|
||||||
|
const meta = new Set(['result', 'table', '_start', '_stop', '_measurement', '_time', 'sensor', 'session', '']);
|
||||||
|
const fields = new Set();
|
||||||
|
sessionRows.forEach(r => Object.keys(r).forEach(k => { if (!meta.has(k)) fields.add(k); }));
|
||||||
|
return [...fields];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGrid() {
|
||||||
|
const grid = document.getElementById('dataGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const fields = getFieldsFromRows();
|
||||||
|
fields.forEach(key => {
|
||||||
|
const def = FIELD_DEFS[key] || { name: key, unit: '', category: 'engine' };
|
||||||
|
const numericVals = sessionRows.map(r => r[key]).filter(v => typeof v === 'number');
|
||||||
|
if (!numericVals.length) return;
|
||||||
|
const col = getFieldColor(key);
|
||||||
|
const bgCol = col.replace(', 1)', ', 0.12)');
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'data-card';
|
||||||
|
card.dataset.key = key;
|
||||||
|
card.dataset.category = def.category;
|
||||||
|
card.dataset.name = def.name.toLowerCase();
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-top">
|
||||||
|
<div class="card-info"><h4>${def.name}</h4></div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="card-action-btn enlarge-btn" title="Espandi">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-values">
|
||||||
|
<span class="card-main-val">—</span>
|
||||||
|
<span class="card-unit">${def.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-mini-chart"><canvas id="mini-${CSS.escape(key)}"></canvas></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.querySelector('.enlarge-btn').onclick = e => { e.stopPropagation(); openExpandedChart(key); };
|
||||||
|
grid.appendChild(card);
|
||||||
|
|
||||||
|
const ctx = document.getElementById(`mini-${CSS.escape(key)}`).getContext('2d');
|
||||||
|
miniCharts[key] = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: sessionRows.map(() => ''),
|
||||||
|
datasets: [{ data: sessionRows.map(r => r[key] ?? null), borderColor: col, backgroundColor: bgCol, fill: false, tension: 0.3, pointRadius: 0, borderWidth: 2 }]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||||
|
scales: {
|
||||||
|
x: { type: 'category', display: false },
|
||||||
|
y: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGrid(ts) {
|
||||||
|
const idx = nearestIdx(ts);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const row = sessionRows[idx];
|
||||||
|
document.querySelectorAll('.data-card').forEach(card => {
|
||||||
|
const key = card.dataset.key;
|
||||||
|
const v = row[key];
|
||||||
|
const el = card.querySelector('.card-main-val');
|
||||||
|
if (typeof v === 'number') el.textContent = v.toFixed(2);
|
||||||
|
else el.textContent = v != null ? String(v) : '—';
|
||||||
|
});
|
||||||
|
if (expChart && expActiveField) updateExpandedChartLine(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
document.querySelectorAll('.data-card').forEach(c => {
|
||||||
|
const matchCat = activeCategory === 'all' || c.dataset.category === activeCategory;
|
||||||
|
const matchStr = !searchQuery || c.dataset.name.includes(searchQuery);
|
||||||
|
c.style.display = matchCat && matchStr ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dataSearch').oninput = e => { searchQuery = e.target.value.toLowerCase(); applyFilters(); };
|
||||||
|
document.querySelectorAll('#catFilter button').forEach(b => {
|
||||||
|
b.onclick = () => {
|
||||||
|
document.querySelectorAll('#catFilter button').forEach(x => x.classList.remove('active'));
|
||||||
|
b.classList.add('active'); activeCategory = b.dataset.cat; applyFilters();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Expanded Chart ---
|
||||||
|
function openExpandedChart(key) {
|
||||||
|
expActiveField = key;
|
||||||
|
const def = FIELD_DEFS[key] || { name: key, unit: '' };
|
||||||
|
document.getElementById('expChartTitle').textContent = `Dettaglio: ${def.name}`;
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'flex';
|
||||||
|
const col = getFieldColor(key);
|
||||||
|
if (!expChart) {
|
||||||
|
const ctx = document.getElementById('expandedChartCanvas').getContext('2d');
|
||||||
|
expChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: [
|
||||||
|
{ label: def.name, data: [], borderColor: col, backgroundColor: col.replace(',1)',',0.12)'), fill: false, tension: 0.3, pointRadius: 0, borderWidth: 2 },
|
||||||
|
{ type: 'line', data: [], borderColor: 'rgba(239,68,68,0.8)', borderWidth: 1, borderDash: [3,3], pointRadius: 0, fill: false }
|
||||||
|
]},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
scales: {
|
||||||
|
x: { type: 'category', ticks: { maxTicksLimit: 8, color: TICK_COLOR, font: { size: 10 } }, grid: { display: false } },
|
||||||
|
y: { ticks: { color: TICK_COLOR, font: { size: 10 }, maxTicksLimit: 5 }, grid: { color: GRID_COLOR } }
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `${ctx.parsed.y?.toFixed(3)} ${def.unit}` } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const data = sessionRows.map(r => r[key] ?? null);
|
||||||
|
const labels = sessionTimes.map(t => fmtTime(t));
|
||||||
|
expChart.data.labels = labels;
|
||||||
|
expChart.data.datasets[0].data = data;
|
||||||
|
expChart.data.datasets[0].borderColor = col;
|
||||||
|
expChart.data.datasets[0].backgroundColor = col.replace(',1)',',0.12)');
|
||||||
|
expChart.update('none');
|
||||||
|
updateExpandedChartLine(nearestIdx(currentT));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpandedChartLine(idx) {
|
||||||
|
if (!expChart) return;
|
||||||
|
const key = expActiveField;
|
||||||
|
const nulls = sessionRows.map(() => null);
|
||||||
|
if (idx >= 0) nulls[idx] = sessionRows[idx][key];
|
||||||
|
expChart.data.datasets[1].data = nulls;
|
||||||
|
expChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('closeExpBtn').onclick = () => {
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'none';
|
||||||
|
expActiveField = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Mapbox ---
|
||||||
|
function initMap() {
|
||||||
|
if (mapbox) { mapbox.remove(); mapbox = null; mapDot = null; }
|
||||||
|
document.getElementById('sessionMap').innerHTML = '';
|
||||||
|
|
||||||
|
if (!positionData.length) {
|
||||||
|
document.getElementById('noGpsMsg').style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('noGpsMsg').style.display = 'none';
|
||||||
|
|
||||||
|
mapbox = new mapboxgl.Map({
|
||||||
|
container: 'sessionMap',
|
||||||
|
style: 'mapbox://styles/mapbox/dark-v11',
|
||||||
|
center: [positionData[0].lon, positionData[0].lat],
|
||||||
|
zoom: 12
|
||||||
|
});
|
||||||
|
|
||||||
|
mapbox.on('load', () => {
|
||||||
|
const coordinates = positionData.map(p => [p.lon, p.lat]);
|
||||||
|
|
||||||
|
mapbox.addSource('route', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'LineString', coordinates } }
|
||||||
|
});
|
||||||
|
mapbox.addLayer({ id: 'route', type: 'line', source: 'route', paint: { 'line-color': '#3b82f6', 'line-width': 3 } });
|
||||||
|
|
||||||
|
// Wider hit area for hover
|
||||||
|
mapbox.addLayer({ id: 'route-hover', type: 'line', source: 'route', paint: { 'line-color': 'transparent', 'line-width': 16 } });
|
||||||
|
|
||||||
|
mapbox.addSource('dot', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [positionData[positionData.length-1].lon, positionData[positionData.length-1].lat] } }
|
||||||
|
});
|
||||||
|
mapbox.addLayer({ id: 'dot', type: 'circle', source: 'dot', paint: { 'circle-radius': 8, 'circle-color': '#fff', 'circle-stroke-color': '#3b82f6', 'circle-stroke-width': 3 } });
|
||||||
|
|
||||||
|
mapbox.fitBounds(coordinates.reduce((b, c) => b.extend(c), new mapboxgl.LngLatBounds(coordinates[0], coordinates[0])), { padding: 30 });
|
||||||
|
|
||||||
|
mapbox.on('mousemove', 'route-hover', e => {
|
||||||
|
if (!e.features.length) return;
|
||||||
|
const pt = e.lngLat;
|
||||||
|
let nearest = positionData[0], minD = Infinity;
|
||||||
|
positionData.forEach(p => {
|
||||||
|
const d = Math.hypot(p.lon - pt.lng, p.lat - pt.lat);
|
||||||
|
if (d < minD) { minD = d; nearest = p; }
|
||||||
|
});
|
||||||
|
seekTo(nearest.ts);
|
||||||
|
});
|
||||||
|
mapbox.on('mouseleave', 'route-hover', () => {});
|
||||||
|
|
||||||
|
document.getElementById('mapBar').style.display = 'flex';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMapDot(ts) {
|
||||||
|
if (!mapbox || !positionData.length) return;
|
||||||
|
const idx = nearestIdx(ts);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const row = sessionRows[idx];
|
||||||
|
const lat = row['navigation.position.latitude'];
|
||||||
|
const lon = row['navigation.position.longitude'];
|
||||||
|
if (lat == null || lon == null) return;
|
||||||
|
mapbox.getSource('dot')?.setData({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Timeline ---
|
||||||
|
function initTimeline() {
|
||||||
|
document.getElementById('tlLabelStart').textContent = fmtTime(tStart);
|
||||||
|
document.getElementById('tlLabelEnd').textContent = fmtTime(tEnd);
|
||||||
|
setTimelineMode(false);
|
||||||
|
setHandlePos('single', 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHandlePos(which, frac) {
|
||||||
|
const el = which === 'single' ? document.getElementById('tlHandleSingle')
|
||||||
|
: which === 'left' ? document.getElementById('tlHandleLeft')
|
||||||
|
: document.getElementById('tlHandleRight');
|
||||||
|
el.style.left = `${(frac * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function posToFrac(px) {
|
||||||
|
const track = document.getElementById('tlTrack');
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
return Math.max(0, Math.min(1, (px - rect.left) / rect.width));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fracToTs(frac) { return tStart + frac * (tEnd - tStart); }
|
||||||
|
function tsToFrac(ts) { return (tEnd === tStart) ? 0 : (ts - tStart) / (tEnd - tStart); }
|
||||||
|
|
||||||
|
function updateFill() {
|
||||||
|
const fill = document.getElementById('tlFill');
|
||||||
|
if (!restrictMode) {
|
||||||
|
const f = tsToFrac(currentT);
|
||||||
|
fill.style.left = '0%';
|
||||||
|
fill.style.width = `${f * 100}%`;
|
||||||
|
} else {
|
||||||
|
const fl = tsToFrac(restrictStart);
|
||||||
|
const fr = tsToFrac(restrictEnd);
|
||||||
|
fill.style.left = `${fl * 100}%`;
|
||||||
|
fill.style.width = `${(fr - fl) * 100}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimelineMode(restrict) {
|
||||||
|
restrictMode = restrict;
|
||||||
|
document.getElementById('restrictBtn').classList.toggle('active', restrict);
|
||||||
|
document.getElementById('tlHandleSingle').classList.toggle('hidden', restrict);
|
||||||
|
document.getElementById('tlHandleLeft').classList.toggle('hidden', !restrict);
|
||||||
|
document.getElementById('tlHandleRight').classList.toggle('hidden', !restrict);
|
||||||
|
if (restrict) {
|
||||||
|
restrictStart = tStart; restrictEnd = tEnd;
|
||||||
|
setHandlePos('left', 0); setHandlePos('right', 1);
|
||||||
|
}
|
||||||
|
updateFill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekTo(ts) {
|
||||||
|
currentT = Math.max(tStart, Math.min(tEnd, ts));
|
||||||
|
setHandlePos('single', tsToFrac(currentT));
|
||||||
|
document.getElementById('tlLabelCurrent').textContent = fmtTime(currentT);
|
||||||
|
updateFill();
|
||||||
|
updateGrid(currentT);
|
||||||
|
updateMapDot(currentT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag logic
|
||||||
|
let dragging = null;
|
||||||
|
function startDrag(id, e) {
|
||||||
|
dragging = id;
|
||||||
|
e.preventDefault();
|
||||||
|
const onMove = ev => {
|
||||||
|
const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
|
||||||
|
const frac = posToFrac(clientX);
|
||||||
|
const ts = fracToTs(frac);
|
||||||
|
if (id === 'single') {
|
||||||
|
seekTo(ts);
|
||||||
|
} else if (id === 'left') {
|
||||||
|
restrictStart = Math.max(tStart, Math.min(restrictEnd - 1000, ts));
|
||||||
|
setHandlePos('left', tsToFrac(restrictStart));
|
||||||
|
updateFill();
|
||||||
|
} else if (id === 'right') {
|
||||||
|
restrictEnd = Math.min(tEnd, Math.max(restrictStart + 1000, ts));
|
||||||
|
setHandlePos('right', tsToFrac(restrictEnd));
|
||||||
|
updateFill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onUp = () => { dragging = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tlHandleSingle').addEventListener('mousedown', e => startDrag('single', e));
|
||||||
|
document.getElementById('tlHandleLeft').addEventListener('mousedown', e => startDrag('left', e));
|
||||||
|
document.getElementById('tlHandleRight').addEventListener('mousedown', e => startDrag('right', e));
|
||||||
|
|
||||||
|
document.getElementById('tlTrack').addEventListener('click', e => {
|
||||||
|
if (dragging) return;
|
||||||
|
const frac = posToFrac(e.clientX);
|
||||||
|
if (!restrictMode) seekTo(fracToTs(frac));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('restrictBtn').onclick = () => setTimelineMode(!restrictMode);
|
||||||
|
|
||||||
|
// --- Download ---
|
||||||
|
document.getElementById('downloadBtn').onclick = async () => {
|
||||||
|
if (!currentSensorId || !currentSessionId) return;
|
||||||
|
const btn = document.getElementById('downloadBtn');
|
||||||
|
const orig = btn.textContent; btn.textContent = '...';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ session: currentSessionId, from: String(tStart) });
|
||||||
|
if (restrictMode) params.set('to', String(restrictEnd));
|
||||||
|
const res = await fetch(`${API_URL}/sessions/${encodeURIComponent(currentSensorId)}/csv?${params}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) { showToast('Errore durante il download'); return; }
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url;
|
||||||
|
a.download = `session_${currentSessionId}_${currentSensorId}.csv`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const sizeStr = blob.size < 1024 ? `${blob.size} B` : blob.size < 1048576 ? `${(blob.size/1024).toFixed(1)} KB` : `${(blob.size/1048576).toFixed(1)} MB`;
|
||||||
|
const text = await blob.text();
|
||||||
|
const rows = Math.max(0, text.split('\n').length - 2);
|
||||||
|
const dur = restrictMode ? fmtDuration(restrictEnd - restrictStart) : fmtDuration(tEnd - tStart);
|
||||||
|
showToast(`${rows} righe • ${sizeStr} • ${dur}`);
|
||||||
|
} catch { showToast('Errore durante il download'); }
|
||||||
|
finally { btn.textContent = orig; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Change session ---
|
||||||
|
document.getElementById('changeSessionBtn').onclick = () => {
|
||||||
|
document.getElementById('sessionOverlay').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
document.addEventListener('DOMContentLoaded', loadSessionsList);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
console/src/static/fonts/atkinson-bold.ttf
Normal file
BIN
console/src/static/fonts/atkinson-bold.ttf
Normal file
Binary file not shown.
BIN
console/src/static/fonts/atkinson-regular.ttf
Normal file
BIN
console/src/static/fonts/atkinson-regular.ttf
Normal file
Binary file not shown.
634
console/src/static/styles/kiosk.css
Normal file
634
console/src/static/styles/kiosk.css
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
:root {
|
||||||
|
--card-bg: #101415;
|
||||||
|
--card-border: #2a2d2e;
|
||||||
|
--card-border-active: #3a9bff;
|
||||||
|
--danger: #ff4d4d;
|
||||||
|
--success: #34d399;
|
||||||
|
--grid-dot: rgba(255, 255, 255, 0.04);
|
||||||
|
--snap-line: rgba(50, 152, 255, 0.25);
|
||||||
|
--cols: 24;
|
||||||
|
--rows: 18
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'hyperlegible';
|
||||||
|
src: url('../fonts/atkinson-regular.ttf');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'hyperlegible';
|
||||||
|
src: url('../fonts/atkinson-bold.ttf');
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'hyperlegible', sans-serif;
|
||||||
|
background-color: black;
|
||||||
|
color: white
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar {
|
||||||
|
background: rgba(16, 20, 21, 0.88);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 20px;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.toolbar button.primary {
|
||||||
|
background-color: rgba(255, 255, 255, 0.103);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.primary:hover {
|
||||||
|
background: #4da8ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** CAMVAS!!! */
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 70%;
|
||||||
|
position: absolute;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle 1px, #393b3c 0.8px, transparent 0.8px);
|
||||||
|
background-size: calc(100% / var(--cols)) calc(100% / var(--rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Snap guides ───────────────────────────────────── */
|
||||||
|
.guide {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--snap-line);
|
||||||
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.horizontal {
|
||||||
|
height: 1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.vertical {
|
||||||
|
width: 1px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CARDS */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #101415;
|
||||||
|
border: 2px dashed var(--card-border);
|
||||||
|
border-radius: 15px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: box-shadow 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94), border-color 0.25s ease;
|
||||||
|
will-change: left, top, width, height;
|
||||||
|
overflow: visible;
|
||||||
|
/* Necessario per vedere i manigli di resize fuori bordo se necessario */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stili Header Card */
|
||||||
|
.card-header {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Path Picker Menu ────────────────────────────── */
|
||||||
|
.label-wrapper {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 28px;
|
||||||
|
left: -8px;
|
||||||
|
background: rgba(26, 30, 31, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--card-border-active);
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 2000;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 5px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.editable .label-wrapper:hover .path-menu {
|
||||||
|
display: block;
|
||||||
|
animation: spawnIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-option {
|
||||||
|
padding: 10px 15px;
|
||||||
|
color: #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-option:hover {
|
||||||
|
background: var(--card-border-active);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 0, 0, 0.404);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-close:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:not(.editable) .card-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 70px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
height: calc(100% - 33px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RESIZE HANDLERS (.rh) */
|
||||||
|
.rh {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
border-radius: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.corner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--card-border-active);
|
||||||
|
border: 2px solid #101415;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.edge {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner alignment */
|
||||||
|
.rh.nw {
|
||||||
|
top: -6px;
|
||||||
|
left: -6px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.ne {
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.se {
|
||||||
|
bottom: -6px;
|
||||||
|
right: -6px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.sw {
|
||||||
|
bottom: -6px;
|
||||||
|
left: -6px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edge alignment */
|
||||||
|
.rh.n {
|
||||||
|
top: -4px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 8px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.s {
|
||||||
|
bottom: -4px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 8px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.e {
|
||||||
|
right: -4px;
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 8px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.w {
|
||||||
|
left: -4px;
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 8px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:not(.selected) .rh.corner {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.selected .rh.corner {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardSpawn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.spawning {
|
||||||
|
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardRemove {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.removing {
|
||||||
|
animation: cardRemove 0.2s ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stili per le classi dinamiche delle card */
|
||||||
|
.card.selected {
|
||||||
|
border-color: var(--card-border-active);
|
||||||
|
box-shadow: 0 0 15px rgba(50, 152, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.dragging,
|
||||||
|
.card.resizing {
|
||||||
|
cursor: grabbing;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stili per gli elementi aggiunti da canvas.js */
|
||||||
|
.tooltip {
|
||||||
|
position: fixed;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-badge {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 60px;
|
||||||
|
/* Regola in base all'altezza della toolbar */
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 3000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content textarea {
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
min-height: 200px;
|
||||||
|
background-color: #2a2d2e;
|
||||||
|
border: 1px solid #3a3d3e;
|
||||||
|
color: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
white-space: nowrap;
|
||||||
|
background-color: rgba(255, 255, 255, 0.103);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button.primary {
|
||||||
|
background-color: #4da8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edit Mode & Animations ──────────────────────── */
|
||||||
|
@keyframes cardSpawn {
|
||||||
|
0% { opacity: 0; transform: scale(0.92); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardRemove {
|
||||||
|
0% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(0.88); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.spawning {
|
||||||
|
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.removing {
|
||||||
|
animation: cardRemove 0.2s ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas state during editing */
|
||||||
|
.canvas.edit-active {
|
||||||
|
outline: 2px dashed rgba(58, 155, 255, 0.3);
|
||||||
|
outline-offset: -10px;
|
||||||
|
background-color: rgba(58, 155, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.editable {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.editable:not(.selected) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide handlers when not editing */
|
||||||
|
.card:not(.editable) .rh {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:not(.editable) {
|
||||||
|
cursor: default;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rimuovi padding se la card contiene la mappa per farla aderire ai bordi */
|
||||||
|
.card[data-type="map"] .card-body {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-map-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0 0 15px 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix per Mapbox canvas che a volte non prende il 100% se il container è flex */
|
||||||
|
.card-map-canvas .mapboxgl-canvas-container,
|
||||||
|
.card-map-canvas .mapboxgl-canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.primary {
|
||||||
|
background-color: var(--card-border-active) !important;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Global Edit Mode Overrides ──────────────────── */
|
||||||
|
body.edit-mode {
|
||||||
|
background-color: #0a0e0f;
|
||||||
|
transition: background-color 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode .toolbar {
|
||||||
|
background: rgba(58, 155, 255, 0.15) !important;
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
border: 2px dashed var(--card-border-active) !important;
|
||||||
|
box-shadow: 0 0 20px rgba(58, 155, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode .toolbar p#cardCount {
|
||||||
|
color: var(--card-border-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes editPulse {
|
||||||
|
0% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode .canvas.edit-active::after {
|
||||||
|
content: "DASHBOARD EDITING";
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--card-border-active);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
animation: editPulse 2s infinite ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
771
console/src/static/styles/rulesets.css
Normal file
771
console/src/static/styles/rulesets.css
Normal file
@@ -0,0 +1,771 @@
|
|||||||
|
/* --- Rulesets Page --- */
|
||||||
|
|
||||||
|
.rs-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.rs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 0 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--header-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type Picker */
|
||||||
|
.rs-type-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(241, 245, 249, 0.8);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-type-picker button {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-type-picker button.active {
|
||||||
|
background: white;
|
||||||
|
color: var(--accent-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-type-picker button:hover:not(.active) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.rs-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-filter-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-filter-btn.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-filter-btn:hover:not(.active) {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-sort-select {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
padding-right: 28px;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-new-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-new-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules Grid */
|
||||||
|
.rs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rule Card */
|
||||||
|
.rs-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
box-shadow: 0 8px 30px rgba(191, 219, 254, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-id {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-version {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-badge {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-badge.active {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-badge.archived {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-badge.inactive {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(226, 232, 240, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-items-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-card-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(241, 245, 249, 0.8);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== POPUP OVERLAY ===== */
|
||||||
|
.rs-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup {
|
||||||
|
background: white;
|
||||||
|
border-radius: 24px;
|
||||||
|
width: 700px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 28px 16px;
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 24px 24px 0 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-close:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-popup-body {
|
||||||
|
padding: 20px 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popup sections */
|
||||||
|
.rs-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline editable fields */
|
||||||
|
.rs-field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 80px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-field-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons row */
|
||||||
|
.rs-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.active-toggle {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.active-toggle.off {
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.archive-toggle.on {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-action-btn.danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Items section */
|
||||||
|
.rs-items-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-add-item-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px dashed var(--accent-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-add-item-btn:hover {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item row */
|
||||||
|
.rs-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item:hover {
|
||||||
|
background: rgba(241, 245, 249, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field:focus {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: white;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field.narrow {
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field.medium {
|
||||||
|
width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-field.wide {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch for item enabled */
|
||||||
|
.rs-toggle {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toggle.on {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toggle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-toggle.on::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-delete {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags input */
|
||||||
|
.rs-tags-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tags-wrap:focus-within {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag-chip button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag-chip button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tag-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Saving indicator */
|
||||||
|
.rs-saving {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-saving.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item field labels header */
|
||||||
|
.rs-item-labels {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-item-labels span.narrow { width: 60px; flex-shrink: 0; }
|
||||||
|
.rs-item-labels span.medium { width: 100px; flex-shrink: 0; }
|
||||||
|
.rs-item-labels span.wide { flex: 1; min-width: 80px; }
|
||||||
|
.rs-item-labels span.toggle-space { width: 36px; flex-shrink: 0; }
|
||||||
|
.rs-item-labels span.delete-space { width: 24px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Confirm dialog */
|
||||||
|
.rs-confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-box {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-box h3 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-box p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-actions button {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
background: white;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-actions button.confirm-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-confirm-actions button.confirm-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back link */
|
||||||
|
.rs-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-back:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
@@ -17,16 +17,16 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
networks:
|
networks:
|
||||||
- meb-proxy-net
|
- meb-public
|
||||||
- meb-internal
|
- meb-private
|
||||||
ports:
|
ports:
|
||||||
- "3006:3006"
|
- "3006:3006"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.auth.rule=Host(`auth.${URL_DOMAIN}`)"
|
- "traefik.http.routers.auth.rule=Host(`auth.${DOMAIN:-mebboat.it}`)"
|
||||||
- "traefik.http.routers.auth.entrypoints=web"
|
- "traefik.http.routers.auth.entrypoints=websecure"
|
||||||
- "traefik.http.services.auth.loadbalancer.server.port=3006"
|
- "traefik.http.services.auth.loadbalancer.server.port=3006"
|
||||||
- "traefik.docker.network=meb-proxy-net"
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
container_name: api-services
|
container_name: api-services
|
||||||
@@ -37,16 +37,20 @@ services:
|
|||||||
command: npm run dev
|
command: npm run dev
|
||||||
volumes:
|
volumes:
|
||||||
- ./api/src:/app/src
|
- ./api/src:/app/src
|
||||||
- /app/node_modules
|
|
||||||
- ./ml:/ml-source
|
- ./ml:/ml-source
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
env_file:
|
env_file:
|
||||||
- ./api/.env
|
- ./api/.env
|
||||||
networks:
|
networks:
|
||||||
- meb-proxy-net
|
- meb-public
|
||||||
- meb-internal
|
- meb-private
|
||||||
ports:
|
labels:
|
||||||
- "3003:3003"
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.api.rule=Host(`api.${DOMAIN:-mebboat.it}`)"
|
||||||
|
- "traefik.http.routers.api.entrypoints=websecure"
|
||||||
|
- "traefik.http.services.api.loadbalancer.server.port=3003"
|
||||||
|
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
console:
|
console:
|
||||||
build:
|
build:
|
||||||
@@ -60,10 +64,15 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./console/.env
|
- ./console/.env
|
||||||
networks:
|
networks:
|
||||||
- meb-proxy-net
|
- meb-public
|
||||||
- meb-internal
|
- meb-private
|
||||||
ports:
|
labels:
|
||||||
- "3004:3004"
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.console.rule=Host(`console.${DOMAIN:-mebboat.it}`)"
|
||||||
|
- "traefik.http.routers.console.entrypoints=websecure"
|
||||||
|
- "traefik.http.services.console.loadbalancer.server.port=3004"
|
||||||
|
- "traefik.http.routers.console.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
realtime:
|
realtime:
|
||||||
build:
|
build:
|
||||||
@@ -71,33 +80,46 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
ports:
|
|
||||||
- "3002:3002"
|
|
||||||
- "3102:3102"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./realtime:/app
|
- ./realtime:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
env_file:
|
env_file:
|
||||||
- ./realtime/.env
|
- ./realtime/.env
|
||||||
networks:
|
networks:
|
||||||
- meb-proxy-net
|
- meb-private
|
||||||
- meb-internal
|
- meb-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.realtime.rule=Host(`realtime.${DOMAIN:-mebboat.it}`)"
|
||||||
|
- "traefik.http.routers.realtime.entrypoints=websecure"
|
||||||
|
- "traefik.http.services.realtime.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.http.routers.realtime.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
# ml:
|
- "traefik.http.services.realtime.loadbalancer.sticky.cookie=true"
|
||||||
# container_name: ml-service
|
- "traefik.http.services.realtime.loadbalancer.sticky.cookie.name=realtime-ws"
|
||||||
# build:
|
- "traefik.http.services.realtime.loadbalancer.sticky.cookie.secure=true"
|
||||||
# context: ./ml
|
|
||||||
# dockerfile: Dockerfile
|
ml:
|
||||||
# restart: unless-stopped
|
container_name: ml-service
|
||||||
# volumes:
|
build:
|
||||||
# - ./ml:/app
|
context: ./ml
|
||||||
# env_file:
|
dockerfile: Dockerfile
|
||||||
# - ./ml/.env
|
restart: unless-stopped
|
||||||
# ports:
|
volumes:
|
||||||
# - "3005:3005"
|
- ./ml:/app
|
||||||
# networks:
|
env_file:
|
||||||
# - meb-proxy-net
|
- ./ml/.env
|
||||||
# - meb-internal
|
networks:
|
||||||
|
- meb-private
|
||||||
|
- meb-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.ml.rule=Host(`ml.${DOMAIN:-mebboat.it}`)"
|
||||||
|
- "traefik.http.routers.ml.entrypoints=websecure"
|
||||||
|
- "traefik.http.services.ml.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.http.routers.ml.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.docker.network=meb-public"
|
||||||
|
|
||||||
# marine:
|
# marine:
|
||||||
# container_name: marine-service
|
# container_name: marine-service
|
||||||
@@ -117,7 +139,7 @@ services:
|
|||||||
# - meb-internal
|
# - meb-internal
|
||||||
# labels:
|
# labels:
|
||||||
# - "traefik.enable=true"
|
# - "traefik.enable=true"
|
||||||
# - "traefik.http.routers.marine.rule=Host(`api.${URL_DOMAIN}`) && PathPrefix(`/marine`)"
|
# - "traefik.http.routers.marine.rule=Host(`api.${DOMAIN:-mebboat.it}`) && PathPrefix(`/marine`)"
|
||||||
# - "traefik.http.routers.marine.entrypoints=web"
|
# - "traefik.http.routers.marine.entrypoints=web"
|
||||||
# - "traefik.http.services.marine.loadbalancer.server.port=8001"
|
# - "traefik.http.services.marine.loadbalancer.server.port=8001"
|
||||||
# - "traefik.docker.network=meb-proxy-net"
|
# - "traefik.docker.network=meb-proxy-net"
|
||||||
@@ -133,8 +155,8 @@ services:
|
|||||||
# environment:
|
# environment:
|
||||||
# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits
|
# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits
|
||||||
# - AUTH_SERVICE_URL=http://auth:3001
|
# - AUTH_SERVICE_URL=http://auth:3001
|
||||||
# - AUTH_URL=http://auth.${URL_DOMAIN:-localhost}
|
# - AUTH_URL=http://auth.${DOMAIN:-localhost}
|
||||||
# - API_URL=http://api.${URL_DOMAIN:-localhost}
|
# - API_URL=http://api.${DOMAIN:-localhost}
|
||||||
# - NODE_ENV=${NODE_ENV:-development}
|
# - NODE_ENV=${NODE_ENV:-development}
|
||||||
# volumes:
|
# volumes:
|
||||||
# - ./circuits/src:/app/src
|
# - ./circuits/src:/app/src
|
||||||
@@ -151,14 +173,14 @@ services:
|
|||||||
# - meb-internal
|
# - meb-internal
|
||||||
# labels:
|
# labels:
|
||||||
# - "traefik.enable=true"
|
# - "traefik.enable=true"
|
||||||
# - "traefik.http.routers.circuits.rule=Host(`circuits.${URL_DOMAIN}`)"
|
# - "traefik.http.routers.circuits.rule=Host(`circuits.${DOMAIN:-mebboat.it}`)"
|
||||||
# - "traefik.http.routers.circuits.entrypoints=web"
|
# - "traefik.http.routers.circuits.entrypoints=web"
|
||||||
# - "traefik.http.services.circuits.loadbalancer.server.port=3005"
|
# - "traefik.http.services.circuits.loadbalancer.server.port=3005"
|
||||||
# - "traefik.docker.network=meb-proxy-net"
|
# - "traefik.docker.network=meb-proxy-net"
|
||||||
# - "traefik.http.routers.circuits.middlewares=cors-ignore"
|
# - "traefik.http.routers.circuits.middlewares=cors-ignore"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
meb-proxy-net:
|
meb-public:
|
||||||
external: true
|
external: true
|
||||||
meb-internal:
|
meb-private:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
85
ml/core/auth.py
Normal file
85
ml/core/auth.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Middleware / dependency di autenticazione per FastAPI (servizio ML).
|
||||||
|
Verifica il JWT firmato da auth.mebboat.it (JWT_SECRET condiviso).
|
||||||
|
Supporta cookie `auth_token` (SSO via .mebboat.it) e header Authorization: Bearer <jwt>.
|
||||||
|
|
||||||
|
Il cookie auth_token è condiviso tra i sottodomini grazie a domain=.mebboat.it:
|
||||||
|
- console.mebboat.it imposta il cookie al login
|
||||||
|
- ml.mebboat.it lo riceve automaticamente dal browser
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
from core.auth import require_auth, require_internal
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected_route(user = Depends(require_auth)):
|
||||||
|
return {"user": user}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import Cookie, Header, HTTPException, Request, status
|
||||||
|
|
||||||
|
SECRET = os.environ.get("JWT_SECRET")
|
||||||
|
INTERNAL_KEY = os.environ.get("INTERNAL_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def _verify(token: Optional[str]):
|
||||||
|
"""Verifica e decodifica un JWT. Ritorna il payload o None."""
|
||||||
|
if not token or not isinstance(token, str) or len(token) > 2048:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
|
||||||
|
return {
|
||||||
|
"user_id": payload.get("sub"),
|
||||||
|
"username": payload.get("username"),
|
||||||
|
"session_id": payload.get("session_id"),
|
||||||
|
"iat": payload.get("iat"),
|
||||||
|
"exp": payload.get("exp"),
|
||||||
|
}
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(
|
||||||
|
request: Request,
|
||||||
|
auth_token: Optional[str] = Cookie(default=None),
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
x_api_key: Optional[str] = Header(default=None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
FastAPI dependency: accetta utente loggato (cookie/bearer) o chiamata interna.
|
||||||
|
Uso: `user = Depends(require_auth)`.
|
||||||
|
|
||||||
|
Il cookie auth_token arriva automaticamente dal browser se l'utente
|
||||||
|
ha effettuato il login su auth.mebboat.it (dominio .mebboat.it).
|
||||||
|
"""
|
||||||
|
# Service-to-service
|
||||||
|
if x_api_key and INTERNAL_KEY and x_api_key == INTERNAL_KEY:
|
||||||
|
request.state.internal = True
|
||||||
|
return {"internal": True}
|
||||||
|
|
||||||
|
# Bearer token
|
||||||
|
bearer = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
bearer = authorization[7:]
|
||||||
|
|
||||||
|
user = _verify(auth_token or bearer)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="unauthorized",
|
||||||
|
)
|
||||||
|
request.state.user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_internal(x_api_key: Optional[str] = Header(default=None)):
|
||||||
|
"""FastAPI dependency: solo chiamate service-to-service con x-api-key."""
|
||||||
|
if not INTERNAL_KEY or x_api_key != INTERNAL_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return True
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn
|
||||||
|
PyJWT
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
PORT=3004
|
|
||||||
|
|
||||||
VERSION=1.0.0
|
VERSION=1.0.0
|
||||||
VERSION_BUILD=1.0
|
VERSION_BUILD=1.0
|
||||||
VERSION_STATE=beta
|
VERSION_STATE=beta
|
||||||
@@ -7,3 +5,4 @@ VERSION_STATE=beta
|
|||||||
INFLX_URL=
|
INFLX_URL=
|
||||||
INFLX_TOKEN=
|
INFLX_TOKEN=
|
||||||
INFLX_ORG=
|
INFLX_ORG=
|
||||||
|
INFLX_BUCKET=
|
||||||
@@ -7,6 +7,6 @@ RUN npm install
|
|||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
EXPOSE 3002
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
"@influxdata/influxdb-client": "^1.35.0",
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||||
"@msgpack/msgpack": "^3.1.3",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
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()');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Database connection failed:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initDB() {
|
|
||||||
try {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS sensors (
|
|
||||||
id VARCHAR(10) PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code_hash TEXT NOT NULL UNIQUE,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
last_seen TIMESTAMP DEFAULT NOW(),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sensors_code_hash ON sensors(code_hash);
|
|
||||||
`);
|
|
||||||
console.log('[DB] Database schema initialized (sensors table ensured)');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[DB] Schema initialization 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,
|
|
||||||
initDB,
|
|
||||||
getSensor,
|
|
||||||
updateLastSeen,
|
|
||||||
setSensorActivity,
|
|
||||||
getSensors,
|
|
||||||
sensorsExists,
|
|
||||||
createSensor
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkRedis() {
|
|
||||||
try {
|
|
||||||
await redis.ping();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
setSession,
|
|
||||||
getSession,
|
|
||||||
deleteSession,
|
|
||||||
getSessions,
|
|
||||||
publishSensorData,
|
|
||||||
addWatcher,
|
|
||||||
removeWatcher,
|
|
||||||
getWatcherCount,
|
|
||||||
checkRedis,
|
|
||||||
redis,
|
|
||||||
redisSub
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
@@ -1,157 +1,53 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const WebSocket = require('ws');
|
const crypto = require('crypto');
|
||||||
const Redis = require('ioredis');
|
const parser = require('cookie-parser');
|
||||||
const redisHelper = require('./helper/redis');
|
|
||||||
const influxWriter = require('./helper/influxWriter');
|
|
||||||
const influxReader = require('./helper/influxReader');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
const db = require('./store/db')
|
||||||
|
const redis = require('./store/redis');
|
||||||
|
const wsHandler = require('./ws/handler');
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(parser());
|
||||||
|
|
||||||
|
// CORS — consenti richieste dalla console e altri client browser
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
|
res.header('Access-Control-Allow-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');
|
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
require('./socket');
|
// DATABASE POSTGRESQL
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.redirect('/health');
|
res.redirect('/health');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
const dbConnected = await require('./helper/authdb').checkDB();
|
|
||||||
const redisConnected = await redisHelper.checkRedis();
|
|
||||||
console.log('DATABASE LOGS', process.env.DB_USER, process.env.DB_HOST, process.env.DB_NAME, process.env.DB_PASSWORD, process.env.DB_PORT);
|
|
||||||
console.log('REDIS LOGS', process.env.REDIS_HOST, process.env.REDIS_PORT);
|
|
||||||
|
|
||||||
res.status(200).send({
|
const sensorsDB = db.checkConnection('sensors');
|
||||||
status: dbConnected && redisConnected ? 'OK' : 'DEGRADED',
|
const dataDB = db.checkConnection('data');
|
||||||
database: dbConnected ? 'connected' : 'disconnected',
|
|
||||||
redis: redisConnected ? 'connected' : 'disconnected',
|
res.json({
|
||||||
service: 'realtime',
|
status: 'ok',
|
||||||
|
databases: {
|
||||||
|
sensors: sensorsDB ? 'connected' : 'disconnected',
|
||||||
|
data: dataDB ? 'connected' : 'disconnected'
|
||||||
|
},
|
||||||
|
redis: redis.checkRedis() ? 'connected' : 'disconnected',
|
||||||
version: process.env.VERSION,
|
version: process.env.VERSION,
|
||||||
build: process.env.VERSION_BUILD,
|
build_number: 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('/connect', require('./routes/connect'));
|
||||||
|
app.use('/sensors', require('./routes/sensors'));
|
||||||
app.use('/sessions', require('./routes/sessions'));
|
app.use('/sessions', require('./routes/sessions'));
|
||||||
|
|
||||||
// --- Flush buffer e CSV export ---
|
const server = app.listen(3000, '0.0.0.0', () => {
|
||||||
|
console.log(`Realtime started`);
|
||||||
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) => {
|
wsHandler.setup(server);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
const server = app.listen(PORT, '0.0.0.0', async () => {
|
|
||||||
console.log(`Realtime on port ${PORT}`);
|
|
||||||
await require('./helper/authdb').initDB();
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
51
realtime/src/middlewares/auth.js
Normal file
51
realtime/src/middlewares/auth.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Middleware di autenticazione per il servizio realtime.
|
||||||
|
* Usa il JWT condiviso via cookie .mebboat.it o Authorization Bearer.
|
||||||
|
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET e verificato localmente.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET;
|
||||||
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
|
||||||
|
|
||||||
|
function extractToken(req) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
return (req.cookies && req.cookies.auth_token) || bearer || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
if (!token || typeof token !== 'string' || token.length > 2048) return null;
|
||||||
|
try {
|
||||||
|
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
|
||||||
|
return {
|
||||||
|
user_id: p.sub,
|
||||||
|
username: p.username,
|
||||||
|
session_id: p.session_id,
|
||||||
|
iat: p.iat,
|
||||||
|
exp: p.exp
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accetta utente loggato (cookie/bearer) o chiamata interna (x-api-key).
|
||||||
|
*/
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
// Service-to-service
|
||||||
|
const apiKey = req.headers['x-api-key'];
|
||||||
|
if (apiKey && INTERNAL_KEY && apiKey === INTERNAL_KEY) {
|
||||||
|
req.internal = true;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
// User auth
|
||||||
|
const user = verifyToken(extractToken(req));
|
||||||
|
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuth, verifyToken, extractToken };
|
||||||
@@ -1,73 +1,70 @@
|
|||||||
const express = require('express');
|
const router = require('express').Router();
|
||||||
const db = require('../helper/authdb');
|
const db = require('../store/db');
|
||||||
const tokenStore = require('../helper/tokenStore');
|
const { createConnectionToken } = require('../store/redis');
|
||||||
const redis = require('../helper/redis');
|
const crypto = require('crypto');
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /connect
|
* Aggiunge un nuovo sensore autorizzato a partire da un nome univoco e un codice che verrà salvato in forma hashata.
|
||||||
* 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) => {
|
router.post('/new', async (req, res) => {
|
||||||
const { name, code } = req.body;
|
const { name, code } = req.body;
|
||||||
|
|
||||||
if (!name || !code) {
|
if (!name || !code) {
|
||||||
return res.status(400).send({ error: 'Name and code are required' });
|
return res.status(400).json({ error: 'name and code are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (code.length < 6) {
|
||||||
|
return res.status(400).json({ error: 'code must be at least 6 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const hash = crypto.scryptSync(code, salt, 64).toString('hex');
|
||||||
|
const codeHash = `${salt}:${hash}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.createSensor(name, code);
|
await db.query('sensors',
|
||||||
return res.status(200).send({ result: 'created' });
|
'INSERT INTO sensors (name, code_hash) VALUES ($1, $2)',
|
||||||
} catch (error) {
|
[name, codeHash]
|
||||||
return res.status(500).send({ error: `${error}` });
|
);
|
||||||
|
res.status(201).json({ status: 'ok' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'name already exists' });
|
||||||
|
}
|
||||||
|
console.error('Error creating sensor', err);
|
||||||
|
res.status(500).json({ error: `internal server error, ${err}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { name, code } = req.body;
|
||||||
|
|
||||||
|
if (!name || !code) {
|
||||||
|
return res.status(400).json({ error: 'name and code required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query('sensors',
|
||||||
|
'SELECT code_hash FROM sensors WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(401).json({ error: 'invalid name or code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [salt, storedHash] = result.rows[0].code_hash.split(':');
|
||||||
|
const hash = crypto.scryptSync(code, salt, 64).toString('hex');
|
||||||
|
|
||||||
|
if (hash !== storedHash) {
|
||||||
|
return res.status(401).json({ error: 'invalid name or code' });
|
||||||
|
}
|
||||||
|
const token = await createConnectionToken(name);
|
||||||
|
|
||||||
|
res.status(200).json({ s: 'ok', t: token });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error verifying connection', err);
|
||||||
|
res.status(500).json({ error: `internal server error, ${err}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
12
realtime/src/routes/kiosk.js
Normal file
12
realtime/src/routes/kiosk.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const db = require('../store/db');
|
||||||
|
|
||||||
|
// Endpoint per ricevere dati dal kiosk
|
||||||
|
router.post('/data', async (req, res) => {
|
||||||
|
const { session_id, sensor_code, value, timestamp } = req.body;
|
||||||
|
if (!session_id || !sensor_code || value === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,36 +1,60 @@
|
|||||||
const express = require('express');
|
const router = require('express').Router();
|
||||||
const db = require('../helper/authdb');
|
const db = require('../store/db');
|
||||||
|
|
||||||
router = express.Router();
|
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const sensors = await db.getSensors();
|
try {
|
||||||
res.status(200).json(sensors);
|
const result = await db.query('sensors', 'SELECT id, name FROM sensors');
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching sensors', err);
|
||||||
|
res.status(500).json({ error: `internal server error, ${err}` });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/:activity', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
const { id, activity } = req.params;
|
const { id } = 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 {
|
try {
|
||||||
const exists = await db.sensorsExists(id);
|
const result = await db.query('sensors', 'SELECT id, name FROM sensors WHERE id = $1', [id]);
|
||||||
if (!exists) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(404).json({ error: `Sensor with id ${id} not found` });
|
return res.status(404).json({ error: 'sensor not found' });
|
||||||
}
|
}
|
||||||
await db.setSensorActivity(id, isActive);
|
res.json(result.rows[0]);
|
||||||
res.status(200).json({ status: `Sensor ${activity}` });
|
} catch (err) {
|
||||||
} catch (error) {
|
console.error('Error fetching sensor', err);
|
||||||
console.error('Error updating sensor ID:', id, error);
|
res.status(500).json({ error: `internal server error, ${err}` });
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
module.exports = router
|
//Toggle availability
|
||||||
|
router.post('/:id/inactive', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const result = await db.query('sensors', 'SELECT id, name FROM sensors WHERE id = $1', [id]);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'sensor not found' });
|
||||||
|
}
|
||||||
|
await db.query('sensors', 'UPDATE sensors SET active = false WHERE id = $1', [id]);
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating sensor status', err);
|
||||||
|
res.status(500).json({ error: `internal server error, ${err}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/active', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const result = await db.query('sensors', 'SELECT id, name FROM sensors WHERE id = $1', [id]);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'sensor not found' });
|
||||||
|
}
|
||||||
|
await db.query('sensors', 'UPDATE sensors SET active = true WHERE id = $1', [id]);
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating sensor status', err);
|
||||||
|
res.status(500).json({ error: `internal server error, ${err}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,35 +1,254 @@
|
|||||||
const express = require('express');
|
const router = require('express').Router();
|
||||||
const redis = require('../helper/redis');
|
const { queryAll, query, hset } = require('../store/redis');
|
||||||
|
const { connectedSensors } = require('../ws/handler');
|
||||||
const router = express.Router();
|
const { flush, exportSessionCSV } = require('../store/influx');
|
||||||
|
const db = require('../store/db');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /sessions
|
* GET /sessions — Lista tutte le sessioni attive dei sensori
|
||||||
* 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) => {
|
router.get('/', async (req, res) => {
|
||||||
const { sensor } = req.query;
|
|
||||||
|
|
||||||
// Se viene passato un parametro ?sensor=ID, restituiamo solo quello
|
|
||||||
if (sensor) {
|
|
||||||
try {
|
try {
|
||||||
const session = await redis.getSession(sensor);
|
const keys = await queryAll('sensors');
|
||||||
if (!session) {
|
const sessions = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
const name = key.replace('sensors:', '');
|
||||||
|
const info = await query(name, 'sensors');
|
||||||
|
sessions[name] = {
|
||||||
|
name,
|
||||||
|
connectedAt: info.connectedAt || info.timestamp || null,
|
||||||
|
session: info.session || null,
|
||||||
|
status: info.status || 'unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
res.json(sessions);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching sessions', err);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/history — Lista tutte le sessioni passate (da sessiondataref)
|
||||||
|
*/
|
||||||
|
router.get('/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query('sensors',
|
||||||
|
`SELECT * FROM sessiondataref ORDER BY created_at DESC LIMIT 100`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching session history:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/pending — Lista token di connessione pendenti
|
||||||
|
*/
|
||||||
|
router.get('/pending', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const keys = await queryAll('sensors_pending');
|
||||||
|
res.json(keys);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching pending tokens', err);
|
||||||
|
res.status(500).json({ error: `Error fetching pending tokens, ${err}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/connected — Lista sensori attualmente connessi
|
||||||
|
*/
|
||||||
|
router.get('/connected', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const keys = await queryAll('sensor');
|
||||||
|
const connected = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const name = key.replace('sensor:', '');
|
||||||
|
const info = await query(name, 'sensor');
|
||||||
|
if (info.status === 'connected') {
|
||||||
|
connected.push({ name, connectedAt: info.timestamp });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json(connected);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching connected sensors', err);
|
||||||
|
res.status(500).json({ error: `Error fetching connected sensors, ${err}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/connected/:id — Verifica se un sensore specifico e connesso
|
||||||
|
*/
|
||||||
|
router.get('/connected/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const info = await query(id, 'sensor');
|
||||||
|
if (!info || info.status !== 'connected') {
|
||||||
|
return res.status(404).json({ error: 'sensor not connected' });
|
||||||
|
}
|
||||||
|
res.json({ name: id, connectedAt: info.timestamp });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching sensor connection status', err);
|
||||||
|
res.status(500).json({ error: `Error fetching sensor connection status, ${err}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /sessions/:id/flush — Forza il flush del buffer InfluxDB
|
||||||
|
*/
|
||||||
|
router.post('/:id/flush', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await flush();
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error flushing:', err.message);
|
||||||
|
res.status(500).json({ error: 'flush failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/:id/csv — Esporta tutti i dati della sessione come CSV.
|
||||||
|
* Usa il session_id da Redis (sensore connesso) oppure il query param ?session=sXXXX.
|
||||||
|
* Il CSV contiene tutti i dati logs per quel sensor + session da InfluxDB.
|
||||||
|
*/
|
||||||
|
router.get('/:id/csv', async (req, res) => {
|
||||||
|
const sensorName = req.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determina il session_id: da query param, da Redis, o dall'ultimo in DB
|
||||||
|
let sessionId = req.query.session || null;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
// Prova da Redis (sensore connesso)
|
||||||
|
const info = await query(sensorName, 'sensors');
|
||||||
|
sessionId = info?.session || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
// Ultima sessione in sessiondataref
|
||||||
|
const result = await db.query('sensors',
|
||||||
|
`SELECT session_id FROM sessiondataref WHERE sensor_name = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[sensorName]
|
||||||
|
);
|
||||||
|
sessionId = result.rows[0]?.session_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(404).json({ error: 'No session found for this sensor' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determina il range temporale: da connectedAt della sessione
|
||||||
|
let since = req.query.from ? new Date(parseInt(req.query.from)).toISOString() : null;
|
||||||
|
|
||||||
|
if (!since) {
|
||||||
|
// Cerca il created_at nella sessiondataref
|
||||||
|
const result = await db.query('sensors',
|
||||||
|
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
since = result.rows[0]?.created_at?.toISOString() || '-30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = await exportSessionCSV(sensorName, sessionId, since);
|
||||||
|
|
||||||
|
if (!csv) {
|
||||||
|
return res.status(404).json({ error: 'No data found for this session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="session_${sessionId}_${sensorName}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error exporting CSV:', err.message);
|
||||||
|
res.status(500).json({ error: 'CSV export failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/:id/details — Ottieni i dettagli della sessione corrente
|
||||||
|
*/
|
||||||
|
router.get('/:id/details', async (req, res) => {
|
||||||
|
const sensorName = req.params.id;
|
||||||
|
const sessionId = req.query.session || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (sessionId) {
|
||||||
|
result = await db.query('sensors',
|
||||||
|
`SELECT * FROM sessiondataref WHERE session_id = $1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Ultima sessione per questo sensore
|
||||||
|
result = await db.query('sensors',
|
||||||
|
`SELECT * FROM sessiondataref WHERE sensor_name = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[sensorName]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
}
|
}
|
||||||
return res.status(200).json(JSON.parse(session));
|
|
||||||
} catch (error) {
|
res.json(result.rows[0]);
|
||||||
return res.status(500).json({ error: `${error}` });
|
} catch (err) {
|
||||||
|
console.error('Error fetching session details:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /sessions/:id/details — Aggiorna nome, descrizione o tags della sessione.
|
||||||
|
* Body: { name?, description?, tags? }
|
||||||
|
*/
|
||||||
|
router.put('/:id/details', async (req, res) => {
|
||||||
|
const sensorName = req.params.id;
|
||||||
|
const { session: sessionId, name, description, tags } = req.body;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: 'session id is required in body' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Altrimenti restituiamo tutta la lista
|
|
||||||
try {
|
try {
|
||||||
const sessions = await redis.getSessions();
|
const updates = [];
|
||||||
res.status(200).json(sessions);
|
const values = [];
|
||||||
} catch (error) {
|
let idx = 1;
|
||||||
res.status(500).json({ error: `${error}` });
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
updates.push(`name = $${idx++}`);
|
||||||
|
values.push(name);
|
||||||
|
}
|
||||||
|
if (description !== undefined) {
|
||||||
|
updates.push(`description = $${idx++}`);
|
||||||
|
values.push(description);
|
||||||
|
}
|
||||||
|
if (tags !== undefined) {
|
||||||
|
updates.push(`tags = $${idx++}`);
|
||||||
|
values.push(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
values.push(sessionId);
|
||||||
|
|
||||||
|
const result = await db.query('sensors',
|
||||||
|
`UPDATE sessiondataref SET ${updates.join(', ')} WHERE session_id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating session details:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
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}`);
|
|
||||||
80
realtime/src/store/db.js
Normal file
80
realtime/src/store/db.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
user: process.env.DB_USER || 'meb',
|
||||||
|
password: process.env.DB_PSW,
|
||||||
|
host: process.env.DB_HOST || 'meb-postgres',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbs = {
|
||||||
|
data: { name: 'data' },
|
||||||
|
sensors: { name: 'sensors' },
|
||||||
|
kiosk: { name: 'kiosk' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const pools = {};
|
||||||
|
|
||||||
|
function getPool(db) {
|
||||||
|
const dbConfig = dbs[db];
|
||||||
|
if (!dbConfig) throw new Error(`Database ${db} not configured`);
|
||||||
|
if (!pools[db]) {
|
||||||
|
pools[db] = new Pool({ ...baseConfig, database: dbConfig.name });
|
||||||
|
}
|
||||||
|
return pools[db];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkConnection(db) {
|
||||||
|
try {
|
||||||
|
await getPool(db).query('SELECT NOW()');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error connecting to ${db} database`, err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function query(db, text, params) {
|
||||||
|
const pool = getPool(db);
|
||||||
|
return pool.query(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
// Tabella sensori
|
||||||
|
await query('sensors', `
|
||||||
|
CREATE TABLE IF NOT EXISTS sensors (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Tabella sessioni: mappa session_id (tag InfluxDB) a metadati custom
|
||||||
|
await query('sensors', `
|
||||||
|
CREATE TABLE IF NOT EXISTS sessiondataref (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
session_id VARCHAR(32) UNIQUE NOT NULL,
|
||||||
|
sensor_name VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255),
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
disconnected_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('[DB] Tabelle verificate (sensors, sessiondataref)');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB] Error creating tables:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
module.exports = { checkConnection, query };
|
||||||
136
realtime/src/store/influx.js
Normal file
136
realtime/src/store/influx.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
const { InfluxDB, Point } = require('@influxdata/influxdb-client');
|
||||||
|
|
||||||
|
const client = new InfluxDB({
|
||||||
|
url: process.env.INFLX_URL,
|
||||||
|
token: process.env.INFLX_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bucket = process.env.INFLX_BUCKET || 'logs';
|
||||||
|
const org = process.env.INFLX_ORG;
|
||||||
|
|
||||||
|
const writeApi = client.getWriteApi(org, bucket, 'ms', {
|
||||||
|
flushInterval: 100,
|
||||||
|
batchSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrive dati generici su InfluxDB senza mapping.
|
||||||
|
* @param {string} measurement - nome della measurement (es. 'logs', 'weather')
|
||||||
|
* @param {Object} fields - campi { key: value }
|
||||||
|
* @param {string} sensor - nome del sensore
|
||||||
|
* @param {string} session - id sessione (tag immutabile)
|
||||||
|
* @param {number} timestamp - timestamp unix ms
|
||||||
|
*/
|
||||||
|
function writeGenericData(measurement, fields, sensor, session, timestamp) {
|
||||||
|
const point = new Point(measurement)
|
||||||
|
.tag('sensor', sensor)
|
||||||
|
.tag('session', session)
|
||||||
|
.timestamp(timestamp);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
point.floatField(key, value);
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
point.stringField(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeApi.writePoint(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrive un batch di punti forecast (previsioni orarie).
|
||||||
|
* @param {Array} points - array di [timestamp_ms, { key: value, ... }]
|
||||||
|
* @param {string} sensor - nome del sensore
|
||||||
|
* @param {string} session - id sessione
|
||||||
|
*/
|
||||||
|
function writeForecastBatch(points, sensor, session) {
|
||||||
|
for (const [ts, fields] of points) {
|
||||||
|
writeGenericData('weather_forecast', fields, sensor, session, ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forza il flush del buffer di scrittura.
|
||||||
|
*/
|
||||||
|
async function flush() {
|
||||||
|
try {
|
||||||
|
await writeApi.flush();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[INFLUX] Flush error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query storica per una sessione: ritorna righe pivotate con tutti i campi.
|
||||||
|
* @param {string} sensor - nome sensore
|
||||||
|
* @param {string} session - session_id (tag InfluxDB)
|
||||||
|
* @param {string} since - ISO timestamp o duration (es. "-30d")
|
||||||
|
* @returns {Array<Object>}
|
||||||
|
*/
|
||||||
|
async function queryHistory(sensor, session, since) {
|
||||||
|
const queryApi = client.getQueryApi(org);
|
||||||
|
const fluxQuery = `
|
||||||
|
from(bucket: "${bucket}")
|
||||||
|
|> range(start: ${since})
|
||||||
|
|> filter(fn: (r) => r._measurement == "logs")
|
||||||
|
|> filter(fn: (r) => r.sensor == "${sensor}")
|
||||||
|
|> filter(fn: (r) => r.session == "${session}")
|
||||||
|
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||||
|
|> sort(columns: ["_time"])
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
queryApi.queryRows(fluxQuery, {
|
||||||
|
next(row, tableMeta) {
|
||||||
|
rows.push(tableMeta.toObject(row));
|
||||||
|
},
|
||||||
|
error: reject,
|
||||||
|
complete() { resolve(rows); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esporta tutti i dati di una sessione come CSV.
|
||||||
|
* @param {string} sensor - nome sensore
|
||||||
|
* @param {string} session - session_id
|
||||||
|
* @param {string} since - ISO timestamp inizio (opzionale, default -30d)
|
||||||
|
* @returns {string} CSV content
|
||||||
|
*/
|
||||||
|
async function exportSessionCSV(sensor, session, since) {
|
||||||
|
const start = since || '-30d';
|
||||||
|
const rows = await queryHistory(sensor, session, start);
|
||||||
|
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
|
||||||
|
// Raccogli tutti i field names (esclusi meta InfluxDB)
|
||||||
|
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
|
||||||
|
const fieldNames = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (!metaKeys.has(key) && key !== '_time') {
|
||||||
|
fieldNames.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = Array.from(fieldNames).sort();
|
||||||
|
const header = ['timestamp', ...fields].join(',');
|
||||||
|
|
||||||
|
const csvRows = rows.map(row => {
|
||||||
|
const ts = row._time || '';
|
||||||
|
const values = fields.map(f => {
|
||||||
|
const v = row[f];
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
return [ts, ...values].join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
return header + '\n' + csvRows.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV };
|
||||||
133
realtime/src/store/redis.js
Normal file
133
realtime/src/store/redis.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
const redis = require('ioredis');
|
||||||
|
|
||||||
|
const connectionsToken = "sensors_pending";
|
||||||
|
const connectedSensorsKey = "sensors";
|
||||||
|
|
||||||
|
|
||||||
|
const client = new redis({
|
||||||
|
host: process.env.REDIS_HOST,
|
||||||
|
port: parseInt(process.env.REDIS_PORT),
|
||||||
|
password: process.env.REDIS_PASSWORD,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
lazyConnect: true,
|
||||||
|
retryStrategy(times) {
|
||||||
|
const delay = Math.min(times * 50, 2000);
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', (error) => {
|
||||||
|
console.error('Redis error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
console.log('Connected to Redis');
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('reconnecting', () => {
|
||||||
|
console.log('Reconnecting to Redis');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function configure() {
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.ping();
|
||||||
|
console.log('Redis connection established');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to connect to Redis', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connected() {
|
||||||
|
return client.status === 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRedis() {
|
||||||
|
try {
|
||||||
|
if (client.status !== 'ready') {
|
||||||
|
await client.connect().catch(() => {});
|
||||||
|
}
|
||||||
|
await client.ping();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} sensor
|
||||||
|
* @param {*} status
|
||||||
|
* @param {*} timestamp
|
||||||
|
*/
|
||||||
|
async function appendAsConnection(sensor, status, timestamp) {
|
||||||
|
try {
|
||||||
|
await client.hset(`sensor:${sensor}`, 'status', status, 'timestamp', timestamp);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Redis append error', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupera i valori di un campo specifico per una chiave data.
|
||||||
|
* @param {String} name - la chiave da cui leggere, es. "sensor:123"
|
||||||
|
* @param {String} from - il nome del campo da leggere, es. "status" o "timestamp"
|
||||||
|
* @returns {Object} un oggetto contenente i valori del campo richiesto
|
||||||
|
*/
|
||||||
|
async function query(name, from) {
|
||||||
|
const key = `${from}:${name}`;
|
||||||
|
return await client.hgetall(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un token temporaneo per una nuova connessione WebSocket, scade dopo 5 secondi.
|
||||||
|
* @returns {string} il token
|
||||||
|
*/
|
||||||
|
async function createConnectionToken(sensor) {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const key = `${connectionsToken}:${token}`;
|
||||||
|
await client.set(key, sensor, 'EX', 5);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica e consuma un token di connessione.
|
||||||
|
* @returns {string|null} il nome del sensore se valido, null altrimenti
|
||||||
|
*/
|
||||||
|
async function consumeConnectionToken(token) {
|
||||||
|
const key = `${connectionsToken}:${token}`;
|
||||||
|
const sensor = await client.get(key);
|
||||||
|
if (sensor) {
|
||||||
|
await client.del(key);
|
||||||
|
}
|
||||||
|
return sensor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restituisce tutte le chiavi che corrispondono a un prefisso.
|
||||||
|
* @param {String} from - il prefisso da cercare, es. "sensor", "ws_token"
|
||||||
|
* @returns {string[]} lista di chiavi trovate
|
||||||
|
*/
|
||||||
|
async function queryAll(from) {
|
||||||
|
const keys = [];
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const [next, found] = await client.scan(cursor, 'MATCH', `${from}:*`, 'COUNT', 100);
|
||||||
|
cursor = next;
|
||||||
|
keys.push(...found);
|
||||||
|
} while (cursor !== '0');
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
configure();
|
||||||
|
|
||||||
|
async function hset(key, ...args) {
|
||||||
|
return client.hset(key, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(key) {
|
||||||
|
return client.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { checkRedis, appendAsConnection, createConnectionToken, consumeConnectionToken, query, queryAll, hset, del };
|
||||||
206
realtime/src/ws/handler.js
Normal file
206
realtime/src/ws/handler.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const { decode } = require('@msgpack/msgpack');
|
||||||
|
const { consumeConnectionToken, appendAsConnection, query, hset, del } = require('../store/redis');
|
||||||
|
const { writeGenericData, writeForecastBatch } = require('../store/influx');
|
||||||
|
const db = require('../store/db');
|
||||||
|
|
||||||
|
// In-memory registries
|
||||||
|
const sensorWatchers = new Map(); // sensorName → Set<WebSocket> (watchers)
|
||||||
|
const connectedSensors = new Map(); // sensorName → WebSocket (sensor clients)
|
||||||
|
|
||||||
|
function generateSessionId() {
|
||||||
|
const num = Math.floor(1000 + Math.random() * 9000);
|
||||||
|
return `s${num}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(server) {
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on('upgrade', async (req, socket, head) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (path === '/' || path === '') {
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensor = await consumeConnectionToken(token);
|
||||||
|
if (!sensor) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
ws.sensorName = sensor;
|
||||||
|
ws.sessionId = generateSessionId();
|
||||||
|
ws.connectedAt = new Date().toISOString();
|
||||||
|
handleSensorConnection(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (path === '/live') {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
handleWatcherConnection(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSensorConnection(ws) {
|
||||||
|
const { sensorName, sessionId, connectedAt } = ws;
|
||||||
|
console.log(`Sensor connected: ${sensorName} (session: ${sessionId})`);
|
||||||
|
|
||||||
|
// Register in global registry
|
||||||
|
connectedSensors.set(sensorName, ws);
|
||||||
|
|
||||||
|
appendAsConnection(sensorName, 'connected', connectedAt);
|
||||||
|
hset(`sensors:${sensorName}`, 'session', sessionId, 'connectedAt', connectedAt);
|
||||||
|
|
||||||
|
// Crea riga in sessiondataref su PostgreSQL (nome di default = sessionId)
|
||||||
|
try {
|
||||||
|
await db.query('sensors',
|
||||||
|
`INSERT INTO sessiondataref (session_id, sensor_name, name, created_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (session_id) DO NOTHING`,
|
||||||
|
[sessionId, sensorName, sessionId]
|
||||||
|
);
|
||||||
|
console.log(`[${sensorName}] Session ${sessionId} registrata in sessiondataref`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${sensorName}] Errore creazione sessiondataref:`, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.ping();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const packet = decode(data);
|
||||||
|
|
||||||
|
// Messaggio di inizializzazione
|
||||||
|
if (packet._t === 'init') {
|
||||||
|
ws.sensorUptime = packet.uptime || null;
|
||||||
|
console.log(`[${sensorName}] Init — uptime:`, ws.sensorUptime);
|
||||||
|
if (ws.sensorUptime != null) {
|
||||||
|
hset(`sensors:${sensorName}`, 'uptime', String(ws.sensorUptime));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ts, _m, ...fields } = packet;
|
||||||
|
|
||||||
|
// InfluxDB: usa SEMPRE sessionId come tag (non cambia mai)
|
||||||
|
if (_m === 'forecast_batch') {
|
||||||
|
if (Array.isArray(fields.points)) {
|
||||||
|
writeForecastBatch(fields.points, sensorName, sessionId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const measurement = _m || 'sensor_data';
|
||||||
|
writeGenericData(measurement, fields, sensorName, sessionId, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast ai watchers
|
||||||
|
const watchers = sensorWatchers.get(sensorName);
|
||||||
|
if (watchers && watchers.size > 0) {
|
||||||
|
const msg = JSON.stringify({
|
||||||
|
timestamp: ts,
|
||||||
|
measurement: _m || 'sensor_data',
|
||||||
|
fields: fields
|
||||||
|
});
|
||||||
|
for (const watcher of watchers) {
|
||||||
|
if (watcher.readyState === watcher.OPEN) {
|
||||||
|
watcher.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error processing sensor data from ${sensorName}:`, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', async () => {
|
||||||
|
console.log(`Sensor disconnected: ${sensorName}`);
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
connectedSensors.delete(sensorName);
|
||||||
|
appendAsConnection(sensorName, 'disconnected', new Date().toISOString());
|
||||||
|
|
||||||
|
// Aggiorna disconnected_at in sessiondataref
|
||||||
|
try {
|
||||||
|
await db.query('sensors',
|
||||||
|
`UPDATE sessiondataref SET disconnected_at = NOW() WHERE session_id = $1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${sensorName}] Errore update disconnected_at:`, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
del(`sensors:${sensorName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error(`WebSocket error for sensor ${sensorName}:`, err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWatcherConnection(ws) {
|
||||||
|
console.log('Watcher connected, waiting for watch action...');
|
||||||
|
|
||||||
|
ws.on('message', async (data) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
|
||||||
|
if (msg.action === 'watch' && msg.sensorId) {
|
||||||
|
if (ws.sensorName) {
|
||||||
|
sensorWatchers.get(ws.sensorName)?.delete(ws);
|
||||||
|
if (sensorWatchers.get(ws.sensorName)?.size === 0) {
|
||||||
|
sensorWatchers.delete(ws.sensorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.sensorName = msg.sensorId;
|
||||||
|
|
||||||
|
if (!sensorWatchers.has(msg.sensorId)) {
|
||||||
|
sensorWatchers.set(msg.sensorId, new Set());
|
||||||
|
}
|
||||||
|
sensorWatchers.get(msg.sensorId).add(ws);
|
||||||
|
|
||||||
|
console.log(`Watcher now watching sensor: ${msg.sensorId}`);
|
||||||
|
|
||||||
|
} else if (msg.action === 'unwatch') {
|
||||||
|
if (ws.sensorName) {
|
||||||
|
sensorWatchers.get(ws.sensorName)?.delete(ws);
|
||||||
|
if (sensorWatchers.get(ws.sensorName)?.size === 0) {
|
||||||
|
sensorWatchers.delete(ws.sensorName);
|
||||||
|
}
|
||||||
|
ws.sensorName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore non-JSON messages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (ws.sensorName) {
|
||||||
|
sensorWatchers.get(ws.sensorName)?.delete(ws);
|
||||||
|
if (sensorWatchers.get(ws.sensorName)?.size === 0) {
|
||||||
|
sensorWatchers.delete(ws.sensorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Watcher disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error('WebSocket error for watcher:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setup, connectedSensors };
|
||||||
Reference in New Issue
Block a user