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 id, name, created_at FROM views ORDER BY name'); const rows = stmt.all(); res.json(rows); } 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 }); } }); // 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 } = req.query; let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? '; const params = [since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()]; // Helper for filtering could be added here if needed, // but fetching last 24h of *all* data might not be too huge if not too many sensors. // However, optimization: if query params `channels` provided as "device:channel,device2:channel2" if (req.query.selection) { // selection format: "device:channel,device:channel" const selections = req.query.selection.split(','); if (selections.length > 0) { const placeholders = selections.map(() => '(device = ? AND channel = ?)').join(' OR '); sql += `AND (${placeholders}) `; selections.forEach(s => { const lastColonIndex = s.lastIndexOf(':'); if (lastColonIndex !== -1) { const d = s.substring(0, lastColonIndex); const c = s.substring(lastColonIndex + 1); params.push(d, c); } }); } } sql += 'ORDER BY timestamp ASC'; const stmt = db.prepare(sql); const rows = stmt.all(...params); res.json(rows); } catch (err) { console.error(err); res.status(500).json({ error: err.message }); } }); return middlewares; }, }, };