From c3dcefe857e2506d81a91dedc293bc673d2ebaba Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:57:00 -0800 Subject: [PATCH] feat: Pulse test page and Environment Dashboard integration --- frontend/src/pages/EnvironmentDashboard.tsx | 366 ++++++++++++++++---- frontend/src/pages/PulseTestPage.tsx | 316 +++++++++++++++++ frontend/src/router.tsx | 7 + 3 files changed, 616 insertions(+), 73 deletions(-) create mode 100644 frontend/src/pages/PulseTestPage.tsx diff --git a/frontend/src/pages/EnvironmentDashboard.tsx b/frontend/src/pages/EnvironmentDashboard.tsx index 0e775bc..ed7a613 100644 --- a/frontend/src/pages/EnvironmentDashboard.tsx +++ b/frontend/src/pages/EnvironmentDashboard.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react'; import { Thermometer, Droplets, Wind, Sun, AlertTriangle, - Activity, Settings, RefreshCw, ChevronRight, Loader2 + Activity, Settings, RefreshCw, ChevronRight, Wifi, WifiOff, Bell } from 'lucide-react'; import api from '../lib/api'; import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives'; +import { useNotifications } from '../hooks/useNotifications'; interface SensorData { id: string; @@ -21,6 +22,18 @@ interface SensorData { alertCount: number; } +interface PulseReading { + deviceId: string; + deviceName: string; + temperature: number; + humidity: number; + vpd: number; + dewpoint: number; + light?: number; + co2?: number; + timestamp: Date; +} + interface EnvironmentAlert { id: string; type: string; @@ -63,24 +76,43 @@ const sensorAccents: Record(null); const [sensors, setSensors] = useState([]); + const [pulseReadings, setPulseReadings] = useState([]); + const [pulseConnected, setPulseConnected] = useState(false); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [selectedRoom, setSelectedRoom] = useState(''); + const { connected: wsConnected, alerts: wsAlerts, unreadCount } = useNotifications(); + useEffect(() => { loadData(); - const interval = setInterval(loadData, 60000); + const interval = setInterval(loadData, 30000); // Refresh every 30s return () => clearInterval(interval); }, [selectedRoom]); const loadData = async () => { try { + // Load internal sensors and dashboard const [dashRes, sensorsRes] = await Promise.all([ api.get('/api/environment/dashboard', { params: { roomId: selectedRoom || undefined } }), api.get('/api/environment/sensors', { params: { roomId: selectedRoom || undefined } }) ]); setDashboard(dashRes.data); setSensors(sensorsRes.data); + + // Try to load Pulse data + try { + const pulseStatusRes = await api.get('/api/pulse/status'); + setPulseConnected(pulseStatusRes.data.connected); + + if (pulseStatusRes.data.connected) { + const pulseReadingsRes = await api.get('/api/pulse/readings'); + setPulseReadings(pulseReadingsRes.data.readings || []); + } + } catch (pulseError) { + console.log('Pulse not configured:', pulseError); + setPulseConnected(false); + } } catch (error) { console.error('Failed to load environment data:', error); } finally { @@ -94,6 +126,18 @@ export default function EnvironmentDashboard() { setRefreshing(false); }; + const getTempColor = (temp: number) => { + if (temp < 65) return 'text-blue-500'; + if (temp > 82) return 'text-red-500'; + return 'text-green-500'; + }; + + const getVpdColor = (vpd: number) => { + if (vpd < 0.8) return 'text-blue-500'; + if (vpd > 1.2) return 'text-red-500'; + return 'text-green-500'; + }; + const getStatusClass = (value: number, min?: number, max?: number): string => { if (min && value < min) return 'text-accent'; if (max && value > max) return 'text-destructive'; @@ -111,23 +155,72 @@ export default function EnvironmentDashboard() { ); } + const hasData = sensors.length > 0 || pulseReadings.length > 0; + return (
- - +
+ {/* Live indicator */} +
+ {wsConnected ? ( + + ) : ( + + )} + + {wsConnected ? 'Live' : 'Offline'} + + {unreadCount > 0 && ( + + + {unreadCount} + + )} +
+ + +
} /> - {/* Active Alerts */} + {/* Real-time Alerts from WebSocket */} + {wsAlerts.length > 0 && ( +
+
+ + + {wsAlerts.length} Real-time Alert{wsAlerts.length > 1 ? 's' : ''} + +
+
+ {wsAlerts.slice(0, 3).map((alert, idx) => ( +
+
+

{alert.sensorName}: {alert.type}

+

+ Value: {alert.value?.toFixed(1)} (threshold: {alert.threshold?.toFixed(1)}) +

+
+ + {new Date(alert.timestamp).toLocaleTimeString()} + +
+ ))} +
+
+ )} + + {/* Active Alerts from database */} {dashboard?.alerts.active && dashboard.alerts.active > 0 && (
@@ -161,79 +254,206 @@ export default function EnvironmentDashboard() {
)} - {/* Quick Stats Grid */} -
- {Object.entries(dashboard?.averages || {}).map(([type, value]) => { - const Icon = sensorIcons[type] || Activity; - const readings = dashboard?.readings[type] || []; - const unit = readings[0]?.unit || ''; - const accent = sensorAccents[type] || 'accent'; - - return ( - - ); - })} -
- - {/* Sensors List */} -
-

All Sensors

-
- {sensors.map(sensor => { - const Icon = sensorIcons[sensor.type] || Activity; - const reading = sensor.latestReading; - - return ( + {/* Pulse Sensors Section */} + {pulseReadings.length > 0 && ( +
+
+

+ + Pulse Grow Sensors +

+ + {pulseReadings.length} device{pulseReadings.length !== 1 ? 's' : ''} + +
+
+ {pulseReadings.map((reading, index) => (
-
- + {/* Device Header */} +
+

+ {reading.deviceName || `Device ${reading.deviceId}`} +

+
-
-
{sensor.name}
-
- {sensor.type.replace('_', ' ').toLowerCase()} + + {/* Metrics Grid */} +
+ {/* Temperature */} +
+
+ + Temp +
+

+ {reading.temperature.toFixed(1)}°F +

-
-
- {reading ? ( - <> -
- {reading.value.toFixed(1)} {reading.unit} + + {/* Humidity */} +
+
+ + Humidity +
+

+ {reading.humidity.toFixed(1)}% +

+
+ + {/* VPD */} +
+
+ + VPD +
+

+ {reading.vpd.toFixed(2)} kPa +

+
+ + {/* Dewpoint */} +
+
+ + Dewpoint +
+

+ {reading.dewpoint.toFixed(1)}°F +

+
+ + {/* CO2 (if available) */} + {reading.co2 !== undefined && ( +
+
+ + CO2
-
- {new Date(reading.timestamp).toLocaleTimeString()} +

+ {reading.co2} ppm +

+
+ )} + + {/* Light (if available) */} + {reading.light !== undefined && ( +
+
+ + Light
- - ) : ( - No data +

+ {reading.light.toFixed(0)} lux +

+
)}
- {sensor.alertCount > 0 && ( - {sensor.alertCount} - )} - + + {/* Timestamp */} +

+ {new Date(reading.timestamp).toLocaleString()} +

- ); - })} - {sensors.length === 0 && ( - - )} + ))} +
-
+ )} + + {/* Quick Stats Grid (from internal sensors) */} + {Object.keys(dashboard?.averages || {}).length > 0 && ( + <> +

Internal Sensors

+
+ {Object.entries(dashboard?.averages || {}).map(([type, value]) => { + const Icon = sensorIcons[type] || Activity; + const readings = dashboard?.readings[type] || []; + const unit = readings[0]?.unit || ''; + const accent = sensorAccents[type] || 'accent'; + + return ( + + ); + })} +
+ + )} + + {/* Internal Sensors List */} + {sensors.length > 0 && ( +
+

All Internal Sensors

+
+ {sensors.map(sensor => { + const Icon = sensorIcons[sensor.type] || Activity; + const reading = sensor.latestReading; + + return ( +
+
+ +
+
+
{sensor.name}
+
+ {sensor.type.replace('_', ' ').toLowerCase()} +
+
+
+ {reading ? ( + <> +
+ {reading.value.toFixed(1)} {reading.unit} +
+
+ {new Date(reading.timestamp).toLocaleTimeString()} +
+ + ) : ( + No data + )} +
+ {sensor.alertCount > 0 && ( + {sensor.alertCount} + )} + +
+ ); + })} +
+
+ )} + + {/* Empty State - only show if no data at all */} + {!hasData && !pulseConnected && ( +
+ +

Add sensors to start monitoring.

+

+ To connect your Pulse Grow sensor, set the PULSE_API_KEY environment variable. +

+
+ } + /> +
+ )} {/* Environment Profile */} {dashboard?.profile && ( diff --git a/frontend/src/pages/PulseTestPage.tsx b/frontend/src/pages/PulseTestPage.tsx new file mode 100644 index 0000000..ac1346e --- /dev/null +++ b/frontend/src/pages/PulseTestPage.tsx @@ -0,0 +1,316 @@ +import React, { useEffect, useState } from 'react'; +import { useAuth } from '../../context/AuthContext'; +import { Activity, Thermometer, Droplets, Wind, Sun, Wifi, WifiOff, RefreshCw, Bell } from 'lucide-react'; +import { useNotifications } from '../../hooks/useNotifications'; + +interface PulseDevice { + id: string; + name: string; + type: string; + isOnline: boolean; +} + +interface PulseReading { + deviceId: string; + deviceName: string; + temperature: number; + humidity: number; + vpd: number; + dewpoint: number; + light?: number; + co2?: number; + timestamp: Date; +} + +interface PulseStatus { + connected: boolean; + deviceCount: number; + error?: string; +} + +export default function PulseTestPage() { + const { token } = useAuth(); + const { connected: wsConnected, alerts, unreadCount } = useNotifications(); + + const [status, setStatus] = useState(null); + const [devices, setDevices] = useState([]); + const [readings, setReadings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchData = async () => { + if (!token) return; + + setLoading(true); + setError(null); + + try { + // Fetch status + const statusRes = await fetch('/api/pulse/status', { + headers: { Authorization: `Bearer ${token}` } + }); + const statusData = await statusRes.json(); + setStatus(statusData); + + if (statusData.connected) { + // Fetch devices + const devicesRes = await fetch('/api/pulse/devices', { + headers: { Authorization: `Bearer ${token}` } + }); + const devicesData = await devicesRes.json(); + setDevices(devicesData.devices || []); + + // Fetch readings + const readingsRes = await fetch('/api/pulse/readings', { + headers: { Authorization: `Bearer ${token}` } + }); + const readingsData = await readingsRes.json(); + setReadings(readingsData.readings || []); + } + + setLastUpdate(new Date()); + } catch (err: any) { + setError(err.message || 'Failed to fetch data'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + + // Refresh every 30 seconds + const interval = setInterval(fetchData, 30000); + return () => clearInterval(interval); + }, [token]); + + const getStatusColor = (connected: boolean) => + connected ? 'text-green-500' : 'text-red-500'; + + const getVpdColor = (vpd: number) => { + if (vpd < 0.8) return 'text-blue-500'; + if (vpd > 1.2) return 'text-red-500'; + return 'text-green-500'; + }; + + const getTempColor = (temp: number) => { + if (temp < 65) return 'text-blue-500'; + if (temp > 82) return 'text-red-500'; + return 'text-green-500'; + }; + + return ( +
+ {/* Header */} +
+
+

+ Pulse Sensor Dashboard +

+

+ Live environment monitoring from Pulse Grow +

+
+
+ {/* WebSocket Status */} +
+ {wsConnected ? ( + + ) : ( + + )} + + {wsConnected ? 'Live' : 'Offline'} + + {unreadCount > 0 && ( + + + {unreadCount} + + )} +
+ + {/* Refresh Button */} + +
+
+ + {/* Connection Status Card */} +
+
+
+
+ +
+
+

+ Pulse API Connection +

+

+ {status?.connected + ? `Connected • ${status.deviceCount} device(s)` + : status?.error || 'Not connected'} +

+
+
+ {lastUpdate && ( +

+ Last update: {lastUpdate.toLocaleTimeString()} +

+ )} +
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Sensor Readings Grid */} + {readings.length > 0 && ( +
+ {readings.map((reading, index) => ( +
+ {/* Device Header */} +
+

+ {reading.deviceName || `Device ${reading.deviceId}`} +

+
+
+ + {/* Metrics Grid */} +
+ {/* Temperature */} +
+
+ + Temp +
+

+ {reading.temperature.toFixed(1)}°F +

+
+ + {/* Humidity */} +
+
+ + Humidity +
+

+ {reading.humidity.toFixed(1)}% +

+
+ + {/* VPD */} +
+
+ + VPD +
+

+ {reading.vpd.toFixed(2)} kPa +

+
+ + {/* Dewpoint */} +
+
+ + Dewpoint +
+

+ {reading.dewpoint.toFixed(1)}°F +

+
+ + {/* Light (if available) */} + {reading.light !== undefined && ( +
+
+ + Light +
+

+ {reading.light.toFixed(0)} lux +

+
+ )} + + {/* CO2 (if available) */} + {reading.co2 !== undefined && ( +
+
+ + CO2 +
+

+ {reading.co2} ppm +

+
+ )} +
+ + {/* Timestamp */} +

+ {new Date(reading.timestamp).toLocaleString()} +

+
+ ))} +
+ )} + + {/* Empty State */} + {!loading && readings.length === 0 && status?.connected && ( +
+ +

+ No Sensor Readings +

+

+ Waiting for data from Pulse devices... +

+
+ )} + + {/* Recent Alerts */} + {alerts.length > 0 && ( +
+

+ + Recent Alerts ({alerts.length}) +

+
+ {alerts.slice(0, 5).map((alert, index) => ( +
+

{alert.sensorName}

+

+ {alert.type}: {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)}) +

+

+ {new Date(alert.timestamp).toLocaleTimeString()} +

+
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 8e43e3e..8e94b31 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -49,6 +49,8 @@ const Facility3DViewerPage = lazy(() => import('./pages/Facility3DViewerPage')); // 2D Layout Editor (Rackula-inspired) const LayoutEditorPage = lazy(() => import('./pages/LayoutEditorPage')); +// Pulse Sensor Test Page +const PulseTestPage = lazy(() => import('./pages/PulseTestPage')); // Loading spinner component for Suspense fallbacks const PageLoader = () => ( @@ -198,6 +200,11 @@ export const router = createBrowserRouter([ path: 'layout-editor/:floorId?', element: }>, }, + // Pulse Sensor Test Page + { + path: 'pulse', + element: }>, + }, // 404 catch-all { path: '*',