feat: Failsafe admin console with threshold and Kasa controls
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2026-01-06 00:09:55 -08:00
parent e4c506d074
commit 14e76f2cdf
2 changed files with 384 additions and 0 deletions

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

View file

@ -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 = () => (
<div className="flex items-center justify-center p-8">
@ -205,6 +208,11 @@ export const router = createBrowserRouter([
path: 'pulse',
element: <Suspense fallback={<PageLoader />}><PulseTestPage /></Suspense>,
},
// Failsafe Settings (Admin)
{
path: 'failsafe',
element: <Suspense fallback={<PageLoader />}><FailsafeSettingsPage /></Suspense>,
},
// 404 catch-all
{
path: '*',