14a32bdc0d
- Unified monorepo with backend (Express), frontend (Next.js), and devops - Backend: ESLint, Prettier, Jest tests (3 passing), health endpoint, .env.example - Frontend: Fixed build errors, fixed all lint errors (0 remaining), tests passing - DevOps: Docker Compose with PostgreSQL, backend, frontend + healthchecks - CI/CD: 3 GitHub Actions workflows (backend, frontend, docker integration) - DX: Husky pre-commit hooks with smart change detection - Docs: Root README with architecture, CONTRIBUTING.md, PR template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
400 lines
15 KiB
TypeScript
400 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
|
|
import { Badge } from "@/components/ui/layout/Badge";
|
|
import {
|
|
Zap,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
Clock,
|
|
Target,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
BarChart3,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import { supabase } from "@/lib/supabase";
|
|
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
|
|
|
|
interface PerformanceMetric {
|
|
id: string;
|
|
website_name: string;
|
|
website_url: string;
|
|
lighthouse_score: number;
|
|
performance_score: number;
|
|
accessibility_score: number;
|
|
best_practices_score: number;
|
|
seo_score: number;
|
|
first_contentful_paint: number;
|
|
largest_contentful_paint: number;
|
|
cumulative_layout_shift: number;
|
|
total_blocking_time: number;
|
|
created_at: string;
|
|
}
|
|
|
|
interface PerformanceSummary {
|
|
totalWebsites: number;
|
|
averageScore: number;
|
|
goodPerformance: number;
|
|
needsImprovement: number;
|
|
poor: number;
|
|
}
|
|
|
|
export default function PerformancePage() {
|
|
const { userDetails } = useAuth();
|
|
const [metrics, setMetrics] = useState<PerformanceMetric[]>([]);
|
|
const [summary, setSummary] = useState<PerformanceSummary>({
|
|
totalWebsites: 0,
|
|
averageScore: 0,
|
|
goodPerformance: 0,
|
|
needsImprovement: 0,
|
|
poor: 0,
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d");
|
|
|
|
useEffect(() => {
|
|
if (userDetails?.organization_id) {
|
|
loadPerformanceData();
|
|
}
|
|
}, [userDetails, timeRange]);
|
|
|
|
const loadPerformanceData = async () => {
|
|
if (!userDetails?.organization_id) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Calculate date range
|
|
const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90;
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - days);
|
|
|
|
// Fetch latest performance data for each website
|
|
const { data: scanData, error } = await supabase
|
|
.from("scans")
|
|
.select(`
|
|
id,
|
|
lighthouse_score,
|
|
created_at,
|
|
scan_results!inner (
|
|
category,
|
|
score,
|
|
metrics
|
|
),
|
|
pages!inner (
|
|
websites!inner (
|
|
id,
|
|
name,
|
|
base_url,
|
|
organization_id
|
|
)
|
|
)
|
|
`)
|
|
.eq("pages.websites.organization_id", userDetails.organization_id)
|
|
.eq("status", "completed")
|
|
.gte("created_at", startDate.toISOString())
|
|
.order("created_at", { ascending: false });
|
|
|
|
if (error) {
|
|
const errorInfo = extractSupabaseErrorInfo(error);
|
|
logError("Error loading performance data", error, {
|
|
organizationId: userDetails.organization_id,
|
|
timeRange,
|
|
startDate: startDate.toISOString(),
|
|
supabaseError: errorInfo
|
|
});
|
|
|
|
// If tables don't exist, set empty metrics
|
|
if (errorInfo.message?.includes("does not exist") || errorInfo.details?.includes("does not exist")) {
|
|
setMetrics([]);
|
|
setSummary({
|
|
totalWebsites: 0,
|
|
averageScore: 0,
|
|
goodPerformance: 0,
|
|
needsImprovement: 0,
|
|
poor: 0,
|
|
});
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// Process the data to get latest metrics per website
|
|
const websiteMetrics = new Map<string, PerformanceMetric>();
|
|
|
|
scanData?.forEach((scan: any) => {
|
|
const website = scan.pages.websites;
|
|
if (!websiteMetrics.has(website.id)) {
|
|
const results = scan.scan_results || [];
|
|
|
|
websiteMetrics.set(website.id, {
|
|
id: scan.id,
|
|
website_name: website.name,
|
|
website_url: website.base_url,
|
|
lighthouse_score: scan.lighthouse_score || 0,
|
|
performance_score: results.find((r: any) => r.category === "performance")?.score || 0,
|
|
accessibility_score: results.find((r: any) => r.category === "accessibility")?.score || 0,
|
|
best_practices_score: results.find((r: any) => r.category === "best-practices")?.score || 0,
|
|
seo_score: results.find((r: any) => r.category === "seo")?.score || 0,
|
|
first_contentful_paint: results.find((r: any) => r.category === "performance")?.metrics?.first_contentful_paint || 0,
|
|
largest_contentful_paint: results.find((r: any) => r.category === "performance")?.metrics?.largest_contentful_paint || 0,
|
|
cumulative_layout_shift: results.find((r: any) => r.category === "performance")?.metrics?.cumulative_layout_shift || 0,
|
|
total_blocking_time: results.find((r: any) => r.category === "performance")?.metrics?.total_blocking_time || 0,
|
|
created_at: scan.created_at,
|
|
});
|
|
}
|
|
});
|
|
|
|
const metricsArray = Array.from(websiteMetrics.values());
|
|
setMetrics(metricsArray);
|
|
|
|
// Calculate summary
|
|
if (metricsArray.length > 0) {
|
|
const avgScore = Math.round(
|
|
metricsArray.reduce((sum, m) => sum + m.lighthouse_score, 0) / metricsArray.length
|
|
);
|
|
const good = metricsArray.filter(m => m.lighthouse_score >= 90).length;
|
|
const needsImprovement = metricsArray.filter(m => m.lighthouse_score >= 50 && m.lighthouse_score < 90).length;
|
|
const poor = metricsArray.filter(m => m.lighthouse_score < 50).length;
|
|
|
|
setSummary({
|
|
totalWebsites: metricsArray.length,
|
|
averageScore: avgScore,
|
|
goodPerformance: good,
|
|
needsImprovement,
|
|
poor,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const errorInfo = extractSupabaseErrorInfo(error);
|
|
logError("Error loading performance data", error, {
|
|
organizationId: userDetails.organization_id,
|
|
timeRange,
|
|
function: "loadPerformanceData",
|
|
supabaseError: errorInfo
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getScoreColor = (score: number) => {
|
|
if (score >= 90) return "text-green-600 bg-green-100";
|
|
if (score >= 50) return "text-yellow-600 bg-yellow-100";
|
|
return "text-red-600 bg-red-100";
|
|
};
|
|
|
|
const getScoreIcon = (score: number) => {
|
|
if (score >= 90) return <CheckCircle className="w-4 h-4" />;
|
|
if (score >= 50) return <AlertTriangle className="w-4 h-4" />;
|
|
return <TrendingDown className="w-4 h-4" />;
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
|
<Zap className="w-6 h-6" />
|
|
Performance Overview
|
|
</h1>
|
|
<p className="text-gray-600 mt-1">
|
|
Monitor and analyze your websites' performance metrics
|
|
</p>
|
|
</div>
|
|
<select
|
|
value={timeRange}
|
|
onChange={(e) => setTimeRange(e.target.value as "7d" | "30d" | "90d")}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="7d">Last 7 days</option>
|
|
<option value="30d">Last 30 days</option>
|
|
<option value="90d">Last 90 days</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Total Websites</p>
|
|
<p className="text-2xl font-bold">{summary.totalWebsites}</p>
|
|
</div>
|
|
<BarChart3 className="w-8 h-8 text-blue-600" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Average Score</p>
|
|
<p className="text-2xl font-bold">{summary.averageScore}</p>
|
|
</div>
|
|
<Target className="w-8 h-8 text-purple-600" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Good Performance</p>
|
|
<p className="text-2xl font-bold text-green-600">{summary.goodPerformance}</p>
|
|
</div>
|
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Needs Improvement</p>
|
|
<p className="text-2xl font-bold text-yellow-600">{summary.needsImprovement}</p>
|
|
</div>
|
|
<AlertTriangle className="w-8 h-8 text-yellow-600" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Performance Metrics */}
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-semibold text-gray-900">Website Performance</h2>
|
|
|
|
{metrics.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{metrics.map((metric, index) => (
|
|
<motion.div
|
|
key={metric.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.1 }}
|
|
>
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900">{metric.website_name}</h3>
|
|
<p className="text-sm text-gray-500">{metric.website_url}</p>
|
|
<p className="text-xs text-gray-400">
|
|
Last scan: {new Date(metric.created_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<Badge className={`flex items-center gap-1 ${getScoreColor(metric.lighthouse_score)}`}>
|
|
{getScoreIcon(metric.lighthouse_score)}
|
|
{metric.lighthouse_score}/100
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="text-center">
|
|
<div className={`text-lg font-semibold ${getScoreColor(metric.performance_score).split(' ')[0]}`}>
|
|
{metric.performance_score}
|
|
</div>
|
|
<div className="text-xs text-gray-500">Performance</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className={`text-lg font-semibold ${getScoreColor(metric.accessibility_score).split(' ')[0]}`}>
|
|
{metric.accessibility_score}
|
|
</div>
|
|
<div className="text-xs text-gray-500">Accessibility</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className={`text-lg font-semibold ${getScoreColor(metric.best_practices_score).split(' ')[0]}`}>
|
|
{metric.best_practices_score}
|
|
</div>
|
|
<div className="text-xs text-gray-500">Best Practices</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className={`text-lg font-semibold ${getScoreColor(metric.seo_score).split(' ')[0]}`}>
|
|
{metric.seo_score}
|
|
</div>
|
|
<div className="text-xs text-gray-500">SEO</div>
|
|
</div>
|
|
</div>
|
|
|
|
{(metric.first_contentful_paint > 0 || metric.largest_contentful_paint > 0) && (
|
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
{metric.first_contentful_paint > 0 && (
|
|
<div>
|
|
<div className="font-medium text-gray-900">
|
|
{(metric.first_contentful_paint / 1000).toFixed(1)}s
|
|
</div>
|
|
<div className="text-gray-500">FCP</div>
|
|
</div>
|
|
)}
|
|
{metric.largest_contentful_paint > 0 && (
|
|
<div>
|
|
<div className="font-medium text-gray-900">
|
|
{(metric.largest_contentful_paint / 1000).toFixed(1)}s
|
|
</div>
|
|
<div className="text-gray-500">LCP</div>
|
|
</div>
|
|
)}
|
|
{metric.cumulative_layout_shift > 0 && (
|
|
<div>
|
|
<div className="font-medium text-gray-900">
|
|
{metric.cumulative_layout_shift.toFixed(3)}
|
|
</div>
|
|
<div className="text-gray-500">CLS</div>
|
|
</div>
|
|
)}
|
|
{metric.total_blocking_time > 0 && (
|
|
<div>
|
|
<div className="font-medium text-gray-900">
|
|
{metric.total_blocking_time}ms
|
|
</div>
|
|
<div className="text-gray-500">TBT</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="p-12 text-center">
|
|
<Zap className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
No performance data available
|
|
</h3>
|
|
<p className="text-gray-600">
|
|
Run scans on your websites to see performance metrics here
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
} |