This commit is contained in:
sebseb7
2025-12-25 01:42:03 +01:00
parent 2d7bfe247d
commit 53e2e04cb9
5 changed files with 399 additions and 105 deletions

View File

@@ -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.`);

View File

@@ -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}

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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 });