u
This commit is contained in:
@@ -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
|
||||||
|
secondary: { main: '#83a598' }, // Gruvbox blue
|
||||||
|
background: {
|
||||||
|
default: '#282828', // Gruvbox dark bg
|
||||||
|
paper: '#3c3836', // Gruvbox dark lighter
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#ebdbb2',
|
||||||
|
secondary: '#a89984'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleMenu = (event) => setAnchorEl(event.currentTarget);
|
export default class App extends Component {
|
||||||
const handleClose = () => setAnchorEl(null);
|
constructor(props) {
|
||||||
const handleLogout = () => {
|
super(props);
|
||||||
handleClose();
|
this.state = {
|
||||||
onLogout();
|
selectedChannels: [],
|
||||||
navigate('/');
|
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));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleLogin = (userData) => {
|
||||||
|
this.setState({ user: userData });
|
||||||
|
localStorage.setItem('authToken', userData.token);
|
||||||
|
localStorage.setItem('authUser', userData.username);
|
||||||
|
localStorage.setItem('authRole', userData.role);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLogout = () => {
|
||||||
|
this.setState({ user: null });
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('authUser');
|
||||||
|
localStorage.removeItem('authRole');
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { selectedChannels, user, loading } = this.state;
|
||||||
|
|
||||||
|
// While checking auth, we could show loader, but it's sync here mostly.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ThemeProvider theme={darkTheme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<BrowserRouter>
|
||||||
|
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||||
<AppBar position="static">
|
<AppBar position="static">
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, cursor: 'pointer' }} onClick={() => navigate('/')}>
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
TischlerCtrl
|
TischlerCtrl
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Button color="inherit" startIcon={<DashboardIcon />} onClick={() => navigate('/')}>
|
<Button color="inherit" component={Link} to="/" startIcon={<DashboardIcon />}>Views</Button>
|
||||||
Views
|
<Button color="inherit" component={Link} to="/live" startIcon={<ShowChartIcon />}>Live</Button>
|
||||||
</Button>
|
<Button color="inherit" component={Link} to="/settings" startIcon={<SettingsIcon />}>Settings</Button>
|
||||||
<Button color="inherit" startIcon={<ShowChartIcon />} onClick={() => navigate('/live')}>
|
|
||||||
Live
|
|
||||||
</Button>
|
|
||||||
<Button color="inherit" startIcon={<SettingsIcon />} onClick={() => navigate('/settings')}>
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<div>
|
<Button color="inherit" onClick={this.handleLogout}>Logout ({user.username})</Button>
|
||||||
<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')}>
|
<Button color="inherit" component={Link} to="/login">Login</Button>
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</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>
|
<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>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
157
uiserver/src/components/Chart.js
vendored
157
uiserver/src/components/Chart.js
vendored
@@ -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,20 +74,61 @@ 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();
|
// Collect all data points for this axis
|
||||||
const interval = setInterval(fetchData, 60000);
|
let axisMin = Infinity;
|
||||||
return () => clearInterval(interval);
|
let axisMax = -Infinity;
|
||||||
}, [selectedChannels, channelConfig]);
|
|
||||||
|
const axisSeries = series.filter(s => s.yAxisKey === axisKey).map(s => s.dataKey);
|
||||||
|
|
||||||
|
if (axisSeries.length === 0) return {}; // No data for this axis
|
||||||
|
|
||||||
|
// Check if config exists for this axis
|
||||||
|
const { axisConfig } = this.props;
|
||||||
|
let cfgMin = NaN;
|
||||||
|
let cfgMax = NaN;
|
||||||
|
if (axisConfig && axisConfig[axisKey]) {
|
||||||
|
cfgMin = parseFloat(axisConfig[axisKey].min);
|
||||||
|
cfgMax = parseFloat(axisConfig[axisKey].max);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimization: If no config set, just return empty and let chart autoscale fully.
|
||||||
|
if (isNaN(cfgMin) && isNaN(cfgMax)) return {};
|
||||||
|
|
||||||
|
// Calculate data bounds
|
||||||
|
let hasData = false;
|
||||||
|
this.state.data.forEach(row => {
|
||||||
|
axisSeries.forEach(key => {
|
||||||
|
const val = row[key];
|
||||||
|
if (val !== null && val !== undefined) {
|
||||||
|
hasData = true;
|
||||||
|
if (val < axisMin) axisMin = val;
|
||||||
|
if (val > axisMax) axisMax = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasData) return {}; // No valid data points
|
||||||
|
|
||||||
|
// Apply config soft limits
|
||||||
|
if (!isNaN(cfgMin)) axisMin = Math.min(axisMin, cfgMin);
|
||||||
|
if (!isNaN(cfgMax)) axisMax = Math.max(axisMax, cfgMax);
|
||||||
|
|
||||||
|
return { min: axisMin, max: axisMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { loading, data } = this.state;
|
||||||
|
const { channelConfig } = this.props;
|
||||||
|
const effectiveChannels = this.getEffectiveChannels(this.props);
|
||||||
|
|
||||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
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>;
|
if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
|
||||||
@@ -88,50 +156,8 @@ export default function Chart({ selectedChannels = [], channelConfig = null, axi
|
|||||||
|
|
||||||
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
|
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
|
||||||
|
|
||||||
const computeAxisLimits = (axisKey) => {
|
const leftLimits = this.computeAxisLimits('left', effectiveChannels, series);
|
||||||
// Collect all data points for this axis
|
const rightLimits = this.computeAxisLimits('right', effectiveChannels, series);
|
||||||
let axisMin = Infinity;
|
|
||||||
let axisMax = -Infinity;
|
|
||||||
|
|
||||||
const axisSeries = series.filter(s => s.yAxisKey === axisKey).map(s => s.dataKey);
|
|
||||||
|
|
||||||
if (axisSeries.length === 0) return {}; // No data for this axis
|
|
||||||
|
|
||||||
// Check if config exists for this axis
|
|
||||||
let cfgMin = NaN;
|
|
||||||
let cfgMax = NaN;
|
|
||||||
if (axisConfig && axisConfig[axisKey]) {
|
|
||||||
cfgMin = parseFloat(axisConfig[axisKey].min);
|
|
||||||
cfgMax = parseFloat(axisConfig[axisKey].max);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimization: If no config set, just return empty and let chart autoscale fully.
|
|
||||||
if (isNaN(cfgMin) && isNaN(cfgMax)) return {};
|
|
||||||
|
|
||||||
// Calculate data bounds
|
|
||||||
let hasData = false;
|
|
||||||
data.forEach(row => {
|
|
||||||
axisSeries.forEach(key => {
|
|
||||||
const val = row[key];
|
|
||||||
if (val !== null && val !== undefined) {
|
|
||||||
hasData = true;
|
|
||||||
if (val < axisMin) axisMin = val;
|
|
||||||
if (val > axisMax) axisMax = val;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasData) return {}; // No valid data points
|
|
||||||
|
|
||||||
// Apply config soft limits
|
|
||||||
if (!isNaN(cfgMin)) axisMin = Math.min(axisMin, cfgMin);
|
|
||||||
if (!isNaN(cfgMax)) axisMax = Math.max(axisMax, cfgMax);
|
|
||||||
|
|
||||||
return { min: axisMin, max: axisMax };
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftLimits = computeAxisLimits('left');
|
|
||||||
const rightLimits = computeAxisLimits('right');
|
|
||||||
|
|
||||||
const yAxes = [
|
const yAxes = [
|
||||||
{ id: 'left', scaleType: 'linear', ...leftLimits }
|
{ id: 'left', scaleType: 'linear', ...leftLimits }
|
||||||
@@ -167,4 +193,5 @@ export default function Chart({ selectedChannels = [], channelConfig = null, axi
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { username, password, error } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xs" sx={{ mt: 8 }}>
|
<Container maxWidth="xs" sx={{ mt: 8 }}>
|
||||||
<Paper sx={{ p: 4, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Paper sx={{ p: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
<Typography variant="h5" align="center">Login</Typography>
|
<Typography variant="h5" gutterBottom>Login</Typography>
|
||||||
{error && <Alert severity="error">{error}</Alert>}
|
{error && <Typography color="error">{error}</Typography>}
|
||||||
|
<Box component="form" onSubmit={this.handleSubmit} sx={{ mt: 1, width: '100%' }}>
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
||||||
<TextField
|
<TextField
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
label="Username"
|
label="Username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => this.setState({ username: e.target.value })}
|
||||||
fullWidth
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => this.setState({ password: e.target.value })}
|
||||||
fullWidth
|
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="contained" color="primary" fullWidth>
|
<Button type="submit" fullWidth variant="contained" sx={{ mt: 3, mb: 2 }}>
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withRouter(Login);
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
|
toggleChannel = (id) => {
|
||||||
|
// 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];
|
||||||
|
|
||||||
|
onSelectionChange(newSelection);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { loading, devices } = this.state;
|
||||||
|
const { selectedChannels } = this.props;
|
||||||
|
|
||||||
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
|
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 (
|
return (
|
||||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
<Container maxWidth="md" sx={{ mt: 4 }}>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>Settings</Typography>
|
||||||
Channel Selection
|
<Typography variant="subtitle1" gutterBottom>Select Channels for Live View</Typography>
|
||||||
</Typography>
|
|
||||||
<Paper>
|
|
||||||
<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 />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={device.channel}
|
|
||||||
secondary={`Device: ${device.device}`}
|
|
||||||
/>
|
|
||||||
<ListItemSecondaryAction>
|
|
||||||
<Switch
|
<Switch
|
||||||
edge="end"
|
edge="end"
|
||||||
checked={isSelected}
|
checked={selectedChannels.includes(id)}
|
||||||
onChange={() => onToggleChannel(id)}
|
onChange={() => this.toggleChannel(id)}
|
||||||
/>
|
/>
|
||||||
</ListItemSecondaryAction>
|
|
||||||
</ListItem>
|
</ListItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{devices.length === 0 && (
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText primary="No devices found in database" />
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
</List>
|
</List>
|
||||||
</Paper>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,52 @@
|
|||||||
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]);
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { view, loading, error } = this.state;
|
||||||
|
|
||||||
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
|
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
|
||||||
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
|
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
|
||||||
|
|
||||||
// Parse view config (compat with both array and object format)
|
// Parse view config
|
||||||
let channelsData = [];
|
let channelsData = [];
|
||||||
let axesData = null;
|
let axesData = null;
|
||||||
|
|
||||||
@@ -39,7 +57,6 @@ export default function ViewDisplay() {
|
|||||||
axesData = view.config.axes;
|
axesData = view.config.axes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map view config to Chart format with aliases and axis
|
|
||||||
const channelConfig = channelsData.map(item => ({
|
const channelConfig = channelsData.map(item => ({
|
||||||
id: `${item.device}:${item.channel}`,
|
id: `${item.device}:${item.channel}`,
|
||||||
alias: item.alias,
|
alias: item.alias,
|
||||||
@@ -54,4 +71,7 @@ export default function ViewDisplay() {
|
|||||||
<Chart channelConfig={channelConfig} axisConfig={axesData} />
|
<Chart channelConfig={channelConfig} axisConfig={axesData} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withRouter(ViewDisplay);
|
||||||
|
|||||||
@@ -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,
|
||||||
const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias, yAxis }]
|
viewName: '',
|
||||||
const [axisConfig, setAxisConfig] = useState({
|
availableDevices: [],
|
||||||
|
viewConfig: [], // [{ device, channel, alias, yAxis }]
|
||||||
|
axisConfig: {
|
||||||
left: { min: '', max: '' },
|
left: { min: '', max: '' },
|
||||||
right: { min: '', max: '' }
|
right: { min: '', max: '' }
|
||||||
});
|
},
|
||||||
|
// Config item selection state
|
||||||
|
selDevice: '',
|
||||||
|
selChannel: '',
|
||||||
|
alias: '',
|
||||||
|
yAxis: 'left'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Selection state for new item
|
componentDidMount() {
|
||||||
const [selDevice, setSelDevice] = useState('');
|
this.refreshViews();
|
||||||
const [selChannel, setSelChannel] = useState('');
|
if (this.isAdmin()) {
|
||||||
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,38 +155,50 @@ 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({
|
||||||
|
viewConfig: [...viewConfig, {
|
||||||
device: selDevice,
|
device: selDevice,
|
||||||
channel: selChannel,
|
channel: selChannel,
|
||||||
alias: alias || `${selDevice}:${selChannel}`,
|
alias: alias || `${selDevice}:${selChannel}`,
|
||||||
yAxis: yAxis
|
yAxis: yAxis
|
||||||
}]);
|
}],
|
||||||
setSelDevice('');
|
selDevice: '',
|
||||||
setSelChannel('');
|
selChannel: '',
|
||||||
setAlias('');
|
alias: '',
|
||||||
setYAxis('left');
|
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 }
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
views, open, editingId, viewName, availableDevices, viewConfig, axisConfig,
|
||||||
|
selDevice, selChannel, alias, yAxis
|
||||||
|
} = this.state;
|
||||||
|
const { router } = this.props;
|
||||||
|
const isAdmin = this.isAdmin();
|
||||||
|
|
||||||
|
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
|
||||||
|
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
<Container maxWidth="md" sx={{ mt: 4 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="h4">Views</Typography>
|
<Typography variant="h4">Views</Typography>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
|
||||||
Create View
|
Create View
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -178,7 +210,7 @@ export default function ViewManager({ user }) {
|
|||||||
button
|
button
|
||||||
key={view.id}
|
key={view.id}
|
||||||
divider
|
divider
|
||||||
onClick={() => navigate(`/views/${view.id}`)}
|
onClick={() => router.navigate(`/views/${view.id}`)}
|
||||||
>
|
>
|
||||||
<ListItemIcon><DashboardIcon /></ListItemIcon>
|
<ListItemIcon><DashboardIcon /></ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
@@ -187,8 +219,8 @@ export default function ViewManager({ user }) {
|
|||||||
/>
|
/>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Box>
|
<Box>
|
||||||
<IconButton onClick={(e) => handleOpenEdit(view, e)}><EditIcon /></IconButton>
|
<IconButton onClick={(e) => this.handleOpenEdit(view, e)}><EditIcon /></IconButton>
|
||||||
<IconButton onClick={(e) => handleDelete(view.id, e)}><DeleteIcon /></IconButton>
|
<IconButton onClick={(e) => this.handleDelete(view.id, e)}><DeleteIcon /></IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -196,8 +228,7 @@ export default function ViewManager({ user }) {
|
|||||||
{views.length === 0 && <Typography>No views available.</Typography>}
|
{views.length === 0 && <Typography>No views available.</Typography>}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
||||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth>
|
|
||||||
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
|
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -206,7 +237,7 @@ export default function ViewManager({ user }) {
|
|||||||
label="View Name"
|
label="View Name"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={viewName}
|
value={viewName}
|
||||||
onChange={(e) => setViewName(e.target.value)}
|
onChange={(e) => this.setState({ viewName: e.target.value })}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -216,13 +247,13 @@ export default function ViewManager({ user }) {
|
|||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
<Typography variant="caption" sx={{ width: 40 }}>Left:</Typography>
|
<Typography variant="caption" sx={{ width: 40 }}>Left:</Typography>
|
||||||
<TextField size="small" label="Min" type="number" value={axisConfig.left.min} onChange={(e) => handleAxisChange('left', 'min', e.target.value)} />
|
<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) => handleAxisChange('left', 'max', 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>
|
||||||
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
<Typography variant="caption" sx={{ width: 40 }}>Right:</Typography>
|
<Typography variant="caption" sx={{ width: 40 }}>Right:</Typography>
|
||||||
<TextField size="small" label="Min" type="number" value={axisConfig.right.min} onChange={(e) => handleAxisChange('right', 'min', e.target.value)} />
|
<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) => handleAxisChange('right', 'max', 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>
|
</Box>
|
||||||
@@ -232,19 +263,19 @@ export default function ViewManager({ user }) {
|
|||||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||||
<InputLabel>Device</InputLabel>
|
<InputLabel>Device</InputLabel>
|
||||||
<Select value={selDevice} label="Device" onChange={(e) => setSelDevice(e.target.value)}>
|
<Select value={selDevice} label="Device" onChange={(e) => this.setState({ selDevice: e.target.value })}>
|
||||||
{uniqueDevices.map(d => <MenuItem key={d} value={d}>{d}</MenuItem>)}
|
{uniqueDevices.map(d => <MenuItem key={d} value={d}>{d}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||||
<InputLabel>Channel</InputLabel>
|
<InputLabel>Channel</InputLabel>
|
||||||
<Select value={selChannel} label="Channel" onChange={(e) => setSelChannel(e.target.value)}>
|
<Select value={selChannel} label="Channel" onChange={(e) => this.setState({ selChannel: e.target.value })}>
|
||||||
{channelsForDevice.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
{channelsForDevice.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||||
<InputLabel>Axis</InputLabel>
|
<InputLabel>Axis</InputLabel>
|
||||||
<Select value={yAxis} label="Axis" onChange={(e) => setYAxis(e.target.value)}>
|
<Select value={yAxis} label="Axis" onChange={(e) => this.setState({ yAxis: e.target.value })}>
|
||||||
<MenuItem value="left">Left</MenuItem>
|
<MenuItem value="left">Left</MenuItem>
|
||||||
<MenuItem value="right">Right</MenuItem>
|
<MenuItem value="right">Right</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -253,10 +284,10 @@ export default function ViewManager({ user }) {
|
|||||||
size="small"
|
size="small"
|
||||||
label="Alias (Optional)"
|
label="Alias (Optional)"
|
||||||
value={alias}
|
value={alias}
|
||||||
onChange={(e) => setAlias(e.target.value)}
|
onChange={(e) => this.setState({ alias: e.target.value })}
|
||||||
sx={{ flexGrow: 1 }}
|
sx={{ flexGrow: 1 }}
|
||||||
/>
|
/>
|
||||||
<Button variant="outlined" onClick={addConfigItem}>Add</Button>
|
<Button variant="outlined" onClick={this.addConfigItem}>Add</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
@@ -265,13 +296,15 @@ export default function ViewManager({ user }) {
|
|||||||
key={idx}
|
key={idx}
|
||||||
label={`${item.alias} (${item.yAxis})`}
|
label={`${item.alias} (${item.yAxis})`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelDevice(item.device);
|
this.setState({
|
||||||
setSelChannel(item.channel);
|
selDevice: item.device,
|
||||||
setAlias(item.alias);
|
selChannel: item.channel,
|
||||||
setYAxis(item.yAxis);
|
alias: item.alias,
|
||||||
setViewConfig(viewConfig.filter((_, i) => i !== idx));
|
yAxis: item.yAxis,
|
||||||
|
viewConfig: viewConfig.filter((_, i) => i !== idx)
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onDelete={() => setViewConfig(viewConfig.filter((_, i) => i !== idx))}
|
onDelete={() => this.setState({ viewConfig: viewConfig.filter((_, i) => i !== idx) })}
|
||||||
sx={{ cursor: 'pointer' }}
|
sx={{ cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -282,10 +315,13 @@ export default function ViewManager({ user }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
<Button onClick={() => this.setState({ open: false })}>Cancel</Button>
|
||||||
<Button onClick={handleSave} color="primary">Save</Button>
|
<Button onClick={this.handleSave} color="primary">Save</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withRouter(ViewManager);
|
||||||
|
|||||||
18
uiserver/src/components/withRouter.js
Normal file
18
uiserver/src/components/withRouter.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user