diff --git a/manage-users.js b/manage-users.js index 8f34cc7..5166945 100644 --- a/manage-users.js +++ b/manage-users.js @@ -23,9 +23,9 @@ const updateUserRole = db.prepare('UPDATE users SET role = ? WHERE id = ?'); const updateUserPassword = db.prepare('UPDATE users SET password_hash = ? WHERE id = ?'); const deleteUser = db.prepare('DELETE FROM users WHERE id = ?'); -console.log('\n╔════════════════════════════════════╗'); -console.log('║ 🔐 User Manager - AC Dashboard ║'); -console.log('╚════════════════════════════════════╝\n'); +console.log('\n╔══════════════════════════════════════╗'); +console.log('║ 🔐 User Manager - AC Dashboard ║'); +console.log('╚══════════════════════════════════════╝\n'); async function listUsers() { const users = getAllUsers.all(); diff --git a/server.js b/server.js index 073b846..734b5fc 100644 --- a/server.js +++ b/server.js @@ -65,6 +65,20 @@ db.exec(` ) `); +db.exec(` + CREATE TABLE IF NOT EXISTS rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + trigger_type TEXT NOT NULL, + trigger_data TEXT NOT NULL, + action_type TEXT NOT NULL, + action_data TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + const insertStmt = db.prepare(` INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -72,6 +86,19 @@ const insertStmt = db.prepare(` const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?'); +// Rules prepared statements +const getAllRules = db.prepare('SELECT * FROM rules ORDER BY id'); +const getRuleById = db.prepare('SELECT * FROM rules WHERE id = ?'); +const insertRule = db.prepare(` + INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data) + VALUES (?, ?, ?, ?, ?, ?) +`); +const updateRule = db.prepare(` + UPDATE rules SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? +`); +const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?'); + // --- AC INFINITY API LOGIC --- let token = null; @@ -269,6 +296,137 @@ app.get('/api/auth/me', (req, res) => { } }); +// --- AUTH MIDDLEWARE --- +function optionalAuth(req, res, next) { + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + try { + const token = authHeader.split(' ')[1]; + req.user = jwt.verify(token, JWT_SECRET); + } catch (e) { + req.user = null; + } + } + next(); +} + +function requireAuth(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + try { + const token = authHeader.split(' ')[1]; + req.user = jwt.verify(token, JWT_SECRET); + next(); + } catch (e) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +function requireAdmin(req, res, next) { + if (!req.user || req.user.role !== 'admin') { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); +} + +// --- RULES API --- +// Helper to format rule for API response +function formatRule(row) { + const trigger = JSON.parse(row.trigger_data); + const action = JSON.parse(row.action_data); + + // Add type back for legacy/action compatibility + if (action.type === undefined && row.action_type) { + action.type = row.action_type; + } + + return { + id: row.id, + name: row.name, + enabled: row.enabled === 1, + trigger, + action + }; +} + +// GET /api/rules - public (guests can view) +app.get('/api/rules', (req, res) => { + try { + const rows = getAllRules.all(); + res.json(rows.map(formatRule)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// POST /api/rules - admin only +app.post('/api/rules', requireAuth, requireAdmin, (req, res) => { + try { + const { name, enabled, trigger, action } = req.body; + if (!name || !trigger || !action) { + return res.status(400).json({ error: 'name, trigger, and action required' }); + } + + // Determine trigger type for storage (combined, time, sensor) + let triggerType = 'combined'; + if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time'; + if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor'; + + const triggerData = JSON.stringify(trigger); + const actionData = JSON.stringify(action); + const result = insertRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData); + + const newRule = getRuleById.get(result.lastInsertRowid); + res.status(201).json(formatRule(newRule)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// PUT /api/rules/:id - admin only +app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => { + try { + const { id } = req.params; + const existing = getRuleById.get(id); + if (!existing) { + return res.status(404).json({ error: 'Rule not found' }); + } + + const { name, enabled, trigger, action } = req.body; + + // Determine trigger type for storage + let triggerType = 'combined'; + if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time'; + if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor'; + + const triggerData = JSON.stringify(trigger); + const actionData = JSON.stringify(action); + updateRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, id); + + const updated = getRuleById.get(id); + res.json(formatRule(updated)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// DELETE /api/rules/:id - admin only +app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => { + try { + const { id } = req.params; + const existing = getRuleById.get(id); + if (!existing) { + return res.status(404).json({ error: 'Rule not found' }); + } + deleteRule.run(id); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // API: Devices app.get('/api/devices', (req, res) => { try { diff --git a/src/client/App.js b/src/client/App.js index fcf2d22..5e141ff 100644 --- a/src/client/App.js +++ b/src/client/App.js @@ -104,8 +104,8 @@ function AppContent() { {/* Dashboard is always visible to everyone */} - {/* Rule Manager only visible to logged-in admins */} - {isAdmin && } + {/* Rule Manager visible to everyone (guests read-only, admins can edit) */} + {/* Login dialog - shown on demand */} diff --git a/src/client/RuleCard.js b/src/client/RuleCard.js index 522c0ce..647cfb0 100644 --- a/src/client/RuleCard.js +++ b/src/client/RuleCard.js @@ -13,70 +13,97 @@ import { const EditIcon = () => ✏️; const DeleteIcon = () => 🗑️; -const dayLabels = { - mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S' -}; - +const dayLabels = { mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S' }; const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; function TriggerSummary({ trigger }) { - if (trigger.type === 'time') { + const parts = []; + + // Time range + if (trigger.timeRange) { + const { start, end, days } = trigger.timeRange; + const isEveryDay = days?.length === 7; + const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d)); + let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' : + dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join(''); + + parts.push( + + + + {start}–{end} ({dayText}) + + + ); + } + + // Sensor conditions + if (trigger.sensors && trigger.sensors.length > 0) { + const logic = trigger.sensorLogic || 'and'; + const sensorText = trigger.sensors.map((s, i) => ( + + {i > 0 && } + {s.sensorLabel || s.sensor} {s.operator} {s.value} + + )); + + parts.push( + + + + {sensorText} + + + ); + } + + // Legacy support for old trigger format + if (trigger.type === 'time' && !trigger.timeRange) { const days = trigger.days || []; const isEveryDay = days.length === 7; - const isWeekdays = days.length === 5 && - ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d)); + const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d)); + let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' : + dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' '); - let dayText = isEveryDay ? 'Every day' : - isWeekdays ? 'Weekdays' : - dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' '); + parts.push( + + + At {trigger.time} ({dayText}) + + ); + } - return ( - - + if (trigger.type === 'sensor' && !trigger.sensors) { + parts.push( + + - At {trigger.time} • {dayText} + {trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value} ); } - if (trigger.type === 'sensor') { - return ( - - - - When {trigger.sensor} {trigger.operator} {trigger.value} - - - ); - } + if (parts.length === 0) return null; - return null; + return ( + + {parts} + + ); } function ActionSummary({ action }) { if (action.type === 'toggle') { return ( - + - Turn {action.target} {action.state ? 'on' : 'off'} + → {action.targetLabel || action.target} {action.state ? 'ON' : 'OFF'} ); @@ -84,14 +111,10 @@ function ActionSummary({ action }) { if (action.type === 'keepOn') { return ( - - + + - Keep {action.target} on for {action.duration} min + → {action.targetLabel || action.target} ON for {action.duration}m ); @@ -100,7 +123,7 @@ function ActionSummary({ action }) { return null; } -export default function RuleCard({ rule, onEdit, onDelete, onToggle }) { +export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly }) { return ( {!rule.enabled && ( - + )} - + - - - - - - - - - - - - - - - + {!readOnly && ( + + + + + + + + + + + + + + )} ); diff --git a/src/client/RuleEditor.js b/src/client/RuleEditor.js index 11c24b7..522054a 100644 --- a/src/client/RuleEditor.js +++ b/src/client/RuleEditor.js @@ -17,7 +17,11 @@ import { Divider, Slider, Switch, - FormControlLabel + FormControlLabel, + CircularProgress, + IconButton, + Paper, + Chip } from '@mui/material'; const DAYS = [ @@ -30,109 +34,189 @@ const DAYS = [ { key: 'sun', label: 'Sun' } ]; -const SENSORS = ['Temperature', 'Humidity', 'CO2', 'VPD', 'Light Level']; const OPERATORS = [ - { value: '>', label: 'Greater than (>)' }, - { value: '<', label: 'Less than (<)' }, - { value: '>=', label: 'Greater or equal (≥)' }, - { value: '<=', label: 'Less or equal (≤)' }, - { value: '==', label: 'Equal to (=)' } + { value: '>', label: '>' }, + { value: '<', label: '<' }, + { value: '>=', label: '≥' }, + { value: '<=', label: '≤' }, + { value: '==', label: '=' } ]; -const OUTPUTS = [ - 'Workshop Light', - 'Exhaust Fan', - 'Heater', - 'Humidifier', - 'All Outlets', - 'Grow Light', - 'Circulation Fan' -]; +// Single sensor condition component +function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) { + return ( + + + Sensor + + + + + + onChange({ ...condition, value: Number(e.target.value) })} + sx={{ width: 80 }} + disabled={disabled} + /> + {onRemove && ( + + ❌ + + )} + + ); +} -export default function RuleEditor({ open, rule, onSave, onClose }) { +export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], saving }) { const [name, setName] = useState(''); - const [triggerType, setTriggerType] = useState('time'); - // Time trigger state - const [time, setTime] = useState('08:00'); + // Time range state + const [useTimeRange, setUseTimeRange] = useState(false); + const [timeStart, setTimeStart] = useState('08:00'); + const [timeEnd, setTimeEnd] = useState('18:00'); const [days, setDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']); - // Sensor trigger state - const [sensor, setSensor] = useState('Temperature'); - const [operator, setOperator] = useState('>'); - const [sensorValue, setSensorValue] = useState(25); + // Sensor conditions state + const [useSensors, setUseSensors] = useState(false); + const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]); + const [sensorLogic, setSensorLogic] = useState('and'); // 'and' or 'or' // Action state const [actionType, setActionType] = useState('toggle'); - const [target, setTarget] = useState('Workshop Light'); + const [target, setTarget] = useState(''); const [toggleState, setToggleState] = useState(true); const [duration, setDuration] = useState(15); - // Reset form when rule changes + // Reset form when rule changes or dialog opens useEffect(() => { if (rule) { setName(rule.name); - setTriggerType(rule.trigger.type); - if (rule.trigger.type === 'time') { - setTime(rule.trigger.time); - setDays(rule.trigger.days || []); - } else { - setSensor(rule.trigger.sensor); - setOperator(rule.trigger.operator); - setSensorValue(rule.trigger.value); + // Parse trigger + const trigger = rule.trigger || {}; + setUseTimeRange(!!trigger.timeRange); + if (trigger.timeRange) { + setTimeStart(trigger.timeRange.start || '08:00'); + setTimeEnd(trigger.timeRange.end || '18:00'); + setDays(trigger.timeRange.days || []); } - setActionType(rule.action.type); - setTarget(rule.action.target); - if (rule.action.type === 'toggle') { - setToggleState(rule.action.state); + setUseSensors(!!trigger.sensors && trigger.sensors.length > 0); + if (trigger.sensors && trigger.sensors.length > 0) { + setSensorConditions(trigger.sensors); + setSensorLogic(trigger.sensorLogic || 'and'); + } + + // Parse action + setActionType(rule.action?.type || 'toggle'); + setTarget(rule.action?.target || ''); + if (rule.action?.type === 'toggle') { + setToggleState(rule.action?.state ?? true); } else { - setDuration(rule.action.duration); + setDuration(rule.action?.duration || 15); } } else { - // Reset to defaults for new rule + // Reset to defaults setName(''); - setTriggerType('time'); - setTime('08:00'); + setUseTimeRange(true); + setTimeStart('08:00'); + setTimeEnd('18:00'); setDays(['mon', 'tue', 'wed', 'thu', 'fri']); - setSensor('Temperature'); - setOperator('>'); - setSensorValue(25); + setUseSensors(false); + setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]); + setSensorLogic('and'); setActionType('toggle'); - setTarget('Workshop Light'); + setTarget(outputs[0]?.id || ''); setToggleState(true); setDuration(15); } - }, [rule, open]); + }, [rule, open, sensors, outputs]); + + // Set default sensor/output when lists load + useEffect(() => { + if (sensorConditions[0]?.sensor === '' && sensors.length > 0) { + setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]); + } + if (!target && outputs.length > 0) setTarget(outputs[0].id); + }, [sensors, outputs, sensorConditions, target]); const handleDaysChange = (event, newDays) => { - if (newDays.length > 0) { - setDays(newDays); + if (newDays.length > 0) setDays(newDays); + }; + + const addSensorCondition = () => { + setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]); + }; + + const updateSensorCondition = (index, newCondition) => { + const updated = [...sensorConditions]; + updated[index] = newCondition; + setSensorConditions(updated); + }; + + const removeSensorCondition = (index) => { + if (sensorConditions.length > 1) { + setSensorConditions(sensorConditions.filter((_, i) => i !== index)); } }; const handleSave = () => { + const selectedOutput = outputs.find(o => o.id === target); + + // Build trigger object + const trigger = {}; + if (useTimeRange) { + trigger.timeRange = { start: timeStart, end: timeEnd, days }; + } + if (useSensors && sensorConditions.length > 0) { + trigger.sensors = sensorConditions.map(c => ({ + ...c, + sensorLabel: sensors.find(s => s.id === c.sensor)?.label + })); + trigger.sensorLogic = sensorLogic; + } + const ruleData = { name, - trigger: triggerType === 'time' - ? { type: 'time', time, days } - : { type: 'sensor', sensor, operator, value: sensorValue }, + trigger, action: actionType === 'toggle' - ? { type: 'toggle', target, state: toggleState } - : { type: 'keepOn', target, duration } + ? { type: 'toggle', target, targetLabel: selectedOutput?.label, state: toggleState } + : { type: 'keepOn', target, targetLabel: selectedOutput?.label, duration } }; onSave(ruleData); }; const isValid = name.trim().length > 0 && - (triggerType !== 'time' || days.length > 0); + (useTimeRange || useSensors) && + (!useTimeRange || days.length > 0) && + (!useSensors || sensorConditions.every(c => c.sensor)) && + target; return ( setName(e.target.value)} fullWidth - placeholder="e.g., Morning Lights" + placeholder="e.g., Daytime High Humidity Fan" + disabled={saving} /> - {/* Trigger Section */} + {/* TRIGGERS SECTION */} - TRIGGER (When to activate) + TRIGGERS (When to activate - conditions are combined with AND) - - Trigger Type - - - - {triggerType === 'time' && ( - - setTime(e.target.value)} - InputLabelProps={{ shrink: true }} - fullWidth - /> - - - Days of Week - - - {DAYS.map(day => ( - - {day.label} - - ))} - - - - )} - - {triggerType === 'sensor' && ( - - - Sensor - - - - - Condition - - - setSensorValue(Number(e.target.value))} - fullWidth + {/* Time Range Trigger */} + + setUseTimeRange(e.target.checked)} + disabled={saving} /> + } + label={⏰ Time Range} + /> + + {useTimeRange && ( + + + setTimeStart(e.target.value)} + InputLabelProps={{ shrink: true }} + size="small" + disabled={saving} + /> + to + setTimeEnd(e.target.value)} + InputLabelProps={{ shrink: true }} + size="small" + disabled={saving} + /> + + + + Days + + + {DAYS.map(day => ( + + {day.label} + + ))} + + - - )} + )} + + + {/* Sensor Conditions Trigger */} + + setUseSensors(e.target.checked)} + disabled={saving || sensors.length === 0} + /> + } + label={ + + 📊 Sensor Conditions {sensors.length === 0 && '(no sensors available)'} + + } + /> + + {useSensors && ( + + {sensorConditions.length > 1 && ( + + Combine conditions with: + v && setSensorLogic(v)} + size="small" + disabled={saving} + > + + AND + + + OR + + + + )} + + {sensorConditions.map((cond, i) => ( + + {i > 0 && ( + + )} + updateSensorCondition(i, newCond)} + onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null} + disabled={saving} + /> + + ))} + + + + )} + - {/* Action Section */} + {/* ACTION SECTION */} ACTION (What to do) @@ -267,6 +404,7 @@ export default function RuleEditor({ open, rule, onSave, onClose }) { value={actionType} label="Action Type" onChange={(e) => setActionType(e.target.value)} + disabled={saving} > 🔛 Toggle On/Off ⏱️ Keep On for X Minutes @@ -279,9 +417,10 @@ export default function RuleEditor({ open, rule, onSave, onClose }) { value={target} label="Target Output" onChange={(e) => setTarget(e.target.value)} + disabled={saving} > - {OUTPUTS.map(o => ( - {o} + {outputs.map(o => ( + {o.label} ))} @@ -293,6 +432,7 @@ export default function RuleEditor({ open, rule, onSave, onClose }) { checked={toggleState} onChange={(e) => setToggleState(e.target.checked)} color="primary" + disabled={saving} /> } label={toggleState ? 'Turn ON' : 'Turn OFF'} @@ -316,6 +456,7 @@ export default function RuleEditor({ open, rule, onSave, onClose }) { { value: 120, label: '2h' } ]} valueLabelDisplay="auto" + disabled={saving} /> )} @@ -324,13 +465,13 @@ export default function RuleEditor({ open, rule, onSave, onClose }) { - diff --git a/src/client/RuleManager.js b/src/client/RuleManager.js index 6d9aecf..da0190f 100644 --- a/src/client/RuleManager.js +++ b/src/client/RuleManager.js @@ -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 ( + + + Loading rules... + + ); + } + return ( - Configure triggers and actions for home automation + {isAdmin ? 'Configure triggers and actions for home automation' : 'View automation rules (read-only)'} - + {isAdmin && ( + + )} + {error && ( + setError(null)}> + {error} + + )} + {rules.length === 0 ? ( - No rules configured. Click "Add Rule" to create one. + No rules configured. {isAdmin && 'Click "Add Rule" to create one.'} ) : ( @@ -154,20 +270,26 @@ export default function RuleManager() { 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} /> ))} )} - + {isAdmin && ( + + )} ); }