feat: Add new API endpoints and HTML pages for ML model management
- Implemented HTML pages for datasets, models, training, testing, and results. - Created API endpoints for managing repositories, results, tests, and training sessions. - Added functionality for streaming training progress via Server-Sent Events (SSE). - Introduced a Dockerfile for the ML runner with necessary dependencies. - Developed an SDK for user code execution within the runner container. - Enhanced CSS styles for improved UI layout and navigation. - Established a layout template for consistent HTML structure across pages. - Added JavaScript for dynamic interactions on the models page. - Implemented WebSocket handling for real-time communication with kiosk devices and controllers. - Implemented model registration and management API at /api/models - Added Gitea proxy API for repository interactions at /api/repos - Created results API for listing and comparing training results at /api/results - Developed training management API for enqueueing and retrieving training jobs at /api/trainings - Introduced SSE endpoint for live training progress updates - Added HTML pages for models, datasets, and training management - Created a Dockerfile for the ML runner with necessary dependencies - Developed SDK for user code execution within the runner container - Enhanced CSS styles for improved UI/UX - Implemented WebSocket communication for real-time device and controller interactions in the kiosk system
This commit is contained in:
153
realtime/package-lock.json
generated
153
realtime/package-lock.json
generated
@@ -11,8 +11,10 @@
|
||||
"@influxdata/influxdb-client": "^1.35.0",
|
||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.20.0",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
@@ -84,6 +86,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -162,6 +170,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
@@ -220,6 +247,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -525,18 +561,103 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -876,12 +997,44 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
|
||||
@@ -45,9 +45,13 @@ app.get('/health', (req, res) => {
|
||||
app.use('/connect', require('./routes/connect'));
|
||||
app.use('/sensors', require('./routes/sensors'));
|
||||
app.use('/sessions', require('./routes/sessions'));
|
||||
app.use('/rules', require('./routes/rules'));
|
||||
|
||||
const server = app.listen(3000, '0.0.0.0', () => {
|
||||
console.log(`Realtime started`);
|
||||
});
|
||||
|
||||
wsHandler.setup(server);
|
||||
|
||||
// deve essere caricato DOPO setup per avere kioskRelay pronto
|
||||
app.use('/kiosk', require('./routes/kiosk'));
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
const router = require('express').Router();
|
||||
const db = require('../store/db');
|
||||
const { kioskRelay } = require('../ws/handler');
|
||||
|
||||
// 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' });
|
||||
}
|
||||
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
|
||||
|
||||
function requireInternal(req, res, next) {
|
||||
if (!INTERNAL_KEY || req.headers['x-api-key'] !== INTERNAL_KEY)
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
next();
|
||||
}
|
||||
|
||||
// Chiamato dall'API quando cambia il template attivo
|
||||
router.post('/notify-active', requireInternal, (req, res) => {
|
||||
const { template } = req.body || {};
|
||||
if (!template || !template.id) return res.status(400).json({ error: 'template.id required' });
|
||||
kioskRelay.notifyActiveTemplateChange(template);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// Stato dispositivi connessi (diagnostica)
|
||||
router.get('/status', requireInternal, (req, res) => {
|
||||
const list = [];
|
||||
for (const [name, ws] of kioskRelay.devices) {
|
||||
list.push({ sensor: name, templateId: ws.templateId || null, lastSeen: ws.lastSeen || null });
|
||||
}
|
||||
res.json({ devices: list });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
57
realtime/src/routes/rules.js
Normal file
57
realtime/src/routes/rules.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Relay HTTP → WS per il push dei rulesets ai sensori.
|
||||
* Chiamato SOLO dal servizio api (internal, x-api-key).
|
||||
*
|
||||
* POST /rules/push
|
||||
* Body: { sensors: [name, ...], type, ruleset }
|
||||
* -> invia msgpack { _t: 'ruleset_update', type, ruleset } ad ogni sensore
|
||||
* online tramite la connessione WS gia' stabilita.
|
||||
*/
|
||||
|
||||
const router = require('express').Router();
|
||||
const { encode } = require('@msgpack/msgpack');
|
||||
const { connectedSensors } = require('../ws/handler');
|
||||
|
||||
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
|
||||
|
||||
function requireInternal(req, res, next) {
|
||||
const k = req.headers['x-api-key'];
|
||||
if (!INTERNAL_KEY || !k || k !== INTERNAL_KEY) {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.post('/push', requireInternal, (req, res) => {
|
||||
const { sensors, type, ruleset } = req.body || {};
|
||||
if (!Array.isArray(sensors) || !sensors.length) return res.status(400).json({ error: 'sensors array required' });
|
||||
if (!type || !ruleset) return res.status(400).json({ error: 'type and ruleset required' });
|
||||
|
||||
const payload = { _t: 'ruleset_update', type, ruleset };
|
||||
let encoded;
|
||||
try {
|
||||
encoded = encode(payload);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: `encode error: ${err.message}` });
|
||||
}
|
||||
|
||||
const pushed = [], offline = [], errors = [];
|
||||
for (const name of sensors) {
|
||||
const ws = connectedSensors.get(name);
|
||||
if (!ws || ws.readyState !== ws.OPEN) {
|
||||
offline.push(name);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ws.send(encoded);
|
||||
pushed.push(name);
|
||||
} catch (err) {
|
||||
errors.push({ sensor: name, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[RULES] push type=${type} v=${ruleset?.version?.str || '?'} → pushed=${pushed.length} offline=${offline.length} err=${errors.length}`);
|
||||
res.json({ pushed, offline, errors });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -5,16 +5,40 @@ const client = new InfluxDB({
|
||||
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,
|
||||
});
|
||||
// Bucket dedicati per dominio. Il default per i logs viene mantenuto su
|
||||
// INFLX_BUCKET per retro-compatibilità con la configurazione esistente.
|
||||
// Per i dati meteo current e forecast usiamo bucket separati: sono dati
|
||||
// indipendenti dai logs (frequenze e retention diverse) e tenerli separati
|
||||
// permette policy di retention più aggressive per i forecast (timestamp
|
||||
// futuri sovrascritti spesso) senza toccare il volume dei logs.
|
||||
const BUCKETS = {
|
||||
logs: process.env.INFLX_BUCKET_LOGS || process.env.INFLX_BUCKET || 'logs',
|
||||
weather: process.env.INFLX_BUCKET_WEATHER || 'weather_current',
|
||||
weather_forecast: process.env.INFLX_BUCKET_FORECAST || 'weather_forecast',
|
||||
};
|
||||
|
||||
const writeApis = {};
|
||||
function getWriteApi(bucket) {
|
||||
if (!writeApis[bucket]) {
|
||||
writeApis[bucket] = client.getWriteApi(org, bucket, 'ms', {
|
||||
flushInterval: 1000,
|
||||
batchSize: 200,
|
||||
maxRetries: 3,
|
||||
});
|
||||
}
|
||||
return writeApis[bucket];
|
||||
}
|
||||
|
||||
function bucketFor(measurement) {
|
||||
if (measurement === 'weather') return BUCKETS.weather;
|
||||
if (measurement === 'weather_forecast') return BUCKETS.weather_forecast;
|
||||
return BUCKETS.logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrive dati generici su InfluxDB senza mapping.
|
||||
* Scrive dati generici su InfluxDB nel bucket appropriato per il measurement.
|
||||
* @param {string} measurement - nome della measurement (es. 'logs', 'weather')
|
||||
* @param {Object} fields - campi { key: value }
|
||||
* @param {string} sensor - nome del sensore
|
||||
@@ -36,11 +60,12 @@ function writeGenericData(measurement, fields, sensor, session, timestamp) {
|
||||
}
|
||||
}
|
||||
|
||||
writeApi.writePoint(point);
|
||||
getWriteApi(bucketFor(measurement)).writePoint(point);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrive un batch di punti forecast (previsioni orarie).
|
||||
* Usa il bucket weather_forecast (non i logs).
|
||||
* @param {Array} points - array di [timestamp_ms, { key: value, ... }]
|
||||
* @param {string} sensor - nome del sensore
|
||||
* @param {string} session - id sessione
|
||||
@@ -52,14 +77,14 @@ function writeForecastBatch(points, sensor, session) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Forza il flush del buffer di scrittura.
|
||||
* Forza il flush dei buffer di scrittura su tutti i bucket.
|
||||
*/
|
||||
async function flush() {
|
||||
try {
|
||||
await writeApi.flush();
|
||||
} catch (err) {
|
||||
console.error('[INFLUX] Flush error:', err.message);
|
||||
}
|
||||
await Promise.all(Object.values(writeApis).map(async (wa) => {
|
||||
try { await wa.flush(); } catch (err) {
|
||||
console.error('[INFLUX] Flush error:', err.message);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +97,7 @@ async function flush() {
|
||||
async function queryHistory(sensor, session, since) {
|
||||
const queryApi = client.getQueryApi(org);
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
from(bucket: "${BUCKETS.logs}")
|
||||
|> range(start: ${since})
|
||||
|> filter(fn: (r) => r._measurement == "logs")
|
||||
|> filter(fn: (r) => r.sensor == "${sensor}")
|
||||
@@ -95,10 +120,6 @@ async function queryHistory(sensor, session, since) {
|
||||
|
||||
/**
|
||||
* Esporta tutti i dati di una sessione come CSV.
|
||||
* @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';
|
||||
@@ -106,7 +127,6 @@ async function exportSessionCSV(sensor, session, since) {
|
||||
|
||||
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) {
|
||||
@@ -133,4 +153,4 @@ async function exportSessionCSV(sensor, session, since) {
|
||||
return header + '\n' + csvRows.join('\n') + '\n';
|
||||
}
|
||||
|
||||
module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV };
|
||||
module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV, BUCKETS };
|
||||
|
||||
@@ -3,6 +3,7 @@ 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');
|
||||
const kioskRelay = require('./kiosk');
|
||||
|
||||
// In-memory registries
|
||||
const sensorWatchers = new Map(); // sensorName → Set<WebSocket> (watchers)
|
||||
@@ -42,6 +43,9 @@ function setup(server) {
|
||||
handleSensorConnection(ws);
|
||||
});
|
||||
|
||||
} else if (path === '/kiosk') {
|
||||
await kioskRelay.handleUpgrade(wss, req, socket, head, url);
|
||||
|
||||
} else if (path === '/live') {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
handleWatcherConnection(ws);
|
||||
@@ -95,6 +99,58 @@ async function handleSensorConnection(ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset sessione richiesto dal plugin (es. dopo un nuovo ruleset
|
||||
// di logs/meteo). La connessione WS persiste: cambiamo solo il
|
||||
// sessionId, marchiamo la vecchia come disconnessa e creiamo la
|
||||
// nuova in sessiondataref. I dati successivi useranno il nuovo tag.
|
||||
if (packet._t === 'session_reset') {
|
||||
const prev = ws.sessionId;
|
||||
const next = generateSessionId();
|
||||
ws.sessionId = next;
|
||||
console.log(`[${sensorName}] session_reset ${prev} → ${next} (reason: ${packet.reason || 'n/a'})`);
|
||||
try {
|
||||
await db.query('sensors',
|
||||
`UPDATE sessiondataref SET disconnected_at = NOW() WHERE session_id = $1 AND disconnected_at IS NULL`,
|
||||
[prev]
|
||||
);
|
||||
await db.query('sensors',
|
||||
`INSERT INTO sessiondataref (session_id, sensor_name, name, created_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (session_id) DO NOTHING`,
|
||||
[next, sensorName, next]
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[${sensorName}] session_reset DB error:`, err.message);
|
||||
}
|
||||
hset(`sensors:${sensorName}`, 'session', next);
|
||||
try {
|
||||
const { encode } = require('@msgpack/msgpack');
|
||||
ws.send(encode({ _t: 'session_id', sessionId: next, prev }));
|
||||
} catch (err) {
|
||||
console.error(`[${sensorName}] session_reset reply error:`, err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ACK di un ruleset ricevuto e applicato: il plugin ci dice
|
||||
// che la versione X del tipo Y e' ora attiva sul device.
|
||||
if (packet._t === 'ruleset_ack') {
|
||||
const { type, ruleset_id } = packet;
|
||||
if (type && ruleset_id) {
|
||||
const API = process.env.API_URL || 'http://meb-api:3000';
|
||||
const KEY = process.env.INTERNAL_API_KEY;
|
||||
if (KEY) {
|
||||
fetch(`${API}/rules/${type}/${ruleset_id}/ack`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
|
||||
body: JSON.stringify({ sensor: sensorName })
|
||||
}).catch(err => console.error(`[${sensorName}] ruleset_ack forward error:`, err.message));
|
||||
}
|
||||
console.log(`[${sensorName}] ruleset_ack type=${type} id=${ruleset_id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { ts, _m, ...fields } = packet;
|
||||
|
||||
// InfluxDB: usa SEMPRE sessionId come tag (non cambia mai)
|
||||
@@ -203,4 +259,4 @@ function handleWatcherConnection(ws) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { setup, connectedSensors };
|
||||
module.exports = { setup, connectedSensors, kioskRelay };
|
||||
|
||||
167
realtime/src/ws/kiosk.js
Normal file
167
realtime/src/ws/kiosk.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Kiosk realtime relay.
|
||||
* Due ruoli:
|
||||
* - device: il plugin kiosk sulla barca (uno per sensorName)
|
||||
* - controller: la pagina kiosklive.html (N per sensorName)
|
||||
* Messaggi JSON (no msgpack, canale leggero).
|
||||
*/
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { consumeConnectionToken } = require('../store/redis');
|
||||
|
||||
const devices = new Map(); // sensorName → ws
|
||||
const controllers = new Map(); // sensorName → Set<ws>
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
function verifyJwt(token) {
|
||||
if (!token || !JWT_SECRET) return null;
|
||||
try { return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); } catch { return null; }
|
||||
}
|
||||
|
||||
function extractCookie(req, name) {
|
||||
const raw = req.headers.cookie || '';
|
||||
const m = raw.split(';').map(s => s.trim()).find(s => s.startsWith(name + '='));
|
||||
return m ? decodeURIComponent(m.slice(name.length + 1)) : null;
|
||||
}
|
||||
|
||||
function send(ws, obj) {
|
||||
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function broadcastControllers(sensorName, obj) {
|
||||
const set = controllers.get(sensorName);
|
||||
if (!set) return;
|
||||
const msg = JSON.stringify(obj);
|
||||
for (const c of set) if (c.readyState === c.OPEN) c.send(msg);
|
||||
}
|
||||
|
||||
function deviceStatus(sensorName) {
|
||||
const d = devices.get(sensorName);
|
||||
return {
|
||||
t: 'kiosk_status',
|
||||
online: !!d,
|
||||
templateId: d?.templateId || null,
|
||||
lastSeen: d?.lastSeen || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestisce l'upgrade per /kiosk?role=device|controller&sensor=<name>
|
||||
* @returns true se gestito
|
||||
*/
|
||||
async function handleUpgrade(wss, req, socket, head, url) {
|
||||
const role = url.searchParams.get('role');
|
||||
const sensorName = url.searchParams.get('sensor');
|
||||
|
||||
if (!role || !sensorName) {
|
||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return true;
|
||||
}
|
||||
|
||||
if (role === 'device') {
|
||||
const token = url.searchParams.get('token');
|
||||
const sensor = token ? await consumeConnectionToken(token) : null;
|
||||
if (!sensor || sensor !== sensorName) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return true;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sensorName = sensorName;
|
||||
ws.role = 'device';
|
||||
attachDevice(ws);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (role === 'controller') {
|
||||
const token = extractCookie(req, 'auth_token') || url.searchParams.get('token');
|
||||
const payload = verifyJwt(token);
|
||||
if (!payload) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return true;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sensorName = sensorName;
|
||||
ws.role = 'controller';
|
||||
ws.user = payload.sub;
|
||||
attachController(ws);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return true;
|
||||
}
|
||||
|
||||
function attachDevice(ws) {
|
||||
const name = ws.sensorName;
|
||||
const prev = devices.get(name);
|
||||
if (prev && prev.readyState === prev.OPEN) prev.close(4000, 'replaced');
|
||||
devices.set(name, ws);
|
||||
ws.lastSeen = Date.now();
|
||||
broadcastControllers(name, deviceStatus(name));
|
||||
console.log(`[kiosk] device online: ${name}`);
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let m; try { m = JSON.parse(raw.toString()); } catch { return; }
|
||||
ws.lastSeen = Date.now();
|
||||
switch (m.t) {
|
||||
case 'hello':
|
||||
ws.templateId = m.templateId || null;
|
||||
broadcastControllers(name, deviceStatus(name));
|
||||
break;
|
||||
case 'ack':
|
||||
broadcastControllers(name, m);
|
||||
break;
|
||||
case 'heartbeat':
|
||||
break;
|
||||
default:
|
||||
// echo diagnostici opzionali
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const hb = setInterval(() => { if (ws.readyState === ws.OPEN) ws.ping(); }, 25000);
|
||||
|
||||
ws.on('close', () => {
|
||||
clearInterval(hb);
|
||||
if (devices.get(name) === ws) devices.delete(name);
|
||||
broadcastControllers(name, deviceStatus(name));
|
||||
console.log(`[kiosk] device offline: ${name}`);
|
||||
});
|
||||
ws.on('error', () => {});
|
||||
}
|
||||
|
||||
function attachController(ws) {
|
||||
const name = ws.sensorName;
|
||||
if (!controllers.has(name)) controllers.set(name, new Set());
|
||||
controllers.get(name).add(ws);
|
||||
send(ws, deviceStatus(name));
|
||||
console.log(`[kiosk] controller connected on ${name} (total=${controllers.get(name).size})`);
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let m; try { m = JSON.parse(raw.toString()); } catch { return; }
|
||||
const allowed = ['patch_box','add_box','remove_box','load_template','apply_inline','persist','reload'];
|
||||
if (!allowed.includes(m.t)) return;
|
||||
const device = devices.get(name);
|
||||
if (!device || device.readyState !== device.OPEN) {
|
||||
send(ws, { t: 'ack', cmdId: m.cmdId, ok: false, err: 'device offline' });
|
||||
return;
|
||||
}
|
||||
send(device, m);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const set = controllers.get(name);
|
||||
if (set) { set.delete(ws); if (!set.size) controllers.delete(name); }
|
||||
});
|
||||
ws.on('error', () => {});
|
||||
}
|
||||
|
||||
/** HTTP notify usato dall'API quando cambia template attivo */
|
||||
function notifyActiveTemplateChange(template) {
|
||||
for (const [name, ws] of devices) {
|
||||
send(ws, { t: 'load_template', templateId: template.id });
|
||||
}
|
||||
for (const name of controllers.keys()) {
|
||||
broadcastControllers(name, { t: 'active_template_changed', templateId: template.id });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleUpgrade, notifyActiveTemplateChange, devices };
|
||||
Reference in New Issue
Block a user