This commit is contained in:
sebseb7
2025-12-25 01:48:20 +01:00
parent 53e2e04cb9
commit 2c6ddf61f5
2 changed files with 238 additions and 218 deletions

View File

@@ -46,43 +46,41 @@ class ViewDisplay extends Component {
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
// Parse view config into groups
let groups = [];
if (view.config && view.config.groups) {
groups = view.config.groups;
} else {
// Legacy
let channels = [];
let axes = null;
// Parse view config & Flatten groups if present
let channels = [];
let axes = { left: {}, right: {} };
if (view.config) {
if (Array.isArray(view.config)) {
channels = view.config;
} else if (view.config && view.config.channels) {
} else if (view.config.groups) {
// Flatten groups
view.config.groups.forEach(g => {
if (g.channels) channels = [...channels, ...g.channels];
if (g.axes) {
if (g.axes.left) axes.left = { ...axes.left, ...g.axes.left };
if (g.axes.right) axes.right = { ...axes.right, ...g.axes.right };
}
});
} else if (view.config.channels) {
channels = view.config.channels;
axes = view.config.axes;
}
groups = [{ name: 'Default', channels, axes }];
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', p: 2, gap: 2 }}>
<Typography variant="h5">{view.name}</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, flexGrow: 1 }}>
{groups.map((group, idx) => (
<Paper key={idx} sx={{ flex: '1 1 45%', minWidth: '400px', p: 2, display: 'flex', flexDirection: 'column', height: '500px' }}>
<Typography variant="h6">{group.name}</Typography>
<Box sx={{ flexGrow: 1 }}>
<Chart
channelConfig={group.channels.map(c => ({
id: `${c.device}:${c.channel}`,
alias: c.alias,
yAxis: c.yAxis || 'left',
color: c.color
}))}
axisConfig={group.axes}
/>
</Box>
</Paper>
))}
<Box sx={{ flexGrow: 1 }}>
<Chart
channelConfig={channels.map(c => ({
id: `${c.device}:${c.channel}`,
alias: c.alias,
yAxis: c.yAxis || 'left',
color: c.color
}))}
axisConfig={axes}
/>
</Box>
</Box>
);

View File

@@ -11,6 +11,8 @@ import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import { withRouter } from './withRouter';
import Chart from './Chart';
@@ -22,25 +24,41 @@ const RANGES = {
'3m': 90 * 24 * 60 * 60 * 1000,
};
const GRUVBOX_COLORS = [
'#cc241d', '#fb4934', // Red
'#98971a', '#b8bb26', // Green
'#d79921', '#fabd2f', // Yellow
'#458588', '#83a598', // Blue
'#b16286', '#d3869b', // Purple
'#689d6a', '#8ec07c', // Aqua
'#d65d0e', '#fe8019', // Orange
'#928374', '#a89984', // Gray
'#282828', '#ebdbb2', // Bg/Fg (maybe skip dark bg)
'#1d2021', '#fbf1c7' // Hard dark/light
];
class ViewManager extends Component {
constructor(props) {
super(props);
this.state = {
views: [],
open: false,
colorPickerOpen: false,
editingId: null,
viewName: '',
availableDevices: [],
viewConfig: [], // [{ device, channel, alias, yAxis }]
axisConfig: {
left: { min: '', max: '' },
right: { min: '', max: '' }
},
// Config item selection state
selDevice: '',
selChannel: '',
alias: '',
yAxis: 'left',
// Editor State (Flat)
viewConfig: [], // [{ device, channel, alias, yAxis, color }]
axisConfig: { left: { min: '', max: '' }, right: { min: '', max: '' } },
// Edit Helpers
paramSelDevice: '',
paramSelChannel: '',
paramAlias: '',
paramYAxis: 'left',
pickerTargetIndex: null,
// Global Time State
rangeLabel: '1d',
@@ -60,7 +78,6 @@ class ViewManager extends Component {
componentDidUpdate(prevProps) {
if (prevProps.user !== this.props.user) {
// If user changes (e.g. login/logout), refresh
this.refreshViews();
if (this.isAdmin()) {
fetch('/api/devices')
@@ -83,6 +100,49 @@ class ViewManager extends Component {
.catch(console.error);
};
parseViewData(view) {
// Flatten config for display/editing
let channels = [];
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
const config = view.config;
if (!config) return { channels, axes };
if (Array.isArray(config)) {
// Very old legacy (array of channels)
channels = config;
} else if (config.groups) {
// Group format (Recent) - Flatten it!
config.groups.forEach(g => {
if (g.channels) {
channels = [...channels, ...g.channels];
}
// Merge axes? Just take first group's axes or default?
// Let's defer to default or first group if present
if (g.axes) {
if (g.axes.left) axes.left = { ...axes.left, ...g.axes.left };
if (g.axes.right) axes.right = { ...axes.right, ...g.axes.right };
}
});
} else if (config.channels) {
// Standard Legacy
channels = config.channels;
if (config.axes) axes = config.axes;
}
// Normalize channels
channels = channels.map((c, i) => ({
...c,
color: c.color || this.getNextColor(i)
}));
return { channels, axes };
}
getNextColor(idx) {
return GRUVBOX_COLORS[idx % GRUVBOX_COLORS.length];
}
handleOpenCreate = () => {
this.setState({
editingId: null,
@@ -95,26 +155,10 @@ class ViewManager extends Component {
handleOpenEdit = (v, e) => {
e.stopPropagation();
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)) {
channels = data.config;
} else if (data.config && data.config.channels) {
channels = data.config.channels;
if (data.config.axes) {
axes = {
left: { ...axes.left, ...data.config.axes.left },
right: { ...axes.right, ...data.config.axes.right }
};
}
}
channels = channels.map(c => ({ ...c, yAxis: c.yAxis || 'left' }));
const { channels, axes } = this.parseViewData(data);
this.setState({
editingId: v.id,
viewName: v.name,
@@ -137,7 +181,7 @@ class ViewManager extends Component {
};
handleSave = async () => {
const { viewName, viewConfig, editingId, axisConfig } = this.state;
const { viewName, viewConfig, axisConfig, editingId } = this.state;
const { user } = this.props;
if (!viewName || viewConfig.length === 0) return;
@@ -145,6 +189,7 @@ class ViewManager extends Component {
const url = editingId ? `/api/views/${editingId}` : '/api/views';
const method = editingId ? 'PUT' : 'POST';
// Save as flat format again
const finalConfig = {
channels: viewConfig,
axes: axisConfig
@@ -171,155 +216,128 @@ class ViewManager extends Component {
}
};
addConfigItem = () => {
const { selDevice, selChannel, alias, yAxis, viewConfig } = this.state;
if (selDevice && selChannel) {
this.setState({
viewConfig: [...viewConfig, {
device: selDevice,
channel: selChannel,
alias: alias || `${selDevice}:${selChannel}`,
yAxis: yAxis
}],
selDevice: '',
selChannel: '',
alias: '',
yAxis: 'left'
});
}
// --- Editor Logic ---
addChannel = () => {
const { paramSelDevice, paramSelChannel, paramAlias, paramYAxis, viewConfig } = this.state;
if (!paramSelDevice || !paramSelChannel) return;
const color = this.getNextColor(viewConfig.length);
this.setState(prev => ({
viewConfig: [...prev.viewConfig, {
device: paramSelDevice,
channel: paramSelChannel,
alias: paramAlias || `${paramSelDevice}:${paramSelChannel}`,
yAxis: paramYAxis,
color: color
}],
paramSelDevice: '',
paramSelChannel: '',
paramAlias: '',
paramYAxis: 'left'
}));
};
handleAxisChange = (axis, field, value) => {
this.setState(prevState => ({
removeChannel = (idx) => {
this.setState(prev => ({
viewConfig: prev.viewConfig.filter((_, i) => i !== idx)
}));
};
moveChannel = (idx, dir) => {
const newConfig = [...this.state.viewConfig];
const target = idx + dir;
if (target < 0 || target >= newConfig.length) return;
[newConfig[idx], newConfig[target]] = [newConfig[target], newConfig[idx]];
this.setState({ viewConfig: newConfig });
};
updateAxis = (axis, field, val) => {
this.setState(prev => ({
axisConfig: {
...prevState.axisConfig,
[axis]: { ...prevState.axisConfig[axis], [field]: value }
...prev.axisConfig,
[axis]: { ...prev.axisConfig[axis], [field]: val }
}
}));
};
parseViewData(view) {
// view.config is already an object from API (or array for legacy)
let channelsData = [];
let axesData = null;
openColorPicker = (idx) => {
this.setState({ colorPickerOpen: true, pickerTargetIndex: idx });
};
if (Array.isArray(view.config)) {
channelsData = view.config;
} else if (view.config && view.config.channels) {
channelsData = view.config.channels;
axesData = view.config.axes;
selectColor = (color) => {
const { pickerTargetIndex, viewConfig } = this.state;
if (pickerTargetIndex !== null) {
const newConfig = [...viewConfig];
newConfig[pickerTargetIndex].color = color;
this.setState({ viewConfig: newConfig, colorPickerOpen: false, pickerTargetIndex: null });
}
};
const channelConfig = channelsData.map(item => ({
id: `${item.device}:${item.channel}`,
alias: item.alias,
yAxis: item.yAxis || 'left'
}));
return { channelConfig, axesData };
}
// --- Navigation ---
handleRangeChange = (e, newVal) => {
if (newVal) {
this.setState({ rangeLabel: newVal });
}
if (newVal) this.setState({ rangeLabel: newVal });
};
handleTimeNav = (direction) => {
const { windowEnd, rangeLabel } = this.state;
const rangeMs = RANGES[rangeLabel];
let currentEnd = windowEnd ? windowEnd.getTime() : Date.now();
let newEnd;
let newEnd = direction === -1 ? currentEnd - rangeMs : currentEnd + rangeMs;
if (direction === -1) {
newEnd = currentEnd - rangeMs;
if (direction === 1 && newEnd >= Date.now()) {
this.setState({ windowEnd: null });
} else {
newEnd = currentEnd + rangeMs;
if (newEnd >= Date.now()) {
this.setState({ windowEnd: null });
return;
}
this.setState({ windowEnd: new Date(newEnd) });
}
this.setState({ windowEnd: new Date(newEnd) });
};
render() {
const {
views, open, editingId, viewName, availableDevices, viewConfig, axisConfig,
selDevice, selChannel, alias, yAxis,
rangeLabel, windowEnd
views, open, editingId, viewName, availableDevices,
viewConfig, axisConfig,
paramSelDevice, paramSelChannel, paramAlias, paramYAxis,
rangeLabel, windowEnd, colorPickerOpen
} = this.state;
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))];
const channelsForDevice = availableDevices.filter(d => d.device === paramSelDevice).map(d => d.channel);
const rangeMs = RANGES[rangeLabel];
// Format Date Range for Display
let dateDisplay = "Live";
if (windowEnd) {
const start = new Date(windowEnd.getTime() - rangeMs);
dateDisplay = `${start.toLocaleString()} - ${windowEnd.toLocaleString()}`;
} else {
// For Live, technically it's "Last X"
dateDisplay = `Live (Last ${rangeLabel})`;
}
return (
<Container maxWidth="xl" sx={{ mt: 4 }}>
{/* Global Time Controls (Sticky) */}
<Paper sx={{
position: 'sticky',
top: 10,
zIndex: 1000,
p: 2,
mb: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 2,
border: '1px solid #504945'
}}>
{/* Global Time Controls */}
<Paper sx={{ position: 'sticky', top: 10, zIndex: 1000, p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between', border: '1px solid #504945' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<ToggleButtonGroup
value={rangeLabel}
exclusive
onChange={this.handleRangeChange}
size="small"
>
<ToggleButton value="3h">3h</ToggleButton>
<ToggleButton value="1d">1d</ToggleButton>
<ToggleButton value="1w">1w</ToggleButton>
<ToggleButton value="1m">1m</ToggleButton>
<ToggleButton value="3m">3m</ToggleButton>
<ToggleButtonGroup value={rangeLabel} exclusive onChange={this.handleRangeChange} size="small">
{Object.keys(RANGES).map(r => <ToggleButton key={r} value={r}>{r}</ToggleButton>)}
</ToggleButtonGroup>
<Box>
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></IconButton>
<IconButton onClick={() => this.handleTimeNav(1)} disabled={!windowEnd}><ArrowForwardIcon /></IconButton>
</Box>
</Box>
<Typography variant="h6">{dateDisplay}</Typography>
<Box>
{isAdmin && (
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
Create View
</Button>
)}
</Box>
{isAdmin && <Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>Create View</Button>}
</Paper>
{/* View List */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{views.map(view => {
const { channelConfig, axesData } = this.parseViewData(view);
const { channels, axes } = this.parseViewData(view);
return (
<Paper key={view.id} sx={{ p: 2, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h5">{view.name}</Typography>
<Typography variant="h4">{view.name}</Typography>
{isAdmin && (
<Box>
<IconButton onClick={(e) => this.handleOpenEdit(view, e)}><EditIcon /></IconButton>
@@ -329,8 +347,13 @@ class ViewManager extends Component {
</Box>
<Box sx={{ height: '500px' }}>
<Chart
channelConfig={channelConfig}
axisConfig={axesData}
channelConfig={channels.map(c => ({
id: `${c.device}:${c.channel}`,
alias: c.alias,
yAxis: c.yAxis || 'left',
color: c.color
}))}
axisConfig={axes}
windowEnd={windowEnd}
range={rangeMs}
/>
@@ -341,97 +364,96 @@ class ViewManager extends Component {
{views.length === 0 && <Typography>No views available.</Typography>}
</Box>
{/* Edit Dialog */}
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="View Name"
fullWidth
value={viewName}
onChange={(e) => this.setState({ viewName: e.target.value })}
sx={{ mb: 2 }}
margin="dense" label="View Name" fullWidth value={viewName}
onChange={(e) => this.setState({ viewName: e.target.value })} sx={{ mb: 2 }}
/>
{/* Axis Config Section */}
{/* Axis Config */}
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>Axis Configuration (Soft Limits)</Typography>
<Typography variant="subtitle2" gutterBottom>Axis Configuration</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption" sx={{ width: 40 }}>Left:</Typography>
<TextField size="small" label="Min" type="number" value={axisConfig.left.min} onChange={(e) => this.handleAxisChange('left', 'min', e.target.value)} />
<TextField size="small" label="Max" type="number" value={axisConfig.left.max} onChange={(e) => this.handleAxisChange('left', 'max', e.target.value)} />
<TextField size="small" placeholder="Min" value={axisConfig.left.min} onChange={e => this.updateAxis('left', 'min', e.target.value)} />
<TextField size="small" placeholder="Max" value={axisConfig.left.max} onChange={e => this.updateAxis('left', 'max', e.target.value)} />
</Box>
<Box sx={{ flex: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption" sx={{ width: 40 }}>Right:</Typography>
<TextField size="small" label="Min" type="number" value={axisConfig.right.min} onChange={(e) => this.handleAxisChange('right', 'min', e.target.value)} />
<TextField size="small" label="Max" type="number" value={axisConfig.right.max} onChange={(e) => this.handleAxisChange('right', 'max', e.target.value)} />
<TextField size="small" placeholder="Min" value={axisConfig.right.min} onChange={e => this.updateAxis('right', 'min', e.target.value)} />
<TextField size="small" placeholder="Max" value={axisConfig.right.max} onChange={e => this.updateAxis('right', 'max', e.target.value)} />
</Box>
</Box>
</Box>
<Box sx={{ mt: 2, p: 2, border: '1px solid #444', borderRadius: 1 }}>
<Typography variant="subtitle2">Add Channels</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Device</InputLabel>
<Select value={selDevice} label="Device" onChange={(e) => this.setState({ selDevice: 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) => this.setState({ selChannel: 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) => this.setState({ yAxis: e.target.value })}>
<MenuItem value="left">Left</MenuItem>
<MenuItem value="right">Right</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="Alias (Optional)"
value={alias}
onChange={(e) => this.setState({ alias: e.target.value })}
sx={{ flexGrow: 1 }}
/>
<Button variant="outlined" onClick={this.addConfigItem}>Add</Button>
</Box>
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{viewConfig.map((item, idx) => (
<Chip
key={idx}
label={`${item.alias} (${item.yAxis})`}
onClick={() => {
this.setState({
selDevice: item.device,
selChannel: item.channel,
alias: item.alias,
yAxis: item.yAxis,
viewConfig: viewConfig.filter((_, i) => i !== idx)
});
}}
onDelete={() => this.setState({ viewConfig: viewConfig.filter((_, i) => i !== idx) })}
sx={{ cursor: 'pointer' }}
/>
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1 }}>
<Typography variant="subtitle2">Channels</Typography>
<List dense>
{viewConfig.map((ch, idx) => (
<ListItem key={idx} sx={{ pl: 0 }}>
<IconButton size="small" onClick={() => this.openColorPicker(idx)}>
<Box sx={{ width: 20, height: 20, bgcolor: ch.color || '#fff', borderRadius: '50%' }} />
</IconButton>
<ListItemText
primary={ch.alias}
secondary={`${ch.device}:${ch.channel} (${ch.yAxis})`}
sx={{ ml: 1 }}
/>
<IconButton size="small" onClick={() => this.moveChannel(idx, -1)} disabled={idx === 0}><ArrowUpwardIcon /></IconButton>
<IconButton size="small" onClick={() => this.moveChannel(idx, 1)} disabled={idx === viewConfig.length - 1}><ArrowDownwardIcon /></IconButton>
<IconButton size="small" color="error" onClick={() => this.removeChannel(idx)}><DeleteIcon /></IconButton>
</ListItem>
))}
</List>
{/* Add Channel */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1, bgcolor: '#32302f', p: 1, borderRadius: 1 }}>
<Select size="small" value={paramSelDevice} displayEmpty onChange={e => this.setState({ paramSelDevice: e.target.value })} sx={{ minWidth: 120 }}>
<MenuItem value=""><em>Device</em></MenuItem>
{uniqueDevices.map(d => <MenuItem key={d} value={d}>{d}</MenuItem>)}
</Select>
<Select size="small" value={paramSelChannel} displayEmpty onChange={e => this.setState({ paramSelChannel: e.target.value })} sx={{ minWidth: 120 }}>
<MenuItem value=""><em>Channel</em></MenuItem>
{channelsForDevice.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
</Select>
<Select size="small" value={paramYAxis} onChange={e => this.setState({ paramYAxis: e.target.value })} sx={{ width: 80 }}>
<MenuItem value="left">Left</MenuItem>
<MenuItem value="right">Right</MenuItem>
</Select>
<TextField size="small" placeholder="Alias" value={paramAlias} onChange={e => this.setState({ paramAlias: e.target.value })} />
<Button variant="contained" size="small" onClick={this.addChannel}>Add</Button>
</Box>
<Typography variant="caption" sx={{ mt: 1, display: 'block', color: 'text.secondary' }}>
Click a chip to edit its settings.
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ open: false })}>Cancel</Button>
<Button onClick={this.handleSave} color="primary">Save</Button>
<Button onClick={this.handleSave} variant="contained">Save</Button>
</DialogActions>
</Dialog>
{/* Color Picker Dialog */}
<Dialog open={colorPickerOpen} onClose={() => this.setState({ colorPickerOpen: false })}>
<DialogTitle>Select Color</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, width: 300 }}>
{GRUVBOX_COLORS.map(c => (
<Box
key={c}
onClick={() => this.selectColor(c)}
sx={{
width: 40, height: 40, bgcolor: c, cursor: 'pointer', borderRadius: 1,
border: '1px solid #000',
'&:hover': { opacity: 0.8 }
}}
/>
))}
</Box>
</DialogContent>
</Dialog>
</Container>
);
}