u
This commit is contained in:
@@ -5,12 +5,14 @@ import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import ShowChartIcon from '@mui/icons-material/ShowChart';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import RuleIcon from '@mui/icons-material/Rule';
|
||||
|
||||
import Settings from './components/Settings';
|
||||
import Chart from './components/Chart';
|
||||
import Login from './components/Login';
|
||||
import ViewManager from './components/ViewManager';
|
||||
import ViewDisplay from './components/ViewDisplay';
|
||||
import RuleEditor from './components/RuleEditor';
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
@@ -98,6 +100,9 @@ export default class App extends Component {
|
||||
|
||||
<Button color="inherit" component={Link} to="/" startIcon={<DashboardIcon />}>Views</Button>
|
||||
<Button color="inherit" component={Link} to="/live" startIcon={<ShowChartIcon />}>Live</Button>
|
||||
{user && user.role === 'admin' && (
|
||||
<Button color="inherit" component={Link} to="/rules" startIcon={<RuleIcon />}>Rules</Button>
|
||||
)}
|
||||
<Button color="inherit" component={Link} to="/settings" startIcon={<SettingsIcon />}>Settings</Button>
|
||||
|
||||
{user ? (
|
||||
@@ -111,6 +116,7 @@ export default class App extends Component {
|
||||
<Routes>
|
||||
<Route path="/" element={<ViewManager user={user} />} />
|
||||
<Route path="/views/:id" element={<ViewDisplay />} />
|
||||
<Route path="/rules" element={<RuleEditor user={user} />} />
|
||||
<Route path="/live" element={
|
||||
<Chart
|
||||
selectedChannels={selectedChannels}
|
||||
|
||||
13
uiserver/src/components/Chart.js
vendored
13
uiserver/src/components/Chart.js
vendored
@@ -233,6 +233,7 @@ export default class Chart extends Component {
|
||||
let yAxisKey = 'left';
|
||||
let color = undefined;
|
||||
let fillColor = undefined;
|
||||
let fillOpacity = 0.5;
|
||||
if (channelConfig) {
|
||||
const item = channelConfig.find(c => c.id === id);
|
||||
if (item) {
|
||||
@@ -240,6 +241,7 @@ export default class Chart extends Component {
|
||||
if (item.yAxis) yAxisKey = item.yAxis;
|
||||
if (item.color) color = item.color;
|
||||
if (item.fillColor) fillColor = item.fillColor;
|
||||
if (item.fillOpacity !== undefined) fillOpacity = item.fillOpacity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,15 +253,10 @@ export default class Chart extends Component {
|
||||
yAxisKey: yAxisKey,
|
||||
};
|
||||
if (color) sObj.color = color;
|
||||
// Enable area fill if fillColor is set (with 50% transparency)
|
||||
// Enable area fill if fillColor is set (with configurable opacity)
|
||||
if (fillColor) {
|
||||
sObj.area = true;
|
||||
// Convert hex to rgba with 50% opacity
|
||||
const hex = fillColor.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
sObj.areaColor = `rgba(${r}, ${g}, ${b}, 0.5)`;
|
||||
sObj.fillOpacity = fillOpacity;
|
||||
}
|
||||
return sObj;
|
||||
});
|
||||
@@ -310,7 +307,7 @@ export default class Chart extends Component {
|
||||
strokeWidth: 3,
|
||||
},
|
||||
'& .MuiAreaElement-root': {
|
||||
fillOpacity: 0.5,
|
||||
fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
651
uiserver/src/components/RuleEditor.js
Normal file
651
uiserver/src/components/RuleEditor.js
Normal file
@@ -0,0 +1,651 @@
|
||||
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 (
|
||||
<Box sx={{
|
||||
pl: isRoot ? 0 : 2,
|
||||
borderLeft: isRoot ? 'none' : '2px solid',
|
||||
borderColor: group.operator === 'AND' ? '#83a598' : '#fabd2f',
|
||||
ml: isRoot ? 0 : 1,
|
||||
mb: 1
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={group.operator}
|
||||
size="small"
|
||||
color={group.operator === 'AND' ? 'primary' : 'warning'}
|
||||
onClick={this.isAdmin() ? () => this.toggleGroupOperator(path) : undefined}
|
||||
sx={{ cursor: this.isAdmin() ? 'pointer' : 'default' }}
|
||||
/>
|
||||
{this.isAdmin() && (
|
||||
<>
|
||||
<Button size="small" onClick={() => this.addCondition(path)}>+ Condition</Button>
|
||||
<Button size="small" onClick={() => this.addConditionGroup(path, 'AND')}>+ AND Group</Button>
|
||||
<Button size="small" onClick={() => this.addConditionGroup(path, 'OR')}>+ OR Group</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{group.conditions?.map((cond, idx) => {
|
||||
const condPath = [...path, idx];
|
||||
|
||||
// Nested group
|
||||
if (cond.operator === 'AND' || cond.operator === 'OR') {
|
||||
return (
|
||||
<Box key={idx} sx={{ mb: 1 }}>
|
||||
{this.renderConditionGroup(cond, condPath)}
|
||||
{this.isAdmin() && (
|
||||
<IconButton size="small" color="error" onClick={() => this.removeCondition(condPath)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Single condition
|
||||
return (
|
||||
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={cond.type || 'sensor'}
|
||||
onChange={e => this.updateCondition(condPath, { type: e.target.value, operator: CONDITION_OPERATORS[e.target.value][0].value })}
|
||||
disabled={!this.isAdmin()}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
<MenuItem value="time">Time</MenuItem>
|
||||
<MenuItem value="date">Date</MenuItem>
|
||||
<MenuItem value="sensor">Sensor</MenuItem>
|
||||
<MenuItem value="output">Output</MenuItem>
|
||||
</Select>
|
||||
|
||||
{(cond.type === 'sensor' || cond.type === 'output') && (
|
||||
<Select
|
||||
size="small"
|
||||
value={cond.channel || ''}
|
||||
onChange={e => this.updateCondition(condPath, { channel: e.target.value })}
|
||||
disabled={!this.isAdmin()}
|
||||
displayEmpty
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value=""><em>Select Channel</em></MenuItem>
|
||||
{(cond.type === 'sensor' ? sensorChannels : outputChannels.map(c => c.channel))
|
||||
.map(ch => <MenuItem key={ch} value={ch}>{ch}</MenuItem>)}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select
|
||||
size="small"
|
||||
value={cond.operator || '='}
|
||||
onChange={e => this.updateCondition(condPath, { operator: e.target.value })}
|
||||
disabled={!this.isAdmin()}
|
||||
sx={{ minWidth: 80 }}
|
||||
>
|
||||
{(CONDITION_OPERATORS[cond.type] || CONDITION_OPERATORS.sensor).map(op => (
|
||||
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{cond.operator === 'between' ? (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
type={cond.type === 'time' ? 'time' : 'date'}
|
||||
value={Array.isArray(cond.value) ? cond.value[0] : ''}
|
||||
onChange={e => this.updateCondition(condPath, { value: [e.target.value, (cond.value?.[1] || '')] })}
|
||||
disabled={!this.isAdmin()}
|
||||
sx={{ width: 140 }}
|
||||
/>
|
||||
<Typography>to</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
type={cond.type === 'time' ? 'time' : 'date'}
|
||||
value={Array.isArray(cond.value) ? cond.value[1] : ''}
|
||||
onChange={e => this.updateCondition(condPath, { value: [(cond.value?.[0] || ''), e.target.value] })}
|
||||
disabled={!this.isAdmin()}
|
||||
sx={{ width: 140 }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
type={cond.type === 'time' ? 'time' : (cond.type === 'date' ? 'date' : 'number')}
|
||||
value={cond.value ?? ''}
|
||||
onChange={e => 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() && (
|
||||
<IconButton size="small" color="error" onClick={() => this.removeCondition(condPath)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Paper sx={{ p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h5">
|
||||
<RuleIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Rule Editor
|
||||
</Typography>
|
||||
{isAdmin && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
|
||||
Create Rule
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Current Output Values */}
|
||||
<Paper sx={{ p: 2, mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>Current Output Values</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{outputChannels.map(ch => (
|
||||
<Chip
|
||||
key={ch.channel}
|
||||
label={`${ch.description}: ${outputValues[ch.channel] ?? 0}`}
|
||||
color={outputValues[ch.channel] > 0 ? 'success' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Rules List */}
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Rules (Priority Order)</Typography>
|
||||
<List>
|
||||
{rules.map((rule, idx) => (
|
||||
<ListItem
|
||||
key={rule.id}
|
||||
sx={{
|
||||
bgcolor: rule.enabled ? 'transparent' : 'rgba(0,0,0,0.2)',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
border: '1px solid #504945'
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{isAdmin ? (
|
||||
<IconButton onClick={() => this.toggleRuleEnabled(rule)}>
|
||||
{rule.enabled ? <PlayArrowIcon color="success" /> : <PauseIcon color="disabled" />}
|
||||
</IconButton>
|
||||
) : (
|
||||
rule.enabled ? <PlayArrowIcon color="success" /> : <PauseIcon color="disabled" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1">{rule.name}</Typography>
|
||||
<Chip size="small" label={rule.type || 'static'} />
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
When: {this.formatConditionSummary(rule.conditions)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Then: Set {rule.action?.channel} = {rule.action?.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton size="small" onClick={() => this.moveRule(idx, -1)} disabled={idx === 0}>
|
||||
<ArrowUpwardIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => this.moveRule(idx, 1)} disabled={idx === rules.length - 1}>
|
||||
<ArrowDownwardIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={(e) => this.handleOpenEdit(rule, e)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton color="error" onClick={(e) => this.handleDelete(rule.id, e)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
{rules.length === 0 && (
|
||||
<Typography color="text.secondary" sx={{ p: 2 }}>
|
||||
No rules defined. {isAdmin ? 'Click "Create Rule" to add one.' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
{/* Edit/Create Dialog */}
|
||||
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{editingId ? 'Edit Rule' : 'Create New Rule'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Rule Name"
|
||||
value={ruleName}
|
||||
onChange={e => this.setState({ ruleName: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={ruleEnabled}
|
||||
onChange={e => this.setState({ ruleEnabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle1" gutterBottom>Conditions (When)</Typography>
|
||||
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1, mb: 2 }}>
|
||||
{this.renderConditionGroup(conditions)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle1" gutterBottom>Action (Then)</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Typography>Set</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={action.channel}
|
||||
onChange={e => this.setState({ action: { ...action, channel: e.target.value } })}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
{outputChannels.map(ch => (
|
||||
<MenuItem key={ch.channel} value={ch.channel}>
|
||||
{ch.description} ({ch.channel})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Typography>to</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={action.value}
|
||||
onChange={e => this.setState({ action: { ...action, value: parseFloat(e.target.value) || 0 } })}
|
||||
inputProps={{
|
||||
min: outputChannels.find(c => c.channel === action.channel)?.min || 0,
|
||||
max: outputChannels.find(c => c.channel === action.channel)?.max || 10
|
||||
}}
|
||||
sx={{ width: 100 }}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => this.setState({ open: false })}>Cancel</Button>
|
||||
<Button onClick={this.handleSave} variant="contained">Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RuleEditor;
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
|
||||
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton,
|
||||
ToggleButton, ToggleButtonGroup
|
||||
ToggleButton, ToggleButtonGroup, Slider
|
||||
} from '@mui/material';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
@@ -42,6 +42,8 @@ class ViewManager extends Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
views: [],
|
||||
rules: [],
|
||||
outputValues: {},
|
||||
open: false,
|
||||
colorPickerOpen: false,
|
||||
colorPickerMode: 'line',
|
||||
@@ -69,6 +71,13 @@ class ViewManager extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
this.refreshViews();
|
||||
this.loadRules();
|
||||
this.loadOutputValues();
|
||||
// Refresh rules and outputs every 30s
|
||||
this.rulesInterval = setInterval(() => {
|
||||
this.loadRules();
|
||||
this.loadOutputValues();
|
||||
}, 30000);
|
||||
if (this.isAdmin()) {
|
||||
fetch('/api/devices')
|
||||
.then(res => res.json())
|
||||
@@ -77,6 +86,10 @@ class ViewManager extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.rulesInterval) clearInterval(this.rulesInterval);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.user !== this.props.user) {
|
||||
this.refreshViews();
|
||||
@@ -101,6 +114,20 @@ class ViewManager extends Component {
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
loadRules = () => {
|
||||
fetch('/api/rules')
|
||||
.then(res => res.json())
|
||||
.then(rules => this.setState({ rules }))
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
loadOutputValues = () => {
|
||||
fetch('/api/outputs/values')
|
||||
.then(res => res.json())
|
||||
.then(outputValues => this.setState({ outputValues }))
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
parseViewData(view) {
|
||||
let channels = [];
|
||||
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
|
||||
@@ -131,6 +158,73 @@ class ViewManager extends Component {
|
||||
return { channels, axes };
|
||||
}
|
||||
|
||||
// Emoji for rule based on action channel
|
||||
getRuleEmoji = (rule) => {
|
||||
const channel = rule.action?.channel || '';
|
||||
const emojis = {
|
||||
'CircFanLevel': '🌀',
|
||||
'TentLightLevel': '💡',
|
||||
'TentExhaustLevel': '💨',
|
||||
'RoomExhaustLevel': '🌬️',
|
||||
'CO2Valve': '🫧',
|
||||
'BigDehumid': '💧',
|
||||
'TentDehumid': '💦'
|
||||
};
|
||||
return emojis[channel] || '⚡';
|
||||
};
|
||||
|
||||
// Format conditions for display
|
||||
formatRuleConditions = (condition) => {
|
||||
if (!condition) return '(always)';
|
||||
|
||||
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 { type, channel, operator, value } = condition;
|
||||
const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
|
||||
const op = opSymbols[operator] || operator;
|
||||
|
||||
switch (type) {
|
||||
case 'time':
|
||||
if (operator === 'between' && Array.isArray(value)) {
|
||||
return `🕐 ${value[0]} - ${value[1]}`;
|
||||
}
|
||||
return `🕐 ${op} ${value}`;
|
||||
case 'date':
|
||||
if (operator === 'between' && Array.isArray(value)) {
|
||||
return `📅 ${value[0]} to ${value[1]}`;
|
||||
}
|
||||
return `📅 ${operator} ${value}`;
|
||||
case 'sensor':
|
||||
const sensorName = channel?.split(':').pop() || channel;
|
||||
return `📡 ${sensorName} ${op} ${value}`;
|
||||
case 'output':
|
||||
return `⚙️ ${channel} ${op} ${value}`;
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
};
|
||||
|
||||
// Format action for display
|
||||
formatRuleAction = (action) => {
|
||||
if (!action?.channel) return '?';
|
||||
const channelNames = {
|
||||
'CircFanLevel': '🌀 Circ Fan',
|
||||
'TentLightLevel': '💡 Tent Light',
|
||||
'TentExhaustLevel': '💨 Tent Exhaust',
|
||||
'RoomExhaustLevel': '🌬️ Room Exhaust',
|
||||
'CO2Valve': '🫧 CO2',
|
||||
'BigDehumid': '💧 Big Dehumid',
|
||||
'TentDehumid': '💦 Tent Dehumid'
|
||||
};
|
||||
const name = channelNames[action.channel] || action.channel;
|
||||
return `${name} = ${action.value}`;
|
||||
};
|
||||
|
||||
getNextColor(idx) {
|
||||
return GRUVBOX_COLORS[idx % GRUVBOX_COLORS.length];
|
||||
}
|
||||
@@ -305,6 +399,17 @@ class ViewManager extends Component {
|
||||
clearFillColor = (idx) => {
|
||||
const newConfig = [...this.state.viewConfig];
|
||||
delete newConfig[idx].fillColor;
|
||||
delete newConfig[idx].fillOpacity;
|
||||
this.setState({ viewConfig: newConfig });
|
||||
};
|
||||
|
||||
updateFillOpacity = (idx, value) => {
|
||||
const newConfig = this.state.viewConfig.map((ch, i) => {
|
||||
if (i === idx) {
|
||||
return { ...ch, fillOpacity: value };
|
||||
}
|
||||
return ch;
|
||||
});
|
||||
this.setState({ viewConfig: newConfig });
|
||||
};
|
||||
|
||||
@@ -391,7 +496,8 @@ class ViewManager extends Component {
|
||||
alias: c.alias,
|
||||
yAxis: c.yAxis || 'left',
|
||||
color: c.color,
|
||||
fillColor: c.fillColor
|
||||
fillColor: c.fillColor,
|
||||
fillOpacity: c.fillOpacity
|
||||
}))}
|
||||
axisConfig={axes}
|
||||
windowEnd={windowEnd}
|
||||
@@ -402,6 +508,47 @@ class ViewManager extends Component {
|
||||
);
|
||||
})}
|
||||
{views.length === 0 && <Typography>No views available.</Typography>}
|
||||
|
||||
{/* Rules Summary */}
|
||||
{this.state.rules.length > 0 && (
|
||||
<Paper sx={{ p: 2, mt: 4 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>🤖 Active Rules</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{this.state.rules.filter(r => r.enabled).map((rule, idx) => (
|
||||
<Box
|
||||
key={rule.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #504945',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '1.2em' }}>
|
||||
{this.getRuleEmoji(rule)}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{rule.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{this.formatRuleConditions(rule.conditions)} → {this.formatRuleAction(rule.action)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.85em', color: 'text.secondary' }}>
|
||||
#{idx + 1}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, textAlign: 'center' }}>
|
||||
📊 Current outputs: {Object.entries(this.state.outputValues).filter(([k, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(', ') || 'all off'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
||||
@@ -440,9 +587,21 @@ class ViewManager extends Component {
|
||||
<Box sx={{ width: 20, height: 20, bgcolor: ch.fillColor || 'transparent', borderRadius: '50%', border: ch.fillColor ? '2px solid #fff' : '2px dashed #666' }} />
|
||||
</IconButton>
|
||||
{ch.fillColor && (
|
||||
<IconButton size="small" onClick={() => this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}>
|
||||
<DeleteIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
<>
|
||||
<Slider
|
||||
size="small"
|
||||
value={ch.fillOpacity ?? 0.5}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onChange={(e, val) => this.updateFillOpacity(idx, val)}
|
||||
sx={{ width: 60, ml: 1 }}
|
||||
title="Fill opacity"
|
||||
/>
|
||||
<IconButton size="small" onClick={() => this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}>
|
||||
<DeleteIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<ListItemText
|
||||
primary={ch.alias}
|
||||
|
||||
Reference in New Issue
Block a user