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 && (
- } onClick={() => setOpen(true)}>
+ } onClick={handleOpenCreate}>
Create View
)}
@@ -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 */}
-
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 && (
+ } onClick={handleOpenCreate}>
+ Create View
+
+ )}
+
+
+
+ {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))}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}