diff --git a/api/src/index.js b/api/src/index.js index 28557a8..528cfc9 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -96,6 +96,9 @@ app.use('/params', paramsRoutes) const settingsRoutes = require('./routes/settings') app.use('/settings', settingsRoutes) +const sessionsRoutes = require('./routes/sessions') +app.use('/sessions', sessionsRoutes) + app.listen(PORT, '0.0.0.0', () => { console.log(`Started on port ${PORT}`); }); diff --git a/api/src/routes/sessions.js b/api/src/routes/sessions.js new file mode 100644 index 0000000..125e646 --- /dev/null +++ b/api/src/routes/sessions.js @@ -0,0 +1,88 @@ +const router = require('express').Router(); +const { query: dbQuery } = require('../storage/postgres'); +const { querySessionHistory, exportSessionCSV } = require('../storage/influx'); + +/** + * GET /sessions/history + * Lista tutte le sessioni di registrazione storiche da PostgreSQL (sessiondataref). + */ +router.get('/history', async (req, res) => { + try { + const result = await dbQuery( + `SELECT * FROM sessiondataref ORDER BY created_at DESC LIMIT 100`, + [], + 'sensors' + ); + res.json(result.rows); + } 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; diff --git a/api/src/storage/influx.js b/api/src/storage/influx.js index c369917..0944bdd 100644 --- a/api/src/storage/influx.js +++ b/api/src/storage/influx.js @@ -60,6 +60,64 @@ async function query(bucket, relativeTime, measurement, sensor, field) { } +const sessionBucket = process.env.INFLX_BUCKET || 'logs'; + +/** + * 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>} + */ +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} + */ +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'; +} + async function checkInflux() { try { const result = await querying.collectRows( @@ -78,9 +136,11 @@ async function checkInflux() { } module.exports = { - write:append, + write: append, writeBatch, query, + querySessionHistory, + exportSessionCSV, checkInflux } diff --git a/console/src/index.js b/console/src/index.js index b112a4e..1358213 100644 --- a/console/src/index.js +++ b/console/src/index.js @@ -91,6 +91,11 @@ 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', () => { console.log(`Started on port ${PORT}`); }); diff --git a/console/src/pages/kioskedit.html b/console/src/pages/kioskedit.html new file mode 100644 index 0000000..f362d13 --- /dev/null +++ b/console/src/pages/kioskedit.html @@ -0,0 +1,558 @@ + + + + + + Kiosk Dashboard + + + + + + + + +
+
Trascina o aggiungi una card per iniziare
+
+ + +
+
1u = 0px
+
+ + + + +
+

0 cards

+ + + + + + + + + +
+ + + + + + + + diff --git a/console/src/pages/sessions.html b/console/src/pages/sessions.html new file mode 100644 index 0000000..7265e1e --- /dev/null +++ b/console/src/pages/sessions.html @@ -0,0 +1,922 @@ + + + + + + + + + + +
+ + +
+
+
Caricamento dati sessione...
+
+ + +
+
+ + +
+
Caricamento sessioni...
+
+
+
+ + +
+
+ +

Sessioni

+
+
+ + +
+
+ + +
+ + +
+
+
Nessun dato GPS per questa sessione
+ +
+ + +
+
+
+ + +
+
+ + + + +
+
+
+
+
+ + +
+
+

Dettaglio

+ +
+
+ +
+
+ + +
+
+ +
+
+
+
+
+
+
+
+ + + +
+
+ +
+
+ +
+ +
+ + + + diff --git a/console/src/static/fonts/atkinson-bold.ttf b/console/src/static/fonts/atkinson-bold.ttf new file mode 100644 index 0000000..80da7d0 Binary files /dev/null and b/console/src/static/fonts/atkinson-bold.ttf differ diff --git a/console/src/static/fonts/atkinson-regular.ttf b/console/src/static/fonts/atkinson-regular.ttf new file mode 100644 index 0000000..f0edd19 Binary files /dev/null and b/console/src/static/fonts/atkinson-regular.ttf differ diff --git a/console/src/static/styles/kiosk.css b/console/src/static/styles/kiosk.css new file mode 100644 index 0000000..75ad743 --- /dev/null +++ b/console/src/static/styles/kiosk.css @@ -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; +} \ No newline at end of file diff --git a/realtime/src/routes/kiosk.js b/realtime/src/routes/kiosk.js new file mode 100644 index 0000000..1b8ac67 --- /dev/null +++ b/realtime/src/routes/kiosk.js @@ -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; \ No newline at end of file diff --git a/realtime/src/store/db.js b/realtime/src/store/db.js index d5f28f5..2595b80 100644 --- a/realtime/src/store/db.js +++ b/realtime/src/store/db.js @@ -11,8 +11,9 @@ const baseConfig = { }; const dbs = { - data: { name: process.env.DATA_DB || 'data' }, - sensors: { name: process.env.SENSORS_DB || 'sensors' } + data: { name: 'data' }, + sensors: { name: 'sensors' }, + kiosk: { name: 'kiosk' }, } const pools = {}; diff --git a/realtime/src/store/influx.js b/realtime/src/store/influx.js index 63bbfc2..dc1b56b 100644 --- a/realtime/src/store/influx.js +++ b/realtime/src/store/influx.js @@ -5,7 +5,7 @@ const client = new InfluxDB({ token: process.env.INFLX_TOKEN, }); -const bucket = process.env.INFLX_BUCKET || 'sensors'; +const bucket = process.env.INFLX_BUCKET || 'logs'; const org = process.env.INFLX_ORG; const writeApi = client.getWriteApi(org, bucket, 'ms', {