Files
tischlerCtrl/uiserver/webpack.config.js
sebseb7 db4f27302b u
2025-12-25 02:09:06 +01:00

326 lines
13 KiB
JavaScript

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 name');
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 ORDER BY position ASC, id ASC');
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;
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) updateStmt.run(item.position, item.id);
});
try {
updateMany(order);
res.json({ success: true });
} 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, 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;
},
},
};