feat: Environment Reports with alert response time analytics and PDF export
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-06 01:46:59 -08:00
parent 7cb7843ceb
commit 28532d4d9b
3 changed files with 841 additions and 0 deletions

View file

@ -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<string, { count: number; resolved: number; avgResolution: number | null }> = {};
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 ==================== // ==================== ENVIRONMENT PROFILES ====================
/** /**

View file

@ -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<ReportData | null>(null);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState<'24h' | '7d' | '30d'>('24h');
const reportRef = useRef<HTMLDivElement>(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<string, { temps: number[]; humidities: number[]; vpds: number[] }>();
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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-8 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
);
}
return (
<>
{/* Print Styles */}
<style>{`
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.no-print { display: none !important; }
.print-only { display: block !important; }
.print-break { page-break-before: always; }
.bg-gradient-to-br { background: white !important; }
* { color: black !important; }
.text-white, .text-slate-300, .text-slate-400 { color: #333 !important; }
}
`}</style>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-8">
<div className="max-w-5xl mx-auto">
{/* Header Controls - Hidden in print */}
<div className="no-print mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 shadow-lg shadow-blue-500/25">
<FileText className="w-6 h-6 text-white" />
</div>
Environment Report
</h1>
<p className="text-slate-400 ml-14 mt-1">
Generate and export environmental monitoring reports
</p>
</div>
<div className="flex items-center gap-3">
{/* Date Range Selector */}
<div className="relative">
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value as any)}
className="appearance-none bg-slate-800 border border-slate-700 rounded-xl px-4 py-2.5 pr-10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
</div>
{/* Export Buttons */}
<button
onClick={handlePrint}
className="flex items-center gap-2 px-4 py-2.5 bg-slate-700 hover:bg-slate-600 text-white rounded-xl text-sm font-medium transition-all"
>
<Printer className="w-4 h-4" />
Print
</button>
<button
onClick={handleExportPDF}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl text-sm font-medium transition-all shadow-lg shadow-blue-500/25"
>
<Download className="w-4 h-4" />
Export PDF
</button>
</div>
</div>
{/* Report Content */}
<div ref={reportRef} className="space-y-6">
{/* Report Header */}
<div className="p-8 rounded-2xl bg-slate-800/50 border border-slate-700/50 text-center">
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-emerald-500/20 text-emerald-400 rounded-full text-sm font-medium mb-4">
<Activity className="w-4 h-4" />
Environment Monitoring Report
</div>
<h2 className="text-2xl font-bold text-white mb-2">Veridian Grow Operations</h2>
<p className="text-slate-400">
<Calendar className="w-4 h-4 inline mr-2" />
{reportData?.period.start} {reportData?.period.end}
</p>
<p className="text-xs text-slate-500 mt-2">Generated: {formatDate()}</p>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 rounded-2xl bg-gradient-to-br from-red-500/20 to-orange-500/10 border border-red-500/20"
>
<div className="flex items-center gap-2 mb-2 text-slate-400">
<Thermometer className="w-5 h-5 text-red-400" />
<span className="text-sm">Avg Temperature</span>
</div>
<p className="text-3xl font-bold text-white">
{reportData?.devices[0]?.stats.temperature.avg.toFixed(1) || '--'}°F
</p>
<p className="text-xs text-slate-500 mt-1">
Range: {reportData?.devices[0]?.stats.temperature.min.toFixed(1)}° {reportData?.devices[0]?.stats.temperature.max.toFixed(1)}°
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="p-5 rounded-2xl bg-gradient-to-br from-blue-500/20 to-cyan-500/10 border border-blue-500/20"
>
<div className="flex items-center gap-2 mb-2 text-slate-400">
<Droplets className="w-5 h-5 text-blue-400" />
<span className="text-sm">Avg Humidity</span>
</div>
<p className="text-3xl font-bold text-white">
{reportData?.devices[0]?.stats.humidity.avg.toFixed(0) || '--'}%
</p>
<p className="text-xs text-slate-500 mt-1">
Range: {reportData?.devices[0]?.stats.humidity.min.toFixed(0)}% {reportData?.devices[0]?.stats.humidity.max.toFixed(0)}%
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="p-5 rounded-2xl bg-gradient-to-br from-purple-500/20 to-pink-500/10 border border-purple-500/20"
>
<div className="flex items-center gap-2 mb-2 text-slate-400">
<Wind className="w-5 h-5 text-purple-400" />
<span className="text-sm">Avg VPD</span>
</div>
<p className="text-3xl font-bold text-white">
{reportData?.devices[0]?.stats.vpd.avg.toFixed(2) || '--'} kPa
</p>
<p className="text-xs text-slate-500 mt-1">
Range: {reportData?.devices[0]?.stats.vpd.min.toFixed(2)} {reportData?.devices[0]?.stats.vpd.max.toFixed(2)}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="p-5 rounded-2xl bg-gradient-to-br from-amber-500/20 to-yellow-500/10 border border-amber-500/20"
>
<div className="flex items-center gap-2 mb-2 text-slate-400">
<AlertTriangle className="w-5 h-5 text-amber-400" />
<span className="text-sm">Alerts</span>
</div>
<p className="text-3xl font-bold text-white">
{reportData?.alerts.total || 0}
</p>
<p className="text-xs text-slate-500 mt-1">
<CheckCircle className="w-3 h-3 inline mr-1 text-green-400" />
{reportData?.alerts.resolved || 0} resolved
</p>
</motion.div>
</div>
{/* Temperature Trend Chart */}
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-400" />
Temperature Trend
</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<defs>
<linearGradient id="reportTempGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={10} tickLine={false} />
<YAxis stroke="#64748b" fontSize={10} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} />
<ReferenceLine y={82} stroke="#f59e0b" strokeDasharray="5 5" label={{ value: 'Max', fill: '#f59e0b', fontSize: 10 }} />
<ReferenceLine y={65} stroke="#3b82f6" strokeDasharray="5 5" label={{ value: 'Min', fill: '#3b82f6', fontSize: 10 }} />
<Area type="monotone" dataKey="temperature" stroke="#ef4444" fill="url(#reportTempGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Humidity and VPD Charts */}
<div className="grid grid-cols-2 gap-6">
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-400" />
Humidity Trend
</h3>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<defs>
<linearGradient id="reportHumidityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={9} tickLine={false} />
<YAxis stroke="#64748b" fontSize={9} tickLine={false} domain={[0, 100]} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} />
<Area type="monotone" dataKey="humidity" stroke="#3b82f6" fill="url(#reportHumidityGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
<Wind className="w-4 h-4 text-purple-400" />
VPD Trend
</h3>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<defs>
<linearGradient id="reportVpdGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={9} tickLine={false} />
<YAxis stroke="#64748b" fontSize={9} tickLine={false} domain={[0, 2]} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} />
<ReferenceLine y={1.2} stroke="#f59e0b" strokeDasharray="3 3" />
<ReferenceLine y={0.8} stroke="#3b82f6" strokeDasharray="3 3" />
<Area type="monotone" dataKey="vpd" stroke="#a855f7" fill="url(#reportVpdGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Hourly Breakdown */}
{reportData?.hourlyBreakdown && reportData.hourlyBreakdown.length > 0 && (
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50 print-break">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-cyan-400" />
Hourly Average Temperature
</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={reportData.hourlyBreakdown}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="hour" stroke="#64748b" fontSize={10} tickLine={false} />
<YAxis stroke="#64748b" fontSize={10} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} />
<Bar dataKey="avgTemp" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Device Summary Table */}
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="text-lg font-semibold text-white mb-4">Device Summary</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-xs text-slate-400 uppercase tracking-wider">
<th className="pb-3">Device</th>
<th className="pb-3 text-center">Readings</th>
<th className="pb-3 text-center">Temp (Avg)</th>
<th className="pb-3 text-center">Humidity (Avg)</th>
<th className="pb-3 text-center">VPD (Avg)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/50">
{reportData?.devices.map(device => (
<tr key={device.id} className="text-sm">
<td className="py-3 text-white font-medium">{device.name}</td>
<td className="py-3 text-slate-300 text-center">{device.readings}</td>
<td className="py-3 text-center">
<span className="text-red-400 font-medium">{device.stats.temperature.avg.toFixed(1)}°F</span>
</td>
<td className="py-3 text-center">
<span className="text-blue-400 font-medium">{device.stats.humidity.avg.toFixed(0)}%</span>
</td>
<td className="py-3 text-center">
<span className="text-purple-400 font-medium">{device.stats.vpd.avg.toFixed(2)}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Alert Summary */}
{reportData?.alerts.byType && reportData.alerts.byType.length > 0 && (
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-400" />
Alert Summary
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{reportData.alerts.byType.map(alert => (
<div key={alert.type} className="p-4 rounded-xl bg-slate-900/50 border border-slate-700/50">
<p className="text-xs text-slate-400 mb-1">{alert.type.replace('_', ' ')}</p>
<p className="text-2xl font-bold text-white">{alert.count}</p>
</div>
))}
</div>
</div>
)}
{/* Response Time Analytics */}
{reportData?.alertAnalytics && (
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50 print-break">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Clock className="w-5 h-5 text-cyan-400" />
Alert Response Time Analytics
</h3>
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="p-4 rounded-xl bg-gradient-to-br from-emerald-500/20 to-green-500/10 border border-emerald-500/20">
<p className="text-xs text-slate-400 mb-1">Resolution Rate</p>
<p className="text-3xl font-bold text-emerald-400">
{reportData.alertAnalytics.summary.resolutionRate}%
</p>
<p className="text-xs text-slate-500 mt-1">
{reportData.alertAnalytics.summary.resolved} of {reportData.alertAnalytics.summary.total}
</p>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-blue-500/20 to-cyan-500/10 border border-blue-500/20">
<p className="text-xs text-slate-400 mb-1">Avg Resolution Time</p>
<p className="text-3xl font-bold text-blue-400">
{reportData.alertAnalytics.resolutionTimes.avgMinutes || '--'}
</p>
<p className="text-xs text-slate-500 mt-1">minutes</p>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-500/10 border border-amber-500/20">
<p className="text-xs text-slate-400 mb-1">Fastest Resolution</p>
<p className="text-3xl font-bold text-amber-400">
{reportData.alertAnalytics.resolutionTimes.minMinutes || '--'}
</p>
<p className="text-xs text-slate-500 mt-1">minutes</p>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-red-500/20 to-rose-500/10 border border-red-500/20">
<p className="text-xs text-slate-400 mb-1">Slowest Resolution</p>
<p className="text-3xl font-bold text-red-400">
{reportData.alertAnalytics.resolutionTimes.maxMinutes || '--'}
</p>
<p className="text-xs text-slate-500 mt-1">minutes</p>
</div>
</div>
{/* By Alert Type */}
{reportData.alertAnalytics.byType.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-medium text-slate-400 mb-3">Resolution Time by Alert Type</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-xs text-slate-400 uppercase tracking-wider">
<th className="pb-3">Alert Type</th>
<th className="pb-3 text-center">Total</th>
<th className="pb-3 text-center">Resolved</th>
<th className="pb-3 text-center">Avg Resolution</th>
<th className="pb-3 text-center">Resolution Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/50">
{reportData.alertAnalytics.byType.map(alertType => (
<tr key={alertType.type} className="text-sm">
<td className="py-3 text-white font-medium">{alertType.type.replace('_', ' ')}</td>
<td className="py-3 text-slate-300 text-center">{alertType.count}</td>
<td className="py-3 text-center">
<span className="text-green-400">{alertType.resolved}</span>
</td>
<td className="py-3 text-center">
<span className="text-cyan-400 font-medium">
{alertType.avgResolutionMin ? `${alertType.avgResolutionMin} min` : '--'}
</span>
</td>
<td className="py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${alertType.count > 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}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Recent Alerts Timeline */}
{reportData.alertAnalytics.recentAlerts.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-400 mb-3">Recent Alert Timeline</h4>
<div className="space-y-2">
{reportData.alertAnalytics.recentAlerts.slice(0, 5).map(alert => (
<div
key={alert.id}
className={`p-4 rounded-xl border ${alert.resolvedAt
? 'bg-green-500/5 border-green-500/20'
: 'bg-amber-500/5 border-amber-500/20'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${alert.severity === 'CRITICAL' ? 'bg-red-500/20 text-red-400' :
alert.severity === 'WARNING' ? 'bg-amber-500/20 text-amber-400' :
'bg-blue-500/20 text-blue-400'
}`}>
{alert.severity}
</span>
<span className="text-sm text-white font-medium">{alert.type.replace('_', ' ')}</span>
</div>
<span className={`flex items-center gap-1.5 text-xs font-medium ${alert.resolvedAt ? 'text-green-400' : 'text-amber-400'
}`}>
<CheckCircle className="w-3 h-3" />
{alert.resolvedAt ? 'Resolved' : 'Pending'}
</span>
</div>
<p className="text-sm text-slate-300 mb-2">{alert.message}</p>
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>
Triggered: {new Date(alert.createdAt).toLocaleString()}
</span>
{alert.resolutionTimeMin && (
<span className="text-cyan-400">
Return to Normal: {alert.resolutionTimeMin.toFixed(1)} min
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Footer */}
<div className="text-center py-6 text-xs text-slate-500 border-t border-slate-700/50">
<p>Veridian Grow Operations Manager Environment Monitoring Report</p>
<p className="mt-1">Generated automatically Data subject to sensor accuracy</p>
</div>
</div>
</div>
</div>
</>
);
}

View file

@ -55,6 +55,9 @@ const PulseTestPage = lazy(() => import('./pages/PulseTestPage'));
// Failsafe Settings (Admin) // Failsafe Settings (Admin)
const FailsafeSettingsPage = lazy(() => import('./pages/FailsafeSettingsPage')); const FailsafeSettingsPage = lazy(() => import('./pages/FailsafeSettingsPage'));
// Environment Report
const EnvironmentReportPage = lazy(() => import('./pages/EnvironmentReportPage'));
// Loading spinner component for Suspense fallbacks // Loading spinner component for Suspense fallbacks
const PageLoader = () => ( const PageLoader = () => (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
@ -213,6 +216,11 @@ export const router = createBrowserRouter([
path: 'failsafe', path: 'failsafe',
element: <Suspense fallback={<PageLoader />}><FailsafeSettingsPage /></Suspense>, element: <Suspense fallback={<PageLoader />}><FailsafeSettingsPage /></Suspense>,
}, },
// Environment Report
{
path: 'reports/environment',
element: <Suspense fallback={<PageLoader />}><EnvironmentReportPage /></Suspense>,
},
// 404 catch-all // 404 catch-all
{ {
path: '*', path: '*',