mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
feat(ui): replace anomaly alert badge with usage insights card
This commit is contained in:
1 parent
aa1e9323a6
commit
824c3baecf
3 files changed
+179
-135
No files matched your search
@@ -1,128 +0,0 @@
|
|||||||
/**
|
|
||||||
* Anomaly Alert Badge Component
|
|
||||||
*
|
|
||||||
* Displays detected usage anomalies with visual indicators.
|
|
||||||
* Shows high input, I/O ratio, cost spikes, and cache read alerts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { AlertTriangle, ChevronDown, Zap, Gauge, DollarSign, Database } from 'lucide-react';
|
|
||||||
import type { Anomaly, AnomalySummary, AnomalyType } from '@/hooks/use-usage';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface AnomalyAlertBadgeProps {
|
|
||||||
anomalies: Anomaly[];
|
|
||||||
summary: AnomalySummary;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANOMALY_CONFIG: Record<
|
|
||||||
AnomalyType,
|
|
||||||
{ icon: React.ComponentType<{ className?: string }>; color: string; label: string }
|
|
||||||
> = {
|
|
||||||
high_input: { icon: Zap, color: 'text-yellow-600', label: 'High Input' },
|
|
||||||
high_io_ratio: { icon: Gauge, color: 'text-orange-600', label: 'High I/O Ratio' },
|
|
||||||
cost_spike: { icon: DollarSign, color: 'text-red-600', label: 'Cost Spike' },
|
|
||||||
high_cache_read: { icon: Database, color: 'text-cyan-600', label: 'Heavy Caching' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AnomalyAlertBadge({ anomalies, summary, className }: AnomalyAlertBadgeProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
if (summary.totalAnomalies === 0) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className={cn('gap-1', className)}>
|
|
||||||
<span className="text-green-600">No anomalies</span>
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get unique anomaly types for badges
|
|
||||||
const anomalyTypes = new Set(anomalies.map((a) => a.type));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className={cn('gap-2 h-8', className)}>
|
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
||||||
<span className="font-medium">{summary.totalAnomalies} anomalies</span>
|
|
||||||
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-96 p-0" align="end">
|
|
||||||
<div className="p-4 border-b">
|
|
||||||
<h4 className="font-semibold flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
||||||
Detected Anomalies
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Unusual usage patterns detected in the selected period
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary badges */}
|
|
||||||
<div className="p-3 border-b flex flex-wrap gap-2">
|
|
||||||
{Array.from(anomalyTypes).map((type) => {
|
|
||||||
const config = ANOMALY_CONFIG[type];
|
|
||||||
const Icon = config.icon;
|
|
||||||
const count = anomalies.filter((a) => a.type === type).length;
|
|
||||||
return (
|
|
||||||
<Badge key={type} variant="secondary" className="gap-1">
|
|
||||||
<Icon className={cn('h-3 w-3', config.color)} />
|
|
||||||
{count} {config.label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anomaly list */}
|
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
|
||||||
{anomalies.slice(0, 10).map((anomaly, index) => {
|
|
||||||
const config = ANOMALY_CONFIG[anomaly.type];
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="p-3 border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Icon className={cn('h-4 w-4 mt-0.5', config.color)} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">{anomaly.date}</span>
|
|
||||||
{anomaly.model && (
|
|
||||||
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
|
||||||
{truncateModel(anomaly.model)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{anomaly.message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{anomalies.length > 10 && (
|
|
||||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
|
||||||
+{anomalies.length - 10} more anomalies
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateModel(model: string): string {
|
|
||||||
if (model.length <= 20) return model;
|
|
||||||
// Try to extract the meaningful part
|
|
||||||
const parts = model.split('-');
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
return parts.slice(0, 3).join('-') + '...';
|
|
||||||
}
|
|
||||||
return model.slice(0, 17) + '...';
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react';
|
||||||
|
import type { Anomaly, AnomalySummary, AnomalyType } from '@/hooks/use-usage';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface UsageInsightsCardProps {
|
||||||
|
anomalies?: Anomaly[];
|
||||||
|
summary?: AnomalySummary;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANOMALY_CONFIG: Record<
|
||||||
|
AnomalyType,
|
||||||
|
{
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
high_input: {
|
||||||
|
icon: Zap,
|
||||||
|
color: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
label: 'High Input',
|
||||||
|
description: 'Unusually high input token usage detected.',
|
||||||
|
},
|
||||||
|
high_io_ratio: {
|
||||||
|
icon: Gauge,
|
||||||
|
color: 'text-orange-600 dark:text-orange-400',
|
||||||
|
label: 'High I/O Ratio',
|
||||||
|
description: 'Output tokens are significantly higher than input tokens.',
|
||||||
|
},
|
||||||
|
cost_spike: {
|
||||||
|
icon: DollarSign,
|
||||||
|
color: 'text-red-600 dark:text-red-400',
|
||||||
|
label: 'Cost Spike',
|
||||||
|
description: 'Daily cost is significantly higher than average.',
|
||||||
|
},
|
||||||
|
high_cache_read: {
|
||||||
|
icon: Database,
|
||||||
|
color: 'text-cyan-600 dark:text-cyan-400',
|
||||||
|
label: 'Heavy Caching',
|
||||||
|
description: 'High volume of cache read operations.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UsageInsightsCard({
|
||||||
|
anomalies = [],
|
||||||
|
summary,
|
||||||
|
isLoading,
|
||||||
|
className,
|
||||||
|
}: UsageInsightsCardProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col h-full', className)}>
|
||||||
|
<CardHeader className="px-4 py-3 border-b">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Lightbulb className="w-4 h-4 text-muted-foreground" />
|
||||||
|
Usage Insights
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 flex-1 flex items-center justify-center">
|
||||||
|
<div className="animate-pulse flex flex-col items-center gap-2 opacity-50">
|
||||||
|
<div className="h-8 w-8 bg-muted rounded-full" />
|
||||||
|
<div className="h-4 w-32 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnomalies = summary && summary.totalAnomalies > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
|
||||||
|
<CardHeader className="px-4 py-3 border-b bg-muted/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Lightbulb
|
||||||
|
className={cn('w-4 h-4', hasAnomalies ? 'text-amber-500' : 'text-green-500')}
|
||||||
|
/>
|
||||||
|
Usage Insights
|
||||||
|
</CardTitle>
|
||||||
|
{hasAnomalies ? (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider"
|
||||||
|
>
|
||||||
|
Attention Needed
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||||
|
>
|
||||||
|
Healthy
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 flex-1 min-h-0 flex flex-col">
|
||||||
|
{hasAnomalies ? (
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="divide-y">
|
||||||
|
{anomalies.map((anomaly, index) => {
|
||||||
|
const config = ANOMALY_CONFIG[anomaly.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg shrink-0 bg-muted/30',
|
||||||
|
config.color
|
||||||
|
.replace('text-', 'bg-')
|
||||||
|
.replace('600', '100')
|
||||||
|
.replace('400', '900/20')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-4 w-4', config.color)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-medium text-sm">{config.label}</p>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{anomaly.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{anomaly.message}
|
||||||
|
</p>
|
||||||
|
{anomaly.model && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] px-1 py-0 h-5 font-mono"
|
||||||
|
>
|
||||||
|
{anomaly.model}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-foreground">No anomalies detected</p>
|
||||||
|
<p className="text-xs mt-1 max-w-[200px]">
|
||||||
|
Your usage patterns look normal for the selected period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
|||||||
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||||
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||||
import { AnomalyAlertBadge } from '@/components/analytics/anomaly-alert-badge';
|
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
|
||||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useUsageSummary,
|
useUsageSummary,
|
||||||
@@ -105,10 +105,6 @@ export function AnalyticsPage() {
|
|||||||
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Anomaly Alert Badge */}
|
|
||||||
{!isInsightsLoading && insights && (
|
|
||||||
<AnomalyAlertBadge anomalies={insights.anomalies} summary={insights.summary} />
|
|
||||||
)}
|
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={setDateRange}
|
onChange={setDateRange}
|
||||||
@@ -289,11 +285,19 @@ export function AnalyticsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Session Stats - 4/10 width */}
|
{/* Session Stats - 2/10 width */}
|
||||||
<SessionStatsCard
|
<SessionStatsCard
|
||||||
data={sessions}
|
data={sessions}
|
||||||
isLoading={isSessionsLoading}
|
isLoading={isSessionsLoading}
|
||||||
className="lg:col-span-4"
|
className="lg:col-span-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Usage Insights - 2/10 width */}
|
||||||
|
<UsageInsightsCard
|
||||||
|
anomalies={insights?.anomalies}
|
||||||
|
summary={insights?.summary}
|
||||||
|
isLoading={isInsightsLoading}
|
||||||
|
className="lg:col-span-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user