u
This commit is contained in:
8189
uiserver/package-lock.json
generated
Normal file
8189
uiserver/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
uiserver/package.json
Normal file
36
uiserver/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
uiserver/public/index.html
Normal file
23
uiserver/public/index.html
Normal 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>
|
||||
84
uiserver/scripts/manage-users.js
Normal file
84
uiserver/scripts/manage-users.js
Normal 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();
|
||||
}
|
||||
38
uiserver/scripts/migrate.js
Normal file
38
uiserver/scripts/migrate.js
Normal 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
130
uiserver/src/App.js
Normal 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
109
uiserver/src/components/Chart.js
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
64
uiserver/src/components/Login.js
Normal file
64
uiserver/src/components/Login.js
Normal 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>
|
||||
);
|
||||
}
|
||||
71
uiserver/src/components/Settings.js
Normal file
71
uiserver/src/components/Settings.js
Normal 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>
|
||||
);
|
||||
}
|
||||
45
uiserver/src/components/ViewDisplay.js
Normal file
45
uiserver/src/components/ViewDisplay.js
Normal 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>
|
||||
);
|
||||
}
|
||||
167
uiserver/src/components/ViewManager.js
Normal file
167
uiserver/src/components/ViewManager.js
Normal 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
7
uiserver/src/index.js
Normal 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
74
uiserver/src/theme.js
Normal 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
256
uiserver/webpack.config.js
Normal 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;
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user