forked from mirrors/claude-code-usage-dashboard
init
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -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*
|
||||
111
CLAUDE.md
Normal file
111
CLAUDE.md
Normal file
@@ -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 <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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 <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
94
bun.lock
Normal file
94
bun.lock
Normal file
@@ -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=="],
|
||||
}
|
||||
}
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
79
client/App.tsx
Normal file
79
client/App.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
180
client/components/CurrentPeriod.tsx
Normal file
180
client/components/CurrentPeriod.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
client/components/Dashboard.tsx
Normal file
164
client/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
client/components/HistoricalPeriods.tsx
Normal file
227
client/components/HistoricalPeriods.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
client/components/LoginPage.tsx
Normal file
114
client/components/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
client/components/RankingTable.tsx
Normal file
163
client/components/RankingTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
client/components/UserDetailCard.tsx
Normal file
204
client/components/UserDetailCard.tsx
Normal 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
12
client/index.html
Normal 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
7
client/main.tsx
Normal 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
150
client/styles.css
Normal 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);
|
||||
}
|
||||
34
package.json
Normal file
34
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
39
scripts/begin-period.ts
Normal file
39
scripts/begin-period.ts
Normal file
@@ -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();
|
||||
154
server/api-client.ts
Normal file
154
server/api-client.ts
Normal file
@@ -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<void> {
|
||||
if (this.token && Date.now() + this.skewMs < this.expiresAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.login();
|
||||
}
|
||||
|
||||
private async login(): Promise<void> {
|
||||
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<ApiKeysResponse['data']> {
|
||||
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<string> {
|
||||
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();
|
||||
280
server/billing-calculator.ts
Normal file
280
server/billing-calculator.ts
Normal file
@@ -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<string, UserData> {
|
||||
const m = new Map<string, UserData>();
|
||||
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<string>([...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<PeriodInfo[]> {
|
||||
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<PeriodSummary> {
|
||||
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<UserDetail> {
|
||||
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();
|
||||
83
server/database.ts
Normal file
83
server/database.ts
Normal file
@@ -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");
|
||||
168
server/index.ts
Normal file
168
server/index.ts
Normal file
@@ -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}`);
|
||||
74
tailwind.config.js
Normal file
74
tailwind.config.js
Normal file
@@ -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: [],
|
||||
}
|
||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user