This commit is contained in:
sebseb7
2025-12-25 00:19:02 +01:00
parent 077e76735e
commit 0eb05b1cd5
4 changed files with 452 additions and 33 deletions

View File

@@ -3,7 +3,7 @@ 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'; import { useTheme } from '@mui/material/styles';
export default function Chart({ selectedChannels = [], channelConfig = null }) { export default function Chart({ selectedChannels = [], channelConfig = null, axisConfig = null }) {
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const theme = useTheme(); const theme = useTheme();
@@ -66,11 +66,15 @@ export default function Chart({ selectedChannels = [], channelConfig = null }) {
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>;
const series = effectiveChannels.map(id => { const series = effectiveChannels.map(id => {
// Find alias if config exists // Find alias and axis if config exists
let label = id; let label = id;
let yAxisKey = 'left';
if (channelConfig) { if (channelConfig) {
const item = channelConfig.find(c => c.id === id); const item = channelConfig.find(c => c.id === id);
if (item && item.alias) label = item.alias; if (item) {
if (item.alias) label = item.alias;
if (item.yAxis) yAxisKey = item.yAxis;
}
} }
return { return {
@@ -78,9 +82,64 @@ export default function Chart({ selectedChannels = [], channelConfig = null }) {
label: label, label: label,
connectNulls: true, connectNulls: true,
showMark: false, showMark: false,
yAxisKey: yAxisKey,
}; };
}); });
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
const computeAxisLimits = (axisKey) => {
// Collect all data points for this axis
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 = [
{ id: 'left', scaleType: 'linear', ...leftLimits }
];
if (hasRightAxis) {
yAxes.push({ id: 'right', scaleType: 'linear', ...rightLimits });
}
return ( return (
<Box sx={{ width: '100%', height: '80vh', p: 2 }}> <Box sx={{ width: '100%', height: '80vh', p: 2 }}>
<Typography variant="h6" gutterBottom>Last 24 Hours</Typography> <Typography variant="h6" gutterBottom>Last 24 Hours</Typography>
@@ -94,6 +153,8 @@ export default function Chart({ selectedChannels = [], channelConfig = null }) {
scaleType: 'time', scaleType: 'time',
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}]} }]}
yAxis={yAxes}
rightAxis={hasRightAxis ? 'right' : null}
slotProps={{ slotProps={{
legend: { legend: {
direction: 'row', direction: 'row',

View File

@@ -28,10 +28,22 @@ export default function ViewDisplay() {
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>;
// Map view config to Chart format with aliases // Parse view config (compat with both array and object format)
const channelConfig = view.config.map(item => ({ 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
const channelConfig = channelsData.map(item => ({
id: `${item.device}:${item.channel}`, id: `${item.device}:${item.channel}`,
alias: item.alias alias: item.alias,
yAxis: item.yAxis || 'left'
})); }));
return ( return (
@@ -39,7 +51,7 @@ export default function ViewDisplay() {
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
<Typography variant="h5">{view.name}</Typography> <Typography variant="h5">{view.name}</Typography>
</Box> </Box>
<Chart channelConfig={channelConfig} /> <Chart channelConfig={channelConfig} axisConfig={axesData} />
</Box> </Box>
); );
} }

View File

@@ -2,23 +2,32 @@ import React, { useState, useEffect } 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,
FormControl, InputLabel, Select, MenuItem, Box, Chip FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton
} from '@mui/material'; } from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard'; 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 EditIcon from '@mui/icons-material/Edit';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
export default function ViewManager({ user }) { export default function ViewManager({ user }) {
const [views, setViews] = useState([]); const [views, setViews] = useState([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [newViewName, setNewViewName] = useState(''); const [editingId, setEditingId] = useState(null); // ID if editing, null if creating
const [viewName, setViewName] = useState('');
const [availableDevices, setAvailableDevices] = useState([]); const [availableDevices, setAvailableDevices] = useState([]);
const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias }]
const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias, yAxis }]
const [axisConfig, setAxisConfig] = useState({
left: { min: '', max: '' },
right: { min: '', max: '' }
});
// Selection state for new item // Selection state for new item
const [selDevice, setSelDevice] = useState(''); const [selDevice, setSelDevice] = useState('');
const [selChannel, setSelChannel] = useState(''); const [selChannel, setSelChannel] = useState('');
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
const [yAxis, setYAxis] = useState('left');
const navigate = useNavigate(); const navigate = useNavigate();
const isAdmin = user && user.role === 'admin'; const isAdmin = user && user.role === 'admin';
@@ -40,26 +49,86 @@ export default function ViewManager({ user }) {
.catch(console.error); .catch(console.error);
}; };
const handleCreate = async () => { const handleOpenCreate = () => {
if (!newViewName || viewConfig.length === 0) return; setEditingId(null);
setViewName('');
setViewConfig([]);
setAxisConfig({ left: { min: '', max: '' }, right: { min: '', max: '' } });
setOpen(true);
};
const handleOpenEdit = (v, e) => {
e.stopPropagation();
setEditingId(v.id);
setViewName(v.name);
// Fetch full config for this view
fetch(`/api/views/${v.id}`)
.then(res => res.json())
.then(data => {
let channels = [];
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
if (Array.isArray(data.config)) {
// Legacy format: config is just the array of channels
channels = data.config;
} else if (data.config && data.config.channels) {
// New format: { channels, axes }
channels = data.config.channels;
if (data.config.axes) {
// Merge with defaults
axes = {
left: { ...axes.left, ...data.config.axes.left },
right: { ...axes.right, ...data.config.axes.right }
};
}
}
// Ensure config items have yAxis
channels = channels.map(c => ({ ...c, yAxis: c.yAxis || 'left' }));
setViewConfig(channels);
setAxisConfig(axes);
setOpen(true);
});
};
const handleDelete = async (id, e) => {
e.stopPropagation();
if (!window.confirm("Are you sure?")) return;
await fetch(`/api/views/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${user.token}` }
});
refreshViews();
};
const handleSave = async () => {
if (!viewName || viewConfig.length === 0) return;
const url = editingId ? `/api/views/${editingId}` : '/api/views';
const method = editingId ? 'PUT' : 'POST';
// Prepare config object
const finalConfig = {
channels: viewConfig,
axes: axisConfig
};
try { try {
const res = await fetch('/api/views', { const res = await fetch(url, {
method: 'POST', method: method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}` 'Authorization': `Bearer ${user.token}`
}, },
body: JSON.stringify({ name: newViewName, config: viewConfig }) body: JSON.stringify({ name: viewName, config: finalConfig })
}); });
if (res.ok) { if (res.ok) {
setOpen(false); setOpen(false);
setNewViewName('');
setViewConfig([]);
refreshViews(); refreshViews();
} else { } else {
alert('Failed to create view'); alert('Failed to save view');
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -68,8 +137,16 @@ export default function ViewManager({ user }) {
const addConfigItem = () => { const addConfigItem = () => {
if (selDevice && selChannel) { if (selDevice && selChannel) {
setViewConfig([...viewConfig, { device: selDevice, channel: selChannel, alias: alias || `${selDevice}:${selChannel}` }]); setViewConfig([...viewConfig, {
device: selDevice,
channel: selChannel,
alias: alias || `${selDevice}:${selChannel}`,
yAxis: yAxis
}]);
setSelDevice('');
setSelChannel('');
setAlias(''); setAlias('');
setYAxis('left');
} }
}; };
@@ -77,12 +154,19 @@ export default function ViewManager({ user }) {
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel); const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))]; const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
const handleAxisChange = (axis, field, value) => {
setAxisConfig(prev => ({
...prev,
[axis]: { ...prev[axis], [field]: value }
}));
};
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={() => setOpen(true)}> <Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
Create View Create View
</Button> </Button>
)} )}
@@ -101,47 +185,76 @@ export default function ViewManager({ user }) {
primary={view.name} primary={view.name}
secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`} 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> </ListItem>
))} ))}
{views.length === 0 && <Typography>No views available.</Typography>} {views.length === 0 && <Typography>No views available.</Typography>}
</List> </List>
{/* Create Dialog */} {/* Create/Edit Dialog */}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Create New View</DialogTitle> <DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
label="View Name" label="View Name"
fullWidth fullWidth
value={newViewName} value={viewName}
onChange={(e) => setNewViewName(e.target.value)} onChange={(e) => setViewName(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) => 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)} />
</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) => 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)} />
</Box>
</Box>
</Box>
<Box sx={{ mt: 2, p: 2, border: '1px solid #444', borderRadius: 1 }}> <Box sx={{ mt: 2, p: 2, border: '1px solid #444', borderRadius: 1 }}>
<Typography variant="subtitle2">Add Channels</Typography> <Typography variant="subtitle2">Add Channels</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}> <Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
<FormControl fullWidth size="small"> <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) => setSelDevice(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 fullWidth size="small"> <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) => setSelChannel(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>
</Box> <FormControl size="small" sx={{ minWidth: 100 }}>
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}> <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 <TextField
size="small" size="small"
label="Alias (Optional)" label="Alias (Optional)"
fullWidth
value={alias} value={alias}
onChange={(e) => setAlias(e.target.value)} onChange={(e) => setAlias(e.target.value)}
sx={{ flexGrow: 1 }}
/> />
<Button variant="outlined" onClick={addConfigItem}>Add</Button> <Button variant="outlined" onClick={addConfigItem}>Add</Button>
</Box> </Box>
@@ -150,16 +263,27 @@ export default function ViewManager({ user }) {
{viewConfig.map((item, idx) => ( {viewConfig.map((item, idx) => (
<Chip <Chip
key={idx} key={idx}
label={`${item.alias} (${item.device}:${item.channel})`} label={`${item.alias} (${item.yAxis})`}
onClick={() => {
setSelDevice(item.device);
setSelChannel(item.channel);
setAlias(item.alias);
setYAxis(item.yAxis);
setViewConfig(viewConfig.filter((_, i) => i !== idx));
}}
onDelete={() => setViewConfig(viewConfig.filter((_, i) => i !== idx))} onDelete={() => setViewConfig(viewConfig.filter((_, i) => i !== idx))}
sx={{ cursor: 'pointer' }}
/> />
))} ))}
</Box> </Box>
<Typography variant="caption" sx={{ mt: 1, display: 'block', color: 'text.secondary' }}>
Click a chip to edit its settings.
</Typography>
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button> <Button onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} color="primary">Save</Button> <Button onClick={handleSave} color="primary">Save</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Container> </Container>

View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect } from 'react';
import {
Container, Typography, List, ListItem, ListItemText, ListItemIcon,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton
} from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import { useNavigate } from 'react-router-dom';
export default function ViewManager({ user }) {
const [views, setViews] = useState([]);
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState(null); // ID if editing, null if creating
const [viewName, setViewName] = useState('');
const [availableDevices, setAvailableDevices] = useState([]);
const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias, yAxis }]
// 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')
.then(res => res.json())
.then(setAvailableDevices)
.catch(console.error);
}
}, [isAdmin]);
const refreshViews = () => {
fetch('/api/views')
.then(res => res.json())
.then(setViews)
.catch(console.error);
};
const handleOpenCreate = () => {
setEditingId(null);
setViewName('');
setViewConfig([]);
setOpen(true);
};
const handleOpenEdit = (v, e) => {
e.stopPropagation();
setEditingId(v.id);
setViewName(v.name);
// Fetch full config for this view
fetch(`/api/views/${v.id}`)
.then(res => res.json())
.then(data => {
// Ensure config items have yAxis (default to left for legacy views)
const config = (data.config || []).map(c => ({ ...c, yAxis: c.yAxis || 'left' }));
setViewConfig(config);
setOpen(true);
});
};
const handleDelete = async (id, e) => {
e.stopPropagation();
if (!window.confirm("Are you sure?")) return;
await fetch(`/api/views/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${user.token}` }
});
refreshViews();
};
const handleSave = async () => {
if (!viewName || viewConfig.length === 0) return;
const url = editingId ? `/api/views/${editingId}` : '/api/views';
const method = editingId ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({ name: viewName, config: viewConfig })
});
if (res.ok) {
setOpen(false);
refreshViews();
} else {
alert('Failed to save view');
}
} catch (err) {
console.error(err);
}
};
const addConfigItem = () => {
if (selDevice && selChannel) {
setViewConfig([...viewConfig, {
device: selDevice,
channel: selChannel,
alias: alias || `${selDevice}:${selChannel}`,
yAxis: yAxis
}]);
setAlias('');
setYAxis('left');
}
};
// Derived state for channels
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h4">Views</Typography>
{isAdmin && (
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
Create View
</Button>
)}
</Box>
<List>
{views.map(view => (
<ListItem
button
key={view.id}
divider
onClick={() => navigate(`/views/${view.id}`)}
>
<ListItemIcon><DashboardIcon /></ListItemIcon>
<ListItemText
primary={view.name}
secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`}
/>
{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 */}
<Dialog open={open} onClose={() => setOpen(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) => setViewName(e.target.value)}
/>
<Box sx={{ mt: 2, p: 2, border: '1px solid #444', borderRadius: 1 }}>
<Typography variant="subtitle2">Add Channels</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 1, 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>
</Box>
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{viewConfig.map((item, idx) => (
<Chip
key={idx}
label={`${item.alias} (${item.yAxis})`}
onDelete={() => setViewConfig(viewConfig.filter((_, i) => i !== idx))}
/>
))}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSave} color="primary">Save</Button>
</DialogActions>
</Dialog>
</Container>
);
}