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

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;