sync rules
This commit is contained in:
@@ -1,68 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Divider
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import RuleCard from './RuleCard';
|
||||
import RuleEditor from './RuleEditor';
|
||||
|
||||
// Initial mock rules for demonstration
|
||||
const initialRules = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Morning Light',
|
||||
enabled: true,
|
||||
trigger: {
|
||||
type: 'time',
|
||||
time: '06:30',
|
||||
days: ['mon', 'tue', 'wed', 'thu', 'fri']
|
||||
},
|
||||
action: {
|
||||
type: 'toggle',
|
||||
target: 'Workshop Light',
|
||||
state: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'High Humidity Fan',
|
||||
enabled: true,
|
||||
trigger: {
|
||||
type: 'sensor',
|
||||
sensor: 'Humidity',
|
||||
operator: '>',
|
||||
value: 70
|
||||
},
|
||||
action: {
|
||||
type: 'keepOn',
|
||||
target: 'Exhaust Fan',
|
||||
duration: 15
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Evening Shutdown',
|
||||
enabled: false,
|
||||
trigger: {
|
||||
type: 'time',
|
||||
time: '18:00',
|
||||
days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
},
|
||||
action: {
|
||||
type: 'toggle',
|
||||
target: 'All Outlets',
|
||||
state: false
|
||||
}
|
||||
}
|
||||
];
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export default function RuleManager() {
|
||||
const [rules, setRules] = useState(initialRules);
|
||||
const { isAdmin } = useAuth();
|
||||
const [rules, setRules] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState(null);
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Get auth token from localStorage
|
||||
const getAuthHeaders = useCallback(() => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch rules from server (public endpoint)
|
||||
const fetchRules = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('api/rules');
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch rules');
|
||||
}
|
||||
const data = await res.json();
|
||||
setRules(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch devices for sensor/output selection
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('api/devices');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDevices(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch devices:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules();
|
||||
fetchDevices();
|
||||
}, [fetchRules, fetchDevices]);
|
||||
|
||||
// Build available sensors
|
||||
// - Environment sensors (Temp, Humidity) are per DEVICE
|
||||
// - Port values (Fan Speed, Brightness, CO2, etc.) are per PORT
|
||||
const availableSensors = [];
|
||||
const seenDevices = new Set();
|
||||
|
||||
devices.forEach(d => {
|
||||
// Add environment sensors once per device
|
||||
if (!seenDevices.has(d.dev_name)) {
|
||||
seenDevices.add(d.dev_name);
|
||||
availableSensors.push({
|
||||
id: `${d.dev_name}:temp`,
|
||||
label: `${d.dev_name} - Temperature`,
|
||||
type: 'temperature'
|
||||
});
|
||||
availableSensors.push({
|
||||
id: `${d.dev_name}:humidity`,
|
||||
label: `${d.dev_name} - Humidity`,
|
||||
type: 'humidity'
|
||||
});
|
||||
}
|
||||
|
||||
// Add each port as a sensor (Fan Speed, Brightness, CO2, etc.)
|
||||
availableSensors.push({
|
||||
id: `${d.dev_name}:${d.port}:level`,
|
||||
label: `${d.dev_name} - ${d.port_name} Level`,
|
||||
type: d.port_name.toLowerCase()
|
||||
});
|
||||
});
|
||||
|
||||
// Build available outputs: Tapo plugs + device fans/lights
|
||||
const availableOutputs = [
|
||||
{ id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
|
||||
{ id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
|
||||
{ id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
|
||||
{ id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
|
||||
{ id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
|
||||
...devices
|
||||
.filter(d => d.port_name === 'Fan')
|
||||
.map(d => ({
|
||||
id: `${d.dev_name}:fan:${d.port}`,
|
||||
label: `${d.dev_name} - Fan`,
|
||||
type: 'fan'
|
||||
})),
|
||||
...devices
|
||||
.filter(d => d.port_name === 'Light')
|
||||
.map(d => ({
|
||||
id: `${d.dev_name}:light:${d.port}`,
|
||||
label: `${d.dev_name} - Light`,
|
||||
type: 'light'
|
||||
}))
|
||||
];
|
||||
|
||||
const handleAddRule = () => {
|
||||
setEditingRule(null);
|
||||
@@ -74,33 +129,76 @@ export default function RuleManager() {
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteRule = (ruleId) => {
|
||||
setRules(rules.filter(r => r.id !== ruleId));
|
||||
};
|
||||
const handleDeleteRule = async (ruleId) => {
|
||||
if (!confirm('Delete this rule?')) return;
|
||||
|
||||
const handleToggleRule = (ruleId) => {
|
||||
setRules(rules.map(r =>
|
||||
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
|
||||
));
|
||||
};
|
||||
|
||||
const handleSaveRule = (ruleData) => {
|
||||
if (editingRule) {
|
||||
// Update existing rule
|
||||
setRules(rules.map(r =>
|
||||
r.id === editingRule.id ? { ...r, ...ruleData } : r
|
||||
));
|
||||
} else {
|
||||
// Add new rule
|
||||
const newRule = {
|
||||
...ruleData,
|
||||
id: Math.max(0, ...rules.map(r => r.id)) + 1,
|
||||
enabled: true
|
||||
};
|
||||
setRules([...rules, newRule]);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`api/rules/${ruleId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete rule');
|
||||
setRules(rules.filter(r => r.id !== ruleId));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (ruleId) => {
|
||||
const rule = rules.find(r => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`api/rules/${ruleId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ ...rule, enabled: !rule.enabled })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update rule');
|
||||
const updated = await res.json();
|
||||
setRules(rules.map(r => r.id === ruleId ? updated : r));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRule = async (ruleData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingRule) {
|
||||
// Update existing rule
|
||||
const res = await fetch(`api/rules/${editingRule.id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(ruleData)
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update rule');
|
||||
const updated = await res.json();
|
||||
setRules(rules.map(r => r.id === editingRule.id ? updated : r));
|
||||
} else {
|
||||
// Create new rule
|
||||
const res = await fetch('api/rules', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ ...ruleData, enabled: true })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create rule');
|
||||
const newRule = await res.json();
|
||||
setRules([...rules, newRule]);
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setEditingRule(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setEditingRule(null);
|
||||
};
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
@@ -108,6 +206,15 @@ export default function RuleManager() {
|
||||
setEditingRule(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography sx={{ mt: 2 }}>Loading rules...</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
@@ -123,29 +230,38 @@ export default function RuleManager() {
|
||||
⚙️ Automation Rules
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configure triggers and actions for home automation
|
||||
{isAdmin ? 'Configure triggers and actions for home automation' : 'View automation rules (read-only)'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAddRule}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ Add Rule
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAddRule}
|
||||
disabled={saving}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ Add Rule
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
No rules configured. Click "Add Rule" to create one.
|
||||
No rules configured. {isAdmin && 'Click "Add Rule" to create one.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -154,20 +270,26 @@ export default function RuleManager() {
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onEdit={() => handleEditRule(rule)}
|
||||
onDelete={() => handleDeleteRule(rule.id)}
|
||||
onToggle={() => handleToggleRule(rule.id)}
|
||||
onEdit={isAdmin ? () => handleEditRule(rule) : null}
|
||||
onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
|
||||
onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
|
||||
readOnly={!isAdmin}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<RuleEditor
|
||||
open={editorOpen}
|
||||
rule={editingRule}
|
||||
onSave={handleSaveRule}
|
||||
onClose={handleCloseEditor}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<RuleEditor
|
||||
open={editorOpen}
|
||||
rule={editingRule}
|
||||
onSave={handleSaveRule}
|
||||
onClose={handleCloseEditor}
|
||||
sensors={availableSensors}
|
||||
outputs={availableOutputs}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user