- Change generic 'frontend' title to 'mockupAWS - AWS Cost Simulator' - Resolves frontend branding issue identified in testing
269 lines
9.6 KiB
TypeScript
269 lines
9.6 KiB
TypeScript
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/chart-utils';
|
|
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>
|
|
);
|
|
}
|