This commit is contained in:
sebseb7
2025-12-25 00:24:48 +01:00
parent 0eb05b1cd5
commit 2d7bfe247d
7 changed files with 630 additions and 524 deletions

View File

@@ -1,130 +1,133 @@
import React, { useState, useEffect } from 'react'; import React, { Component } from 'react';
import { BrowserRouter, Routes, Route, Link, Navigate, useNavigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router-dom';
import { ThemeProvider, CssBaseline, AppBar, Toolbar, Typography, Button, Box, IconButton, Menu, MenuItem } from '@mui/material'; import { AppBar, Toolbar, Typography, Button, Box, IconButton, CssBaseline } from '@mui/material';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import ShowChartIcon from '@mui/icons-material/ShowChart'; import ShowChartIcon from '@mui/icons-material/ShowChart';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import AccountCircle from '@mui/icons-material/AccountCircle';
import theme from './theme';
import Settings from './components/Settings'; import Settings from './components/Settings';
import Chart from './components/Chart'; import Chart from './components/Chart';
import Login from './components/Login'; import Login from './components/Login';
import ViewManager from './components/ViewManager'; import ViewManager from './components/ViewManager';
import ViewDisplay from './components/ViewDisplay'; import ViewDisplay from './components/ViewDisplay';
function NavBar({ user, onLogout }) { const darkTheme = createTheme({
const navigate = useNavigate(); palette: {
const [anchorEl, setAnchorEl] = useState(null); mode: 'dark',
primary: { main: '#fb4934' }, // Gruvbox red
const handleMenu = (event) => setAnchorEl(event.currentTarget); secondary: { main: '#83a598' }, // Gruvbox blue
const handleClose = () => setAnchorEl(null); background: {
const handleLogout = () => { default: '#282828', // Gruvbox dark bg
handleClose(); paper: '#3c3836', // Gruvbox dark lighter
onLogout(); },
navigate('/'); text: {
}; primary: '#ebdbb2',
secondary: '#a89984'
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) => { export default class App extends Component {
setUser(userData); constructor(props) {
localStorage.setItem('user', JSON.stringify(userData)); super(props);
this.state = {
selectedChannels: [],
user: null, // { username, role, token }
loading: true
};
}
componentDidMount() {
// Load selection from local storage
const saved = localStorage.getItem('selectedChannels');
if (saved) {
try {
this.setState({ selectedChannels: JSON.parse(saved) });
} catch (e) {
console.error("Failed to parse saved channels");
}
}
// Check for existing token
const token = localStorage.getItem('authToken');
const username = localStorage.getItem('authUser');
const role = localStorage.getItem('authRole');
if (token && username) {
this.setState({ user: { username, role, token } });
}
this.setState({ loading: false });
}
handleSelectionChange = (newSelection) => {
this.setState({ selectedChannels: newSelection });
localStorage.setItem('selectedChannels', JSON.stringify(newSelection));
}; };
const handleLogout = () => { handleLogin = (userData) => {
setUser(null); this.setState({ user: userData });
localStorage.removeItem('user'); localStorage.setItem('authToken', userData.token);
localStorage.setItem('authUser', userData.username);
localStorage.setItem('authRole', userData.role);
}; };
const handleToggleChannel = (id) => { handleLogout = () => {
setSelectedChannels(prev => { this.setState({ user: null });
const newSelection = prev.includes(id) localStorage.removeItem('authToken');
? prev.filter(c => c !== id) localStorage.removeItem('authUser');
: [...prev, id]; localStorage.removeItem('authRole');
localStorage.setItem('selectedChannels', JSON.stringify(newSelection));
return newSelection;
});
}; };
return ( render() {
<ThemeProvider theme={theme}> const { selectedChannels, user, loading } = this.state;
<CssBaseline />
<BrowserRouter> // While checking auth, we could show loader, but it's sync here mostly.
<Box sx={{ flexGrow: 1, height: '100vh', display: 'flex', flexDirection: 'column' }}>
<NavBar user={user} onLogout={handleLogout} /> return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<BrowserRouter>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', height: '100vh' }}>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
TischlerCtrl
</Typography>
<Button color="inherit" component={Link} to="/" startIcon={<DashboardIcon />}>Views</Button>
<Button color="inherit" component={Link} to="/live" startIcon={<ShowChartIcon />}>Live</Button>
<Button color="inherit" component={Link} to="/settings" startIcon={<SettingsIcon />}>Settings</Button>
{user ? (
<Button color="inherit" onClick={this.handleLogout}>Logout ({user.username})</Button>
) : (
<Button color="inherit" component={Link} to="/login">Login</Button>
)}
</Toolbar>
</AppBar>
<Box component="main" sx={{ flexGrow: 1, overflow: 'auto' }}>
<Routes> <Routes>
<Route path="/" element={<ViewManager user={user} />} /> <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 />} /> <Route path="/views/:id" element={<ViewDisplay />} />
<Route path="/live" element={
<Chart
selectedChannels={selectedChannels}
/>
} />
<Route path="/settings" element={
<Settings
selectedChannels={selectedChannels}
onSelectionChange={this.handleSelectionChange}
/>
} />
<Route path="/login" element={<Login onLogin={this.handleLogin} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Box> </Box>
</Box> </BrowserRouter>
</BrowserRouter> </ThemeProvider>
</ThemeProvider> );
); }
} }

View File

@@ -1,23 +1,50 @@
import React, { useEffect, useState } from 'react'; import React, { Component } from 'react';
import { Paper, Typography, Box, CircularProgress } from '@mui/material'; import { Paper, Typography, Box, CircularProgress } from '@mui/material';
import { LineChart } from '@mui/x-charts/LineChart'; import { LineChart } from '@mui/x-charts/LineChart';
import { useTheme } from '@mui/material/styles';
export default function Chart({ selectedChannels = [], channelConfig = null, axisConfig = null }) { export default class Chart extends Component {
const [data, setData] = useState([]); constructor(props) {
const [loading, setLoading] = useState(true); super(props);
const theme = useTheme(); this.state = {
data: [],
loading: true
};
this.intervalId = null;
}
// Determine effective channels list componentDidMount() {
const effectiveChannels = channelConfig this.fetchData();
? channelConfig.map(c => c.id) this.intervalId = setInterval(this.fetchData, 60000);
: selectedChannels; }
componentDidUpdate(prevProps) {
// Compare props to see if we need to refetch
const prevEffective = this.getEffectiveChannels(prevProps);
const currEffective = this.getEffectiveChannels(this.props);
if (prevEffective.join(',') !== currEffective.join(',')) {
this.fetchData();
}
}
componentWillUnmount() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
getEffectiveChannels(props) {
return props.channelConfig
? props.channelConfig.map(c => c.id)
: props.selectedChannels;
}
fetchData = () => {
const effectiveChannels = this.getEffectiveChannels(this.props);
const fetchData = () => {
// Only fetch if selection exists // Only fetch if selection exists
if (effectiveChannels.length === 0) { if (effectiveChannels.length === 0) {
setData([]); this.setState({ data: [], loading: false });
setLoading(false);
return; return;
} }
@@ -47,48 +74,15 @@ export default function Chart({ selectedChannels = [], channelConfig = null, axi
}); });
const sortedData = Array.from(timeMap.values()).sort((a, b) => a.time - b.time); const sortedData = Array.from(timeMap.values()).sort((a, b) => a.time - b.time);
setData(sortedData); this.setState({ data: sortedData, loading: false });
setLoading(false);
}) })
.catch(err => { .catch(err => {
console.error("Failed to fetch data", err); console.error("Failed to fetch data", err);
setLoading(false); this.setState({ loading: false });
}); });
}; };
useEffect(() => { computeAxisLimits(axisKey, effectiveChannels, series) {
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 and axis if config exists
let label = id;
let yAxisKey = 'left';
if (channelConfig) {
const item = channelConfig.find(c => c.id === id);
if (item) {
if (item.alias) label = item.alias;
if (item.yAxis) yAxisKey = item.yAxis;
}
}
return {
dataKey: id,
label: label,
connectNulls: true,
showMark: false,
yAxisKey: yAxisKey,
};
});
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
const computeAxisLimits = (axisKey) => {
// Collect all data points for this axis // Collect all data points for this axis
let axisMin = Infinity; let axisMin = Infinity;
let axisMax = -Infinity; let axisMax = -Infinity;
@@ -98,6 +92,7 @@ export default function Chart({ selectedChannels = [], channelConfig = null, axi
if (axisSeries.length === 0) return {}; // No data for this axis if (axisSeries.length === 0) return {}; // No data for this axis
// Check if config exists for this axis // Check if config exists for this axis
const { axisConfig } = this.props;
let cfgMin = NaN; let cfgMin = NaN;
let cfgMax = NaN; let cfgMax = NaN;
if (axisConfig && axisConfig[axisKey]) { if (axisConfig && axisConfig[axisKey]) {
@@ -110,7 +105,7 @@ export default function Chart({ selectedChannels = [], channelConfig = null, axi
// Calculate data bounds // Calculate data bounds
let hasData = false; let hasData = false;
data.forEach(row => { this.state.data.forEach(row => {
axisSeries.forEach(key => { axisSeries.forEach(key => {
const val = row[key]; const val = row[key];
if (val !== null && val !== undefined) { if (val !== null && val !== undefined) {
@@ -128,43 +123,75 @@ export default function Chart({ selectedChannels = [], channelConfig = null, axi
if (!isNaN(cfgMax)) axisMax = Math.max(axisMax, cfgMax); if (!isNaN(cfgMax)) axisMax = Math.max(axisMax, cfgMax);
return { min: axisMin, max: axisMax }; return { min: axisMin, max: axisMax };
};
const leftLimits = computeAxisLimits('left');
const rightLimits = computeAxisLimits('right');
const yAxes = [
{ id: 'left', scaleType: 'linear', ...leftLimits }
];
if (hasRightAxis) {
yAxes.push({ id: 'right', scaleType: 'linear', ...rightLimits });
} }
return ( render() {
<Box sx={{ width: '100%', height: '80vh', p: 2 }}> const { loading, data } = this.state;
<Typography variant="h6" gutterBottom>Last 24 Hours</Typography> const { channelConfig } = this.props;
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}> const effectiveChannels = this.getEffectiveChannels(this.props);
<Box sx={{ flexGrow: 1 }}>
<LineChart if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
dataset={data} if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
series={series}
xAxis={[{ const series = effectiveChannels.map(id => {
dataKey: 'time', // Find alias and axis if config exists
scaleType: 'time', let label = id;
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) let yAxisKey = 'left';
}]} if (channelConfig) {
yAxis={yAxes} const item = channelConfig.find(c => c.id === id);
rightAxis={hasRightAxis ? 'right' : null} if (item) {
slotProps={{ if (item.alias) label = item.alias;
legend: { if (item.yAxis) yAxisKey = item.yAxis;
direction: 'row', }
position: { vertical: 'top', horizontal: 'middle' }, }
padding: 0,
}, return {
}} dataKey: id,
/> label: label,
</Box> connectNulls: true,
</Paper> showMark: false,
</Box> yAxisKey: yAxisKey,
); };
});
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
const leftLimits = this.computeAxisLimits('left', effectiveChannels, series);
const rightLimits = this.computeAxisLimits('right', effectiveChannels, series);
const yAxes = [
{ id: 'left', scaleType: 'linear', ...leftLimits }
];
if (hasRightAxis) {
yAxes.push({ id: 'right', scaleType: 'linear', ...rightLimits });
}
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' })
}]}
yAxis={yAxes}
rightAxis={hasRightAxis ? 'right' : null}
slotProps={{
legend: {
direction: 'row',
position: { vertical: 'top', horizontal: 'middle' },
padding: 0,
},
}}
/>
</Box>
</Paper>
</Box>
);
}
} }

View File

@@ -1,18 +1,21 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { import { Container, Paper, TextField, Button, Typography, Box } from '@mui/material';
Paper, TextField, Button, Typography, Container, Alert import { withRouter } from './withRouter';
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
export default function Login({ onLogin }) { class Login extends Component {
const [username, setUsername] = useState(''); constructor(props) {
const [password, setPassword] = useState(''); super(props);
const [error, setError] = useState(''); this.state = {
const navigate = useNavigate(); username: '',
password: '',
error: ''
};
}
const handleSubmit = async (e) => { handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); const { username, password } = this.state;
const { onLogin, router } = this.props;
try { try {
const res = await fetch('/api/login', { const res = await fetch('/api/login', {
@@ -20,45 +23,53 @@ export default function Login({ onLogin }) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (res.ok) {
throw new Error(data.error || 'Login failed'); onLogin(data);
router.navigate('/');
} else {
this.setState({ error: data.error || 'Login failed' });
} }
onLogin(data); // { token, role, username }
navigate('/');
} catch (err) { } catch (err) {
setError(err.message); this.setState({ error: 'Network error' });
} }
}; };
return ( render() {
<Container maxWidth="xs" sx={{ mt: 8 }}> const { username, password, error } = this.state;
<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' }}> return (
<TextField <Container maxWidth="xs" sx={{ mt: 8 }}>
label="Username" <Paper sx={{ p: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
value={username} <Typography variant="h5" gutterBottom>Login</Typography>
onChange={(e) => setUsername(e.target.value)} {error && <Typography color="error">{error}</Typography>}
fullWidth <Box component="form" onSubmit={this.handleSubmit} sx={{ mt: 1, width: '100%' }}>
/> <TextField
<TextField margin="normal"
label="Password" required
type="password" fullWidth
value={password} label="Username"
onChange={(e) => setPassword(e.target.value)} value={username}
fullWidth onChange={(e) => this.setState({ username: e.target.value })}
/> />
<Button type="submit" variant="contained" color="primary" fullWidth> <TextField
Sign In margin="normal"
</Button> required
</form> fullWidth
</Paper> label="Password"
</Container> type="password"
); value={password}
onChange={(e) => this.setState({ password: e.target.value })}
/>
<Button type="submit" fullWidth variant="contained" sx={{ mt: 3, mb: 2 }}>
Sign In
</Button>
</Box>
</Paper>
</Container>
);
}
} }
export default withRouter(Login);

View File

@@ -1,71 +1,62 @@
import React, { useEffect, useState } from 'react'; import React, { Component } from 'react';
import { import { Container, Typography, List, ListItem, ListItemText, Switch, CircularProgress } from '@mui/material';
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 }) { export default class Settings extends Component {
const [devices, setDevices] = useState([]); constructor(props) {
const [loading, setLoading] = useState(true); super(props);
const [error, setError] = useState(null); this.state = {
devices: [],
loading: true
};
}
useEffect(() => { componentDidMount() {
fetch('/api/devices') fetch('/api/devices')
.then(res => { .then(res => res.json())
if (!res.ok) throw new Error('Failed to load devices'); .then(data => this.setState({ devices: data, loading: false }))
return res.json();
})
.then(data => {
setDevices(data);
setLoading(false);
})
.catch(err => { .catch(err => {
setError(err.message); console.error("Failed to fetch devices", err);
setLoading(false); this.setState({ loading: false });
}); });
}, []); }
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>; toggleChannel = (id) => {
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">Error: {error}</Typography></Container>; // Toggle selection
// We need to notify parent component about change (onSelectionChange)
const { selectedChannels, onSelectionChange } = this.props;
const newSelection = selectedChannels.includes(id)
? selectedChannels.filter(c => c !== id)
: [...selectedChannels, id];
return ( onSelectionChange(newSelection);
<Container maxWidth="md" sx={{ mt: 4 }}> };
<Typography variant="h4" gutterBottom>
Channel Selection render() {
</Typography> const { loading, devices } = this.state;
<Paper> const { selectedChannels } = this.props;
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Typography variant="h4" gutterBottom>Settings</Typography>
<Typography variant="subtitle1" gutterBottom>Select Channels for Live View</Typography>
<List> <List>
{devices.map((device, index) => { {devices.map((item, idx) => {
const id = `${device.device}:${device.channel}`; const id = `${item.device}:${item.channel}`;
const isSelected = selectedChannels.includes(id);
return ( return (
<ListItem key={id} divider={index !== devices.length - 1}> <ListItem key={idx}>
<ListItemIcon> <ListItemText primary={`${item.device} - ${item.channel}`} />
<SensorsIcon /> <Switch
</ListItemIcon> edge="end"
<ListItemText checked={selectedChannels.includes(id)}
primary={device.channel} onChange={() => this.toggleChannel(id)}
secondary={`Device: ${device.device}`}
/> />
<ListItemSecondaryAction>
<Switch
edge="end"
checked={isSelected}
onChange={() => onToggleChannel(id)}
/>
</ListItemSecondaryAction>
</ListItem> </ListItem>
); );
})} })}
{devices.length === 0 && (
<ListItem>
<ListItemText primary="No devices found in database" />
</ListItem>
)}
</List> </List>
</Paper> </Container>
</Container> );
); }
} }

View File

@@ -1,57 +1,77 @@
import React, { useEffect, useState } from 'react'; import React, { Component } from 'react';
import { useParams } from 'react-router-dom';
import { Box, Typography, Container, CircularProgress } from '@mui/material'; import { Box, Typography, Container, CircularProgress } from '@mui/material';
import Chart from './Chart'; import Chart from './Chart';
import { withRouter } from './withRouter';
export default function ViewDisplay() { class ViewDisplay extends Component {
const { id } = useParams(); constructor(props) {
const [view, setView] = useState(null); super(props);
const [loading, setLoading] = useState(true); this.state = {
const [error, setError] = useState(null); view: null,
loading: true,
error: null
};
}
componentDidMount() {
this.fetchView();
}
componentDidUpdate(prevProps) {
if (prevProps.router.params.id !== this.props.router.params.id) {
this.fetchView();
}
}
fetchView() {
const { id } = this.props.router.params;
this.setState({ loading: true, error: null });
useEffect(() => {
fetch(`/api/views/${id}`) fetch(`/api/views/${id}`)
.then(res => { .then(res => {
if (!res.ok) throw new Error('View not found'); if (!res.ok) throw new Error('View not found');
return res.json(); return res.json();
}) })
.then(data => { .then(data => {
setView(data); this.setState({ view: data, loading: false });
setLoading(false);
}) })
.catch(err => { .catch(err => {
setError(err.message); this.setState({ error: err.message, loading: false });
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>;
// Parse view config (compat with both array and object format)
let channelsData = [];
let axesData = null;
if (Array.isArray(view.config)) {
channelsData = view.config;
} else if (view.config && view.config.channels) {
channelsData = view.config.channels;
axesData = view.config.axes;
} }
// Map view config to Chart format with aliases and axis render() {
const channelConfig = channelsData.map(item => ({ const { view, loading, error } = this.state;
id: `${item.device}:${item.channel}`,
alias: item.alias,
yAxis: item.yAxis || 'left'
}));
return ( if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}> if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
<Box sx={{ p: 2 }}>
<Typography variant="h5">{view.name}</Typography> // Parse view config
let channelsData = [];
let axesData = null;
if (Array.isArray(view.config)) {
channelsData = view.config;
} else if (view.config && view.config.channels) {
channelsData = view.config.channels;
axesData = view.config.axes;
}
const channelConfig = channelsData.map(item => ({
id: `${item.device}:${item.channel}`,
alias: item.alias,
yAxis: item.yAxis || 'left'
}));
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2 }}>
<Typography variant="h5">{view.name}</Typography>
</Box>
<Chart channelConfig={channelConfig} axisConfig={axesData} />
</Box> </Box>
<Chart channelConfig={channelConfig} axisConfig={axesData} /> );
</Box> }
);
} }
export default withRouter(ViewDisplay);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { Component } from 'react';
import { import {
Container, Typography, List, ListItem, ListItemText, ListItemIcon, Container, Typography, List, ListItem, ListItemText, ListItemIcon,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
@@ -8,61 +8,78 @@ import DashboardIcon from '@mui/icons-material/Dashboard';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import { useNavigate } from 'react-router-dom'; import { withRouter } from './withRouter';
export default function ViewManager({ user }) { class ViewManager extends Component {
const [views, setViews] = useState([]); constructor(props) {
const [open, setOpen] = useState(false); super(props);
const [editingId, setEditingId] = useState(null); // ID if editing, null if creating this.state = {
const [viewName, setViewName] = useState(''); views: [],
const [availableDevices, setAvailableDevices] = useState([]); open: false,
editingId: null,
viewName: '',
availableDevices: [],
viewConfig: [], // [{ device, channel, alias, yAxis }]
axisConfig: {
left: { min: '', max: '' },
right: { min: '', max: '' }
},
// Config item selection state
selDevice: '',
selChannel: '',
alias: '',
yAxis: 'left'
};
}
const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias, yAxis }] componentDidMount() {
const [axisConfig, setAxisConfig] = useState({ this.refreshViews();
left: { min: '', max: '' }, if (this.isAdmin()) {
right: { min: '', max: '' }
});
// Selection state for new item
const [selDevice, setSelDevice] = useState('');
const [selChannel, setSelChannel] = useState('');
const [alias, setAlias] = useState('');
const [yAxis, setYAxis] = useState('left');
const navigate = useNavigate();
const isAdmin = user && user.role === 'admin';
useEffect(() => {
refreshViews();
if (isAdmin) {
fetch('/api/devices') fetch('/api/devices')
.then(res => res.json()) .then(res => res.json())
.then(setAvailableDevices) .then(devices => this.setState({ availableDevices: devices }))
.catch(console.error); .catch(console.error);
} }
}, [isAdmin]); }
const refreshViews = () => { componentDidUpdate(prevProps) {
if (prevProps.user !== this.props.user) {
// If user changes (e.g. login/logout), refresh
this.refreshViews();
if (this.isAdmin()) {
fetch('/api/devices')
.then(res => res.json())
.then(devices => this.setState({ availableDevices: devices }))
.catch(console.error);
}
}
}
isAdmin() {
const { user } = this.props;
return user && user.role === 'admin';
}
refreshViews = () => {
fetch('/api/views') fetch('/api/views')
.then(res => res.json()) .then(res => res.json())
.then(setViews) .then(views => this.setState({ views }))
.catch(console.error); .catch(console.error);
}; };
const handleOpenCreate = () => { handleOpenCreate = () => {
setEditingId(null); this.setState({
setViewName(''); editingId: null,
setViewConfig([]); viewName: '',
setAxisConfig({ left: { min: '', max: '' }, right: { min: '', max: '' } }); viewConfig: [],
setOpen(true); axisConfig: { left: { min: '', max: '' }, right: { min: '', max: '' } },
open: true
});
}; };
const handleOpenEdit = (v, e) => { handleOpenEdit = (v, e) => {
e.stopPropagation(); e.stopPropagation();
setEditingId(v.id);
setViewName(v.name);
// Fetch full config for this view
fetch(`/api/views/${v.id}`) fetch(`/api/views/${v.id}`)
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
@@ -70,45 +87,48 @@ export default function ViewManager({ user }) {
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } }; let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
if (Array.isArray(data.config)) { if (Array.isArray(data.config)) {
// Legacy format: config is just the array of channels
channels = data.config; channels = data.config;
} else if (data.config && data.config.channels) { } else if (data.config && data.config.channels) {
// New format: { channels, axes }
channels = data.config.channels; channels = data.config.channels;
if (data.config.axes) { if (data.config.axes) {
// Merge with defaults
axes = { axes = {
left: { ...axes.left, ...data.config.axes.left }, left: { ...axes.left, ...data.config.axes.left },
right: { ...axes.right, ...data.config.axes.right } right: { ...axes.right, ...data.config.axes.right }
}; };
} }
} }
// Ensure config items have yAxis
channels = channels.map(c => ({ ...c, yAxis: c.yAxis || 'left' })); channels = channels.map(c => ({ ...c, yAxis: c.yAxis || 'left' }));
setViewConfig(channels); this.setState({
setAxisConfig(axes); editingId: v.id,
setOpen(true); viewName: v.name,
viewConfig: channels,
axisConfig: axes,
open: true
});
}); });
}; };
const handleDelete = async (id, e) => { handleDelete = async (id, e) => {
e.stopPropagation(); e.stopPropagation();
if (!window.confirm("Are you sure?")) return; if (!window.confirm("Are you sure?")) return;
const { user } = this.props;
await fetch(`/api/views/${id}`, { await fetch(`/api/views/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': `Bearer ${user.token}` } headers: { 'Authorization': `Bearer ${user.token}` }
}); });
refreshViews(); this.refreshViews();
}; };
const handleSave = async () => { handleSave = async () => {
const { viewName, viewConfig, editingId, axisConfig } = this.state;
const { user } = this.props;
if (!viewName || viewConfig.length === 0) return; if (!viewName || viewConfig.length === 0) return;
const url = editingId ? `/api/views/${editingId}` : '/api/views'; const url = editingId ? `/api/views/${editingId}` : '/api/views';
const method = editingId ? 'PUT' : 'POST'; const method = editingId ? 'PUT' : 'POST';
// Prepare config object
const finalConfig = { const finalConfig = {
channels: viewConfig, channels: viewConfig,
axes: axisConfig axes: axisConfig
@@ -125,8 +145,8 @@ export default function ViewManager({ user }) {
}); });
if (res.ok) { if (res.ok) {
setOpen(false); this.setState({ open: false });
refreshViews(); this.refreshViews();
} else { } else {
alert('Failed to save view'); alert('Failed to save view');
} }
@@ -135,157 +155,173 @@ export default function ViewManager({ user }) {
} }
}; };
const addConfigItem = () => { addConfigItem = () => {
const { selDevice, selChannel, alias, yAxis, viewConfig } = this.state;
if (selDevice && selChannel) { if (selDevice && selChannel) {
setViewConfig([...viewConfig, { this.setState({
device: selDevice, viewConfig: [...viewConfig, {
channel: selChannel, device: selDevice,
alias: alias || `${selDevice}:${selChannel}`, channel: selChannel,
yAxis: yAxis alias: alias || `${selDevice}:${selChannel}`,
}]); yAxis: yAxis
setSelDevice(''); }],
setSelChannel(''); selDevice: '',
setAlias(''); selChannel: '',
setYAxis('left'); alias: '',
yAxis: 'left'
});
} }
}; };
// Derived state for channels handleAxisChange = (axis, field, value) => {
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel); this.setState(prevState => ({
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))]; axisConfig: {
...prevState.axisConfig,
const handleAxisChange = (axis, field, value) => { [axis]: { ...prevState.axisConfig[axis], [field]: value }
setAxisConfig(prev => ({ }
...prev,
[axis]: { ...prev[axis], [field]: value }
})); }));
}; };
return ( render() {
<Container maxWidth="md" sx={{ mt: 4 }}> const {
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> views, open, editingId, viewName, availableDevices, viewConfig, axisConfig,
<Typography variant="h4">Views</Typography> selDevice, selChannel, alias, yAxis
{isAdmin && ( } = this.state;
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}> const { router } = this.props;
Create View const isAdmin = this.isAdmin();
</Button>
)}
</Box>
<List> const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
{views.map(view => ( const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
<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()}`}
/>
{isAdmin && (
<Box>
<IconButton onClick={(e) => handleOpenEdit(view, e)}><EditIcon /></IconButton>
<IconButton onClick={(e) => handleDelete(view.id, e)}><DeleteIcon /></IconButton>
</Box>
)}
</ListItem>
))}
{views.length === 0 && <Typography>No views available.</Typography>}
</List>
{/* Create/Edit Dialog */} return (
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth> <Container maxWidth="md" sx={{ mt: 4 }}>
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<DialogContent> <Typography variant="h4">Views</Typography>
<TextField {isAdmin && (
autoFocus <Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
margin="dense" Create View
label="View Name" </Button>
fullWidth )}
value={viewName} </Box>
onChange={(e) => setViewName(e.target.value)}
sx={{ mb: 2 }}
/>
{/* Axis Config Section */} <List>
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1, mb: 2 }}> {views.map(view => (
<Typography variant="subtitle2" gutterBottom>Axis Configuration (Soft Limits)</Typography> <ListItem
<Box sx={{ display: 'flex', gap: 2 }}> button
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}> key={view.id}
<Typography variant="caption" sx={{ width: 40 }}>Left:</Typography> divider
<TextField size="small" label="Min" type="number" value={axisConfig.left.min} onChange={(e) => handleAxisChange('left', 'min', e.target.value)} /> onClick={() => router.navigate(`/views/${view.id}`)}
<TextField size="small" label="Max" type="number" value={axisConfig.left.max} onChange={(e) => handleAxisChange('left', 'max', e.target.value)} /> >
</Box> <ListItemIcon><DashboardIcon /></ListItemIcon>
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}> <ListItemText
<Typography variant="caption" sx={{ width: 40 }}>Right:</Typography> primary={view.name}
<TextField size="small" label="Min" type="number" value={axisConfig.right.min} onChange={(e) => handleAxisChange('right', 'min', e.target.value)} /> secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`}
<TextField size="small" label="Max" type="number" value={axisConfig.right.max} onChange={(e) => handleAxisChange('right', 'max', e.target.value)} />
</Box>
</Box>
</Box>
<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, alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 150 }}>
<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 size="small" sx={{ minWidth: 150 }}>
<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>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>Axis</InputLabel>
<Select value={yAxis} label="Axis" onChange={(e) => setYAxis(e.target.value)}>
<MenuItem value="left">Left</MenuItem>
<MenuItem value="right">Right</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="Alias (Optional)"
value={alias}
onChange={(e) => setAlias(e.target.value)}
sx={{ flexGrow: 1 }}
/> />
<Button variant="outlined" onClick={addConfigItem}>Add</Button> {isAdmin && (
<Box>
<IconButton onClick={(e) => this.handleOpenEdit(view, e)}><EditIcon /></IconButton>
<IconButton onClick={(e) => this.handleDelete(view.id, e)}><DeleteIcon /></IconButton>
</Box>
)}
</ListItem>
))}
{views.length === 0 && <Typography>No views available.</Typography>}
</List>
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="View Name"
fullWidth
value={viewName}
onChange={(e) => this.setState({ viewName: e.target.value })}
sx={{ mb: 2 }}
/>
{/* Axis Config Section */}
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>Axis Configuration (Soft Limits)</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption" sx={{ width: 40 }}>Left:</Typography>
<TextField size="small" label="Min" type="number" value={axisConfig.left.min} onChange={(e) => this.handleAxisChange('left', 'min', e.target.value)} />
<TextField size="small" label="Max" type="number" value={axisConfig.left.max} onChange={(e) => this.handleAxisChange('left', 'max', e.target.value)} />
</Box>
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption" sx={{ width: 40 }}>Right:</Typography>
<TextField size="small" label="Min" type="number" value={axisConfig.right.min} onChange={(e) => this.handleAxisChange('right', 'min', e.target.value)} />
<TextField size="small" label="Max" type="number" value={axisConfig.right.max} onChange={(e) => this.handleAxisChange('right', 'max', e.target.value)} />
</Box>
</Box>
</Box> </Box>
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}> <Box sx={{ mt: 2, p: 2, border: '1px solid #444', borderRadius: 1 }}>
{viewConfig.map((item, idx) => ( <Typography variant="subtitle2">Add Channels</Typography>
<Chip <Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
key={idx} <FormControl size="small" sx={{ minWidth: 150 }}>
label={`${item.alias} (${item.yAxis})`} <InputLabel>Device</InputLabel>
onClick={() => { <Select value={selDevice} label="Device" onChange={(e) => this.setState({ selDevice: e.target.value })}>
setSelDevice(item.device); {uniqueDevices.map(d => <MenuItem key={d} value={d}>{d}</MenuItem>)}
setSelChannel(item.channel); </Select>
setAlias(item.alias); </FormControl>
setYAxis(item.yAxis); <FormControl size="small" sx={{ minWidth: 150 }}>
setViewConfig(viewConfig.filter((_, i) => i !== idx)); <InputLabel>Channel</InputLabel>
}} <Select value={selChannel} label="Channel" onChange={(e) => this.setState({ selChannel: e.target.value })}>
onDelete={() => setViewConfig(viewConfig.filter((_, i) => i !== idx))} {channelsForDevice.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
sx={{ cursor: 'pointer' }} </Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>Axis</InputLabel>
<Select value={yAxis} label="Axis" onChange={(e) => this.setState({ yAxis: e.target.value })}>
<MenuItem value="left">Left</MenuItem>
<MenuItem value="right">Right</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="Alias (Optional)"
value={alias}
onChange={(e) => this.setState({ alias: e.target.value })}
sx={{ flexGrow: 1 }}
/> />
))} <Button variant="outlined" onClick={this.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.yAxis})`}
onClick={() => {
this.setState({
selDevice: item.device,
selChannel: item.channel,
alias: item.alias,
yAxis: item.yAxis,
viewConfig: viewConfig.filter((_, i) => i !== idx)
});
}}
onDelete={() => this.setState({ viewConfig: viewConfig.filter((_, i) => i !== idx) })}
sx={{ cursor: 'pointer' }}
/>
))}
</Box>
<Typography variant="caption" sx={{ mt: 1, display: 'block', color: 'text.secondary' }}>
Click a chip to edit its settings.
</Typography>
</Box> </Box>
<Typography variant="caption" sx={{ mt: 1, display: 'block', color: 'text.secondary' }}> </DialogContent>
Click a chip to edit its settings. <DialogActions>
</Typography> <Button onClick={() => this.setState({ open: false })}>Cancel</Button>
</Box> <Button onClick={this.handleSave} color="primary">Save</Button>
</DialogContent> </DialogActions>
<DialogActions> </Dialog>
<Button onClick={() => setOpen(false)}>Cancel</Button> </Container>
<Button onClick={handleSave} color="primary">Save</Button> );
</DialogActions> }
</Dialog>
</Container>
);
} }
export default withRouter(ViewManager);

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { useNavigate, useLocation, useParams } from 'react-router-dom';
export function withRouter(Component) {
function ComponentWithRouterProp(props) {
let location = useLocation();
let navigate = useNavigate();
let params = useParams();
return (
<Component
{...props}
router={{ location, navigate, params }}
/>
);
}
return ComponentWithRouterProp;
}