feat: Failsafe admin console with threshold and Kasa controls
This commit is contained in:
parent
e4c506d074
commit
14e76f2cdf
2 changed files with 384 additions and 0 deletions
376
frontend/src/pages/FailsafeSettingsPage.tsx
Normal file
376
frontend/src/pages/FailsafeSettingsPage.tsx
Normal file
|
|
@ -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<Thresholds>({
|
||||||
|
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<KasaStatus | null>(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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-8">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-3 rounded-xl bg-gradient-to-br from-orange-500 to-red-600 shadow-lg shadow-orange-500/25">
|
||||||
|
<Settings className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">Failsafe Control Center</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 ml-14">
|
||||||
|
Configure alert thresholds and control the Kasa smart plug failsafe system.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Kasa Control Card */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="lg:col-span-1 bg-slate-800/50 backdrop-blur-xl border border-slate-700/50 rounded-2xl p-6 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-2.5 rounded-xl bg-gradient-to-br from-yellow-500 to-amber-600 shadow-lg">
|
||||||
|
<Plug className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">Kasa Smart Plug</h3>
|
||||||
|
<p className="text-sm text-slate-400">Failsafe device</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edge Agent URL */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-slate-400 mb-2">Edge Agent URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={edgeAgentUrl}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Display */}
|
||||||
|
<div className="bg-slate-900/50 rounded-xl p-4 mb-4">
|
||||||
|
{kasaStatus ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 text-sm">Status</span>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${kasaStatus.connected
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-red-500/20 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{kasaStatus.connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 text-sm">Device</span>
|
||||||
|
<span className="text-white text-sm font-medium">{kasaStatus.alias}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 text-sm">Model</span>
|
||||||
|
<span className="text-slate-300 text-sm">{kasaStatus.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 text-sm">IP Address</span>
|
||||||
|
<span className="text-slate-300 text-sm font-mono">{kasaStatus.ip || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 text-sm">Power</span>
|
||||||
|
<span className={`flex items-center gap-1.5 ${kasaStatus.isOn ? 'text-yellow-400' : 'text-slate-500'
|
||||||
|
}`}>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
{kasaStatus.isOn === null ? 'Unknown' : kasaStatus.isOn ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-slate-400">
|
||||||
|
{kasaLoading ? 'Loading...' : 'No status available'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control Buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleKasa(true)}
|
||||||
|
disabled={kasaLoading || !kasaStatus?.connected}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-500 disabled:bg-slate-700 disabled:opacity-50 text-white rounded-xl font-medium transition-all"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
ON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleKasa(false)}
|
||||||
|
disabled={kasaLoading || !kasaStatus?.connected}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 hover:bg-red-500 disabled:bg-slate-700 disabled:opacity-50 text-white rounded-xl font-medium transition-all"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
OFF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={discoverKasa}
|
||||||
|
disabled={kasaLoading}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-xl text-sm transition-all"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
Discover
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={loadKasaStatus}
|
||||||
|
disabled={kasaLoading}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-xl text-sm transition-all"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${kasaLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Threshold Cards */}
|
||||||
|
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{metrics.map((metric, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={metric.key}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
className="bg-slate-800/50 backdrop-blur-xl border border-slate-700/50 rounded-2xl p-6 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className={`p-2.5 rounded-xl bg-gradient-to-br ${metric.color} shadow-lg`}>
|
||||||
|
<metric.icon className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">{metric.label}</h3>
|
||||||
|
<p className="text-sm text-slate-400">Unit: {metric.unit}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||||
|
Minimum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={metric.key === 'vpd' ? 0.1 : 1}
|
||||||
|
value={thresholds[metric.key].min}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||||
|
Maximum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={metric.key === 'vpd' ? 0.1 : 1}
|
||||||
|
value={thresholds[metric.key].max}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Range Indicator */}
|
||||||
|
<div className="mt-4 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full bg-gradient-to-r ${metric.color} rounded-full transition-all`}
|
||||||
|
style={{
|
||||||
|
marginLeft: `${(thresholds[metric.key].min / (metric.key === 'co2' ? 2000 : metric.key === 'vpd' ? 2 : 100)) * 100}%`,
|
||||||
|
width: `${((thresholds[metric.key].max - thresholds[metric.key].min) / (metric.key === 'co2' ? 2000 : metric.key === 'vpd' ? 2 : 100)) * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`mt-6 p-4 rounded-xl ${message.type === 'success'
|
||||||
|
? 'bg-green-500/20 border border-green-500/30 text-green-400'
|
||||||
|
: 'bg-red-500/20 border border-red-500/30 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
className="flex gap-4 mt-6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={loadThresholds}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-medium transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveThresholds}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white rounded-xl font-medium shadow-lg shadow-blue-500/25 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className={`w-5 h-5 ${saving ? 'animate-pulse' : ''}`} />
|
||||||
|
{saving ? 'Saving...' : 'Save Thresholds'}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="mt-8 p-6 bg-amber-500/10 border border-amber-500/20 rounded-2xl"
|
||||||
|
>
|
||||||
|
<h4 className="text-amber-400 font-semibold mb-2">⚡ Failsafe Behavior</h4>
|
||||||
|
<p className="text-slate-300 text-sm">
|
||||||
|
When a Pulse reading exceeds the <strong>Maximum</strong> 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.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,9 @@ const LayoutEditorPage = lazy(() => import('./pages/LayoutEditorPage'));
|
||||||
// Pulse Sensor Test Page
|
// Pulse Sensor Test Page
|
||||||
const PulseTestPage = lazy(() => import('./pages/PulseTestPage'));
|
const PulseTestPage = lazy(() => import('./pages/PulseTestPage'));
|
||||||
|
|
||||||
|
// Failsafe Settings (Admin)
|
||||||
|
const FailsafeSettingsPage = lazy(() => import('./pages/FailsafeSettingsPage'));
|
||||||
|
|
||||||
// Loading spinner component for Suspense fallbacks
|
// Loading spinner component for Suspense fallbacks
|
||||||
const PageLoader = () => (
|
const PageLoader = () => (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
|
|
@ -205,6 +208,11 @@ export const router = createBrowserRouter([
|
||||||
path: 'pulse',
|
path: 'pulse',
|
||||||
element: <Suspense fallback={<PageLoader />}><PulseTestPage /></Suspense>,
|
element: <Suspense fallback={<PageLoader />}><PulseTestPage /></Suspense>,
|
||||||
},
|
},
|
||||||
|
// Failsafe Settings (Admin)
|
||||||
|
{
|
||||||
|
path: 'failsafe',
|
||||||
|
element: <Suspense fallback={<PageLoader />}><FailsafeSettingsPage /></Suspense>,
|
||||||
|
},
|
||||||
// 404 catch-all
|
// 404 catch-all
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue