monitor chart

This commit is contained in:
Randy Lu
2025-10-09 14:41:19 +08:00
parent 161c587c7c
commit 64c9726491
5 changed files with 734 additions and 27 deletions
+203
View File
@@ -0,0 +1,203 @@
# Claude Code Usage Monitor Implementation
## Overview
This document describes the implementation of the Claude Code usage monitoring feature that reads and analyzes usage data from JSONL files stored in `~/.claude/projects/**/*.jsonl`.
## Architecture
### Backend (Rust/Tauri)
#### Data Structures
```rust
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct UsageData {
pub input_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct ProjectUsageRecord {
pub uuid: String,
pub timestamp: String,
pub model: Option<String>,
pub usage: Option<UsageData>,
}
```
#### Core Function: `read_project_usage_files()`
1. **Directory Discovery**
- Base path: `~/.claude/projects/`
- Recursive search through all subdirectories
- Finds all files with `.jsonl` extension
2. **File Processing**
- Reads each JSONL file line by line
- Parses each line as JSON object
- Extracts required fields from each record
3. **Field Extraction Logic**
**UUID & Timestamp (Required)**
```rust
let uuid = json_value.get("uuid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let timestamp = json_value.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
```
**Model (Optional)**
```rust
let model = if let Some(model_str) = json_value.get("model")
.and_then(|v| v.as_str()) {
Some(model_str.to_string())
} else if let Some(message_obj) = json_value.get("message") {
message_obj.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
};
```
**Usage Data (Optional)**
```rust
let usage = if let Some(usage_obj) = json_value.get("usage") {
Some(UsageData {
input_tokens: usage_obj.get("input_tokens").and_then(|v| v.as_u64()),
cache_read_input_tokens: usage_obj.get("cache_read_input_tokens").and_then(|v| v.as_u64()),
output_tokens: usage_obj.get("output_tokens").and_then(|v| v.as_u64()),
})
} else if let Some(message_obj) = json_value.get("message") {
if let Some(usage_obj) = message_obj.get("usage") {
Some(UsageData {
input_tokens: usage_obj.get("input_tokens").and_then(|v| v.as_u64()),
cache_read_input_tokens: usage_obj.get("cache_read_input_tokens").and_then(|v| v.as_u64()),
output_tokens: usage_obj.get("output_tokens").and_then(|v| v.as_u64()),
})
} else {
None
}
} else {
None
};
```
4. **Filtering Logic**
- Records must have valid UUID and timestamp
- Records must have usage data (not None)
- Records must have meaningful token usage: `input_tokens + output_tokens > 0`
### Frontend (TypeScript/React)
#### TypeScript Interfaces
```typescript
export interface UsageData {
input_tokens?: number;
cache_read_input_tokens?: number;
output_tokens?: number;
}
export interface ProjectUsageRecord {
uuid: string;
timestamp: string;
model?: string;
usage?: UsageData;
}
```
#### React Query Hook
```typescript
export const useProjectUsageFiles = () => {
return useQuery({
queryKey: ["project-usage-files"],
queryFn: () => invoke<ProjectUsageRecord[]>("read_project_usage_files"),
});
};
```
#### Monitor Page Implementation
The Monitor page (`src/pages/MonitorPage.tsx`) displays aggregated token usage:
```typescript
<ul>
<li>Total Input Tokens: {usageData.reduce((sum, record) => sum + (record.usage?.input_tokens || 0), 0)}</li>
<li>Total Output Tokens: {usageData.reduce((sum, record) => sum + (record.usage?.output_tokens || 0), 0)}</li>
<li>Total Cache Read Input Tokens: {usageData.reduce((sum, record) => sum + (record.usage?.cache_read_input_tokens || 0), 0)}</li>
</ul>
```
## JSONL File Structure
The system processes JSONL files that contain various types of records. Usage data is typically found in records with the following structure:
```json
{
"parentUuid": "...",
"userType": "external",
"cwd": "...",
"sessionId": "...",
"version": "...",
"gitBranch": "...",
"message": {
"id": "...",
"type": "message",
"role": "assistant",
"model": "glm-4.6",
"content": [...],
"usage": {
"input_tokens": 3505,
"output_tokens": 60,
"cache_read_input_tokens": 9888
}
},
"type": "assistant",
"uuid": "...",
"timestamp": "2025-09-30T09:06:50.519Z"
}
```
## Processing Flow
1. **Directory Scan**: Recursively scan `~/.claude/projects/` for `.jsonl` files
2. **File Read**: Read each JSONL file completely
3. **Line Processing**: Process each line as a separate JSON object
4. **Field Extraction**: Extract UUID, timestamp, model, and usage fields
5. **Filtering**: Filter out records without valid usage data
6. **Aggregation**: Sum tokens across all valid records
7. **Display**: Show aggregated totals in the Monitor page
## Error Handling
- **Missing Directory**: Returns empty array if projects directory doesn't exist
- **Invalid JSON**: Skips malformed JSON lines with error logging
- **Missing Fields**: Gracefully handles missing optional fields (usage, model)
- **File Read Errors**: Returns error message if files can't be read
## Performance Considerations
- All JSONL files are read into memory during processing
- Large project directories may result in longer initial load times
- No pagination implemented - processes all available data at once
- Caching handled by React Query to avoid repeated reads
## Debug Features
The implementation includes debug logging that outputs:
- Projects directory path
- Number of files processed
- Number of lines processed
- Number of valid records found
This helps troubleshoot issues with data discovery and processing.
+2
View File
@@ -35,6 +35,7 @@
"chart.js": "^4.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.2.0",
"lodash-es": "^4.17.21",
@@ -42,6 +43,7 @@
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.64.0",
+42 -19
View File
@@ -16,7 +16,7 @@ importers:
version: 5.2.2(react-hook-form@7.64.0(react@19.2.0))
'@lobehub/icons':
specifier: ^2.38.0
version: 2.38.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 2.38.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -83,6 +83,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
i18next:
specifier: ^25.5.3
version: 25.5.3(typescript@5.8.3)
@@ -104,6 +107,9 @@ importers:
react:
specifier: ^19.1.0
version: 19.2.0
react-chartjs-2:
specifier: ^5.3.0
version: 5.3.0(chart.js@4.5.0)(react@19.2.0)
react-dom:
specifier: ^19.1.0
version: 19.2.0(react@19.2.0)
@@ -2198,6 +2204,9 @@ packages:
dagre-d3-es@7.0.11:
resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.18:
resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==}
@@ -3280,6 +3289,12 @@ packages:
react: ^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-chartjs-2@5.3.0:
resolution: {integrity: sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==}
peerDependencies:
chart.js: ^4.1.1
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-colorful@5.6.1:
resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
peerDependencies:
@@ -4469,12 +4484,12 @@ snapshots:
'@lobehub/emojilib@1.0.0': {}
'@lobehub/fluent-emoji@2.0.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
'@lobehub/fluent-emoji@2.0.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@lobehub/emojilib': 1.0.0
'@lobehub/ui': 2.13.2(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd-style: 3.7.1(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/ui': 2.13.2(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd-style: 3.7.1(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
emoji-regex: 10.5.0
lodash-es: 4.17.21
lucide-react: 0.469.0(react@19.2.0)
@@ -4491,11 +4506,11 @@ snapshots:
- micromark-util-types
- supports-color
'@lobehub/icons@2.38.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
'@lobehub/icons@2.38.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@lobehub/ui': 2.13.2(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd-style: 3.7.1(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/ui': 2.13.2(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd-style: 3.7.1(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
lucide-react: 0.469.0(react@19.2.0)
polished: 4.3.1
react: 19.2.0
@@ -4510,7 +4525,7 @@ snapshots:
- micromark-util-types
- supports-color
'@lobehub/ui@2.13.2(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
'@lobehub/ui@2.13.2(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@ant-design/cssinjs': 1.24.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -4521,16 +4536,16 @@ snapshots:
'@emoji-mart/react': 1.1.1(emoji-mart@5.6.0)(react@19.2.0)
'@floating-ui/react': 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@giscus/react': 3.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/fluent-emoji': 2.0.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/icons': 2.38.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/fluent-emoji': 2.0.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/icons': 2.38.0(@babel/core@7.28.4)(@types/mdast@4.0.4)(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@mdx-js/mdx': 3.1.1
'@mdx-js/react': 3.1.1(@types/react@19.2.0)(react@19.2.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.0)(react@19.2.0)
'@shikijs/transformers': 3.13.0
'@splinetool/runtime': 0.9.526
ahooks: 3.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd-style: 3.7.1(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd-style: 3.7.1(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
chroma-js: 3.1.2
class-variance-authority: 0.7.1
dayjs: 1.11.18
@@ -5805,7 +5820,7 @@ snapshots:
screenfull: 5.2.0
tslib: 2.8.1
antd-style@3.7.1(@types/react@19.2.0)(antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
antd-style@3.7.1(@types/react@19.2.0)(antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@ant-design/cssinjs': 1.24.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@babel/runtime': 7.28.4
@@ -5814,7 +5829,7 @@ snapshots:
'@emotion/react': 11.14.0(@types/react@19.2.0)(react@19.2.0)
'@emotion/serialize': 1.3.3
'@emotion/utils': 1.4.2
antd: 5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
use-merge-value: 1.2.0(react@19.2.0)
transitivePeerDependencies:
@@ -5822,7 +5837,7 @@ snapshots:
- react-dom
- supports-color
antd@5.27.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
antd@5.27.4(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@ant-design/colors': 7.2.1
'@ant-design/cssinjs': 1.24.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -5854,7 +5869,7 @@ snapshots:
rc-motion: 2.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rc-notification: 5.6.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rc-pagination: 5.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rc-picker: 4.11.3(dayjs@1.11.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rc-picker: 4.11.3(date-fns@4.1.0)(dayjs@1.11.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rc-progress: 4.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rc-rate: 2.13.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rc-resize-observer: 1.4.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -6222,6 +6237,8 @@ snapshots:
d3: 7.9.0
lodash-es: 4.17.21
date-fns@4.1.0: {}
dayjs@1.11.18: {}
debug@4.4.3:
@@ -7598,7 +7615,7 @@ snapshots:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
rc-picker@4.11.3(dayjs@1.11.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
rc-picker@4.11.3(date-fns@4.1.0)(dayjs@1.11.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
'@rc-component/trigger': 2.3.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -7609,6 +7626,7 @@ snapshots:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
date-fns: 4.1.0
dayjs: 1.11.18
rc-progress@4.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
@@ -7783,6 +7801,11 @@ snapshots:
- '@babel/core'
- supports-color
react-chartjs-2@5.3.0(chart.js@4.5.0)(react@19.2.0):
dependencies:
chart.js: 4.5.0
react: 19.2.0
react-colorful@5.6.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
+449
View File
@@ -0,0 +1,449 @@
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
ChartOptions,
} from "chart.js";
import { Line } from "react-chartjs-2";
import { format, startOfDay, startOfWeek, startOfMonth, subHours, subDays, getYear, getMonth, getDaysInMonth } from "date-fns";
import { ProjectUsageRecord } from "@/lib/query";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useState, useMemo, useEffect } from "react";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
interface TokenUsageChartProps {
data: ProjectUsageRecord[];
onFilteredDataChange?: (filteredData: ProjectUsageRecord[]) => void;
}
type TimeRange = "5h" | "1h" | "30min" | "today" | "7d" | "30d" | "month" | "week" | "all";
export function TokenUsageChart({ data, onFilteredDataChange }: TokenUsageChartProps) {
const [selectedModel, setSelectedModel] = useState<string>("all");
const [timeRange, setTimeRange] = useState<TimeRange>("5h");
// Get unique models from data
const availableModels = useMemo(() => {
const models = new Set<string>();
data.forEach((record) => {
if (record.model) {
models.add(record.model);
}
});
return Array.from(models).sort();
}, [data]);
// Filter data based on selected model and time range
const filteredData = useMemo(() => {
let filtered = data;
// Filter by model
if (selectedModel !== "all") {
filtered = filtered.filter((record) => record.model === selectedModel);
}
// Filter by time range
const now = new Date();
let startTime: Date;
switch (timeRange) {
case "30min":
startTime = subHours(now, 0.5);
break;
case "1h":
startTime = subHours(now, 1);
break;
case "5h":
startTime = subHours(now, 5);
break;
case "today":
startTime = startOfDay(now);
break;
case "7d":
startTime = subDays(now, 6);
break;
case "30d":
startTime = subDays(now, 29);
break;
case "week":
startTime = startOfWeek(now);
break;
case "month":
startTime = startOfMonth(now);
break;
case "all":
// For "all time", find the earliest timestamp in the data
if (filtered.length > 0) {
const earliestTime = new Date(
Math.min(...filtered.map((record) => new Date(record.timestamp).getTime()))
);
startTime = earliestTime;
} else {
startTime = new Date(0);
}
break;
default:
startTime = subHours(now, 5);
}
filtered = filtered.filter((record) => new Date(record.timestamp) >= startTime);
return filtered;
}, [data, selectedModel, timeRange]);
// Notify parent component when filtered data changes
useEffect(() => {
if (onFilteredDataChange) {
onFilteredDataChange(filteredData);
}
}, [filteredData, onFilteredDataChange]);
// Group data based on time range
const groupDataByInterval = (records: ProjectUsageRecord[]) => {
const intervals: { [key: string]: { input: number; output: number; cache: number } } = {};
const now = new Date();
if (timeRange === "all") {
// For all time, group by month
const earliestTime = records.length > 0
? new Date(Math.min(...records.map((record) => new Date(record.timestamp).getTime())))
: new Date();
const startOfEarliestMonth = new Date(getYear(earliestTime), getMonth(earliestTime), 1);
const startOfCurrentMonth = new Date(getYear(now), getMonth(now), 1);
// Generate monthly intervals from earliest month to current month
for (let monthTime = startOfEarliestMonth.getTime(); monthTime <= startOfCurrentMonth.getTime(); monthTime += getDaysInMonth(new Date(monthTime)) * 24 * 60 * 60 * 1000) {
intervals[monthTime] = { input: 0, output: 0, cache: 0 };
}
// Group records into monthly intervals
records.forEach((record) => {
const recordTime = new Date(record.timestamp);
const monthStart = new Date(getYear(recordTime), getMonth(recordTime), 1);
const monthKey = monthStart.getTime();
if (intervals[monthKey]) {
intervals[monthKey].input += record.usage?.input_tokens || 0;
intervals[monthKey].output += record.usage?.output_tokens || 0;
intervals[monthKey].cache += record.usage?.cache_read_input_tokens || 0;
}
});
} else if (timeRange === "30min" || timeRange === "1h" || timeRange === "5h") {
if (timeRange === "30min" || timeRange === "1h") {
// Group by 5-minute intervals for 30 minutes and 1 hour
const minutes = timeRange === "30min" ? 30 : 60;
const currentInterval = Math.floor(now.getMinutes() / 5) * 5;
now.setMinutes(currentInterval, 0, 0);
// Generate intervals
const intervalCount = Math.ceil(minutes / 5);
for (let i = intervalCount - 1; i >= 0; i--) {
const intervalTime = new Date(now.getTime() - i * 5 * 60 * 1000);
const intervalKey = intervalTime.getTime();
intervals[intervalKey] = { input: 0, output: 0, cache: 0 };
}
// Group records into 5-minute intervals
records.forEach((record) => {
const recordTime = new Date(record.timestamp);
const recordIntervalKey = Math.floor(recordTime.getTime() / (5 * 60 * 1000)) * (5 * 60 * 1000);
if (intervals[recordIntervalKey]) {
intervals[recordIntervalKey].input += record.usage?.input_tokens || 0;
intervals[recordIntervalKey].output += record.usage?.output_tokens || 0;
intervals[recordIntervalKey].cache += record.usage?.cache_read_input_tokens || 0;
}
});
} else {
// Group by 30-minute intervals for 5h time range
const currentInterval = Math.floor(now.getMinutes() / 30) * 30;
now.setMinutes(currentInterval, 0, 0);
// Generate intervals (10 intervals for 5 hours)
for (let i = 9; i >= 0; i--) {
const intervalTime = new Date(now.getTime() - i * 30 * 60 * 1000);
const intervalKey = intervalTime.getTime();
intervals[intervalKey] = { input: 0, output: 0, cache: 0 };
}
// Group records into 30-minute intervals
records.forEach((record) => {
const recordTime = new Date(record.timestamp);
const recordIntervalKey = Math.floor(recordTime.getTime() / (30 * 60 * 1000)) * (30 * 60 * 1000);
if (intervals[recordIntervalKey]) {
intervals[recordIntervalKey].input += record.usage?.input_tokens || 0;
intervals[recordIntervalKey].output += record.usage?.output_tokens || 0;
intervals[recordIntervalKey].cache += record.usage?.cache_read_input_tokens || 0;
}
});
}
} else if (timeRange === "today") {
// Group by hour for today
const startOfToday = startOfDay(now);
const currentHour = now.getHours();
for (let i = 0; i <= currentHour; i++) {
const hourTime = new Date(startOfToday.getTime() + i * 60 * 60 * 1000);
intervals[hourTime.getTime()] = { input: 0, output: 0, cache: 0 };
}
records.forEach((record) => {
const recordTime = new Date(record.timestamp);
const hourStart = new Date(recordTime);
hourStart.setMinutes(0, 0, 0);
const hourKey = hourStart.getTime();
if (intervals[hourKey]) {
intervals[hourKey].input += record.usage?.input_tokens || 0;
intervals[hourKey].output += record.usage?.output_tokens || 0;
intervals[hourKey].cache += record.usage?.cache_read_input_tokens || 0;
}
});
} else {
// Group by day for longer periods (7d, 30d, week, month)
const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : timeRange === "week" ? 7 : 30;
let startDate: Date;
if (timeRange === "week") {
startDate = startOfWeek(now);
} else if (timeRange === "month") {
startDate = startOfMonth(now);
} else {
// For 7d and 30d, start from (days-1) days ago to include today
startDate = subDays(now, days - 1);
}
for (let i = 0; i < days; i++) {
const dayTime = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000);
intervals[dayTime.getTime()] = { input: 0, output: 0, cache: 0 };
}
records.forEach((record) => {
const recordTime = new Date(record.timestamp);
const dayStart = new Date(recordTime);
dayStart.setHours(0, 0, 0, 0);
const dayKey = dayStart.getTime();
if (intervals[dayKey]) {
intervals[dayKey].input += record.usage?.input_tokens || 0;
intervals[dayKey].output += record.usage?.output_tokens || 0;
intervals[dayKey].cache += record.usage?.cache_read_input_tokens || 0;
}
});
}
return intervals;
};
const groupedData = groupDataByInterval(filteredData);
// Prepare chart data
const labels = Object.keys(groupedData)
.map(Number)
.sort((a, b) => a - b)
.map((timestamp) => {
const date = new Date(timestamp);
if (timeRange === "all") {
return format(date, "MMM yyyy");
} else if (timeRange === "today") {
return format(date, "HH:mm");
} else if (timeRange === "30min" || timeRange === "1h" || timeRange === "5h") {
return format(date, "HH:mm");
} else {
return format(date, "MMM dd");
}
});
const chartData = {
labels,
datasets: [
{
label: "Input Tokens",
data: Object.keys(groupedData)
.map(Number)
.sort((a, b) => a - b)
.map((key) => groupedData[key].input),
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
tension: 0.4,
},
{
label: "Output Tokens",
data: Object.keys(groupedData)
.map(Number)
.sort((a, b) => a - b)
.map((key) => groupedData[key].output),
borderColor: "rgb(16, 185, 129)",
backgroundColor: "rgba(16, 185, 129, 0.1)",
tension: 0.4,
},
{
label: "Cache Read Tokens",
data: Object.keys(groupedData)
.map(Number)
.sort((a, b) => a - b)
.map((key) => groupedData[key].cache),
borderColor: "rgb(245, 158, 11)",
backgroundColor: "rgba(245, 158, 11, 0.1)",
tension: 0.4,
hidden: true,
},
],
};
// Get chart title based on time range
const getChartTitle = () => {
const modelFilter = selectedModel !== "all" ? ` - Model: ${selectedModel}` : "";
switch (timeRange) {
case "30min":
return `Token Usage (Last 30 Minutes - 5 min intervals)${modelFilter}`;
case "1h":
return `Token Usage (Last Hour - 5 min intervals)${modelFilter}`;
case "5h":
return `Token Usage (Last 5 Hours)${modelFilter}`;
case "today":
return `Token Usage (Today)${modelFilter}`;
case "7d":
return `Token Usage (Last 7 Days)${modelFilter}`;
case "30d":
return `Token Usage (Last 30 Days)${modelFilter}`;
case "week":
return `Token Usage (This Week)${modelFilter}`;
case "month":
return `Token Usage (This Month)${modelFilter}`;
case "all":
return `Token Usage (All Time - Monthly)${modelFilter}`;
default:
return `Token Usage${modelFilter}`;
}
};
const options: ChartOptions<"line"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top" as const,
},
title: {
display: true,
text: getChartTitle(),
},
tooltip: {
mode: "index",
intersect: false,
callbacks: {
label: (context) => {
let label = context.dataset.label || "";
if (label) {
label += ": ";
}
if (context.parsed.y !== null) {
label += new Intl.NumberFormat().format(context.parsed.y);
}
return label;
},
},
},
},
scales: {
x: {
display: true,
title: {
display: true,
text: "Time",
},
},
y: {
display: true,
title: {
display: true,
text: "Tokens",
},
beginAtZero: true,
},
},
interaction: {
mode: "nearest",
axis: "x",
intersect: false,
},
};
if (!data || data.length === 0) {
return (
<div className="h-96 flex items-center justify-center border rounded-lg bg-muted/20">
<p className="text-muted-foreground">No usage data available</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Filter Controls */}
<div className="flex gap-4 items-center flex-wrap">
<div className="flex items-center gap-2">
<label htmlFor="model-filter" className="text-sm font-medium">
Model:
</label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger id="model-filter" className="w-48">
<SelectValue placeholder="All models" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All models</SelectItem>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<label htmlFor="time-range" className="text-sm font-medium">
Time Range:
</label>
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
<SelectTrigger id="time-range" className="w-48">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="30min">Last 30 minutes</SelectItem>
<SelectItem value="1h">Last 1 hour</SelectItem>
<SelectItem value="5h">Last 5 hours</SelectItem>
<SelectItem value="today">Start of today</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="week">Start of this week</SelectItem>
<SelectItem value="month">Start of this month</SelectItem>
<SelectItem value="all">All time</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Chart */}
<div className="h-96 w-full">
<Line data={chartData} options={options} />
</div>
</div>
);
}
+38 -8
View File
@@ -1,7 +1,18 @@
import { useProjectUsageFiles } from "@/lib/query";
import { TokenUsageChart } from "@/components/TokenUsageChart";
import { useState, useEffect } from "react";
import { ProjectUsageRecord } from "@/lib/query";
export function MonitorPage() {
const { data: usageData, isLoading, error } = useProjectUsageFiles();
const [filteredUsageData, setFilteredUsageData] = useState<ProjectUsageRecord[]>([]);
// Initialize filtered data with full data
useEffect(() => {
if (usageData) {
setFilteredUsageData(usageData);
}
}, [usageData]);
return (
<div className="">
@@ -10,19 +21,38 @@ export function MonitorPage() {
<h3 className="font-bold" data-tauri-drag-region>Monitor</h3>
</div>
</div>
<div className="px-4">
<div className="px-4 space-y-6">
{isLoading ? (
<p>Loading usage data...</p>
) : error ? (
<p>Error loading usage data: {error.message}</p>
) : usageData && usageData.length > 0 ? (
<div>
<ul>
<li>Total Input Tokens: {usageData.reduce((sum, record) => sum + (record.usage?.input_tokens || 0), 0)}</li>
<li>Total Output Tokens: {usageData.reduce((sum, record) => sum + (record.usage?.output_tokens || 0), 0)}</li>
<li>Total Cache Read Input Tokens: {usageData.reduce((sum, record) => sum + (record.usage?.cache_read_input_tokens || 0), 0)}</li>
</ul>
</div>
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 border rounded-lg bg-blue-50 dark:bg-blue-950/20">
<h3 className="font-semibold text-blue-800 dark:text-blue-200">Input Tokens</h3>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-300">
{new Intl.NumberFormat().format(filteredUsageData.reduce((sum, record) => sum + (record.usage?.input_tokens || 0), 0))}
</p>
</div>
<div className="p-4 border rounded-lg bg-green-50 dark:bg-green-950/20">
<h3 className="font-semibold text-green-800 dark:text-green-200">Output Tokens</h3>
<p className="text-2xl font-bold text-green-600 dark:text-green-300">
{new Intl.NumberFormat().format(filteredUsageData.reduce((sum, record) => sum + (record.usage?.output_tokens || 0), 0))}
</p>
</div>
<div className="p-4 border rounded-lg bg-amber-50 dark:bg-amber-950/20">
<h3 className="font-semibold text-amber-800 dark:text-amber-200">Cache Read Tokens</h3>
<p className="text-2xl font-bold text-amber-600 dark:text-amber-300">
{new Intl.NumberFormat().format(filteredUsageData.reduce((sum, record) => sum + (record.usage?.cache_read_input_tokens || 0), 0))}
</p>
</div>
</div>
<div className="mt-6">
<TokenUsageChart data={usageData} onFilteredDataChange={setFilteredUsageData} />
</div>
</>
) : (
<p>No usage data found.</p>
)}