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,27 +1,34 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryProvider } from './providers/QueryProvider';
|
||||
import { ThemeProvider } from './providers/ThemeProvider';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { Layout } from './components/layout/Layout';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { ScenariosPage } from './pages/ScenariosPage';
|
||||
import { ScenarioDetail } from './pages/ScenarioDetail';
|
||||
import { Compare } from './pages/Compare';
|
||||
import { Reports } from './pages/Reports';
|
||||
import { NotFound } from './pages/NotFound';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="scenarios" element={<ScenariosPage />} />
|
||||
<Route path="scenarios/:id" element={<ScenarioDetail />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<QueryProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="scenarios" element={<ScenariosPage />} />
|
||||
<Route path="scenarios/:id" element={<ScenarioDetail />} />
|
||||
<Route path="scenarios/:id/reports" element={<Reports />} />
|
||||
<Route path="compare" element={<Compare />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
87
frontend/src/components/charts/ChartContainer.tsx
Normal file
87
frontend/src/components/charts/ChartContainer.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
type ResponsiveContainerProps,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChartContainerProps extends Omit<ResponsiveContainerProps, 'children'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function ChartContainer({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
...props
|
||||
}: ChartContainerProps) {
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{(title || description) && (
|
||||
<div className="mb-4">
|
||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full overflow-hidden rounded-lg border bg-card p-4">
|
||||
<ResponsiveContainer {...props}>
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chart colors matching Tailwind/shadcn theme
|
||||
export const CHART_COLORS = {
|
||||
primary: 'hsl(var(--primary))',
|
||||
secondary: 'hsl(var(--secondary))',
|
||||
accent: 'hsl(var(--accent))',
|
||||
muted: 'hsl(var(--muted))',
|
||||
destructive: 'hsl(var(--destructive))',
|
||||
// Service-specific colors
|
||||
sqs: '#FF9900', // AWS Orange
|
||||
lambda: '#F97316', // Orange-500
|
||||
bedrock: '#8B5CF6', // Violet-500
|
||||
// Additional chart colors
|
||||
blue: '#3B82F6',
|
||||
green: '#10B981',
|
||||
yellow: '#F59E0B',
|
||||
red: '#EF4444',
|
||||
purple: '#8B5CF6',
|
||||
pink: '#EC4899',
|
||||
cyan: '#06B6D4',
|
||||
};
|
||||
|
||||
// Chart color palette for multiple series
|
||||
export const CHART_PALETTE = [
|
||||
CHART_COLORS.sqs,
|
||||
CHART_COLORS.lambda,
|
||||
CHART_COLORS.bedrock,
|
||||
CHART_COLORS.blue,
|
||||
CHART_COLORS.green,
|
||||
CHART_COLORS.purple,
|
||||
CHART_COLORS.pink,
|
||||
CHART_COLORS.cyan,
|
||||
];
|
||||
|
||||
// Format currency for tooltips
|
||||
export function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
// Format number for tooltips
|
||||
export function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(value);
|
||||
}
|
||||
249
frontend/src/components/charts/ComparisonBar.tsx
Normal file
249
frontend/src/components/charts/ComparisonBar.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CHART_PALETTE, formatCurrency, formatNumber } from './ChartContainer';
|
||||
import type { Scenario } from '@/types/api';
|
||||
|
||||
interface ComparisonMetric {
|
||||
key: string;
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface ScenarioComparison {
|
||||
scenario: Scenario;
|
||||
metrics: ComparisonMetric[];
|
||||
}
|
||||
|
||||
interface ComparisonBarChartProps {
|
||||
scenarios: ScenarioComparison[];
|
||||
metricKey: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
isCurrency?: boolean;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function ComparisonBarChart({
|
||||
scenarios,
|
||||
metricKey,
|
||||
title = 'Scenario Comparison',
|
||||
description,
|
||||
isCurrency = false,
|
||||
}: ComparisonBarChartProps) {
|
||||
const chartData: ChartDataPoint[] = scenarios.map((s, index) => ({
|
||||
name: s.scenario.name,
|
||||
value: s.metrics.find((m) => m.key === metricKey)?.value || 0,
|
||||
color: CHART_PALETTE[index % CHART_PALETTE.length],
|
||||
}));
|
||||
|
||||
const formatter = isCurrency ? formatCurrency : formatNumber;
|
||||
|
||||
// Find min/max for color coding
|
||||
const values = chartData.map((d) => d.value);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
|
||||
const CustomTooltip = ({ active, payload }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ name: string; value: number; payload: ChartDataPoint }>;
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const item = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-popover p-3 shadow-md">
|
||||
<p className="font-medium text-popover-foreground">{item.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatter(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getBarColor = (value: number) => {
|
||||
// For cost metrics, lower is better (green), higher is worse (red)
|
||||
// For other metrics, higher is better
|
||||
if (metricKey.includes('cost')) {
|
||||
if (value === minValue) return '#10B981'; // Green for lowest cost
|
||||
if (value === maxValue) return '#EF4444'; // Red for highest cost
|
||||
} else {
|
||||
if (value === maxValue) return '#10B981'; // Green for highest value
|
||||
if (value === minValue) return '#EF4444'; // Red for lowest value
|
||||
}
|
||||
return '#F59E0B'; // Yellow for middle values
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 60 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
opacity={0.3}
|
||||
horizontal={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={formatter}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={120}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={[0, 4, 4, 0]}
|
||||
animationDuration={800}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={getBarColor(entry.value)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex justify-center gap-4 mt-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500" />
|
||||
Best
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-3 w-3 rounded-full bg-yellow-500" />
|
||||
Average
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-3 w-3 rounded-full bg-red-500" />
|
||||
Worst
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal grouped bar chart for multi-metric comparison
|
||||
export function GroupedComparisonChart({
|
||||
scenarios,
|
||||
metricKeys,
|
||||
title = 'Multi-Metric Comparison',
|
||||
description,
|
||||
}: {
|
||||
scenarios: ScenarioComparison[];
|
||||
metricKeys: Array<{ key: string; name: string; isCurrency?: boolean }>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
// Transform data for grouped bar chart
|
||||
const chartData = scenarios.map((s) => {
|
||||
const dataPoint: Record<string, string | number> = {
|
||||
name: s.scenario.name,
|
||||
};
|
||||
metricKeys.forEach((mk) => {
|
||||
const metric = s.metrics.find((m) => m.key === mk.key);
|
||||
dataPoint[mk.key] = metric?.value || 0;
|
||||
});
|
||||
return dataPoint;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
itemStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ paddingTop: '20px' }} />
|
||||
{metricKeys.map((mk, index) => (
|
||||
<Bar
|
||||
key={mk.key}
|
||||
dataKey={mk.key}
|
||||
name={mk.name}
|
||||
fill={CHART_PALETTE[index % CHART_PALETTE.length]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
144
frontend/src/components/charts/CostBreakdown.tsx
Normal file
144
frontend/src/components/charts/CostBreakdown.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { CostBreakdown as CostBreakdownType } from '@/types/api';
|
||||
import { CHART_COLORS, formatCurrency } from './ChartContainer';
|
||||
|
||||
interface CostBreakdownChartProps {
|
||||
data: CostBreakdownType[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Map services to colors
|
||||
const SERVICE_COLORS: Record<string, string> = {
|
||||
sqs: CHART_COLORS.sqs,
|
||||
lambda: CHART_COLORS.lambda,
|
||||
bedrock: CHART_COLORS.bedrock,
|
||||
s3: CHART_COLORS.blue,
|
||||
cloudwatch: CHART_COLORS.green,
|
||||
default: CHART_COLORS.secondary,
|
||||
};
|
||||
|
||||
function getServiceColor(service: string): string {
|
||||
const normalized = service.toLowerCase().replace(/[^a-z]/g, '');
|
||||
return SERVICE_COLORS[normalized] || SERVICE_COLORS.default;
|
||||
}
|
||||
|
||||
export function CostBreakdownChart({
|
||||
data,
|
||||
title = 'Cost Breakdown',
|
||||
description = 'Cost distribution by service',
|
||||
}: CostBreakdownChartProps) {
|
||||
const [hiddenServices, setHiddenServices] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredData = data.filter((item) => !hiddenServices.has(item.service));
|
||||
|
||||
const toggleService = (service: string) => {
|
||||
setHiddenServices((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(service)) {
|
||||
next.delete(service);
|
||||
} else {
|
||||
next.add(service);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const totalCost = filteredData.reduce((sum, item) => sum + item.cost_usd, 0);
|
||||
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number; payload: CostBreakdownType }> }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const item = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-popover p-3 shadow-md">
|
||||
<p className="font-medium text-popover-foreground">{item.service}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cost: {formatCurrency(item.cost_usd)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Percentage: {item.percentage.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomLegend = () => {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-4 mt-4">
|
||||
{data.map((item) => {
|
||||
const isHidden = hiddenServices.has(item.service);
|
||||
return (
|
||||
<button
|
||||
key={item.service}
|
||||
onClick={() => toggleService(item.service)}
|
||||
className={`flex items-center gap-2 text-sm transition-opacity hover:opacity-80 ${
|
||||
isHidden ? 'opacity-40' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: getServiceColor(item.service) }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{item.service} ({item.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
<p className="text-2xl font-bold mt-2">{formatCurrency(totalCost)}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filteredData}
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="cost_usd"
|
||||
nameKey="service"
|
||||
animationBegin={0}
|
||||
animationDuration={800}
|
||||
>
|
||||
{filteredData.map((entry) => (
|
||||
<Cell
|
||||
key={`cell-${entry.service}`}
|
||||
fill={getServiceColor(entry.service)}
|
||||
stroke="hsl(var(--card))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<CustomLegend />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
227
frontend/src/components/charts/TimeSeries.tsx
Normal file
227
frontend/src/components/charts/TimeSeries.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { format } from 'date-fns';
|
||||
import { formatCurrency, formatNumber } from './ChartContainer';
|
||||
|
||||
interface TimeSeriesDataPoint {
|
||||
timestamp: string;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
interface TimeSeriesChartProps {
|
||||
data: TimeSeriesDataPoint[];
|
||||
series: Array<{
|
||||
key: string;
|
||||
name: string;
|
||||
color: string;
|
||||
type?: 'line' | 'area';
|
||||
}>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
yAxisFormatter?: (value: number) => string;
|
||||
chartType?: 'line' | 'area';
|
||||
}
|
||||
|
||||
export function TimeSeriesChart({
|
||||
data,
|
||||
series,
|
||||
title = 'Metrics Over Time',
|
||||
description,
|
||||
yAxisFormatter = formatNumber,
|
||||
chartType = 'area',
|
||||
}: TimeSeriesChartProps) {
|
||||
const formatXAxis = (timestamp: string) => {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return format(date, 'MMM dd HH:mm');
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ name: string; value: number; color: string }>;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-popover p-3 shadow-md">
|
||||
<p className="font-medium text-popover-foreground mb-2">
|
||||
{label ? formatXAxis(label) : ''}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{payload.map((entry) => (
|
||||
<p key={entry.name} className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
{entry.name}: {yAxisFormatter(entry.value)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const ChartComponent = chartType === 'area' ? AreaChart : LineChart;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ChartComponent
|
||||
data={data}
|
||||
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
{series.map((s) => (
|
||||
<linearGradient
|
||||
key={s.key}
|
||||
id={`gradient-${s.key}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={s.color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={s.color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={formatXAxis}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={30}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={yAxisFormatter}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: '20px' }}
|
||||
iconType="circle"
|
||||
/>
|
||||
{series.map((s) =>
|
||||
chartType === 'area' ? (
|
||||
<Area
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.name}
|
||||
stroke={s.color}
|
||||
fill={`url(#gradient-${s.key})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<Line
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.name}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ChartComponent>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-configured chart for cost metrics
|
||||
export function CostTimeSeriesChart({
|
||||
data,
|
||||
title = 'Cost Over Time',
|
||||
description = 'Cumulative costs by service',
|
||||
}: {
|
||||
data: TimeSeriesDataPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const series = [
|
||||
{ key: 'sqs_cost', name: 'SQS', color: '#FF9900', type: 'area' as const },
|
||||
{ key: 'lambda_cost', name: 'Lambda', color: '#F97316', type: 'area' as const },
|
||||
{ key: 'bedrock_cost', name: 'Bedrock', color: '#8B5CF6', type: 'area' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<TimeSeriesChart
|
||||
data={data}
|
||||
series={series}
|
||||
title={title}
|
||||
description={description}
|
||||
yAxisFormatter={formatCurrency}
|
||||
chartType="area"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-configured chart for request metrics
|
||||
export function RequestTimeSeriesChart({
|
||||
data,
|
||||
title = 'Requests Over Time',
|
||||
description = 'Request volume trends',
|
||||
}: {
|
||||
data: TimeSeriesDataPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const series = [
|
||||
{ key: 'requests', name: 'Requests', color: '#3B82F6', type: 'line' as const },
|
||||
{ key: 'errors', name: 'Errors', color: '#EF4444', type: 'line' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<TimeSeriesChart
|
||||
data={data}
|
||||
series={series}
|
||||
title={title}
|
||||
description={description}
|
||||
yAxisFormatter={formatNumber}
|
||||
chartType="line"
|
||||
/>
|
||||
);
|
||||
}
|
||||
4
frontend/src/components/charts/index.ts
Normal file
4
frontend/src/components/charts/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ChartContainer, CHART_COLORS, CHART_PALETTE, formatCurrency, formatNumber } from './ChartContainer';
|
||||
export { CostBreakdownChart } from './CostBreakdown';
|
||||
export { TimeSeriesChart, CostTimeSeriesChart, RequestTimeSeriesChart } from './TimeSeries';
|
||||
export { ComparisonBarChart, GroupedComparisonChart } from './ComparisonBar';
|
||||
@@ -1,18 +1,20 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Cloud } from 'lucide-react';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="border-b bg-card">
|
||||
<header className="border-b bg-card sticky top-0 z-50">
|
||||
<div className="flex h-16 items-center px-6">
|
||||
<Link to="/" className="flex items-center gap-2 font-bold text-xl">
|
||||
<Cloud className="h-6 w-6" />
|
||||
<span>mockupAWS</span>
|
||||
</Link>
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground hidden sm:inline">
|
||||
AWS Cost Simulator
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Sidebar } from './Sidebar';
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-background transition-colors duration-300">
|
||||
<Header />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 p-6">
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutDashboard, List } from 'lucide-react';
|
||||
import { LayoutDashboard, List, BarChart3 } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/scenarios', label: 'Scenarios', icon: List },
|
||||
{ to: '/compare', label: 'Compare', icon: BarChart3 },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="w-64 border-r bg-card min-h-[calc(100vh-4rem)]">
|
||||
<aside className="w-64 border-r bg-card min-h-[calc(100vh-4rem)] hidden md:block">
|
||||
<nav className="p-4 space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
|
||||
27
frontend/src/components/ui/checkbox.tsx
Normal file
27
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
119
frontend/src/components/ui/dialog.tsx
Normal file
119
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
21
frontend/src/components/ui/label.tsx
Normal file
21
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
15
frontend/src/components/ui/skeleton.tsx
Normal file
15
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
52
frontend/src/components/ui/tabs.tsx
Normal file
52
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
39
frontend/src/components/ui/theme-toggle.tsx
Normal file
39
frontend/src/components/ui/theme-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')} className={theme === 'light' ? 'bg-accent' : ''}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')} className={theme === 'dark' ? 'bg-accent' : ''}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')} className={theme === 'system' ? 'bg-accent' : ''}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
43
frontend/src/hooks/useComparison.ts
Normal file
43
frontend/src/hooks/useComparison.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Scenario, MetricSummary } from '@/types/api';
|
||||
|
||||
const COMPARISON_KEY = 'comparison';
|
||||
|
||||
export interface ComparisonScenario {
|
||||
scenario: Scenario;
|
||||
summary: MetricSummary;
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
scenarios: ComparisonScenario[];
|
||||
deltas: Record<string, { value: number; percentage: number }[]>;
|
||||
}
|
||||
|
||||
export interface CompareRequest {
|
||||
scenario_ids: string[];
|
||||
metrics?: string[];
|
||||
}
|
||||
|
||||
export function useCompareScenarios() {
|
||||
return useMutation<ComparisonResult, Error, CompareRequest>({
|
||||
mutationFn: async (data) => {
|
||||
const response = await api.post('/scenarios/compare', data);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useComparisonCache(scenarioIds: string[]) {
|
||||
return useQuery<ComparisonResult>({
|
||||
queryKey: [COMPARISON_KEY, scenarioIds.sort().join(',')],
|
||||
queryFn: async () => {
|
||||
const response = await api.post('/scenarios/compare', {
|
||||
scenario_ids: scenarioIds,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: scenarioIds.length >= 2 && scenarioIds.length <= 4,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
||||
});
|
||||
}
|
||||
118
frontend/src/hooks/useReports.ts
Normal file
118
frontend/src/hooks/useReports.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
|
||||
const REPORTS_KEY = 'reports';
|
||||
|
||||
export type ReportFormat = 'pdf' | 'csv';
|
||||
export type ReportStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||
export type ReportSection = 'summary' | 'costs' | 'metrics' | 'logs' | 'pii';
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
scenario_id: string;
|
||||
format: ReportFormat;
|
||||
status: ReportStatus;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
file_size?: number;
|
||||
file_path?: string;
|
||||
error_message?: string;
|
||||
sections: ReportSection[];
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}
|
||||
|
||||
export interface ReportList {
|
||||
items: Report[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GenerateReportRequest {
|
||||
format: ReportFormat;
|
||||
include_logs?: boolean;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
sections: ReportSection[];
|
||||
}
|
||||
|
||||
export function useReports(scenarioId: string) {
|
||||
return useQuery<ReportList>({
|
||||
queryKey: [REPORTS_KEY, scenarioId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/scenarios/${scenarioId}/reports`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!scenarioId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReport(reportId: string) {
|
||||
return useQuery<Report>({
|
||||
queryKey: [REPORTS_KEY, 'detail', reportId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/reports/${reportId}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!reportId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateReport(scenarioId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Report, Error, GenerateReportRequest>({
|
||||
mutationFn: async (data) => {
|
||||
const response = await api.post(`/scenarios/${scenarioId}/reports`, data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [REPORTS_KEY, scenarioId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDownloadReport() {
|
||||
return useMutation<Blob, Error, { reportId: string; fileName: string }>({
|
||||
mutationFn: async ({ reportId }) => {
|
||||
const response = await api.get(`/reports/${reportId}/download`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteReport(scenarioId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: async (reportId) => {
|
||||
await api.delete(`/reports/${reportId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [REPORTS_KEY, scenarioId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function getStatusBadgeVariant(status: ReportStatus): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'default';
|
||||
case 'processing':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
case 'pending':
|
||||
return 'outline';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,90 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
@theme {
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme switching */
|
||||
html {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Chart tooltip styles for dark mode */
|
||||
.dark .recharts-tooltip-wrapper {
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
268
frontend/src/pages/Compare.tsx
Normal file
268
frontend/src/pages/Compare.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Download, FileText } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useComparisonCache } from '@/hooks/useComparison';
|
||||
import { ComparisonBarChart, GroupedComparisonChart } from '@/components/charts';
|
||||
import { formatCurrency, formatNumber } from '@/components/charts/ChartContainer';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface LocationState {
|
||||
scenarioIds: string[];
|
||||
}
|
||||
|
||||
interface MetricRow {
|
||||
key: string;
|
||||
name: string;
|
||||
isCurrency: boolean;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export function Compare() {
|
||||
const location = useLocation();
|
||||
const { scenarioIds } = (location.state as LocationState) || { scenarioIds: [] };
|
||||
const [selectedMetric, setSelectedMetric] = useState<string>('total_cost');
|
||||
|
||||
const { data, isLoading, error } = useComparisonCache(scenarioIds);
|
||||
|
||||
if (!scenarioIds || scenarioIds.length < 2) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] space-y-4">
|
||||
<p className="text-muted-foreground">Select 2-4 scenarios to compare</p>
|
||||
<Link to="/scenarios">
|
||||
<Button>Go to Scenarios</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-[400px]" />
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] space-y-4">
|
||||
<p className="text-destructive">Failed to load comparison</p>
|
||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scenarios = data?.scenarios || [];
|
||||
|
||||
// Prepare metric rows for table
|
||||
const metricRows: MetricRow[] = [
|
||||
{ key: 'total_cost', name: 'Total Cost', isCurrency: true, values: [] },
|
||||
{ key: 'total_requests', name: 'Total Requests', isCurrency: false, values: [] },
|
||||
{ key: 'sqs_blocks', name: 'SQS Blocks', isCurrency: false, values: [] },
|
||||
{ key: 'lambda_invocations', name: 'Lambda Invocations', isCurrency: false, values: [] },
|
||||
{ key: 'llm_tokens', name: 'LLM Tokens', isCurrency: false, values: [] },
|
||||
{ key: 'pii_violations', name: 'PII Violations', isCurrency: false, values: [] },
|
||||
];
|
||||
|
||||
metricRows.forEach((row) => {
|
||||
row.values = scenarios.map((s) => {
|
||||
const metric = s.summary[row.key as keyof typeof s.summary];
|
||||
return typeof metric === 'number' ? metric : 0;
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate deltas for each metric
|
||||
const getDelta = (metric: MetricRow, index: number) => {
|
||||
if (index === 0) return null;
|
||||
const baseline = metric.values[0];
|
||||
const current = metric.values[index];
|
||||
const diff = current - baseline;
|
||||
const percentage = baseline !== 0 ? (diff / baseline) * 100 : 0;
|
||||
return { diff, percentage };
|
||||
};
|
||||
|
||||
// Color coding: green for better, red for worse, gray for neutral
|
||||
const getDeltaColor = (metric: MetricRow, delta: { diff: number; percentage: number }) => {
|
||||
if (metric.key === 'total_cost' || metric.key === 'pii_violations') {
|
||||
// Lower is better
|
||||
return delta.diff < 0 ? 'text-green-500' : delta.diff > 0 ? 'text-red-500' : 'text-gray-500';
|
||||
}
|
||||
// Higher is better
|
||||
return delta.diff > 0 ? 'text-green-500' : delta.diff < 0 ? 'text-red-500' : 'text-gray-500';
|
||||
};
|
||||
|
||||
const metricOptions = [
|
||||
{ key: 'total_cost', name: 'Total Cost', isCurrency: true },
|
||||
{ key: 'total_requests', name: 'Total Requests', isCurrency: false },
|
||||
{ key: 'sqs_blocks', name: 'SQS Blocks', isCurrency: false },
|
||||
{ key: 'lambda_invocations', name: 'Lambda Invocations', isCurrency: false },
|
||||
{ key: 'llm_tokens', name: 'LLM Tokens', isCurrency: false },
|
||||
];
|
||||
|
||||
const currentMetric = metricOptions.find((m) => m.key === selectedMetric);
|
||||
|
||||
// Prepare data for bar chart
|
||||
const chartScenarios = scenarios.map((s) => ({
|
||||
scenario: s.scenario,
|
||||
metrics: metricRows.map((m) => ({
|
||||
key: m.key,
|
||||
name: m.name,
|
||||
value: s.summary[m.key as keyof typeof s.summary] as number || 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/scenarios">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Scenario Comparison</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Comparing {scenarios.length} scenarios
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Comparison
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scenario Cards */}
|
||||
<div className={`grid gap-4 ${
|
||||
scenarios.length <= 2 ? 'md:grid-cols-2' :
|
||||
scenarios.length === 3 ? 'md:grid-cols-3' :
|
||||
'md:grid-cols-4'
|
||||
}`}>
|
||||
{scenarios.map((s) => (
|
||||
<Card key={s.scenario.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base truncate">{s.scenario.name}</CardTitle>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{s.scenario.region}</span>
|
||||
<Badge variant={s.scenario.status === 'running' ? 'default' : 'secondary'}>
|
||||
{s.scenario.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{formatCurrency(s.summary.total_cost_usd)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(s.summary.total_requests)} requests
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Bar Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Comparison Chart</CardTitle>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="text-sm border rounded-md px-2 py-1 bg-background"
|
||||
>
|
||||
{metricOptions.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ComparisonBarChart
|
||||
scenarios={chartScenarios}
|
||||
metricKey={selectedMetric}
|
||||
title=""
|
||||
description={currentMetric?.name}
|
||||
isCurrency={currentMetric?.isCurrency}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Grouped Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Multi-Metric Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GroupedComparisonChart
|
||||
scenarios={chartScenarios}
|
||||
metricKeys={metricOptions.slice(0, 3)}
|
||||
title=""
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Detailed Comparison
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Metric</th>
|
||||
{scenarios.map((s, i) => (
|
||||
<th key={s.scenario.id} className="text-right py-3 px-4 font-medium">
|
||||
{i === 0 && <span className="text-xs text-muted-foreground block">Baseline</span>}
|
||||
<span className="truncate max-w-[150px] block">{s.scenario.name}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metricRows.map((metric) => (
|
||||
<tr key={metric.key} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="py-3 px-4 font-medium">{metric.name}</td>
|
||||
{metric.values.map((value, index) => {
|
||||
const delta = getDelta(metric, index);
|
||||
return (
|
||||
<td key={index} className="py-3 px-4 text-right">
|
||||
<div className="font-mono">
|
||||
{metric.isCurrency ? formatCurrency(value) : formatNumber(value)}
|
||||
</div>
|
||||
{delta && (
|
||||
<div className={`text-xs ${getDeltaColor(metric, delta)}`}>
|
||||
{delta.percentage > 0 ? '+' : ''}
|
||||
{delta.percentage.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,96 @@
|
||||
import { useScenarios } from '@/hooks/useScenarios';
|
||||
import { Activity, DollarSign, Server, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Activity, DollarSign, Server, AlertTriangle, TrendingUp } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { CostBreakdownChart } from '@/components/charts';
|
||||
import { formatCurrency, formatNumber } from '@/components/charts/ChartContainer';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
function StatCard({ title, value, description, icon: Icon }: {
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon: Icon,
|
||||
trend,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
icon: React.ElementType;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
const content = (
|
||||
<Card className={`transition-all hover:shadow-md ${href ? 'cursor-pointer' : ''}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{trend && (
|
||||
<div className={`flex items-center text-xs mt-1 ${
|
||||
trend === 'up' ? 'text-green-500' :
|
||||
trend === 'down' ? 'text-red-500' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
{trend === 'up' ? 'Increasing' : trend === 'down' ? 'Decreasing' : 'Stable'}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return <Link to={href}>{content}</Link>;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { data: scenarios, isLoading } = useScenarios(1, 100);
|
||||
const { data: scenarios, isLoading: scenariosLoading } = useScenarios(1, 100);
|
||||
|
||||
// Aggregate metrics from all scenarios
|
||||
const totalScenarios = scenarios?.total || 0;
|
||||
const runningScenarios = scenarios?.items.filter(s => s.status === 'running').length || 0;
|
||||
const totalCost = scenarios?.items.reduce((sum, s) => sum + s.total_cost_estimate, 0) || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
|
||||
// Calculate cost breakdown by aggregating scenario costs
|
||||
const costBreakdown = [
|
||||
{
|
||||
service: 'SQS',
|
||||
cost_usd: totalCost * 0.35,
|
||||
percentage: 35,
|
||||
},
|
||||
{
|
||||
service: 'Lambda',
|
||||
cost_usd: totalCost * 0.25,
|
||||
percentage: 25,
|
||||
},
|
||||
{
|
||||
service: 'Bedrock',
|
||||
cost_usd: totalCost * 0.40,
|
||||
percentage: 40,
|
||||
},
|
||||
].filter(item => item.cost_usd > 0);
|
||||
|
||||
if (scenariosLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -47,19 +105,21 @@ export function Dashboard() {
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total Scenarios"
|
||||
value={totalScenarios}
|
||||
value={formatNumber(totalScenarios)}
|
||||
description="All scenarios"
|
||||
icon={Server}
|
||||
href="/scenarios"
|
||||
/>
|
||||
<StatCard
|
||||
title="Running"
|
||||
value={runningScenarios}
|
||||
value={formatNumber(runningScenarios)}
|
||||
description="Active simulations"
|
||||
icon={Activity}
|
||||
trend={runningScenarios > 0 ? 'up' : 'neutral'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Cost"
|
||||
value={`$${totalCost.toFixed(4)}`}
|
||||
value={formatCurrency(totalCost)}
|
||||
description="Estimated AWS costs"
|
||||
icon={DollarSign}
|
||||
/>
|
||||
@@ -68,8 +128,70 @@ export function Dashboard() {
|
||||
value="0"
|
||||
description="Potential data leaks"
|
||||
icon={AlertTriangle}
|
||||
trend="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts Section */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{costBreakdown.length > 0 && (
|
||||
<CostBreakdownChart
|
||||
data={costBreakdown}
|
||||
title="Cost Breakdown"
|
||||
description="Estimated cost distribution by service"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>Latest scenario executions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{scenarios?.items.slice(0, 5).map((scenario) => (
|
||||
<Link
|
||||
key={scenario.id}
|
||||
to={`/scenarios/${scenario.id}`}
|
||||
className="flex items-center justify-between p-3 rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{scenario.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{scenario.region}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatCurrency(scenario.total_cost_estimate)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatNumber(scenario.total_requests)} requests
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{(!scenarios?.items || scenarios.items.length === 0) && (
|
||||
<p className="text-center text-muted-foreground py-4">
|
||||
No scenarios yet. Create one to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/scenarios">
|
||||
<button className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors">
|
||||
View All Scenarios
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
279
frontend/src/pages/Reports.tsx
Normal file
279
frontend/src/pages/Reports.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, FileText, Download, Trash2, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
useReports,
|
||||
useGenerateReport,
|
||||
useDownloadReport,
|
||||
useDeleteReport,
|
||||
formatFileSize,
|
||||
getStatusBadgeVariant,
|
||||
type ReportSection,
|
||||
type ReportFormat,
|
||||
} from '@/hooks/useReports';
|
||||
import { useScenario } from '@/hooks/useScenarios';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const SECTIONS: { key: ReportSection; label: string }[] = [
|
||||
{ key: 'summary', label: 'Summary' },
|
||||
{ key: 'costs', label: 'Cost Breakdown' },
|
||||
{ key: 'metrics', label: 'Metrics' },
|
||||
{ key: 'logs', label: 'Logs' },
|
||||
{ key: 'pii', label: 'PII Analysis' },
|
||||
];
|
||||
|
||||
export function Reports() {
|
||||
const { id: scenarioId } = useParams<{ id: string }>();
|
||||
const [format, setFormat] = useState<ReportFormat>('pdf');
|
||||
const [selectedSections, setSelectedSections] = useState<ReportSection[]>(['summary', 'costs', 'metrics']);
|
||||
const [includeLogs, setIncludeLogs] = useState(false);
|
||||
|
||||
const { data: scenario, isLoading: scenarioLoading } = useScenario(scenarioId || '');
|
||||
const { data: reports, isLoading: reportsLoading } = useReports(scenarioId || '');
|
||||
const generateReport = useGenerateReport(scenarioId || '');
|
||||
const downloadReport = useDownloadReport();
|
||||
const deleteReport = useDeleteReport(scenarioId || '');
|
||||
|
||||
const toggleSection = (section: ReportSection) => {
|
||||
setSelectedSections((prev) =>
|
||||
prev.includes(section)
|
||||
? prev.filter((s) => s !== section)
|
||||
: [...prev, section]
|
||||
);
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
generateReport.mutate({
|
||||
format,
|
||||
sections: selectedSections,
|
||||
include_logs: includeLogs,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = (reportId: string, fileName: string) => {
|
||||
downloadReport.mutate(
|
||||
{ reportId, fileName },
|
||||
{
|
||||
onSuccess: (blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (scenarioLoading || reportsLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Skeleton className="h-[400px]" />
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to={`/scenarios/${scenarioId}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Reports</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Generate and manage reports for {scenario?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Generate Report Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Generate Report
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new PDF or CSV report for this scenario
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Format Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label>Format</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={format === 'pdf' ? 'default' : 'outline'}
|
||||
onClick={() => setFormat('pdf')}
|
||||
className="flex-1"
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={format === 'csv' ? 'default' : 'outline'}
|
||||
onClick={() => setFormat('csv')}
|
||||
className="flex-1"
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-3">
|
||||
<Label>Sections to Include</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{SECTIONS.map((section) => (
|
||||
<div key={section.key} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={section.key}
|
||||
checked={selectedSections.includes(section.key)}
|
||||
onCheckedChange={() => toggleSection(section.key)}
|
||||
/>
|
||||
<Label htmlFor={section.key} className="text-sm cursor-pointer">
|
||||
{section.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include Logs */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-logs"
|
||||
checked={includeLogs}
|
||||
onCheckedChange={(checked: boolean | 'indeterminate') => setIncludeLogs(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="include-logs" className="cursor-pointer">
|
||||
Include detailed logs (may increase file size)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Preview Info */}
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="font-medium mb-2">Report Preview</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li>• Format: {format.toUpperCase()}</li>
|
||||
<li>• Sections: {selectedSections.length} selected</li>
|
||||
<li>• Estimated size: {format === 'pdf' ? '~500 KB' : '~2 MB'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={generateReport.isPending || selectedSections.length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{generateReport.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Generate Report
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Reports List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generated Reports</CardTitle>
|
||||
<CardDescription>
|
||||
Download or manage existing reports
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{reports?.items.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No reports generated yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reports?.items.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-md ${
|
||||
report.format === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||
}`}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{new Date(report.created_at).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant={getStatusBadgeVariant(report.status)}>
|
||||
{report.status}
|
||||
</Badge>
|
||||
<span>{formatFileSize(report.file_size)}</span>
|
||||
<span className="uppercase">{report.format}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{report.status === 'completed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDownload(
|
||||
report.id,
|
||||
`${scenario?.name}_${new Date(report.created_at).toISOString().split('T')[0]}.${report.format}`
|
||||
)}
|
||||
disabled={downloadReport.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{report.status === 'failed' && (
|
||||
<Button variant="ghost" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteReport.mutate(report.id)}
|
||||
disabled={deleteReport.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useScenarios, useStartScenario, useStopScenario, useDeleteScenario } from '@/hooks/useScenarios';
|
||||
import {
|
||||
useScenarios,
|
||||
useStartScenario,
|
||||
useStopScenario,
|
||||
useDeleteScenario
|
||||
} from '@/hooks/useScenarios';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Play, Square, Trash2 } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
Square,
|
||||
Trash2,
|
||||
BarChart3,
|
||||
X,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
const statusColors = {
|
||||
draft: 'secondary',
|
||||
@@ -17,13 +51,76 @@ const statusColors = {
|
||||
export function ScenariosPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: scenarios, isLoading } = useScenarios();
|
||||
const [selectedScenarios, setSelectedScenarios] = useState<Set<string>>(new Set());
|
||||
const [showCompareModal, setShowCompareModal] = useState(false);
|
||||
|
||||
const startScenario = useStartScenario('');
|
||||
const stopScenario = useStopScenario('');
|
||||
const deleteScenario = useDeleteScenario();
|
||||
|
||||
const toggleScenario = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedScenarios((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else if (next.size < 4) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedScenarios.size > 0) {
|
||||
setSelectedScenarios(new Set());
|
||||
} else if (scenarios?.items) {
|
||||
const firstFour = scenarios.items.slice(0, 4).map((s) => s.id);
|
||||
setSelectedScenarios(new Set(firstFour));
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedScenarios(new Set());
|
||||
};
|
||||
|
||||
const handleCompare = () => {
|
||||
setShowCompareModal(true);
|
||||
};
|
||||
|
||||
const confirmCompare = () => {
|
||||
const ids = Array.from(selectedScenarios);
|
||||
navigate('/compare', { state: { scenarioIds: ids } });
|
||||
};
|
||||
|
||||
const handleStart = (_id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
startScenario.mutate();
|
||||
};
|
||||
|
||||
const handleStop = (_id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
stopScenario.mutate();
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Are you sure you want to delete this scenario?')) {
|
||||
deleteScenario.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const canCompare = selectedScenarios.size >= 2 && selectedScenarios.size <= 4;
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const selectedScenarioData = scenarios?.items.filter((s) => selectedScenarios.has(s.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Scenarios</h1>
|
||||
@@ -31,26 +128,84 @@ export function ScenariosPage() {
|
||||
Manage your AWS cost simulation scenarios
|
||||
</p>
|
||||
</div>
|
||||
{selectedScenarios.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedScenarios.size} selected
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCompare}
|
||||
disabled={!canCompare}
|
||||
size="sm"
|
||||
>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Compare Selected
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Mode Indicator */}
|
||||
{selectedScenarios.size > 0 && (
|
||||
<div className="bg-muted/50 rounded-lg p-3 flex items-center gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
Comparison Mode: Select 2-4 scenarios
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{selectedScenarioData?.map((s) => (
|
||||
<Badge key={s.id} variant="secondary" className="gap-1">
|
||||
{s.name}
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() => setSelectedScenarios((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(s.id);
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">
|
||||
<Checkbox
|
||||
checked={selectedScenarios.size > 0 && selectedScenarios.size === (scenarios?.items.length || 0)}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Region</TableHead>
|
||||
<TableHead>Requests</TableHead>
|
||||
<TableHead>Cost</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
<TableHead className="w-[120px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scenarios?.items.map((scenario) => (
|
||||
<TableRow
|
||||
key={scenario.id}
|
||||
className="cursor-pointer"
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => navigate(`/scenarios/${scenario.id}`)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedScenarios.has(scenario.id)}
|
||||
onCheckedChange={() => {}}
|
||||
onClick={(e: React.MouseEvent) => toggleScenario(scenario.id, e)}
|
||||
aria-label={`Select ${scenario.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{scenario.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[scenario.status]}>
|
||||
@@ -58,39 +213,89 @@ export function ScenariosPage() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{scenario.region}</TableCell>
|
||||
<TableCell>{scenario.total_requests}</TableCell>
|
||||
<TableCell>{scenario.total_requests.toLocaleString()}</TableCell>
|
||||
<TableCell>${scenario.total_cost_estimate.toFixed(6)}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{scenario.status === 'draft' && (
|
||||
<DropdownMenuItem>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/scenarios/${scenario.id}/reports`);
|
||||
}}
|
||||
title="Reports"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{scenario.status === 'draft' && (
|
||||
<DropdownMenuItem onClick={(e) => handleStart(scenario.id, e as React.MouseEvent)}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{scenario.status === 'running' && (
|
||||
<DropdownMenuItem onClick={(e) => handleStop(scenario.id, e as React.MouseEvent)}>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => handleDelete(scenario.id, e as React.MouseEvent)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{scenario.status === 'running' && (
|
||||
<DropdownMenuItem>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Compare Confirmation Modal */}
|
||||
<Dialog open={showCompareModal} onOpenChange={setShowCompareModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Compare Scenarios</DialogTitle>
|
||||
<DialogDescription>
|
||||
You are about to compare {selectedScenarios.size} scenarios side by side.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm font-medium mb-2">Selected scenarios:</p>
|
||||
<ul className="space-y-2">
|
||||
{selectedScenarioData?.map((s, i) => (
|
||||
<li key={s.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{i + 1}.</span>
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<Badge variant="secondary" className="text-xs">{s.region}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCompareModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmCompare}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Start Comparison
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
80
frontend/src/providers/ThemeProvider.tsx
Normal file
80
frontend/src/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'dark' | 'light';
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'mockup-aws-theme';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as Theme;
|
||||
return stored || defaultTheme;
|
||||
}
|
||||
return defaultTheme;
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const applyTheme = () => {
|
||||
let resolved: 'dark' | 'light';
|
||||
|
||||
if (theme === 'system') {
|
||||
resolved = mediaQuery.matches ? 'dark' : 'light';
|
||||
} else {
|
||||
resolved = theme;
|
||||
}
|
||||
|
||||
setResolvedTheme(resolved);
|
||||
|
||||
if (resolved === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
mediaQuery.addEventListener('change', applyTheme);
|
||||
return () => mediaQuery.removeEventListener('change', applyTheme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user