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
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
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 { 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',
|
|
running: 'default',
|
|
completed: 'outline',
|
|
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 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 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">
|
|
{/* 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>
|
|
</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 text-muted-foreground">Total Requests</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(metrics?.summary.total_requests || 0)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Cost</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<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 text-muted-foreground">SQS Blocks</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<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>
|
|
);
|
|
}
|