diff --git a/uiserver/scripts/manage-users.js b/uiserver/scripts/manage-users.js
index fe2b3d8..b34a5ba 100644
--- a/uiserver/scripts/manage-users.js
+++ b/uiserver/scripts/manage-users.js
@@ -65,9 +65,22 @@ try {
console.error('Error: username required');
process.exit(1);
}
- const stmt = db.prepare('DELETE FROM users WHERE username = ?');
- const info = stmt.run(username);
- if (info.changes > 0) {
+
+ // Find user first to get ID
+ const userStmt = db.prepare('SELECT id FROM users WHERE username = ?');
+ const user = userStmt.get(username);
+
+ if (user) {
+ // Orphan views created by this user (set created_by to NULL)
+ const viewUnlinkStmt = db.prepare('UPDATE views SET created_by = NULL WHERE created_by = ?');
+ const viewInfo = viewUnlinkStmt.run(user.id);
+ if (viewInfo.changes > 0) {
+ console.log(`Unlinked ${viewInfo.changes} views from user '${username}'.`);
+ }
+
+ // Delete user
+ const deleteStmt = db.prepare('DELETE FROM users WHERE id = ?');
+ deleteStmt.run(user.id);
console.log(`User '${username}' deleted.`);
} else {
console.log(`User '${username}' not found.`);
diff --git a/uiserver/src/components/Chart.js b/uiserver/src/components/Chart.js
index 6bd8028..fb29074 100644
--- a/uiserver/src/components/Chart.js
+++ b/uiserver/src/components/Chart.js
@@ -1,6 +1,8 @@
import React, { Component } from 'react';
-import { Paper, Typography, Box, CircularProgress } from '@mui/material';
+import { Box, Paper, Typography, CircularProgress, IconButton } from '@mui/material';
import { LineChart } from '@mui/x-charts/LineChart';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
export default class Chart extends Component {
constructor(props) {
@@ -9,37 +11,58 @@ export default class Chart extends Component {
data: [],
loading: true
};
- this.intervalId = null;
+ this.interval = null;
}
componentDidMount() {
this.fetchData();
- this.intervalId = setInterval(this.fetchData, 60000);
+ // Set interval if in Live mode (no windowEnd prop or windowEnd is null)
+ if (!this.props.windowEnd) {
+ this.interval = setInterval(this.fetchData, 60000);
+ }
}
componentDidUpdate(prevProps) {
- // Compare props to see if we need to refetch
const prevEffective = this.getEffectiveChannels(prevProps);
const currEffective = this.getEffectiveChannels(this.props);
- if (prevEffective.join(',') !== currEffective.join(',')) {
+ const propsChanged = prevEffective.join(',') !== currEffective.join(',') ||
+ JSON.stringify(prevProps.channelConfig) !== JSON.stringify(this.props.channelConfig) ||
+ JSON.stringify(prevProps.axisConfig) !== JSON.stringify(this.props.axisConfig) ||
+ prevProps.windowEnd !== this.props.windowEnd ||
+ prevProps.range !== this.props.range;
+
+ if (propsChanged) {
this.fetchData();
}
+
+ // Manage interval based on windowEnd prop
+ if (prevProps.windowEnd !== this.props.windowEnd) {
+ if (this.interval) {
+ clearInterval(this.interval);
+ this.interval = null;
+ }
+ if (!this.props.windowEnd) {
+ this.interval = setInterval(this.fetchData, 60000);
+ }
+ }
}
componentWillUnmount() {
- if (this.intervalId) {
- clearInterval(this.intervalId);
+ if (this.interval) {
+ clearInterval(this.interval);
}
}
getEffectiveChannels(props) {
- return props.channelConfig
- ? props.channelConfig.map(c => c.id)
- : props.selectedChannels;
+ if (props.channelConfig) {
+ return props.channelConfig.map(c => c.id);
+ }
+ return props.selectedChannels || [];
}
fetchData = () => {
+ const { windowEnd, range } = this.props;
const effectiveChannels = this.getEffectiveChannels(this.props);
// Only fetch if selection exists
@@ -49,35 +72,104 @@ export default class Chart extends Component {
}
const selectionStr = effectiveChannels.join(',');
- const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
- fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}`)
+ // Time Window Logic
+ const rangeMs = range || 24 * 60 * 60 * 1000;
+
+ const endTimeVal = windowEnd ? windowEnd.getTime() : Date.now();
+ const startWindowTime = endTimeVal - rangeMs;
+
+ const since = new Date(startWindowTime).toISOString();
+ const until = new Date(endTimeVal).toISOString();
+
+ fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}&until=${until}`)
.then(res => res.json())
- .then(rows => {
- const timeMap = new Map();
+ .then(dataObj => {
+ // 1. Parse raw rows into intervals per channel
+ const intervals = [];
+ const timestampsSet = new Set();
- rows.forEach(row => {
- const id = `${row.device}:${row.channel}`;
+ // dataObj format: { "device:channel": [ [timestamp, value, until], ... ] }
+ Object.entries(dataObj).forEach(([id, points]) => {
+ // Check if this ID is in our effective/requested list
if (!effectiveChannels.includes(id)) return;
- const time = new Date(row.timestamp).getTime();
- if (!timeMap.has(time)) {
- timeMap.set(time, { time: new Date(row.timestamp) });
- }
- const entry = timeMap.get(time);
+ // Ensure sorted by time
+ points.sort((a, b) => new Date(a[0]) - new Date(b[0]));
- let val = row.value;
- if (row.data_type === 'json' && !val) {
- val = null;
+ for (let i = 0; i < points.length; i++) {
+ const [tsStr, rawVal, untilStr] = points[i];
+ const val = Number(rawVal);
+
+ let start = new Date(tsStr).getTime();
+ let explicitEnd = untilStr ? new Date(untilStr).getTime() : null;
+
+ // Determine start of next point to prevent overlap
+ let nextStart = null;
+ if (i < points.length - 1) {
+ nextStart = new Date(points[i + 1][0]).getTime();
+ }
+
+ // Calculate effective end
+ let end = explicitEnd;
+ // If 'until' is null, extend to next point or now
+ if (!end) {
+ end = nextStart || endTimeVal;
+ }
+
+ // Strict Cutoff: Current interval cannot extend past the start of the next interval
+ // This fixes the "vertical artifacting" where old data persists underneath new data
+ if (nextStart && end > nextStart) {
+ end = nextStart;
+ }
+
+ // Clamping logic
+ if (start < startWindowTime) start = startWindowTime;
+ if (end > endTimeVal) end = endTimeVal;
+
+ // If valid interval
+ if (end >= start) {
+ intervals.push({ id, start, end, val });
+ timestampsSet.add(start);
+ timestampsSet.add(end);
+ }
}
- entry[id] = val;
});
- const sortedData = Array.from(timeMap.values()).sort((a, b) => a.time - b.time);
- this.setState({ data: sortedData, loading: false });
+ // 2. Sort unique timestamps
+ const sortedTimestamps = Array.from(timestampsSet).sort((a, b) => a - b);
+
+ // 3. densify data
+ const denseData = sortedTimestamps.map(t => {
+ const row = { time: new Date(t) };
+ intervals.forEach(inv => {
+ // Inclusive of start, exclusive of end?
+ // Actually, dense data points are discrete samples.
+ // We check: start <= t <= end.
+ // However, if we have contiguous intervals [A, B] and [B, C],
+ // Point B belongs to the *second* interval usually (new value starts).
+ // With our cutoff logic, first interval ends at B, second starts at B.
+ // If we use <= end, both match at B. Last writer wins?
+ // Intervals are pushed in channel order.
+ // For same channel, intervals are sorted.
+ // `intervals.forEach(inv)` -> if multiple match, the *last* one in the array (later time) overwrites.
+ // So correct: t=B matches [A, B] and [B, C]. [B, C] comes later in `intervals` (if we sorted intervals total? No we didn't).
+ // We only sorted points within channel loop. `intervals` array order is: channel A points, channel B points...
+ // So overlapping intervals for SAME channel:
+ // [A, B] pushed first. [B, C] pushed second.
+ // At t=B, both match. [B, C] overwrites. Correct (new value wins).
+
+ if (t >= inv.start && t <= inv.end) {
+ row[inv.id] = inv.val;
+ }
+ });
+ return row;
+ });
+
+ this.setState({ data: denseData, loading: false });
})
.catch(err => {
- console.error("Failed to fetch data", err);
+ console.error(err);
this.setState({ loading: false });
});
};
@@ -93,8 +185,8 @@ export default class Chart extends Component {
// Check if config exists for this axis
const { axisConfig } = this.props;
- let cfgMin = NaN;
- let cfgMax = NaN;
+ let cfgMin = parseFloat(NaN);
+ let cfgMax = parseFloat(NaN);
if (axisConfig && axisConfig[axisKey]) {
cfgMin = parseFloat(axisConfig[axisKey].min);
cfgMax = parseFloat(axisConfig[axisKey].max);
@@ -127,7 +219,7 @@ export default class Chart extends Component {
render() {
const { loading, data } = this.state;
- const { channelConfig } = this.props;
+ const { channelConfig, windowEnd, range } = this.props;
const effectiveChannels = this.getEffectiveChannels(this.props);
if (loading) return ;
@@ -137,21 +229,25 @@ export default class Chart extends Component {
// Find alias and axis if config exists
let label = id;
let yAxisKey = 'left';
+ let color = undefined;
if (channelConfig) {
const item = channelConfig.find(c => c.id === id);
if (item) {
if (item.alias) label = item.alias;
if (item.yAxis) yAxisKey = item.yAxis;
+ if (item.color) color = item.color;
}
}
- return {
+ const sObj = {
dataKey: id,
label: label,
connectNulls: true,
showMark: false,
yAxisKey: yAxisKey,
};
+ if (color) sObj.color = color;
+ return sObj;
});
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
@@ -166,17 +262,23 @@ export default class Chart extends Component {
yAxes.push({ id: 'right', scaleType: 'linear', ...rightLimits });
}
+ // Calculate X-Axis Limits
+ const rangeMs = range || 24 * 60 * 60 * 1000;
+ const axisEnd = windowEnd ? windowEnd.getTime() : Date.now();
+ const axisStart = axisEnd - rangeMs;
+
return (
-
- Last 24 Hours
-
-
+
+
+
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}]}
yAxis={yAxes}
diff --git a/uiserver/src/components/ViewDisplay.js b/uiserver/src/components/ViewDisplay.js
index 4784b10..fd33614 100644
--- a/uiserver/src/components/ViewDisplay.js
+++ b/uiserver/src/components/ViewDisplay.js
@@ -46,29 +46,44 @@ class ViewDisplay extends Component {
if (loading) return ;
if (error) return {error};
- // Parse view config
- 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;
+ // Parse view config into groups
+ let groups = [];
+ if (view.config && view.config.groups) {
+ groups = view.config.groups;
+ } else {
+ // Legacy
+ let channels = [];
+ let axes = null;
+ if (Array.isArray(view.config)) {
+ channels = view.config;
+ } else if (view.config && view.config.channels) {
+ channels = view.config.channels;
+ axes = view.config.axes;
+ }
+ groups = [{ name: 'Default', channels, axes }];
}
- const channelConfig = channelsData.map(item => ({
- id: `${item.device}:${item.channel}`,
- alias: item.alias,
- yAxis: item.yAxis || 'left'
- }));
-
return (
-
-
- {view.name}
+
+ {view.name}
+
+ {groups.map((group, idx) => (
+
+ {group.name}
+
+ ({
+ id: `${c.device}:${c.channel}`,
+ alias: c.alias,
+ yAxis: c.yAxis || 'left',
+ color: c.color
+ }))}
+ axisConfig={group.axes}
+ />
+
+
+ ))}
-
);
}
diff --git a/uiserver/src/components/ViewManager.js b/uiserver/src/components/ViewManager.js
index 2e6b74a..c4cffb7 100644
--- a/uiserver/src/components/ViewManager.js
+++ b/uiserver/src/components/ViewManager.js
@@ -1,14 +1,26 @@
import React, { Component } from 'react';
import {
- Container, Typography, List, ListItem, ListItemText, ListItemIcon,
+ Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
- FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton
+ FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton,
+ ToggleButton, ToggleButtonGroup
} 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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import { withRouter } from './withRouter';
+import Chart from './Chart';
+
+const RANGES = {
+ '3h': 3 * 60 * 60 * 1000,
+ '1d': 24 * 60 * 60 * 1000,
+ '1w': 7 * 24 * 60 * 60 * 1000,
+ '1m': 30 * 24 * 60 * 60 * 1000,
+ '3m': 90 * 24 * 60 * 60 * 1000,
+};
class ViewManager extends Component {
constructor(props) {
@@ -28,7 +40,11 @@ class ViewManager extends Component {
selDevice: '',
selChannel: '',
alias: '',
- yAxis: 'left'
+ yAxis: 'left',
+
+ // Global Time State
+ rangeLabel: '1d',
+ windowEnd: null // null = Live
};
}
@@ -182,51 +198,148 @@ class ViewManager extends Component {
}));
};
+ parseViewData(view) {
+ // view.config is already an object from API (or array for legacy)
+ 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;
+ }
+
+ const channelConfig = channelsData.map(item => ({
+ id: `${item.device}:${item.channel}`,
+ alias: item.alias,
+ yAxis: item.yAxis || 'left'
+ }));
+
+ return { channelConfig, axesData };
+ }
+
+ handleRangeChange = (e, 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;
+
+ if (direction === -1) {
+ newEnd = currentEnd - rangeMs;
+ } else {
+ newEnd = currentEnd + rangeMs;
+ if (newEnd >= Date.now()) {
+ this.setState({ windowEnd: null });
+ return;
+ }
+ }
+ this.setState({ windowEnd: new Date(newEnd) });
+ };
+
render() {
const {
views, open, editingId, viewName, availableDevices, viewConfig, axisConfig,
- selDevice, selChannel, alias, yAxis
+ selDevice, selChannel, alias, yAxis,
+ rangeLabel, windowEnd
} = this.state;
- const { router } = this.props;
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))];
- return (
-
-
- Views
- {isAdmin && (
- } onClick={this.handleOpenCreate}>
- Create View
-
- )}
-
+ const rangeMs = RANGES[rangeLabel];
-
- {views.map(view => (
- router.navigate(`/views/${view.id}`)}
+ // 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 (
+
+ {/* Global Time Controls (Sticky) */}
+
+
+
-
-
- {isAdmin && (
-
- this.handleOpenEdit(view, e)}>
- this.handleDelete(view.id, e)}>
+ 3h
+ 1d
+ 1w
+ 1m
+ 3m
+
+
+ this.handleTimeNav(-1)}>
+ this.handleTimeNav(1)} disabled={!windowEnd}>
+
+
+
+ {dateDisplay}
+
+
+ {isAdmin && (
+ } onClick={this.handleOpenCreate}>
+ Create View
+
+ )}
+
+
+
+
+ {views.map(view => {
+ const { channelConfig, axesData } = this.parseViewData(view);
+ return (
+
+
+ {view.name}
+ {isAdmin && (
+
+ this.handleOpenEdit(view, e)}>
+ this.handleDelete(view.id, e)}>
+
+ )}
- )}
-
- ))}
+
+
+
+
+ );
+ })}
{views.length === 0 && No views available.}
-
+