refactor(admin-web): replace hardcoded chart colors with theme tokens
This commit is contained in:
parent
a33f3cfbd6
commit
fab88a57a4
@ -1,7 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
@ -44,12 +53,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@ -192,7 +196,9 @@ function ClusterTimelineChart({ clusters }: { clusters: TelemetryCluster[] }) {
|
||||
else if (c.severity === 'error') b.error += c.totalCount;
|
||||
else if (c.severity === 'fatal') b.fatal += c.totalCount;
|
||||
}
|
||||
return Array.from(buckets.values()).sort((a, b) => a.date.localeCompare(b.date)).slice(-14);
|
||||
return Array.from(buckets.values())
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(-14);
|
||||
}, [clusters]);
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
@ -218,9 +224,15 @@ function ClusterTimelineChart({ clusters }: { clusters: TelemetryCluster[] }) {
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="fatal" stackId="a" fill="#ef4444" name="Fatal" />
|
||||
<Bar dataKey="error" stackId="a" fill="#f97316" name="Error" />
|
||||
<Bar dataKey="warn" stackId="a" fill="#eab308" name="Warn" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="fatal" stackId="a" fill="hsl(var(--destructive))" name="Fatal" />
|
||||
<Bar dataKey="error" stackId="a" fill="hsl(var(--chart-5))" name="Error" />
|
||||
<Bar
|
||||
dataKey="warn"
|
||||
stackId="a"
|
||||
fill="hsl(var(--chart-4))"
|
||||
name="Warn"
|
||||
radius={[2, 2, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
@ -345,7 +357,7 @@ export default function ClientLogsPage() {
|
||||
// Fetch ingestion metrics
|
||||
useEffect(() => {
|
||||
fetch('/api/telemetry/metrics')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(d => d && setIngestionMetrics(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
@ -356,20 +368,26 @@ export default function ClientLogsPage() {
|
||||
setGeoLoading(true);
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 86400000).toISOString();
|
||||
fetch(`/api/telemetry/geo?from=${encodeURIComponent(sevenDaysAgo)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(d => d && setGeoData(d.distribution || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setGeoLoading(false));
|
||||
}, [tab]);
|
||||
|
||||
const handleClusterAction = async (cluster: TelemetryCluster, action: 'resolved' | 'ignored' | 'open') => {
|
||||
const handleClusterAction = async (
|
||||
cluster: TelemetryCluster,
|
||||
action: 'resolved' | 'ignored' | 'open'
|
||||
) => {
|
||||
const pk = cluster.pk || '';
|
||||
try {
|
||||
await fetch(`/api/telemetry/clusters/${encodeURIComponent(cluster.id)}?pk=${encodeURIComponent(pk)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: action }),
|
||||
});
|
||||
await fetch(
|
||||
`/api/telemetry/clusters/${encodeURIComponent(cluster.id)}?pk=${encodeURIComponent(pk)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: action }),
|
||||
}
|
||||
);
|
||||
fetchEvents();
|
||||
} catch {
|
||||
// best effort
|
||||
@ -378,7 +396,8 @@ export default function ClientLogsPage() {
|
||||
|
||||
const handleGdprErasure = async () => {
|
||||
if (!gdprUserId.trim()) return;
|
||||
if (!confirm(`Delete ALL telemetry data for user "${gdprUserId}"? This cannot be undone.`)) return;
|
||||
if (!confirm(`Delete ALL telemetry data for user "${gdprUserId}"? This cannot be undone.`))
|
||||
return;
|
||||
setGdprLoading(true);
|
||||
setGdprResult(null);
|
||||
try {
|
||||
@ -402,7 +421,7 @@ export default function ClientLogsPage() {
|
||||
};
|
||||
|
||||
// Client-side text filter
|
||||
const filteredEvents = events.filter((e) => {
|
||||
const filteredEvents = events.filter(e => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
@ -417,10 +436,10 @@ export default function ClientLogsPage() {
|
||||
});
|
||||
|
||||
// Stats
|
||||
const errorCount = events.filter((e) => e.eventType === 'error' || e.eventType === 'fatal').length;
|
||||
const warnCount = events.filter((e) => e.eventType === 'warn').length;
|
||||
const keyboardCount = events.filter((e) => e.channel === 'keyboard_extension').length;
|
||||
const uniqueSessions = new Set(events.map((e) => e.sessionId)).size;
|
||||
const errorCount = events.filter(e => e.eventType === 'error' || e.eventType === 'fatal').length;
|
||||
const warnCount = events.filter(e => e.eventType === 'warn').length;
|
||||
const keyboardCount = events.filter(e => e.channel === 'keyboard_extension').length;
|
||||
const uniqueSessions = new Set(events.map(e => e.sessionId)).size;
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
@ -539,7 +558,7 @@ export default function ClientLogsPage() {
|
||||
<Input
|
||||
placeholder="Module..."
|
||||
value={module}
|
||||
onChange={(e) => setModule(e.target.value)}
|
||||
onChange={e => setModule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
@ -549,7 +568,7 @@ export default function ClientLogsPage() {
|
||||
placeholder="Search events, errors, users..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -612,18 +631,17 @@ export default function ClientLogsPage() {
|
||||
) : filteredEvents.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
No telemetry events found. Events will appear here once clients start sending data.
|
||||
No telemetry events found. Events will appear here once clients start
|
||||
sending data.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredEvents.map((evt) => (
|
||||
filteredEvents.map(evt => (
|
||||
<>
|
||||
<TableRow
|
||||
key={evt.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() =>
|
||||
setExpandedEvent(expandedEvent === evt.id ? null : evt.id)
|
||||
}
|
||||
onClick={() => setExpandedEvent(expandedEvent === evt.id ? null : evt.id)}
|
||||
>
|
||||
<TableCell>
|
||||
{expandedEvent === evt.id ? (
|
||||
@ -690,9 +708,7 @@ export default function ClientLogsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block">User ID</span>
|
||||
<span className="font-medium font-mono">
|
||||
{evt.userId || '—'}
|
||||
</span>
|
||||
<span className="font-medium font-mono">{evt.userId || '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block">Install ID</span>
|
||||
@ -716,7 +732,9 @@ export default function ClientLogsPage() {
|
||||
</div>
|
||||
{evt.errorDomain && (
|
||||
<div>
|
||||
<span className="text-muted-foreground block">Error Domain</span>
|
||||
<span className="text-muted-foreground block">
|
||||
Error Domain
|
||||
</span>
|
||||
<span className="font-medium font-mono">{evt.errorDomain}</span>
|
||||
</div>
|
||||
)}
|
||||
@ -783,11 +801,12 @@ export default function ClientLogsPage() {
|
||||
) : clusters.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
No error clusters found. Clusters form automatically when warn/error/fatal events arrive.
|
||||
No error clusters found. Clusters form automatically when warn/error/fatal
|
||||
events arrive.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
clusters.map((c) => (
|
||||
clusters.map(c => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{eventTypeBadge(c.severity)}</TableCell>
|
||||
<TableCell>
|
||||
@ -816,15 +835,24 @@ export default function ClientLogsPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.status === 'resolved' ? (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-500/10 text-green-500 border-green-500/20"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-3 w-3" /> Resolved
|
||||
</Badge>
|
||||
) : c.status === 'ignored' ? (
|
||||
<Badge variant="outline" className="bg-gray-500/10 text-gray-400 border-gray-500/20">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-gray-500/10 text-gray-400 border-gray-500/20"
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" /> Ignored
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-orange-500/10 text-orange-500 border-orange-500/20">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-orange-500/10 text-orange-500 border-orange-500/20"
|
||||
>
|
||||
Open
|
||||
</Badge>
|
||||
)}
|
||||
@ -903,7 +931,8 @@ export default function ClientLogsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ingestionMetrics.totalPiiBlocked} PII blocked, {ingestionMetrics.totalDuplicatesDropped} duplicates
|
||||
{ingestionMetrics.totalPiiBlocked} PII blocked,{' '}
|
||||
{ingestionMetrics.totalDuplicatesDropped} duplicates
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -951,15 +980,27 @@ export default function ClientLogsPage() {
|
||||
</div>
|
||||
) : geoData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
No geo data available. Enable geo enrichment with <code>TELEMETRY_GEO_ENABLED=true</code> and configure <code>TELEMETRY_GEO_API_URL</code>.
|
||||
No geo data available. Enable geo enrichment with{' '}
|
||||
<code>TELEMETRY_GEO_ENABLED=true</code> and configure{' '}
|
||||
<code>TELEMETRY_GEO_API_URL</code>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, geoData.length * 32)}>
|
||||
<BarChart data={geoData} layout="vertical" margin={{ left: 40, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
||||
<YAxis type="category" dataKey="countryCode" tick={{ fontSize: 12 }} width={50} stroke="hsl(var(--muted-foreground))" />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="countryCode"
|
||||
tick={{ fontSize: 12 }}
|
||||
width={50}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
@ -967,7 +1008,10 @@ export default function ClientLogsPage() {
|
||||
borderRadius: '8px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number | undefined) => [(value ?? 0).toLocaleString(), 'Events']}
|
||||
formatter={(value: number | undefined) => [
|
||||
(value ?? 0).toLocaleString(),
|
||||
'Events',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
@ -1010,8 +1054,8 @@ export default function ClientLogsPage() {
|
||||
GDPR Data Erasure
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Delete all telemetry events for a specific user. This action is irreversible
|
||||
and will be logged in the audit trail.
|
||||
Delete all telemetry events for a specific user. This action is irreversible and
|
||||
will be logged in the audit trail.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@ -1019,7 +1063,7 @@ export default function ClientLogsPage() {
|
||||
<Input
|
||||
placeholder="Enter user ID..."
|
||||
value={gdprUserId}
|
||||
onChange={(e) => setGdprUserId(e.target.value)}
|
||||
onChange={e => setGdprUserId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@ -1030,9 +1074,11 @@ export default function ClientLogsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{gdprResult && (
|
||||
<p className={`text-sm ${
|
||||
gdprResult.startsWith('Error') ? 'text-red-500' : 'text-green-500'
|
||||
}`}>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
gdprResult.startsWith('Error') ? 'text-red-500' : 'text-green-500'
|
||||
}`}
|
||||
>
|
||||
{gdprResult}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -365,8 +365,8 @@ export default function DashboardPage() {
|
||||
<AreaChart data={dailyMetrics}>
|
||||
<defs>
|
||||
<linearGradient id="colorUsers" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
@ -381,7 +381,7 @@ export default function DashboardPage() {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="activeUsers"
|
||||
stroke="hsl(221, 83%, 53%)"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
fill="url(#colorUsers)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
@ -408,7 +408,7 @@ export default function DashboardPage() {
|
||||
border: '1px solid hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="revenue" fill="hsl(142, 71%, 45%)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="hsl(var(--chart-2))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
|
||||
@ -56,10 +56,10 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
const COLORS = [
|
||||
'hsl(221, 83%, 53%)',
|
||||
'hsl(142, 71%, 45%)',
|
||||
'hsl(38, 92%, 50%)',
|
||||
'hsl(280, 67%, 55%)',
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
];
|
||||
|
||||
function usageRecordsToDailyMetrics(records: ApiUsageRecord[]): DailyMetric[] {
|
||||
@ -88,8 +88,12 @@ export default function UsagePage() {
|
||||
const [timeRange, setTimeRange] = useState('30d');
|
||||
const [dailyMetrics, setDailyMetrics] = useState<DailyMetric[]>(mockDailyMetrics);
|
||||
const [modelUsage, setModelUsage] = useState<(ApiModelBreakdown & { percentage: number })[]>([]);
|
||||
const [sourceUsage, setSourceUsage] = useState<(ApiSourceBreakdown & { percentage: number })[]>([]);
|
||||
const [productUsage, setProductUsage] = useState<(ApiProductBreakdown & { percentage: number })[]>([]);
|
||||
const [sourceUsage, setSourceUsage] = useState<(ApiSourceBreakdown & { percentage: number })[]>(
|
||||
[]
|
||||
);
|
||||
const [productUsage, setProductUsage] = useState<
|
||||
(ApiProductBreakdown & { percentage: number })[]
|
||||
>([]);
|
||||
const [users, setUsers] = useState<User[]>(mockUsers);
|
||||
const [cohorts, setCohorts] = useState<RetentionCohort[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
@ -211,7 +215,8 @@ export default function UsagePage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
Viewing usage for: {users.find(u => u.id === selectedUserId)?.name || selectedUserId}
|
||||
Viewing usage for:{' '}
|
||||
{users.find(u => u.id === selectedUserId)?.name || selectedUserId}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||||
({users.find(u => u.id === selectedUserId)?.email})
|
||||
@ -310,8 +315,8 @@ export default function UsagePage() {
|
||||
<AreaChart data={dailyMetrics}>
|
||||
<defs>
|
||||
<linearGradient id="colorTokens" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
@ -327,7 +332,7 @@ export default function UsagePage() {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="totalTokens"
|
||||
stroke="hsl(221, 83%, 53%)"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
fill="url(#colorTokens)"
|
||||
strokeWidth={2}
|
||||
name="Tokens"
|
||||
@ -356,7 +361,7 @@ export default function UsagePage() {
|
||||
/>
|
||||
<Bar
|
||||
dataKey="totalRequests"
|
||||
fill="hsl(142, 71%, 45%)"
|
||||
fill="hsl(var(--chart-2))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Requests"
|
||||
/>
|
||||
|
||||
@ -276,8 +276,8 @@ export default function UserDetailPage() {
|
||||
<AreaChart data={dailyUsage}>
|
||||
<defs>
|
||||
<linearGradient id="colorDict" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
@ -292,7 +292,7 @@ export default function UserDetailPage() {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="dictations"
|
||||
stroke="hsl(221, 83%, 53%)"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
fill="url(#colorDict)"
|
||||
strokeWidth={2}
|
||||
name="Dictations"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user