This commit is contained in:
sebseb7
2025-12-26 00:32:04 +01:00
parent 93e3baa1c5
commit 94a435c6f6
5 changed files with 527 additions and 33 deletions

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TischlerCtrl UI</title>
<title>CTRL Freak</title>
<style>
body {
margin: 0;

View File

@@ -95,7 +95,7 @@ export default class App extends Component {
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
TischlerCtrl
CTRL Freak
</Typography>
<Button color="inherit" component={Link} to="/" startIcon={<DashboardIcon />}>Views</Button>

View File

@@ -1,9 +1,112 @@
import React, { Component } from 'react';
import { Box, Paper, Typography, CircularProgress, IconButton } from '@mui/material';
import { LineChart } from '@mui/x-charts/LineChart';
import { useDrawingArea, useYScale, useXScale } from '@mui/x-charts/hooks';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
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 {
constructor(props) {
super(props);
@@ -115,9 +218,14 @@ export default class Chart extends Component {
// Calculate effective end
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) {
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
@@ -169,7 +277,19 @@ export default class Chart extends Component {
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 => {
console.error(err);
@@ -305,6 +425,32 @@ export default class Chart extends Component {
const axisEnd = windowEnd ? windowEnd.getTime() : Date.now();
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 (
<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' }}>
@@ -367,7 +513,20 @@ export default class Chart extends Component {
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>
</Paper>
</Box>

View File

@@ -24,6 +24,14 @@ const RANGES = {
'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 = [
'#cc241d', '#fb4934', // Red
'#98971a', '#b8bb26', // Green
@@ -74,6 +82,7 @@ class ViewManager extends Component {
this.refreshViews();
this.loadRules();
this.loadOutputValues();
this.loadRuleStatus(); // Load immediately on mount
// Refresh rules and outputs every 30s
this.rulesInterval = setInterval(() => {
this.loadRules();
@@ -174,48 +183,122 @@ class ViewManager extends Component {
'CircFanLevel': '🌀',
'CO2Valve': '🫧',
'BigDehumid': '💧',
'TentExhaust': '💨'
'TentExhaust': '💨',
'RoomExhaust': '🌬️'
};
return emojis[channel] || '⚡';
};
// Format conditions for display
formatRuleConditions = (condition) => {
if (!condition) return '(always)';
// Format conditions for display - returns React components with visual grouping
formatRuleConditions = (condition, depth = 0) => {
if (!condition) return <span style={{ color: '#888' }}>(always)</span>;
if (condition.operator === 'AND' || condition.operator === 'OR') {
const parts = (condition.conditions || []).map(c => this.formatRuleConditions(c)).filter(Boolean);
if (parts.length === 0) return '(always)';
const sep = condition.operator === 'AND' ? ' & ' : ' | ';
return parts.join(sep);
const parts = (condition.conditions || []).map((c, i) => this.formatRuleConditions(c, depth + 1)).filter(Boolean);
if (parts.length === 0) return <span style={{ color: '#888' }}>(always)</span>;
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 opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
const op = opSymbols[operator] || operator;
let text = '?';
switch (type) {
case 'time':
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':
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':
// Show device:channel for clarity
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':
return `⚙️ ${channel} ${op} ${value}`;
text = `⚙️ ${channel} ${op} ${value}`;
break;
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
@@ -225,7 +308,8 @@ class ViewManager extends Component {
'CircFanLevel': '🌀 Circ Fan',
'CO2Valve': '🫧 CO2',
'BigDehumid': '💧 Big Dehumid',
'TentExhaust': '💨 Tent Exhaust Fan'
'TentExhaust': '💨 Tent Exhaust Fan',
'RoomExhaust': '🌬️ Room Exhaust'
};
const name = channelNames[action.channel] || action.channel;
@@ -414,6 +498,14 @@ class ViewManager extends Component {
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) => {
const newConfig = this.state.viewConfig.map((ch, i) => {
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() {
const {
views, open, editingId, viewName, availableDevices,
@@ -464,14 +595,71 @@ class ViewManager extends Component {
return (
<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 }}>
<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>)}
</ToggleButtonGroup>
<Box>
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></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>
<Typography variant="h6">{dateDisplay}</Typography>
@@ -508,7 +696,8 @@ class ViewManager extends Component {
yAxis: c.yAxis || 'left',
color: c.color,
fillColor: c.fillColor,
fillOpacity: c.fillOpacity
fillOpacity: c.fillOpacity,
sma: c.sma || 0
}))}
axisConfig={axes}
windowEnd={windowEnd}
@@ -563,6 +752,9 @@ class ViewManager extends Component {
)}
</Box>
{/* Scroll space at end of page */}
<Box sx={{ height: 200 }} />
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
<DialogContent>
@@ -615,11 +807,36 @@ class ViewManager extends Component {
</IconButton>
</>
)}
<ListItemText
primary={ch.alias}
secondary={`${ch.device}:${ch.channel} (${ch.yAxis})`}
sx={{ ml: 1 }}
<Select
size="small"
value={ch.sma || 0}
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 === viewConfig.length - 1}><ArrowDownwardIcon /></IconButton>
<IconButton size="small" color="error" onClick={() => this.removeChannel(idx)}><DeleteIcon /></IconButton>

View File

@@ -52,6 +52,7 @@ const OUTPUT_BINDINGS = {
'CO2Valve': { device: 'tapo', channel: 'c', type: 'switch' },
'TentExhaust': { device: 'tapo', channel: 'fantent', type: 'switch' },
'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: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' },
{ 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
@@ -740,10 +742,83 @@ module.exports = {
app.put('/api/views/:id', requireAdmin, (req, res) => {
const { name, config } = req.body;
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 info = stmt.run(name, JSON.stringify(config), req.params.id);
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 });
} else {
res.status(404).json({ error: 'View not found' });
@@ -910,6 +985,12 @@ module.exports = {
app.put('/api/rules/:id', requireAdmin, (req, res) => {
const { name, type, enabled, conditions, action } = req.body;
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(`
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
WHERE id = ?
@@ -922,9 +1003,46 @@ module.exports = {
JSON.stringify(action),
req.params.id
);
if (info.changes > 0) {
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 });
} else {
res.status(404).json({ error: 'Rule not found' });