diff --git a/frontend/src/pages/FailsafeSettingsPage.tsx b/frontend/src/pages/FailsafeSettingsPage.tsx new file mode 100644 index 0000000..81e778e --- /dev/null +++ b/frontend/src/pages/FailsafeSettingsPage.tsx @@ -0,0 +1,376 @@ +import { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { Settings, Thermometer, Droplets, Wind, Gauge, Save, RefreshCw, Power, Plug, Search, Zap } from 'lucide-react'; +import api from '../lib/api'; + +interface Thresholds { + temperature: { min: number; max: number }; + humidity: { min: number; max: number }; + vpd: { min: number; max: number }; + co2: { min: number; max: number }; +} + +interface KasaStatus { + isOn: boolean | null; + alias: string; + ip: string | null; + model: string; + connected: boolean; + error?: string; +} + +// Edge Agent URL (on local network during demo) +const EDGE_AGENT_URL = 'http://localhost:3030'; + +export default function FailsafeSettingsPage() { + const [thresholds, setThresholds] = useState({ + temperature: { min: 65, max: 82 }, + humidity: { min: 40, max: 70 }, + vpd: { min: 0.8, max: 1.2 }, + co2: { min: 400, max: 1500 } + }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Kasa state + const [kasaStatus, setKasaStatus] = useState(null); + const [kasaLoading, setKasaLoading] = useState(false); + const [edgeAgentUrl, setEdgeAgentUrl] = useState(EDGE_AGENT_URL); + + useEffect(() => { + loadThresholds(); + loadKasaStatus(); + }, []); + + async function loadThresholds() { + try { + setLoading(true); + const res = await api.get('/pulse/thresholds'); + if (res.data.thresholds) { + setThresholds(res.data.thresholds); + } + } catch (error) { + console.error('Failed to load thresholds:', error); + } finally { + setLoading(false); + } + } + + async function saveThresholds() { + try { + setSaving(true); + setMessage(null); + await api.post('/pulse/thresholds', thresholds); + setMessage({ type: 'success', text: 'Thresholds updated successfully!' }); + } catch (error) { + setMessage({ type: 'error', text: 'Failed to save thresholds' }); + } finally { + setSaving(false); + } + } + + async function loadKasaStatus() { + try { + setKasaLoading(true); + const res = await fetch(`${edgeAgentUrl}/kasa/status`); + const data = await res.json(); + setKasaStatus(data); + } catch (error: any) { + setKasaStatus({ isOn: null, alias: 'Unreachable', ip: null, model: 'Unknown', connected: false, error: error.message }); + } finally { + setKasaLoading(false); + } + } + + async function toggleKasa(state: boolean) { + try { + setKasaLoading(true); + await fetch(`${edgeAgentUrl}/kasa/power`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state }) + }); + await loadKasaStatus(); + } catch (error) { + console.error('Failed to toggle Kasa:', error); + } finally { + setKasaLoading(false); + } + } + + async function discoverKasa() { + try { + setKasaLoading(true); + await fetch(`${edgeAgentUrl}/kasa/discover`, { method: 'POST' }); + await loadKasaStatus(); + } catch (error) { + console.error('Failed to discover Kasa:', error); + } finally { + setKasaLoading(false); + } + } + + function updateThreshold(metric: keyof Thresholds, field: 'min' | 'max', value: number) { + setThresholds(prev => ({ + ...prev, + [metric]: { ...prev[metric], [field]: value } + })); + } + + const metrics = [ + { key: 'temperature' as const, label: 'Temperature', icon: Thermometer, unit: '°F', color: 'from-red-500 to-orange-500' }, + { key: 'humidity' as const, label: 'Humidity', icon: Droplets, unit: '%', color: 'from-blue-500 to-cyan-500' }, + { key: 'vpd' as const, label: 'VPD', icon: Gauge, unit: 'kPa', color: 'from-purple-500 to-pink-500' }, + { key: 'co2' as const, label: 'CO₂', icon: Wind, unit: 'ppm', color: 'from-green-500 to-emerald-500' } + ]; + + return ( +
+
+ {/* Header */} + +
+
+ +
+

Failsafe Control Center

+
+

+ Configure alert thresholds and control the Kasa smart plug failsafe system. +

+
+ +
+ {/* Kasa Control Card */} + +
+
+ +
+
+

Kasa Smart Plug

+

Failsafe device

+
+
+ + {/* Edge Agent URL */} +
+ + setEdgeAgentUrl(e.target.value)} + className="w-full px-3 py-2 bg-slate-900/50 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="http://localhost:3030" + /> +
+ + {/* Status Display */} +
+ {kasaStatus ? ( +
+
+ Status + + {kasaStatus.connected ? 'Connected' : 'Disconnected'} + +
+
+ Device + {kasaStatus.alias} +
+
+ Model + {kasaStatus.model} +
+
+ IP Address + {kasaStatus.ip || 'N/A'} +
+
+ Power + + + {kasaStatus.isOn === null ? 'Unknown' : kasaStatus.isOn ? 'ON' : 'OFF'} + +
+
+ ) : ( +
+ {kasaLoading ? 'Loading...' : 'No status available'} +
+ )} +
+ + {/* Control Buttons */} +
+ + +
+ +
+ + +
+
+ + {/* Threshold Cards */} +
+ {metrics.map((metric, index) => ( + +
+
+ +
+
+

{metric.label}

+

Unit: {metric.unit}

+
+
+ +
+
+ + updateThreshold(metric.key, 'min', parseFloat(e.target.value))} + className="w-full px-4 py-3 bg-slate-900/50 border border-slate-600 rounded-xl text-white text-lg font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + /> +
+
+ + updateThreshold(metric.key, 'max', parseFloat(e.target.value))} + className="w-full px-4 py-3 bg-slate-900/50 border border-slate-600 rounded-xl text-white text-lg font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + /> +
+
+ + {/* Visual Range Indicator */} +
+
+
+ + ))} +
+
+ + {/* Message */} + {message && ( + + {message.text} + + )} + + {/* Action Buttons */} + + + + + + {/* Info Box */} + +

⚡ Failsafe Behavior

+

+ When a Pulse reading exceeds the Maximum threshold for 30+ seconds, + the backend will automatically command the Edge Agent to toggle the Kasa smart plug. + This is used for emergency ventilation or cooling. +

+
+
+
+ ); +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 8e94b31..b9df9a9 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -52,6 +52,9 @@ const LayoutEditorPage = lazy(() => import('./pages/LayoutEditorPage')); // Pulse Sensor Test Page const PulseTestPage = lazy(() => import('./pages/PulseTestPage')); +// Failsafe Settings (Admin) +const FailsafeSettingsPage = lazy(() => import('./pages/FailsafeSettingsPage')); + // Loading spinner component for Suspense fallbacks const PageLoader = () => (
@@ -205,6 +208,11 @@ export const router = createBrowserRouter([ path: 'pulse', element: }>, }, + // Failsafe Settings (Admin) + { + path: 'failsafe', + element: }>, + }, // 404 catch-all { path: '*',