chore: import upstream snapshot with attribution
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:58 +08:00
commit bb5c75ce05
8824 changed files with 1946442 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
output: 'static',
outDir: '../src/analytics-web',
build: {
assets: '_astro',
format: 'file',
inlineStylesheets: 'auto',
},
integrations: [react()],
vite: {
plugins: [tailwindcss()],
},
});
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "analytics-ui",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev --port 4321",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"echarts": "^5.6.0",
"geist": "^1.4.2"
},
"devDependencies": {
"@astrojs/react": "^4.4.2",
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"astro": "^5.17.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.1.18",
"typescript": "^5.8.3"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
import React, { useEffect, useRef } from 'react';
interface ActivityHeatmapProps {
data: Array<{ date: string; value: number }>;
loading?: boolean;
year?: number;
}
export function ActivityHeatmap({ data, loading, year }: ActivityHeatmapProps) {
const ref = useRef<HTMLDivElement>(null);
const chartRef = useRef<any>(null);
const targetYear = year ?? new Date().getFullYear();
useEffect(() => {
if (loading || !ref.current) return;
let mounted = true;
import('../lib/charts').then(({ createChart, activityHeatmapOption, getThemeColors }) => {
if (!mounted || !ref.current) return;
chartRef.current?.dispose();
const yearData = (data || []).filter(d => d.date.startsWith(String(targetYear)));
const colors = getThemeColors();
chartRef.current = createChart(ref.current, activityHeatmapOption(yearData, colors, targetYear));
});
return () => {
mounted = false;
chartRef.current?.dispose();
chartRef.current = null;
};
}, [data, loading, targetYear]);
if (loading) {
return <div className="skeleton" style={{ height: 160, borderRadius: 'var(--radius-sm)' }} />;
}
return (
<div>
<div ref={ref} style={{ height: 160, width: '100%' }} />
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 6, gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 10, color: 'var(--ink-subtle)' }}>Less</span>
{[0.1, 0.3, 0.5, 0.75, 1].map((op, i) => (
<div
key={i}
style={{
width: 10, height: 10, borderRadius: 2,
background: `rgba(249, 115, 22, ${op})`,
}}
/>
))}
<span style={{ fontSize: 10, color: 'var(--ink-subtle)' }}>More</span>
</div>
</div>
);
}
@@ -0,0 +1,121 @@
import React from 'react';
export interface BarListItem {
name: string;
value: number;
label?: string; // pre-formatted string (e.g. "1.2M")
color?: string; // CSS color; defaults to --accent
}
interface BarListProps {
items: BarListItem[];
maxItems?: number;
valueLabel?: string;
loading?: boolean;
}
export function BarList({ items, maxItems = 8, valueLabel = 'Count', loading }: BarListProps) {
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{[80, 65, 55, 40, 30].map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="skeleton" style={{ height: 11, width: `${w * 0.4}%` }} />
<div className="skeleton" style={{ height: 6, flex: 1 }} />
<div className="skeleton" style={{ height: 11, width: 36 }} />
</div>
))}
</div>
);
}
if (!items?.length) {
return (
<div style={{ color: 'var(--ink-subtle)', fontSize: 13, padding: '12px 0' }}>
No data available
</div>
);
}
const sorted = [...items].sort((a, b) => b.value - a.value);
const shown = sorted.slice(0, maxItems);
const rest = sorted.slice(maxItems);
if (rest.length > 0) {
shown.push({
name: `Other (${rest.length})`,
value: rest.reduce((s, r) => s + r.value, 0),
color: 'var(--ink-subtle)',
});
}
const max = Math.max(...shown.map(i => i.value), 1);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Column headers */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
<span className="text-label">Name</span>
<span className="text-label">{valueLabel}</span>
</div>
{shown.map((item, idx) => (
<div
key={idx}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '5px 0',
borderBottom: idx < shown.length - 1 ? '1px solid var(--border-subtle)' : 'none',
}}
>
{/* Name */}
<span
style={{
fontSize: 12,
color: 'var(--ink)',
minWidth: 90,
maxWidth: 140,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={item.name}
>
{item.name}
</span>
{/* Bar track */}
<div
style={{
flex: 1,
background: 'var(--bg-secondary)',
borderRadius: 2,
height: 5,
overflow: 'hidden',
}}
>
<div
style={{
width: `${(item.value / max) * 100}%`,
height: '100%',
background: item.color || 'var(--accent)',
borderRadius: 2,
transition: 'width 0.4s ease',
}}
/>
</div>
{/* Value */}
<span
className="font-mono"
style={{ fontSize: 11, color: 'var(--ink-muted)', minWidth: 44, textAlign: 'right' }}
>
{item.label ?? item.value.toLocaleString()}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,88 @@
import React, { useEffect, useRef, useCallback } from 'react';
interface ChartCardProps {
title: string;
subtitle?: string;
height?: number | string;
loading?: boolean;
children?: React.ReactNode; // for non-ECharts content (BarList etc.)
getOption?: () => any; // ECharts option factory (sync or returns Promise)
className?: string;
style?: React.CSSProperties;
}
export function ChartCard({
title,
subtitle,
height = 220,
loading,
children,
getOption,
className = '',
style,
}: ChartCardProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<any>(null);
const initChart = useCallback(async () => {
if (!canvasRef.current || !getOption) return;
const { createChart } = await import('../lib/charts');
const optionOrPromise = getOption();
const option = optionOrPromise && typeof optionOrPromise.then === 'function'
? await optionOrPromise
: optionOrPromise;
if (!option || Object.keys(option).length === 0) return;
if (chartRef.current) {
chartRef.current.setOption(option, { notMerge: true });
} else if (canvasRef.current) {
chartRef.current = createChart(canvasRef.current, option);
}
}, [getOption]);
useEffect(() => {
if (!getOption || !canvasRef.current) return;
// Lazy-init via IntersectionObserver
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
initChart();
},
{ threshold: 0.1 },
);
observer.observe(canvasRef.current);
return () => {
observer.disconnect();
chartRef.current?.dispose();
chartRef.current = null;
};
}, []); // only on mount/unmount
// Re-draw when getOption changes (data refresh)
useEffect(() => {
if (chartRef.current && getOption) {
initChart();
}
}, [initChart]);
return (
<div className={`card ${className}`} style={{ display: 'flex', flexDirection: 'column', gap: 14, ...style }}>
{/* Header */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--ink)' }}>{title}</div>
{subtitle && <div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 2 }}>{subtitle}</div>}
</div>
{/* Content */}
{loading ? (
<div className="skeleton" style={{ height: height === 'auto' ? 160 : height, borderRadius: 'var(--radius-sm)' }} />
) : getOption ? (
<div ref={canvasRef} style={{ height }} />
) : (
children
)}
</div>
);
}
@@ -0,0 +1,75 @@
import React, { useEffect, useRef } from 'react';
interface KPICardProps {
label: string;
value: string | number;
sub?: string;
delta?: number; // % change vs prev period (positive = up, negative = down)
icon?: string; // emoji prefix
sparklineData?: number[];
loading?: boolean;
}
export function KPICard({ label, value, sub, delta, icon, sparklineData, loading }: KPICardProps) {
const sparkRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!sparklineData?.length || !sparkRef.current) return;
let chart: any;
import('../lib/charts').then(({ createChart, sparklineOption, getThemeColors }) => {
if (!sparkRef.current) return;
chart = createChart(sparkRef.current, sparklineOption(sparklineData!, getThemeColors()));
});
return () => chart?.dispose();
}, [sparklineData]);
if (loading) {
return (
<div className="card" style={{ minHeight: 110 }}>
<div className="skeleton" style={{ height: 11, width: '55%', marginBottom: 14 }} />
<div className="skeleton" style={{ height: 44, width: '70%', marginBottom: 10 }} />
<div className="skeleton" style={{ height: 11, width: '40%' }} />
</div>
);
}
const deltaColor = delta == null
? 'var(--ink-muted)'
: delta > 0 ? 'var(--green)' : 'var(--red)';
const deltaSign = delta != null && delta > 0 ? '+' : '';
const formattedValue =
typeof value === 'number' ? value.toLocaleString() : (value ?? '—');
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span className="text-label">
{icon && <span style={{ marginRight: 5 }}>{icon}</span>}
{label}
</span>
{delta != null && (
<span
className="font-mono"
style={{ fontSize: 11, color: deltaColor, background: deltaColor + '18', padding: '1px 6px', borderRadius: 'var(--radius-sm)' }}
>
{deltaSign}{delta.toFixed(1)}%
</span>
)}
</div>
{/* Value */}
<div className="text-kpi">{formattedValue}</div>
{/* Sub-label */}
{sub && (
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{sub}</div>
)}
{/* Sparkline */}
{sparklineData?.length ? (
<div ref={sparkRef} style={{ height: 40, marginTop: 4 }} />
) : null}
</div>
);
}
@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
interface Session {
startTime: string;
endTime?: string;
duration: number;
messageCount: number;
conversationCount: number;
}
interface SessionTimerProps {
sessionData?: {
sessions?: Session[];
totalSessions?: number;
currentSession?: Session;
avgSessionLength?: number;
};
loading?: boolean;
}
function fmtDuration(ms: number): string {
const mins = Math.floor(ms / 60_000);
if (mins < 60) return `${mins}m`;
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
}
export function SessionTimer({ sessionData, loading }: SessionTimerProps) {
const [, tick] = useState(0);
useEffect(() => {
const t = setInterval(() => tick(n => n + 1), 60_000);
return () => clearInterval(t);
}, []);
if (loading) {
return (
<div className="card" style={{ minHeight: 110 }}>
<div className="skeleton" style={{ height: 11, width: '40%', marginBottom: 14 }} />
<div className="skeleton" style={{ height: 36, width: '60%', marginBottom: 12 }} />
<div className="skeleton" style={{ height: 11, width: '55%' }} />
</div>
);
}
const total = sessionData?.totalSessions ?? 0;
const avg = sessionData?.avgSessionLength;
const recentSessions = (sessionData?.sessions ?? []).slice(0, 3);
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span className="text-label"> Sessions</span>
<span
className="font-mono"
style={{ fontSize: 20, fontWeight: 600, color: 'var(--ink)' }}
>
{total.toLocaleString()}
</span>
</div>
{/* Avg duration */}
{avg != null && (
<div style={{ fontSize: 12, color: 'var(--ink-muted)' }}>
Avg duration: <span className="font-mono" style={{ color: 'var(--ink)' }}>{fmtDuration(avg)}</span>
</div>
)}
{/* Recent sessions */}
{recentSessions.length > 0 && (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
borderTop: '1px solid var(--border)',
paddingTop: 10,
marginTop: 2,
}}
>
<span className="text-label" style={{ marginBottom: 2 }}>Recent</span>
{recentSessions.map((s, i) => (
<div
key={i}
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, gap: 8 }}
>
<span style={{ color: 'var(--ink-muted)' }}>
{new Date(s.startTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
<span className="font-mono" style={{ color: 'var(--ink)' }}>
{s.messageCount} msgs · {fmtDuration(s.duration)}
</span>
</div>
))}
</div>
)}
{/* Empty state */}
{total === 0 && (
<div style={{ fontSize: 12, color: 'var(--ink-subtle)' }}>No session data yet</div>
)}
</div>
);
}
@@ -0,0 +1,36 @@
import React from 'react';
interface SkeletonProps {
height?: number | string;
width?: number | string;
className?: string;
style?: React.CSSProperties;
}
export function Skeleton({ height = 16, width = '100%', className = '', style }: SkeletonProps) {
return (
<div
className={`skeleton ${className}`}
style={{ height, width, ...style }}
/>
);
}
export function KPICardSkeleton() {
return (
<div className="card" style={{ minHeight: 110 }}>
<Skeleton height={11} width="55%" style={{ marginBottom: 14 }} />
<Skeleton height={44} width="70%" style={{ marginBottom: 10 }} />
<Skeleton height={11} width="40%" />
</div>
);
}
export function ChartCardSkeleton({ height = 220 }: { height?: number }) {
return (
<div className="card">
<Skeleton height={12} width="40%" style={{ marginBottom: 16 }} />
<Skeleton height={height} />
</div>
);
}
@@ -0,0 +1,43 @@
import React, { useEffect, useState } from 'react';
export function ThemeToggle() {
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
useEffect(() => {
const stored = localStorage.getItem('theme') as 'dark' | 'light' | null;
const current = document.documentElement.getAttribute('data-theme') as 'dark' | 'light';
setTheme(stored || current || 'dark');
}, []);
function toggle() {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
return (
<button
onClick={toggle}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
style={{
background: 'none',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
padding: '5px 10px',
color: 'var(--ink-muted)',
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
transition: 'border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')}
>
<span>{theme === 'dark' ? '☀️' : '🌙'}</span>
<span>{theme === 'dark' ? 'Light' : 'Dark'}</span>
</button>
);
}
@@ -0,0 +1,51 @@
import React from 'react';
export type TimeRange = '7d' | '30d' | '90d' | 'all';
interface TimeRangePickerProps {
value: TimeRange;
onChange: (range: TimeRange) => void;
}
const OPTIONS: { value: TimeRange; label: string }[] = [
{ value: '7d', label: '7D' },
{ value: '30d', label: '30D' },
{ value: '90d', label: '90D' },
{ value: 'all', label: 'All' },
];
export function TimeRangePicker({ value, onChange }: TimeRangePickerProps) {
return (
<div
style={{
display: 'flex',
gap: 2,
background: 'var(--bg-secondary)',
borderRadius: 'var(--radius-sm)',
padding: 2,
border: '1px solid var(--border)',
}}
>
{OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
style={{
padding: '3px 10px',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: 12,
fontWeight: value === opt.value ? 600 : 400,
background: value === opt.value ? 'var(--bg-card)' : 'transparent',
color: value === opt.value ? 'var(--ink)' : 'var(--ink-muted)',
boxShadow: value === opt.value ? 'var(--shadow-sm)' : 'none',
transition: 'all 0.12s ease',
}}
>
{opt.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,607 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { api, type ApiResponse, type AgentData, type ActivityData } from '../lib/api';
import { wsClient, type WsStatus } from '../lib/ws';
import { KPICard } from '../components/KPICard';
import { BarList } from '../components/BarList';
import { ChartCard } from '../components/ChartCard';
import { ActivityHeatmap } from '../components/ActivityHeatmap';
import { SessionTimer } from '../components/SessionTimer';
import { ThemeToggle } from '../components/ThemeToggle';
import { TimeRangePicker, type TimeRange } from '../components/TimeRangePicker';
// ── Sidebar ───────────────────────────────────────────────────────────────────
const NAV_ITEMS = [
{ id: 'dashboard', icon: '📊', label: 'Dashboard' },
{ id: 'agents', icon: '🤖', label: 'Agents' },
];
function Sidebar({ view, onNavigate }: { view: string; onNavigate: (v: string) => void }) {
return (
<aside
style={{
width: 192,
flexShrink: 0,
borderRight: '1px solid var(--border)',
background: 'var(--bg-secondary)',
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}
>
{/* Logo */}
<div
style={{
padding: '16px 16px 14px',
display: 'flex',
alignItems: 'center',
gap: 10,
borderBottom: '1px solid var(--border)',
}}
>
<span style={{ fontSize: 22 }}>🔮</span>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink)', lineHeight: 1.2 }}>
Claude Code
</div>
<div style={{ fontSize: 10, color: 'var(--ink-muted)' }}>Analytics</div>
</div>
</div>
{/* Nav items */}
<nav style={{ padding: '8px 0' }}>
{NAV_ITEMS.map(item => {
const active = view === item.id;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 16px',
background: active ? 'var(--bg-card)' : 'transparent',
border: 'none',
borderLeft: `2px solid ${active ? 'var(--accent)' : 'transparent'}`,
cursor: 'pointer',
color: active ? 'var(--ink)' : 'var(--ink-muted)',
fontSize: 13,
fontWeight: active ? 500 : 400,
transition: 'all 0.12s ease',
width: '100%',
textAlign: 'left',
}}
>
<span style={{ fontSize: 15 }}>{item.icon}</span>
{item.label}
</button>
);
})}
</nav>
</aside>
);
}
// ── Header ────────────────────────────────────────────────────────────────────
function Header({
lastUpdate,
wsStatus,
onRefresh,
}: {
lastUpdate?: string;
wsStatus: WsStatus;
onRefresh: () => void;
}) {
const connected = wsStatus === 'connected';
const dotColor = connected ? 'var(--green)' : wsStatus === 'connecting' ? 'var(--accent)' : 'var(--ink-subtle)';
return (
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 24px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-card)',
position: 'sticky',
top: 0,
zIndex: 20,
minHeight: 48,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: dotColor,
boxShadow: connected ? `0 0 6px ${dotColor}` : 'none',
transition: 'background 0.3s, box-shadow 0.3s',
}}
/>
<span style={{ fontSize: 12, color: 'var(--ink-muted)' }}>
{connected ? 'Live' : wsStatus === 'connecting' ? 'Connecting…' : 'Polling'}
</span>
{lastUpdate && (
<span style={{ fontSize: 11, color: 'var(--ink-subtle)' }}>
· {new Date(lastUpdate).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
onClick={onRefresh}
style={{
background: 'none',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
padding: '4px 12px',
cursor: 'pointer',
color: 'var(--ink-muted)',
fontSize: 12,
transition: 'border-color 0.12s',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')}
>
Refresh
</button>
<ThemeToggle />
</div>
</header>
);
}
// ── Dashboard view ────────────────────────────────────────────────────────────
function DashboardView({
data,
agents,
activity,
loading,
timeRange,
onTimeRangeChange,
}: {
data: ApiResponse | null;
agents: AgentData | null;
activity: Array<{ date: string; value: number }>;
loading: boolean;
timeRange: TimeRange;
onTimeRangeChange: (r: TimeRange) => void;
}) {
const summary = data?.summary;
const tokens = data?.detailedTokenUsage;
// Filter conversations by time range
const filteredConversations = useMemo(() => {
const convs = data?.conversations ?? [];
if (timeRange === 'all') return convs;
const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return convs.filter(c => c.lastModified && new Date(c.lastModified) >= cutoff);
}, [data?.conversations, timeRange]);
// Build daily token data for timeline chart
const tokenTimelineGetOption = useCallback(() => {
if (!filteredConversations.length) return {};
const byDay: Record<string, { input: number; output: number; cache: number }> = {};
filteredConversations.forEach(c => {
if (!c.lastModified) return;
const date = c.lastModified.substring(0, 10);
if (!byDay[date]) byDay[date] = { input: 0, output: 0, cache: 0 };
byDay[date].input += c.inputTokens ?? 0;
byDay[date].output += c.outputTokens ?? 0;
byDay[date].cache += (c.cacheCreationTokens ?? 0) + (c.cacheReadTokens ?? 0);
});
const series = Object.entries(byDay)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, v]) => ({ date, ...v }));
return import('../lib/charts').then(({ tokenTimelineOption, getThemeColors }) =>
tokenTimelineOption(series, getThemeColors())
);
}, [filteredConversations]);
// Token distribution donut
const tokenDistGetOption = useCallback(() => {
if (!tokens) return {};
return import('../lib/charts').then(({ tokenDistributionOption, getThemeColors }) =>
tokenDistributionOption(
{
input: tokens.totalInput ?? 0,
output: tokens.totalOutput ?? 0,
cacheCreation: tokens.totalCacheCreation ?? 0,
cacheRead: tokens.totalCacheRead ?? 0,
},
getThemeColors(),
)
);
}, [tokens]);
// Agent bar list
const agentItems = useMemo(() =>
(agents?.topAgents ?? []).map(a => ({ name: a.name, value: a.count })),
[agents],
);
// Project bar list
const projectItems = useMemo(() =>
(data?.activeProjects ?? []).slice(0, 10).map((p: any) => ({
name: (p.name ?? p.path?.split('/').pop()) || 'Unknown',
value: p.conversations ?? p.fileCount ?? 1,
})),
[data?.activeProjects],
);
const fmt = (n: number | null | undefined) =>
n != null ? n.toLocaleString() : '—';
// Compute total tokens from filtered conversations (fallback to summary)
const filteredTokens = useMemo(() =>
filteredConversations.reduce((s, c) => s + (c.tokens ?? 0), 0),
[filteredConversations],
);
return (
<div style={{ padding: 24, overflowY: 'auto', flex: 1 }}>
{/* Page header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 20,
}}
>
<h1 style={{ fontSize: 15, fontWeight: 600, color: 'var(--ink)' }}>Dashboard</h1>
<TimeRangePicker value={timeRange} onChange={onTimeRangeChange} />
</div>
{/* Bento grid */}
<div className="bento-grid">
{/* ── KPI row ── */}
<div className="col-span-3">
<KPICard
label="Total Tokens"
icon="⚡"
value={fmt(filteredTokens || summary?.totalTokens)}
sub={`Conversations: ${fmt(filteredConversations.length)}`}
loading={loading}
/>
</div>
<div className="col-span-3">
<KPICard
label="Conversations"
icon="💬"
value={fmt(filteredConversations.length || summary?.totalConversations)}
sub={`Active: ${fmt(summary?.activeConversations)}`}
loading={loading}
/>
</div>
<div className="col-span-3">
<KPICard
label="Sessions"
icon="🕐"
value={fmt(data?.sessionData?.totalSessions)}
sub={`Projects: ${fmt(data?.activeProjects?.length)}`}
loading={loading}
/>
</div>
<div className="col-span-3">
<KPICard
label="Agent Uses"
icon="🤖"
value={fmt(agents?.totalAgentUses)}
sub={`Types: ${Object.keys(agents?.agentTypes ?? {}).length}`}
loading={loading}
/>
</div>
{/* ── Token timeline + Session timer ── */}
<div className="col-span-8">
<ChartCard
title="Token Usage Over Time"
subtitle="Input · Output · Cache — stacked"
height={220}
loading={loading}
getOption={tokenTimelineGetOption}
/>
</div>
<div className="col-span-4">
<SessionTimer sessionData={data?.sessionData} loading={loading} />
</div>
{/* ── Activity heatmap ── */}
<div className="col-span-12">
<div className="card">
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 14 }}>
Activity Heatmap
</div>
<ActivityHeatmap
data={activity}
loading={loading}
/>
</div>
</div>
{/* ── Agent distribution + Projects ── */}
<div className="col-span-6">
<ChartCard title="Agent Usage" subtitle="By invocations" loading={loading}>
<BarList items={agentItems} maxItems={8} valueLabel="Uses" loading={loading} />
</ChartCard>
</div>
<div className="col-span-6">
<ChartCard title="Projects" subtitle="By conversations" loading={loading}>
<BarList items={projectItems} maxItems={8} valueLabel="Convs" loading={loading} />
</ChartCard>
</div>
{/* ── Token distribution donut + Recent conversations ── */}
<div className="col-span-4">
<ChartCard
title="Token Distribution"
subtitle="Input · Output · Cache"
height={200}
loading={loading}
getOption={tokenDistGetOption}
/>
</div>
<div className="col-span-8">
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>Recent Conversations</div>
{loading ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[...Array(5)].map((_, i) => (
<div key={i} className="skeleton" style={{ height: 32 }} />
))}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{(data?.conversations ?? []).slice(0, 7).map((c, i, arr) => (
<div
key={c.id ?? i}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '7px 0',
borderBottom: i < arr.length - 1 ? '1px solid var(--border-subtle)' : 'none',
}}
>
<div
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: c.status === 'active' ? 'var(--green)' : 'var(--ink-subtle)',
flexShrink: 0,
}}
/>
<span
style={{
flex: 1,
fontSize: 12,
color: 'var(--ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{c.projectName ?? (c.id?.substring(0, 20) + '…') ?? 'Unknown'}
</span>
<span
className="font-mono"
style={{ fontSize: 11, color: 'var(--ink-muted)', flexShrink: 0 }}
>
{(c.tokens ?? 0).toLocaleString()} tok
</span>
<span style={{ fontSize: 11, color: 'var(--ink-subtle)', flexShrink: 0 }}>
{c.lastModified
? new Date(c.lastModified).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
: ''}
</span>
</div>
))}
{!data?.conversations?.length && (
<div style={{ color: 'var(--ink-subtle)', fontSize: 12, padding: '8px 0' }}>
No conversations found in ~/.claude
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
// ── Agents view ───────────────────────────────────────────────────────────────
function AgentsView({ agents, loading }: { agents: AgentData | null; loading: boolean }) {
const agentItems = useMemo(() =>
(agents?.topAgents ?? []).map(a => ({ name: a.name, value: a.count })),
[agents],
);
const typeItems = useMemo(() =>
Object.entries(agents?.agentTypes ?? {})
.map(([name, value]) => ({ name, value: value as number }))
.sort((a, b) => b.value - a.value),
[agents],
);
return (
<div style={{ padding: 24, overflowY: 'auto', flex: 1 }}>
<h1 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Agent Analytics</h1>
<div className="bento-grid">
<div className="col-span-4">
<KPICard
label="Total Agent Uses"
icon="🤖"
value={(agents?.totalAgentUses ?? 0).toLocaleString()}
loading={loading}
/>
</div>
<div className="col-span-4">
<KPICard
label="Agent Types"
icon="🧩"
value={Object.keys(agents?.agentTypes ?? {}).length}
sub="distinct types used"
loading={loading}
/>
</div>
<div className="col-span-4">
<KPICard
label="Top Agent"
icon="⭐"
value={agents?.topAgents?.[0]?.name ?? '—'}
sub={agents?.topAgents?.[0] ? `${agents.topAgents[0].count} uses` : ''}
loading={loading}
/>
</div>
<div className="col-span-6">
<ChartCard title="Top Agents" subtitle="By total invocations" loading={loading}>
<BarList items={agentItems} maxItems={12} valueLabel="Uses" loading={loading} />
</ChartCard>
</div>
<div className="col-span-6">
<ChartCard title="By Agent Type" subtitle="Distribution across types" loading={loading}>
<BarList items={typeItems} maxItems={12} valueLabel="Uses" loading={loading} />
</ChartCard>
</div>
</div>
</div>
);
}
// ── Root Dashboard island ─────────────────────────────────────────────────────
export default function Dashboard() {
const [view, setView] = useState<string>(() =>
typeof window !== 'undefined'
? (window.location.hash.replace('#', '') || 'dashboard')
: 'dashboard',
);
const [data, setData] = useState<ApiResponse | null>(null);
const [agents, setAgents] = useState<AgentData | null>(null);
const [activity, setActivity] = useState<Array<{ date: string; value: number }>>([]);
const [loading, setLoading] = useState(true);
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const navigate = useCallback((v: string) => {
setView(v);
if (typeof window !== 'undefined') window.location.hash = '#' + v;
}, []);
// Hash routing
useEffect(() => {
function onHash() {
setView(window.location.hash.replace('#', '') || 'dashboard');
}
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
// Load all data
const loadData = useCallback(async () => {
try {
setLoading(true);
const [mainData, agentData, activityData] = await Promise.all([
api.getData().catch(() => null),
api.getAgents().catch(() => null),
api.getActivity().catch(() => null),
]);
if (mainData) setData(mainData);
if (agentData) setAgents(agentData);
if (activityData) setActivity((activityData as any).heatmapData ?? []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
// WebSocket + polling fallback
useEffect(() => {
wsClient.connect();
const unsubStatus = wsClient.onStatus(setWsStatus);
const unsubData = wsClient.on('data_updates', () => { api.invalidate(); loadData(); });
const unsubConv = wsClient.on('conversation_updates', () => { api.invalidate('/api/data'); loadData(); });
// Polling fallback: 30s if WS connected, 10s if not
const poll = setInterval(() => {
api.invalidate();
loadData();
}, wsStatus === 'connected' ? 30_000 : 10_000);
return () => {
unsubStatus();
unsubData();
unsubConv();
clearInterval(poll);
wsClient.disconnect();
};
}, [loadData, wsStatus]);
const handleRefresh = useCallback(() => {
api.invalidate();
loadData();
}, [loadData]);
return (
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--bg-primary)' }}>
<Sidebar view={view} onNavigate={navigate} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0 }}>
<Header
lastUpdate={data?.timestamp}
wsStatus={wsStatus}
onRefresh={handleRefresh}
/>
{view === 'dashboard' && (
<DashboardView
data={data}
agents={agents}
activity={activity}
loading={loading}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
)}
{view === 'agents' && (
<AgentsView agents={agents} loading={loading} />
)}
{view !== 'dashboard' && view !== 'agents' && (
<div style={{ padding: 24 }}>
<div style={{ color: 'var(--ink-muted)', fontSize: 13 }}>
View &quot;{view}&quot; not found.{' '}
<button
onClick={() => navigate('dashboard')}
style={{
color: 'var(--accent)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 13,
textDecoration: 'underline',
}}
>
Back to Dashboard
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,34 @@
---
interface Props {
title?: string;
}
const { title = 'Claude Code Analytics' } = Astro.props;
---
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔮</text></svg>" />
<style>
/* Prevent FOUC before theme script runs */
html { visibility: hidden; }
html.ready { visibility: visible; }
</style>
</head>
<body>
<slot />
<!-- Apply theme before paint to avoid flash of wrong theme -->
<script is:inline>
(function() {
var stored = localStorage.getItem('theme');
var preferred = stored
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', preferred);
document.documentElement.classList.add('ready');
})();
</script>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
// ── Types ────────────────────────────────────────────────────────────────────
export interface TokenUsage {
input: number;
output: number;
cacheCreation: number;
cacheRead: number;
total: number;
}
export interface Conversation {
id: string;
projectName?: string;
lastModified: string;
tokens: number;
inputTokens?: number;
outputTokens?: number;
cacheCreationTokens?: number;
cacheReadTokens?: number;
model?: string;
status?: 'active' | 'idle' | 'completed';
toolUsage?: Record<string, number>;
messageCount?: number;
fileSize?: number;
}
export interface Summary {
totalConversations: number;
totalTokens: number;
activeConversations: number;
avgTokensPerConversation: number;
lastActivity: string | null;
totalSessions?: number;
thisMonthConversations?: number;
thisWeekConversations?: number;
thisMonthTokens?: number;
}
export interface DetailedTokenUsage {
byModel?: Record<string, number>;
totalInput: number;
totalOutput: number;
totalCacheCreation: number;
totalCacheRead: number;
}
export interface AgentData {
agentTypes?: Record<string, number>;
topAgents?: Array<{ name: string; count: number }>;
totalAgentUses?: number;
byConversation?: Record<string, number>;
summary?: any;
}
export interface ActivityData {
heatmapData?: Array<{ date: string; value: number }>;
dateRange?: { start: string; end: string };
}
export interface ApiResponse {
conversations: Conversation[];
summary: Summary;
realtimeStats?: {
totalConversations: number;
totalTokens: number;
activeProjects: number;
lastActivity: string | null;
};
detailedTokenUsage: DetailedTokenUsage | null;
sessionData?: {
sessions: any[];
totalSessions: number;
avgSessionLength?: number;
currentSession?: any;
};
activeProjects?: any[];
timestamp: string;
lastUpdate: string;
}
// ── Cache entry ───────────────────────────────────────────────────────────────
interface CacheEntry {
data: unknown;
ts: number;
}
// ── ApiClient ─────────────────────────────────────────────────────────────────
class ApiClient {
private cache: Map<string, CacheEntry> = new Map();
private readonly DEFAULT_TTL = 30_000; // 30 seconds
private async request<T>(url: string, ttl = this.DEFAULT_TTL): Promise<T> {
const now = performance.now();
const entry = this.cache.get(url);
if (entry && now - entry.ts < ttl) {
return entry.data as T;
}
const res = await fetch(url);
if (!res.ok) throw new Error(`API ${url} returned ${res.status}`);
const data = await res.json() as T;
this.cache.set(url, { data, ts: now });
return data;
}
async getData(): Promise<ApiResponse> {
const raw = await this.request<any>('/api/data');
// Normalize detailedTokenUsage field names (backend uses inputTokens, frontend expects totalInput)
if (raw.detailedTokenUsage) {
const dtu = raw.detailedTokenUsage;
raw.detailedTokenUsage = {
...dtu,
totalInput: dtu.totalInput ?? dtu.inputTokens ?? 0,
totalOutput: dtu.totalOutput ?? dtu.outputTokens ?? 0,
totalCacheCreation: dtu.totalCacheCreation ?? dtu.cacheCreationTokens ?? 0,
totalCacheRead: dtu.totalCacheRead ?? dtu.cacheReadTokens ?? 0,
};
}
// Flatten tokenUsage nested object onto conversation
if (raw.conversations) {
raw.conversations = raw.conversations.map((c: any) => ({
...c,
inputTokens: c.inputTokens ?? c.tokenUsage?.inputTokens ?? 0,
outputTokens: c.outputTokens ?? c.tokenUsage?.outputTokens ?? 0,
cacheCreationTokens: c.cacheCreationTokens ?? c.tokenUsage?.cacheCreationTokens ?? 0,
cacheReadTokens: c.cacheReadTokens ?? c.tokenUsage?.cacheReadTokens ?? 0,
}));
}
return raw as ApiResponse;
}
async getConversations(page = 0, limit = 20) {
const url = `/api/conversations?page=${page}&limit=${limit}`;
return this.request<{ conversations: Conversation[]; pagination: any }>(url, 10_000);
}
async getAgents(startDate?: string, endDate?: string): Promise<AgentData> {
const params = new URLSearchParams();
if (startDate) params.set('startDate', startDate);
if (endDate) params.set('endDate', endDate);
const qs = params.toString();
const raw = await this.request<any>(`/api/agents${qs ? '?' + qs : ''}`, 60_000);
// Normalize backend agentStats → topAgents, totalAgentInvocations → totalAgentUses
return {
totalAgentUses: raw.totalAgentUses ?? raw.totalAgentInvocations ?? 0,
totalAgentTypes: raw.totalAgentTypes ?? 0,
topAgents: (raw.topAgents ?? raw.agentStats ?? []).map((a: any) => ({
name: a.name ?? a.type ?? 'Unknown',
count: a.count ?? a.totalInvocations ?? 0,
})),
agentTypes: raw.agentTypes ?? Object.fromEntries(
(raw.agentStats ?? []).map((a: any) => [a.type ?? a.name, a.totalInvocations ?? a.count ?? 0])
),
};
}
async getActivity(): Promise<ActivityData> {
const raw = await this.request<any>('/api/activity', 300_000);
// Normalize dailyActivity → heatmapData
return {
heatmapData: (raw.heatmapData ?? raw.dailyActivity ?? []).map((d: any) => ({
date: d.date,
value: d.value ?? d.conversations ?? 0,
})),
dateRange: raw.dateRange ?? (raw.startDate ? { start: raw.startDate, end: raw.endDate } : undefined),
};
}
async getHealth() {
return this.request('/api/system/health', 10_000);
}
async forceRefresh(): Promise<void> {
this.cache.clear();
await fetch('/api/refresh').catch(() => null);
}
invalidate(endpoint?: string): void {
if (endpoint) {
this.cache.delete(endpoint);
} else {
this.cache.clear();
}
}
}
export const api = new ApiClient();
+244
View File
@@ -0,0 +1,244 @@
// ECharts tree-shaken setup — no CDN, fully offline
import * as echarts from 'echarts/core';
import { LineChart, BarChart, PieChart, HeatmapChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
LegendComponent,
CalendarComponent,
VisualMapComponent,
DataZoomComponent,
TitleComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
LineChart, BarChart, PieChart, HeatmapChart,
GridComponent, TooltipComponent, LegendComponent,
CalendarComponent, VisualMapComponent, DataZoomComponent, TitleComponent,
CanvasRenderer,
]);
// ── Theme colors from CSS vars ────────────────────────────────────────────────
export interface ThemeColors {
ink: string;
inkMuted: string;
inkSubtle: string;
border: string;
bgCard: string;
bgSecondary: string;
accent: string;
green: string;
blue: string;
red: string;
}
export function getThemeColors(): ThemeColors {
const css = getComputedStyle(document.documentElement);
const get = (v: string) => css.getPropertyValue(v).trim();
return {
ink: get('--ink'),
inkMuted: get('--ink-muted'),
inkSubtle: get('--ink-subtle'),
border: get('--border'),
bgCard: get('--bg-card'),
bgSecondary: get('--bg-secondary'),
accent: get('--accent'),
green: get('--green'),
blue: get('--blue'),
red: get('--red'),
};
}
// ── Categorical palette (consistent series colors) ────────────────────────────
const PALETTE = ['#f97316', '#3b82f6', '#22c55e', '#a855f7', '#ec4899', '#eab308', '#14b8a6', '#ef4444'];
// ── Chart factory ─────────────────────────────────────────────────────────────
export function createChart(
el: HTMLElement,
option: echarts.EChartsOption,
): echarts.ECharts {
const chart = echarts.init(el);
chart.setOption(option);
// Resize when container size changes
const ro = new ResizeObserver(() => chart.resize());
ro.observe(el);
// Store cleanup on element for disposal
(el as HTMLElement & { _echart?: echarts.ECharts; _ero?: ResizeObserver })._echart = chart;
(el as HTMLElement & { _ero?: ResizeObserver })._ero = ro;
return chart;
}
// ── Chart option factories ────────────────────────────────────────────────────
export function tokenTimelineOption(
data: Array<{ date: string; input: number; output: number; cache: number }>,
colors: ThemeColors,
): echarts.EChartsOption {
const dates = data.map(d => d.date);
return {
backgroundColor: 'transparent',
grid: { top: 16, right: 16, bottom: 60, left: 56 },
tooltip: {
trigger: 'axis',
backgroundColor: colors.bgCard,
borderColor: colors.border,
textStyle: { color: colors.ink, fontSize: 12, fontFamily: 'inherit' },
},
legend: {
bottom: 30,
textStyle: { color: colors.inkMuted, fontSize: 11 },
itemWidth: 12,
itemHeight: 8,
},
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 20, bottom: 4, borderColor: colors.border, fillerColor: colors.accent + '20', handleStyle: { color: colors.accent } }],
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: colors.border } },
axisLabel: { color: colors.inkMuted, fontSize: 11 },
splitLine: { show: false },
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisLabel: { color: colors.inkMuted, fontSize: 11, formatter: (v: number) => v >= 1000 ? (v / 1000).toFixed(0) + 'k' : String(v) },
splitLine: { lineStyle: { color: colors.border, type: 'dashed' } },
},
series: [
{
name: 'Input', type: 'line', stack: 'tokens',
data: data.map(d => d.input),
areaStyle: { opacity: 0.3 },
lineStyle: { width: 1.5 },
itemStyle: { color: colors.blue },
symbol: 'none',
smooth: true,
},
{
name: 'Output', type: 'line', stack: 'tokens',
data: data.map(d => d.output),
areaStyle: { opacity: 0.3 },
lineStyle: { width: 1.5 },
itemStyle: { color: colors.accent },
symbol: 'none',
smooth: true,
},
{
name: 'Cache', type: 'line', stack: 'tokens',
data: data.map(d => d.cache),
areaStyle: { opacity: 0.2 },
lineStyle: { width: 1, type: 'dashed' },
itemStyle: { color: colors.inkMuted },
symbol: 'none',
smooth: true,
},
],
};
}
export function activityHeatmapOption(
data: Array<{ date: string; value: number }>,
colors: ThemeColors,
year: number,
): echarts.EChartsOption {
const maxVal = Math.max(...data.map(d => d.value), 1);
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: colors.bgCard,
borderColor: colors.border,
textStyle: { color: colors.ink, fontSize: 12 },
formatter: (p: any) => `${p.data[0]}<br/>${p.data[1]} conversation${p.data[1] !== 1 ? 's' : ''}`,
},
visualMap: {
min: 0,
max: maxVal,
show: false,
inRange: { color: [colors.bgSecondary, colors.accent] },
},
calendar: {
top: 16,
left: 36,
right: 16,
bottom: 16,
range: String(year),
cellSize: ['auto', 14],
itemStyle: { borderColor: colors.bgSecondary, borderWidth: 2, color: colors.bgSecondary },
splitLine: { show: false },
yearLabel: { show: false },
monthLabel: { color: colors.inkMuted, fontSize: 10 },
dayLabel: { color: colors.inkSubtle, fontSize: 9, nameMap: ['S', 'M', 'T', 'W', 'T', 'F', 'S'] },
},
series: [{
type: 'heatmap',
coordinateSystem: 'calendar',
data: data.map(d => [d.date, d.value]),
itemStyle: { borderRadius: 2 },
}],
};
}
export function tokenDistributionOption(
data: { input: number; output: number; cacheCreation: number; cacheRead: number },
colors: ThemeColors,
): echarts.EChartsOption {
const total = data.input + data.output + data.cacheCreation + data.cacheRead;
if (total === 0) return {};
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: colors.bgCard,
borderColor: colors.border,
textStyle: { color: colors.ink, fontSize: 12 },
formatter: (p: any) => `${p.name}<br/>${p.value.toLocaleString()} (${p.percent}%)`,
},
legend: {
bottom: 0,
textStyle: { color: colors.inkMuted, fontSize: 11 },
itemWidth: 10,
itemHeight: 10,
},
series: [{
type: 'pie',
radius: ['50%', '80%'],
center: ['50%', '44%'],
avoidLabelOverlap: false,
label: { show: false },
emphasis: { label: { show: false } },
data: [
{ name: 'Input', value: data.input, itemStyle: { color: colors.blue } },
{ name: 'Output', value: data.output, itemStyle: { color: colors.accent } },
{ name: 'Cache Write', value: data.cacheCreation, itemStyle: { color: colors.green } },
{ name: 'Cache Read', value: data.cacheRead, itemStyle: { color: colors.inkMuted } },
],
}],
};
}
export function sparklineOption(
data: number[],
colors: ThemeColors,
): echarts.EChartsOption {
return {
backgroundColor: 'transparent',
grid: { top: 2, right: 2, bottom: 2, left: 2 },
xAxis: { type: 'category', show: false },
yAxis: { type: 'value', show: false },
series: [{
type: 'line',
data,
smooth: true,
symbol: 'none',
lineStyle: { width: 1.5, color: colors.accent },
areaStyle: { color: colors.accent, opacity: 0.15 },
}],
};
}
+139
View File
@@ -0,0 +1,139 @@
// ── Types ────────────────────────────────────────────────────────────────────
type WsHandler = (data: unknown) => void;
export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
// ── WebSocketClient ───────────────────────────────────────────────────────────
class WebSocketClient {
private ws: WebSocket | null = null;
private handlers: Map<string, Set<WsHandler>> = new Map();
private statusHandlers: Set<(s: WsStatus) => void> = new Set();
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly baseDelay = 1_000;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
public status: WsStatus = 'disconnected';
connect(): void {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.setStatus('connecting');
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${location.host}/ws`;
try {
this.ws = new WebSocket(url);
} catch {
this.setStatus('error');
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.setStatus('connected');
// Subscribe to all channels
this.send({ type: 'subscribe', channels: ['data_updates', 'conversation_updates', 'system_updates'] });
this.startHeartbeat();
};
this.ws.onmessage = (ev: MessageEvent) => {
this.handleMessage(ev.data as string);
};
this.ws.onerror = () => {
this.setStatus('error');
};
this.ws.onclose = () => {
this.stopHeartbeat();
if (this.status !== 'disconnected') {
this.setStatus('disconnected');
this.scheduleReconnect();
}
};
}
disconnect(): void {
this.stopHeartbeat();
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.setStatus('disconnected');
this.ws?.close();
this.ws = null;
}
/** Register a handler for a channel. Returns an unsubscribe function. */
on(channel: string, handler: WsHandler): () => void {
if (!this.handlers.has(channel)) {
this.handlers.set(channel, new Set());
}
this.handlers.get(channel)!.add(handler);
return () => this.handlers.get(channel)?.delete(handler);
}
/** Register a status change handler. Returns an unsubscribe function. */
onStatus(handler: (s: WsStatus) => void): () => void {
this.statusHandlers.add(handler);
// Immediately notify current status
handler(this.status);
return () => this.statusHandlers.delete(handler);
}
private send(msg: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
private setStatus(s: WsStatus): void {
this.status = s;
this.statusHandlers.forEach(h => h(s));
}
private handleMessage(raw: string): void {
try {
const msg = JSON.parse(raw) as { type?: string; channel?: string; event?: string; data?: unknown };
const channel = msg.channel || msg.type || msg.event;
if (!channel) return;
const handlers = this.handlers.get(channel);
if (handlers) handlers.forEach(h => h(msg.data ?? msg));
} catch {
// ignore malformed messages
}
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatInterval = setInterval(() => {
this.send({ type: 'ping' });
}, 30_000);
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
const delay = Math.min(this.baseDelay * Math.pow(2, this.reconnectAttempts), 30_000);
this.reconnectAttempts++;
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.connect();
}, delay);
}
}
export const wsClient = new WebSocketClient();
@@ -0,0 +1,8 @@
---
import Base from '../layouts/Base.astro';
import Dashboard from '../islands/Dashboard';
import '../styles/tokens.css';
---
<Base title="Claude Code Analytics">
<Dashboard client:only="react" />
</Base>
+182
View File
@@ -0,0 +1,182 @@
/* ── Font stack ────────────────────────────────────────────────────────────── */
:root {
--font-sans: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--font-mono: 'Geist Mono', 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, 'Menlo', monospace;
}
/* ── Light theme ───────────────────────────────────────────────────────────── */
:root {
--bg-primary: #fafafa;
--bg-secondary: #f4f4f5;
--bg-card: #ffffff;
--bg-card-hover: #f9f9f9;
--border: #e4e4e7;
--border-subtle: #f0f0f0;
--ink: #171717;
--ink-muted: #71717a;
--ink-subtle: #a1a1aa;
--accent: #f97316;
--accent-subtle: #fff7ed;
--green: #22c55e;
--green-subtle: #dcfce7;
--red: #ef4444;
--blue: #3b82f6;
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
/* ── Dark theme ────────────────────────────────────────────────────────────── */
[data-theme="dark"] {
--bg-primary: #0a0a0a;
--bg-secondary: #111111;
--bg-card: #161616;
--bg-card-hover: #1a1a1a;
--border: #262626;
--border-subtle: #1c1c1c;
--ink: #fafafa;
--ink-muted: #a1a1aa;
--ink-subtle: #52525b;
--accent: #f97316;
--accent-subtle: #1c1008;
--green: #22c55e;
--green-subtle: #0d2010;
--red: #f87171;
--blue: #60a5fa;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* ── Global resets ─────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--ink);
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background 0.2s ease, color 0.2s ease;
}
body {
min-height: 100vh;
}
/* ── Typography utilities ──────────────────────────────────────────────────── */
.font-mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.text-kpi {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 600;
letter-spacing: -0.02em;
color: var(--ink);
line-height: 1;
}
.text-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.text-muted {
color: var(--ink-muted);
}
.text-subtle {
color: var(--ink-subtle);
}
/* ── Card ──────────────────────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.15s ease;
}
.card:hover {
box-shadow: var(--shadow);
}
/* ── Bento grid ────────────────────────────────────────────────────────────── */
.bento-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
}
@media (max-width: 1100px) {
.bento-grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 700px) {
.bento-grid {
grid-template-columns: 1fr;
}
}
.col-span-3 { grid-column: span 3; }
.col-span-4 { grid-column: span 4; }
.col-span-6 { grid-column: span 6; }
.col-span-8 { grid-column: span 8; }
.col-span-12 { grid-column: span 12; }
@media (max-width: 1100px) {
.col-span-3,
.col-span-4 { grid-column: span 3; }
.col-span-6,
.col-span-8 { grid-column: span 6; }
.col-span-12 { grid-column: 1 / -1; }
}
@media (max-width: 700px) {
.col-span-3,
.col-span-4,
.col-span-6,
.col-span-8,
.col-span-12 { grid-column: 1 / -1; }
}
/* ── Skeleton loader ───────────────────────────────────────────────────────── */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--bg-secondary) 25%,
var(--border) 50%,
var(--bg-secondary) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
/* ── Scrollbar ─────────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--ink-subtle); }
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}