commit c6bdca6379eb692510cfcf2d760894d10a0d7b8e Author: YouXam Date: Wed Aug 27 20:33:03 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f59d4e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +*.db +.cursor* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a89d02f --- /dev/null +++ b/bun.lock @@ -0,0 +1,94 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "ai-usage", + "dependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "bun-plugin-tailwind": "^0.0.15", + "hono": "^4.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.1.0", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.12", + "typescript": "^5.5.4", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], + + "@types/node": ["@types/node@22.18.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.24", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], + + "browserslist": ["browserslist@4.25.3", "", { "dependencies": { "caniuse-lite": "^1.0.30001735", "electron-to-chromium": "^1.5.204", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="], + + "bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001737", "", {}, "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.209", "", {}, "sha512-Xoz0uMrim9ZETCQt8UgM5FxQF9+imA7PBpokoGcZloA1uw2LeHzTlip5cb5KOAsXZLjh/moN2vReN3ZjJmjI9A=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + + "hono": ["hono@4.9.4", "", {}, "sha512-61hl6MF6ojTl/8QSRu5ran6GXt+6zsngIUN95KzF5v5UjiX/xnrLR358BNRawwIRO49JwUqJqQe3Rb2v559R8Q=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + + "bun-types/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + + "bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..de249f9 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[serve.static] +plugins = ["bun-plugin-tailwind"] diff --git a/client/App.tsx b/client/App.tsx new file mode 100644 index 0000000..18b4d5a --- /dev/null +++ b/client/App.tsx @@ -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(''); + const [userId, setUserId] = useState(''); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + 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 ( + + ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/client/components/CurrentPeriod.tsx b/client/components/CurrentPeriod.tsx new file mode 100644 index 0000000..d76f620 --- /dev/null +++ b/client/components/CurrentPeriod.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + 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 ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !summary) { + return ( +
+
+ + + +
+

Failed to Load Period Data

+

{error}

+ +
+ ); + } + + return ( +
+ {/* Header with period info */} +
+

+ Current Period: {formatDate(summary.period.startAt)} → {formatDate(summary.period.endAt, true)} +

+ +
+
+
{formatCurrency(summary.totals.totalCost)}
+
Total Cost
+
+
+
{summary.totals.userCount}
+
Active Users
+
+
+
+ {myUser ? formatCurrency(myUser.cost) : '$0.00'} +
+
+ Your Cost ({myUser ? (myUser.share * 100).toFixed(2) : '0.00'}%) +
+
+
+
+ + {/* Ranking Table */} + + + {/* User Detail Card */} + +
+ ); +} \ No newline at end of file diff --git a/client/components/Dashboard.tsx b/client/components/Dashboard.tsx new file mode 100644 index 0000000..b562eb2 --- /dev/null +++ b/client/components/Dashboard.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + 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 ( +
+
+ + + + +

Loading dashboard...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +

Error Loading Dashboard

+

{error}

+ +
+
+
+ ); + } + + return ( +
+ + +
+
+
+ +
+
+ + {activeTab === 'current' && currentPeriod && ( + + )} + + {activeTab === 'historical' && ( + + )} + + {activeTab === 'current' && !currentPeriod && ( +
+

No current period found. This happens when there are no billing snapshots yet.

+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/client/components/HistoricalPeriods.tsx b/client/components/HistoricalPeriods.tsx new file mode 100644 index 0000000..b8aa859 --- /dev/null +++ b/client/components/HistoricalPeriods.tsx @@ -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(null); + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + 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 ( +
+ + + +

No Historical Periods

+

+ Historical periods will appear here after you create billing snapshots using the bun begin-period command. +

+
+ ); + } + + const myUser = summary?.ranking.find(u => u.isMe); + + return ( +
+ {/* Period Selector */} +
+ + +
+ + {selectedPeriod && ( + <> + {/* Period Summary */} + {isLoading ? ( +
+
+
+
+
+
+
+
+ ) : error ? ( +
+
+ + + +
+

Failed to Load Period Data

+

{error}

+ +
+ ) : summary && ( + <> +
+

+ Period #{selectedPeriod.index}: {formatDateRange(summary.period.startAt, summary.period.endAt)} +

+ +
+
+
{formatCurrency(summary.totals.totalCost)}
+
Total Cost
+
+
+
{summary.totals.userCount}
+
Active Users
+
+
+
+ {myUser ? formatCurrency(myUser.cost) : '$0.00'} +
+
+ Your Cost ({myUser ? (myUser.share * 100).toFixed(2) : '0.00'}%) +
+
+
+
+ + {/* Ranking Table */} + + + {/* User Detail Card */} + + + )} + + )} +
+ ); +} \ No newline at end of file diff --git a/client/components/LoginPage.tsx b/client/components/LoginPage.tsx new file mode 100644 index 0000000..f87b9ae --- /dev/null +++ b/client/components/LoginPage.tsx @@ -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 ( +
+
+
+

Claude Code Usage Dashboard

+

+ Monitor your API usage and billing across different periods +

+
+
+ +
+
+
+
+ +
+ 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} + /> + +
+
+ + {error && ( +
+
+
+ + + +
+
+

{error}

+
+
+
+ )} + +
+ +
+
+ +
+
+

Enter your API key to view your usage statistics and billing information.

+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/components/RankingTable.tsx b/client/components/RankingTable.tsx new file mode 100644 index 0000000..c4dc6b8 --- /dev/null +++ b/client/components/RankingTable.tsx @@ -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 ( + + 🥇 1st + + ); + } + if (rank === 2) { + return ( + + 🥈 2nd + + ); + } + if (rank === 3) { + return ( + + 🥉 3rd + + ); + } + return ( + + #{rank} + + ); + }; + + return ( +
+
+

{title}

+
+ +
+ + + + + + + + + + + + + {ranking.map((user, index) => { + const rank = index + 1; + + return ( + + + + + + + + + ); + })} + +
+ Rank + + User + + Cost + + Share + + Requests + + Tokens +
+ {getRankBadge(rank)} + +
+
+
+ {user.name} + {user.isMe && ( + + You + + )} +
+
+
+
+
+ {formatCurrency(user.cost)} +
+
+
+ {formatPercentage(user.share)} +
+
+
+ {formatNumber(user.periodRequests)} +
+
+
+ {formatNumber(user.periodTokens)} +
+
+
+ + {ranking.length === 0 && ( +
+ + + +

No data available

+

There are no users in this billing period.

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/client/components/UserDetailCard.tsx b/client/components/UserDetailCard.tsx new file mode 100644 index 0000000..f87c225 --- /dev/null +++ b/client/components/UserDetailCard.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + 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 ( +
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+

Your Usage Details

+
+
+
+ + + +
+
+

{error}

+
+
+
+
+ ); + } + + if (!userDetail) return null; + + const endUsage = userDetail.raw.end?.usage?.total; + const startUsage = userDetail.raw.start?.usage?.total; + + return ( +
+
+

Your Usage Details

+ +
+ + {/* Cost Overview */} +
+
+
{formatCurrency(userDetail.startCost)}
+
Start Cost
+
+
+
{formatCurrency(userDetail.endCost)}
+
End Cost
+
+
+
{formatCurrency(userDetail.deltaCost)}
+
Period Cost
+
+
+ + {/* Usage Metrics */} + {endUsage && ( +
+
+
+ {formatNumber(calculateDelta(endUsage.requests || 0, startUsage?.requests || 0))} +
+
Requests
+
+
+
+ {formatNumber(calculateDelta(endUsage.tokens || 0, startUsage?.tokens || 0))} +
+
Total Tokens
+
+
+
+ {formatNumber(calculateDelta(endUsage.inputTokens || 0, startUsage?.inputTokens || 0))} +
+
Input Tokens
+
+
+
+ {formatNumber(calculateDelta(endUsage.outputTokens || 0, startUsage?.outputTokens || 0))} +
+
Output Tokens
+
+
+
+ {formatNumber(calculateDelta(endUsage.cacheCreateTokens || 0, startUsage?.cacheCreateTokens || 0))} +
+
Cache Create
+
+
+
+ {formatNumber(calculateDelta(endUsage.cacheReadTokens || 0, startUsage?.cacheReadTokens || 0))} +
+
Cache Read
+
+
+ )} + + {/* Raw Data */} + {showRawData && ( +
+
+

Period Start Data

+
+              {JSON.stringify(userDetail.raw.start, null, 2)}
+            
+
+
+

Period End Data

+
+              {JSON.stringify(userDetail.raw.end, null, 2)}
+            
+
+
+ )} + +
+ ); +} \ No newline at end of file diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..8fc880f --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + Claude Code Usage Dashboard + + +
+ + + diff --git a/client/main.tsx b/client/main.tsx new file mode 100644 index 0000000..750d842 --- /dev/null +++ b/client/main.tsx @@ -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(); \ No newline at end of file diff --git a/client/styles.css b/client/styles.css new file mode 100644 index 0000000..e6a2d3b --- /dev/null +++ b/client/styles.css @@ -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); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1ead444 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "ai-usage-dashboard", + "version": "1.0.0", + "description": "AI API Usage Dashboard with billing period tracking", + "main": "server/index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun --hot server/index.ts", + "start": "bun server/index.ts", + "begin-period": "bun scripts/begin-period.ts", + "build": "bun build client/main.tsx --outdir dist/client", + "setup": "bun install && bun scripts/setup-db.ts" + }, + "dependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "bun-plugin-tailwind": "^0.0.15", + "hono": "^4.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.1.0", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.12", + "typescript": "^5.5.4" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/scripts/begin-period.ts b/scripts/begin-period.ts new file mode 100644 index 0000000..a3ce646 --- /dev/null +++ b/scripts/begin-period.ts @@ -0,0 +1,39 @@ +import { apiClient } from '../server/api-client'; +import { db } from '../server/database'; + +async function beginNewPeriod() { + console.log('Starting new billing period...'); + + try { + // Fetch current costs from the API + console.log('Fetching current costs from API...'); + const currentCosts = await apiClient.getCurrentCosts(); + console.log(`Retrieved data for ${currentCosts.length} users`); + + // Insert snapshot into database + console.log('Creating billing snapshot...'); + const snapshotId = db.insertSnapshot(JSON.stringify(currentCosts)); + console.log(`Created snapshot with ID: ${snapshotId}`); + + // Display summary + const totalCost = currentCosts.reduce((sum, user) => { + return sum + (user.usage?.total?.cost || 0); + }, 0); + + console.log('\n=== New Billing Period Started ==='); + console.log(`Snapshot ID: ${snapshotId}`); + console.log(`Timestamp: ${new Date().toISOString()}`); + console.log(`Users: ${currentCosts.length}`); + console.log(`Total Cost at Snapshot: $${totalCost.toFixed(2)}`); + console.log('====================================\n'); + + console.log('✅ New billing period created successfully!'); + + } catch (error) { + console.error('❌ Failed to create new billing period:'); + console.error(error); + process.exit(1); + } +} + +beginNewPeriod(); \ No newline at end of file diff --git a/server/api-client.ts b/server/api-client.ts new file mode 100644 index 0000000..03dbcf3 --- /dev/null +++ b/server/api-client.ts @@ -0,0 +1,154 @@ +interface LoginResponse { + success: boolean; + token: string; + expiresIn: number; +} + +interface ApiKeysResponse { + success: boolean; + data: Array<{ + id: string; + name: string; + usage: { + total: { + cost: number; + tokens: number; + inputTokens: number; + outputTokens: number; + cacheCreateTokens: number; + cacheReadTokens: number; + requests: number; + formattedCost: string; + }; + }; + [key: string]: any; + }>; +} + +interface KeyIdResponse { + success: boolean; + data: { + id: string; + }; +} + +export class ApiClient { + private baseUrl: string; + private username: string; + private password: string; + private token: string | null = null; + private expiresAt: number = 0; + private readonly skewMs = 10000; // 10 seconds + + constructor() { + this.baseUrl = process.env.BASE_URL || ''; + this.username = process.env.ADMIN_USERNAME || ''; + this.password = process.env.ADMIN_PASSWORD || ''; + + if (!this.baseUrl || !this.username || !this.password) { + throw new Error('Missing required environment variables: BASE_URL, ADMIN_USERNAME, ADMIN_PASSWORD'); + } + } + + private async ensureValidToken(): Promise { + if (this.token && Date.now() + this.skewMs < this.expiresAt) { + return; + } + + await this.login(); + } + + private async login(): Promise { + let retries = 0; + const maxRetries = 3; + + while (retries < maxRetries) { + try { + const response = await fetch(`${this.baseUrl}/web/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + }); + + if (!response.ok) { + throw new Error(`Login failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as LoginResponse; + + if (!data.success || !data.token) { + throw new Error('Login response invalid'); + } + + this.token = data.token; + this.expiresAt = Date.now() + data.expiresIn; + return; + + } catch (error) { + retries++; + if (retries >= maxRetries) { + throw new Error(`Login failed after ${maxRetries} retries: ${error}`); + } + + // Exponential backoff + const delay = Math.pow(2, retries - 1) * 1000; + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + async getCurrentCosts(): Promise { + await this.ensureValidToken(); + + const response = await fetch(`${this.baseUrl}/admin/api-keys?timeRange=all`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch current costs: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as ApiKeysResponse; + + if (!data.success || !Array.isArray(data.data)) { + throw new Error('Invalid response format from admin/api-keys'); + } + + return data.data; + } + + async getKeyId(apiKey: string): Promise { + const response = await fetch(`${this.baseUrl}/apiStats/api/get-key-id`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + apiKey, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to get key ID: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as KeyIdResponse; + + if (!data.success || !data.data?.id) { + throw new Error('Invalid API key or response format'); + } + + return data.data.id; + } +} + +export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/server/billing-calculator.ts b/server/billing-calculator.ts new file mode 100644 index 0000000..481b734 --- /dev/null +++ b/server/billing-calculator.ts @@ -0,0 +1,280 @@ +import { db } from './database'; +import { apiClient } from './api-client'; + +export interface UserData { + id: string; + name: string; + usage: { + total: { + cost: number; + tokens: number; + inputTokens: number; + outputTokens: number; + cacheCreateTokens: number; + cacheReadTokens: number; + requests: number; + formattedCost: string; + }; + }; + [key: string]: any; +} + +export interface PeriodInfo { + index: number; + startSnapshotId: number | null; + startAt: string | null; + endAt: string | null; + isCurrent: boolean; +} + +export interface UserRanking { + id: string; + name: string; + cost: number; + share: number; + isMe: boolean; + rawStart: UserData | null; + rawEnd: UserData | null; + periodTokens: number; + periodRequests: number; +} + +export interface PeriodSummary { + period: PeriodInfo; + totals: { + totalCost: number; + userCount: number; + }; + ranking: UserRanking[]; +} + +export interface UserDetail { + id: string; + name: string; + startCost: number; + endCost: number; + deltaCost: number; + raw: { + start: UserData | null; + end: UserData | null; + }; +} + +function mapFromDataArray(data: UserData[]): Map { + const m = new Map(); + for (const u of data ?? []) m.set(u.id, u); + return m; +} + +function computePeriodDelta(startData: UserData[], endData: UserData[], meId?: string) { + const start = mapFromDataArray(startData); + const end = mapFromDataArray(endData); + + const ids = new Set([...Array.from(start.keys()), ...Array.from(end.keys())]); + const users: UserRanking[] = []; + + for (const id of Array.from(ids)) { + const endU = end.get(id); + if (!endU) continue; // 删除用户:不计入 + + const startU = start.get(id); + const startCost = Number(startU?.usage?.total?.cost ?? 0); + const endCost = Number(endU?.usage?.total?.cost ?? 0); + const startTokens = Number(startU?.usage?.total?.tokens ?? 0); + const endTokens = Number(endU?.usage?.total?.tokens ?? 0); + const startRequests = Number(startU?.usage?.total?.requests ?? 0); + const endRequests = Number(endU?.usage?.total?.requests ?? 0); + + let delta = endCost - startCost; + if (!Number.isFinite(delta) || delta < 0) delta = 0; + + let deltaTokens = endTokens - startTokens; + if (!Number.isFinite(deltaTokens) || deltaTokens < 0) deltaTokens = 0; + + let deltaRequests = endRequests - startRequests; + if (!Number.isFinite(deltaRequests) || deltaRequests < 0) deltaRequests = 0; + + users.push({ + id: meId === id ? id : '', // Only include ID for current user + name: endU.name || 'User', + cost: +delta.toFixed(6), + share: 0, // will be calculated below + isMe: meId === id, + rawStart: start.get(id) ?? null, + rawEnd: endU, + periodTokens: deltaTokens, + periodRequests: deltaRequests + }); + } + + const totalCost = users.reduce((s, u) => s + u.cost, 0); + for (const u of users) u.share = totalCost > 0 ? u.cost / totalCost : 0; + users.sort((a, b) => b.cost - a.cost); + + const me = meId ? users.find(u => u.id === meId) : null; + + return { users, totalCost: +totalCost.toFixed(6), me }; +} + +export class BillingCalculator { + async getPeriods(): Promise { + const snapshots = db.getSnapshots(); + const periods: PeriodInfo[] = []; + + if (snapshots.length === 0) { + // No snapshots case: only current period from beginning + periods.push({ + index: 0, + startSnapshotId: null, + startAt: null, + endAt: null, + isCurrent: true + }); + } else { + // Add current period (last snapshot to now) + const lastSnapshot = snapshots[snapshots.length - 1]; + if (lastSnapshot) { + periods.push({ + index: snapshots.length, + startSnapshotId: lastSnapshot.id, + startAt: lastSnapshot.created_at, + endAt: null, + isCurrent: true + }); + } + + // Add first historical period: from beginning to first snapshot + const firstSnapshot = snapshots[0]; + if (firstSnapshot) { + periods.push({ + index: 0, + startSnapshotId: null, + startAt: null, + endAt: firstSnapshot.created_at, + isCurrent: false + }); + } + + // Add other historical periods: from snapshot to snapshot + for (let i = 1; i < snapshots.length; i++) { + const startSnapshot = snapshots[i - 1]; + const endSnapshot = snapshots[i]; + + if (startSnapshot && endSnapshot) { + periods.push({ + index: i, + startSnapshotId: startSnapshot.id, + startAt: startSnapshot.created_at, + endAt: endSnapshot.created_at, + isCurrent: false + }); + } + } + } + + return periods; + } + + async getPeriodSummary(periodIndex: number, meId?: string): Promise { + const periods = await this.getPeriods(); + const period = periods.find(p => p.index === periodIndex); + + if (!period) { + throw new Error(`Period ${periodIndex} not found`); + } + + let startData: UserData[] = []; + let endData: UserData[] = []; + + if (period.index === 0) { + // First historical period: start from 0, end with first snapshot + if (period.endAt) { + // There is a snapshot - find it and use it as endData + const snapshots = db.getSnapshots(); + const endSnapshot = snapshots.find(s => s.created_at === period.endAt); + if (endSnapshot) { + // Handle double JSON encoding issue + let rawData = endSnapshot.raw_json; + if (typeof rawData === 'string' && rawData.startsWith('"')) { + rawData = JSON.parse(rawData); + } + endData = JSON.parse(rawData); + } + } else { + // No snapshots case: start from 0, end with current data + endData = await apiClient.getCurrentCosts(); + } + } else if (period.isCurrent) { + // Current period: start from latest snapshot data, end with current data + const startSnapshot = db.getSnapshotById(period.startSnapshotId!); + if (startSnapshot) { + // Handle double JSON encoding issue + let rawData = startSnapshot.raw_json; + if (typeof rawData === 'string' && rawData.startsWith('"')) { + rawData = JSON.parse(rawData); + } + startData = JSON.parse(rawData); + } + endData = await apiClient.getCurrentCosts(); + } else { + // Historical period: both start and end from snapshots + const startSnapshot = db.getSnapshotById(period.startSnapshotId!); + if (startSnapshot) { + // Handle double JSON encoding issue + let rawData = startSnapshot.raw_json; + if (typeof rawData === 'string' && rawData.startsWith('"')) { + rawData = JSON.parse(rawData); + } + startData = JSON.parse(rawData); + } + + const snapshots = db.getSnapshots(); + const endSnapshotIndex = snapshots.findIndex(s => s.created_at === period.endAt); + if (endSnapshotIndex !== -1 && snapshots[endSnapshotIndex]) { + // Handle double JSON encoding issue + let rawData = snapshots[endSnapshotIndex]!.raw_json; + if (typeof rawData === 'string' && rawData.startsWith('"')) { + rawData = JSON.parse(rawData); + } + endData = JSON.parse(rawData); + } + } + + const result = computePeriodDelta(startData, endData, meId); + + return { + period: { + ...period, + endAt: period.endAt || new Date().toISOString() + }, + totals: { + totalCost: result.totalCost, + userCount: result.users.filter(u => u.cost > 0).length + }, + ranking: result.users + }; + } + + async getUserDetail(periodIndex: number, meId: string): Promise { + const summary = await this.getPeriodSummary(periodIndex, meId); + const me = summary.ranking.find(u => u.id === meId); + + if (!me) { + throw new Error('User not found in this period (possibly deleted)'); + } + + return { + id: me.id, + name: me.name, + startCost: Number(me.rawStart?.usage?.total?.cost ?? 0), + endCost: Number(me.rawEnd?.usage?.total?.cost ?? 0), + deltaCost: me.cost, + raw: { + start: me.rawStart, + end: me.rawEnd + } + }; + } +} + +export const billingCalculator = new BillingCalculator(); \ No newline at end of file diff --git a/server/database.ts b/server/database.ts new file mode 100644 index 0000000..c56d901 --- /dev/null +++ b/server/database.ts @@ -0,0 +1,83 @@ +import { Database } from "bun:sqlite"; + +export interface BillingSnapshot { + id: number; + created_at: string; + timezone: string; + raw_json: string; +} + +export class DatabaseManager { + private db: Database; + + constructor(dbPath: string = "./app.db") { + this.db = new Database(dbPath); + this.initDatabase(); + } + + private initDatabase() { + this.db.run(` + CREATE TABLE IF NOT EXISTS billing_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai', + raw_json TEXT NOT NULL + ) + `); + this.db.run(` + CREATE INDEX IF NOT EXISTS idx_billing_snapshots_created_at ON billing_snapshots(created_at) + `); + } + + insertSnapshot(rawJson: string, timezone: string = 'Asia/Shanghai'): number { + const stmt = this.db.prepare(` + INSERT INTO billing_snapshots (created_at, timezone, raw_json) + VALUES (?, ?, ?) + `); + + const result = stmt.run( + new Date().toISOString(), + timezone, + rawJson + ); + + return result.lastInsertRowid as number; + } + + getSnapshots(): BillingSnapshot[] { + const stmt = this.db.prepare(` + SELECT id, created_at, timezone, raw_json + FROM billing_snapshots + ORDER BY datetime(created_at) ASC + `); + + return stmt.all() as BillingSnapshot[]; + } + + getSnapshotById(id: number): BillingSnapshot | null { + const stmt = this.db.prepare(` + SELECT id, created_at, timezone, raw_json + FROM billing_snapshots + WHERE id = ? + `); + + return stmt.get(id) as BillingSnapshot | null; + } + + getLatestSnapshot(): BillingSnapshot | null { + const stmt = this.db.prepare(` + SELECT id, created_at, timezone, raw_json + FROM billing_snapshots + ORDER BY datetime(created_at) DESC + LIMIT 1 + `); + + return stmt.get() as BillingSnapshot | null; + } + + close() { + this.db.close(); + } +} + +export const db = new DatabaseManager(process.env.DATABASE_URL || "./app.db"); \ No newline at end of file diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..56c03cb --- /dev/null +++ b/server/index.ts @@ -0,0 +1,168 @@ +import { apiClient } from './api-client'; +import { billingCalculator } from './billing-calculator'; +import indexHtml from '../client/index.html'; + +// Middleware to validate API key and get user ID +const validateApiKey = async (request: Request): Promise<{valid: boolean, userId?: string, error?: string}> => { + const apiKey = request.headers.get('x-api-key'); + + if (!apiKey) { + return { valid: false, error: 'API key required' }; + } + + try { + const userId = await apiClient.getKeyId(apiKey); + return { valid: true, userId }; + } catch (error) { + return { valid: false, error: 'Invalid API key' }; + } +}; + +const port = parseInt(process.env.PORT || '3000'); + +Bun.serve({ + port, + routes: { + '/': indexHtml, + + '/api/periods': { + async GET(req) { + const validation = await validateApiKey(req); + if (!validation.valid) { + return new Response(JSON.stringify({ error: validation.error }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + try { + const periods = await billingCalculator.getPeriods(); + return new Response(JSON.stringify({ periods }), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error getting periods:', error); + return new Response(JSON.stringify({ error: 'Failed to get periods' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + }, + + '/api/periods/:index/summary': { + async GET(req: Request) { + const validation = await validateApiKey(req); + if (!validation.valid) { + return new Response(JSON.stringify({ error: validation.error }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + try { + const url = new URL(req.url); + const periodIndex = parseInt(url.pathname.split('/')[3] || '0'); + + if (isNaN(periodIndex)) { + return new Response(JSON.stringify({ error: 'Invalid period index' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + const summary = await billingCalculator.getPeriodSummary(periodIndex, validation.userId); + return new Response(JSON.stringify(summary), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error getting period summary:', error); + if (error instanceof Error && error.message.includes('not found')) { + return new Response(JSON.stringify({ error: 'Period not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response(JSON.stringify({ error: 'Failed to get period summary' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + }, + + '/api/periods/:index/me': { + async GET(req) { + const validation = await validateApiKey(req); + if (!validation.valid) { + return new Response(JSON.stringify({ error: validation.error }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + try { + const url = new URL(req.url); + const periodIndex = parseInt(url.pathname.split('/')[3] || '0'); + + if (isNaN(periodIndex)) { + return new Response(JSON.stringify({ error: 'Invalid period index' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + const userDetail = await billingCalculator.getUserDetail(periodIndex, validation.userId!); + return new Response(JSON.stringify(userDetail), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error getting user detail:', error); + if (error instanceof Error && error.message.includes('not found')) { + return new Response(JSON.stringify({ error: 'User not found in this period (possibly deleted)' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response(JSON.stringify({ error: 'Failed to get user details' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + }, + + '/api/auth/test': { + async POST(req: Request) { + try { + const body = await req.json() as { apiKey?: string }; + const { apiKey } = body; + + if (!apiKey) { + return new Response(JSON.stringify({ error: 'API key required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + const userId = await apiClient.getKeyId(apiKey); + return new Response(JSON.stringify({ success: true, userId }), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error testing API key:', error); + return new Response(JSON.stringify({ error: 'Invalid API key' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + } + } + }, + // development: { + // hmr: true, + // console: true, + // } +}); + +console.log(`Server running on http://localhost:${port}`); \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..e2cee7d --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,74 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./client/**/*.{js,ts,jsx,tsx,html}", + ], + theme: { + extend: { + colors: { + background: "var(--color-background)", + foreground: "var(--color-foreground)", + card: { + DEFAULT: "var(--color-card)", + foreground: "var(--color-card-foreground)", + }, + popover: { + DEFAULT: "var(--color-popover)", + foreground: "var(--color-popover-foreground)", + }, + primary: { + DEFAULT: "var(--color-primary)", + foreground: "var(--color-primary-foreground)", + }, + secondary: { + DEFAULT: "var(--color-secondary)", + foreground: "var(--color-secondary-foreground)", + }, + muted: { + DEFAULT: "var(--color-muted)", + foreground: "var(--color-muted-foreground)", + }, + accent: { + DEFAULT: "var(--color-accent)", + foreground: "var(--color-accent-foreground)", + }, + destructive: { + DEFAULT: "var(--color-destructive)", + foreground: "var(--color-destructive-foreground)", + }, + border: "var(--color-border)", + input: "var(--color-input)", + ring: "var(--color-ring)", + chart: { + "1": "var(--color-chart-1)", + "2": "var(--color-chart-2)", + "3": "var(--color-chart-3)", + "4": "var(--color-chart-4)", + "5": "var(--color-chart-5)", + }, + }, + borderRadius: { + lg: "var(--radius-lg)", + md: "var(--radius-md)", + sm: "var(--radius-sm)", + xl: "var(--radius-xl)", + }, + fontFamily: { + sans: "var(--font-sans)", + serif: "var(--font-serif)", + mono: "var(--font-mono)", + }, + boxShadow: { + "2xs": "var(--shadow-2xs)", + xs: "var(--shadow-xs)", + sm: "var(--shadow-sm)", + DEFAULT: "var(--shadow)", + md: "var(--shadow-md)", + lg: "var(--shadow-lg)", + xl: "var(--shadow-xl)", + "2xl": "var(--shadow-2xl)", + } + } + }, + plugins: [], +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4dd09d5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ES2020", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "esModuleInterop": true, + "downlevelIteration": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}