feat: Pulse test page and Environment Dashboard integration
This commit is contained in:
parent
215d24eb0e
commit
c3dcefe857
3 changed files with 616 additions and 73 deletions
|
|
@ -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 && (
|
||||||
|
|
|
||||||
316
frontend/src/pages/PulseTestPage.tsx
Normal file
316
frontend/src/pages/PulseTestPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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: '*',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue