- Replace {data} destructuring with typed direct returns across all
apiClient.get/post calls (assistant, chat, admin tabs)
- Remove unused accessToken variable in chat page
- Wrap CREATE TYPE in migration 002 with exception handler to ignore
duplicate_object error on re-deploy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
213 lines
8.4 KiB
TypeScript
213 lines
8.4 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { Card } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import apiClient from '@/lib/api-client';
|
|
import {
|
|
Loader2, MessageSquare, MessagesSquare, Users, Zap, BookOpenCheck, Sparkles,
|
|
} from 'lucide-react';
|
|
import type { AnalyticsResponse } from '@/types';
|
|
|
|
export function AnalyticsTab() {
|
|
const [analytics, setAnalytics] = useState<AnalyticsResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [days, setDays] = useState(30);
|
|
|
|
const fetchAnalytics = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await apiClient.get<AnalyticsResponse>(`/admin/analytics?days=${days}`);
|
|
setAnalytics(data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch analytics:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [days]);
|
|
|
|
useEffect(() => { fetchAnalytics(); }, [fetchAnalytics]);
|
|
|
|
const formatNumber = (n: number) => {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
return String(n);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex justify-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!analytics) {
|
|
return (
|
|
<Card className="p-6">
|
|
<p className="text-center text-muted-foreground">Failed to load analytics</p>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const { overview, token_usage_by_mode, top_users, conversations_per_day } = analytics;
|
|
const maxDaily = Math.max(...conversations_per_day.map((d) => d.count), 1);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Period Selector */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">Period:</span>
|
|
{[7, 30, 90].map((d) => (
|
|
<button
|
|
key={d}
|
|
onClick={() => setDays(d)}
|
|
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
|
|
days === d
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
|
}`}
|
|
>
|
|
{d}d
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Overview Cards */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
|
<MessageSquare className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Conversations</p>
|
|
<p className="text-2xl font-bold text-foreground">{formatNumber(overview.total_conversations)}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/30">
|
|
<MessagesSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Messages</p>
|
|
<p className="text-2xl font-bold text-foreground">{formatNumber(overview.total_messages)}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
|
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Active Users (7d)</p>
|
|
<p className="text-2xl font-bold text-foreground">{overview.active_users_7d}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
|
<Zap className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Total Tokens</p>
|
|
<p className="text-2xl font-bold text-foreground">
|
|
{formatNumber(overview.total_input_tokens + overview.total_output_tokens)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Token Usage by Mode */}
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{token_usage_by_mode.map((mode) => (
|
|
<Card key={mode.mode} className="p-4">
|
|
<div className="mb-3 flex items-center gap-2">
|
|
{mode.mode === 'rag' ? (
|
|
<BookOpenCheck className="h-5 w-5 text-primary" />
|
|
) : (
|
|
<Sparkles className="h-5 w-5 text-accent" />
|
|
)}
|
|
<h4 className="font-semibold text-foreground capitalize">{mode.mode}</h4>
|
|
<Badge variant="outline" className="ml-auto text-xs">
|
|
{mode.conversation_count} conversations
|
|
</Badge>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Input Tokens</p>
|
|
<p className="text-lg font-semibold text-foreground">{formatNumber(mode.total_input_tokens)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Output Tokens</p>
|
|
<p className="text-lg font-semibold text-foreground">{formatNumber(mode.total_output_tokens)}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
{token_usage_by_mode.length === 0 && (
|
|
<Card className="col-span-2 p-6 text-center text-muted-foreground">No usage data yet</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top Users + Conversations per Day */}
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
{/* Top Users */}
|
|
<Card className="p-4">
|
|
<h4 className="mb-3 font-semibold text-foreground">Top Users</h4>
|
|
{top_users.length === 0 ? (
|
|
<p className="py-4 text-center text-muted-foreground">No data yet</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{top_users.map((u, i) => (
|
|
<div key={u.user_id} className="flex items-center gap-3">
|
|
<span className="w-6 text-right text-sm font-medium text-muted-foreground">{i + 1}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="truncate text-sm font-medium text-foreground">{u.display_name}</p>
|
|
<p className="truncate text-xs text-muted-foreground">{u.email}</p>
|
|
</div>
|
|
<Badge variant="outline">{u.message_count} msgs</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Conversations per Day */}
|
|
<Card className="p-4">
|
|
<h4 className="mb-3 font-semibold text-foreground">Conversations per Day</h4>
|
|
{conversations_per_day.length === 0 ? (
|
|
<p className="py-4 text-center text-muted-foreground">No data yet</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{conversations_per_day.slice(-14).map((day) => (
|
|
<div key={day.date} className="flex items-center gap-2">
|
|
<span className="w-20 shrink-0 text-xs text-muted-foreground">
|
|
{new Date(day.date).toLocaleDateString('en-GB', { month: 'short', day: 'numeric' })}
|
|
</span>
|
|
<div className="flex-1">
|
|
<div
|
|
className="h-5 rounded bg-primary/20"
|
|
style={{ width: `${Math.max((day.count / maxDaily) * 100, 2)}%` }}
|
|
>
|
|
<div
|
|
className="h-full rounded bg-primary transition-all"
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="w-8 text-right text-xs font-medium text-foreground">{day.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|