u
This commit is contained in:
67
uiserver/src/components/Chart.js
vendored
67
uiserver/src/components/Chart.js
vendored
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
222
uiserver/src/components/ViewManager.js.bak
Normal file
222
uiserver/src/components/ViewManager.js.bak
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user