diff --git a/uiserver/src/components/Chart.js b/uiserver/src/components/Chart.js index b55977b..5487a51 100644 --- a/uiserver/src/components/Chart.js +++ b/uiserver/src/components/Chart.js @@ -3,7 +3,7 @@ import { Paper, Typography, Box, CircularProgress } from '@mui/material'; import { LineChart } from '@mui/x-charts/LineChart'; 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 [loading, setLoading] = useState(true); const theme = useTheme(); @@ -66,11 +66,15 @@ export default function Chart({ selectedChannels = [], channelConfig = null }) { if (effectiveChannels.length === 0) return No channels selected.; const series = effectiveChannels.map(id => { - // Find alias if config exists + // Find alias and axis if config exists let label = id; + let yAxisKey = 'left'; if (channelConfig) { const item = channelConfig.find(c => c.id === id); - if (item && item.alias) label = item.alias; + if (item) { + if (item.alias) label = item.alias; + if (item.yAxis) yAxisKey = item.yAxis; + } } return { @@ -78,9 +82,64 @@ export default function Chart({ selectedChannels = [], channelConfig = null }) { label: label, connectNulls: true, showMark: false, + yAxisKey: yAxisKey, }; }); + const hasRightAxis = series.some(s => s.yAxisKey === 'right'); + + const computeAxisLimits = (axisKey) => { + // Collect all data points for this axis + 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 ( Last 24 Hours @@ -94,6 +153,8 @@ export default function Chart({ selectedChannels = [], channelConfig = null }) { scaleType: 'time', valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]} + yAxis={yAxes} + rightAxis={hasRightAxis ? 'right' : null} slotProps={{ legend: { direction: 'row', diff --git a/uiserver/src/components/ViewDisplay.js b/uiserver/src/components/ViewDisplay.js index 9ae0ee4..2c1263e 100644 --- a/uiserver/src/components/ViewDisplay.js +++ b/uiserver/src/components/ViewDisplay.js @@ -28,10 +28,22 @@ export default function ViewDisplay() { if (loading) return ; if (error) return {error}; - // Map view config to Chart format with aliases - const channelConfig = view.config.map(item => ({ + // Parse view config (compat with both array and object format) + let channelsData = []; + let axesData = null; + + if (Array.isArray(view.config)) { + channelsData = view.config; + } else if (view.config && view.config.channels) { + channelsData = view.config.channels; + axesData = view.config.axes; + } + + // Map view config to Chart format with aliases and axis + const channelConfig = channelsData.map(item => ({ id: `${item.device}:${item.channel}`, - alias: item.alias + alias: item.alias, + yAxis: item.yAxis || 'left' })); return ( @@ -39,7 +51,7 @@ export default function ViewDisplay() { {view.name} - + ); } diff --git a/uiserver/src/components/ViewManager.js b/uiserver/src/components/ViewManager.js index baed1cc..5e5b539 100644 --- a/uiserver/src/components/ViewManager.js +++ b/uiserver/src/components/ViewManager.js @@ -2,23 +2,32 @@ 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 + 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 [newViewName, setNewViewName] = useState(''); + 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 }] + + const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias, yAxis }] + const [axisConfig, setAxisConfig] = useState({ + left: { min: '', max: '' }, + right: { min: '', max: '' } + }); // Selection state for new item const [selDevice, setSelDevice] = useState(''); const [selChannel, setSelChannel] = useState(''); const [alias, setAlias] = useState(''); + const [yAxis, setYAxis] = useState('left'); const navigate = useNavigate(); const isAdmin = user && user.role === 'admin'; @@ -40,26 +49,86 @@ export default function ViewManager({ user }) { .catch(console.error); }; - const handleCreate = async () => { - if (!newViewName || viewConfig.length === 0) return; + const handleOpenCreate = () => { + 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 { - const res = await fetch('/api/views', { - method: 'POST', + const res = await fetch(url, { + method: method, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${user.token}` }, - body: JSON.stringify({ name: newViewName, config: viewConfig }) + body: JSON.stringify({ name: viewName, config: finalConfig }) }); if (res.ok) { setOpen(false); - setNewViewName(''); - setViewConfig([]); refreshViews(); } else { - alert('Failed to create view'); + alert('Failed to save view'); } } catch (err) { console.error(err); @@ -68,8 +137,16 @@ export default function ViewManager({ user }) { const addConfigItem = () => { 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(''); + setYAxis('left'); } }; @@ -77,12 +154,19 @@ export default function ViewManager({ user }) { const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel); const uniqueDevices = [...new Set(availableDevices.map(d => d.device))]; + const handleAxisChange = (axis, field, value) => { + setAxisConfig(prev => ({ + ...prev, + [axis]: { ...prev[axis], [field]: value } + })); + }; + return ( Views {isAdmin && ( - )} @@ -101,47 +185,76 @@ export default function ViewManager({ user }) { primary={view.name} secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`} /> + {isAdmin && ( + + handleOpenEdit(view, e)}> + handleDelete(view.id, e)}> + + )} ))} {views.length === 0 && No views available.} - {/* Create Dialog */} - setOpen(false)} maxWidth="sm" fullWidth> - Create New View + {/* Create/Edit Dialog */} + setOpen(false)} maxWidth="md" fullWidth> + {editingId ? 'Edit View' : 'Create New View'} setNewViewName(e.target.value)} + value={viewName} + onChange={(e) => setViewName(e.target.value)} + sx={{ mb: 2 }} /> + {/* Axis Config Section */} + + Axis Configuration (Soft Limits) + + + Left: + handleAxisChange('left', 'min', e.target.value)} /> + handleAxisChange('left', 'max', e.target.value)} /> + + + Right: + handleAxisChange('right', 'min', e.target.value)} /> + handleAxisChange('right', 'max', e.target.value)} /> + + + + Add Channels - - + + Device - + Channel - - + + Axis + + setAlias(e.target.value)} + sx={{ flexGrow: 1 }} /> @@ -150,16 +263,27 @@ export default function ViewManager({ user }) { {viewConfig.map((item, idx) => ( { + 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))} + sx={{ cursor: 'pointer' }} /> ))} + + Click a chip to edit its settings. + - + diff --git a/uiserver/src/components/ViewManager.js.bak b/uiserver/src/components/ViewManager.js.bak new file mode 100644 index 0000000..9b19c79 --- /dev/null +++ b/uiserver/src/components/ViewManager.js.bak @@ -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 ( + + + Views + {isAdmin && ( + + )} + + + + {views.map(view => ( + navigate(`/views/${view.id}`)} + > + + + {isAdmin && ( + + handleOpenEdit(view, e)}> + handleDelete(view.id, e)}> + + )} + + ))} + {views.length === 0 && No views available.} + + + {/* Create/Edit Dialog */} + setOpen(false)} maxWidth="md" fullWidth> + {editingId ? 'Edit View' : 'Create New View'} + + setViewName(e.target.value)} + /> + + + Add Channels + + + Device + + + + Channel + + + + Axis + + + setAlias(e.target.value)} + sx={{ flexGrow: 1 }} + /> + + + + + {viewConfig.map((item, idx) => ( + setViewConfig(viewConfig.filter((_, i) => i !== idx))} + /> + ))} + + + + + + + + + + ); +}