import React, { Component } from 'react';
import {
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
FormControl, InputLabel, Select, MenuItem, Box, IconButton, Switch,
FormControlLabel, Chip, Divider, Tooltip
} from '@mui/material';
import RuleIcon from '@mui/icons-material/Rule';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
// Condition operators by type
const CONDITION_OPERATORS = {
time: [
{ value: 'between', label: 'Between' },
{ value: '=', label: '=' },
{ value: '<', label: '<' },
{ value: '>', label: '>' }
],
date: [
{ value: 'before', label: 'Before' },
{ value: 'after', label: 'After' },
{ value: 'between', label: 'Between' }
],
sensor: [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
{ value: '<', label: '<' },
{ value: '>', label: '>' },
{ value: '<=', label: '<=' },
{ value: '>=', label: '>=' }
],
output: [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
{ value: '<', label: '<' },
{ value: '>', label: '>' },
{ value: '<=', label: '<=' },
{ value: '>=', label: '>=' }
]
};
class RuleEditor extends Component {
constructor(props) {
super(props);
this.state = {
rules: [],
outputChannels: [],
devices: [],
outputValues: {},
// Dialog state
open: false,
editingId: null,
ruleName: '',
ruleEnabled: true,
conditions: { operator: 'AND', conditions: [] },
action: { channel: '', value: 0 }
};
}
componentDidMount() {
this.refreshRules();
this.loadOutputChannels();
this.loadDevices();
this.loadOutputValues();
// Refresh output values every 10s
this.refreshInterval = setInterval(() => this.loadOutputValues(), 10000);
}
componentWillUnmount() {
if (this.refreshInterval) clearInterval(this.refreshInterval);
}
isAdmin() {
const { user } = this.props;
return user && user.role === 'admin';
}
refreshRules = () => {
fetch('/api/rules')
.then(res => res.json())
.then(rules => this.setState({ rules }))
.catch(console.error);
};
loadOutputChannels = () => {
fetch('/api/outputs')
.then(res => res.json())
.then(outputChannels => this.setState({ outputChannels }))
.catch(console.error);
};
loadDevices = () => {
fetch('/api/devices')
.then(res => res.json())
.then(devices => this.setState({ devices }))
.catch(console.error);
};
loadOutputValues = () => {
fetch('/api/outputs/values')
.then(res => res.json())
.then(outputValues => this.setState({ outputValues }))
.catch(console.error);
};
// Dialog handlers
handleOpenCreate = () => {
this.setState({
editingId: null,
ruleName: '',
ruleEnabled: true,
conditions: { operator: 'AND', conditions: [] },
action: { channel: this.state.outputChannels[0]?.channel || '', value: 0 },
open: true
});
};
handleOpenEdit = (rule, e) => {
e.stopPropagation();
this.setState({
editingId: rule.id,
ruleName: rule.name,
ruleEnabled: !!rule.enabled,
conditions: rule.conditions || { operator: 'AND', conditions: [] },
action: rule.action || { channel: '', value: 0 },
open: true
});
};
handleDelete = async (id, e) => {
e.stopPropagation();
if (!window.confirm("Delete this rule?")) return;
const { user } = this.props;
await fetch(`/api/rules/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${user.token}` }
});
this.refreshRules();
};
moveRule = async (idx, dir) => {
const newRules = [...this.state.rules];
const target = idx + dir;
if (target < 0 || target >= newRules.length) return;
[newRules[idx], newRules[target]] = [newRules[target], newRules[idx]];
this.setState({ rules: newRules });
const order = newRules.map((r, i) => ({ id: r.id, position: i }));
const { user } = this.props;
try {
await fetch('/api/rules/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({ order })
});
} catch (err) {
console.error("Failed to save order", err);
}
};
handleSave = async () => {
const { ruleName, ruleEnabled, conditions, action, editingId } = this.state;
const { user } = this.props;
if (!ruleName || !action.channel) {
alert('Please fill in all required fields');
return;
}
const url = editingId ? `/api/rules/${editingId}` : '/api/rules';
const method = editingId ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({
name: ruleName,
enabled: ruleEnabled,
conditions,
action
})
});
if (res.ok) {
this.setState({ open: false });
this.refreshRules();
} else {
const err = await res.json();
alert('Failed to save rule: ' + err.error);
}
} catch (err) {
console.error(err);
}
};
toggleRuleEnabled = async (rule) => {
const { user } = this.props;
try {
await fetch(`/api/rules/${rule.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({
...rule,
enabled: !rule.enabled
})
});
this.refreshRules();
} catch (err) {
console.error(err);
}
};
// Condition editing
addCondition = (parentPath = []) => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (const idx of parentPath) {
target = target.conditions[idx];
}
target.conditions.push({
type: 'sensor',
operator: '>',
channel: '',
value: 0
});
return { conditions: newConditions };
});
};
addConditionGroup = (parentPath = [], groupType = 'AND') => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (const idx of parentPath) {
target = target.conditions[idx];
}
target.conditions.push({
operator: groupType,
conditions: []
});
return { conditions: newConditions };
});
};
updateCondition = (path, updates) => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (let i = 0; i < path.length - 1; i++) {
target = target.conditions[path[i]];
}
const idx = path[path.length - 1];
target.conditions[idx] = { ...target.conditions[idx], ...updates };
return { conditions: newConditions };
});
};
removeCondition = (path) => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (let i = 0; i < path.length - 1; i++) {
target = target.conditions[path[i]];
}
const idx = path[path.length - 1];
target.conditions.splice(idx, 1);
return { conditions: newConditions };
});
};
toggleGroupOperator = (path) => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (const idx of path) {
target = target.conditions[idx];
}
if (path.length === 0) {
// Root level
newConditions.operator = newConditions.operator === 'AND' ? 'OR' : 'AND';
} else {
target.operator = target.operator === 'AND' ? 'OR' : 'AND';
}
return { conditions: newConditions };
});
};
// Render a condition group recursively
renderConditionGroup = (group, path = []) => {
const { devices, outputChannels } = this.state;
const isRoot = path.length === 0;
// Build sensor channels list
const sensorChannels = devices.map(d => `${d.device}:${d.channel}`);
return (
this.toggleGroupOperator(path) : undefined}
sx={{ cursor: this.isAdmin() ? 'pointer' : 'default' }}
/>
{this.isAdmin() && (
<>
>
)}
{group.conditions?.map((cond, idx) => {
const condPath = [...path, idx];
// Nested group
if (cond.operator === 'AND' || cond.operator === 'OR') {
return (
{this.renderConditionGroup(cond, condPath)}
{this.isAdmin() && (
this.removeCondition(condPath)}>
)}
);
}
// Single condition
return (
{(cond.type === 'sensor' || cond.type === 'output') && (
)}
{cond.operator === 'between' ? (
<>
this.updateCondition(condPath, { value: [e.target.value, (cond.value?.[1] || '')] })}
disabled={!this.isAdmin()}
sx={{ width: 140 }}
/>
to
this.updateCondition(condPath, { value: [(cond.value?.[0] || ''), e.target.value] })}
disabled={!this.isAdmin()}
sx={{ width: 140 }}
/>
>
) : (
this.updateCondition(condPath, {
value: cond.type === 'sensor' || cond.type === 'output'
? parseFloat(e.target.value) || 0
: e.target.value
})}
disabled={!this.isAdmin()}
sx={{ width: 140 }}
/>
)}
{this.isAdmin() && (
this.removeCondition(condPath)}>
)}
);
})}
);
};
formatConditionSummary = (condition) => {
if (!condition) return '';
if (condition.operator === 'AND' || condition.operator === 'OR') {
const parts = (condition.conditions || []).map(c => this.formatConditionSummary(c)).filter(Boolean);
return parts.length > 0 ? `(${parts.join(` ${condition.operator} `)})` : '';
}
const { type, channel, operator, value } = condition;
let formatted = '';
switch (type) {
case 'time':
formatted = operator === 'between'
? `${value?.[0] || '?'} - ${value?.[1] || '?'}`
: `time ${operator} ${value}`;
break;
case 'date':
formatted = operator === 'between'
? `date ${value?.[0] || '?'} to ${value?.[1] || '?'}`
: `date ${operator} ${value}`;
break;
case 'sensor':
case 'output':
formatted = `${channel || '?'} ${operator} ${value}`;
break;
default:
formatted = JSON.stringify(condition);
}
return formatted;
};
render() {
const { rules, outputChannels, outputValues, open, editingId, ruleName, ruleEnabled, conditions, action } = this.state;
const isAdmin = this.isAdmin();
return (
Rule Editor
{isAdmin && (
} onClick={this.handleOpenCreate}>
Create Rule
)}
{/* Current Output Values */}
Current Output Values
{outputChannels.map(ch => (
0 ? 'success' : 'default'}
variant="outlined"
/>
))}
{/* Rules List */}
Rules (Priority Order)
{rules.map((rule, idx) => (
{isAdmin ? (
this.toggleRuleEnabled(rule)}>
{rule.enabled ? : }
) : (
rule.enabled ? :
)}
{rule.name}
}
secondary={
When: {this.formatConditionSummary(rule.conditions)}
Then: Set {rule.action?.channel} = {rule.action?.value}
}
/>
{isAdmin && (
this.moveRule(idx, -1)} disabled={idx === 0}>
this.moveRule(idx, 1)} disabled={idx === rules.length - 1}>
this.handleOpenEdit(rule, e)}>
this.handleDelete(rule.id, e)}>
)}
))}
{rules.length === 0 && (
No rules defined. {isAdmin ? 'Click "Create Rule" to add one.' : ''}
)}
{/* Edit/Create Dialog */}
);
}
}
export default RuleEditor;