diff --git a/backend/src/routes/environment.routes.ts b/backend/src/routes/environment.routes.ts index 629a09f..5528ead 100644 --- a/backend/src/routes/environment.routes.ts +++ b/backend/src/routes/environment.routes.ts @@ -324,6 +324,137 @@ export async function environmentRoutes(fastify: FastifyInstance) { } }); + /** + * GET /alerts/analytics + * Get alert analytics with response times + */ + fastify.get('/alerts/analytics', { + handler: async (request, reply) => { + try { + const { hours = 168 } = request.query as { hours?: number }; // Default 7 days + const since = new Date(); + since.setHours(since.getHours() - Number(hours)); + + const alerts = await prisma.environmentAlert.findMany({ + where: { + createdAt: { gte: since } + }, + orderBy: { createdAt: 'desc' } + }); + + // Calculate metrics + const totalAlerts = alerts.length; + const resolvedAlerts = alerts.filter(a => a.resolvedAt); + const acknowledgedAlerts = alerts.filter(a => a.acknowledgedAt); + const unresolvedAlerts = alerts.filter(a => !a.resolvedAt); + + // Response times (time from creation to acknowledgement) + const responseTimes = acknowledgedAlerts.map(a => { + const created = new Date(a.createdAt).getTime(); + const acked = new Date(a.acknowledgedAt!).getTime(); + return (acked - created) / 1000 / 60; // minutes + }); + + // Resolution times (time from creation to resolution) + const resolutionTimes = resolvedAlerts.map(a => { + const created = new Date(a.createdAt).getTime(); + const resolved = new Date(a.resolvedAt!).getTime(); + return (resolved - created) / 1000 / 60; // minutes + }); + + // Calculate averages + const avgResponseTime = responseTimes.length > 0 + ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length + : null; + + const avgResolutionTime = resolutionTimes.length > 0 + ? resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length + : null; + + const minResponseTime = responseTimes.length > 0 ? Math.min(...responseTimes) : null; + const maxResponseTime = responseTimes.length > 0 ? Math.max(...responseTimes) : null; + const minResolutionTime = resolutionTimes.length > 0 ? Math.min(...resolutionTimes) : null; + const maxResolutionTime = resolutionTimes.length > 0 ? Math.max(...resolutionTimes) : null; + + // Group by type + const byType: Record = {}; + alerts.forEach(a => { + if (!byType[a.type]) { + byType[a.type] = { count: 0, resolved: 0, avgResolution: null }; + } + byType[a.type].count++; + if (a.resolvedAt) byType[a.type].resolved++; + }); + + // Calculate avg resolution per type + Object.keys(byType).forEach(type => { + const typeAlerts = resolvedAlerts.filter(a => a.type === type); + if (typeAlerts.length > 0) { + const times = typeAlerts.map(a => { + const created = new Date(a.createdAt).getTime(); + const resolved = new Date(a.resolvedAt!).getTime(); + return (resolved - created) / 1000 / 60; + }); + byType[type].avgResolution = times.reduce((a, b) => a + b, 0) / times.length; + } + }); + + // Recent alerts for timeline + const recentAlerts = alerts.slice(0, 10).map(a => ({ + id: a.id, + type: a.type, + severity: a.severity, + message: a.message, + value: a.value, + threshold: a.threshold, + createdAt: a.createdAt, + acknowledgedAt: a.acknowledgedAt, + resolvedAt: a.resolvedAt, + responseTimeMin: a.acknowledgedAt + ? (new Date(a.acknowledgedAt).getTime() - new Date(a.createdAt).getTime()) / 1000 / 60 + : null, + resolutionTimeMin: a.resolvedAt + ? (new Date(a.resolvedAt).getTime() - new Date(a.createdAt).getTime()) / 1000 / 60 + : null + })); + + return { + period: { + hours: Number(hours), + from: since.toISOString(), + to: new Date().toISOString() + }, + summary: { + total: totalAlerts, + resolved: resolvedAlerts.length, + acknowledged: acknowledgedAlerts.length, + unresolved: unresolvedAlerts.length, + resolutionRate: totalAlerts > 0 ? (resolvedAlerts.length / totalAlerts * 100).toFixed(1) : 0 + }, + responseTimes: { + avgMinutes: avgResponseTime?.toFixed(1) || null, + minMinutes: minResponseTime?.toFixed(1) || null, + maxMinutes: maxResponseTime?.toFixed(1) || null + }, + resolutionTimes: { + avgMinutes: avgResolutionTime?.toFixed(1) || null, + minMinutes: minResolutionTime?.toFixed(1) || null, + maxMinutes: maxResolutionTime?.toFixed(1) || null + }, + byType: Object.entries(byType).map(([type, data]) => ({ + type, + ...data, + avgResolutionMin: data.avgResolution?.toFixed(1) || null + })), + recentAlerts + }; + } catch (error) { + fastify.log.error(error); + return reply.status(500).send({ error: 'Failed to fetch alert analytics' }); + } + } + }); + // ==================== ENVIRONMENT PROFILES ==================== /** diff --git a/frontend/src/pages/EnvironmentReportPage.tsx b/frontend/src/pages/EnvironmentReportPage.tsx new file mode 100644 index 0000000..bb14e58 --- /dev/null +++ b/frontend/src/pages/EnvironmentReportPage.tsx @@ -0,0 +1,702 @@ +import { useState, useEffect, useRef } from 'react'; +import { motion } from 'framer-motion'; +import { + FileText, Download, Printer, Calendar, Thermometer, Droplets, Wind, + AlertTriangle, CheckCircle, TrendingUp, Clock, Activity, ChevronDown +} from 'lucide-react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart, ReferenceLine, BarChart, Bar } from 'recharts'; +import api from '../lib/api'; + +interface ReportData { + period: { start: string; end: string }; + devices: { + id: string; + name: string; + readings: number; + stats: { + temperature: { min: number; max: number; avg: number }; + humidity: { min: number; max: number; avg: number }; + vpd: { min: number; max: number; avg: number }; + }; + }[]; + alerts: { + total: number; + resolved: number; + byType: { type: string; count: number }[]; + }; + history: { + timestamp: string; + temperature: number; + humidity: number; + vpd: number; + }[]; + hourlyBreakdown: { + hour: string; + avgTemp: number; + avgHumidity: number; + avgVpd: number; + }[]; + alertAnalytics?: { + summary: { + total: number; + resolved: number; + acknowledged: number; + unresolved: number; + resolutionRate: string | number; + }; + responseTimes: { + avgMinutes: string | null; + minMinutes: string | null; + maxMinutes: string | null; + }; + resolutionTimes: { + avgMinutes: string | null; + minMinutes: string | null; + maxMinutes: string | null; + }; + byType: { + type: string; + count: number; + resolved: number; + avgResolutionMin: string | null; + }[]; + recentAlerts: { + id: string; + type: string; + severity: string; + message: string; + value: number; + threshold: number; + createdAt: string; + acknowledgedAt: string | null; + resolvedAt: string | null; + responseTimeMin: number | null; + resolutionTimeMin: number | null; + }[]; + }; +} + +export default function EnvironmentReportPage() { + const [reportData, setReportData] = useState(null); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState<'24h' | '7d' | '30d'>('24h'); + const reportRef = useRef(null); + + useEffect(() => { + fetchReportData(); + }, [dateRange]); + + async function fetchReportData() { + setLoading(true); + try { + // Fetch Pulse readings and history + const [readingsRes, devicesRes] = await Promise.all([ + api.get('/pulse/readings'), + api.get('/pulse/devices') + ]); + + const devices = devicesRes.data.devices || []; + const readings = readingsRes.data.readings || []; + + // Get history for first device (or all) + const hours = dateRange === '24h' ? 24 : dateRange === '7d' ? 168 : 720; + let history: any[] = []; + + if (devices.length > 0) { + try { + const historyRes = await api.get(`/pulse/devices/${devices[0].id}/history?hours=${hours}`); + history = (historyRes.data.readings || []).map((r: any) => ({ + timestamp: new Date(r.timestamp).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }), + temperature: r.temperature, + humidity: r.humidity, + vpd: r.vpd + })); + } catch { + // Use current readings as fallback + } + } + + // Calculate stats + const deviceStats = readings.map((r: any) => ({ + id: r.deviceId, + name: r.deviceName, + readings: history.length || 1, + stats: { + temperature: { + min: history.length ? Math.min(...history.map(h => h.temperature)) : r.temperature, + max: history.length ? Math.max(...history.map(h => h.temperature)) : r.temperature, + avg: history.length ? history.reduce((a, h) => a + h.temperature, 0) / history.length : r.temperature + }, + humidity: { + min: history.length ? Math.min(...history.map(h => h.humidity)) : r.humidity, + max: history.length ? Math.max(...history.map(h => h.humidity)) : r.humidity, + avg: history.length ? history.reduce((a, h) => a + h.humidity, 0) / history.length : r.humidity + }, + vpd: { + min: history.length ? Math.min(...history.map(h => h.vpd)) : r.vpd, + max: history.length ? Math.max(...history.map(h => h.vpd)) : r.vpd, + avg: history.length ? history.reduce((a, h) => a + h.vpd, 0) / history.length : r.vpd + } + } + })); + + // Calculate hourly breakdown + const hourlyMap = new Map(); + history.forEach(h => { + const hour = new Date(h.timestamp).getHours().toString().padStart(2, '0') + ':00'; + if (!hourlyMap.has(hour)) { + hourlyMap.set(hour, { temps: [], humidities: [], vpds: [] }); + } + const bucket = hourlyMap.get(hour)!; + bucket.temps.push(h.temperature); + bucket.humidities.push(h.humidity); + bucket.vpds.push(h.vpd); + }); + + const hourlyBreakdown = Array.from(hourlyMap.entries()).map(([hour, data]) => ({ + hour, + avgTemp: data.temps.reduce((a, b) => a + b, 0) / data.temps.length, + avgHumidity: data.humidities.reduce((a, b) => a + b, 0) / data.humidities.length, + avgVpd: data.vpds.reduce((a, b) => a + b, 0) / data.vpds.length + })).sort((a, b) => a.hour.localeCompare(b.hour)); + + // Fetch alerts + let alertData = { total: 0, resolved: 0, byType: [] as any[] }; + try { + const alertsRes = await api.get('/environment/alerts'); + const alerts = alertsRes.data || []; + alertData = { + total: alerts.length, + resolved: alerts.filter((a: any) => a.resolvedAt).length, + byType: Object.entries( + alerts.reduce((acc: any, a: any) => { + acc[a.type] = (acc[a.type] || 0) + 1; + return acc; + }, {}) + ).map(([type, count]) => ({ type, count: count as number })) + }; + } catch { + // Ignore + } + + // Fetch alert analytics with response times + let alertAnalytics = null; + try { + const analyticsRes = await api.get(`/environment/alerts/analytics?hours=${hours}`); + alertAnalytics = analyticsRes.data; + } catch { + // Ignore + } + + const now = new Date(); + const startDate = new Date(); + startDate.setHours(startDate.getHours() - hours); + + setReportData({ + period: { + start: startDate.toLocaleDateString(), + end: now.toLocaleDateString() + }, + devices: deviceStats, + alerts: alertData, + history: history.slice(-100), // Limit for chart performance + hourlyBreakdown, + alertAnalytics + }); + } catch (error) { + console.error('Failed to fetch report data:', error); + } finally { + setLoading(false); + } + } + + function handlePrint() { + window.print(); + } + + function handleExportPDF() { + // Use browser print dialog with PDF option + window.print(); + } + + const formatDate = () => { + return new Date().toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( + <> + {/* Print Styles */} + + +
+
+ {/* Header Controls - Hidden in print */} +
+
+

+
+ +
+ Environment Report +

+

+ Generate and export environmental monitoring reports +

+
+ +
+ {/* Date Range Selector */} +
+ + +
+ + {/* Export Buttons */} + + +
+
+ + {/* Report Content */} +
+ {/* Report Header */} +
+
+ + Environment Monitoring Report +
+

Veridian Grow Operations

+

+ + {reportData?.period.start} – {reportData?.period.end} +

+

Generated: {formatDate()}

+
+ + {/* Summary Stats */} +
+ +
+ + Avg Temperature +
+

+ {reportData?.devices[0]?.stats.temperature.avg.toFixed(1) || '--'}°F +

+

+ Range: {reportData?.devices[0]?.stats.temperature.min.toFixed(1)}° – {reportData?.devices[0]?.stats.temperature.max.toFixed(1)}° +

+
+ + +
+ + Avg Humidity +
+

+ {reportData?.devices[0]?.stats.humidity.avg.toFixed(0) || '--'}% +

+

+ Range: {reportData?.devices[0]?.stats.humidity.min.toFixed(0)}% – {reportData?.devices[0]?.stats.humidity.max.toFixed(0)}% +

+
+ + +
+ + Avg VPD +
+

+ {reportData?.devices[0]?.stats.vpd.avg.toFixed(2) || '--'} kPa +

+

+ Range: {reportData?.devices[0]?.stats.vpd.min.toFixed(2)} – {reportData?.devices[0]?.stats.vpd.max.toFixed(2)} +

+
+ + +
+ + Alerts +
+

+ {reportData?.alerts.total || 0} +

+

+ + {reportData?.alerts.resolved || 0} resolved +

+
+
+ + {/* Temperature Trend Chart */} +
+

+ + Temperature Trend +

+
+ + + + + + + + + + + + + + + + + +
+
+ + {/* Humidity and VPD Charts */} +
+
+

+ + Humidity Trend +

+
+ + + + + + + + + + + + + + + +
+
+ +
+

+ + VPD Trend +

+
+ + + + + + + + + + + + + + + + + +
+
+
+ + {/* Hourly Breakdown */} + {reportData?.hourlyBreakdown && reportData.hourlyBreakdown.length > 0 && ( +
+

+ + Hourly Average Temperature +

+
+ + + + + + + + + +
+
+ )} + + {/* Device Summary Table */} +
+

Device Summary

+
+ + + + + + + + + + + + {reportData?.devices.map(device => ( + + + + + + + + ))} + +
DeviceReadingsTemp (Avg)Humidity (Avg)VPD (Avg)
{device.name}{device.readings} + {device.stats.temperature.avg.toFixed(1)}°F + + {device.stats.humidity.avg.toFixed(0)}% + + {device.stats.vpd.avg.toFixed(2)} +
+
+
+ + {/* Alert Summary */} + {reportData?.alerts.byType && reportData.alerts.byType.length > 0 && ( +
+

+ + Alert Summary +

+
+ {reportData.alerts.byType.map(alert => ( +
+

{alert.type.replace('_', ' ')}

+

{alert.count}

+
+ ))} +
+
+ )} + + {/* Response Time Analytics */} + {reportData?.alertAnalytics && ( +
+

+ + Alert Response Time Analytics +

+ + {/* KPIs */} +
+
+

Resolution Rate

+

+ {reportData.alertAnalytics.summary.resolutionRate}% +

+

+ {reportData.alertAnalytics.summary.resolved} of {reportData.alertAnalytics.summary.total} +

+
+ +
+

Avg Resolution Time

+

+ {reportData.alertAnalytics.resolutionTimes.avgMinutes || '--'} +

+

minutes

+
+ +
+

Fastest Resolution

+

+ {reportData.alertAnalytics.resolutionTimes.minMinutes || '--'} +

+

minutes

+
+ +
+

Slowest Resolution

+

+ {reportData.alertAnalytics.resolutionTimes.maxMinutes || '--'} +

+

minutes

+
+
+ + {/* By Alert Type */} + {reportData.alertAnalytics.byType.length > 0 && ( +
+

Resolution Time by Alert Type

+
+ + + + + + + + + + + + {reportData.alertAnalytics.byType.map(alertType => ( + + + + + + + + ))} + +
Alert TypeTotalResolvedAvg ResolutionResolution Rate
{alertType.type.replace('_', ' ')}{alertType.count} + {alertType.resolved} + + + {alertType.avgResolutionMin ? `${alertType.avgResolutionMin} min` : '--'} + + + 0 && alertType.resolved / alertType.count >= 0.9 + ? 'bg-green-500/20 text-green-400' + : alertType.count > 0 && alertType.resolved / alertType.count >= 0.5 + ? 'bg-amber-500/20 text-amber-400' + : 'bg-red-500/20 text-red-400' + }`}> + {alertType.count > 0 ? Math.round(alertType.resolved / alertType.count * 100) : 0}% + +
+
+
+ )} + + {/* Recent Alerts Timeline */} + {reportData.alertAnalytics.recentAlerts.length > 0 && ( +
+

Recent Alert Timeline

+
+ {reportData.alertAnalytics.recentAlerts.slice(0, 5).map(alert => ( +
+
+
+ + {alert.severity} + + {alert.type.replace('_', ' ')} +
+ + + {alert.resolvedAt ? 'Resolved' : 'Pending'} + +
+

{alert.message}

+
+ + Triggered: {new Date(alert.createdAt).toLocaleString()} + + {alert.resolutionTimeMin && ( + + Return to Normal: {alert.resolutionTimeMin.toFixed(1)} min + + )} +
+
+ ))} +
+
+ )} +
+ )} + + {/* Footer */} +
+

Veridian Grow Operations Manager • Environment Monitoring Report

+

Generated automatically • Data subject to sensor accuracy

+
+
+
+
+ + ); +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index b9df9a9..8042585 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -55,6 +55,9 @@ const PulseTestPage = lazy(() => import('./pages/PulseTestPage')); // Failsafe Settings (Admin) const FailsafeSettingsPage = lazy(() => import('./pages/FailsafeSettingsPage')); +// Environment Report +const EnvironmentReportPage = lazy(() => import('./pages/EnvironmentReportPage')); + // Loading spinner component for Suspense fallbacks const PageLoader = () => (
@@ -213,6 +216,11 @@ export const router = createBrowserRouter([ path: 'failsafe', element: }>, }, + // Environment Report + { + path: 'reports/environment', + element: }>, + }, // 404 catch-all { path: '*',