u
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>TischlerCtrl UI</title>
|
<title>CTRL Freak</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default class App extends Component {
|
|||||||
<AppBar position="static">
|
<AppBar position="static">
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
TischlerCtrl
|
CTRL Freak
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Button color="inherit" component={Link} to="/" startIcon={<DashboardIcon />}>Views</Button>
|
<Button color="inherit" component={Link} to="/" startIcon={<DashboardIcon />}>Views</Button>
|
||||||
|
|||||||
167
uiserver/src/components/Chart.js
vendored
167
uiserver/src/components/Chart.js
vendored
@@ -1,9 +1,112 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { Box, Paper, Typography, CircularProgress, IconButton } 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 { useDrawingArea, useYScale, useXScale } from '@mui/x-charts/hooks';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
|
||||||
|
// Custom component to render a horizontal band between two y-values
|
||||||
|
function ReferenceArea({ yMin, yMax, color = 'rgba(76, 175, 80, 0.15)', axisId = 'left' }) {
|
||||||
|
const { left, width } = useDrawingArea();
|
||||||
|
const yScale = useYScale(axisId);
|
||||||
|
|
||||||
|
if (!yScale) return null;
|
||||||
|
|
||||||
|
const y1 = yScale(yMax);
|
||||||
|
const y2 = yScale(yMin);
|
||||||
|
|
||||||
|
if (y1 === undefined || y2 === undefined) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
x={left}
|
||||||
|
y={Math.min(y1, y2)}
|
||||||
|
width={width}
|
||||||
|
height={Math.abs(y2 - y1)}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom component to render vertical time bands every 6 hours aligned to midnight
|
||||||
|
function TimeReferenceAreas({ axisStart, axisEnd, colors }) {
|
||||||
|
const { top, height } = useDrawingArea();
|
||||||
|
const xScale = useXScale();
|
||||||
|
|
||||||
|
if (!xScale) return null;
|
||||||
|
|
||||||
|
// Calculate 6-hour bands aligned to midnight
|
||||||
|
const SIX_HOURS = 6 * 60 * 60 * 1000;
|
||||||
|
const bands = [];
|
||||||
|
|
||||||
|
// Find the first midnight before axisStart
|
||||||
|
const startDate = new Date(axisStart);
|
||||||
|
const midnight = new Date(startDate);
|
||||||
|
midnight.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Start from that midnight
|
||||||
|
let bandStart = midnight.getTime();
|
||||||
|
|
||||||
|
while (bandStart < axisEnd) {
|
||||||
|
const bandEnd = bandStart + SIX_HOURS;
|
||||||
|
|
||||||
|
// Only render if band overlaps with visible range
|
||||||
|
if (bandEnd > axisStart && bandStart < axisEnd) {
|
||||||
|
const visibleStart = Math.max(bandStart, axisStart);
|
||||||
|
const visibleEnd = Math.min(bandEnd, axisEnd);
|
||||||
|
|
||||||
|
const x1 = xScale(new Date(visibleStart));
|
||||||
|
const x2 = xScale(new Date(visibleEnd));
|
||||||
|
|
||||||
|
if (x1 !== undefined && x2 !== undefined) {
|
||||||
|
// Determine which 6-hour block (0-3) based on hour of day
|
||||||
|
const hour = new Date(bandStart).getHours();
|
||||||
|
const blockIndex = Math.floor(hour / 6); // 0, 1, 2, or 3
|
||||||
|
const color = colors[blockIndex % colors.length];
|
||||||
|
|
||||||
|
bands.push(
|
||||||
|
<rect
|
||||||
|
key={bandStart}
|
||||||
|
x={Math.min(x1, x2)}
|
||||||
|
y={top}
|
||||||
|
width={Math.abs(x2 - x1)}
|
||||||
|
height={height}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bandStart = bandEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{bands}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate Simple Moving Average
|
||||||
|
function calculateSMA(data, channelKey, period) {
|
||||||
|
if (period <= 1 || data.length === 0) return data;
|
||||||
|
|
||||||
|
return data.map((row, i) => {
|
||||||
|
const newRow = { ...row };
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
// Look back up to 'period' samples
|
||||||
|
for (let j = Math.max(0, i - period + 1); j <= i; j++) {
|
||||||
|
const val = data[j][channelKey];
|
||||||
|
if (val !== null && val !== undefined && !isNaN(val)) {
|
||||||
|
values.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average if we have values
|
||||||
|
if (values.length > 0) {
|
||||||
|
newRow[channelKey] = values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default class Chart extends Component {
|
export default class Chart extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -115,9 +218,14 @@ export default class Chart extends Component {
|
|||||||
|
|
||||||
// Calculate effective end
|
// Calculate effective end
|
||||||
let end = explicitEnd;
|
let end = explicitEnd;
|
||||||
// If 'until' is null, extend to next point or now
|
// If 'until' is null, extend to next point or now (but never beyond current time)
|
||||||
|
const nowTime = Date.now();
|
||||||
if (!end) {
|
if (!end) {
|
||||||
end = nextStart || endTimeVal;
|
end = nextStart || Math.min(endTimeVal, nowTime);
|
||||||
|
}
|
||||||
|
// Never extend data beyond the current time
|
||||||
|
if (end > nowTime) {
|
||||||
|
end = nowTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict Cutoff: Current interval cannot extend past the start of the next interval
|
// Strict Cutoff: Current interval cannot extend past the start of the next interval
|
||||||
@@ -169,7 +277,19 @@ export default class Chart extends Component {
|
|||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setState({ data: denseData, loading: false });
|
// 4. Apply SMA for channels that have it configured
|
||||||
|
const { channelConfig } = this.props;
|
||||||
|
let processedData = denseData;
|
||||||
|
|
||||||
|
if (channelConfig) {
|
||||||
|
channelConfig.forEach(cfg => {
|
||||||
|
if (cfg.sma && cfg.sma > 1) {
|
||||||
|
processedData = calculateSMA(processedData, cfg.id, cfg.sma);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ data: processedData, loading: false });
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -305,6 +425,32 @@ export default class Chart extends Component {
|
|||||||
const axisEnd = windowEnd ? windowEnd.getTime() : Date.now();
|
const axisEnd = windowEnd ? windowEnd.getTime() : Date.now();
|
||||||
const axisStart = axisEnd - rangeMs;
|
const axisStart = axisEnd - rangeMs;
|
||||||
|
|
||||||
|
// Determine if all visible series are Temperature channels
|
||||||
|
const isTemperatureOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
|
||||||
|
const lcId = id.toLowerCase();
|
||||||
|
return lcId.includes('temp') || lcId.includes('temperature');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine if all visible series are Humidity channels
|
||||||
|
const isHumidityOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
|
||||||
|
const lcId = id.toLowerCase();
|
||||||
|
return lcId.includes('humid') || lcId.includes('humidity') || lcId.includes('rh');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine if all visible series are Light channels
|
||||||
|
const isLightOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
|
||||||
|
const lcId = id.toLowerCase();
|
||||||
|
return lcId.includes('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Colors for 6-hour time bands (midnight, 6am, noon, 6pm)
|
||||||
|
const lightBandColors = [
|
||||||
|
'rgba(0, 0, 0, 0.1)', // 00:00-06:00 - black (night)
|
||||||
|
'rgba(135, 206, 250, 0.1)', // 06:00-12:00 - light blue (morning)
|
||||||
|
'rgba(255, 255, 180, 0.1)', // 12:00-18:00 - light yellow (afternoon)
|
||||||
|
'rgba(255, 200, 150, 0.1)', // 18:00-24:00 - light orange (evening)
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', p: 2, boxSizing: 'border-box' }}>
|
<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' }}>
|
<Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||||
@@ -367,7 +513,20 @@ export default class Chart extends Component {
|
|||||||
fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5,
|
fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{/* Green reference band for temperature charts (20-25°C) */}
|
||||||
|
{isTemperatureOnly && (
|
||||||
|
<ReferenceArea yMin={20} yMax={25} color="rgba(76, 175, 80, 0.2)" />
|
||||||
|
)}
|
||||||
|
{/* Green reference band for humidity charts (50-70%) */}
|
||||||
|
{isHumidityOnly && (
|
||||||
|
<ReferenceArea yMin={50} yMax={70} color="rgba(76, 175, 80, 0.2)" />
|
||||||
|
)}
|
||||||
|
{/* Time-based vertical bands for light charts (6-hour intervals) */}
|
||||||
|
{isLightOnly && (
|
||||||
|
<TimeReferenceAreas axisStart={axisStart} axisEnd={axisEnd} colors={lightBandColors} />
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ const RANGES = {
|
|||||||
'3m': 90 * 24 * 60 * 60 * 1000,
|
'3m': 90 * 24 * 60 * 60 * 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SMA_OPTIONS = [
|
||||||
|
{ value: 0, label: 'Off' },
|
||||||
|
{ value: 3, label: '3' },
|
||||||
|
{ value: 5, label: '5' },
|
||||||
|
{ value: 10, label: '10' },
|
||||||
|
{ value: 15, label: '15' },
|
||||||
|
];
|
||||||
|
|
||||||
const GRUVBOX_COLORS = [
|
const GRUVBOX_COLORS = [
|
||||||
'#cc241d', '#fb4934', // Red
|
'#cc241d', '#fb4934', // Red
|
||||||
'#98971a', '#b8bb26', // Green
|
'#98971a', '#b8bb26', // Green
|
||||||
@@ -74,6 +82,7 @@ class ViewManager extends Component {
|
|||||||
this.refreshViews();
|
this.refreshViews();
|
||||||
this.loadRules();
|
this.loadRules();
|
||||||
this.loadOutputValues();
|
this.loadOutputValues();
|
||||||
|
this.loadRuleStatus(); // Load immediately on mount
|
||||||
// Refresh rules and outputs every 30s
|
// Refresh rules and outputs every 30s
|
||||||
this.rulesInterval = setInterval(() => {
|
this.rulesInterval = setInterval(() => {
|
||||||
this.loadRules();
|
this.loadRules();
|
||||||
@@ -174,48 +183,122 @@ class ViewManager extends Component {
|
|||||||
'CircFanLevel': '🌀',
|
'CircFanLevel': '🌀',
|
||||||
'CO2Valve': '🫧',
|
'CO2Valve': '🫧',
|
||||||
'BigDehumid': '💧',
|
'BigDehumid': '💧',
|
||||||
'TentExhaust': '💨'
|
'TentExhaust': '💨',
|
||||||
|
'RoomExhaust': '🌬️'
|
||||||
};
|
};
|
||||||
return emojis[channel] || '⚡';
|
return emojis[channel] || '⚡';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format conditions for display
|
// Format conditions for display - returns React components with visual grouping
|
||||||
formatRuleConditions = (condition) => {
|
formatRuleConditions = (condition, depth = 0) => {
|
||||||
if (!condition) return '(always)';
|
if (!condition) return <span style={{ color: '#888' }}>(always)</span>;
|
||||||
|
|
||||||
if (condition.operator === 'AND' || condition.operator === 'OR') {
|
if (condition.operator === 'AND' || condition.operator === 'OR') {
|
||||||
const parts = (condition.conditions || []).map(c => this.formatRuleConditions(c)).filter(Boolean);
|
const parts = (condition.conditions || []).map((c, i) => this.formatRuleConditions(c, depth + 1)).filter(Boolean);
|
||||||
if (parts.length === 0) return '(always)';
|
if (parts.length === 0) return <span style={{ color: '#888' }}>(always)</span>;
|
||||||
const sep = condition.operator === 'AND' ? ' & ' : ' | ';
|
|
||||||
return parts.join(sep);
|
const isAnd = condition.operator === 'AND';
|
||||||
|
const borderColor = isAnd ? 'rgba(100, 150, 255, 0.5)' : 'rgba(255, 150, 100, 0.5)';
|
||||||
|
const bgColor = isAnd ? 'rgba(100, 150, 255, 0.08)' : 'rgba(255, 150, 100, 0.08)';
|
||||||
|
const label = isAnd ? 'ALL' : 'ANY';
|
||||||
|
const symbol = isAnd ? 'and' : 'or';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: bgColor,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
fontSize: depth > 0 ? '0.9em' : '1em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isAnd ? '#6496ff' : '#ff9664',
|
||||||
|
mr: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}:
|
||||||
|
</Typography>
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{part}
|
||||||
|
{i < parts.length - 1 && (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
mx: 0.5,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isAnd ? '#6496ff' : '#ff9664',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{symbol}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, channel, operator, value } = condition;
|
const { type, channel, operator, value } = condition;
|
||||||
const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
|
const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
|
||||||
const op = opSymbols[operator] || operator;
|
const op = opSymbols[operator] || operator;
|
||||||
|
|
||||||
|
let text = '?';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'time':
|
case 'time':
|
||||||
if (operator === 'between' && Array.isArray(value)) {
|
if (operator === 'between' && Array.isArray(value)) {
|
||||||
return `🕐 ${value[0]} - ${value[1]}`;
|
text = `🕐 ${value[0]} - ${value[1]}`;
|
||||||
|
} else {
|
||||||
|
text = `🕐 ${op} ${value}`;
|
||||||
}
|
}
|
||||||
return `🕐 ${op} ${value}`;
|
break;
|
||||||
case 'date':
|
case 'date':
|
||||||
if (operator === 'between' && Array.isArray(value)) {
|
if (operator === 'between' && Array.isArray(value)) {
|
||||||
return `📅 ${value[0]} to ${value[1]}`;
|
text = `📅 ${value[0]} to ${value[1]}`;
|
||||||
|
} else {
|
||||||
|
text = `📅 ${operator} ${value}`;
|
||||||
}
|
}
|
||||||
return `📅 ${operator} ${value}`;
|
break;
|
||||||
case 'sensor':
|
case 'sensor':
|
||||||
// Show device:channel for clarity
|
|
||||||
if (value && value.type === 'dynamic') {
|
if (value && value.type === 'dynamic') {
|
||||||
return `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`;
|
text = `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`;
|
||||||
|
} else {
|
||||||
|
text = `📡 ${channel} ${op} ${value}`;
|
||||||
}
|
}
|
||||||
return `📡 ${channel} ${op} ${value}`;
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
return `⚙️ ${channel} ${op} ${value}`;
|
text = `⚙️ ${channel} ${op} ${value}`;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return '?';
|
text = '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
px: 0.5,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format action for display
|
// Format action for display
|
||||||
@@ -225,7 +308,8 @@ class ViewManager extends Component {
|
|||||||
'CircFanLevel': '🌀 Circ Fan',
|
'CircFanLevel': '🌀 Circ Fan',
|
||||||
'CO2Valve': '🫧 CO2',
|
'CO2Valve': '🫧 CO2',
|
||||||
'BigDehumid': '💧 Big Dehumid',
|
'BigDehumid': '💧 Big Dehumid',
|
||||||
'TentExhaust': '💨 Tent Exhaust Fan'
|
'TentExhaust': '💨 Tent Exhaust Fan',
|
||||||
|
'RoomExhaust': '🌬️ Room Exhaust'
|
||||||
};
|
};
|
||||||
const name = channelNames[action.channel] || action.channel;
|
const name = channelNames[action.channel] || action.channel;
|
||||||
|
|
||||||
@@ -414,6 +498,14 @@ class ViewManager extends Component {
|
|||||||
this.setState({ viewConfig: newConfig });
|
this.setState({ viewConfig: newConfig });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
updateChannel = (idx, updates) => {
|
||||||
|
const newConfig = this.state.viewConfig.map((ch, i) => {
|
||||||
|
if (i === idx) return { ...ch, ...updates };
|
||||||
|
return ch;
|
||||||
|
});
|
||||||
|
this.setState({ viewConfig: newConfig });
|
||||||
|
};
|
||||||
|
|
||||||
updateFillOpacity = (idx, value) => {
|
updateFillOpacity = (idx, value) => {
|
||||||
const newConfig = this.state.viewConfig.map((ch, i) => {
|
const newConfig = this.state.viewConfig.map((ch, i) => {
|
||||||
if (i === idx) {
|
if (i === idx) {
|
||||||
@@ -441,6 +533,45 @@ class ViewManager extends Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleAlignToPeriod = () => {
|
||||||
|
const { rangeLabel } = this.state;
|
||||||
|
const now = new Date();
|
||||||
|
let periodEnd;
|
||||||
|
|
||||||
|
switch (rangeLabel) {
|
||||||
|
case '1d':
|
||||||
|
// Midnight of tomorrow (so range - 24h = midnight today)
|
||||||
|
periodEnd = new Date(now);
|
||||||
|
periodEnd.setDate(periodEnd.getDate() + 1);
|
||||||
|
periodEnd.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case '1w':
|
||||||
|
// Midnight of next Monday (start of next week)
|
||||||
|
periodEnd = new Date(now);
|
||||||
|
const dayOfWeek = periodEnd.getDay(); // 0 = Sunday
|
||||||
|
const daysUntilNextMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
||||||
|
periodEnd.setDate(periodEnd.getDate() + daysUntilNextMonday);
|
||||||
|
periodEnd.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case '1m':
|
||||||
|
// First day of next month (midnight)
|
||||||
|
periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case '3m':
|
||||||
|
// First day of next quarter (midnight)
|
||||||
|
const nextQuarterMonth = (Math.floor(now.getMonth() / 3) + 1) * 3;
|
||||||
|
periodEnd = new Date(now.getFullYear(), nextQuarterMonth, 1, 0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// For 3h or unsupported, don't change
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Set window end to the end of the period
|
||||||
|
// This makes the chart show [period_start, period_end]
|
||||||
|
// e.g., for 1d, shows 0:00 to 23:59:59 of today
|
||||||
|
this.setState({ windowEnd: periodEnd });
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
views, open, editingId, viewName, availableDevices,
|
views, open, editingId, viewName, availableDevices,
|
||||||
@@ -464,14 +595,71 @@ class ViewManager extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||||
<Paper sx={{ position: 'sticky', top: 10, zIndex: 1000, p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between', border: '1px solid #504945' }}>
|
<Paper sx={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 10,
|
||||||
|
zIndex: 1000,
|
||||||
|
p: 2,
|
||||||
|
mb: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
bgcolor: 'rgba(20, 30, 50, 0.95)',
|
||||||
|
border: '2px solid #1976d2',
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5), 0 0 15px rgba(25, 118, 210, 0.3)',
|
||||||
|
}}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<ToggleButtonGroup value={rangeLabel} exclusive onChange={this.handleRangeChange} size="small">
|
<ToggleButtonGroup
|
||||||
|
value={rangeLabel}
|
||||||
|
exclusive
|
||||||
|
onChange={this.handleRangeChange}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
'& .MuiToggleButton-root': {
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(100, 180, 255, 0.3)',
|
||||||
|
border: '2px solid #64b5f6',
|
||||||
|
boxShadow: '0 0 15px rgba(100, 180, 255, 0.6), inset 0 0 8px rgba(100, 180, 255, 0.2)',
|
||||||
|
transform: 'scale(1.08)',
|
||||||
|
zIndex: 1,
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: '#1976d2',
|
||||||
|
color: 'white',
|
||||||
|
border: '2px solid #42a5f5',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: '#1e88e5',
|
||||||
|
boxShadow: '0 0 20px rgba(100, 180, 255, 0.8)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
{Object.keys(RANGES).map(r => <ToggleButton key={r} value={r}>{r}</ToggleButton>)}
|
{Object.keys(RANGES).map(r => <ToggleButton key={r} value={r}>{r}</ToggleButton>)}
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
<Box>
|
<Box>
|
||||||
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></IconButton>
|
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></IconButton>
|
||||||
<IconButton onClick={() => this.handleTimeNav(1)} disabled={!windowEnd}><ArrowForwardIcon /></IconButton>
|
<IconButton onClick={() => this.handleTimeNav(1)} disabled={!windowEnd}><ArrowForwardIcon /></IconButton>
|
||||||
|
{['1d', '1w', '1m', '3m'].includes(rangeLabel) && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={this.handleAlignToPeriod}
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
minWidth: 'auto',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
title={`Align to ${rangeLabel === '1d' ? 'today' : rangeLabel === '1w' ? 'this week' : rangeLabel === '1m' ? 'this month' : 'this quarter'}`}
|
||||||
|
>
|
||||||
|
📅 Align
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h6">{dateDisplay}</Typography>
|
<Typography variant="h6">{dateDisplay}</Typography>
|
||||||
@@ -508,7 +696,8 @@ class ViewManager extends Component {
|
|||||||
yAxis: c.yAxis || 'left',
|
yAxis: c.yAxis || 'left',
|
||||||
color: c.color,
|
color: c.color,
|
||||||
fillColor: c.fillColor,
|
fillColor: c.fillColor,
|
||||||
fillOpacity: c.fillOpacity
|
fillOpacity: c.fillOpacity,
|
||||||
|
sma: c.sma || 0
|
||||||
}))}
|
}))}
|
||||||
axisConfig={axes}
|
axisConfig={axes}
|
||||||
windowEnd={windowEnd}
|
windowEnd={windowEnd}
|
||||||
@@ -563,6 +752,9 @@ class ViewManager extends Component {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Scroll space at end of page */}
|
||||||
|
<Box sx={{ height: 200 }} />
|
||||||
|
|
||||||
<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>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -615,11 +807,36 @@ class ViewManager extends Component {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ListItemText
|
<Select
|
||||||
primary={ch.alias}
|
size="small"
|
||||||
secondary={`${ch.device}:${ch.channel} (${ch.yAxis})`}
|
value={ch.sma || 0}
|
||||||
sx={{ ml: 1 }}
|
onChange={e => this.updateChannel(idx, { sma: e.target.value })}
|
||||||
|
sx={{ width: 70, ml: 1 }}
|
||||||
|
title="Simple Moving Average"
|
||||||
|
>
|
||||||
|
{SMA_OPTIONS.map(opt => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>SMA: {opt.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={ch.alias}
|
||||||
|
onChange={e => this.updateChannel(idx, { alias: e.target.value })}
|
||||||
|
sx={{ ml: 1, flex: 1, minWidth: 100 }}
|
||||||
|
placeholder="Alias"
|
||||||
/>
|
/>
|
||||||
|
<Typography variant="caption" sx={{ ml: 1, color: 'text.secondary', whiteSpace: 'nowrap' }}>
|
||||||
|
{ch.device}:{ch.channel}
|
||||||
|
</Typography>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={ch.yAxis || 'left'}
|
||||||
|
onChange={e => this.updateChannel(idx, { yAxis: e.target.value })}
|
||||||
|
sx={{ width: 75, ml: 1 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="left">Left</MenuItem>
|
||||||
|
<MenuItem value="right">Right</MenuItem>
|
||||||
|
</Select>
|
||||||
<IconButton size="small" onClick={() => this.moveChannel(idx, -1)} disabled={idx === 0}><ArrowUpwardIcon /></IconButton>
|
<IconButton size="small" onClick={() => this.moveChannel(idx, -1)} disabled={idx === 0}><ArrowUpwardIcon /></IconButton>
|
||||||
<IconButton size="small" onClick={() => this.moveChannel(idx, 1)} disabled={idx === viewConfig.length - 1}><ArrowDownwardIcon /></IconButton>
|
<IconButton size="small" onClick={() => this.moveChannel(idx, 1)} disabled={idx === viewConfig.length - 1}><ArrowDownwardIcon /></IconButton>
|
||||||
<IconButton size="small" color="error" onClick={() => this.removeChannel(idx)}><DeleteIcon /></IconButton>
|
<IconButton size="small" color="error" onClick={() => this.removeChannel(idx)}><DeleteIcon /></IconButton>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const OUTPUT_BINDINGS = {
|
|||||||
'CO2Valve': { device: 'tapo', channel: 'c', type: 'switch' },
|
'CO2Valve': { device: 'tapo', channel: 'c', type: 'switch' },
|
||||||
'TentExhaust': { device: 'tapo', channel: 'fantent', type: 'switch' },
|
'TentExhaust': { device: 'tapo', channel: 'fantent', type: 'switch' },
|
||||||
'CircFanLevel': { device: 'ac', channel: 'tent:fan', type: 'level' },
|
'CircFanLevel': { device: 'ac', channel: 'tent:fan', type: 'level' },
|
||||||
|
'RoomExhaust': { device: 'ac', channel: 'wall-fan', type: 'level' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -359,6 +360,7 @@ const OUTPUT_CHANNELS = [
|
|||||||
{ channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' },
|
{ channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' },
|
||||||
{ channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' },
|
{ channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' },
|
||||||
{ channel: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' },
|
{ channel: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' },
|
||||||
|
{ channel: 'RoomExhaust', type: 'number', min: 0, max: 10, description: 'Room Exhaust Fan' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get current sensor value
|
// Get current sensor value
|
||||||
@@ -740,10 +742,83 @@ module.exports = {
|
|||||||
app.put('/api/views/:id', requireAdmin, (req, res) => {
|
app.put('/api/views/:id', requireAdmin, (req, res) => {
|
||||||
const { name, config } = req.body;
|
const { name, config } = req.body;
|
||||||
try {
|
try {
|
||||||
|
// Get old view for comparison
|
||||||
|
const oldView = db.prepare('SELECT * FROM views WHERE id = ?').get(req.params.id);
|
||||||
|
if (!oldView) {
|
||||||
|
return res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
|
||||||
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
|
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
|
||||||
const info = stmt.run(name, JSON.stringify(config), req.params.id);
|
const info = stmt.run(name, JSON.stringify(config), req.params.id);
|
||||||
if (info.changes > 0) {
|
if (info.changes > 0) {
|
||||||
global.insertChangelog(req.user.username, `Updated view "${name}" (ID: ${req.params.id})`);
|
// Build detailed changelog
|
||||||
|
const changes = [];
|
||||||
|
|
||||||
|
// Check name change
|
||||||
|
if (oldView.name !== name) {
|
||||||
|
changes.push(`renamed: "${oldView.name}" → "${name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse configs for comparison
|
||||||
|
let oldConfig = {};
|
||||||
|
try { oldConfig = JSON.parse(oldView.config || '{}'); } catch (e) { }
|
||||||
|
const newConfig = config || {};
|
||||||
|
|
||||||
|
// Compare channels
|
||||||
|
const oldChannels = (oldConfig.channels || []).map(ch =>
|
||||||
|
typeof ch === 'string' ? ch : ch.channel
|
||||||
|
);
|
||||||
|
const newChannels = (newConfig.channels || []).map(ch =>
|
||||||
|
typeof ch === 'string' ? ch : ch.channel
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = newChannels.filter(ch => !oldChannels.includes(ch));
|
||||||
|
const removed = oldChannels.filter(ch => !newChannels.includes(ch));
|
||||||
|
|
||||||
|
if (added.length > 0) {
|
||||||
|
changes.push(`added channels: ${added.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (removed.length > 0) {
|
||||||
|
changes.push(`removed channels: ${removed.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for color/fill changes
|
||||||
|
const oldChannelConfigs = {};
|
||||||
|
(oldConfig.channels || []).forEach(ch => {
|
||||||
|
if (typeof ch === 'object') {
|
||||||
|
oldChannelConfigs[ch.channel] = ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const newChannelConfigs = {};
|
||||||
|
(newConfig.channels || []).forEach(ch => {
|
||||||
|
if (typeof ch === 'object') {
|
||||||
|
newChannelConfigs[ch.channel] = ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorChanges = [];
|
||||||
|
for (const ch of newChannels) {
|
||||||
|
const oldCh = oldChannelConfigs[ch] || {};
|
||||||
|
const newCh = newChannelConfigs[ch] || {};
|
||||||
|
if (oldCh.color !== newCh.color || oldCh.fillColor !== newCh.fillColor) {
|
||||||
|
colorChanges.push(ch.split(':').pop()); // Just the channel name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colorChanges.length > 0) {
|
||||||
|
changes.push(`colors changed for: ${colorChanges.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check order change
|
||||||
|
if (added.length === 0 && removed.length === 0 &&
|
||||||
|
JSON.stringify(oldChannels) !== JSON.stringify(newChannels)) {
|
||||||
|
changes.push('channel order changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeText = changes.length > 0
|
||||||
|
? `Updated view "${name}": ${changes.join('; ')}`
|
||||||
|
: `Updated view "${name}" (no significant changes)`;
|
||||||
|
global.insertChangelog(req.user.username, changeText);
|
||||||
|
|
||||||
res.json({ id: req.params.id, name, config });
|
res.json({ id: req.params.id, name, config });
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ error: 'View not found' });
|
res.status(404).json({ error: 'View not found' });
|
||||||
@@ -910,6 +985,12 @@ module.exports = {
|
|||||||
app.put('/api/rules/:id', requireAdmin, (req, res) => {
|
app.put('/api/rules/:id', requireAdmin, (req, res) => {
|
||||||
const { name, type, enabled, conditions, action } = req.body;
|
const { name, type, enabled, conditions, action } = req.body;
|
||||||
try {
|
try {
|
||||||
|
// Get old rule for comparison
|
||||||
|
const oldRule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id);
|
||||||
|
if (!oldRule) {
|
||||||
|
return res.status(404).json({ error: 'Rule not found' });
|
||||||
|
}
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
|
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -922,9 +1003,46 @@ module.exports = {
|
|||||||
JSON.stringify(action),
|
JSON.stringify(action),
|
||||||
req.params.id
|
req.params.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (info.changes > 0) {
|
if (info.changes > 0) {
|
||||||
runRules(); // Trigger rules immediately
|
runRules(); // Trigger rules immediately
|
||||||
global.insertChangelog(req.user?.username || 'admin', `Updated rule "${name}" (ID: ${req.params.id})`);
|
|
||||||
|
// Build detailed changelog
|
||||||
|
const changes = [];
|
||||||
|
if (oldRule.name !== name) {
|
||||||
|
changes.push(`name: "${oldRule.name}" → "${name}"`);
|
||||||
|
}
|
||||||
|
if (!!oldRule.enabled !== !!enabled) {
|
||||||
|
changes.push(`enabled: ${oldRule.enabled ? 'on' : 'off'} → ${enabled ? 'on' : 'off'}`);
|
||||||
|
}
|
||||||
|
const oldConditions = oldRule.conditions || '{}';
|
||||||
|
const newConditions = JSON.stringify(conditions);
|
||||||
|
if (oldConditions !== newConditions) {
|
||||||
|
changes.push('conditions changed');
|
||||||
|
}
|
||||||
|
const oldAction = oldRule.action || '{}';
|
||||||
|
const newAction = JSON.stringify(action);
|
||||||
|
if (oldAction !== newAction) {
|
||||||
|
// Parse to show what changed in action
|
||||||
|
try {
|
||||||
|
const oldA = JSON.parse(oldAction);
|
||||||
|
const newA = action;
|
||||||
|
if (oldA.channel !== newA.channel) {
|
||||||
|
changes.push(`action channel: ${oldA.channel} → ${newA.channel}`);
|
||||||
|
}
|
||||||
|
if (JSON.stringify(oldA.value) !== JSON.stringify(newA.value)) {
|
||||||
|
changes.push(`action value: ${JSON.stringify(oldA.value)} → ${JSON.stringify(newA.value)}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
changes.push('action changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeText = changes.length > 0
|
||||||
|
? `Updated rule "${name}": ${changes.join(', ')}`
|
||||||
|
: `Updated rule "${name}" (no changes)`;
|
||||||
|
global.insertChangelog(req.user?.username || 'admin', changeText);
|
||||||
|
|
||||||
res.json({ id: req.params.id, name, type, enabled, conditions, action });
|
res.json({ id: req.params.id, name, type, enabled, conditions, action });
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ error: 'Rule not found' });
|
res.status(404).json({ error: 'Rule not found' });
|
||||||
|
|||||||
Reference in New Issue
Block a user