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
612 lines (600 loc) • 34.9 kB
JSX
'use client';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { PieChart as RechartsPieChart, Pie, BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsRechartsTooltip, Legend, ResponsiveContainer, Cell, LabelList, Sector } from 'recharts';
import { Terminal, CheckCircle, Info, Chrome, Globe, Compass, Users, ListFilter, RotateCcw, Search } from 'lucide-react'; // Added Search
import { cn } from '@/lib/utils';
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
// Import UI components for filters
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
// --- FIX: Import Tooltip components for the reset button ---
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
const LazyChartWrapper = ({ children, placeholderHeight = '300px' }) => {
const [isVisible, setIsVisible] = useState(false);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
}, {
rootMargin: '0px 0px 200px 0px',
});
const currentRef = ref.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, []);
return (<div ref={ref} style={{ minHeight: !isVisible ? placeholderHeight : undefined }}>
{isVisible ? children : <Skeleton className="w-full rounded-lg" style={{ height: placeholderHeight }}/>}
</div>);
};
const COLORS = {
passed: 'hsl(var(--chart-3))',
failed: 'hsl(var(--destructive))',
skipped: 'hsl(var(--accent))',
timedOut: 'hsl(var(--destructive))',
pending: 'hsl(var(--muted-foreground))',
default1: 'hsl(var(--chart-1))',
default2: 'hsl(var(--chart-2))',
default3: 'hsl(var(--chart-4))',
default4: 'hsl(var(--chart-5))',
default5: 'hsl(var(--chart-3))',
};
function formatDurationForChart(ms) {
if (ms === 0)
return '0s';
const seconds = parseFloat((ms / 1000).toFixed(1));
return `${seconds}s`;
}
function formatTestNameForChart(fullName) {
if (!fullName)
return '';
const parts = fullName.split(" > ");
return parts[parts.length - 1] || fullName;
}
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
const dataPoint = payload[0].payload;
const isStackedBarTooltip = dataPoint && dataPoint.total !== undefined && payload.length > 0;
const isPieChartTooltip = dataPoint && dataPoint.percentage !== undefined && dataPoint.name;
let displayTitle;
if (isPieChartTooltip && dataPoint?.name) {
displayTitle = dataPoint.name;
}
else if (dataPoint?.fullTestName) {
displayTitle = formatTestNameForChart(dataPoint.fullTestName);
}
else {
displayTitle = String(label);
}
if (displayTitle === "undefined" && payload[0]?.name !== undefined) {
displayTitle = String(payload[0].name);
}
if (displayTitle === "undefined") {
displayTitle = "Details";
}
return (<div className="bg-card p-3 border border-border rounded-md shadow-lg">
<p className="label text-sm font-semibold text-foreground truncate max-w-xs" title={displayTitle}>
{displayTitle}
</p>
{payload.map((entry, index) => (<p key={`item-${index}`} style={{ color: entry.color || entry.payload?.fill }} className="text-xs">
{`${entry.name || 'Value'}: ${entry.value?.toLocaleString()}${entry.unit || ''}`}
{isPieChartTooltip && dataPoint && entry.name === dataPoint.name && ` (${dataPoint.percentage}%)`}
</p>))}
{isStackedBarTooltip && dataPoint && (<p className="text-xs font-bold mt-1 text-foreground">
Total: {dataPoint.total.toLocaleString()}
</p>)}
</div>);
}
return null;
};
function normalizeBrowserNameForIcon(rawBrowserName) {
if (!rawBrowserName)
return 'Unknown';
const lowerName = rawBrowserName.toLowerCase();
if ((lowerName.includes('chrome') || lowerName.includes('chromium')) && (lowerName.includes('mobile') || lowerName.includes('android'))) {
return 'Chrome Mobile';
}
if (lowerName.includes('safari') && lowerName.includes('mobile')) {
return 'Mobile Safari';
}
if (lowerName.includes('chrome') || lowerName.includes('chromium')) {
return 'Chrome';
}
if (lowerName.includes('firefox')) {
return 'Firefox';
}
if (lowerName.includes('msedge') || lowerName.includes('edge')) {
return 'Edge';
}
if (lowerName.includes('safari') || lowerName.includes('webkit')) {
return 'Safari';
}
return 'Unknown';
}
const BrowserIcon = ({ browserName, className }) => {
const normalizedForIcon = normalizeBrowserNameForIcon(browserName);
if (normalizedForIcon === 'Chrome' || normalizedForIcon === 'Chrome Mobile') {
return <Chrome className={cn("h-4 w-4", className)}/>;
}
if (normalizedForIcon === 'Safari' || normalizedForIcon === 'Mobile Safari') {
return <Compass className={cn("h-4 w-4", className)}/>;
}
return <Globe className={cn("h-4 w-4", className)}/>;
};
const ActiveShape = (props) => {
const RADIAN = Math.PI / 180;
const { cx, cy, midAngle, innerRadius = 0, outerRadius = 0, startAngle = 0, endAngle = 0, fill, payload, percent, value = 0 } = props;
const sin = Math.sin(-RADIAN * (midAngle ?? 0));
const cos = Math.cos(-RADIAN * (midAngle ?? 0));
const sx = (cx ?? 0) + (outerRadius + 10) * cos;
const sy = (cy ?? 0) + (outerRadius + 10) * sin;
const mx = (cx ?? 0) + (outerRadius + 30) * cos;
const my = (cy ?? 0) + (outerRadius + 30) * sin;
const ex = mx + (cos >= 0 ? 1 : -1) * 22;
const ey = my;
const textAnchor = cos >= 0 ? 'start' : 'end';
const centerNameTextFill = payload?.name === 'Passed' ? COLORS.passed : 'hsl(var(--foreground))';
return (<g>
<text x={cx} y={cy} dy={8} textAnchor="middle" fill={centerNameTextFill} className="text-lg font-bold">
{payload?.name}
</text>
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill}/>
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius + 6} outerRadius={outerRadius + 10} fill={fill}/>
<path d={`M${sx},${sy}L${mx},${my}L${ex},${ey}`} stroke={fill} fill="none"/>
<circle cx={ex} cy={ey} r={2} fill={fill} stroke="none"/>
<text x={ex + (cos >= 0 ? 1 : -1) * 12} y={ey} textAnchor={textAnchor} fill="hsl(var(--foreground))" className="text-xs">{`${value}`}</text>
<text x={ex + (cos >= 0 ? 1 : -1) * 12} y={ey} dy={18} textAnchor={textAnchor} fill="hsl(var(--muted-foreground))" className="text-xs">
{`(Rate ${((percent ?? 0) * 100).toFixed(2)}%)`}
</text>
</g>);
};
export function DashboardOverviewCharts({ currentRun, loading, error }) {
const [testNameFilter, setTestNameFilter] = useState('');
const [suiteFilter, setSuiteFilter] = useState('all');
const [workerFilter, setWorkerFilter] = useState([]);
const availableSuites = useMemo(() => {
if (!currentRun?.results)
return [];
const suites = new Set(currentRun.results.map(t => t.suiteName).filter(name => name));
return Array.from(suites).sort();
}, [currentRun?.results]);
const availableWorkers = useMemo(() => {
if (!currentRun?.results)
return [];
const workerIds = currentRun.results
.map(t => t.workerId)
.filter(id => id != null && id !== '' && String(id) !== '-1')
.map(id => String(id));
const uniqueWorkerIds = Array.from(new Set(workerIds));
return uniqueWorkerIds.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}, [currentRun?.results]);
useEffect(() => {
if (availableWorkers.length > 0) {
setWorkerFilter(availableWorkers);
}
}, [availableWorkers]);
const workerDonutData = useMemo(() => {
if (!currentRun?.results)
return [];
const filteredTests = currentRun.results.filter(test => {
const workerIdStr = test.workerId != null ? String(test.workerId) : '';
if (workerFilter.length > 0 && !workerFilter.includes(workerIdStr)) {
return false;
}
if (suiteFilter !== 'all' && test.suiteName !== suiteFilter) {
return false;
}
if (testNameFilter && !test.name.toLowerCase().includes(testNameFilter.toLowerCase())) {
return false;
}
if (workerIdStr === '-1' || workerIdStr === '') {
return false;
}
return test.startTime && typeof test.duration === 'number';
});
const testsByWorker = filteredTests.reduce((acc, test) => {
const workerId = test.workerId != null ? String(test.workerId) : 'unknown';
if (!acc[workerId]) {
acc[workerId] = [];
}
acc[workerId].push(test);
return acc;
}, {});
return Object.entries(testsByWorker)
.map(([workerId, tests]) => ({
workerId,
tests: tests.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()),
totalDuration: tests.reduce((sum, test) => sum + test.duration, 0)
}))
.sort((a, b) => a.workerId.localeCompare(b.workerId, undefined, { numeric: true }));
}, [currentRun?.results, testNameFilter, suiteFilter, workerFilter]);
const [activeIndex, setActiveIndex] = useState(0);
const onPieEnter = useCallback((_, index) => {
setActiveIndex(index);
}, []);
const onPieMouseLeave = useCallback(() => {
setActiveIndex(0);
}, []);
const isFiltered = useMemo(() => {
return testNameFilter !== '' || suiteFilter !== 'all' || workerFilter.length !== availableWorkers.length;
}, [testNameFilter, suiteFilter, workerFilter, availableWorkers]);
const handleResetFilters = useCallback(() => {
setTestNameFilter('');
setSuiteFilter('all');
setWorkerFilter(availableWorkers);
}, [availableWorkers]);
if (loading) {
return (<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mt-6">
{[...Array(5)].map((_, i) => (<Card key={i} className="shadow-md rounded-xl">
<CardHeader>
<Skeleton className="h-5 w-3/4 rounded-md"/>
<Skeleton className="h-4 w-1/2 mt-1 rounded-md"/>
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full rounded-lg"/>
</CardContent>
</Card>))}
</div>);
}
if (error) {
return (<Alert variant="destructive" className="mt-6 rounded-lg">
<Terminal className="h-4 w-4"/>
<AlertTitle>Error Loading Chart Data</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>);
}
if (!currentRun || !currentRun.run || !currentRun.results) {
return (<Alert className="mt-6 rounded-lg">
<Info className="h-4 w-4"/>
<AlertTitle>No Data for Charts</AlertTitle>
<AlertDescription>Current run data is not available to display charts.</AlertDescription>
</Alert>);
}
const { passed, failed, skipped, timedOut = 0, pending = 0 } = currentRun.run;
const totalTestsForPie = passed + failed + skipped + timedOut + pending;
const testDistributionData = [
{ name: 'Passed', value: passed, fill: COLORS.passed },
{ name: 'Failed', value: failed + timedOut, fill: COLORS.failed },
{ name: 'Skipped', value: skipped, fill: COLORS.skipped },
...(pending > 0 ? [{ name: 'Pending', value: pending, fill: COLORS.pending }] : []),
]
.filter(d => d.value > 0)
.map(d => ({ ...d, name: d.name, value: d.value, fill: d.fill, percentage: totalTestsForPie > 0 ? ((d.value / totalTestsForPie) * 100).toFixed(1) : '0.0' }));
const browserDistributionRaw = currentRun.results.reduce((acc, test) => {
const browserName = test.browser || 'Unknown';
if (!acc[browserName]) {
acc[browserName] = { name: browserName, passed: 0, failed: 0, skipped: 0, pending: 0, total: 0 };
}
if (test.status === 'passed')
acc[browserName].passed++;
else if (test.status === 'failed' || test.status === 'timedOut')
acc[browserName].failed++;
else if (test.status === 'skipped')
acc[browserName].skipped++;
else if (test.status === 'pending')
acc[browserName].pending++;
acc[browserName].total++;
return acc;
}, {});
const browserChartData = Object.values(browserDistributionRaw).sort((a, b) => b.total - a.total);
const failedTestsDurationData = currentRun.results
.filter((test) => test.status === 'failed' || test.status === 'timedOut')
.map((test) => {
const shortName = formatTestNameForChart(test.name);
return {
name: shortName.length > 50 ? shortName.substring(0, 47) + '...' : shortName,
duration: test.duration,
durationFormatted: formatDurationForChart(test.duration),
fullTestName: test.name,
};
})
.sort((a, b) => b.duration - a.duration)
.slice(0, 10);
const suiteDistributionRaw = currentRun.results.reduce((acc, test) => {
const suiteName = test.suiteName || 'Unknown Suite';
if (!acc[suiteName]) {
acc[suiteName] = { name: suiteName, passed: 0, failed: 0, skipped: 0, pending: 0, total: 0 };
}
if (test.status === 'passed')
acc[suiteName].passed++;
else if (test.status === 'failed' || test.status === 'timedOut')
acc[suiteName].failed++;
else if (test.status === 'skipped')
acc[suiteName].skipped++;
else if (test.status === 'pending')
acc[suiteName].pending++;
acc[suiteName].total++;
return acc;
}, {});
const testsPerSuiteChartData = Object.values(suiteDistributionRaw).sort((a, b) => b.total - a.total);
const slowestTestsData = [...currentRun.results]
.sort((a, b) => b.duration - a.duration)
.slice(0, 5)
.map((test) => {
const shortName = formatTestNameForChart(test.name);
return {
name: shortName,
duration: test.duration,
durationFormatted: formatDurationForChart(test.duration),
fullTestName: test.name,
status: test.status,
};
});
const showPendingInBrowserChart = browserChartData.some(d => d.pending > 0);
const showPendingInSuiteChart = testsPerSuiteChartData.some(s => s.pending > 0);
return (<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 mt-6">
<Card className="lg:col-span-1 shadow-lg hover:shadow-xl transition-shadow duration-300 rounded-xl">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-semibold text-foreground">Test Distribution</CardTitle>
<CardDescription className="text-xs">Passed, Failed, Skipped for the current run.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex justify-center items-center min-h-[280px]">
<div className="w-full h-[280px]">
{testDistributionData.length > 0 ? (<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }} onMouseLeave={onPieMouseLeave}>
<Pie activeIndex={activeIndex} activeShape={ActiveShape} data={testDistributionData} cx="50%" cy="50%" innerRadius={60} outerRadius={90} dataKey="value" nameKey="name" onMouseEnter={onPieEnter} paddingAngle={2} stroke="hsl(var(--card))">
{testDistributionData.map((entry, index) => (<Cell key={`cell-${index}`} fill={entry.fill}/>))}
</Pie>
<RechartsRechartsTooltip content={<CustomTooltip />}/>
<Legend iconSize={10} layout="horizontal" verticalAlign="bottom" align="center" wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }}/>
</RechartsPieChart>
</ResponsiveContainer>) : (<div className="text-center text-muted-foreground">No test distribution data.</div>)}
</div>
</CardContent>
</Card>
<Card className="lg:col-span-1 shadow-lg hover:shadow-xl transition-shadow duration-300 rounded-xl">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-semibold text-foreground">Tests by Browser</CardTitle>
<CardDescription className="text-xs">Breakdown of test outcomes per browser.</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="w-full h-[250px]">
{browserChartData.length > 0 ? (<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={browserChartData} layout="vertical" margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis type="number" stroke="hsl(var(--muted-foreground))" fontSize={10}/>
<YAxis dataKey="name" type="category" stroke="hsl(var(--muted-foreground))" fontSize={10} width={150} tickFormatter={(value) => value.length > 20 ? value.substring(0, 17) + '...' : value} interval={0}/>
<RechartsRechartsTooltip content={<CustomTooltip />} cursor={{ fill: 'hsl(var(--muted))', fillOpacity: 0.3 }}/>
<Legend wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }}/>
<Bar dataKey="passed" name="Passed" stackId="a" fill={COLORS.passed} barSize={20}/>
<Bar dataKey="failed" name="Failed" stackId="a" fill={COLORS.failed} barSize={20}/>
<Bar dataKey="skipped" name="Skipped" stackId="a" fill={COLORS.skipped} barSize={20}/>
{showPendingInBrowserChart && (<Bar dataKey="pending" name="Pending" stackId="a" fill={COLORS.pending} barSize={20}/>)}
</RechartsBarChart>
</ResponsiveContainer>) : (<div className="text-center text-muted-foreground h-[250px] flex items-center justify-center">No browser data.</div>)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 mt-3 text-xs text-muted-foreground">
{browserChartData.map(b => (<div key={b.name} className="flex items-center gap-1" title={b.name}>
<BrowserIcon browserName={b.name} className="mr-1"/>
<span className="truncate max-w-[150px]">{b.name}</span>
</div>))}
</div>
<p className="text-xs text-muted-foreground mt-2">Note: Icons are representative. Full browser name (including version) is shown in tooltip.</p>
</CardContent>
</Card>
<Card className="lg:col-span-1 shadow-lg hover:shadow-xl transition-shadow duration-300 rounded-xl">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-semibold text-foreground">Failed Tests Duration</CardTitle>
<CardDescription className="text-xs">Duration of failed or timed out tests (Top 10).</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="w-full h-[250px]">
{failedTestsDurationData.length > 0 ? (<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={failedTestsDurationData} margin={{ top: 5, right: 5, left: 5, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis dataKey="name" stroke="hsl(var(--muted-foreground))" fontSize={10} angle={-40} textAnchor="end" interval={0}/>
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={10} tickFormatter={(value) => formatDurationForChart(value)} domain={[0, (dataMax) => dataMax > 0 ? Math.round(dataMax * 1.20) : 100]}/>
<RechartsRechartsTooltip content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (<div className="bg-card p-3 border border-border rounded-md shadow-lg">
<p className="label text-sm font-semibold text-foreground truncate max-w-xs" title={data.fullTestName}>{formatTestNameForChart(data.fullTestName)}</p>
<p className="text-xs" style={{ color: COLORS.failed }}>
Duration: {formatDurationForChart(data.duration)}
</p>
</div>);
}
return null;
}} cursor={{ fill: 'hsl(var(--muted))', fillOpacity: 0.3 }}/>
<Bar dataKey="duration" name="Duration" fill={COLORS.failed} barSize={20}>
<LabelList dataKey="durationFormatted" position="top" style={{ fontSize: '10px', fill: 'hsl(var(--destructive))' }}/>
</Bar>
</RechartsBarChart>
</ResponsiveContainer>) : (<div className="flex flex-col items-center justify-center h-[250px] text-center">
<CheckCircle className="h-12 w-12 text-green-500 mb-2"/>
<p className="text-muted-foreground">No failed tests in this run!</p>
</div>)}
</div>
</CardContent>
</Card>
<Card className="lg:col-span-1 shadow-lg hover:shadow-xl transition-shadow duration-300 rounded-xl">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-semibold text-foreground">Slowest Tests (Top 5)</CardTitle>
<CardDescription className="text-xs">Top 5 longest running tests in this run. Full names in tooltip.</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="w-full h-[250px]">
{slowestTestsData.length > 0 ? (<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={slowestTestsData} margin={{ top: 5, right: 5, left: 5, bottom: 30 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis dataKey="name" tickLine={false} tickFormatter={() => ''} stroke="hsl(var(--muted-foreground))"/>
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={10} tickFormatter={(value) => formatDurationForChart(value)} domain={[0, (dataMax) => dataMax > 0 ? Math.round(dataMax * 1.20) : 100]}/>
<RechartsRechartsTooltip content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (<div className="bg-card p-3 border border-border rounded-md shadow-lg">
<p className="label text-sm font-semibold text-foreground truncate max-w-xs" title={data.fullTestName}>{formatTestNameForChart(data.fullTestName)}</p>
<p className="text-xs" style={{ color: data.status === 'passed' ? COLORS.passed : data.status === 'failed' || data.status === 'timedOut' ? COLORS.failed : COLORS.skipped }}>
Duration: {formatDurationForChart(data.duration)} (Status: {data.status})
</p>
</div>);
}
return null;
}} cursor={{ fill: 'hsl(var(--muted))', fillOpacity: 0.3 }}/>
<Bar dataKey="duration" name="Duration" barSize={20}>
{slowestTestsData.map((entry, index) => (<Cell key={`cell-${index}`} fill={entry.status === 'passed' ? COLORS.passed : entry.status === 'failed' || entry.status === 'timedOut' ? COLORS.failed : COLORS.skipped}/>))}
<LabelList dataKey="durationFormatted" position="top" style={{ fontSize: '10px', fill: 'hsl(var(--foreground))' }}/>
</Bar>
</RechartsBarChart>
</ResponsiveContainer>) : (<p className="text-muted-foreground h-[250px] flex items-center justify-center">No test data to display for slowest tests.</p>)}
</div>
</CardContent>
</Card>
<Card className="lg:col-span-2 shadow-lg hover:shadow-xl transition-shadow duration-300 rounded-xl">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-semibold text-foreground">Tests per Suite</CardTitle>
<CardDescription className="text-xs">Breakdown of test outcomes per suite.</CardDescription>
</div>
</CardHeader>
<CardContent className="max-h-[400px] overflow-y-auto">
<div className="w-full" style={{ height: Math.max(250, testsPerSuiteChartData.length * 45 + 60) }}>
{testsPerSuiteChartData.length > 0 ? (<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={testsPerSuiteChartData} layout="vertical" margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis type="number" stroke="hsl(var(--muted-foreground))" fontSize={10}/>
<YAxis dataKey="name" type="category" stroke="hsl(var(--muted-foreground))" fontSize={10} width={150} tickFormatter={(value) => value.length > 20 ? value.substring(0, 17) + '...' : value} interval={0}/>
<RechartsRechartsTooltip content={<CustomTooltip />} cursor={{ fill: 'hsl(var(--muted))', fillOpacity: 0.3 }}/>
<Legend wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }}/>
<Bar dataKey="passed" name="Passed" stackId="suiteStack" fill={COLORS.passed} barSize={15}/>
<Bar dataKey="failed" name="Failed" stackId="suiteStack" fill={COLORS.failed} barSize={15}/>
<Bar dataKey="skipped" name="Skipped" stackId="suiteStack" fill={COLORS.skipped} barSize={15}/>
{showPendingInSuiteChart && (<Bar dataKey="pending" name="Pending" stackId="suiteStack" fill={COLORS.pending} barSize={15}/>)}
</RechartsBarChart>
</ResponsiveContainer>) : (<div className="text-center text-muted-foreground h-[250px] flex items-center justify-center">No suite data.</div>)}
</div>
</CardContent>
</Card>
<Card className="lg:col-span-2 shadow-lg hover:shadow-xl transition-shadow duration-300 rounded-xl">
<CardHeader>
<div className="flex flex-row items-center justify-between">
<CardTitle className="text-lg font-semibold text-primary flex items-center">
<Users className="h-5 w-5 mr-2"/> Worker Utilization
</CardTitle>
</div>
<CardDescription className="text-xs">
Filter and inspect tests chronologically for each worker. Slice size represents test duration.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mb-6">
<div className="relative w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Filter by test name..." value={testNameFilter} onChange={(e) => setTestNameFilter(e.target.value)} className="pl-8 w-full"/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Select value={suiteFilter} onValueChange={setSuiteFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Filter by suite"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Suites</SelectItem>
{availableSuites.map(suite => (<SelectItem key={suite} value={suite}>{suite}</SelectItem>))}
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full sm:w-[180px] justify-between">
<span>Workers ({workerFilter.length}/{availableWorkers.length})</span>
<ListFilter className="h-4 w-4"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>Visible Workers</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableWorkers.length > 0 ? availableWorkers.map(workerId => (<DropdownMenuCheckboxItem key={workerId} checked={workerFilter.includes(workerId)} onCheckedChange={(checked) => {
setWorkerFilter(prev => checked ? [...prev, workerId] : prev.filter(id => id !== workerId));
}}>
Worker {workerId}
</DropdownMenuCheckboxItem>)) : <DropdownMenuLabel className="font-normal text-muted-foreground">No workers found</DropdownMenuLabel>}
</DropdownMenuContent>
</DropdownMenu>
{isFiltered && (<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleResetFilters}>
<RotateCcw className="h-4 w-4"/>
<span className="sr-only">Reset Filters</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Reset Filters</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>)}
</div>
</div>
{workerDonutData.length > 0 ? (<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{workerDonutData.map(({ workerId, tests, totalDuration }) => (<LazyChartWrapper key={workerId}>
<div className="flex flex-col items-center">
<h4 className="font-semibold text-center mb-2">Worker {workerId}</h4>
<div className="w-full h-[250px] relative">
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart>
<Pie data={tests} dataKey="duration" nameKey="name" cx="50%" cy="50%" innerRadius={60} outerRadius={80} paddingAngle={2} stroke="hsl(var(--card))">
{tests.map((test, index) => (<Cell key={`cell-${index}`} fill={COLORS[test.status] || COLORS.default1}/>))}
</Pie>
<RechartsRechartsTooltip content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (<div className="bg-background p-3 border border-border rounded-md shadow-lg max-w-sm">
<p className="label text-sm font-semibold text-foreground truncate" title={data.name}>
{formatTestNameForChart(data.name)}
</p>
<p className="text-xs text-muted-foreground mt-1">Suite: {data.suiteName || 'N/A'}</p>
<p className="text-xs" style={{ color: payload[0].color || COLORS.default1 }}>
Status: {data.status}
</p>
<p className="text-xs text-muted-foreground">
Duration: {formatDurationForChart(data.duration)}
</p>
</div>);
}
return null;
}}/>
<Legend iconSize={10} layout="horizontal" verticalAlign="bottom" align="center" wrapperStyle={{ fontSize: "12px", paddingTop: "10px" }} payload={[
{ value: 'Passed', type: 'square', id: 'ID01', color: COLORS.passed },
{ value: 'Failed', type: 'square', id: 'ID02', color: COLORS.failed },
{ value: 'Skipped', type: 'square', id: 'ID03', color: COLORS.skipped }
]}/>
</RechartsPieChart>
</ResponsiveContainer>
<div className="absolute top-1/2 left-1/2 z-10 -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none" style={{ marginTop: '-20px' }}>
<div className="text-xs text-muted-foreground">Total</div>
<div className="text-lg font-bold text-foreground">{formatDurationForChart(totalDuration)}</div>
</div>
</div>
</div>
</LazyChartWrapper>))}
</div>) : (<div className="flex flex-col items-center justify-center h-[200px] text-center p-4 rounded-lg bg-muted/60">
<Info className="h-8 w-8 text-muted-foreground mb-3"/>
<p className="font-semibold text-foreground">No Matching Data</p>
<p className="text-muted-foreground text-sm mt-1">
Adjust your filters or verify the run data.
</p>
</div>)}
</CardContent>
</Card>
</div>);
}
//# sourceMappingURL=DashboardOverviewCharts.jsx.map