diff --git a/uiserver/.env.example b/uiserver/.env.example new file mode 100644 index 0000000..e43576c --- /dev/null +++ b/uiserver/.env.example @@ -0,0 +1,14 @@ +# Database path (default: ../server/data/sensors.db) +DB_PATH= + +# JWT secret for authentication (CHANGE IN PRODUCTION!) +JWT_SECRET=your-secret-key-here + +# WebSocket port for agent connections (default: 3962) +WS_PORT=3962 + +# Webpack dev server port (default: 3905) +DEV_SERVER_PORT=3905 + +# Rule runner interval in milliseconds (default: 10000 = 10s) +RULE_RUNNER_INTERVAL=10000 diff --git a/uiserver/api/auth.js b/uiserver/api/auth.js new file mode 100644 index 0000000..e703ae2 --- /dev/null +++ b/uiserver/api/auth.js @@ -0,0 +1,28 @@ +/** + * Auth API - Login endpoint + */ + +module.exports = function setupAuthApi(app, { db, bcrypt, jwt, JWT_SECRET }) { + // POST /api/login + app.post('/api/login', (req, res) => { + const { username, password } = req.body; + try { + const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); + const user = stmt.get(username); + + if (!user || !bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const token = jwt.sign({ + id: user.id, + username: user.username, + role: user.role + }, JWT_SECRET, { expiresIn: '24h' }); + + res.json({ token, role: user.role, username: user.username }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); +}; diff --git a/uiserver/api/devices.js b/uiserver/api/devices.js new file mode 100644 index 0000000..2b3254d --- /dev/null +++ b/uiserver/api/devices.js @@ -0,0 +1,25 @@ +/** + * Devices API - List unique device/channel pairs + */ + +module.exports = function setupDevicesApi(app, { db, OUTPUT_CHANNELS }) { + // GET /api/devices - Returns list of unique device/channel pairs (sensors + outputs) + app.get('/api/devices', (req, res) => { + try { + if (!db) throw new Error('Database not connected'); + // Get sensor channels + const sensorStmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel"); + const sensorRows = sensorStmt.all(); + + // Add output channels with 'output' as device + const outputRows = OUTPUT_CHANNELS.map(ch => ({ + device: 'output', + channel: ch.channel + })); + + res.json([...sensorRows, ...outputRows]); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); +}; diff --git a/uiserver/api/index.js b/uiserver/api/index.js new file mode 100644 index 0000000..6980b6f --- /dev/null +++ b/uiserver/api/index.js @@ -0,0 +1,43 @@ +/** + * API Routes Index - Sets up all API endpoints + */ + +const setupAuthApi = require('./auth'); +const setupViewsApi = require('./views'); +const setupRulesApi = require('./rules'); +const setupOutputsApi = require('./outputs'); +const setupDevicesApi = require('./devices'); +const setupReadingsApi = require('./readings'); + +module.exports = function setupAllApis(app, context) { + const { db, bcrypt, jwt, JWT_SECRET, OUTPUT_CHANNELS, OUTPUT_BINDINGS, runRules, activeRuleIds } = context; + + // Auth middleware helpers + const checkAuth = (req, res, next) => { + const authHeader = req.headers.authorization; + if (authHeader) { + const token = authHeader.split(' ')[1]; + jwt.verify(token, JWT_SECRET, (err, user) => { + if (user) req.user = user; + next(); + }); + } else { + next(); + } + }; + + const requireAdmin = (req, res, next) => { + if (!req.user || req.user.role !== 'admin') { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); + }; + + // Setup all API routes + setupAuthApi(app, { db, bcrypt, jwt, JWT_SECRET }); + setupViewsApi(app, { db, checkAuth, requireAdmin }); + setupRulesApi(app, { db, checkAuth, requireAdmin, runRules, activeRuleIds }); + setupOutputsApi(app, { db, OUTPUT_CHANNELS, OUTPUT_BINDINGS }); + setupDevicesApi(app, { db, OUTPUT_CHANNELS }); + setupReadingsApi(app, { db }); +}; diff --git a/uiserver/api/outputs.js b/uiserver/api/outputs.js new file mode 100644 index 0000000..9baaa05 --- /dev/null +++ b/uiserver/api/outputs.js @@ -0,0 +1,73 @@ +/** + * Outputs API - Output channel definitions and values + */ + +module.exports = function setupOutputsApi(app, { db, OUTPUT_CHANNELS, OUTPUT_BINDINGS }) { + // GET /api/outputs - List output channel definitions + app.get('/api/outputs', (req, res) => { + res.json(OUTPUT_CHANNELS); + }); + + // GET /api/outputs/values - Get current output values + app.get('/api/outputs/values', (req, res) => { + try { + if (!db) throw new Error('Database not connected'); + const result = {}; + const stmt = db.prepare(` + SELECT channel, value FROM output_events + WHERE id IN ( + SELECT MAX(id) FROM output_events GROUP BY channel + ) + `); + const rows = stmt.all(); + rows.forEach(row => { + result[row.channel] = row.value; + }); + // Fill in defaults for missing channels + OUTPUT_CHANNELS.forEach(ch => { + if (result[ch.channel] === undefined) { + result[ch.channel] = 0; + } + }); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // GET /api/outputs/commands - Get desired states for bound devices + // Agents poll this to get commands. Returns { "device:channel": { state: 0|1 } } + app.get('/api/outputs/commands', (req, res) => { + try { + if (!db) throw new Error('Database not connected'); + + // Get current output values + const stmt = db.prepare(` + SELECT channel, value FROM output_events + WHERE id IN ( + SELECT MAX(id) FROM output_events GROUP BY channel + ) + `); + const rows = stmt.all(); + const outputValues = {}; + rows.forEach(row => { + outputValues[row.channel] = row.value; + }); + + // Map to device commands + const commands = {}; + for (const [outputChannel, binding] of Object.entries(OUTPUT_BINDINGS)) { + const value = outputValues[outputChannel] ?? 0; + const deviceKey = `${binding.device}:${binding.channel}`; + commands[deviceKey] = { + state: value > 0 ? 1 : 0, + source: outputChannel + }; + } + + res.json(commands); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); +}; diff --git a/uiserver/api/readings.js b/uiserver/api/readings.js new file mode 100644 index 0000000..56681c8 --- /dev/null +++ b/uiserver/api/readings.js @@ -0,0 +1,124 @@ +/** + * Readings API - Sensor and output data for charts + */ + +module.exports = function setupReadingsApi(app, { db }) { + // GET /api/readings + // Query params: since, until, selection (comma-separated device:channel pairs) + app.get('/api/readings', (req, res) => { + try { + if (!db) throw new Error('Database not connected'); + const { since, until } = req.query; + const startTime = since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const endTime = until || new Date().toISOString(); + + const requestedSensorChannels = []; // [{device, channel}] + const requestedOutputChannels = []; // [channel] + + if (req.query.selection) { + const selections = req.query.selection.split(','); + selections.forEach(s => { + const lastColonIndex = s.lastIndexOf(':'); + if (lastColonIndex !== -1) { + const d = s.substring(0, lastColonIndex); + const c = s.substring(lastColonIndex + 1); + if (d === 'output') { + requestedOutputChannels.push(c); + } else { + requestedSensorChannels.push({ device: d, channel: c }); + } + } + }); + } + + const result = {}; + + // 1. Fetch sensor data + if (requestedSensorChannels.length > 0) { + let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? '; + const params = [startTime, endTime]; + + const placeholders = []; + requestedSensorChannels.forEach(ch => { + placeholders.push('(device = ? AND channel = ?)'); + params.push(ch.device, ch.channel); + }); + if (placeholders.length > 0) { + sql += `AND (${placeholders.join(' OR ')}) `; + } + sql += 'ORDER BY timestamp ASC'; + + const rows = db.prepare(sql).all(...params); + + // Backfill for sensors + const backfillStmt = db.prepare(` + SELECT * FROM sensor_events + WHERE device = ? AND channel = ? + AND timestamp <= ? + AND (until >= ? OR until IS NULL) + ORDER BY timestamp DESC LIMIT 1 + `); + + const backfillRows = []; + requestedSensorChannels.forEach(ch => { + const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime); + if (prev) backfillRows.push(prev); + }); + + [...backfillRows, ...rows].forEach(row => { + const key = `${row.device}:${row.channel}`; + if (!result[key]) result[key] = []; + const pt = [row.timestamp, row.value]; + if (row.until) pt.push(row.until); + result[key].push(pt); + }); + } + + // 2. Fetch output data + if (requestedOutputChannels.length > 0) { + let sql = 'SELECT * FROM output_events WHERE timestamp > ? AND timestamp <= ? '; + const params = [startTime, endTime]; + + const placeholders = requestedOutputChannels.map(() => 'channel = ?'); + sql += `AND (${placeholders.join(' OR ')}) `; + params.push(...requestedOutputChannels); + sql += 'ORDER BY timestamp ASC'; + + const rows = db.prepare(sql).all(...params); + + // Backfill for outputs + const backfillStmt = db.prepare(` + SELECT * FROM output_events + WHERE channel = ? + AND timestamp <= ? + AND (until >= ? OR until IS NULL) + ORDER BY timestamp DESC LIMIT 1 + `); + + const backfillRows = []; + requestedOutputChannels.forEach(ch => { + const prev = backfillStmt.get(ch, startTime, startTime); + if (prev) { + backfillRows.push(prev); + } else { + // No data at all - add default 0 value at startTime + backfillRows.push({ channel: ch, timestamp: startTime, value: 0, until: null }); + } + }); + + [...backfillRows, ...rows].forEach(row => { + const key = `output:${row.channel}`; + if (!result[key]) result[key] = []; + const pt = [row.timestamp, row.value]; + if (row.until) pt.push(row.until); + result[key].push(pt); + }); + } + + res.json(result); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); +}; diff --git a/uiserver/api/rules.js b/uiserver/api/rules.js new file mode 100644 index 0000000..a6ab566 --- /dev/null +++ b/uiserver/api/rules.js @@ -0,0 +1,166 @@ +/** + * Rules API - CRUD for automation rules + */ + +module.exports = function setupRulesApi(app, { db, checkAuth, requireAdmin, runRules, activeRuleIds }) { + // Apply checkAuth middleware to rules routes + app.use('/api/rules', checkAuth); + + // GET /api/rules/status - Get currently active rule IDs + app.get('/api/rules/status', (req, res) => { + res.json({ activeIds: Array.from(activeRuleIds) }); + }); + + // GET /api/rules - List all rules + app.get('/api/rules', (req, res) => { + try { + if (!db) throw new Error('Database not connected'); + const stmt = db.prepare('SELECT * FROM rules ORDER BY position ASC, id ASC'); + const rows = stmt.all(); + const rules = rows.map(row => ({ + ...row, + conditions: JSON.parse(row.conditions || '{}'), + action: JSON.parse(row.action || '{}') + })); + res.json(rules); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // POST /api/rules - Create rule (admin only) + app.post('/api/rules', requireAdmin, (req, res) => { + const { name, type = 'static', enabled = 1, conditions, action } = req.body; + if (!name || !conditions || !action) { + return res.status(400).json({ error: 'Missing required fields: name, conditions, action' }); + } + try { + const stmt = db.prepare(` + INSERT INTO rules (name, type, enabled, conditions, action, created_by) + VALUES (?, ?, ?, ?, ?, ?) + `); + const info = stmt.run( + name, + type, + enabled ? 1 : 0, + JSON.stringify(conditions), + JSON.stringify(action), + req.user?.id || null + ); + runRules(); // Trigger rules immediately + global.insertChangelog(req.user?.username || 'admin', `Created rule "${name}"`); + res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // PUT /api/rules/:id - Update rule (admin only) + app.put('/api/rules/:id', requireAdmin, (req, res) => { + const { name, type, enabled, conditions, action } = req.body; + try { + // Get old rule for comparison + const oldRule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id); + if (!oldRule) { + return res.status(404).json({ error: 'Rule not found' }); + } + + const stmt = db.prepare(` + UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now') + WHERE id = ? + `); + const info = stmt.run( + name, + type || 'static', + enabled ? 1 : 0, + JSON.stringify(conditions), + JSON.stringify(action), + req.params.id + ); + + if (info.changes > 0) { + runRules(); // Trigger rules immediately + + // Build detailed changelog + const changes = []; + if (oldRule.name !== name) { + changes.push(`name: "${oldRule.name}" → "${name}"`); + } + if (!!oldRule.enabled !== !!enabled) { + changes.push(`enabled: ${oldRule.enabled ? 'on' : 'off'} → ${enabled ? 'on' : 'off'}`); + } + const oldConditions = oldRule.conditions || '{}'; + const newConditions = JSON.stringify(conditions); + if (oldConditions !== newConditions) { + changes.push('conditions changed'); + } + const oldAction = oldRule.action || '{}'; + const newAction = JSON.stringify(action); + if (oldAction !== newAction) { + try { + const oldA = JSON.parse(oldAction); + const newA = action; + if (oldA.channel !== newA.channel) { + changes.push(`action channel: ${oldA.channel} → ${newA.channel}`); + } + if (JSON.stringify(oldA.value) !== JSON.stringify(newA.value)) { + changes.push(`action value: ${JSON.stringify(oldA.value)} → ${JSON.stringify(newA.value)}`); + } + } catch (e) { + changes.push('action changed'); + } + } + + const changeText = changes.length > 0 + ? `Updated rule "${name}": ${changes.join(', ')}` + : `Updated rule "${name}" (no changes)`; + global.insertChangelog(req.user?.username || 'admin', changeText); + + res.json({ id: req.params.id, name, type, enabled, conditions, action }); + } else { + res.status(404).json({ error: 'Rule not found' }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // DELETE /api/rules/:id - Delete rule (admin only) + app.delete('/api/rules/:id', requireAdmin, (req, res) => { + try { + const stmt = db.prepare('DELETE FROM rules WHERE id = ?'); + const ruleName = db.prepare('SELECT name FROM rules WHERE id = ?').get(req.params.id)?.name || 'Unknown Rule'; + const info = stmt.run(req.params.id); + if (info.changes > 0) { + runRules(); // Trigger rules immediately + global.insertChangelog(req.user?.username || 'admin', `Deleted rule "${ruleName}" (ID: ${req.params.id})`); + res.json({ success: true }); + } else { + res.status(404).json({ error: 'Rule not found' }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // POST /api/rules/reorder - Reorder rules (admin only) + app.post('/api/rules/reorder', requireAdmin, (req, res) => { + const { order } = req.body; + if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' }); + + const updateStmt = db.prepare('UPDATE rules SET position = ? WHERE id = ?'); + const updateMany = db.transaction((items) => { + for (const item of items) { + updateStmt.run(item.position, item.id); + } + }); + + try { + updateMany(order); + runRules(); // Trigger rules immediately + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); +}; diff --git a/uiserver/api/views.js b/uiserver/api/views.js new file mode 100644 index 0000000..cae538b --- /dev/null +++ b/uiserver/api/views.js @@ -0,0 +1,186 @@ +/** + * Views API - CRUD for dashboard views + */ + +module.exports = function setupViewsApi(app, { db, checkAuth, requireAdmin }) { + // Apply checkAuth middleware to views routes + app.use('/api/views', checkAuth); + + // POST /api/views - Create view (admin only) + app.post('/api/views', requireAdmin, (req, res) => { + const { name, config } = req.body; + try { + const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)'); + const info = stmt.run(name, JSON.stringify(config), req.user.id); + global.insertChangelog(req.user.username, `Created view "${name}"`); + res.json({ id: info.lastInsertRowid, name, config }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // GET /api/views - List all views (public) + app.get('/api/views', (req, res) => { + try { + const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC'); + const rows = stmt.all(); + const views = rows.map(row => { + try { + return { ...row, config: JSON.parse(row.config) }; + } catch (e) { + return row; + } + }); + res.json(views); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // GET /api/views/:id - Get single view + app.get('/api/views/:id', (req, res) => { + try { + const stmt = db.prepare('SELECT * FROM views WHERE id = ?'); + const view = stmt.get(req.params.id); + if (view) { + view.config = JSON.parse(view.config); + res.json(view); + } else { + res.status(404).json({ error: 'View not found' }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // DELETE /api/views/:id - Delete view (admin only) + app.delete('/api/views/:id', requireAdmin, (req, res) => { + try { + const stmt = db.prepare('DELETE FROM views WHERE id = ?'); + const viewName = db.prepare('SELECT name FROM views WHERE id = ?').get(req.params.id)?.name || 'Unknown View'; + const info = stmt.run(req.params.id); + if (info.changes > 0) { + global.insertChangelog(req.user.username, `Deleted view "${viewName}" (ID: ${req.params.id})`); + res.json({ success: true }); + } else { + res.status(404).json({ error: 'View not found' }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // PUT /api/views/:id - Update view (admin only) + app.put('/api/views/:id', requireAdmin, (req, res) => { + const { name, config } = req.body; + try { + // Get old view for comparison + const oldView = db.prepare('SELECT * FROM views WHERE id = ?').get(req.params.id); + if (!oldView) { + return res.status(404).json({ error: 'View not found' }); + } + + const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?'); + const info = stmt.run(name, JSON.stringify(config), req.params.id); + if (info.changes > 0) { + // Build detailed changelog + const changes = []; + + // Check name change + if (oldView.name !== name) { + changes.push(`renamed: "${oldView.name}" → "${name}"`); + } + + // Parse configs for comparison + let oldConfig = {}; + try { oldConfig = JSON.parse(oldView.config || '{}'); } catch (e) { } + const newConfig = config || {}; + + // Compare channels + const oldChannels = (oldConfig.channels || []).map(ch => + typeof ch === 'string' ? ch : ch.channel + ); + const newChannels = (newConfig.channels || []).map(ch => + typeof ch === 'string' ? ch : ch.channel + ); + + const added = newChannels.filter(ch => !oldChannels.includes(ch)); + const removed = oldChannels.filter(ch => !newChannels.includes(ch)); + + if (added.length > 0) { + changes.push(`added channels: ${added.join(', ')}`); + } + if (removed.length > 0) { + changes.push(`removed channels: ${removed.join(', ')}`); + } + + // Check for color/fill changes + const oldChannelConfigs = {}; + (oldConfig.channels || []).forEach(ch => { + if (typeof ch === 'object') { + oldChannelConfigs[ch.channel] = ch; + } + }); + const newChannelConfigs = {}; + (newConfig.channels || []).forEach(ch => { + if (typeof ch === 'object') { + newChannelConfigs[ch.channel] = ch; + } + }); + + const colorChanges = []; + for (const ch of newChannels) { + const oldCh = oldChannelConfigs[ch] || {}; + const newCh = newChannelConfigs[ch] || {}; + if (oldCh.color !== newCh.color || oldCh.fillColor !== newCh.fillColor) { + colorChanges.push(ch.split(':').pop()); + } + } + if (colorChanges.length > 0) { + changes.push(`colors changed for: ${colorChanges.join(', ')}`); + } + + // Check order change + if (added.length === 0 && removed.length === 0 && + JSON.stringify(oldChannels) !== JSON.stringify(newChannels)) { + changes.push('channel order changed'); + } + + const changeText = changes.length > 0 + ? `Updated view "${name}": ${changes.join('; ')}` + : `Updated view "${name}" (no significant changes)`; + global.insertChangelog(req.user.username, changeText); + + res.json({ id: req.params.id, name, config }); + } else { + res.status(404).json({ error: 'View not found' }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // POST /api/views/reorder - Reorder views (admin only) + app.post('/api/views/reorder', requireAdmin, (req, res) => { + const { order } = req.body; + console.log('[API] Reorder request:', order); + if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' }); + + const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?'); + const updateMany = db.transaction((items) => { + for (const item of items) { + console.log('[API] Updating view', item.id, 'to position', item.position); + updateStmt.run(item.position, item.id); + } + }); + + try { + updateMany(order); + console.log('[API] Reorder successful'); + res.json({ success: true }); + } catch (err) { + console.error('[API] Reorder error:', err); + res.status(500).json({ error: err.message }); + } + }); +}; diff --git a/uiserver/webpack.config.js b/uiserver/webpack.config.js index 380a285..fa54149 100644 --- a/uiserver/webpack.config.js +++ b/uiserver/webpack.config.js @@ -12,7 +12,9 @@ config(); // Database connection for Dev Server API const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../server/data/sensors.db'); const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-me'; -const WS_PORT = process.env.WS_PORT || 3962; +const WS_PORT = parseInt(process.env.WS_PORT || '3962', 10); +const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT || '3905', 10); +const RULE_RUNNER_INTERVAL = parseInt(process.env.RULE_RUNNER_INTERVAL || '10000', 10); let db; try { @@ -572,12 +574,16 @@ function runRules() { console.error('[RuleRunner] Error running rules:', err.message); } } + // Also sync immediately on startup after a short delay setTimeout(syncOutputStates, 5000); // Start the WebSocket server const agentWss = createAgentWebSocketServer(); +// Import API setup +const setupAllApis = require('./api'); + module.exports = { entry: './src/index.js', output: { @@ -621,7 +627,7 @@ module.exports = { }), ], devServer: { - port: 3905, + port: DEV_SERVER_PORT, historyApiFallback: true, hot: true, allowedHosts: 'all', @@ -634,484 +640,26 @@ module.exports = { throw new Error('webpack-dev-server is not defined'); } - // API Endpoints + // Setup body parser const app = devServer.app; const bodyParser = require('body-parser'); app.use(bodyParser.json()); - // --- Auth API --- - app.post('/api/login', (req, res) => { - const { username, password } = req.body; - try { - const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); - const user = stmt.get(username); - - if (!user || !bcrypt.compareSync(password, user.password_hash)) { - return res.status(401).json({ error: 'Invalid credentials' }); - } - - const token = jwt.sign({ - id: user.id, - username: user.username, - role: user.role - }, JWT_SECRET, { expiresIn: '24h' }); - - res.json({ token, role: user.role, username: user.username }); - } catch (err) { - res.status(500).json({ error: err.message }); - } + // Setup all API routes from extracted modules + setupAllApis(app, { + db, + bcrypt, + jwt, + JWT_SECRET, + OUTPUT_CHANNELS, + OUTPUT_BINDINGS, + runRules, + activeRuleIds }); - // Middleware to check auth (Optional for read, required for write) - const checkAuth = (req, res, next) => { - const authHeader = req.headers.authorization; - if (authHeader) { - const token = authHeader.split(' ')[1]; - jwt.verify(token, JWT_SECRET, (err, user) => { - if (user) req.user = user; - next(); - }); - } else { - next(); - } - }; - - const requireAdmin = (req, res, next) => { - if (!req.user || req.user.role !== 'admin') { - return res.status(403).json({ error: 'Admin access required' }); - } - next(); - }; - - app.use('/api/views', checkAuth); - - // --- Views API --- - app.post('/api/views', requireAdmin, (req, res) => { - const { name, config } = req.body; - try { - const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)'); - const info = stmt.run(name, JSON.stringify(config), req.user.id); - global.insertChangelog(req.user.username, `Created view "${name}"`); - res.json({ id: info.lastInsertRowid, name, config }); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // Publicly list views - app.get('/api/views', (req, res) => { - try { - const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC'); - const rows = stmt.all(); - const views = rows.map(row => { - try { - return { ...row, config: JSON.parse(row.config) }; - } catch (e) { - return row; - } - }); - res.json(views); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - app.get('/api/views/:id', (req, res) => { - try { - const stmt = db.prepare('SELECT * FROM views WHERE id = ?'); - const view = stmt.get(req.params.id); - if (view) { - view.config = JSON.parse(view.config); - res.json(view); - } else { - res.status(404).json({ error: 'View not found' }); - } - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // Delete View - app.delete('/api/views/:id', requireAdmin, (req, res) => { - try { - const stmt = db.prepare('DELETE FROM views WHERE id = ?'); - // Get name before delete for logging - const viewName = db.prepare('SELECT name FROM views WHERE id = ?').get(req.params.id)?.name || 'Unknown View'; - const info = stmt.run(req.params.id); - if (info.changes > 0) { - global.insertChangelog(req.user.username, `Deleted view "${viewName}" (ID: ${req.params.id})`); - res.json({ success: true }); - } else { - res.status(404).json({ error: 'View not found' }); - } - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // Update View - app.put('/api/views/:id', requireAdmin, (req, res) => { - const { name, config } = req.body; - try { - // Get old view for comparison - const oldView = db.prepare('SELECT * FROM views WHERE id = ?').get(req.params.id); - if (!oldView) { - return res.status(404).json({ error: 'View not found' }); - } - - const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?'); - const info = stmt.run(name, JSON.stringify(config), req.params.id); - if (info.changes > 0) { - // Build detailed changelog - const changes = []; - - // Check name change - if (oldView.name !== name) { - changes.push(`renamed: "${oldView.name}" → "${name}"`); - } - - // Parse configs for comparison - let oldConfig = {}; - try { oldConfig = JSON.parse(oldView.config || '{}'); } catch (e) { } - const newConfig = config || {}; - - // Compare channels - const oldChannels = (oldConfig.channels || []).map(ch => - typeof ch === 'string' ? ch : ch.channel - ); - const newChannels = (newConfig.channels || []).map(ch => - typeof ch === 'string' ? ch : ch.channel - ); - - const added = newChannels.filter(ch => !oldChannels.includes(ch)); - const removed = oldChannels.filter(ch => !newChannels.includes(ch)); - - if (added.length > 0) { - changes.push(`added channels: ${added.join(', ')}`); - } - if (removed.length > 0) { - changes.push(`removed channels: ${removed.join(', ')}`); - } - - // Check for color/fill changes - const oldChannelConfigs = {}; - (oldConfig.channels || []).forEach(ch => { - if (typeof ch === 'object') { - oldChannelConfigs[ch.channel] = ch; - } - }); - const newChannelConfigs = {}; - (newConfig.channels || []).forEach(ch => { - if (typeof ch === 'object') { - newChannelConfigs[ch.channel] = ch; - } - }); - - const colorChanges = []; - for (const ch of newChannels) { - const oldCh = oldChannelConfigs[ch] || {}; - const newCh = newChannelConfigs[ch] || {}; - if (oldCh.color !== newCh.color || oldCh.fillColor !== newCh.fillColor) { - colorChanges.push(ch.split(':').pop()); // Just the channel name - } - } - if (colorChanges.length > 0) { - changes.push(`colors changed for: ${colorChanges.join(', ')}`); - } - - // Check order change - if (added.length === 0 && removed.length === 0 && - JSON.stringify(oldChannels) !== JSON.stringify(newChannels)) { - changes.push('channel order changed'); - } - - const changeText = changes.length > 0 - ? `Updated view "${name}": ${changes.join('; ')}` - : `Updated view "${name}" (no significant changes)`; - global.insertChangelog(req.user.username, changeText); - - res.json({ id: req.params.id, name, config }); - } else { - res.status(404).json({ error: 'View not found' }); - } - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // Reorder Views - app.post('/api/views/reorder', requireAdmin, (req, res) => { - const { order } = req.body; - console.log('[API] Reorder request:', order); - if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' }); - - const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?'); - const updateMany = db.transaction((items) => { - for (const item of items) { - console.log('[API] Updating view', item.id, 'to position', item.position); - updateStmt.run(item.position, item.id); - } - }); - - try { - updateMany(order); - console.log('[API] Reorder successful'); - res.json({ success: true }); - } catch (err) { - console.error('[API] Reorder error:', err); - res.status(500).json({ error: err.message }); - } - }); - - // ============================================= - // RULES API - // ============================================= - - // Apply checkAuth middleware to rules API routes - app.use('/api/rules', checkAuth); - - // Virtual output channel definitions - // Virtual output channel definitions - MOVED TO GLOBAL SCOPE - - // GET /api/outputs - List output channel definitions - app.get('/api/outputs', (req, res) => { - res.json(OUTPUT_CHANNELS); - }); - - // GET /api/outputs/values - Get current output values - app.get('/api/outputs/values', (req, res) => { - try { - if (!db) throw new Error('Database not connected'); - const result = {}; - const stmt = db.prepare(` - SELECT channel, value FROM output_events - WHERE id IN ( - SELECT MAX(id) FROM output_events GROUP BY channel - ) - `); - const rows = stmt.all(); - rows.forEach(row => { - result[row.channel] = row.value; - }); - // Fill in defaults for missing channels - OUTPUT_CHANNELS.forEach(ch => { - if (result[ch.channel] === undefined) { - result[ch.channel] = 0; - } - }); - res.json(result); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // GET /api/outputs/commands - Get desired states for bound devices - // Agents poll this to get commands. Returns { "device:channel": { state: 0|1 } } - app.get('/api/outputs/commands', (req, res) => { - try { - if (!db) throw new Error('Database not connected'); - - // Get current output values - const stmt = db.prepare(` - SELECT channel, value FROM output_events - WHERE id IN ( - SELECT MAX(id) FROM output_events GROUP BY channel - ) - `); - const rows = stmt.all(); - const outputValues = {}; - rows.forEach(row => { - outputValues[row.channel] = row.value; - }); - - // Map to device commands - const commands = {}; - for (const [outputChannel, binding] of Object.entries(OUTPUT_BINDINGS)) { - const value = outputValues[outputChannel] ?? 0; - const deviceKey = `${binding.device}:${binding.channel}`; - commands[deviceKey] = { - state: value > 0 ? 1 : 0, - source: outputChannel - }; - } - - res.json(commands); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // GET /api/rules/status - Get currently active rule IDs - app.get('/api/rules/status', (req, res) => { - res.json({ activeIds: Array.from(activeRuleIds) }); - }); - - // GET /api/rules - List all rules - app.get('/api/rules', (req, res) => { - try { - if (!db) throw new Error('Database not connected'); - const stmt = db.prepare('SELECT * FROM rules ORDER BY position ASC, id ASC'); - const rows = stmt.all(); - const rules = rows.map(row => ({ - ...row, - conditions: JSON.parse(row.conditions || '{}'), - action: JSON.parse(row.action || '{}') - })); - res.json(rules); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - - - // POST /api/rules - Create rule (admin only) - app.post('/api/rules', requireAdmin, (req, res) => { - const { name, type = 'static', enabled = 1, conditions, action } = req.body; - if (!name || !conditions || !action) { - return res.status(400).json({ error: 'Missing required fields: name, conditions, action' }); - } - try { - const stmt = db.prepare(` - INSERT INTO rules (name, type, enabled, conditions, action, created_by) - VALUES (?, ?, ?, ?, ?, ?) - `); - const info = stmt.run( - name, - type, - enabled ? 1 : 0, - JSON.stringify(conditions), - JSON.stringify(action), - req.user?.id || null - ); - runRules(); // Trigger rules immediately - global.insertChangelog(req.user?.username || 'admin', `Created rule "${name}"`); - res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action }); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // PUT /api/rules/:id - Update rule (admin only) - app.put('/api/rules/:id', requireAdmin, (req, res) => { - const { name, type, enabled, conditions, action } = req.body; - try { - // Get old rule for comparison - const oldRule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id); - if (!oldRule) { - return res.status(404).json({ error: 'Rule not found' }); - } - - const stmt = db.prepare(` - UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now') - WHERE id = ? - `); - const info = stmt.run( - name, - type || 'static', - enabled ? 1 : 0, - JSON.stringify(conditions), - JSON.stringify(action), - req.params.id - ); - - if (info.changes > 0) { - runRules(); // Trigger rules immediately - - // Build detailed changelog - const changes = []; - if (oldRule.name !== name) { - changes.push(`name: "${oldRule.name}" → "${name}"`); - } - if (!!oldRule.enabled !== !!enabled) { - changes.push(`enabled: ${oldRule.enabled ? 'on' : 'off'} → ${enabled ? 'on' : 'off'}`); - } - const oldConditions = oldRule.conditions || '{}'; - const newConditions = JSON.stringify(conditions); - if (oldConditions !== newConditions) { - changes.push('conditions changed'); - } - const oldAction = oldRule.action || '{}'; - const newAction = JSON.stringify(action); - if (oldAction !== newAction) { - // Parse to show what changed in action - try { - const oldA = JSON.parse(oldAction); - const newA = action; - if (oldA.channel !== newA.channel) { - changes.push(`action channel: ${oldA.channel} → ${newA.channel}`); - } - if (JSON.stringify(oldA.value) !== JSON.stringify(newA.value)) { - changes.push(`action value: ${JSON.stringify(oldA.value)} → ${JSON.stringify(newA.value)}`); - } - } catch (e) { - changes.push('action changed'); - } - } - - const changeText = changes.length > 0 - ? `Updated rule "${name}": ${changes.join(', ')}` - : `Updated rule "${name}" (no changes)`; - global.insertChangelog(req.user?.username || 'admin', changeText); - - res.json({ id: req.params.id, name, type, enabled, conditions, action }); - } else { - res.status(404).json({ error: 'Rule not found' }); - } - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // DELETE /api/rules/:id - Delete rule (admin only) - app.delete('/api/rules/:id', requireAdmin, (req, res) => { - try { - const stmt = db.prepare('DELETE FROM rules WHERE id = ?'); - // Get name before delete - const ruleName = db.prepare('SELECT name FROM rules WHERE id = ?').get(req.params.id)?.name || 'Unknown Rule'; - const info = stmt.run(req.params.id); - if (info.changes > 0) { - runRules(); // Trigger rules immediately - global.insertChangelog(req.user?.username || 'admin', `Deleted rule "${ruleName}" (ID: ${req.params.id})`); - res.json({ success: true }); - } else { - res.status(404).json({ error: 'Rule not found' }); - } - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // POST /api/rules/reorder - Reorder rules (admin only) - app.post('/api/rules/reorder', requireAdmin, (req, res) => { - const { order } = req.body; - if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' }); - - const updateStmt = db.prepare('UPDATE rules SET position = ? WHERE id = ?'); - const updateMany = db.transaction((items) => { - for (const item of items) { - updateStmt.run(item.position, item.id); - } - }); - - try { - updateMany(order); - runRules(); // Trigger rules immediately - res.json({ success: true }); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // ============================================= - // RULE RUNNER (Background Job) - // ============================================= - - // Rule Engine functions moved to global scope - - // Start rule runner (every 10 seconds) - const ruleRunnerInterval = setInterval(runRules, 10000); - console.log('[RuleRunner] Started background job (10s interval)'); + // Start rule runner + const ruleRunnerInterval = setInterval(runRules, RULE_RUNNER_INTERVAL); + console.log(`[RuleRunner] Started background job (${RULE_RUNNER_INTERVAL / 1000}s interval)`); // Clean up on server close devServer.server?.on('close', () => { @@ -1119,155 +667,6 @@ module.exports = { console.log('[RuleRunner] Stopped background job'); }); - // GET /api/devices - // Returns list of unique device/channel pairs (sensors + outputs) - app.get('/api/devices', (req, res) => { - try { - if (!db) throw new Error('Database not connected'); - // Get sensor channels - const sensorStmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel"); - const sensorRows = sensorStmt.all(); - - // Add output channels with 'output' as device - const outputRows = OUTPUT_CHANNELS.map(ch => ({ - device: 'output', - channel: ch.channel - })); - - res.json([...sensorRows, ...outputRows]); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - // GET /api/readings - // Query params: devices (comma sep), channels (comma sep), since (timestamp) - // Actually, user wants "Last 24h". - // We can accept `since` or valid ISO string. - // Expected params: `?device=x&channel=y` (single) or query for multiple? - // User asked for "chart that is refreshed once a minute... display the last 24 hours with the devices/channels previously selected" - // Efficient query: select * from sensor_events where timestamp > ? and (device,channel) IN (...) - // For simplicity, let's allow fetching by multiple devices/channels or just all for last 24h and filter client side? - // No, database filtering is better. - // Let's support ?since=ISO_DATE - - app.get('/api/readings', (req, res) => { - try { - if (!db) throw new Error('Database not connected'); - const { since, until } = req.query; - const startTime = since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - const endTime = until || new Date().toISOString(); - - const requestedSensorChannels = []; // [{device, channel}] - const requestedOutputChannels = []; // [channel] - - if (req.query.selection) { - const selections = req.query.selection.split(','); - selections.forEach(s => { - const lastColonIndex = s.lastIndexOf(':'); - if (lastColonIndex !== -1) { - const d = s.substring(0, lastColonIndex); - const c = s.substring(lastColonIndex + 1); - if (d === 'output') { - requestedOutputChannels.push(c); - } else { - requestedSensorChannels.push({ device: d, channel: c }); - } - } - }); - } - - const result = {}; - - // 1. Fetch sensor data - if (requestedSensorChannels.length > 0) { - let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? '; - const params = [startTime, endTime]; - - const placeholders = []; - requestedSensorChannels.forEach(ch => { - placeholders.push('(device = ? AND channel = ?)'); - params.push(ch.device, ch.channel); - }); - if (placeholders.length > 0) { - sql += `AND (${placeholders.join(' OR ')}) `; - } - sql += 'ORDER BY timestamp ASC'; - - const rows = db.prepare(sql).all(...params); - - // Backfill for sensors - const backfillStmt = db.prepare(` - SELECT * FROM sensor_events - WHERE device = ? AND channel = ? - AND timestamp <= ? - AND (until >= ? OR until IS NULL) - ORDER BY timestamp DESC LIMIT 1 - `); - - const backfillRows = []; - requestedSensorChannels.forEach(ch => { - const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime); - if (prev) backfillRows.push(prev); - }); - - [...backfillRows, ...rows].forEach(row => { - const key = `${row.device}:${row.channel}`; - if (!result[key]) result[key] = []; - const pt = [row.timestamp, row.value]; - if (row.until) pt.push(row.until); - result[key].push(pt); - }); - } - - // 2. Fetch output data - if (requestedOutputChannels.length > 0) { - let sql = 'SELECT * FROM output_events WHERE timestamp > ? AND timestamp <= ? '; - const params = [startTime, endTime]; - - const placeholders = requestedOutputChannels.map(() => 'channel = ?'); - sql += `AND (${placeholders.join(' OR ')}) `; - params.push(...requestedOutputChannels); - sql += 'ORDER BY timestamp ASC'; - - const rows = db.prepare(sql).all(...params); - - // Backfill for outputs - const backfillStmt = db.prepare(` - SELECT * FROM output_events - WHERE channel = ? - AND timestamp <= ? - AND (until >= ? OR until IS NULL) - ORDER BY timestamp DESC LIMIT 1 - `); - - const backfillRows = []; - requestedOutputChannels.forEach(ch => { - const prev = backfillStmt.get(ch, startTime, startTime); - if (prev) { - backfillRows.push(prev); - } else { - // No data at all - add default 0 value at startTime - backfillRows.push({ channel: ch, timestamp: startTime, value: 0, until: null }); - } - }); - - [...backfillRows, ...rows].forEach(row => { - const key = `output:${row.channel}`; - if (!result[key]) result[key] = []; - const pt = [row.timestamp, row.value]; - if (row.until) pt.push(row.until); - result[key].push(pt); - }); - } - - res.json(result); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); - } - }); - return middlewares; }, },