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 ====================
|
// ==================== 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)
|
// 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: '*',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue