feat: implement v0.4.0 - Reports, Charts, Comparison, Dark Mode, E2E Testing
Backend (@backend-dev): - Add ReportService with PDF/CSV generation (reportlab, pandas) - Implement Report API endpoints (POST, GET, DELETE, download) - Add ReportRepository and schemas - Configure storage with auto-cleanup (30 days) - Rate limiting: 10 downloads/minute - Professional PDF templates with charts support Frontend (@frontend-dev): - Integrate Recharts for data visualization - Add CostBreakdown, TimeSeries, ComparisonBar charts - Implement scenario comparison page with multi-select - Add dark/light mode toggle with ThemeProvider - Create Reports page with generation form and list - Add new UI components: checkbox, dialog, tabs, label, skeleton - Implement useComparison and useReports hooks QA (@qa-engineer): - Setup Playwright E2E testing framework - Create 7 test spec files with 94 test cases - Add visual regression testing with baselines - Configure multi-browser testing (Chrome, Firefox, WebKit) - Add mobile responsive tests - Create test fixtures and helpers - Setup GitHub Actions CI workflow Documentation (@spec-architect): - Create detailed kanban-v0.4.0.md with 27 tasks - Update progress.md with v0.4.0 tracking - Create v0.4.0 planning prompt Features: ✅ PDF/CSV Report Generation ✅ Interactive Charts (Pie, Area, Bar) ✅ Scenario Comparison (2-4 scenarios) ✅ Dark/Light Mode Toggle ✅ E2E Test Suite (94 tests) Dependencies added: - Backend: reportlab, pandas, slowapi - Frontend: recharts, date-fns, @radix-ui/react-checkbox/dialog/tabs - Testing: @playwright/test 27 tasks completed, 100% v0.4.0 implementation
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useScenario } from '@/hooks/useScenarios';
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { FileText, ArrowLeft, Play, Square, BarChart3, PieChart, Activity } from 'lucide-react';
|
||||
import { useScenario, useStartScenario, useStopScenario } from '@/hooks/useScenarios';
|
||||
import { useMetrics } from '@/hooks/useMetrics';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { CostBreakdownChart, TimeSeriesChart } from '@/components/charts';
|
||||
import { formatCurrency, formatNumber } from '@/components/charts/ChartContainer';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const statusColors = {
|
||||
draft: 'secondary',
|
||||
@@ -11,67 +18,285 @@ const statusColors = {
|
||||
archived: 'destructive',
|
||||
} as const;
|
||||
|
||||
interface TimeSeriesDataPoint {
|
||||
timestamp: string;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export function ScenarioDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: scenario, isLoading: isLoadingScenario } = useScenario(id || '');
|
||||
const { data: metrics, isLoading: isLoadingMetrics } = useMetrics(id || '');
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const startScenario = useStartScenario(id || '');
|
||||
const stopScenario = useStopScenario(id || '');
|
||||
|
||||
const handleStart = () => startScenario.mutate();
|
||||
const handleStop = () => stopScenario.mutate();
|
||||
|
||||
if (isLoadingScenario || isLoadingMetrics) {
|
||||
return <div>Loading...</div>;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!scenario) {
|
||||
return <div>Scenario not found</div>;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh]">
|
||||
<p className="text-muted-foreground mb-4">Scenario not found</p>
|
||||
<Link to="/scenarios">
|
||||
<Button>Back to Scenarios</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare time series data
|
||||
const timeseriesData = metrics?.timeseries?.map((point) => ({
|
||||
timestamp: point.timestamp,
|
||||
value: point.value,
|
||||
metric_type: point.metric_type,
|
||||
})) || [];
|
||||
|
||||
// Group time series by metric type
|
||||
const groupedTimeseries = timeseriesData.reduce((acc, point) => {
|
||||
if (!acc[point.metric_type]) {
|
||||
acc[point.metric_type] = [];
|
||||
}
|
||||
acc[point.metric_type].push(point);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof timeseriesData>);
|
||||
|
||||
// Transform for chart
|
||||
const chartData: TimeSeriesDataPoint[] = Object.keys(groupedTimeseries).length > 0
|
||||
? groupedTimeseries[Object.keys(groupedTimeseries)[0]].map((point, index) => {
|
||||
const dataPoint: TimeSeriesDataPoint = {
|
||||
timestamp: point.timestamp,
|
||||
};
|
||||
Object.keys(groupedTimeseries).forEach((type) => {
|
||||
const typeData = groupedTimeseries[type];
|
||||
dataPoint[type] = typeData[index]?.value || 0;
|
||||
});
|
||||
return dataPoint;
|
||||
})
|
||||
: [];
|
||||
|
||||
const timeSeriesSeries = Object.keys(groupedTimeseries).map((type, index) => ({
|
||||
key: type,
|
||||
name: type.replace(/_/g, ' ').toUpperCase(),
|
||||
color: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'][index % 5],
|
||||
type: 'line' as const,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{scenario.name}</h1>
|
||||
<p className="text-muted-foreground">{scenario.description}</p>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<Link to="/scenarios">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{scenario.name}</h1>
|
||||
<Badge variant={statusColors[scenario.status]}>
|
||||
{scenario.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">{scenario.description}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
||||
<span>Region: {scenario.region}</span>
|
||||
<span>•</span>
|
||||
<span>Created: {new Date(scenario.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/scenarios/${id}/reports`}>
|
||||
<Button variant="outline">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Reports
|
||||
</Button>
|
||||
</Link>
|
||||
{scenario.status === 'draft' && (
|
||||
<Button onClick={handleStart} disabled={startScenario.isPending}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{scenario.status === 'running' && (
|
||||
<Button onClick={handleStop} disabled={stopScenario.isPending} variant="secondary">
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={statusColors[scenario.status]}>
|
||||
{scenario.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Requests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics?.summary.total_requests || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Requests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${(metrics?.summary.total_cost_usd || 0).toFixed(6)}
|
||||
{formatNumber(metrics?.summary.total_requests || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">SQS Blocks</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Cost</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics?.summary.sqs_blocks || 0}</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(metrics?.summary.total_cost_usd || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">LLM Tokens</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">SQS Blocks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics?.summary.llm_tokens || 0}</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(metrics?.summary.sqs_blocks || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Lambda Invocations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(metrics?.summary.lambda_invocations || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">
|
||||
<PieChart className="mr-2 h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="metrics">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Metrics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analysis">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
Analysis
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Cost Breakdown Chart */}
|
||||
{metrics?.cost_breakdown && metrics.cost_breakdown.length > 0 && (
|
||||
<CostBreakdownChart
|
||||
data={metrics.cost_breakdown}
|
||||
title="Cost by Service"
|
||||
description="Distribution of costs across AWS services"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Summary Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Additional Metrics</CardTitle>
|
||||
<CardDescription>Detailed breakdown of scenario metrics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-muted-foreground">LLM Tokens</span>
|
||||
<span className="font-medium">{formatNumber(metrics?.summary.llm_tokens || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-muted-foreground">PII Violations</span>
|
||||
<span className="font-medium">{formatNumber(metrics?.summary.pii_violations || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-muted-foreground">Avg Cost per Request</span>
|
||||
<span className="font-medium">
|
||||
{metrics?.summary.total_requests
|
||||
? formatCurrency(metrics.summary.total_cost_usd / metrics.summary.total_requests)
|
||||
: '$0.0000'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge variant={statusColors[scenario.status]}>{scenario.status}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metrics" className="space-y-6">
|
||||
{chartData.length > 0 ? (
|
||||
<TimeSeriesChart
|
||||
data={chartData}
|
||||
series={timeSeriesSeries}
|
||||
title="Metrics Over Time"
|
||||
description="Track metric trends throughout the scenario execution"
|
||||
chartType="line"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
No time series data available yet
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analysis" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analysis</CardTitle>
|
||||
<CardDescription>Advanced analysis and insights</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<p className="font-medium mb-2">Cost Efficiency</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{metrics?.summary.total_requests
|
||||
? `Average cost per request: ${formatCurrency(
|
||||
metrics.summary.total_cost_usd / metrics.summary.total_requests
|
||||
)}`
|
||||
: 'No request data available'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<p className="font-medium mb-2">PII Risk Assessment</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{metrics?.summary.pii_violations
|
||||
? `${metrics.summary.pii_violations} potential PII violations detected`
|
||||
: 'No PII violations detected'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user