feat: Pulse test page and Environment Dashboard integration
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-05 20:57:00 -08:00
parent 215d24eb0e
commit c3dcefe857
3 changed files with 616 additions and 73 deletions

View file

@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Thermometer, Droplets, Wind, Sun, AlertTriangle, Thermometer, Droplets, Wind, Sun, AlertTriangle,
Activity, Settings, RefreshCw, ChevronRight, Loader2 Activity, Settings, RefreshCw, ChevronRight, Wifi, WifiOff, Bell
} from 'lucide-react'; } from 'lucide-react';
import api from '../lib/api'; import api from '../lib/api';
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives'; import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
import { useNotifications } from '../hooks/useNotifications';
interface SensorData { interface SensorData {
id: string; id: string;
@ -21,6 +22,18 @@ interface SensorData {
alertCount: number; alertCount: number;
} }
interface PulseReading {
deviceId: string;
deviceName: string;
temperature: number;
humidity: number;
vpd: number;
dewpoint: number;
light?: number;
co2?: number;
timestamp: Date;
}
interface EnvironmentAlert { interface EnvironmentAlert {
id: string; id: string;
type: string; type: string;
@ -63,24 +76,43 @@ const sensorAccents: Record<string, 'success' | 'warning' | 'destructive' | 'acc
export default function EnvironmentDashboard() { export default function EnvironmentDashboard() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null); const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [sensors, setSensors] = useState<SensorData[]>([]); const [sensors, setSensors] = useState<SensorData[]>([]);
const [pulseReadings, setPulseReadings] = useState<PulseReading[]>([]);
const [pulseConnected, setPulseConnected] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [selectedRoom, setSelectedRoom] = useState<string>(''); const [selectedRoom, setSelectedRoom] = useState<string>('');
const { connected: wsConnected, alerts: wsAlerts, unreadCount } = useNotifications();
useEffect(() => { useEffect(() => {
loadData(); loadData();
const interval = setInterval(loadData, 60000); const interval = setInterval(loadData, 30000); // Refresh every 30s
return () => clearInterval(interval); return () => clearInterval(interval);
}, [selectedRoom]); }, [selectedRoom]);
const loadData = async () => { const loadData = async () => {
try { try {
// Load internal sensors and dashboard
const [dashRes, sensorsRes] = await Promise.all([ const [dashRes, sensorsRes] = await Promise.all([
api.get('/api/environment/dashboard', { params: { roomId: selectedRoom || undefined } }), api.get('/api/environment/dashboard', { params: { roomId: selectedRoom || undefined } }),
api.get('/api/environment/sensors', { params: { roomId: selectedRoom || undefined } }) api.get('/api/environment/sensors', { params: { roomId: selectedRoom || undefined } })
]); ]);
setDashboard(dashRes.data); setDashboard(dashRes.data);
setSensors(sensorsRes.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) { } catch (error) {
console.error('Failed to load environment data:', error); console.error('Failed to load environment data:', error);
} finally { } finally {
@ -94,6 +126,18 @@ export default function EnvironmentDashboard() {
setRefreshing(false); 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 => { const getStatusClass = (value: number, min?: number, max?: number): string => {
if (min && value < min) return 'text-accent'; if (min && value < min) return 'text-accent';
if (max && value > max) return 'text-destructive'; if (max && value > max) return 'text-destructive';
@ -111,23 +155,72 @@ export default function EnvironmentDashboard() {
); );
} }
const hasData = sensors.length > 0 || pulseReadings.length > 0;
return ( return (
<div className="space-y-6 pb-20 animate-in"> <div className="space-y-6 pb-20 animate-in">
<PageHeader <PageHeader
title="Environment Monitor" title="Environment Monitor"
subtitle="Real-time sensor data and alerts" subtitle="Real-time sensor data and alerts"
actions={ actions={
<button <div className="flex items-center gap-3">
onClick={handleRefresh} {/* Live indicator */}
disabled={refreshing} <div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-tertiary)]">
className="btn btn-ghost p-2" {wsConnected ? (
> <Wifi className="w-4 h-4 text-green-500" />
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} /> ) : (
</button> <WifiOff className="w-4 h-4 text-gray-400" />
)}
<span className="text-xs font-medium">
{wsConnected ? 'Live' : 'Offline'}
</span>
{unreadCount > 0 && (
<span className="flex items-center gap-1 text-red-500">
<Bell className="w-3 h-3" />
{unreadCount}
</span>
)}
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="btn btn-ghost p-2"
>
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} />
</button>
</div>
} }
/> />
{/* Active Alerts */} {/* Real-time Alerts from WebSocket */}
{wsAlerts.length > 0 && (
<div className="card p-4 border-destructive/50 bg-destructive-muted">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="text-destructive" size={18} />
<span className="font-medium text-destructive text-sm">
{wsAlerts.length} Real-time Alert{wsAlerts.length > 1 ? 's' : ''}
</span>
</div>
<div className="space-y-2">
{wsAlerts.slice(0, 3).map((alert, idx) => (
<div key={alert.id || idx} className="card p-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-primary">{alert.sensorName}: {alert.type}</p>
<p className="text-xs text-tertiary">
Value: {alert.value?.toFixed(1)} (threshold: {alert.threshold?.toFixed(1)})
</p>
</div>
<span className="text-xs text-tertiary">
{new Date(alert.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
</div>
)}
{/* Active Alerts from database */}
{dashboard?.alerts.active && dashboard.alerts.active > 0 && ( {dashboard?.alerts.active && dashboard.alerts.active > 0 && (
<div className="card p-4 border-destructive/50 bg-destructive-muted"> <div className="card p-4 border-destructive/50 bg-destructive-muted">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
@ -161,79 +254,206 @@ export default function EnvironmentDashboard() {
</div> </div>
)} )}
{/* Quick Stats Grid */} {/* Pulse Sensors Section */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> {pulseReadings.length > 0 && (
{Object.entries(dashboard?.averages || {}).map(([type, value]) => { <div>
const Icon = sensorIcons[type] || Activity; <div className="flex items-center justify-between mb-3">
const readings = dashboard?.readings[type] || []; <h2 className="text-sm font-medium text-primary flex items-center gap-2">
const unit = readings[0]?.unit || ''; <Activity className="w-4 h-4 text-green-500" />
const accent = sensorAccents[type] || 'accent'; Pulse Grow Sensors
</h2>
return ( <span className="text-xs text-tertiary">
<MetricCard {pulseReadings.length} device{pulseReadings.length !== 1 ? 's' : ''}
key={type} </span>
icon={Icon} </div>
label={type.replace('_', ' ').toLowerCase()} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
value={`${typeof value === 'number' ? value.toFixed(1) : value}${unit}`} {pulseReadings.map((reading, index) => (
accent={accent}
subtitle={`${readings.length} sensor${readings.length !== 1 ? 's' : ''}`}
/>
);
})}
</div>
{/* Sensors List */}
<div>
<h2 className="text-sm font-medium text-primary mb-3">All Sensors</h2>
<div className="card overflow-hidden divide-y divide-subtle">
{sensors.map(sensor => {
const Icon = sensorIcons[sensor.type] || Activity;
const reading = sensor.latestReading;
return (
<div <div
key={sensor.id} key={reading.deviceId || index}
className="p-4 flex items-center gap-4 hover:bg-tertiary transition-colors duration-fast" className="card p-5 space-y-4"
> >
<div className="w-9 h-9 rounded-md bg-accent-muted flex items-center justify-center"> {/* Device Header */}
<Icon className="text-accent" size={16} /> <div className="flex items-center justify-between">
<h3 className="font-semibold text-primary">
{reading.deviceName || `Device ${reading.deviceId}`}
</h3>
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
</div> </div>
<div className="flex-1 min-w-0">
<div className="font-medium text-primary text-sm">{sensor.name}</div> {/* Metrics Grid */}
<div className="text-xs text-tertiary capitalize"> <div className="grid grid-cols-2 gap-3">
{sensor.type.replace('_', ' ').toLowerCase()} {/* Temperature */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Thermometer className={`w-4 h-4 ${getTempColor(reading.temperature)}`} />
<span className="text-xs text-tertiary">Temp</span>
</div>
<p className={`text-xl font-bold ${getTempColor(reading.temperature)}`}>
{reading.temperature.toFixed(1)}°F
</p>
</div> </div>
</div>
<div className="text-right"> {/* Humidity */}
{reading ? ( <div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<> <div className="flex items-center gap-2 mb-1">
<div className={`text-base font-semibold ${getStatusClass(reading.value, sensor.minThreshold, sensor.maxThreshold)}`}> <Droplets className="w-4 h-4 text-blue-500" />
{reading.value.toFixed(1)} {reading.unit} <span className="text-xs text-tertiary">Humidity</span>
</div>
<p className="text-xl font-bold text-blue-500">
{reading.humidity.toFixed(1)}%
</p>
</div>
{/* VPD */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Wind className={`w-4 h-4 ${getVpdColor(reading.vpd)}`} />
<span className="text-xs text-tertiary">VPD</span>
</div>
<p className={`text-xl font-bold ${getVpdColor(reading.vpd)}`}>
{reading.vpd.toFixed(2)} kPa
</p>
</div>
{/* Dewpoint */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Droplets className="w-4 h-4 text-cyan-500" />
<span className="text-xs text-tertiary">Dewpoint</span>
</div>
<p className="text-xl font-bold text-cyan-500">
{reading.dewpoint.toFixed(1)}°F
</p>
</div>
{/* CO2 (if available) */}
{reading.co2 !== undefined && (
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Wind className="w-4 h-4 text-purple-500" />
<span className="text-xs text-tertiary">CO2</span>
</div> </div>
<div className="text-[10px] text-tertiary"> <p className="text-xl font-bold text-purple-500">
{new Date(reading.timestamp).toLocaleTimeString()} {reading.co2} ppm
</p>
</div>
)}
{/* Light (if available) */}
{reading.light !== undefined && (
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Sun className="w-4 h-4 text-yellow-500" />
<span className="text-xs text-tertiary">Light</span>
</div> </div>
</> <p className="text-xl font-bold text-yellow-500">
) : ( {reading.light.toFixed(0)} lux
<span className="text-tertiary text-xs">No data</span> </p>
</div>
)} )}
</div> </div>
{sensor.alertCount > 0 && (
<span className="badge-destructive text-[10px]">{sensor.alertCount}</span> {/* Timestamp */}
)} <p className="text-xs text-tertiary text-right">
<ChevronRight size={14} className="text-tertiary" /> {new Date(reading.timestamp).toLocaleString()}
</p>
</div> </div>
); ))}
})} </div>
{sensors.length === 0 && (
<EmptyState
icon={Activity}
title="No sensors configured"
description="Add sensors to start monitoring."
/>
)}
</div> </div>
</div> )}
{/* Quick Stats Grid (from internal sensors) */}
{Object.keys(dashboard?.averages || {}).length > 0 && (
<>
<h2 className="text-sm font-medium text-primary">Internal Sensors</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{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 (
<MetricCard
key={type}
icon={Icon}
label={type.replace('_', ' ').toLowerCase()}
value={`${typeof value === 'number' ? value.toFixed(1) : value}${unit}`}
accent={accent}
subtitle={`${readings.length} sensor${readings.length !== 1 ? 's' : ''}`}
/>
);
})}
</div>
</>
)}
{/* Internal Sensors List */}
{sensors.length > 0 && (
<div>
<h2 className="text-sm font-medium text-primary mb-3">All Internal Sensors</h2>
<div className="card overflow-hidden divide-y divide-subtle">
{sensors.map(sensor => {
const Icon = sensorIcons[sensor.type] || Activity;
const reading = sensor.latestReading;
return (
<div
key={sensor.id}
className="p-4 flex items-center gap-4 hover:bg-tertiary transition-colors duration-fast"
>
<div className="w-9 h-9 rounded-md bg-accent-muted flex items-center justify-center">
<Icon className="text-accent" size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-primary text-sm">{sensor.name}</div>
<div className="text-xs text-tertiary capitalize">
{sensor.type.replace('_', ' ').toLowerCase()}
</div>
</div>
<div className="text-right">
{reading ? (
<>
<div className={`text-base font-semibold ${getStatusClass(reading.value, sensor.minThreshold, sensor.maxThreshold)}`}>
{reading.value.toFixed(1)} {reading.unit}
</div>
<div className="text-[10px] text-tertiary">
{new Date(reading.timestamp).toLocaleTimeString()}
</div>
</>
) : (
<span className="text-tertiary text-xs">No data</span>
)}
</div>
{sensor.alertCount > 0 && (
<span className="badge-destructive text-[10px]">{sensor.alertCount}</span>
)}
<ChevronRight size={14} className="text-tertiary" />
</div>
);
})}
</div>
</div>
)}
{/* Empty State - only show if no data at all */}
{!hasData && !pulseConnected && (
<div className="card p-8">
<EmptyState
icon={Activity}
title="No sensors configured"
description={
<div className="space-y-2">
<p>Add sensors to start monitoring.</p>
<p className="text-sm">
To connect your Pulse Grow sensor, set the <code className="px-1 py-0.5 bg-[var(--color-bg-tertiary)] rounded">PULSE_API_KEY</code> environment variable.
</p>
</div>
}
/>
</div>
)}
{/* Environment Profile */} {/* Environment Profile */}
{dashboard?.profile && ( {dashboard?.profile && (

View file

@ -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<PulseStatus | null>(null);
const [devices, setDevices] = useState<PulseDevice[]>([]);
const [readings, setReadings] = useState<PulseReading[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date | null>(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 (
<div className="max-w-6xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">
Pulse Sensor Dashboard
</h1>
<p className="text-sm text-[var(--color-text-tertiary)]">
Live environment monitoring from Pulse Grow
</p>
</div>
<div className="flex items-center gap-4">
{/* WebSocket Status */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-tertiary)]">
{wsConnected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-red-500" />
)}
<span className="text-xs font-medium">
{wsConnected ? 'Live' : 'Offline'}
</span>
{unreadCount > 0 && (
<span className="flex items-center gap-1 text-red-500">
<Bell className="w-3 h-3" />
{unreadCount}
</span>
)}
</div>
{/* Refresh Button */}
<button
onClick={fetchData}
disabled={loading}
className="p-2 rounded-lg bg-[var(--color-bg-tertiary)] hover:bg-[var(--color-primary)] hover:text-white transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Connection Status Card */}
<div className="p-4 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${status?.connected ? 'bg-green-500/10' : 'bg-red-500/10'}`}>
<Activity className={`w-5 h-5 ${getStatusColor(status?.connected || false)}`} />
</div>
<div>
<h3 className="font-semibold text-[var(--color-text-primary)]">
Pulse API Connection
</h3>
<p className="text-sm text-[var(--color-text-tertiary)]">
{status?.connected
? `Connected • ${status.deviceCount} device(s)`
: status?.error || 'Not connected'}
</p>
</div>
</div>
{lastUpdate && (
<p className="text-xs text-[var(--color-text-tertiary)]">
Last update: {lastUpdate.toLocaleTimeString()}
</p>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-500">
{error}
</div>
)}
{/* Sensor Readings Grid */}
{readings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{readings.map((reading, index) => (
<div
key={reading.deviceId || index}
className="p-5 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)] space-y-4"
>
{/* Device Header */}
<div className="flex items-center justify-between">
<h3 className="font-semibold text-[var(--color-text-primary)]">
{reading.deviceName || `Device ${reading.deviceId}`}
</h3>
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-3">
{/* Temperature */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Thermometer className={`w-4 h-4 ${getTempColor(reading.temperature)}`} />
<span className="text-xs text-[var(--color-text-tertiary)]">Temp</span>
</div>
<p className={`text-xl font-bold ${getTempColor(reading.temperature)}`}>
{reading.temperature.toFixed(1)}°F
</p>
</div>
{/* Humidity */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Droplets className="w-4 h-4 text-blue-500" />
<span className="text-xs text-[var(--color-text-tertiary)]">Humidity</span>
</div>
<p className="text-xl font-bold text-blue-500">
{reading.humidity.toFixed(1)}%
</p>
</div>
{/* VPD */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Wind className={`w-4 h-4 ${getVpdColor(reading.vpd)}`} />
<span className="text-xs text-[var(--color-text-tertiary)]">VPD</span>
</div>
<p className={`text-xl font-bold ${getVpdColor(reading.vpd)}`}>
{reading.vpd.toFixed(2)} kPa
</p>
</div>
{/* Dewpoint */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Droplets className="w-4 h-4 text-cyan-500" />
<span className="text-xs text-[var(--color-text-tertiary)]">Dewpoint</span>
</div>
<p className="text-xl font-bold text-cyan-500">
{reading.dewpoint.toFixed(1)}°F
</p>
</div>
{/* Light (if available) */}
{reading.light !== undefined && (
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Sun className="w-4 h-4 text-yellow-500" />
<span className="text-xs text-[var(--color-text-tertiary)]">Light</span>
</div>
<p className="text-xl font-bold text-yellow-500">
{reading.light.toFixed(0)} lux
</p>
</div>
)}
{/* CO2 (if available) */}
{reading.co2 !== undefined && (
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Wind className="w-4 h-4 text-purple-500" />
<span className="text-xs text-[var(--color-text-tertiary)]">CO2</span>
</div>
<p className="text-xl font-bold text-purple-500">
{reading.co2} ppm
</p>
</div>
)}
</div>
{/* Timestamp */}
<p className="text-xs text-[var(--color-text-tertiary)] text-right">
{new Date(reading.timestamp).toLocaleString()}
</p>
</div>
))}
</div>
)}
{/* Empty State */}
{!loading && readings.length === 0 && status?.connected && (
<div className="p-8 text-center rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
<Activity className="w-12 h-12 mx-auto mb-4 text-[var(--color-text-tertiary)]" />
<h3 className="font-semibold text-[var(--color-text-primary)] mb-2">
No Sensor Readings
</h3>
<p className="text-sm text-[var(--color-text-tertiary)]">
Waiting for data from Pulse devices...
</p>
</div>
)}
{/* Recent Alerts */}
{alerts.length > 0 && (
<div className="p-4 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
<h3 className="font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
<Bell className="w-4 h-4" />
Recent Alerts ({alerts.length})
</h3>
<div className="space-y-2">
{alerts.slice(0, 5).map((alert, index) => (
<div
key={alert.id || index}
className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm"
>
<p className="font-medium text-red-500">{alert.sensorName}</p>
<p className="text-[var(--color-text-tertiary)]">
{alert.type}: {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)})
</p>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{new Date(alert.timestamp).toLocaleTimeString()}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

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