feat: Environment Reports with alert response time analytics and PDF export
This commit is contained in:
parent
7cb7843ceb
commit
28532d4d9b
3 changed files with 841 additions and 0 deletions
|
|
@ -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 ====================
|
||||
|
||||
/**
|
||||
|
|
|
|||
702
frontend/src/pages/EnvironmentReportPage.tsx
Normal file
702
frontend/src/pages/EnvironmentReportPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = () => (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
|
@ -213,6 +216,11 @@ export const router = createBrowserRouter([
|
|||
path: 'failsafe',
|
||||
element: <Suspense fallback={<PageLoader />}><FailsafeSettingsPage /></Suspense>,
|
||||
},
|
||||
// Environment Report
|
||||
{
|
||||
path: 'reports/environment',
|
||||
element: <Suspense fallback={<PageLoader />}><EnvironmentReportPage /></Suspense>,
|
||||
},
|
||||
// 404 catch-all
|
||||
{
|
||||
path: '*',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue