This commit is contained in:
sebseb7
2025-12-25 00:08:05 +01:00
parent 1f3292bc17
commit 077e76735e
15 changed files with 9311 additions and 0 deletions

8189
uiserver/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
uiserver/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "uiserver",
"version": "1.0.0",
"description": "UI Server for Sensor Data Visualization",
"main": "src/index.js",
"scripts": {
"start": "webpack serve --mode development --hot",
"build": "webpack --mode production"
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.18.0",
"@mui/material": "^5.14.0",
"@mui/x-charts": "^6.0.0-alpha.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.6.0",
"dotenv": "^16.3.1",
"jsonwebtoken": "^9.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.11.0"
},
"devDependencies": {
"@babel/core": "^7.23.0",
"@babel/preset-env": "^7.23.0",
"@babel/preset-react": "^7.23.0",
"babel-loader": "^9.1.3",
"css-loader": "^6.8.1",
"html-webpack-plugin": "^5.5.3",
"style-loader": "^3.3.3",
"webpack": "^5.88.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
}
}

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TischlerCtrl UI</title>
<style>
body {
margin: 0;
padding: 0;
background: #282828;
color: #ebdbb2;
font-family: Roboto, sans-serif;
}
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path');
const { config } = require('dotenv');
// Load env from root to get DB_PATH if set
config({ path: path.resolve(__dirname, '../../.env') });
const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../../server/data/sensors.db');
function printUsage() {
console.log('Usage:');
console.log(' node manage-users.js add <username> <password> <role>');
console.log(' node manage-users.js list');
console.log(' node manage-users.js delete <username>');
console.log('\nRoles: admin, normal');
}
const args = process.argv.slice(2);
const command = args[0];
if (!command) {
printUsage();
process.exit(1);
}
const db = new Database(dbPath);
try {
if (command === 'add') {
const [_, username, password, role] = args;
if (!username || !password || !role) {
console.error('Error: username, password, and role are required.');
printUsage();
process.exit(1);
}
if (!['admin', 'normal'].includes(role)) {
console.error('Error: role must be either "admin" or "normal"');
process.exit(1);
}
const hash = bcrypt.hashSync(password, 10);
try {
const stmt = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
const info = stmt.run(username, hash, role);
console.log(`User '${username}' created successfully (ID: ${info.lastInsertRowid}).`);
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
console.error(`Error: User '${username}' already exists.`);
} else {
throw err;
}
}
} else if (command === 'list') {
const stmt = db.prepare('SELECT id, username, role, created_at FROM users');
const users = stmt.all();
console.table(users);
} else if (command === 'delete') {
const [_, username] = args;
if (!username) {
console.error('Error: username required');
process.exit(1);
}
const stmt = db.prepare('DELETE FROM users WHERE username = ?');
const info = stmt.run(username);
if (info.changes > 0) {
console.log(`User '${username}' deleted.`);
} else {
console.log(`User '${username}' not found.`);
}
} else {
console.error(`Unknown command: ${command}`);
printUsage();
}
} catch (err) {
console.error('Error:', err.message);
} finally {
db.close();
}

View File

@@ -0,0 +1,38 @@
const Database = require('better-sqlite3');
const path = require('path');
const { config } = require('dotenv');
// Load env from root
config({ path: path.resolve(__dirname, '../../.env') });
const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../../server/data/sensors.db');
const db = new Database(dbPath);
console.log(`[Migrate] Connected to ${dbPath}`);
console.log('[Migrate] Applying schema migrations...');
try {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'normal')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
config TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(created_by) REFERENCES users(id)
);
`);
console.log('[Migrate] Schema applied successfully.');
} catch (err) {
console.error('[Migrate] Error applying schema:', err.message);
} finally {
db.close();
}

130
uiserver/src/App.js Normal file
View File

@@ -0,0 +1,130 @@
import React, { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Link, Navigate, useNavigate } from 'react-router-dom';
import { ThemeProvider, CssBaseline, AppBar, Toolbar, Typography, Button, Box, IconButton, Menu, MenuItem } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import ShowChartIcon from '@mui/icons-material/ShowChart';
import DashboardIcon from '@mui/icons-material/Dashboard';
import AccountCircle from '@mui/icons-material/AccountCircle';
import theme from './theme';
import Settings from './components/Settings';
import Chart from './components/Chart';
import Login from './components/Login';
import ViewManager from './components/ViewManager';
import ViewDisplay from './components/ViewDisplay';
function NavBar({ user, onLogout }) {
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState(null);
const handleMenu = (event) => setAnchorEl(event.currentTarget);
const handleClose = () => setAnchorEl(null);
const handleLogout = () => {
handleClose();
onLogout();
navigate('/');
};
return (
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, cursor: 'pointer' }} onClick={() => navigate('/')}>
TischlerCtrl
</Typography>
<Button color="inherit" startIcon={<DashboardIcon />} onClick={() => navigate('/')}>
Views
</Button>
<Button color="inherit" startIcon={<ShowChartIcon />} onClick={() => navigate('/live')}>
Live
</Button>
<Button color="inherit" startIcon={<SettingsIcon />} onClick={() => navigate('/settings')}>
Settings
</Button>
{user ? (
<div>
<IconButton
size="large"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem disabled>{user.username} ({user.role})</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</div>
) : (
<Button color="inherit" onClick={() => navigate('/login')}>
Login
</Button>
)}
</Toolbar>
</AppBar>
);
}
export default function App() {
const [user, setUser] = useState(null);
const [selectedChannels, setSelectedChannels] = useState([]);
// Load persistence (User + Settings)
useEffect(() => {
try {
const savedSettings = localStorage.getItem('selectedChannels');
if (savedSettings) setSelectedChannels(JSON.parse(savedSettings));
const savedUser = localStorage.getItem('user');
if (savedUser) setUser(JSON.parse(savedUser));
} catch (e) {
console.error("Failed to load persistence", e);
}
}, []);
const handleLogin = (userData) => {
setUser(userData);
localStorage.setItem('user', JSON.stringify(userData));
};
const handleLogout = () => {
setUser(null);
localStorage.removeItem('user');
};
const handleToggleChannel = (id) => {
setSelectedChannels(prev => {
const newSelection = prev.includes(id)
? prev.filter(c => c !== id)
: [...prev, id];
localStorage.setItem('selectedChannels', JSON.stringify(newSelection));
return newSelection;
});
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<Box sx={{ flexGrow: 1, height: '100vh', display: 'flex', flexDirection: 'column' }}>
<NavBar user={user} onLogout={handleLogout} />
<Box component="main" sx={{ flexGrow: 1, overflow: 'auto' }}>
<Routes>
<Route path="/" element={<ViewManager user={user} />} />
<Route path="/live" element={<Chart selectedChannels={selectedChannels} />} />
<Route path="/settings" element={<Settings selectedChannels={selectedChannels} onToggleChannel={handleToggleChannel} />} />
<Route path="/login" element={<Login onLogin={handleLogin} />} />
<Route path="/views/:id" element={<ViewDisplay />} />
</Routes>
</Box>
</Box>
</BrowserRouter>
</ThemeProvider>
);
}

109
uiserver/src/components/Chart.js vendored Normal file
View File

@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { Paper, Typography, Box, CircularProgress } from '@mui/material';
import { LineChart } from '@mui/x-charts/LineChart';
import { useTheme } from '@mui/material/styles';
export default function Chart({ selectedChannels = [], channelConfig = null }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const theme = useTheme();
// Determine effective channels list
const effectiveChannels = channelConfig
? channelConfig.map(c => c.id)
: selectedChannels;
const fetchData = () => {
// Only fetch if selection exists
if (effectiveChannels.length === 0) {
setData([]);
setLoading(false);
return;
}
const selectionStr = effectiveChannels.join(',');
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}`)
.then(res => res.json())
.then(rows => {
const timeMap = new Map();
rows.forEach(row => {
const id = `${row.device}:${row.channel}`;
if (!effectiveChannels.includes(id)) return;
const time = new Date(row.timestamp).getTime();
if (!timeMap.has(time)) {
timeMap.set(time, { time: new Date(row.timestamp) });
}
const entry = timeMap.get(time);
let val = row.value;
if (row.data_type === 'json' && !val) {
val = null;
}
entry[id] = val;
});
const sortedData = Array.from(timeMap.values()).sort((a, b) => a.time - b.time);
setData(sortedData);
setLoading(false);
})
.catch(err => {
console.error("Failed to fetch data", err);
setLoading(false);
});
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 60000);
return () => clearInterval(interval);
}, [selectedChannels, channelConfig]);
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
const series = effectiveChannels.map(id => {
// Find alias if config exists
let label = id;
if (channelConfig) {
const item = channelConfig.find(c => c.id === id);
if (item && item.alias) label = item.alias;
}
return {
dataKey: id,
label: label,
connectNulls: true,
showMark: false,
};
});
return (
<Box sx={{ width: '100%', height: '80vh', p: 2 }}>
<Typography variant="h6" gutterBottom>Last 24 Hours</Typography>
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flexGrow: 1 }}>
<LineChart
dataset={data}
series={series}
xAxis={[{
dataKey: 'time',
scaleType: 'time',
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}]}
slotProps={{
legend: {
direction: 'row',
position: { vertical: 'top', horizontal: 'middle' },
padding: 0,
},
}}
/>
</Box>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import {
Paper, TextField, Button, Typography, Container, Alert
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
export default function Login({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Login failed');
}
onLogin(data); // { token, role, username }
navigate('/');
} catch (err) {
setError(err.message);
}
};
return (
<Container maxWidth="xs" sx={{ mt: 8 }}>
<Paper sx={{ p: 4, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5" align="center">Login</Typography>
{error && <Alert severity="error">{error}</Alert>}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<TextField
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
fullWidth
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
/>
<Button type="submit" variant="contained" color="primary" fullWidth>
Sign In
</Button>
</form>
</Paper>
</Container>
);
}

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react';
import {
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
Switch, Paper, Typography, CircularProgress, Container
} from '@mui/material';
import SensorsIcon from '@mui/icons-material/Sensors';
export default function Settings({ selectedChannels, onToggleChannel }) {
const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/devices')
.then(res => {
if (!res.ok) throw new Error('Failed to load devices');
return res.json();
})
.then(data => {
setDevices(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">Error: {error}</Typography></Container>;
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Typography variant="h4" gutterBottom>
Channel Selection
</Typography>
<Paper>
<List>
{devices.map((device, index) => {
const id = `${device.device}:${device.channel}`;
const isSelected = selectedChannels.includes(id);
return (
<ListItem key={id} divider={index !== devices.length - 1}>
<ListItemIcon>
<SensorsIcon />
</ListItemIcon>
<ListItemText
primary={device.channel}
secondary={`Device: ${device.device}`}
/>
<ListItemSecondaryAction>
<Switch
edge="end"
checked={isSelected}
onChange={() => onToggleChannel(id)}
/>
</ListItemSecondaryAction>
</ListItem>
);
})}
{devices.length === 0 && (
<ListItem>
<ListItemText primary="No devices found in database" />
</ListItem>
)}
</List>
</Paper>
</Container>
);
}

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Box, Typography, Container, CircularProgress } from '@mui/material';
import Chart from './Chart';
export default function ViewDisplay() {
const { id } = useParams();
const [view, setView] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/views/${id}`)
.then(res => {
if (!res.ok) throw new Error('View not found');
return res.json();
})
.then(data => {
setView(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [id]);
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
// Map view config to Chart format with aliases
const channelConfig = view.config.map(item => ({
id: `${item.device}:${item.channel}`,
alias: item.alias
}));
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2 }}>
<Typography variant="h5">{view.name}</Typography>
</Box>
<Chart channelConfig={channelConfig} />
</Box>
);
}

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react';
import {
Container, Typography, List, ListItem, ListItemText, ListItemIcon,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
FormControl, InputLabel, Select, MenuItem, Box, Chip
} from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard';
import AddIcon from '@mui/icons-material/Add';
import { useNavigate } from 'react-router-dom';
export default function ViewManager({ user }) {
const [views, setViews] = useState([]);
const [open, setOpen] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [availableDevices, setAvailableDevices] = useState([]);
const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias }]
// Selection state for new item
const [selDevice, setSelDevice] = useState('');
const [selChannel, setSelChannel] = useState('');
const [alias, setAlias] = useState('');
const navigate = useNavigate();
const isAdmin = user && user.role === 'admin';
useEffect(() => {
refreshViews();
if (isAdmin) {
fetch('/api/devices')
.then(res => res.json())
.then(setAvailableDevices)
.catch(console.error);
}
}, [isAdmin]);
const refreshViews = () => {
fetch('/api/views')
.then(res => res.json())
.then(setViews)
.catch(console.error);
};
const handleCreate = async () => {
if (!newViewName || viewConfig.length === 0) return;
try {
const res = await fetch('/api/views', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({ name: newViewName, config: viewConfig })
});
if (res.ok) {
setOpen(false);
setNewViewName('');
setViewConfig([]);
refreshViews();
} else {
alert('Failed to create view');
}
} catch (err) {
console.error(err);
}
};
const addConfigItem = () => {
if (selDevice && selChannel) {
setViewConfig([...viewConfig, { device: selDevice, channel: selChannel, alias: alias || `${selDevice}:${selChannel}` }]);
setAlias('');
}
};
// Derived state for channels
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h4">Views</Typography>
{isAdmin && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>
Create View
</Button>
)}
</Box>
<List>
{views.map(view => (
<ListItem
button
key={view.id}
divider
onClick={() => navigate(`/views/${view.id}`)}
>
<ListItemIcon><DashboardIcon /></ListItemIcon>
<ListItemText
primary={view.name}
secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`}
/>
</ListItem>
))}
{views.length === 0 && <Typography>No views available.</Typography>}
</List>
{/* Create Dialog */}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create New View</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="View Name"
fullWidth
value={newViewName}
onChange={(e) => setNewViewName(e.target.value)}
/>
<Box sx={{ mt: 2, p: 2, border: '1px solid #444', borderRadius: 1 }}>
<Typography variant="subtitle2">Add Channels</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<FormControl fullWidth size="small">
<InputLabel>Device</InputLabel>
<Select value={selDevice} label="Device" onChange={(e) => setSelDevice(e.target.value)}>
{uniqueDevices.map(d => <MenuItem key={d} value={d}>{d}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>Channel</InputLabel>
<Select value={selChannel} label="Channel" onChange={(e) => setSelChannel(e.target.value)}>
{channelsForDevice.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<TextField
size="small"
label="Alias (Optional)"
fullWidth
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
<Button variant="outlined" onClick={addConfigItem}>Add</Button>
</Box>
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{viewConfig.map((item, idx) => (
<Chip
key={idx}
label={`${item.alias} (${item.device}:${item.channel})`}
onDelete={() => setViewConfig(viewConfig.filter((_, i) => i !== idx))}
/>
))}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} color="primary">Save</Button>
</DialogActions>
</Dialog>
</Container>
);
}

7
uiserver/src/index.js Normal file
View File

@@ -0,0 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(<App />);

74
uiserver/src/theme.js Normal file
View File

@@ -0,0 +1,74 @@
import { createTheme } from '@mui/material/styles';
const gruvbox = {
bg: '#282828',
bg1: '#3c3836',
bg2: '#504945',
fg: '#ebdbb2',
red: '#cc241d',
green: '#98971a',
yellow: '#d79921',
blue: '#458588',
purple: '#b16286',
aqua: '#689d6a',
orange: '#d65d0e',
gray: '#928374',
};
const theme = createTheme({
palette: {
mode: 'dark',
background: {
default: gruvbox.bg,
paper: gruvbox.bg1,
},
primary: {
main: gruvbox.orange,
},
secondary: {
main: gruvbox.blue,
},
text: {
primary: gruvbox.fg,
secondary: gruvbox.gray,
},
error: {
main: gruvbox.red,
},
success: {
main: gruvbox.green,
},
warning: {
main: gruvbox.yellow,
},
info: {
main: gruvbox.blue,
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
h1: { fontSize: '2rem', fontWeight: 600, color: gruvbox.fg },
h2: { fontSize: '1.5rem', fontWeight: 500, color: gruvbox.fg },
body1: { color: gruvbox.fg },
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: gruvbox.bg2,
color: gruvbox.fg,
},
},
},
MuiCard: {
styleOverrides: {
root: {
backgroundColor: gruvbox.bg1,
backgroundImage: 'none',
},
},
},
},
});
export default theme;

256
uiserver/webpack.config.js Normal file
View File

@@ -0,0 +1,256 @@
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;
},
},
};