ca-grow-ops-manager/frontend/src/pages/EnvironmentDashboard.tsx
fullsizemalt 1abb972d37
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
feat: Pulse sparklines, sidebar updates, and WS fix
2026-01-05 22:45:37 -08:00

411 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
Thermometer, Droplets, Wind, Sun, AlertTriangle,
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';
import { PulseSensorCard } from '../components/dashboard/PulseSensorCard';
interface SensorData {
id: string;
name: string;
type: string;
roomId?: string;
latestReading?: {
value: number;
unit: string;
timestamp: string;
};
minThreshold?: number;
maxThreshold?: 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 {
id: string;
type: string;
severity: string;
message: string;
value?: number;
createdAt: string;
sensor?: { name: string };
}
interface DashboardData {
sensors: number;
readings: Record<string, { value: number; unit: string; sensor: string }[]>;
averages: Record<string, number>;
alerts: {
active: number;
list: EnvironmentAlert[];
};
profile?: any;
}
const sensorIcons: Record<string, any> = {
TEMPERATURE: Thermometer,
HUMIDITY: Droplets,
CO2: Wind,
LIGHT_PAR: Sun,
LIGHT_LUX: Sun,
VPD: Activity,
};
const sensorAccents: Record<string, 'success' | 'warning' | 'destructive' | 'accent'> = {
TEMPERATURE: 'warning',
HUMIDITY: 'accent',
CO2: 'accent',
LIGHT_PAR: 'warning',
LIGHT_LUX: 'warning',
VPD: 'success',
};
export default function EnvironmentDashboard() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [sensors, setSensors] = useState<SensorData[]>([]);
const [pulseReadings, setPulseReadings] = useState<PulseReading[]>([]);
const [sparklines, setSparklines] = useState<Record<string, PulseReading[]>>({});
const [pulseConnected, setPulseConnected] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedRoom, setSelectedRoom] = useState<string>('');
const { connected: wsConnected, alerts: wsAlerts, unreadCount } = useNotifications();
useEffect(() => {
loadData();
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('/environment/dashboard', { params: { roomId: selectedRoom || undefined } }),
api.get('/environment/sensors', { params: { roomId: selectedRoom || undefined } })
]);
setDashboard(dashRes.data);
setSensors(sensorsRes.data);
// Try to load Pulse data
try {
const pulseStatusRes = await api.get('/pulse/status');
setPulseConnected(pulseStatusRes.data.connected);
if (pulseStatusRes.data.connected) {
// Fetch readings and sparklines in parallel
// Use catch for sparklines to avoid failing the whole dash if rate limited
const [readingsRes, sparklinesRes] = await Promise.all([
api.get('/pulse/readings'),
api.get('/pulse/sparklines').catch(() => ({ data: { sparklines: {} } }))
]);
setPulseReadings(readingsRes.data.readings || []);
setSparklines(sparklinesRes.data.sparklines || {});
}
} catch (pulseError) {
console.log('Pulse not configured or error:', pulseError);
setPulseConnected(false);
}
} catch (error) {
console.error('Failed to load environment data:', error);
} finally {
setLoading(false);
}
};
const handleRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const getStatusClass = (value: number, min?: number, max?: number): string => {
if (min && value < min) return 'text-accent';
if (max && value > max) return 'text-destructive';
return 'text-success';
};
if (loading) {
return (
<div className="space-y-6 animate-in">
<PageHeader title="Environment Monitor" subtitle="Loading..." />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
</div>
</div>
);
}
const hasData = sensors.length > 0 || pulseReadings.length > 0;
return (
<div className="space-y-6 pb-20 animate-in">
<PageHeader
title="Environment Monitor"
subtitle="Real-time sensor data and alerts"
actions={
<div className="flex items-center gap-3">
{/* Live indicator */}
<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-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>
}
/>
{/* 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 && (
<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">
{dashboard.alerts.active} Active Alert{dashboard.alerts.active > 1 ? 's' : ''}
</span>
</div>
<div className="space-y-2">
{dashboard.alerts.list.slice(0, 3).map(alert => (
<div
key={alert.id}
className="card p-3 flex items-center justify-between"
>
<div>
<p className="text-sm font-medium text-primary">{alert.message}</p>
<p className="text-xs text-tertiary">
{new Date(alert.createdAt).toLocaleString()}
</p>
</div>
<span className={`
text-[10px] font-medium px-2 py-0.5 rounded
${alert.severity === 'CRITICAL' ? 'badge-destructive' :
alert.severity === 'WARNING' ? 'badge-warning' : 'badge'}
`}>
{alert.severity}
</span>
</div>
))}
</div>
</div>
)}
{/* Pulse Sensors Section */}
{pulseReadings.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-primary flex items-center gap-2">
<Activity className="w-4 h-4 text-green-500" />
Pulse Grow Sensors
</h2>
<span className="text-xs text-tertiary">
{pulseReadings.length} device{pulseReadings.length !== 1 ? 's' : ''}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pulseReadings.map((reading) => (
<PulseSensorCard
key={reading.deviceId}
reading={reading}
history={{
temperature: (sparklines[reading.deviceId] || []).map(r => r.temperature)
}}
/>
))}
</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="Add sensors to start monitoring. To connect your Pulse Grow sensor, set the PULSE_API_KEY environment variable."
/>
</div>
)}
{/* Environment Profile */}
{dashboard?.profile && (
<div className="card p-4">
<div className="flex items-center gap-2 mb-4">
<Settings size={16} className="text-tertiary" />
<span className="font-medium text-primary text-sm">
Active Profile: {dashboard.profile.name}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{dashboard.profile.tempMinF && dashboard.profile.tempMaxF && (
<div>
<span className="text-xs text-tertiary uppercase tracking-wider">Temperature</span>
<div className="font-medium text-primary">
{dashboard.profile.tempMinF}°F - {dashboard.profile.tempMaxF}°F
</div>
</div>
)}
{dashboard.profile.humidityMin && dashboard.profile.humidityMax && (
<div>
<span className="text-xs text-tertiary uppercase tracking-wider">Humidity</span>
<div className="font-medium text-primary">
{dashboard.profile.humidityMin}% - {dashboard.profile.humidityMax}%
</div>
</div>
)}
{dashboard.profile.co2Min && dashboard.profile.co2Max && (
<div>
<span className="text-xs text-tertiary uppercase tracking-wider">CO</span>
<div className="font-medium text-primary">
{dashboard.profile.co2Min} - {dashboard.profile.co2Max} ppm
</div>
</div>
)}
{dashboard.profile.lightHours && (
<div>
<span className="text-xs text-tertiary uppercase tracking-wider">Light Cycle</span>
<div className="font-medium text-primary">
{dashboard.profile.lightHours}h on
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}