This commit is contained in:
YouXam
2025-08-27 20:33:03 +08:00
commit c6bdca6379
23 changed files with 2413 additions and 0 deletions

79
client/App.tsx Normal file
View File

@@ -0,0 +1,79 @@
import React, { useState, useEffect } from 'react';
import { LoginPage } from './components/LoginPage';
import { Dashboard } from './components/Dashboard';
export function App() {
const [apiKey, setApiKey] = useState<string>('');
const [userId, setUserId] = useState<string>('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>('');
useEffect(() => {
const savedApiKey = localStorage.getItem('ai-usage-api-key');
if (savedApiKey) {
validateApiKey(savedApiKey);
}
}, []);
const validateApiKey = async (key: string) => {
setIsLoading(true);
setError('');
try {
const response = await fetch('/api/auth/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ apiKey: key }),
});
if (!response.ok) {
throw new Error('Invalid API key');
}
const data = await response.json();
setApiKey(key);
setUserId(data.userId);
setIsAuthenticated(true);
localStorage.setItem('ai-usage-api-key', key);
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
setIsAuthenticated(false);
localStorage.removeItem('ai-usage-api-key');
} finally {
setIsLoading(false);
}
};
const handleLogin = (key: string) => {
validateApiKey(key);
};
const handleLogout = () => {
setApiKey('');
setUserId('');
setIsAuthenticated(false);
setError('');
localStorage.removeItem('ai-usage-api-key');
};
if (isAuthenticated) {
return (
<Dashboard
apiKey={apiKey}
userId={userId}
onLogout={handleLogout}
/>
);
}
return (
<LoginPage
onLogin={handleLogin}
isLoading={isLoading}
error={error}
/>
);
}

View File

@@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { RankingTable } from './RankingTable';
import { UserDetailCard } from './UserDetailCard';
interface Period {
index: number;
startSnapshotId: number | null;
startAt: string | null;
endAt: string | null;
isCurrent: boolean;
}
interface PeriodSummary {
period: {
index: number;
startAt: string | null;
endAt: string | null;
isCurrent: boolean;
};
totals: {
totalCost: number;
userCount: number;
};
ranking: Array<{
id: string;
name: string;
cost: number;
share: number;
isMe: boolean;
rawStart: any;
rawEnd: any;
periodTokens: number;
periodRequests: number;
}>;
}
interface CurrentPeriodProps {
period: Period;
apiKey: string;
userId: string;
}
export function CurrentPeriod({ period, apiKey, userId }: CurrentPeriodProps) {
const [summary, setSummary] = useState<PeriodSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
useEffect(() => {
fetchSummary();
}, [period.index]);
const fetchSummary = async () => {
setIsLoading(true);
setError('');
try {
const response = await fetch(`/api/periods/${period.index}/summary`, {
headers: {
'X-API-Key': apiKey,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch period summary: ${response.status}`);
}
const data = await response.json();
setSummary(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load period data');
} finally {
setIsLoading(false);
}
};
const formatDate = (dateString: string | null, isEndDate = false) => {
if (!dateString && isEndDate) return 'Now';
if (!dateString) return 'Beginning';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const myUser = summary?.ranking.find(u => u.isMe);
if (isLoading) {
return (
<div className="space-y-6">
<div className="bg-card p-6 rounded-lg shadow-sm border border-border animate-pulse">
<div className="h-6 bg-muted rounded w-1/3 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="h-20 bg-muted rounded"></div>
<div className="h-20 bg-muted rounded"></div>
<div className="h-20 bg-muted rounded"></div>
</div>
</div>
</div>
);
}
if (error || !summary) {
return (
<div className="bg-card p-8 rounded-lg shadow-sm border border-border text-center">
<div className="text-destructive mb-4">
<svg className="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-card-foreground mb-2">Failed to Load Period Data</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={fetchSummary}
className="bg-primary text-primary-foreground px-4 py-2 rounded-md hover:bg-primary/90 transition-colors"
>
Retry
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header with period info */}
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
<h2 className="text-lg font-semibold text-card-foreground mb-4">
Current Period: {formatDate(summary.period.startAt)} {formatDate(summary.period.endAt, true)}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="text-2xl font-bold text-primary">{formatCurrency(summary.totals.totalCost)}</div>
<div className="text-sm text-muted-foreground">Total Cost</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{summary.totals.userCount}</div>
<div className="text-sm text-muted-foreground">Active Users</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">
{myUser ? formatCurrency(myUser.cost) : '$0.00'}
</div>
<div className="text-sm text-muted-foreground">
Your Cost ({myUser ? (myUser.share * 100).toFixed(2) : '0.00'}%)
</div>
</div>
</div>
</div>
{/* Ranking Table */}
<RankingTable
ranking={summary.ranking}
title="User Ranking"
/>
{/* User Detail Card */}
<UserDetailCard
periodIndex={period.index}
apiKey={apiKey}
userId={userId}
/>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import { CurrentPeriod } from './CurrentPeriod';
import { HistoricalPeriods } from './HistoricalPeriods';
interface DashboardProps {
apiKey: string;
userId: string;
onLogout: () => void;
}
interface Period {
index: number;
startSnapshotId: number | null;
startAt: string | null;
endAt: string | null;
isCurrent: boolean;
}
export function Dashboard({ apiKey, userId, onLogout }: DashboardProps) {
const [activeTab, setActiveTab] = useState<'current' | 'historical'>('current');
const [periods, setPeriods] = useState<Period[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
useEffect(() => {
fetchPeriods();
}, []);
const fetchPeriods = async () => {
setIsLoading(true);
setError('');
try {
const response = await fetch('/api/periods', {
headers: {
'X-API-Key': apiKey,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch periods: ${response.status}`);
}
const data = await response.json();
setPeriods(data.periods || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load periods');
} finally {
setIsLoading(false);
}
};
const currentPeriod = periods.find(p => p.isCurrent);
const historicalPeriods = periods.filter(p => !p.isCurrent);
if (isLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<svg className="animate-spin h-8 w-8 text-muted-foreground mx-auto mb-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="bg-card p-8 rounded-lg shadow-sm border border-border max-w-md w-full mx-4">
<div className="text-center">
<svg className="h-12 w-12 text-destructive mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h3 className="text-lg font-medium text-card-foreground mb-2">Error Loading Dashboard</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={fetchPeriods}
className="bg-primary text-primary-foreground px-4 py-2 rounded-md hover:bg-primary/90 transition-colors"
>
Try Again
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<nav className="bg-card shadow-sm border-b border-border">
<div className="container mx-auto px-4 lg:px-8 max-w-6xl">
<div className="flex justify-between items-center h-16">
<div>
<h1 className="text-xl font-semibold text-card-foreground">Claude Code Usage Dashboard</h1>
</div>
<div className="flex items-center space-x-4">
<button
onClick={onLogout}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Logout
</button>
</div>
</div>
</div>
</nav>
<div className="container mx-auto px-4 lg:px-8 max-w-6xl py-8">
<div className="mb-6">
<div className="border-b border-border">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('current')}
className={`whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'current'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Current Period
</button>
<button
onClick={() => setActiveTab('historical')}
className={`whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'historical'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Historical Periods
</button>
</nav>
</div>
</div>
{activeTab === 'current' && currentPeriod && (
<CurrentPeriod
period={currentPeriod}
apiKey={apiKey}
userId={userId}
/>
)}
{activeTab === 'historical' && (
<HistoricalPeriods
periods={historicalPeriods}
apiKey={apiKey}
userId={userId}
/>
)}
{activeTab === 'current' && !currentPeriod && (
<div className="bg-card p-8 rounded-lg shadow-sm border border-border text-center">
<p className="text-muted-foreground">No current period found. This happens when there are no billing snapshots yet.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { useState, useEffect } from 'react';
import { RankingTable } from './RankingTable';
import { UserDetailCard } from './UserDetailCard';
interface Period {
index: number;
startSnapshotId: number | null;
startAt: string | null;
endAt: string | null;
isCurrent: boolean;
}
interface PeriodSummary {
period: {
index: number;
startAt: string | null;
endAt: string | null;
isCurrent: boolean;
};
totals: {
totalCost: number;
userCount: number;
};
ranking: Array<{
id: string;
name: string;
cost: number;
share: number;
isMe: boolean;
rawStart: any;
rawEnd: any;
periodTokens: number;
periodRequests: number;
}>;
}
interface HistoricalPeriodsProps {
periods: Period[];
apiKey: string;
userId: string;
}
export function HistoricalPeriods({ periods, apiKey, userId }: HistoricalPeriodsProps) {
const [selectedPeriod, setSelectedPeriod] = useState<Period | null>(null);
const [summary, setSummary] = useState<PeriodSummary | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>('');
useEffect(() => {
if (periods.length > 0 && !selectedPeriod) {
setSelectedPeriod(periods[0] || null); // Select the most recent historical period
}
}, [periods]);
useEffect(() => {
if (selectedPeriod) {
fetchSummary(selectedPeriod.index);
}
}, [selectedPeriod]);
const fetchSummary = async (periodIndex: number) => {
setIsLoading(true);
setError('');
try {
const response = await fetch(`/api/periods/${periodIndex}/summary`, {
headers: {
'X-API-Key': apiKey,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch period summary: ${response.status}`);
}
const data = await response.json();
setSummary(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load period data');
setSummary(null);
} finally {
setIsLoading(false);
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
};
const formatDateRange = (startAt: string | null, endAt: string | null) => {
return `${formatDate(startAt)}${formatDate(endAt)}`;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
if (periods.length === 0) {
return (
<div className="bg-card p-8 rounded-lg shadow-sm border border-border text-center">
<svg className="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-lg font-medium text-card-foreground">No Historical Periods</h3>
<p className="mt-1 text-muted-foreground">
Historical periods will appear here after you create billing snapshots using the <code>bun begin-period</code> command.
</p>
</div>
);
}
const myUser = summary?.ranking.find(u => u.isMe);
return (
<div className="space-y-6">
{/* Period Selector */}
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
<label htmlFor="period-select" className="block text-lg font-medium text-card-foreground mb-4">
Select Historical Period
</label>
<select
id="period-select"
value={selectedPeriod?.index ?? ''}
onChange={(e) => {
const periodIndex = parseInt(e.target.value);
const period = periods.find(p => p.index === periodIndex);
setSelectedPeriod(period || null);
}}
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring sm:text-sm bg-background text-foreground"
>
{periods.map((period) => (
<option key={period.index} value={period.index}>
Period #{period.index} - {formatDateRange(period.startAt, period.endAt)}
</option>
))}
</select>
</div>
{selectedPeriod && (
<>
{/* Period Summary */}
{isLoading ? (
<div className="bg-card p-6 rounded-lg shadow-sm border border-border animate-pulse">
<div className="h-6 bg-muted rounded w-1/3 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="h-20 bg-muted rounded"></div>
<div className="h-20 bg-muted rounded"></div>
<div className="h-20 bg-muted rounded"></div>
</div>
</div>
) : error ? (
<div className="bg-card p-8 rounded-lg shadow-sm border border-border text-center">
<div className="text-destructive mb-4">
<svg className="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-card-foreground mb-2">Failed to Load Period Data</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={() => fetchSummary(selectedPeriod.index)}
className="bg-primary text-primary-foreground px-4 py-2 rounded-md hover:bg-primary/90 transition-colors"
>
Retry
</button>
</div>
) : summary && (
<>
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
<h2 className="text-lg font-semibold text-card-foreground mb-4">
Period #{selectedPeriod.index}: {formatDateRange(summary.period.startAt, summary.period.endAt)}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="text-2xl font-bold text-primary">{formatCurrency(summary.totals.totalCost)}</div>
<div className="text-sm text-muted-foreground">Total Cost</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-card-foreground">{summary.totals.userCount}</div>
<div className="text-sm text-muted-foreground">Active Users</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">
{myUser ? formatCurrency(myUser.cost) : '$0.00'}
</div>
<div className="text-sm text-muted-foreground">
Your Cost ({myUser ? (myUser.share * 100).toFixed(2) : '0.00'}%)
</div>
</div>
</div>
</div>
{/* Ranking Table */}
<RankingTable
ranking={summary.ranking}
title={`Period #${selectedPeriod.index} Ranking`}
/>
{/* User Detail Card */}
<UserDetailCard
periodIndex={selectedPeriod.index}
apiKey={apiKey}
userId={userId}
/>
</>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
interface LoginPageProps {
onLogin: (apiKey: string) => void;
isLoading: boolean;
error: string;
}
export function LoginPage({ onLogin, isLoading, error }: LoginPageProps) {
const [apiKey, setApiKey] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (apiKey.trim()) {
onLogin(apiKey.trim());
}
};
return (
<div className="min-h-screen bg-background flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="text-center">
<h1 className="text-3xl font-bold text-foreground">Claude Code Usage Dashboard</h1>
<p className="mt-2 text-sm text-muted-foreground">
Monitor your API usage and billing across different periods
</p>
</div>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-card py-8 px-4 shadow-sm border border-border rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="api-key" className="block text-sm font-medium text-card-foreground">
API Key
</label>
<div className="mt-1 relative">
<input
id="api-key"
name="api-key"
type={showApiKey ? 'text' : 'password'}
required
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="appearance-none block w-full px-3 py-2 pr-10 border border-border rounded-md shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring sm:text-sm bg-background text-foreground"
placeholder="Enter your API key"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-sm leading-5"
>
{showApiKey ? (
<svg className="h-5 w-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464l1.414-1.414M14.12 14.12l1.415 1.415" />
</svg>
) : (
<svg className="h-5 w-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-destructive">{error}</p>
</div>
</div>
</div>
)}
<div>
<button
type="submit"
disabled={isLoading || !apiKey.trim()}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary-foreground bg-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-primary-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
</>
) : (
'Access Dashboard'
)}
</button>
</div>
</form>
<div className="mt-6">
<div className="text-center text-sm text-muted-foreground">
<p>Enter your API key to view your usage statistics and billing information.</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
// React is used in JSX, TypeScript just doesn't detect it
interface RankingUser {
id: string;
name: string;
cost: number;
share: number;
isMe: boolean;
rawStart: any;
rawEnd: any;
periodTokens: number;
periodRequests: number;
}
interface RankingTableProps {
ranking: RankingUser[];
title: string;
}
export function RankingTable({ ranking, title }: RankingTableProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const formatPercentage = (ratio: number) => {
return (ratio * 100).toFixed(2) + '%';
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US').format(num);
};
const getRankBadge = (rank: number) => {
if (rank === 1) {
return (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-chart-1/20 text-chart-1 rounded-full">
🥇 1st
</span>
);
}
if (rank === 2) {
return (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-chart-2/20 text-chart-2 rounded-full">
🥈 2nd
</span>
);
}
if (rank === 3) {
return (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full">
🥉 3rd
</span>
);
}
return (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full">
#{rank}
</span>
);
};
return (
<div className="bg-card rounded-lg shadow-sm border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-lg font-medium text-card-foreground">{title}</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Rank
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
User
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">
Cost
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">
Share
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">
Requests
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">
Tokens
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{ranking.map((user, index) => {
const rank = index + 1;
return (
<tr
key={user.isMe ? user.id : `user-${index}`}
className={`${
user.isMe ? 'bg-primary/10 border-l-4 border-l-primary' : ''
} hover:bg-muted/50 transition-colors`}
>
<td className="px-6 py-4 whitespace-nowrap">
{getRankBadge(rank)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div>
<div className="text-sm font-medium text-card-foreground">
{user.name}
{user.isMe && (
<span className="ml-2 inline-flex items-center px-2 py-1 text-xs font-medium bg-primary/20 text-primary rounded-full">
You
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="text-sm font-semibold text-primary">
{formatCurrency(user.cost)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="text-sm text-card-foreground">
{formatPercentage(user.share)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="text-sm text-card-foreground">
{formatNumber(user.periodRequests)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="text-sm text-card-foreground">
{formatNumber(user.periodTokens)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{ranking.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-card-foreground">No data available</h3>
<p className="mt-1 text-sm text-muted-foreground">There are no users in this billing period.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,204 @@
import React, { useState, useEffect } from 'react';
interface UserDetail {
id: string;
name: string;
startCost: number;
endCost: number;
deltaCost: number;
raw: {
start: any;
end: any;
};
}
interface UserDetailCardProps {
periodIndex: number;
apiKey: string;
userId: string;
}
export function UserDetailCard({ periodIndex, apiKey, userId }: UserDetailCardProps) {
const [userDetail, setUserDetail] = useState<UserDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
const [showRawData, setShowRawData] = useState(false);
useEffect(() => {
fetchUserDetail();
}, [periodIndex]);
const fetchUserDetail = async () => {
setIsLoading(true);
setError('');
try {
const response = await fetch(`/api/periods/${periodIndex}/me`, {
headers: {
'X-API-Key': apiKey,
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('You are not found in this period (possibly deleted)');
}
throw new Error(`Failed to fetch user details: ${response.status}`);
}
const data = await response.json();
setUserDetail(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load user details');
} finally {
setIsLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US').format(num);
};
const calculateDelta = (endValue: number, startValue: number) => {
const delta = endValue - startValue;
return Math.max(0, delta); // Negative values treated as 0
};
if (isLoading) {
return (
<div className="bg-card p-6 rounded-lg shadow-sm border border-border animate-pulse">
<div className="h-6 bg-muted rounded w-1/4 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="h-16 bg-muted rounded"></div>
<div className="h-16 bg-muted rounded"></div>
<div className="h-16 bg-muted rounded"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
<h3 className="text-lg font-medium text-card-foreground mb-4">Your Usage Details</h3>
<div className="bg-chart-3/20 border border-chart-3/50 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-chart-3" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-chart-3">{error}</p>
</div>
</div>
</div>
</div>
);
}
if (!userDetail) return null;
const endUsage = userDetail.raw.end?.usage?.total;
const startUsage = userDetail.raw.start?.usage?.total;
return (
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-card-foreground">Your Usage Details</h3>
<button
onClick={() => setShowRawData(!showRawData)}
className="text-sm text-muted-foreground hover:text-card-foreground transition-colors"
>
{showRawData ? 'Hide Raw Data' : 'Show Raw Data'}
</button>
</div>
{/* Cost Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold text-card-foreground">{formatCurrency(userDetail.startCost)}</div>
<div className="text-sm text-muted-foreground">Start Cost</div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold text-card-foreground">{formatCurrency(userDetail.endCost)}</div>
<div className="text-sm text-muted-foreground">End Cost</div>
</div>
<div className="text-center p-4 bg-primary/10 rounded-lg">
<div className="text-lg font-semibold text-primary">{formatCurrency(userDetail.deltaCost)}</div>
<div className="text-sm text-primary">Period Cost</div>
</div>
</div>
{/* Usage Metrics */}
{endUsage && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="text-center">
<div className="text-lg font-semibold text-card-foreground">
{formatNumber(calculateDelta(endUsage.requests || 0, startUsage?.requests || 0))}
</div>
<div className="text-sm text-muted-foreground">Requests</div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-card-foreground">
{formatNumber(calculateDelta(endUsage.tokens || 0, startUsage?.tokens || 0))}
</div>
<div className="text-sm text-muted-foreground">Total Tokens</div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-card-foreground">
{formatNumber(calculateDelta(endUsage.inputTokens || 0, startUsage?.inputTokens || 0))}
</div>
<div className="text-sm text-muted-foreground">Input Tokens</div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-card-foreground">
{formatNumber(calculateDelta(endUsage.outputTokens || 0, startUsage?.outputTokens || 0))}
</div>
<div className="text-sm text-muted-foreground">Output Tokens</div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-card-foreground">
{formatNumber(calculateDelta(endUsage.cacheCreateTokens || 0, startUsage?.cacheCreateTokens || 0))}
</div>
<div className="text-sm text-muted-foreground">Cache Create</div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-card-foreground">
{formatNumber(calculateDelta(endUsage.cacheReadTokens || 0, startUsage?.cacheReadTokens || 0))}
</div>
<div className="text-sm text-muted-foreground">Cache Read</div>
</div>
</div>
)}
{/* Raw Data */}
{showRawData && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-card-foreground mb-2">Period Start Data</h4>
<pre className="text-xs bg-muted/50 p-4 rounded-lg overflow-x-auto border border-border">
{JSON.stringify(userDetail.raw.start, null, 2)}
</pre>
</div>
<div>
<h4 className="text-sm font-medium text-card-foreground mb-2">Period End Data</h4>
<pre className="text-xs bg-muted/50 p-4 rounded-lg overflow-x-auto border border-border">
{JSON.stringify(userDetail.raw.end, null, 2)}
</pre>
</div>
</div>
)}
</div>
);
}

12
client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code Usage Dashboard</title>
<link rel="stylesheet" crossorigin href="../ai-usage/index-cq4ce245.css"><script type="module" crossorigin src="../ai-usage/index-jqd5mhpf.js"></script></head>
<body>
<div id="root"></div>
</body>
</html>

7
client/main.tsx Normal file
View File

@@ -0,0 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
const root = createRoot(document.getElementById('root')!);
root.render(<App />);

150
client/styles.css Normal file
View File

@@ -0,0 +1,150 @@
@import "tailwindcss";
:root {
--background: oklch(0.9818 0.0054 95.0986);
--foreground: oklch(0.3438 0.0269 95.7226);
--card: oklch(0.9818 0.0054 95.0986);
--card-foreground: oklch(0.1908 0.0020 106.5859);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2671 0.0196 98.9390);
--primary: oklch(0.6171 0.1375 39.0427);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9245 0.0138 92.9892);
--secondary-foreground: oklch(0.4334 0.0177 98.6048);
--muted: oklch(0.9341 0.0153 90.2390);
--muted-foreground: oklch(0.6059 0.0075 97.4233);
--accent: oklch(0.9245 0.0138 92.9892);
--accent-foreground: oklch(0.2671 0.0196 98.9390);
--destructive: oklch(0.1908 0.0020 106.5859);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8847 0.0069 97.3627);
--input: oklch(0.7621 0.0156 98.3528);
--ring: oklch(0.6171 0.1375 39.0427);
--chart-1: oklch(0.5583 0.1276 42.9956);
--chart-2: oklch(0.6898 0.1581 290.4107);
--chart-3: oklch(0.8816 0.0276 93.1280);
--chart-4: oklch(0.8822 0.0403 298.1792);
--chart-5: oklch(0.5608 0.1348 42.0584);
--sidebar: oklch(0.9663 0.0080 98.8792);
--sidebar-foreground: oklch(0.3590 0.0051 106.6524);
--sidebar-primary: oklch(0.6171 0.1375 39.0427);
--sidebar-primary-foreground: oklch(0.9881 0 0);
--sidebar-accent: oklch(0.9245 0.0138 92.9892);
--sidebar-accent-foreground: oklch(0.3250 0 0);
--sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0.7731 0 0);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.2679 0.0036 106.6427);
--foreground: oklch(0.8074 0.0142 93.0137);
--card: oklch(0.2679 0.0036 106.6427);
--card-foreground: oklch(0.9818 0.0054 95.0986);
--popover: oklch(0.3085 0.0035 106.6039);
--popover-foreground: oklch(0.9211 0.0040 106.4781);
--primary: oklch(0.6724 0.1308 38.7559);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9818 0.0054 95.0986);
--secondary-foreground: oklch(0.3085 0.0035 106.6039);
--muted: oklch(0.2213 0.0038 106.7070);
--muted-foreground: oklch(0.7713 0.0169 99.0657);
--accent: oklch(0.2130 0.0078 95.4245);
--accent-foreground: oklch(0.9663 0.0080 98.8792);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.3618 0.0101 106.8928);
--input: oklch(0.4336 0.0113 100.2195);
--ring: oklch(0.6724 0.1308 38.7559);
--chart-1: oklch(0.5583 0.1276 42.9956);
--chart-2: oklch(0.6898 0.1581 290.4107);
--chart-3: oklch(0.2130 0.0078 95.4245);
--chart-4: oklch(0.3074 0.0516 289.3230);
--chart-5: oklch(0.5608 0.1348 42.0584);
--sidebar: oklch(0.2357 0.0024 67.7077);
--sidebar-foreground: oklch(0.8074 0.0142 93.0137);
--sidebar-primary: oklch(0.3250 0 0);
--sidebar-primary-foreground: oklch(0.9881 0 0);
--sidebar-accent: oklch(0.1680 0.0020 106.6177);
--sidebar-accent-foreground: oklch(0.8074 0.0142 93.0137);
--sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0.7731 0 0);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}