@oh-my-pi/omp-stats 6.9.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/package.json +66 -0
- package/src/aggregator.ts +125 -0
- package/src/client/App.tsx +168 -0
- package/src/client/api.ts +33 -0
- package/src/client/components/RequestDetail.tsx +88 -0
- package/src/client/components/RequestList.tsx +71 -0
- package/src/client/components/StatCard.tsx +30 -0
- package/src/client/index.tsx +5 -0
- package/src/client/types.ts +82 -0
- package/src/db.ts +396 -0
- package/src/index.ts +168 -0
- package/src/parser.ts +166 -0
- package/src/server.ts +147 -0
- package/src/types.ts +139 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @oh-my-pi/omp-stats
|
|
2
|
+
|
|
3
|
+
Local observability dashboard for AI usage statistics.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Session log parsing**: Reads JSONL session logs from `~/.omp/agent/sessions/`
|
|
8
|
+
- **SQLite aggregation**: Efficient stats storage and querying using `bun:sqlite`
|
|
9
|
+
- **Web dashboard**: Real-time metrics visualization with Chart.js
|
|
10
|
+
- **Incremental sync**: Only processes new/modified log entries
|
|
11
|
+
|
|
12
|
+
## Metrics Tracked
|
|
13
|
+
|
|
14
|
+
| Metric | Calculation |
|
|
15
|
+
|--------|-------------|
|
|
16
|
+
| Tokens/s | `output_tokens / (duration / 1000)` |
|
|
17
|
+
| Cache Rate | `cache_read / (input + cache_read) * 100` |
|
|
18
|
+
| Error Rate | `count(stopReason=error) / total_calls * 100` |
|
|
19
|
+
| Total Cost | Sum of `usage.cost.total` |
|
|
20
|
+
| Avg Latency | Mean of `duration` |
|
|
21
|
+
| TTFT | Mean of `ttft` (time to first token) |
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Via CLI
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Start dashboard server (default: http://localhost:3847)
|
|
29
|
+
omp stats
|
|
30
|
+
|
|
31
|
+
# Custom port
|
|
32
|
+
omp stats --port 8080
|
|
33
|
+
|
|
34
|
+
# Print summary to console
|
|
35
|
+
omp stats --summary
|
|
36
|
+
|
|
37
|
+
# Output as JSON (for scripting)
|
|
38
|
+
omp stats --json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Programmatic
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { getDashboardStats, syncAllSessions } from "@oh-my-pi/omp-stats";
|
|
45
|
+
|
|
46
|
+
// Sync session logs to database
|
|
47
|
+
const { processed, files } = await syncAllSessions();
|
|
48
|
+
|
|
49
|
+
// Get aggregated stats
|
|
50
|
+
const stats = await getDashboardStats();
|
|
51
|
+
console.log(stats.overall.totalCost);
|
|
52
|
+
console.log(stats.byModel[0].avgTokensPerSecond);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## API Endpoints
|
|
56
|
+
|
|
57
|
+
| Endpoint | Description |
|
|
58
|
+
|----------|-------------|
|
|
59
|
+
| `GET /api/stats` | Overall stats with all breakdowns |
|
|
60
|
+
| `GET /api/stats/models` | Per-model statistics |
|
|
61
|
+
| `GET /api/stats/folders` | Per-folder/project statistics |
|
|
62
|
+
| `GET /api/stats/timeseries` | Hourly time series data |
|
|
63
|
+
| `GET /api/sync` | Trigger sync and return counts |
|
|
64
|
+
|
|
65
|
+
## Data Storage
|
|
66
|
+
|
|
67
|
+
- **Session logs**: `~/.omp/agent/sessions/` (JSONL files)
|
|
68
|
+
- **Stats database**: `~/.omp/stats.db` (SQLite)
|
|
69
|
+
|
|
70
|
+
## Dashboard
|
|
71
|
+
|
|
72
|
+
The web dashboard provides:
|
|
73
|
+
|
|
74
|
+
- Overall metrics cards (requests, cost, cache rate, error rate, duration, tokens/s)
|
|
75
|
+
- Time series chart showing requests and errors over time
|
|
76
|
+
- Per-model breakdown table
|
|
77
|
+
- Per-folder breakdown table
|
|
78
|
+
- Auto-refresh every 30 seconds
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oh-my-pi/omp-stats",
|
|
3
|
+
"version": "6.9.69",
|
|
4
|
+
"description": "Local observability dashboard for pi AI usage statistics",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"omp-stats": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"import": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"./src/server": {
|
|
17
|
+
"types": "./src/server.ts",
|
|
18
|
+
"import": "./src/server.ts"
|
|
19
|
+
},
|
|
20
|
+
"./src/db": {
|
|
21
|
+
"types": "./src/db.ts",
|
|
22
|
+
"import": "./src/db.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"public",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"ai",
|
|
32
|
+
"observability",
|
|
33
|
+
"metrics",
|
|
34
|
+
"dashboard",
|
|
35
|
+
"llm",
|
|
36
|
+
"statistics"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "bun run src/index.ts",
|
|
40
|
+
"check": "tsgo --noEmit && tsgo --noEmit -p tsconfig.client.json",
|
|
41
|
+
"build": "bun run build.ts && tsgo -p tsconfig.build.json"
|
|
42
|
+
},
|
|
43
|
+
"author": "Can Bölük",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/can1357/oh-my-pi.git",
|
|
48
|
+
"directory": "packages/omp-stats"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@oh-my-pi/pi-ai": "6.9.69",
|
|
52
|
+
"date-fns": "^4.1.0",
|
|
53
|
+
"lucide-react": "^0.562.0",
|
|
54
|
+
"react": "^19.2.3",
|
|
55
|
+
"react-dom": "^19.2.3",
|
|
56
|
+
"recharts": "^3.6.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^24.3.0",
|
|
60
|
+
"@types/react": "^19.2.9",
|
|
61
|
+
"@types/react-dom": "^19.2.3"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"bun": ">=1.0.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
getRecentErrors as dbGetRecentErrors,
|
|
4
|
+
getRecentRequests as dbGetRecentRequests,
|
|
5
|
+
getFileOffset,
|
|
6
|
+
getMessageById,
|
|
7
|
+
getMessageCount,
|
|
8
|
+
getOverallStats,
|
|
9
|
+
getStatsByFolder,
|
|
10
|
+
getStatsByModel,
|
|
11
|
+
getTimeSeries,
|
|
12
|
+
initDb,
|
|
13
|
+
insertMessageStats,
|
|
14
|
+
setFileOffset,
|
|
15
|
+
} from "./db";
|
|
16
|
+
import { getSessionEntry, listAllSessionFiles, parseSessionFile } from "./parser";
|
|
17
|
+
import type { DashboardStats, MessageStats, RequestDetails } from "./types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sync a single session file to the database.
|
|
21
|
+
* Only processes new entries since the last sync.
|
|
22
|
+
*/
|
|
23
|
+
async function syncSessionFile(sessionFile: string): Promise<number> {
|
|
24
|
+
// Get file stats
|
|
25
|
+
let fileStats: Awaited<ReturnType<typeof stat>>;
|
|
26
|
+
try {
|
|
27
|
+
fileStats = await stat(sessionFile);
|
|
28
|
+
} catch {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const lastModified = fileStats.mtimeMs;
|
|
33
|
+
|
|
34
|
+
// Check if file has changed since last sync
|
|
35
|
+
const stored = getFileOffset(sessionFile);
|
|
36
|
+
if (stored && stored.lastModified >= lastModified) {
|
|
37
|
+
return 0; // File hasn't changed
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Parse file from last offset
|
|
41
|
+
const fromOffset = stored?.offset ?? 0;
|
|
42
|
+
const { stats, newOffset } = await parseSessionFile(sessionFile, fromOffset);
|
|
43
|
+
|
|
44
|
+
if (stats.length > 0) {
|
|
45
|
+
insertMessageStats(stats);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Update offset tracker
|
|
49
|
+
setFileOffset(sessionFile, newOffset, lastModified);
|
|
50
|
+
|
|
51
|
+
return stats.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sync all session files to the database.
|
|
56
|
+
* Returns the number of new entries processed.
|
|
57
|
+
*/
|
|
58
|
+
export async function syncAllSessions(): Promise<{ processed: number; files: number }> {
|
|
59
|
+
await initDb();
|
|
60
|
+
|
|
61
|
+
const files = await listAllSessionFiles();
|
|
62
|
+
let totalProcessed = 0;
|
|
63
|
+
let filesProcessed = 0;
|
|
64
|
+
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const count = await syncSessionFile(file);
|
|
67
|
+
if (count > 0) {
|
|
68
|
+
totalProcessed += count;
|
|
69
|
+
filesProcessed++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { processed: totalProcessed, files: filesProcessed };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get all dashboard stats.
|
|
78
|
+
*/
|
|
79
|
+
export async function getDashboardStats(): Promise<DashboardStats> {
|
|
80
|
+
await initDb();
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
overall: getOverallStats(),
|
|
84
|
+
byModel: getStatsByModel(),
|
|
85
|
+
byFolder: getStatsByFolder(),
|
|
86
|
+
timeSeries: getTimeSeries(24),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
|
|
91
|
+
await initDb();
|
|
92
|
+
return dbGetRecentRequests(limit);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function getRecentErrors(limit?: number): Promise<MessageStats[]> {
|
|
96
|
+
await initDb();
|
|
97
|
+
return dbGetRecentErrors(limit);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function getRequestDetails(id: number): Promise<RequestDetails | null> {
|
|
101
|
+
await initDb();
|
|
102
|
+
const msg = getMessageById(id);
|
|
103
|
+
if (!msg) return null;
|
|
104
|
+
|
|
105
|
+
const entry = await getSessionEntry(msg.sessionFile, msg.entryId);
|
|
106
|
+
if (!entry || entry.type !== "message") return null;
|
|
107
|
+
|
|
108
|
+
// TODO: Get parent/context messages?
|
|
109
|
+
// For now we return the single entry which contains the assistant response.
|
|
110
|
+
// The user prompt is likely the parent.
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
...msg,
|
|
114
|
+
messages: [entry],
|
|
115
|
+
output: (entry as any).message,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get the current message count in the database.
|
|
121
|
+
*/
|
|
122
|
+
export async function getTotalMessageCount(): Promise<number> {
|
|
123
|
+
await initDb();
|
|
124
|
+
return getMessageCount();
|
|
125
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Activity, AlertCircle, BarChart2, CheckCircle, Database, RefreshCw, Server } from "lucide-react";
|
|
3
|
+
import { getRecentErrors, getRecentRequests, getStats, sync } from "./api";
|
|
4
|
+
import { RequestDetail } from "./components/RequestDetail";
|
|
5
|
+
import { RequestList } from "./components/RequestList";
|
|
6
|
+
import { StatCard } from "./components/StatCard";
|
|
7
|
+
import type { DashboardStats, MessageStats } from "./types";
|
|
8
|
+
|
|
9
|
+
export default function App() {
|
|
10
|
+
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
11
|
+
const [recentRequests, setRecentRequests] = useState<MessageStats[]>([]);
|
|
12
|
+
const [recentErrors, setRecentErrors] = useState<MessageStats[]>([]);
|
|
13
|
+
const [selectedRequest, setSelectedRequest] = useState<number | null>(null);
|
|
14
|
+
const [syncing, setSyncing] = useState(false);
|
|
15
|
+
const [activeTab, setActiveTab] = useState<"overview" | "requests" | "errors">("overview");
|
|
16
|
+
|
|
17
|
+
const loadData = async () => {
|
|
18
|
+
try {
|
|
19
|
+
const [s, r, e] = await Promise.all([
|
|
20
|
+
getStats(),
|
|
21
|
+
getRecentRequests(50),
|
|
22
|
+
getRecentErrors(50)
|
|
23
|
+
]);
|
|
24
|
+
setStats(s);
|
|
25
|
+
setRecentRequests(r);
|
|
26
|
+
setRecentErrors(e);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(err);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const handleSync = async () => {
|
|
33
|
+
setSyncing(true);
|
|
34
|
+
try {
|
|
35
|
+
await sync();
|
|
36
|
+
await loadData();
|
|
37
|
+
} finally {
|
|
38
|
+
setSyncing(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
loadData();
|
|
44
|
+
const interval = setInterval(loadData, 30000);
|
|
45
|
+
return () => clearInterval(interval);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
if (!stats) return <div style={{ padding: 40, textAlign: "center" }}>Loading stats...</div>;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "20px" }}>
|
|
52
|
+
<header style={{
|
|
53
|
+
display: "flex", justifyContent: "space-between", alignItems: "center",
|
|
54
|
+
marginBottom: "30px", paddingBottom: "20px", borderBottom: "1px solid var(--border)"
|
|
55
|
+
}}>
|
|
56
|
+
<h1 style={{ margin: 0, fontSize: "1.5rem", display: "flex", alignItems: "center", gap: "10px" }}>
|
|
57
|
+
<Activity color="var(--accent)" />
|
|
58
|
+
AI Usage Statistics
|
|
59
|
+
</h1>
|
|
60
|
+
<div style={{ display: "flex", gap: "15px", alignItems: "center" }}>
|
|
61
|
+
<div style={{ display: "flex", background: "var(--bg-secondary)", borderRadius: "6px", padding: "4px" }}>
|
|
62
|
+
{(["overview", "requests", "errors"] as const).map(tab => (
|
|
63
|
+
<button
|
|
64
|
+
key={tab}
|
|
65
|
+
onClick={() => setActiveTab(tab)}
|
|
66
|
+
style={{
|
|
67
|
+
background: activeTab === tab ? "var(--bg-card)" : "transparent",
|
|
68
|
+
color: activeTab === tab ? "var(--text-primary)" : "var(--text-secondary)",
|
|
69
|
+
border: "none", padding: "6px 16px", borderRadius: "4px",
|
|
70
|
+
cursor: "pointer", textTransform: "capitalize", fontWeight: 500
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{tab}
|
|
74
|
+
</button>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
<button
|
|
78
|
+
onClick={handleSync}
|
|
79
|
+
disabled={syncing}
|
|
80
|
+
style={{
|
|
81
|
+
background: "var(--accent)", color: "white", border: "none",
|
|
82
|
+
padding: "8px 16px", borderRadius: "6px", cursor: "pointer",
|
|
83
|
+
display: "flex", alignItems: "center", gap: "8px", opacity: syncing ? 0.7 : 1
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<RefreshCw size={16} className={syncing ? "spin" : ""} />
|
|
87
|
+
{syncing ? "Syncing..." : "Sync"}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</header>
|
|
91
|
+
|
|
92
|
+
{activeTab === "overview" && (
|
|
93
|
+
<>
|
|
94
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "20px", marginBottom: "30px" }}>
|
|
95
|
+
<StatCard
|
|
96
|
+
title="Total Requests"
|
|
97
|
+
value={stats.overall.totalRequests.toLocaleString()}
|
|
98
|
+
detail={`${stats.overall.successfulRequests} success, ${stats.overall.failedRequests} errors`}
|
|
99
|
+
icon={<Server size={20} />}
|
|
100
|
+
/>
|
|
101
|
+
<StatCard
|
|
102
|
+
title="Total Cost"
|
|
103
|
+
value={`$${stats.overall.totalCost.toFixed(2)}`}
|
|
104
|
+
detail={stats.overall.totalRequests > 0 ? `$${(stats.overall.totalCost / stats.overall.totalRequests).toFixed(4)} avg/req` : '-'}
|
|
105
|
+
icon={<Activity size={20} />}
|
|
106
|
+
/>
|
|
107
|
+
<StatCard
|
|
108
|
+
title="Cache Rate"
|
|
109
|
+
value={`${(stats.overall.cacheRate * 100).toFixed(1)}%`}
|
|
110
|
+
detail={`${(stats.overall.totalCacheReadTokens / 1000).toFixed(1)}k cached tokens`}
|
|
111
|
+
icon={<Database size={20} />}
|
|
112
|
+
/>
|
|
113
|
+
<StatCard
|
|
114
|
+
title="Error Rate"
|
|
115
|
+
value={`${(stats.overall.errorRate * 100).toFixed(1)}%`}
|
|
116
|
+
detail={`${stats.overall.failedRequests} failed requests`}
|
|
117
|
+
icon={<AlertCircle size={20} />}
|
|
118
|
+
color="var(--error)"
|
|
119
|
+
/>
|
|
120
|
+
<StatCard
|
|
121
|
+
title="Tokens/Sec"
|
|
122
|
+
value={stats.overall.avgTokensPerSecond?.toFixed(1) ?? "-"}
|
|
123
|
+
detail={`${(stats.overall.totalInputTokens + stats.overall.totalOutputTokens).toLocaleString()} total tokens`}
|
|
124
|
+
icon={<BarChart2 size={20} />}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "20px", height: "400px" }}>
|
|
129
|
+
<RequestList
|
|
130
|
+
title="Recent Requests"
|
|
131
|
+
requests={recentRequests}
|
|
132
|
+
onSelect={(r) => r.id && setSelectedRequest(r.id)}
|
|
133
|
+
/>
|
|
134
|
+
<RequestList
|
|
135
|
+
title="Recent Errors"
|
|
136
|
+
requests={recentErrors}
|
|
137
|
+
onSelect={(r) => r.id && setSelectedRequest(r.id)}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{activeTab === "requests" && (
|
|
144
|
+
<div style={{ height: "calc(100vh - 150px)" }}>
|
|
145
|
+
<RequestList
|
|
146
|
+
title="All Recent Requests"
|
|
147
|
+
requests={recentRequests}
|
|
148
|
+
onSelect={(r) => r.id && setSelectedRequest(r.id)}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{activeTab === "errors" && (
|
|
154
|
+
<div style={{ height: "calc(100vh - 150px)" }}>
|
|
155
|
+
<RequestList
|
|
156
|
+
title="Failed Requests"
|
|
157
|
+
requests={recentErrors}
|
|
158
|
+
onSelect={(r) => r.id && setSelectedRequest(r.id)}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{selectedRequest !== null && (
|
|
164
|
+
<RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DashboardStats, MessageStats, RequestDetails } from "./types";
|
|
2
|
+
|
|
3
|
+
const API_BASE = "/api";
|
|
4
|
+
|
|
5
|
+
export async function getStats(): Promise<DashboardStats> {
|
|
6
|
+
const res = await fetch(`${API_BASE}/stats`);
|
|
7
|
+
if (!res.ok) throw new Error("Failed to fetch stats");
|
|
8
|
+
return res.json() as Promise<DashboardStats>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function getRecentRequests(limit = 50): Promise<MessageStats[]> {
|
|
12
|
+
const res = await fetch(`${API_BASE}/stats/recent?limit=${limit}`);
|
|
13
|
+
if (!res.ok) throw new Error("Failed to fetch recent requests");
|
|
14
|
+
return res.json() as Promise<MessageStats[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getRecentErrors(limit = 50): Promise<MessageStats[]> {
|
|
18
|
+
const res = await fetch(`${API_BASE}/stats/errors?limit=${limit}`);
|
|
19
|
+
if (!res.ok) throw new Error("Failed to fetch recent errors");
|
|
20
|
+
return res.json() as Promise<MessageStats[]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function getRequestDetails(id: number): Promise<RequestDetails> {
|
|
24
|
+
const res = await fetch(`${API_BASE}/request/${id}`);
|
|
25
|
+
if (!res.ok) throw new Error("Failed to fetch request details");
|
|
26
|
+
return res.json() as Promise<RequestDetails>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function sync(): Promise<any> {
|
|
30
|
+
const res = await fetch(`${API_BASE}/sync`);
|
|
31
|
+
if (!res.ok) throw new Error("Failed to sync");
|
|
32
|
+
return res.json();
|
|
33
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { getRequestDetails } from "../api";
|
|
3
|
+
import type { RequestDetails } from "../types";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface RequestDetailProps {
|
|
7
|
+
id: number;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function RequestDetail({ id, onClose }: RequestDetailProps) {
|
|
12
|
+
const [details, setDetails] = useState<RequestDetails | null>(null);
|
|
13
|
+
const [loading, setLoading] = useState(true);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
getRequestDetails(id).then(setDetails).catch(console.error).finally(() => setLoading(false));
|
|
17
|
+
}, [id]);
|
|
18
|
+
|
|
19
|
+
if (!details && loading) {
|
|
20
|
+
return (
|
|
21
|
+
<div style={{
|
|
22
|
+
position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)",
|
|
23
|
+
display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100
|
|
24
|
+
}}>
|
|
25
|
+
<div style={{ background: "var(--bg-secondary)", padding: "20px", borderRadius: "8px" }}>Loading...</div>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!details) return null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div style={{
|
|
34
|
+
position: "fixed", inset: 0, background: "rgba(0,0,0,0.8)",
|
|
35
|
+
display: "flex", justifyContent: "end", zIndex: 100
|
|
36
|
+
}} onClick={onClose}>
|
|
37
|
+
<div style={{
|
|
38
|
+
width: "800px", maxWidth: "100%", background: "var(--bg-primary)",
|
|
39
|
+
height: "100%", overflowY: "auto", borderLeft: "1px solid var(--border)",
|
|
40
|
+
padding: "30px"
|
|
41
|
+
}} onClick={e => e.stopPropagation()}>
|
|
42
|
+
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "20px" }}>
|
|
43
|
+
<h2 style={{ margin: 0 }}>Request Details</h2>
|
|
44
|
+
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-secondary)", cursor: "pointer" }}>
|
|
45
|
+
<X />
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "20px", marginBottom: "30px" }}>
|
|
50
|
+
<div>
|
|
51
|
+
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Model</div>
|
|
52
|
+
<div>{details.model} ({details.provider})</div>
|
|
53
|
+
</div>
|
|
54
|
+
<div>
|
|
55
|
+
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Cost</div>
|
|
56
|
+
<div>${details.usage.cost.total.toFixed(4)}</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div>
|
|
59
|
+
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Tokens</div>
|
|
60
|
+
<div>{details.usage.totalTokens} (In: {details.usage.input}, Out: {details.usage.output})</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div>
|
|
63
|
+
<div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>Duration</div>
|
|
64
|
+
<div>{details.duration ? `${(details.duration / 1000).toFixed(2)}s` : '-'}</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<h3 style={{ borderBottom: "1px solid var(--border)", paddingBottom: "10px", marginBottom: "15px" }}>Output</h3>
|
|
69
|
+
<pre style={{
|
|
70
|
+
background: "var(--bg-secondary)", padding: "20px", borderRadius: "8px",
|
|
71
|
+
whiteSpace: "pre-wrap", overflowX: "auto", fontSize: "0.9rem",
|
|
72
|
+
fontFamily: "monospace"
|
|
73
|
+
}}>
|
|
74
|
+
{JSON.stringify(details.output, null, 2)}
|
|
75
|
+
</pre>
|
|
76
|
+
|
|
77
|
+
<h3 style={{ borderBottom: "1px solid var(--border)", paddingBottom: "10px", marginBottom: "15px", marginTop: "30px" }}>Raw Metadata</h3>
|
|
78
|
+
<pre style={{
|
|
79
|
+
background: "var(--bg-secondary)", padding: "20px", borderRadius: "8px",
|
|
80
|
+
whiteSpace: "pre-wrap", overflowX: "auto", fontSize: "0.8rem",
|
|
81
|
+
fontFamily: "monospace", color: "var(--text-secondary)"
|
|
82
|
+
}}>
|
|
83
|
+
{JSON.stringify(details, null, 2)}
|
|
84
|
+
</pre>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { formatDistanceToNow } from "date-fns";
|
|
2
|
+
import type { MessageStats } from "../types";
|
|
3
|
+
|
|
4
|
+
interface RequestListProps {
|
|
5
|
+
requests: MessageStats[];
|
|
6
|
+
onSelect: (req: MessageStats) => void;
|
|
7
|
+
title: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function RequestList({ requests, onSelect, title }: RequestListProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div style={{
|
|
13
|
+
background: "var(--bg-secondary)",
|
|
14
|
+
borderRadius: "12px",
|
|
15
|
+
border: "1px solid var(--border)",
|
|
16
|
+
overflow: "hidden",
|
|
17
|
+
display: "flex",
|
|
18
|
+
flexDirection: "column",
|
|
19
|
+
height: "100%"
|
|
20
|
+
}}>
|
|
21
|
+
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
|
|
22
|
+
<h3 style={{ margin: 0, fontSize: "1rem" }}>{title}</h3>
|
|
23
|
+
</div>
|
|
24
|
+
<div style={{ overflowY: "auto", flex: 1 }}>
|
|
25
|
+
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.9rem" }}>
|
|
26
|
+
<thead style={{ background: "rgba(0,0,0,0.2)", position: "sticky", top: 0 }}>
|
|
27
|
+
<tr>
|
|
28
|
+
<th style={{ textAlign: "left", padding: "12px 20px", color: "var(--text-secondary)", fontWeight: 500 }}>Time</th>
|
|
29
|
+
<th style={{ textAlign: "left", padding: "12px 20px", color: "var(--text-secondary)", fontWeight: 500 }}>Model</th>
|
|
30
|
+
<th style={{ textAlign: "right", padding: "12px 20px", color: "var(--text-secondary)", fontWeight: 500 }}>Tokens</th>
|
|
31
|
+
<th style={{ textAlign: "right", padding: "12px 20px", color: "var(--text-secondary)", fontWeight: 500 }}>Cost</th>
|
|
32
|
+
<th style={{ textAlign: "right", padding: "12px 20px", color: "var(--text-secondary)", fontWeight: 500 }}>Duration</th>
|
|
33
|
+
</tr>
|
|
34
|
+
</thead>
|
|
35
|
+
<tbody>
|
|
36
|
+
{requests.map(req => (
|
|
37
|
+
<tr
|
|
38
|
+
key={`${req.sessionFile}-${req.entryId}`}
|
|
39
|
+
onClick={() => onSelect(req)}
|
|
40
|
+
style={{
|
|
41
|
+
cursor: "pointer",
|
|
42
|
+
borderBottom: "1px solid var(--border)",
|
|
43
|
+
transition: "background 0.1s"
|
|
44
|
+
}}
|
|
45
|
+
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(255,255,255,0.05)"}
|
|
46
|
+
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
|
|
47
|
+
>
|
|
48
|
+
<td style={{ padding: "12px 20px" }}>
|
|
49
|
+
{formatDistanceToNow(req.timestamp, { addSuffix: true })}
|
|
50
|
+
</td>
|
|
51
|
+
<td style={{ padding: "12px 20px" }}>
|
|
52
|
+
<div style={{ fontWeight: 500 }}>{req.model}</div>
|
|
53
|
+
<div style={{ fontSize: "0.8rem", color: "var(--text-secondary)" }}>{req.provider}</div>
|
|
54
|
+
</td>
|
|
55
|
+
<td style={{ padding: "12px 20px", textAlign: "right" }}>
|
|
56
|
+
{req.usage.totalTokens.toLocaleString()}
|
|
57
|
+
</td>
|
|
58
|
+
<td style={{ padding: "12px 20px", textAlign: "right" }}>
|
|
59
|
+
${req.usage.cost.total.toFixed(4)}
|
|
60
|
+
</td>
|
|
61
|
+
<td style={{ padding: "12px 20px", textAlign: "right" }}>
|
|
62
|
+
{req.duration ? `${(req.duration / 1000).toFixed(1)}s` : '-'}
|
|
63
|
+
</td>
|
|
64
|
+
</tr>
|
|
65
|
+
))}
|
|
66
|
+
</tbody>
|
|
67
|
+
</table>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface StatCardProps {
|
|
4
|
+
title: string;
|
|
5
|
+
value: string | number;
|
|
6
|
+
detail?: string;
|
|
7
|
+
icon?: ReactNode;
|
|
8
|
+
color?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function StatCard({ title, value, detail, icon, color = "#4ade80" }: StatCardProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div style={{
|
|
14
|
+
background: "var(--bg-secondary)",
|
|
15
|
+
padding: "20px",
|
|
16
|
+
borderRadius: "12px",
|
|
17
|
+
border: "1px solid var(--border)",
|
|
18
|
+
display: "flex",
|
|
19
|
+
flexDirection: "column",
|
|
20
|
+
gap: "8px"
|
|
21
|
+
}}>
|
|
22
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
23
|
+
<h3 style={{ margin: 0, fontSize: "0.9rem", color: "var(--text-secondary)", fontWeight: 500 }}>{title}</h3>
|
|
24
|
+
{icon && <div style={{ color }}>{icon}</div>}
|
|
25
|
+
</div>
|
|
26
|
+
<div style={{ fontSize: "1.8rem", fontWeight: 700 }}>{value}</div>
|
|
27
|
+
{detail && <div style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>{detail}</div>}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|