chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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()],
|
||||
},
|
||||
});
|
||||
Generated
+7481
File diff suppressed because it is too large
Load Diff
@@ -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 "{view}" 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>
|
||||
@@ -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();
|
||||
@@ -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 },
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user