u
This commit is contained in:
@@ -65,9 +65,22 @@ try {
|
|||||||
console.error('Error: username required');
|
console.error('Error: username required');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const stmt = db.prepare('DELETE FROM users WHERE username = ?');
|
|
||||||
const info = stmt.run(username);
|
// Find user first to get ID
|
||||||
if (info.changes > 0) {
|
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.`);
|
console.log(`User '${username}' deleted.`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`User '${username}' not found.`);
|
console.log(`User '${username}' not found.`);
|
||||||
|
|||||||
174
uiserver/src/components/Chart.js
vendored
174
uiserver/src/components/Chart.js
vendored
@@ -1,6 +1,8 @@
|
|||||||
import React, { Component } from 'react';
|
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 { 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 {
|
export default class Chart extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -9,37 +11,58 @@ export default class Chart extends Component {
|
|||||||
data: [],
|
data: [],
|
||||||
loading: true
|
loading: true
|
||||||
};
|
};
|
||||||
this.intervalId = null;
|
this.interval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.fetchData();
|
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) {
|
componentDidUpdate(prevProps) {
|
||||||
// Compare props to see if we need to refetch
|
|
||||||
const prevEffective = this.getEffectiveChannels(prevProps);
|
const prevEffective = this.getEffectiveChannels(prevProps);
|
||||||
const currEffective = this.getEffectiveChannels(this.props);
|
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();
|
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() {
|
componentWillUnmount() {
|
||||||
if (this.intervalId) {
|
if (this.interval) {
|
||||||
clearInterval(this.intervalId);
|
clearInterval(this.interval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getEffectiveChannels(props) {
|
getEffectiveChannels(props) {
|
||||||
return props.channelConfig
|
if (props.channelConfig) {
|
||||||
? props.channelConfig.map(c => c.id)
|
return props.channelConfig.map(c => c.id);
|
||||||
: props.selectedChannels;
|
}
|
||||||
|
return props.selectedChannels || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData = () => {
|
fetchData = () => {
|
||||||
|
const { windowEnd, range } = this.props;
|
||||||
const effectiveChannels = this.getEffectiveChannels(this.props);
|
const effectiveChannels = this.getEffectiveChannels(this.props);
|
||||||
|
|
||||||
// Only fetch if selection exists
|
// Only fetch if selection exists
|
||||||
@@ -49,35 +72,104 @@ export default class Chart extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectionStr = effectiveChannels.join(',');
|
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(res => res.json())
|
||||||
.then(rows => {
|
.then(dataObj => {
|
||||||
const timeMap = new Map();
|
// 1. Parse raw rows into intervals per channel
|
||||||
|
const intervals = [];
|
||||||
|
const timestampsSet = new Set();
|
||||||
|
|
||||||
rows.forEach(row => {
|
// dataObj format: { "device:channel": [ [timestamp, value, until], ... ] }
|
||||||
const id = `${row.device}:${row.channel}`;
|
Object.entries(dataObj).forEach(([id, points]) => {
|
||||||
|
// Check if this ID is in our effective/requested list
|
||||||
if (!effectiveChannels.includes(id)) return;
|
if (!effectiveChannels.includes(id)) return;
|
||||||
|
|
||||||
const time = new Date(row.timestamp).getTime();
|
// Ensure sorted by time
|
||||||
if (!timeMap.has(time)) {
|
points.sort((a, b) => new Date(a[0]) - new Date(b[0]));
|
||||||
timeMap.set(time, { time: new Date(row.timestamp) });
|
|
||||||
}
|
|
||||||
const entry = timeMap.get(time);
|
|
||||||
|
|
||||||
let val = row.value;
|
for (let i = 0; i < points.length; i++) {
|
||||||
if (row.data_type === 'json' && !val) {
|
const [tsStr, rawVal, untilStr] = points[i];
|
||||||
val = null;
|
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);
|
// 2. Sort unique timestamps
|
||||||
this.setState({ data: sortedData, loading: false });
|
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 => {
|
.catch(err => {
|
||||||
console.error("Failed to fetch data", err);
|
console.error(err);
|
||||||
this.setState({ loading: false });
|
this.setState({ loading: false });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -93,8 +185,8 @@ export default class Chart extends Component {
|
|||||||
|
|
||||||
// Check if config exists for this axis
|
// Check if config exists for this axis
|
||||||
const { axisConfig } = this.props;
|
const { axisConfig } = this.props;
|
||||||
let cfgMin = NaN;
|
let cfgMin = parseFloat(NaN);
|
||||||
let cfgMax = NaN;
|
let cfgMax = parseFloat(NaN);
|
||||||
if (axisConfig && axisConfig[axisKey]) {
|
if (axisConfig && axisConfig[axisKey]) {
|
||||||
cfgMin = parseFloat(axisConfig[axisKey].min);
|
cfgMin = parseFloat(axisConfig[axisKey].min);
|
||||||
cfgMax = parseFloat(axisConfig[axisKey].max);
|
cfgMax = parseFloat(axisConfig[axisKey].max);
|
||||||
@@ -127,7 +219,7 @@ export default class Chart extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { loading, data } = this.state;
|
const { loading, data } = this.state;
|
||||||
const { channelConfig } = this.props;
|
const { channelConfig, windowEnd, range } = this.props;
|
||||||
const effectiveChannels = this.getEffectiveChannels(this.props);
|
const effectiveChannels = this.getEffectiveChannels(this.props);
|
||||||
|
|
||||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
@@ -137,21 +229,25 @@ export default class Chart extends Component {
|
|||||||
// Find alias and axis if config exists
|
// Find alias and axis if config exists
|
||||||
let label = id;
|
let label = id;
|
||||||
let yAxisKey = 'left';
|
let yAxisKey = 'left';
|
||||||
|
let color = undefined;
|
||||||
if (channelConfig) {
|
if (channelConfig) {
|
||||||
const item = channelConfig.find(c => c.id === id);
|
const item = channelConfig.find(c => c.id === id);
|
||||||
if (item) {
|
if (item) {
|
||||||
if (item.alias) label = item.alias;
|
if (item.alias) label = item.alias;
|
||||||
if (item.yAxis) yAxisKey = item.yAxis;
|
if (item.yAxis) yAxisKey = item.yAxis;
|
||||||
|
if (item.color) color = item.color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const sObj = {
|
||||||
dataKey: id,
|
dataKey: id,
|
||||||
label: label,
|
label: label,
|
||||||
connectNulls: true,
|
connectNulls: true,
|
||||||
showMark: false,
|
showMark: false,
|
||||||
yAxisKey: yAxisKey,
|
yAxisKey: yAxisKey,
|
||||||
};
|
};
|
||||||
|
if (color) sObj.color = color;
|
||||||
|
return sObj;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
|
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 });
|
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 (
|
return (
|
||||||
<Box sx={{ width: '100%', height: '80vh', p: 2 }}>
|
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', p: 2, boxSizing: 'border-box' }}>
|
||||||
<Typography variant="h6" gutterBottom>Last 24 Hours</Typography>
|
<Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||||
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
|
||||||
<LineChart
|
<LineChart
|
||||||
dataset={data}
|
dataset={data}
|
||||||
series={series}
|
series={series}
|
||||||
xAxis={[{
|
xAxis={[{
|
||||||
dataKey: 'time',
|
dataKey: 'time',
|
||||||
scaleType: 'time',
|
scaleType: 'time',
|
||||||
|
min: new Date(axisStart),
|
||||||
|
max: new Date(axisEnd),
|
||||||
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
}]}
|
}]}
|
||||||
yAxis={yAxes}
|
yAxis={yAxes}
|
||||||
|
|||||||
@@ -46,29 +46,44 @@ class ViewDisplay extends Component {
|
|||||||
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
|
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
|
||||||
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
|
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
|
||||||
|
|
||||||
// Parse view config
|
// Parse view config into groups
|
||||||
let channelsData = [];
|
let groups = [];
|
||||||
let axesData = null;
|
if (view.config && view.config.groups) {
|
||||||
|
groups = view.config.groups;
|
||||||
if (Array.isArray(view.config)) {
|
} else {
|
||||||
channelsData = view.config;
|
// Legacy
|
||||||
} else if (view.config && view.config.channels) {
|
let channels = [];
|
||||||
channelsData = view.config.channels;
|
let axes = null;
|
||||||
axesData = view.config.axes;
|
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 (
|
return (
|
||||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', p: 2, gap: 2 }}>
|
||||||
<Box sx={{ p: 2 }}>
|
<Typography variant="h5">{view.name}</Typography>
|
||||||
<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>
|
</Box>
|
||||||
<Chart channelConfig={channelConfig} axisConfig={axesData} />
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import {
|
import {
|
||||||
Container, Typography, List, ListItem, ListItemText, ListItemIcon,
|
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
|
||||||
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
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';
|
} from '@mui/material';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
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 { 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 {
|
class ViewManager extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -28,7 +40,11 @@ class ViewManager extends Component {
|
|||||||
selDevice: '',
|
selDevice: '',
|
||||||
selChannel: '',
|
selChannel: '',
|
||||||
alias: '',
|
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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
views, open, editingId, viewName, availableDevices, viewConfig, axisConfig,
|
views, open, editingId, viewName, availableDevices, viewConfig, axisConfig,
|
||||||
selDevice, selChannel, alias, yAxis
|
selDevice, selChannel, alias, yAxis,
|
||||||
|
rangeLabel, windowEnd
|
||||||
} = this.state;
|
} = this.state;
|
||||||
const { router } = this.props;
|
|
||||||
const isAdmin = this.isAdmin();
|
const isAdmin = this.isAdmin();
|
||||||
|
|
||||||
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
|
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
|
||||||
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
|
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
|
||||||
|
|
||||||
return (
|
const rangeMs = RANGES[rangeLabel];
|
||||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
|
||||||
<Typography variant="h4">Views</Typography>
|
|
||||||
{isAdmin && (
|
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
|
|
||||||
Create View
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<List>
|
// Format Date Range for Display
|
||||||
{views.map(view => (
|
let dateDisplay = "Live";
|
||||||
<ListItem
|
if (windowEnd) {
|
||||||
button
|
const start = new Date(windowEnd.getTime() - rangeMs);
|
||||||
key={view.id}
|
dateDisplay = `${start.toLocaleString()} - ${windowEnd.toLocaleString()}`;
|
||||||
divider
|
} else {
|
||||||
onClick={() => router.navigate(`/views/${view.id}`)}
|
// 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'
|
||||||
|
}}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={rangeLabel}
|
||||||
|
exclusive
|
||||||
|
onChange={this.handleRangeChange}
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<ListItemIcon><DashboardIcon /></ListItemIcon>
|
<ToggleButton value="3h">3h</ToggleButton>
|
||||||
<ListItemText
|
<ToggleButton value="1d">1d</ToggleButton>
|
||||||
primary={view.name}
|
<ToggleButton value="1w">1w</ToggleButton>
|
||||||
secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`}
|
<ToggleButton value="1m">1m</ToggleButton>
|
||||||
/>
|
<ToggleButton value="3m">3m</ToggleButton>
|
||||||
{isAdmin && (
|
</ToggleButtonGroup>
|
||||||
<Box>
|
<Box>
|
||||||
<IconButton onClick={(e) => this.handleOpenEdit(view, e)}><EditIcon /></IconButton>
|
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></IconButton>
|
||||||
<IconButton onClick={(e) => this.handleDelete(view.id, e)}><DeleteIcon /></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>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{views.map(view => {
|
||||||
|
const { channelConfig, axesData } = 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>
|
||||||
|
{isAdmin && (
|
||||||
|
<Box>
|
||||||
|
<IconButton onClick={(e) => this.handleOpenEdit(view, e)}><EditIcon /></IconButton>
|
||||||
|
<IconButton onClick={(e) => this.handleDelete(view.id, e)}><DeleteIcon /></IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
<Box sx={{ height: '500px' }}>
|
||||||
</ListItem>
|
<Chart
|
||||||
))}
|
channelConfig={channelConfig}
|
||||||
|
axisConfig={axesData}
|
||||||
|
windowEnd={windowEnd}
|
||||||
|
range={rangeMs}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{views.length === 0 && <Typography>No views available.</Typography>}
|
{views.length === 0 && <Typography>No views available.</Typography>}
|
||||||
</List>
|
</Box>
|
||||||
|
|
||||||
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
|
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
|
||||||
|
|||||||
@@ -131,9 +131,16 @@ module.exports = {
|
|||||||
// Publicly list views
|
// Publicly list views
|
||||||
app.get('/api/views', (req, res) => {
|
app.get('/api/views', (req, res) => {
|
||||||
try {
|
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();
|
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) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
@@ -213,29 +220,33 @@ module.exports = {
|
|||||||
app.get('/api/readings', (req, res) => {
|
app.get('/api/readings', (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!db) throw new Error('Database not connected');
|
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 > ? ';
|
// 1. Fetch main data window
|
||||||
const params = [since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()];
|
let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? ';
|
||||||
|
const params = [startTime, endTime];
|
||||||
|
|
||||||
// Helper for filtering could be added here if needed,
|
const requestedChannels = []; // [{device, channel}]
|
||||||
// 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"
|
|
||||||
|
|
||||||
if (req.query.selection) {
|
if (req.query.selection) {
|
||||||
// selection format: "device:channel,device:channel"
|
|
||||||
const selections = req.query.selection.split(',');
|
const selections = req.query.selection.split(',');
|
||||||
if (selections.length > 0) {
|
if (selections.length > 0) {
|
||||||
const placeholders = selections.map(() => '(device = ? AND channel = ?)').join(' OR ');
|
const placeholders = [];
|
||||||
sql += `AND (${placeholders}) `;
|
|
||||||
selections.forEach(s => {
|
selections.forEach(s => {
|
||||||
const lastColonIndex = s.lastIndexOf(':');
|
const lastColonIndex = s.lastIndexOf(':');
|
||||||
if (lastColonIndex !== -1) {
|
if (lastColonIndex !== -1) {
|
||||||
const d = s.substring(0, lastColonIndex);
|
const d = s.substring(0, lastColonIndex);
|
||||||
const c = s.substring(lastColonIndex + 1);
|
const c = s.substring(lastColonIndex + 1);
|
||||||
|
placeholders.push('(device = ? AND channel = ?)');
|
||||||
params.push(d, c);
|
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 stmt = db.prepare(sql);
|
||||||
const rows = stmt.all(...params);
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
|
|||||||
Reference in New Issue
Block a user