411 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|