pulse-dashboard
Version:
A Next.js Dashboard application for real-time monitoring and historical analysis of Playwright test executions, based on playwright-pulse-report. This component provides the UI for visualizing Playwright test results and can be run as a standalone CLI too
327 lines (323 loc) • 17.2 kB
JSX
'use client';
import * as React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { TrendingUp, Terminal, Info, Users } from 'lucide-react';
const CustomTooltip = ({ active, payload, label, }) => {
if (active && payload && payload.length) {
return (<div className="custom-recharts-tooltip">
<p className="label">{`Date: ${label}`}</p>
{payload.map((entry, index) => (<p key={`item-${index}`} style={{ color: entry.color }} className="text-xs">
{`${entry.name}: ${entry.value?.toLocaleString()}${entry.unit || ""}`}
</p>))}
</div>);
}
return null;
};
const TrendAnalysisComponent = ({ trends, loading, error, currentResults, }) => {
const outcomesChartRef = React.useRef(null);
const durationChartRef = React.useRef(null);
const describeDurationChartRef = React.useRef(null);
const severityDistributionChartRef = React.useRef(null);
const workerCountChartRef = React.useRef(null);
const formattedTrends = React.useMemo(() => {
if (!trends || trends.length === 0) {
return [];
}
return trends.map((t) => ({
...t,
date: new Date(t.date).toLocaleDateString("en-CA", {
month: "short",
day: "numeric",
}),
durationSeconds: parseFloat((t.duration / 1000).toFixed(2)),
workerCount: t.workerCount, // workerCount is already a number or undefined
}));
}, [trends]);
const describeDurationsData = React.useMemo(() => {
console.log("[Describe Duration Chart] currentResults:", currentResults?.length, "items");
if (!currentResults || currentResults.length === 0) {
console.log("[Describe Duration Chart] No current results");
return [];
}
const describeMap = new Map();
let foundAnyDescribe = false;
currentResults.forEach((test) => {
if (test.describe) {
const describeName = test.describe;
if (!describeName ||
describeName.trim().toLowerCase() === "n/a" ||
describeName.trim() === "") {
return;
}
foundAnyDescribe = true;
const fileName = test.spec_file || "Unknown File";
const key = `${fileName}::${describeName}`;
if (!describeMap.has(key)) {
describeMap.set(key, {
duration: 0,
file: fileName,
describe: describeName,
});
}
describeMap.get(key).duration += test.duration;
}
});
if (!foundAnyDescribe) {
console.log("[Describe Duration Chart] No valid describe blocks found");
return [];
}
const result = Array.from(describeMap.values()).map((val) => ({
describe: val.describe,
duration: val.duration,
durationSeconds: parseFloat((val.duration / 1000).toFixed(2)),
file: val.file,
}));
console.log("[Describe Duration Chart] Processed data:", result.length, "describe blocks");
return result;
}, [currentResults]);
const severityDistributionData = React.useMemo(() => {
if (!currentResults || currentResults.length === 0) {
return { series: [], categories: [] };
}
const severityLevels = ["Critical", "High", "Medium", "Low", "Minor"];
const data = {
passed: [0, 0, 0, 0, 0],
failed: [0, 0, 0, 0, 0],
skipped: [0, 0, 0, 0, 0],
};
currentResults.forEach((test) => {
const sev = test.severity || "Medium";
const status = String(test.status).toLowerCase();
let index = severityLevels.indexOf(sev);
if (index === -1)
index = 2;
if (status === "passed") {
data.passed[index]++;
}
else if (status === "failed" ||
status === "timedout" ||
status === "interrupted") {
data.failed[index]++;
}
else {
data.skipped[index]++;
}
});
const series = [
{ name: "Passed", data: data.passed },
{ name: "Failed", data: data.failed },
{ name: "Skipped", data: data.skipped },
];
return { series, categories: severityLevels };
}, [currentResults]);
if (loading) {
return (<Card className="shadow-xl rounded-xl backdrop-blur-md bg-card/80 border-border/50">
<CardHeader>
<CardTitle className="flex items-center text-2xl font-headline text-primary">
<TrendingUp className="h-7 w-7 mr-2"/>
Historical Trend Analysis
</CardTitle>
</CardHeader>
<CardContent className="space-y-8 p-6">
{[...Array(3)].map((_, i) => (<div key={i} className="bg-muted/30 p-4 rounded-lg shadow-inner">
<Skeleton className="h-6 w-1/3 mb-4 rounded-md bg-muted/50"/>
<Skeleton className="h-64 w-full rounded-md bg-muted/50"/>
</div>))}
</CardContent>
</Card>);
}
if (error) {
return (<Alert variant="destructive" className="mt-4 shadow-md rounded-lg">
<Terminal className="h-4 w-4"/>
<AlertTitle>Error Fetching Historical Trends</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>);
}
if (!trends || trends.length === 0) {
return (<Card className="shadow-xl rounded-xl backdrop-blur-md bg-card/80 border-border/50">
<CardHeader>
<CardTitle className="flex items-center text-2xl font-headline text-primary">
<TrendingUp className="h-7 w-7 mr-2"/>
Historical Trend Analysis
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<Alert className="rounded-lg border-primary/30 bg-primary/5 text-primary">
<Info className="h-5 w-5 text-primary/80"/>
<AlertTitle className="font-semibold">
No Historical Data
</AlertTitle>
<AlertDescription>
No historical trend data available. Ensure 'trend-*.json' files
exist in 'pulse-report/history/' and are correctly formatted.
</AlertDescription>
</Alert>
</CardContent>
</Card>);
}
const workerCountDataAvailable = formattedTrends.some((t) => typeof t.workerCount === "number" && t.workerCount > 0);
return (<Card className="shadow-xl rounded-xl backdrop-blur-md bg-card/80 border-border/50">
<CardHeader>
<CardTitle className="text-2xl font-headline text-primary flex items-center">
<TrendingUp className="h-7 w-7 mr-2"/>
Historical Trend Analysis
</CardTitle>
</CardHeader>
<CardContent className="space-y-10 p-6">
<div>
<div className="flex justify-between items-center mb-4">
<h4 className="text-xl font-semibold text-foreground">
Test Outcomes Over Time
</h4>
</div>
<div ref={outcomesChartRef} className="w-full h-[350px] bg-muted/30 p-4 rounded-lg shadow-inner">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedTrends} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }}/>
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }}/>
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: "hsl(var(--muted))", fillOpacity: 0.2 }}/>
<Legend wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }}/>
<Line type="monotone" dataKey="totalTests" name="Total Tests" stroke="hsl(var(--chart-2))" strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }}/>
<Line type="monotone" dataKey="passed" name="Passed" stroke="hsl(var(--chart-3))" strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }}/>
<Line type="monotone" dataKey="failed" name="Failed" stroke="hsl(var(--chart-4))" strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }}/>
<Line type="monotone" dataKey="skipped" name="Skipped" stroke="hsl(var(--chart-5))" strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }}/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-4">
<h4 className="text-xl font-semibold text-foreground">
Test Duration Over Time
</h4>
</div>
<div ref={durationChartRef} className="w-full h-[350px] bg-muted/30 p-4 rounded-lg shadow-inner">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={formattedTrends} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }}/>
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }} unit="s" name="Seconds"/>
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: "hsl(var(--muted))", fillOpacity: 0.2 }}/>
<Legend wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }}/>
<Bar dataKey="durationSeconds" name="Duration (s)" fill="hsl(var(--chart-1))" radius={[6, 6, 0, 0]} barSize={20}/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{describeDurationsData.length > 0 && (<div>
<div className="flex justify-between items-center mb-4">
<h4 className="text-xl font-semibold text-foreground">
Test Describe Duration
</h4>
</div>
<div ref={describeDurationChartRef} className="w-full h-[400px] bg-muted/30 p-4 rounded-lg shadow-inner">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={describeDurationsData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis dataKey="describe" hide={true} tick={false} axisLine={false}/>
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }} label={{
value: "Total Duration (s)",
angle: -90,
position: "insideLeft",
style: { textAnchor: "middle" },
}}/>
<RechartsTooltip content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (<div className="custom-recharts-tooltip">
<p className="label font-semibold">{`Describe: ${data.describe}`}</p>
<p className="text-xs" style={{ opacity: 0.8 }}>{`File: ${data.file}`}</p>
<p className="text-xs" style={{ color: payload[0].color }}>{`Duration: ${data.durationSeconds}s`}</p>
</div>);
}
return null;
}} cursor={{ fill: "hsl(var(--muted))", fillOpacity: 0.2 }}/>
<Legend wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }}/>
<Bar dataKey="durationSeconds" name="Duration (s)" fill="hsl(var(--accent))" radius={[4, 4, 0, 0]}/>
</BarChart>
</ResponsiveContainer>
</div>
</div>)}
{severityDistributionData.series.length > 0 && (<div>
<div className="flex justify-between items-center mb-4">
<h4 className="text-xl font-semibold text-foreground">
Severity Distribution
</h4>
</div>
<div ref={severityDistributionChartRef} className="w-full h-[400px] bg-muted/30 p-4 rounded-lg shadow-inner">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={severityDistributionData.categories.map((category, idx) => {
const dataPoint = { severity: category };
severityDistributionData.series.forEach((s) => {
dataPoint[s.name] = s.data[idx];
});
return dataPoint;
})} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis dataKey="severity" stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }}/>
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }} label={{
value: "Test Count",
angle: -90,
position: "insideLeft",
style: { textAnchor: "middle" },
}}/>
<RechartsTooltip content={({ active, payload }) => {
if (active && payload && payload.length) {
const validPayload = payload.filter((p) => p.value && Number(p.value) > 0);
if (validPayload.length === 0)
return null;
return (<div className="custom-recharts-tooltip">
<p className="label font-semibold">{`Severity: ${payload[0].payload.severity}`}</p>
{validPayload.map((entry, index) => (<p key={`item-${index}`} className="text-xs" style={{ color: entry.color }}>
{`${entry.name}: ${entry.value}`}
</p>))}
<p className="text-xs font-semibold mt-1">
{`Total: ${validPayload.reduce((sum, p) => sum + (p.value || 0), 0)}`}
</p>
</div>);
}
return null;
}} cursor={{ fill: "hsl(var(--muted))", fillOpacity: 0.2 }}/>
<Legend wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }}/>
<Bar dataKey="Passed" stackId="a" fill="hsl(var(--chart-3))" radius={[0, 0, 0, 0]}/>
<Bar dataKey="Failed" stackId="a" fill="hsl(var(--chart-4))" radius={[0, 0, 0, 0]}/>
<Bar dataKey="Skipped" stackId="a" fill="hsl(var(--chart-5))" radius={[4, 4, 0, 0]}/>
</BarChart>
</ResponsiveContainer>
</div>
</div>)}
{workerCountDataAvailable && (<div>
<div className="flex justify-between items-center mb-4">
<h4 className="text-xl font-semibold text-foreground flex items-center">
<Users className="h-5 w-5 mr-2 text-primary"/> Active Worker
Count Over Time
</h4>
</div>
<div ref={workerCountChartRef} className="w-full h-[350px] bg-muted/30 p-4 rounded-lg shadow-inner">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedTrends.filter((t) => typeof t.workerCount === "number")} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }}/>
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 12 }} allowDecimals={false} domain={["dataMin", "dataMax"]}/>
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: "hsl(var(--muted))", fillOpacity: 0.2 }}/>
<Legend wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }}/>
<Line type="monotone" dataKey="workerCount" name="Active Workers" stroke="hsl(var(--chart-info))" strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }} connectNulls={false}/>
</LineChart>
</ResponsiveContainer>
</div>
<p className="text-xs text-muted-foreground mt-2">
Note: This chart shows the number of unique worker IDs detected in
each historical run. Runs without worker information will not be
plotted.
</p>
</div>)}
</CardContent>
</Card>);
};
export const TrendAnalysis = React.memo(TrendAnalysisComponent);
TrendAnalysis.displayName = 'TrendAnalysis';
//# sourceMappingURL=TrendAnalysis.jsx.map