sync rules

This commit is contained in:
sebseb7
2025-12-21 02:28:37 +01:00
parent f8a83efb39
commit 096fc2aa72
6 changed files with 776 additions and 333 deletions

View File

@@ -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>
);
}