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 && ( - - )} - + 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 && ( + + )} + + + + + {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.} - + this.setState({ open: false })} maxWidth="md" fullWidth> {editingId ? 'Edit View' : 'Create New View'} diff --git a/uiserver/webpack.config.js b/uiserver/webpack.config.js index 8349f4a..5687bd7 100644 --- a/uiserver/webpack.config.js +++ b/uiserver/webpack.config.js @@ -131,9 +131,16 @@ module.exports = { // Publicly list views app.get('/api/views', (req, res) => { try { - const stmt = db.prepare('SELECT id, name, created_at FROM views ORDER BY name'); + const stmt = db.prepare('SELECT * FROM views ORDER BY name'); const rows = stmt.all(); - res.json(rows); + const views = rows.map(row => { + try { + return { ...row, config: JSON.parse(row.config) }; + } catch (e) { + return row; + } + }); + res.json(views); } catch (err) { res.status(500).json({ error: err.message }); } @@ -213,29 +220,33 @@ module.exports = { app.get('/api/readings', (req, res) => { try { if (!db) throw new Error('Database not connected'); - const { since } = req.query; + const { since, until } = req.query; + const startTime = since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const endTime = until || new Date().toISOString(); - let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? '; - const params = [since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()]; + // 1. Fetch main data window + let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? '; + const params = [startTime, endTime]; - // Helper for filtering could be added here if needed, - // but fetching last 24h of *all* data might not be too huge if not too many sensors. - // However, optimization: if query params `channels` provided as "device:channel,device2:channel2" + const requestedChannels = []; // [{device, channel}] if (req.query.selection) { - // selection format: "device:channel,device:channel" const selections = req.query.selection.split(','); if (selections.length > 0) { - const placeholders = selections.map(() => '(device = ? AND channel = ?)').join(' OR '); - sql += `AND (${placeholders}) `; + const placeholders = []; selections.forEach(s => { const lastColonIndex = s.lastIndexOf(':'); if (lastColonIndex !== -1) { const d = s.substring(0, lastColonIndex); const c = s.substring(lastColonIndex + 1); + placeholders.push('(device = ? AND channel = ?)'); params.push(d, c); + requestedChannels.push({ device: d, channel: c }); } }); + if (placeholders.length > 0) { + sql += `AND (${placeholders.join(' OR ')}) `; + } } } @@ -243,7 +254,47 @@ module.exports = { const stmt = db.prepare(sql); const rows = stmt.all(...params); - res.json(rows); + + // 2. Backfill: Ensure we have a starting point for each channel + // For each requested channel, check if we have data at/near start. + // If the first point for a channel is > startTime, we should try to find the previous value. + // We check for the value that started before (or at) startTime AND ended after (or at) startTime (or hasn't ended). + + const backfillRows = []; + // Find the record that started most recently before startTime BUT was still valid at startTime + const backfillStmt = db.prepare(` + SELECT * FROM sensor_events + WHERE device = ? AND channel = ? + AND timestamp <= ? + AND (until >= ? OR until IS NULL) + ORDER BY timestamp DESC LIMIT 1 + `); + + requestedChannels.forEach(ch => { + const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime); + if (prev) { + // We found a point that covers the startTime. + backfillRows.push(prev); + } + }); + + // Combine and sort + const allRows = [...backfillRows, ...rows]; + + // Transform to Compact Dictionary Format + // { "device:channel": [ [timestamp, value, until], ... ] } + const result = {}; + + allRows.forEach(row => { + const key = `${row.device}:${row.channel}`; + if (!result[key]) result[key] = []; + + const pt = [row.timestamp, row.value]; + if (row.until) pt.push(row.until); + result[key].push(pt); + }); + + res.json(result); } catch (err) { console.error(err); res.status(500).json({ error: err.message });