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

View File

@@ -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 <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
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 (
<Box sx={{ width: '100%', height: '80vh', p: 2 }}>
<Typography variant="h6" gutterBottom>Last 24 Hours</Typography>
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', p: 2, boxSizing: 'border-box' }}>
<Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
<LineChart
dataset={data}
series={series}
xAxis={[{
dataKey: 'time',
scaleType: 'time',
min: new Date(axisStart),
max: new Date(axisEnd),
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}]}
yAxis={yAxes}

View File

@@ -46,29 +46,44 @@ class ViewDisplay extends Component {
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
// 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 (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2 }}>
<Typography variant="h5">{view.name}</Typography>
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', p: 2, gap: 2 }}>
<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>
<Chart channelConfig={channelConfig} axisConfig={axesData} />
</Box>
);
}

View File

@@ -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 (
<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>
const rangeMs = RANGES[rangeLabel];
<List>
{views.map(view => (
<ListItem
button
key={view.id}
divider
onClick={() => 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 (
<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>
<ListItemText
primary={view.name}
secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`}
/>
{isAdmin && (
<Box>
<IconButton onClick={(e) => this.handleOpenEdit(view, e)}><EditIcon /></IconButton>
<IconButton onClick={(e) => this.handleDelete(view.id, e)}><DeleteIcon /></IconButton>
<ToggleButton value="3h">3h</ToggleButton>
<ToggleButton value="1d">1d</ToggleButton>
<ToggleButton value="1w">1w</ToggleButton>
<ToggleButton value="1m">1m</ToggleButton>
<ToggleButton value="3m">3m</ToggleButton>
</ToggleButtonGroup>
<Box>
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></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>
)}
</ListItem>
))}
<Box sx={{ height: '500px' }}>
<Chart
channelConfig={channelConfig}
axisConfig={axesData}
windowEnd={windowEnd}
range={rangeMs}
/>
</Box>
</Paper>
);
})}
{views.length === 0 && <Typography>No views available.</Typography>}
</List>
</Box>
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>

View File

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