const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const Database = require('better-sqlite3'); const { config } = require('dotenv'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); // Load env vars 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'; let db; try { db = new Database(dbPath); console.log(`[UI Server] Connected to database at ${dbPath}`); } catch (err) { console.error(`[UI Server] Failed to connect to database at ${dbPath}:`, err.message); } module.exports = { entry: './src/index.js', output: { path: path.join(__dirname, 'dist'), filename: 'bundle.js', clean: true, }, mode: 'development', devtool: 'source-map', module: { rules: [ { test: /\.(js|jsx)$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env', '@babel/preset-react'], }, }, }, { test: /\.css$/, use: ['style-loader', 'css-loader'], }, ], }, resolve: { extensions: ['.js', '.jsx'], }, plugins: [ new HtmlWebpackPlugin({ template: './public/index.html', }), ], devServer: { port: 3905, historyApiFallback: true, hot: true, allowedHosts: 'all', setupMiddlewares: (middlewares, devServer) => { if (!devServer) { throw new Error('webpack-dev-server is not defined'); } // API Endpoints 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 }); } }); // 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); 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 = ?'); const info = stmt.run(req.params.id); if (info.changes > 0) { 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 { 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) { 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 }); } }); // GET /api/devices // Returns list of unique device/channel pairs app.get('/api/devices', (req, res) => { try { if (!db) throw new Error('Database not connected'); // Filter to only numeric channels which can be charted const stmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel"); const rows = stmt.all(); res.json(rows); } 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(); // 1. Fetch main data window let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? '; const params = [startTime, endTime]; const requestedChannels = []; // [{device, channel}] if (req.query.selection) { const selections = req.query.selection.split(','); if (selections.length > 0) { const placeholders = []; selections.forEach(s => { const lastColonIndex = s.lastIndexOf(':'); if (lastColonIndex !== -1) { const d = s.substring(0, lastColonIndex); const c = s.substring(lastColonIndex + 1); placeholders.push('(device = ? AND channel = ?)'); params.push(d, c); requestedChannels.push({ device: d, channel: c }); } }); if (placeholders.length > 0) { sql += `AND (${placeholders.join(' OR ')}) `; } } } sql += 'ORDER BY timestamp ASC'; const stmt = db.prepare(sql); const rows = stmt.all(...params); // 2. Backfill: Ensure we have a starting point for each channel // For each requested channel, check if we have data at/near start. // If the first point for a channel is > startTime, we should try to find the previous value. // We check for the value that started before (or at) startTime AND ended after (or at) startTime (or hasn't ended). const backfillRows = []; // Find the record that started most recently before startTime BUT was still valid at startTime 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 `); requestedChannels.forEach(ch => { const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime); if (prev) { // We found a point that covers the startTime. backfillRows.push(prev); } }); // Combine and sort const allRows = [...backfillRows, ...rows]; // Transform to Compact Dictionary Format // { "device:channel": [ [timestamp, value, until], ... ] } const result = {}; allRows.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); }); res.json(result); } catch (err) { console.error(err); res.status(500).json({ error: err.message }); } }); return middlewares; }, }, };