diff --git a/api/package-lock.json b/api/package-lock.json index 6bc1a97..496e131 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@influxdata/influxdb-client": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0", + "@msgpack/msgpack": "^3.1.3", "cookie-parser": "^1.4.7", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -40,6 +41,15 @@ "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/api/package.json b/api/package.json index 465261d..daca388 100644 --- a/api/package.json +++ b/api/package.json @@ -11,6 +11,7 @@ "dependencies": { "@influxdata/influxdb-client": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0", + "@msgpack/msgpack": "^3.1.3", "cookie-parser": "^1.4.7", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -19,4 +20,4 @@ "minio": "^8.0.7", "pg": "^8.20.0" } -} +} \ No newline at end of file diff --git a/api/src/index.js b/api/src/index.js index 36aad80..7b20121 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -81,7 +81,10 @@ app.use('/params', paramsRoutes) const settingsRoutes = require('./routes/settings') app.use('/settings', settingsRoutes) -// Avvio del server +const rulesRoutes = require('./routes/rules') +app.use('/rules', rulesRoutes) + + app.listen(PORT, '0.0.0.0', () => { console.log(`Started on port ${PORT}`); }); diff --git a/api/src/routes/rules.js b/api/src/routes/rules.js new file mode 100644 index 0000000..5b17cf6 --- /dev/null +++ b/api/src/routes/rules.js @@ -0,0 +1,497 @@ +const router = require('express').Router(); +const db = require('../storage/postgres'); +const { encode } = require('@msgpack/msgpack'); + +// Mapping tipo → tabelle +const TYPE_MAP = { + weather: { rules: 'weather', items: 'weatheritems' }, + data: { rules: 'dataread', items: 'datareaditems' }, + logs: { rules: 'logs', items: 'logitems' } +}; + +const VALID_TYPES = Object.keys(TYPE_MAP); + +/** + * GET /rules — Lista tutte le rules (id, version, active) per ogni tipo + */ +router.get('/', async (req, res) => { + try { + const result = {}; + for (const [type, tables] of Object.entries(TYPE_MAP)) { + const { rows } = await db.query( + `SELECT * FROM ${tables.rules} ORDER BY created_at DESC`, + [], 'rules' + ); + result[type] = rows; + } + res.json(result); + } catch (err) { + console.error('Error fetching rules', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * GET /rules/versions — Ritorna solo id+version delle 3 rules attive (check rapido) + */ +router.get('/versions', async (req, res) => { + try { + const versions = {}; + for (const [type, tables] of Object.entries(TYPE_MAP)) { + const { rows } = await db.query( + `SELECT id, version FROM ${tables.rules} WHERE active = true AND archived = false LIMIT 1`, + [], 'rules' + ); + versions[type] = rows[0] || null; + } + res.json(versions); + } catch (err) { + console.error('Error fetching versions', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * GET /rules/active?type=weather — Ritorna la rule attiva completa con items + * Supporta Accept: application/msgpack per formato compatto + */ +router.get('/active', async (req, res) => { + const { type } = req.query; + if (!type || !VALID_TYPES.includes(type)) { + return res.status(400).json({ error: `invalid type, must be one of: ${VALID_TYPES.join(', ')}` }); + } + + const tables = TYPE_MAP[type]; + + try { + const { rows: ruleRows } = await db.query( + `SELECT * FROM ${tables.rules} WHERE active = true AND archived = false LIMIT 1`, + [], 'rules' + ); + + if (ruleRows.length === 0) { + return res.status(404).json({ error: `no active ${type} rule found` }); + } + + const rule = ruleRows[0]; + + const { rows: items } = await db.query( + `SELECT * FROM ${tables.items} WHERE rule_id = $1 AND enabled = true`, + [rule.id], 'rules' + ); + + const payload = { + id: rule.id, + version: rule.version, + description: rule.description, + tags: rule.tags, + items + }; + + // Se il client accetta msgpack, rispondi in binario + if (req.accepts('application/msgpack')) { + res.set('Content-Type', 'application/msgpack'); + return res.send(Buffer.from(encode(payload))); + } + + res.json(payload); + } catch (err) { + console.error('Error fetching active rule', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * GET /rules/:type/:id — Dettaglio di una rule specifica con items + */ +router.get('/:type/:id', async (req, res) => { + const { type, id } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + + try { + const { rows: ruleRows } = await db.query( + `SELECT * FROM ${tables.rules} WHERE id = $1`, + [id], 'rules' + ); + + if (ruleRows.length === 0) { + return res.status(404).json({ error: 'rule not found' }); + } + + const rule = ruleRows[0]; + const { rows: items } = await db.query( + `SELECT * FROM ${tables.items} WHERE rule_id = $1`, + [rule.id], 'rules' + ); + + res.json({ ...rule, items }); + } catch (err) { + console.error('Error fetching rule', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * PATCH /rules/update?type=weather&from=1.0.0 + * Ritorna le rules con versione > from (per aggiornamenti incrementali) + */ +router.patch('/update', async (req, res) => { + const { type, from } = req.query; + + if (!type || !VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + if (!from) { + return res.status(400).json({ error: 'missing from parameter' }); + } + + const tables = TYPE_MAP[type]; + + try { + const { rows } = await db.query( + `SELECT * FROM ${tables.rules} WHERE version > $1 AND archived = false ORDER BY version ASC`, + [from], 'rules' + ); + + if (rows.length === 0) { + return res.status(404).json({ error: 'no rules found with version greater than specified' }); + } + + // Per ogni rule, allegare gli items + const results = []; + for (const rule of rows) { + const { rows: items } = await db.query( + `SELECT * FROM ${tables.items} WHERE rule_id = $1`, + [rule.id], 'rules' + ); + results.push({ ...rule, items }); + } + + if (req.accepts('application/msgpack')) { + res.set('Content-Type', 'application/msgpack'); + return res.send(Buffer.from(encode(results))); + } + + res.json(results); + } catch (err) { + console.error('Error fetching rules update', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +// --- ID Generation --- +const TYPE_PREFIX = { weather: 'w', data: 'd', logs: 'l' }; + +function generateId(type) { + const prefix = TYPE_PREFIX[type] || 'x'; + const num = Math.floor(1000 + Math.random() * 9000); + // Pad to 8 chars with trailing zeros + return (prefix + num).padEnd(8, '0'); +} + +// --- ITEM FIELD DEFINITIONS per tipo --- +const ITEM_FIELDS = { + weather: ['group_name', 'ref', 'name', 'unit', 'enabled'], + data: ['category', 'path', 'unit', 'enabled'], + logs: ['path', 'ref', 'unit', 'measurement', 'enabled'] +}; + +// Campi rule aggiornabili +const RULE_UPDATE_FIELDS = { + weather: ['version', 'tags', 'description'], + data: ['version', 'tags'], + logs: ['version', 'tags', 'description', 'browser_rule_id'] +}; + +/** + * POST /rules/:type — Crea una nuova rule + */ +router.post('/:type', async (req, res) => { + const { type } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + const id = generateId(type); + const { version, tags, description, browser_rule_id } = req.body; + + if (!version) { + return res.status(400).json({ error: 'version is required' }); + } + + try { + let sql, params; + if (type === 'weather') { + sql = `INSERT INTO ${tables.rules} (id, version, tags, description) VALUES ($1, $2, $3, $4) RETURNING *`; + params = [id, version, tags || [], description || null]; + } else if (type === 'logs') { + sql = `INSERT INTO ${tables.rules} (id, version, tags, description, browser_rule_id) VALUES ($1, $2, $3, $4, $5) RETURNING *`; + params = [id, version, tags || [], description || null, browser_rule_id || null]; + } else { + sql = `INSERT INTO ${tables.rules} (id, version, tags) VALUES ($1, $2, $3) RETURNING *`; + params = [id, version, tags || []]; + } + + const { rows } = await db.query(sql, params, 'rules'); + res.status(201).json(rows[0]); + } catch (err) { + console.error('Error creating rule', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * PUT /rules/:type/:id — Aggiorna una rule + */ +router.put('/:type/:id', async (req, res) => { + const { type, id } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + const allowed = RULE_UPDATE_FIELDS[type]; + const sets = []; + const params = []; + let idx = 1; + + for (const field of allowed) { + if (req.body[field] !== undefined) { + sets.push(`${field} = $${idx}`); + params.push(req.body[field]); + idx++; + } + } + + if (sets.length === 0) { + return res.status(400).json({ error: 'no valid fields to update' }); + } + + sets.push(`updated_at = NOW()`); + params.push(id); + + try { + const sql = `UPDATE ${tables.rules} SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`; + const { rows } = await db.query(sql, params, 'rules'); + if (rows.length === 0) return res.status(404).json({ error: 'rule not found' }); + res.json(rows[0]); + } catch (err) { + console.error('Error updating rule', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * DELETE /rules/:type/:id — Elimina una rule (cascade items) + */ +router.delete('/:type/:id', async (req, res) => { + const { type, id } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + + try { + const { rowCount } = await db.query( + `DELETE FROM ${tables.rules} WHERE id = $1`, [id], 'rules' + ); + if (rowCount === 0) return res.status(404).json({ error: 'rule not found' }); + res.json({ status: 'ok' }); + } catch (err) { + console.error('Error deleting rule', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * PATCH /rules/:type/:id/active — Toggle active + * Disattiva tutte le altre, poi attiva questa (o disattiva se già attiva) + */ +router.patch('/:type/:id/active', async (req, res) => { + const { type, id } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + + try { + // Check current state + const { rows: current } = await db.query( + `SELECT active FROM ${tables.rules} WHERE id = $1`, [id], 'rules' + ); + if (current.length === 0) return res.status(404).json({ error: 'rule not found' }); + + const isActive = current[0].active; + + if (isActive) { + // Disattiva + await db.query(`UPDATE ${tables.rules} SET active = false, updated_at = NOW() WHERE id = $1`, [id], 'rules'); + } else { + // Disattiva tutte, poi attiva questa + await db.query(`UPDATE ${tables.rules} SET active = false, updated_at = NOW() WHERE active = true`, [], 'rules'); + await db.query(`UPDATE ${tables.rules} SET active = true, updated_at = NOW() WHERE id = $1`, [id], 'rules'); + } + + res.json({ status: 'ok', active: !isActive }); + } catch (err) { + console.error('Error toggling active', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * PATCH /rules/:type/:id/archive — Toggle archived + */ +router.patch('/:type/:id/archive', async (req, res) => { + const { type, id } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + + try { + const { rows } = await db.query( + `UPDATE ${tables.rules} SET archived = NOT archived, updated_at = NOW() WHERE id = $1 RETURNING archived`, + [id], 'rules' + ); + if (rows.length === 0) return res.status(404).json({ error: 'rule not found' }); + res.json({ status: 'ok', archived: rows[0].archived }); + } catch (err) { + console.error('Error toggling archive', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * POST /rules/:type/:id/items — Aggiungi item + */ +router.post('/:type/:id/items', async (req, res) => { + const { type, id } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + const fields = ITEM_FIELDS[type].filter(f => f !== 'enabled'); + const values = fields.map(f => req.body[f]); + + // Validate required fields present + for (let i = 0; i < fields.length; i++) { + if (values[i] === undefined || values[i] === null || values[i] === '') { + return res.status(400).json({ error: `${fields[i]} is required` }); + } + } + + const allFields = ['rule_id', ...fields]; + const allValues = [id, ...values]; + const placeholders = allValues.map((_, i) => `$${i + 1}`).join(', '); + + try { + const sql = `INSERT INTO ${tables.items} (${allFields.join(', ')}) VALUES (${placeholders}) RETURNING *`; + const { rows } = await db.query(sql, allValues, 'rules'); + res.status(201).json(rows[0]); + } catch (err) { + console.error('Error creating item', err); + if (err.code === '23505') { + return res.status(409).json({ error: 'duplicate item (unique constraint)' }); + } + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * PUT /rules/:type/:id/items/:itemId — Aggiorna item + */ +router.put('/:type/:id/items/:itemId', async (req, res) => { + const { type, id, itemId } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + const allowed = ITEM_FIELDS[type]; + const sets = []; + const params = []; + let idx = 1; + + for (const field of allowed) { + if (req.body[field] !== undefined) { + sets.push(`${field} = $${idx}`); + params.push(req.body[field]); + idx++; + } + } + + if (sets.length === 0) { + return res.status(400).json({ error: 'no valid fields to update' }); + } + + params.push(itemId, id); + + try { + const sql = `UPDATE ${tables.items} SET ${sets.join(', ')} WHERE id = $${idx} AND rule_id = $${idx + 1} RETURNING *`; + const { rows } = await db.query(sql, params, 'rules'); + if (rows.length === 0) return res.status(404).json({ error: 'item not found' }); + res.json(rows[0]); + } catch (err) { + console.error('Error updating item', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * DELETE /rules/:type/:id/items/:itemId — Elimina item + */ +router.delete('/:type/:id/items/:itemId', async (req, res) => { + const { type, id, itemId } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + + try { + const { rowCount } = await db.query( + `DELETE FROM ${tables.items} WHERE id = $1 AND rule_id = $2`, [itemId, id], 'rules' + ); + if (rowCount === 0) return res.status(404).json({ error: 'item not found' }); + res.json({ status: 'ok' }); + } catch (err) { + console.error('Error deleting item', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +/** + * PATCH /rules/:type/:id/items/:itemId/toggle — Toggle enabled + */ +router.patch('/:type/:id/items/:itemId/toggle', async (req, res) => { + const { type, id, itemId } = req.params; + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ error: 'invalid rule type' }); + } + + const tables = TYPE_MAP[type]; + + try { + const { rows } = await db.query( + `UPDATE ${tables.items} SET enabled = NOT enabled WHERE id = $1 AND rule_id = $2 RETURNING enabled`, + [itemId, id], 'rules' + ); + if (rows.length === 0) return res.status(404).json({ error: 'item not found' }); + res.json({ status: 'ok', enabled: rows[0].enabled }); + } catch (err) { + console.error('Error toggling item', err); + res.status(500).json({ error: 'internal server error' }); + } +}); + +module.exports = router; diff --git a/api/src/storage/postgres.js b/api/src/storage/postgres.js index 832e344..55b4124 100644 --- a/api/src/storage/postgres.js +++ b/api/src/storage/postgres.js @@ -13,7 +13,8 @@ const config = { const pools = { data: new Pool({ ...config, database: process.env.DATA_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]) => { diff --git a/console/src/index.js b/console/src/index.js index 7e3a79d..b112a4e 100644 --- a/console/src/index.js +++ b/console/src/index.js @@ -87,6 +87,10 @@ app.get('/live', renderPage('live', { realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002' })); +app.get('/rulesets', renderPage('rulesets', { + apiUrl: process.env.API_URL || 'http://localhost:3003' +})); + app.listen(PORT, '0.0.0.0', () => { console.log(`Started on port ${PORT}`); }); diff --git a/console/src/pages/dashboard.html b/console/src/pages/dashboard.html index 249530b..eb04d05 100644 --- a/console/src/pages/dashboard.html +++ b/console/src/pages/dashboard.html @@ -31,10 +31,10 @@ - +
-

Previsioni

-

Visualizza le condizioni meteo attuali e le previsioni future.

+

Rulesets

+

Gestisci i template di configurazione per weather, data e logs.

diff --git a/console/src/pages/live.html b/console/src/pages/live.html index e4eac83..f072500 100644 --- a/console/src/pages/live.html +++ b/console/src/pages/live.html @@ -28,6 +28,20 @@ + + +