Bundle analysis and monitoring
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals");
|
||||
|
||||
// Ensure web-vitals directory exists
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { metric, data, url, userAgent, timestamp } = await request.json();
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
userAgent,
|
||||
timestamp: new Date(timestamp).toISOString(),
|
||||
receivedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save to file (in production, you would save to a database)
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn("Could not parse existing vitals data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
existingData.push(vitalsData);
|
||||
|
||||
// Keep only last 100 entries per metric
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
|
||||
// Log for monitoring
|
||||
console.log(
|
||||
`Web Vital received: ${metric} = ${data.value}ms (${data.rating})`
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error processing web vital:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const metrics = {};
|
||||
|
||||
if (fs.existsSync(WEB_VITALS_DIR)) {
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const metric = file.replace(".json", "");
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8")
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
.map((d) => d.data.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.data.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
metrics[metric] = {
|
||||
count: data.length,
|
||||
average:
|
||||
values.length > 0
|
||||
? Math.round(
|
||||
values.reduce((a, b) => a + b, 0) / values.length
|
||||
)
|
||||
: 0,
|
||||
min: values.length > 0 ? Math.min(...values) : 0,
|
||||
max: values.length > 0 ? Math.max(...values) : 0,
|
||||
goodCount: ratings.filter((r) => r === "good").length,
|
||||
needsImprovementCount: ratings.filter(
|
||||
(r) => r === "needs-improvement"
|
||||
).length,
|
||||
poorCount: ratings.filter((r) => r === "poor").length,
|
||||
lastUpdated: data[data.length - 1]?.receivedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ metrics });
|
||||
} catch (error) {
|
||||
console.error("Error fetching web vitals:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, memo } from "react";
|
||||
|
||||
const WebVitalsDashboard = memo(() => {
|
||||
const [vitals, setVitals] = useState({
|
||||
lcp: { value: 0, rating: "unknown" },
|
||||
fid: { value: 0, rating: "unknown" },
|
||||
cls: { value: 0, rating: "unknown" },
|
||||
fcp: { value: 0, rating: "unknown" },
|
||||
ttfb: { value: 0, rating: "unknown" },
|
||||
});
|
||||
|
||||
const [metrics, setMetrics] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch Web Vitals data from API
|
||||
const fetchVitals = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/web-vitals");
|
||||
const data = await response.json();
|
||||
setMetrics(data.metrics || {});
|
||||
} catch (error) {
|
||||
console.error("Error fetching web vitals:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVitals();
|
||||
|
||||
// Set up Web Vitals tracking
|
||||
if (typeof window !== "undefined") {
|
||||
import("web-vitals").then(
|
||||
({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
// Track Largest Contentful Paint
|
||||
getLCP((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
lcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track First Input Delay
|
||||
getFID((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fid: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track Cumulative Layout Shift
|
||||
getCLS((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
cls: {
|
||||
value: Math.round(metric.value * 1000) / 1000,
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track First Contentful Paint
|
||||
getFCP((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track Time to First Byte
|
||||
getTTFB((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
ttfb: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "text-green-600 bg-green-50";
|
||||
case "needs-improvement":
|
||||
return "text-yellow-600 bg-yellow-50";
|
||||
case "poor":
|
||||
return "text-red-600 bg-red-50";
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50";
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "✅";
|
||||
case "needs-improvement":
|
||||
return "⚠️";
|
||||
case "poor":
|
||||
return "❌";
|
||||
default:
|
||||
return "❓";
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (metric, value) => {
|
||||
if (metric === "cls") {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
return `${value}ms`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="p-4 border rounded-lg">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
|
||||
Web Vitals Dashboard
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div
|
||||
key={metric}
|
||||
className={`p-4 border rounded-lg ${getRatingColor(data.rating)}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
Value: {formatValue(metric, data.value)}
|
||||
</div>
|
||||
<div className="capitalize">
|
||||
Rating: {data.rating.replace("-", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Historical Metrics */}
|
||||
{Object.keys(metrics).length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
|
||||
Historical Metrics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(metrics).map(([metric, data]) => (
|
||||
<div
|
||||
key={metric}
|
||||
className="p-4 border rounded-lg bg-[var(--color-surface-default-secondary)]"
|
||||
>
|
||||
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Count: {data.count}</div>
|
||||
<div>Average: {formatValue(metric, data.average)}</div>
|
||||
<div>
|
||||
Range: {formatValue(metric, data.min)} -{" "}
|
||||
{formatValue(metric, data.max)}
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-green-600">
|
||||
Good: {data.goodCount}
|
||||
</span>
|
||||
<span className="text-yellow-600">
|
||||
Needs Improvement: {data.needsImprovementCount}
|
||||
</span>
|
||||
<span className="text-red-600">Poor: {data.poorCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Guidelines */}
|
||||
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
|
||||
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
|
||||
Performance Guidelines
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
|
||||
<li>
|
||||
• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s,
|
||||
Poor > 4s
|
||||
</li>
|
||||
<li>
|
||||
• <strong>FID:</strong> Good < 100ms, Needs Improvement
|
||||
100-300ms, Poor > 300ms
|
||||
</li>
|
||||
<li>
|
||||
• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25,
|
||||
Poor > 0.25
|
||||
</li>
|
||||
<li>
|
||||
• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s,
|
||||
Poor > 3s
|
||||
</li>
|
||||
<li>
|
||||
• <strong>TTFB:</strong> Good < 800ms, Needs Improvement
|
||||
800-1800ms, Poor > 1800ms
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
WebVitalsDashboard.displayName = "WebVitalsDashboard";
|
||||
|
||||
export default WebVitalsDashboard;
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import WebVitalsDashboard from "../components/WebVitalsDashboard";
|
||||
import Header from "../components/Header";
|
||||
import Footer from "../components/Footer";
|
||||
|
||||
export default function MonitorPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-[var(--spacing-scale-032)]">
|
||||
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
|
||||
Performance Monitoring
|
||||
</h1>
|
||||
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
|
||||
Real-time monitoring of Core Web Vitals and performance metrics
|
||||
for Community Rule 3.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Performance Targets
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Load Time
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 2 seconds
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
LCP
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 2.5s
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
FID
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 100ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
CLS
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">< 0.1</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Lighthouse Score
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">> 90</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Optimization Status
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Code Splitting & Dynamic Imports
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
React.memo Optimizations
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Image Optimization
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Font Preloading
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Bundle Analysis
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Error Boundaries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebVitalsDashboard />
|
||||
|
||||
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Monitoring Commands
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Bundle Analysis
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run bundle:analyze
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Performance Monitor
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run performance:monitor
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Web Vitals Tracking
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run web-vitals:track
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
All Monitoring
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run monitor:all
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,25 @@ const nextConfig = {
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
|
||||
// Bundle analysis - only in production builds
|
||||
if (process.env.ANALYZE === "true" && !dev) {
|
||||
try {
|
||||
const BundleAnalyzerPlugin =
|
||||
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
|
||||
config.plugins.push(
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: "static",
|
||||
openAnalyzer: false,
|
||||
reportFilename: isServer
|
||||
? "../analyze/server.html"
|
||||
: "../analyze/client.html",
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Bundle analyzer not available:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Production optimizations
|
||||
if (!dev && !isServer) {
|
||||
// Tree shaking optimization
|
||||
|
||||
Generated
+153
-1
@@ -60,7 +60,9 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.4"
|
||||
"wait-on": "^8.0.4",
|
||||
"web-vitals": "^4.2.4",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -2211,6 +2213,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
|
||||
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
|
||||
@@ -8023,6 +8035,19 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -10155,6 +10180,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -12843,6 +12875,22 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/gzip-size": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
|
||||
"integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexer": "^0.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -18629,6 +18677,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
|
||||
"dev": true,
|
||||
"license": "(WTFPL OR MIT)",
|
||||
"bin": {
|
||||
"opener": "bin/opener-bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -23086,6 +23144,13 @@
|
||||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
|
||||
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz",
|
||||
@@ -23103,6 +23168,93 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer": {
|
||||
"version": "4.10.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
|
||||
"integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@discoveryjs/json-ext": "0.5.7",
|
||||
"acorn": "^8.0.4",
|
||||
"acorn-walk": "^8.0.0",
|
||||
"commander": "^7.2.0",
|
||||
"debounce": "^1.2.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"gzip-size": "^6.0.0",
|
||||
"html-escaper": "^2.0.2",
|
||||
"opener": "^1.5.2",
|
||||
"picocolors": "^1.0.0",
|
||||
"sirv": "^2.0.3",
|
||||
"ws": "^7.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/sirv": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
|
||||
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@polka/url": "^1.0.0-next.24",
|
||||
"mrmime": "^2.0.0",
|
||||
"totalist": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
|
||||
+10
-2
@@ -33,7 +33,13 @@
|
||||
"seed-snapshots:local": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium",
|
||||
"visual:test": "npx playwright test tests/e2e/visual-regression.spec.ts",
|
||||
"visual:update": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts",
|
||||
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui"
|
||||
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui",
|
||||
"analyze": "npm run analyze:browser && npm run analyze:server",
|
||||
"analyze:server": "ANALYZE=true npm run build",
|
||||
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
||||
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
||||
"web-vitals:track": "node scripts/web-vitals-tracker.js",
|
||||
"monitor:all": "npm run bundle:analyze && npm run performance:monitor && npm run web-vitals:track"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
@@ -87,6 +93,8 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.4"
|
||||
"wait-on": "^8.0.4",
|
||||
"web-vitals": "^4.2.4",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Bundle Analysis Script
|
||||
* Analyzes webpack bundles and provides detailed performance insights
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const BUNDLE_ANALYSIS_DIR = path.join(__dirname, "..", ".next", "analyze");
|
||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||
|
||||
class BundleAnalyzer {
|
||||
constructor() {
|
||||
this.results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
bundles: {},
|
||||
recommendations: [],
|
||||
budgetViolations: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run bundle analysis using build output
|
||||
*/
|
||||
async analyzeBundles() {
|
||||
console.log("🔍 Starting bundle analysis...");
|
||||
|
||||
try {
|
||||
// Ensure analyze directory exists
|
||||
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
|
||||
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Build the project first
|
||||
console.log("🏗️ Building project...");
|
||||
execSync("npm run build", { stdio: "inherit" });
|
||||
|
||||
// Parse bundle stats from build output
|
||||
await this.parseBundleStats();
|
||||
|
||||
// Check performance budgets
|
||||
this.checkPerformanceBudgets();
|
||||
|
||||
// Generate recommendations
|
||||
this.generateRecommendations();
|
||||
|
||||
// Save results
|
||||
this.saveResults();
|
||||
|
||||
console.log("✅ Bundle analysis complete!");
|
||||
console.log(`📁 Results saved to: ${BUNDLE_ANALYSIS_DIR}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Bundle analysis failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse bundle statistics from build output
|
||||
*/
|
||||
async parseBundleStats() {
|
||||
const staticPath = path.join(__dirname, "..", ".next", "static");
|
||||
const chunksPath = path.join(staticPath, "chunks");
|
||||
|
||||
// Analyze static assets
|
||||
if (fs.existsSync(staticPath)) {
|
||||
this.analyzeDirectory(staticPath, "static");
|
||||
}
|
||||
|
||||
// Analyze chunks
|
||||
if (fs.existsSync(chunksPath)) {
|
||||
this.analyzeDirectory(chunksPath, "chunks");
|
||||
}
|
||||
|
||||
// Analyze pages
|
||||
const pagesPath = path.join(__dirname, "..", ".next", "server", "pages");
|
||||
if (fs.existsSync(pagesPath)) {
|
||||
this.analyzeDirectory(pagesPath, "pages");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze directory for bundle sizes
|
||||
*/
|
||||
analyzeDirectory(dirPath, type) {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.isFile() && (file.endsWith(".js") || file.endsWith(".css"))) {
|
||||
const key = `${type}/${file}`;
|
||||
this.results.bundles[key] = {
|
||||
size: stats.size,
|
||||
sizeKB: Math.round(stats.size / 1024),
|
||||
lastModified: stats.mtime,
|
||||
type: file.endsWith(".css") ? "css" : "js",
|
||||
};
|
||||
} else if (stats.isDirectory()) {
|
||||
this.analyzeDirectory(filePath, `${type}/${file}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check against performance budgets
|
||||
*/
|
||||
checkPerformanceBudgets() {
|
||||
const budgets = PERFORMANCE_BUDGETS.budgets || [];
|
||||
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
const budget = budgets.find(
|
||||
(b) => filename.includes(b.name) || b.name === "all"
|
||||
);
|
||||
|
||||
if (budget) {
|
||||
if (bundle.sizeKB > budget.maxSizeKB) {
|
||||
this.results.budgetViolations.push({
|
||||
file: filename,
|
||||
currentSize: bundle.sizeKB,
|
||||
maxSize: budget.maxSizeKB,
|
||||
overage: bundle.sizeKB - budget.maxSizeKB,
|
||||
severity:
|
||||
bundle.sizeKB > budget.maxSizeKB * 1.2 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Default budget check for large files
|
||||
if (bundle.sizeKB > 500) {
|
||||
this.results.budgetViolations.push({
|
||||
file: filename,
|
||||
currentSize: bundle.sizeKB,
|
||||
maxSize: 500,
|
||||
overage: bundle.sizeKB - 500,
|
||||
severity: bundle.sizeKB > 600 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate optimization recommendations
|
||||
*/
|
||||
generateRecommendations() {
|
||||
const recommendations = [];
|
||||
|
||||
// Check for large bundles
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
if (bundle.sizeKB > 500) {
|
||||
recommendations.push({
|
||||
type: "large-bundle",
|
||||
file: filename,
|
||||
size: bundle.sizeKB,
|
||||
suggestion:
|
||||
"Consider code splitting or dynamic imports for this bundle",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for budget violations
|
||||
if (this.results.budgetViolations.length > 0) {
|
||||
recommendations.push({
|
||||
type: "budget-violation",
|
||||
count: this.results.budgetViolations.length,
|
||||
suggestion:
|
||||
"Review and optimize bundles that exceed performance budgets",
|
||||
});
|
||||
}
|
||||
|
||||
// General recommendations
|
||||
const totalSize = Object.values(this.results.bundles).reduce(
|
||||
(sum, bundle) => sum + bundle.sizeKB,
|
||||
0
|
||||
);
|
||||
|
||||
if (totalSize > 2000) {
|
||||
recommendations.push({
|
||||
type: "total-size",
|
||||
size: totalSize,
|
||||
suggestion: "Consider implementing more aggressive code splitting",
|
||||
});
|
||||
}
|
||||
|
||||
this.results.recommendations = recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save analysis results
|
||||
*/
|
||||
saveResults() {
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
|
||||
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const resultsPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-analysis.json");
|
||||
fs.writeFileSync(resultsPath, JSON.stringify(this.results, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-report.md");
|
||||
|
||||
let report = `# Bundle Analysis Report\n\n`;
|
||||
report += `**Generated:** ${this.results.timestamp}\n\n`;
|
||||
|
||||
// Bundle sizes
|
||||
report += `## Bundle Sizes\n\n`;
|
||||
report += `| File | Size (KB) | Status |\n`;
|
||||
report += `|------|-----------|--------|\n`;
|
||||
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
const status = bundle.sizeKB > 500 ? "⚠️ Large" : "✅ Good";
|
||||
report += `| ${filename} | ${bundle.sizeKB} | ${status} |\n`;
|
||||
});
|
||||
|
||||
// Budget violations
|
||||
if (this.results.budgetViolations.length > 0) {
|
||||
report += `\n## Budget Violations\n\n`;
|
||||
this.results.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.file}**: ${violation.currentSize}KB (exceeds ${violation.maxSize}KB by ${violation.overage}KB)\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
if (this.results.recommendations.length > 0) {
|
||||
report += `\n## Recommendations\n\n`;
|
||||
this.results.recommendations.forEach((rec) => {
|
||||
report += `- ${rec.suggestion}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
}
|
||||
|
||||
// Run analysis if called directly
|
||||
if (require.main === module) {
|
||||
const analyzer = new BundleAnalyzer();
|
||||
analyzer.analyzeBundles().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = BundleAnalyzer;
|
||||
+265
-354
@@ -2,386 +2,297 @@
|
||||
|
||||
/**
|
||||
* Performance Monitoring Script
|
||||
*
|
||||
* This script provides comprehensive performance monitoring capabilities
|
||||
* for the Community Rule application.
|
||||
* Monitors Core Web Vitals and performance metrics
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Performance budgets
|
||||
const PERFORMANCE_BUDGETS = {
|
||||
page_load_time: 3000,
|
||||
first_contentful_paint: 2000,
|
||||
largest_contentful_paint: 2500,
|
||||
first_input_delay: 100,
|
||||
dns_lookup: 100,
|
||||
tcp_connection: 200,
|
||||
ttfb: 600,
|
||||
dom_content_loaded: 1500,
|
||||
full_load: 3000,
|
||||
component_render_time: 500,
|
||||
interaction_time: 100,
|
||||
scroll_performance: 50,
|
||||
network_request_duration: 1000,
|
||||
memory_usage_mb: 50,
|
||||
};
|
||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||
const MONITORING_DIR = path.join(__dirname, "..", ".next", "monitoring");
|
||||
|
||||
// Baseline metrics for regression detection
|
||||
const BASELINE_METRICS = {
|
||||
page_load_time: 2000,
|
||||
first_contentful_paint: 1500,
|
||||
largest_contentful_paint: 2000,
|
||||
first_input_delay: 50,
|
||||
dns_lookup: 50,
|
||||
tcp_connection: 100,
|
||||
ttfb: 400,
|
||||
dom_content_loaded: 1000,
|
||||
full_load: 2000,
|
||||
component_render_time: 300,
|
||||
interaction_time: 50,
|
||||
scroll_performance: 30,
|
||||
network_request_duration: 500,
|
||||
memory_usage_mb: 30,
|
||||
};
|
||||
|
||||
class PerformanceMonitorScript {
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
this.metrics = new Map();
|
||||
this.regressions = [];
|
||||
this.warnings = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI performance tests
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
console.log("🚀 Running Lighthouse CI performance tests...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const lhci = spawn("npx", ["lhci", "autorun"], {
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
lhci.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
console.log(data.toString());
|
||||
});
|
||||
|
||||
lhci.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
lhci.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("✅ Lighthouse CI tests completed successfully");
|
||||
this.analyzeLighthouseResults(output);
|
||||
resolve(output);
|
||||
} else {
|
||||
console.error("❌ Lighthouse CI tests failed");
|
||||
reject(
|
||||
new Error(`Lighthouse CI failed with code ${code}: ${errorOutput}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Playwright performance tests
|
||||
*/
|
||||
async runPlaywrightPerformanceTests() {
|
||||
console.log("🎭 Running Playwright performance tests...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const playwright = spawn(
|
||||
"npx",
|
||||
[
|
||||
"playwright",
|
||||
"test",
|
||||
"tests/e2e/performance.spec.ts",
|
||||
"--reporter=json",
|
||||
],
|
||||
{
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
},
|
||||
);
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
playwright.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
playwright.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
playwright.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("✅ Playwright performance tests completed successfully");
|
||||
this.analyzePlaywrightResults(output);
|
||||
resolve(output);
|
||||
} else {
|
||||
console.error("❌ Playwright performance tests failed");
|
||||
reject(
|
||||
new Error(
|
||||
`Playwright tests failed with code ${code}: ${errorOutput}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Lighthouse CI results
|
||||
*/
|
||||
analyzeLighthouseResults(output) {
|
||||
console.log("📊 Analyzing Lighthouse CI results...");
|
||||
|
||||
// Parse Lighthouse results
|
||||
const lines = output.split("\n");
|
||||
let currentMetric = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes("Performance")) {
|
||||
const scoreMatch = line.match(/(\d+)/);
|
||||
if (scoreMatch) {
|
||||
const score = parseInt(scoreMatch[1]);
|
||||
this.recordMetric("lighthouse_performance_score", score);
|
||||
|
||||
if (score < 90) {
|
||||
this.warnings.push(
|
||||
`Performance score below threshold: ${score}/100`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("First Contentful Paint")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("first_contentful_paint", time);
|
||||
|
||||
if (time > PERFORMANCE_BUDGETS.first_contentful_paint) {
|
||||
this.warnings.push(
|
||||
`First Contentful Paint exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Largest Contentful Paint")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("largest_contentful_paint", time);
|
||||
|
||||
if (time > PERFORMANCE_BUDGETS.largest_contentful_paint) {
|
||||
this.warnings.push(
|
||||
`Largest Contentful Paint exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Total Blocking Time")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("total_blocking_time", time);
|
||||
|
||||
if (time > 300) {
|
||||
this.warnings.push(
|
||||
`Total Blocking Time exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Cumulative Layout Shift")) {
|
||||
const shiftMatch = line.match(/(\d+(?:\.\d+)?)/);
|
||||
if (shiftMatch) {
|
||||
const shift = parseFloat(shiftMatch[1]);
|
||||
this.recordMetric("cumulative_layout_shift", shift);
|
||||
|
||||
if (shift > 0.1) {
|
||||
this.warnings.push(
|
||||
`Cumulative Layout Shift exceeded budget: ${shift}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Playwright test results
|
||||
*/
|
||||
analyzePlaywrightResults(output) {
|
||||
console.log("📊 Analyzing Playwright test results...");
|
||||
|
||||
try {
|
||||
const results = JSON.parse(output);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "failed") {
|
||||
this.warnings.push(`Test failed: ${result.title}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not parse Playwright results as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a performance metric
|
||||
*/
|
||||
recordMetric(name, value) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
this.metrics.get(name).push({
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Check against baseline for regression detection
|
||||
const baseline = BASELINE_METRICS[name];
|
||||
if (baseline) {
|
||||
const regressionThreshold = baseline * 1.2; // 20% regression threshold
|
||||
if (value > regressionThreshold) {
|
||||
this.regressions.push({
|
||||
metric: name,
|
||||
current: value,
|
||||
baseline,
|
||||
regression: (((value - baseline) / baseline) * 100).toFixed(1) + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
generateReport() {
|
||||
console.log("\n📈 Performance Monitoring Report");
|
||||
console.log("================================\n");
|
||||
|
||||
// Summary
|
||||
console.log("📊 Summary:");
|
||||
console.log(`- Total metrics recorded: ${this.metrics.size}`);
|
||||
console.log(
|
||||
`- Performance regressions detected: ${this.regressions.length}`,
|
||||
);
|
||||
console.log(`- Warnings: ${this.warnings.length}\n`);
|
||||
|
||||
// Performance regressions
|
||||
if (this.regressions.length > 0) {
|
||||
console.log("🚨 Performance Regressions:");
|
||||
for (const regression of this.regressions) {
|
||||
console.log(
|
||||
` - ${regression.metric}: ${regression.current} (baseline: ${regression.baseline}, regression: ${regression.regression})`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (this.warnings.length > 0) {
|
||||
console.log("⚠️ Warnings:");
|
||||
for (const warning of this.warnings) {
|
||||
console.log(` - ${warning}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Metrics summary
|
||||
console.log("📋 Metrics Summary:");
|
||||
for (const [name, values] of this.metrics) {
|
||||
const latest = values[values.length - 1];
|
||||
const average =
|
||||
values.reduce((sum, v) => sum + v.value, 0) / values.length;
|
||||
const budget = PERFORMANCE_BUDGETS[name];
|
||||
|
||||
console.log(` - ${name}:`);
|
||||
console.log(` Latest: ${latest.value}`);
|
||||
console.log(` Average: ${average.toFixed(2)}`);
|
||||
if (budget) {
|
||||
const status = latest.value <= budget ? "✅" : "❌";
|
||||
console.log(` Budget: ${budget} ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save report to file
|
||||
const report = {
|
||||
this.metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
totalMetrics: this.metrics.size,
|
||||
regressions: this.regressions.length,
|
||||
warnings: this.warnings.length,
|
||||
},
|
||||
regressions: this.regressions,
|
||||
warnings: this.warnings,
|
||||
metrics: Object.fromEntries(this.metrics),
|
||||
coreWebVitals: {},
|
||||
bundleMetrics: {},
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
const reportPath = path.join(__dirname, "../performance-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
console.log(`\n📄 Report saved to: ${reportPath}`);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all performance monitoring
|
||||
* Run comprehensive performance monitoring
|
||||
*/
|
||||
async run() {
|
||||
console.log("🔍 Starting Performance Monitoring...\n");
|
||||
async monitorPerformance() {
|
||||
console.log("📊 Starting performance monitoring...");
|
||||
|
||||
try {
|
||||
// Run Lighthouse CI tests
|
||||
// Ensure monitoring directory exists
|
||||
if (!fs.existsSync(MONITORING_DIR)) {
|
||||
fs.mkdirSync(MONITORING_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Run Lighthouse CI for Core Web Vitals
|
||||
await this.runLighthouseCI();
|
||||
|
||||
// Run Playwright performance tests
|
||||
await this.runPlaywrightPerformanceTests();
|
||||
// Analyze bundle performance
|
||||
await this.analyzeBundlePerformance();
|
||||
|
||||
// Generate and display report
|
||||
const report = this.generateReport();
|
||||
// Check performance budgets
|
||||
this.checkPerformanceBudgets();
|
||||
|
||||
// Exit with appropriate code
|
||||
if (this.regressions.length > 0) {
|
||||
console.log("❌ Performance regressions detected!");
|
||||
process.exit(1);
|
||||
} else if (this.warnings.length > 0) {
|
||||
console.log("⚠️ Performance warnings detected.");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("✅ All performance checks passed!");
|
||||
process.exit(0);
|
||||
}
|
||||
// Generate performance report
|
||||
this.generatePerformanceReport();
|
||||
|
||||
console.log("✅ Performance monitoring complete!");
|
||||
console.log(`📁 Results saved to: ${MONITORING_DIR}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Performance monitoring failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI for Core Web Vitals
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
console.log("🔍 Running Lighthouse CI...");
|
||||
|
||||
try {
|
||||
// Check if server is running
|
||||
const { execSync } = require("child_process");
|
||||
try {
|
||||
execSync("curl -s http://localhost:3000 > /dev/null", {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ Development server not running, skipping Lighthouse CI..."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run Lighthouse CI with performance focus
|
||||
execSync("npx lhci autorun --collect.url=http://localhost:3000", {
|
||||
stdio: "inherit",
|
||||
cwd: path.join(__dirname, ".."),
|
||||
});
|
||||
|
||||
// Parse Lighthouse results
|
||||
await this.parseLighthouseResults();
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Lighthouse CI failed, continuing with other metrics...");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Lighthouse CI results
|
||||
*/
|
||||
async parseLighthouseResults() {
|
||||
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
|
||||
|
||||
if (fs.existsSync(lhciResultsPath)) {
|
||||
const files = fs.readdirSync(lhciResultsPath);
|
||||
const resultFile = files.find((f) => f.endsWith(".json"));
|
||||
|
||||
if (resultFile) {
|
||||
const results = JSON.parse(
|
||||
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8")
|
||||
);
|
||||
|
||||
if (results.lhr && results.lhr.audits) {
|
||||
this.metrics.coreWebVitals = {
|
||||
lcp: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"largest-contentful-paint"
|
||||
),
|
||||
fid: this.getAuditScore(results.lhr.audits, "max-potential-fid"),
|
||||
cls: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"cumulative-layout-shift"
|
||||
),
|
||||
fcp: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"first-contentful-paint"
|
||||
),
|
||||
tti: this.getAuditScore(results.lhr.audits, "interactive"),
|
||||
performance: results.lhr.categories.performance?.score * 100 || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit score from Lighthouse results
|
||||
*/
|
||||
getAuditScore(audits, auditId) {
|
||||
const audit = audits[auditId];
|
||||
if (!audit) return null;
|
||||
|
||||
return {
|
||||
score: audit.score * 100,
|
||||
value: audit.numericValue,
|
||||
displayValue: audit.displayValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze bundle performance
|
||||
*/
|
||||
async analyzeBundlePerformance() {
|
||||
console.log("📦 Analyzing bundle performance...");
|
||||
|
||||
const bundleStatsPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"static",
|
||||
"chunks"
|
||||
);
|
||||
|
||||
if (fs.existsSync(bundleStatsPath)) {
|
||||
const files = fs.readdirSync(bundleStatsPath);
|
||||
let totalSize = 0;
|
||||
let jsFiles = 0;
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".js")) {
|
||||
const filePath = path.join(bundleStatsPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
totalSize += stats.size;
|
||||
jsFiles++;
|
||||
}
|
||||
});
|
||||
|
||||
this.metrics.bundleMetrics = {
|
||||
totalSizeKB: Math.round(totalSize / 1024),
|
||||
totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
|
||||
fileCount: jsFiles,
|
||||
averageSizeKB: Math.round(totalSize / jsFiles / 1024),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check performance budgets
|
||||
*/
|
||||
checkPerformanceBudgets() {
|
||||
const budgets = PERFORMANCE_BUDGETS.budgets;
|
||||
const violations = [];
|
||||
|
||||
// Check Core Web Vitals
|
||||
if (this.metrics.coreWebVitals.lcp) {
|
||||
const lcpValue = this.metrics.coreWebVitals.lcp.value;
|
||||
const lcpBudget = budgets.find((b) => b.name === "lcp")?.maxValue;
|
||||
|
||||
if (lcpBudget && lcpValue > lcpBudget) {
|
||||
violations.push({
|
||||
metric: "LCP",
|
||||
current: lcpValue,
|
||||
budget: lcpBudget,
|
||||
severity: lcpValue > lcpBudget * 1.5 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check bundle size
|
||||
if (this.metrics.bundleMetrics.totalSizeKB > 2000) {
|
||||
violations.push({
|
||||
metric: "Bundle Size",
|
||||
current: this.metrics.bundleMetrics.totalSizeKB,
|
||||
budget: 2000,
|
||||
severity: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
this.metrics.budgetViolations = violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
generatePerformanceReport() {
|
||||
const reportPath = path.join(MONITORING_DIR, "performance-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(this.metrics, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown performance report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(MONITORING_DIR, "performance-report.md");
|
||||
|
||||
let report = `# Performance Monitoring Report\n\n`;
|
||||
report += `**Generated:** ${this.metrics.timestamp}\n\n`;
|
||||
|
||||
// Core Web Vitals
|
||||
if (Object.keys(this.metrics.coreWebVitals).length > 0) {
|
||||
report += `## Core Web Vitals\n\n`;
|
||||
report += `| Metric | Score | Value | Status |\n`;
|
||||
report += `|--------|-------|-------|--------|\n`;
|
||||
|
||||
Object.entries(this.metrics.coreWebVitals).forEach(([metric, data]) => {
|
||||
if (data && typeof data === "object" && data.score !== undefined) {
|
||||
const status = this.getMetricStatus(metric, data.score);
|
||||
report += `| ${metric.toUpperCase()} | ${data.score} | ${
|
||||
data.displayValue || "N/A"
|
||||
} | ${status} |\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bundle Metrics
|
||||
if (Object.keys(this.metrics.bundleMetrics).length > 0) {
|
||||
report += `\n## Bundle Metrics\n\n`;
|
||||
report += `- **Total Size:** ${this.metrics.bundleMetrics.totalSizeMB}MB (${this.metrics.bundleMetrics.totalSizeKB}KB)\n`;
|
||||
report += `- **File Count:** ${this.metrics.bundleMetrics.fileCount}\n`;
|
||||
report += `- **Average Size:** ${this.metrics.bundleMetrics.averageSizeKB}KB per file\n`;
|
||||
}
|
||||
|
||||
// Budget Violations
|
||||
if (
|
||||
this.metrics.budgetViolations &&
|
||||
this.metrics.budgetViolations.length > 0
|
||||
) {
|
||||
report += `\n## Budget Violations\n\n`;
|
||||
this.metrics.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.metric}**: ${violation.current} (exceeds ${
|
||||
violation.budget
|
||||
}) - ${violation.severity.toUpperCase()}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
report += `\n## Recommendations\n\n`;
|
||||
report += `- Monitor Core Web Vitals regularly\n`;
|
||||
report += `- Implement code splitting for large bundles\n`;
|
||||
report += `- Use dynamic imports for non-critical components\n`;
|
||||
report += `- Optimize images and fonts\n`;
|
||||
report += `- Enable compression and caching\n`;
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status emoji for metric score
|
||||
*/
|
||||
getMetricStatus(metric, score) {
|
||||
if (score >= 90) return "✅ Good";
|
||||
if (score >= 50) return "⚠️ Needs Improvement";
|
||||
return "❌ Poor";
|
||||
}
|
||||
}
|
||||
|
||||
// Run the performance monitor if this script is executed directly
|
||||
// Run monitoring if called directly
|
||||
if (require.main === module) {
|
||||
const monitor = new PerformanceMonitorScript();
|
||||
monitor.run();
|
||||
const monitor = new PerformanceMonitor();
|
||||
monitor.monitorPerformance().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = PerformanceMonitorScript;
|
||||
module.exports = PerformanceMonitor;
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Web Vitals Tracker
|
||||
* Real-time monitoring of Core Web Vitals in production
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const WEB_VITALS_DIR = path.join(__dirname, "..", ".next", "web-vitals");
|
||||
|
||||
class WebVitalsTracker {
|
||||
constructor() {
|
||||
this.metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
vitals: {
|
||||
lcp: [],
|
||||
fid: [],
|
||||
cls: [],
|
||||
fcp: [],
|
||||
ttfb: [],
|
||||
},
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track Web Vitals from client-side
|
||||
*/
|
||||
trackWebVitals() {
|
||||
const trackingCode = `
|
||||
// Web Vitals Tracking Script
|
||||
(function() {
|
||||
// Import web-vitals library
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
const vitals = {};
|
||||
|
||||
// Track Largest Contentful Paint
|
||||
getLCP((metric) => {
|
||||
vitals.lcp = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('lcp', vitals.lcp);
|
||||
});
|
||||
|
||||
// Track First Input Delay
|
||||
getFID((metric) => {
|
||||
vitals.fid = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('fid', vitals.fid);
|
||||
});
|
||||
|
||||
// Track Cumulative Layout Shift
|
||||
getCLS((metric) => {
|
||||
vitals.cls = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('cls', vitals.cls);
|
||||
});
|
||||
|
||||
// Track First Contentful Paint
|
||||
getFCP((metric) => {
|
||||
vitals.fcp = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('fcp', vitals.fcp);
|
||||
});
|
||||
|
||||
// Track Time to First Byte
|
||||
getTTFB((metric) => {
|
||||
vitals.ttfb = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('ttfb', vitals.ttfb);
|
||||
});
|
||||
});
|
||||
|
||||
// Send vitals to server
|
||||
function sendVitals(metric, data) {
|
||||
if (typeof window !== 'undefined' && window.fetch) {
|
||||
fetch('/api/web-vitals', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metric,
|
||||
data,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
return trackingCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API endpoint for receiving Web Vitals
|
||||
*/
|
||||
createAPIEndpoint() {
|
||||
const apiCode = `
|
||||
// API endpoint for Web Vitals tracking
|
||||
export default function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { metric, data, url, userAgent, timestamp } = req.body;
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
userAgent,
|
||||
timestamp: new Date(timestamp).toISOString()
|
||||
};
|
||||
|
||||
// In production, you would save this to a database
|
||||
// For now, we'll log it
|
||||
console.log('Web Vital received:', vitalsData);
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error processing web vital:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return apiCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Web Vitals dashboard
|
||||
*/
|
||||
generateDashboard() {
|
||||
const dashboardCode = `
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const WebVitalsDashboard = () => {
|
||||
const [vitals, setVitals] = useState({
|
||||
lcp: { value: 0, rating: 'unknown' },
|
||||
fid: { value: 0, rating: 'unknown' },
|
||||
cls: { value: 0, rating: 'unknown' },
|
||||
fcp: { value: 0, rating: 'unknown' },
|
||||
ttfb: { value: 0, rating: 'unknown' }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// In a real implementation, you would fetch from your database
|
||||
// For now, we'll use localStorage for demo purposes
|
||||
const storedVitals = localStorage.getItem('web-vitals');
|
||||
if (storedVitals) {
|
||||
setVitals(JSON.parse(storedVitals));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good': return 'text-green-600';
|
||||
case 'needs-improvement': return 'text-yellow-600';
|
||||
case 'poor': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good': return '✅';
|
||||
case 'needs-improvement': return '⚠️';
|
||||
case 'poor': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6">Web Vitals Dashboard</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div key={metric} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div>Value: {data.value}ms</div>
|
||||
<div className={\`font-medium \${getRatingColor(data.rating)}\`}>
|
||||
Rating: {data.rating.replace('-', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">Performance Guidelines</h3>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s, Poor > 4s</li>
|
||||
<li>• <strong>FID:</strong> Good < 100ms, Needs Improvement 100-300ms, Poor > 300ms</li>
|
||||
<li>• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25, Poor > 0.25</li>
|
||||
<li>• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s, Poor > 3s</li>
|
||||
<li>• <strong>TTFB:</strong> Good < 800ms, Needs Improvement 800-1800ms, Poor > 1800ms</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebVitalsDashboard;
|
||||
`;
|
||||
|
||||
return dashboardCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Web Vitals data
|
||||
*/
|
||||
saveVitalsData(metric, data) {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn("Could not parse existing vitals data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
existingData.push({
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Keep only last 100 entries
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Web Vitals report
|
||||
*/
|
||||
generateReport() {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
console.log("No Web Vitals data found");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const metric = file.replace(".json", "");
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8")
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
.map((d) => d.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
report.metrics[metric] = {
|
||||
count: data.length,
|
||||
average:
|
||||
values.length > 0
|
||||
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
|
||||
: 0,
|
||||
min: values.length > 0 ? Math.min(...values) : 0,
|
||||
max: values.length > 0 ? Math.max(...values) : 0,
|
||||
goodCount: ratings.filter((r) => r === "good").length,
|
||||
needsImprovementCount: ratings.filter(
|
||||
(r) => r === "needs-improvement"
|
||||
).length,
|
||||
poorCount: ratings.filter((r) => r === "poor").length,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const reportPath = path.join(WEB_VITALS_DIR, "report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
|
||||
console.log("📊 Web Vitals report generated:", reportPath);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const tracker = new WebVitalsTracker();
|
||||
tracker.generateReport();
|
||||
}
|
||||
|
||||
module.exports = WebVitalsTracker;
|
||||
Reference in New Issue
Block a user