@f5xc-salesdemos/xcsh-stats 14.0.3

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 ADDED
@@ -0,0 +1,82 @@
1
+ # @f5xc-salesdemos/xcsh-stats
2
+
3
+ Local observability dashboard for AI usage statistics.
4
+
5
+ ## Features
6
+
7
+ - **Session log parsing**: Reads JSONL session logs from `~/.xcsh/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
+ xcsh stats
30
+
31
+ # Custom port
32
+ xcsh stats --port 8080
33
+
34
+ # Print summary to console
35
+ xcsh stats --summary
36
+
37
+ # Output as JSON (for scripting)
38
+ xcsh stats --json
39
+ ```
40
+
41
+ ### Programmatic
42
+
43
+ ```typescript
44
+ import { getDashboardStats, syncAllSessions } from "@f5xc-salesdemos/xcsh-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**: `~/.xcsh/agent/sessions/` (JSONL files)
68
+ - **Stats database**: `~/.xcsh/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/build.ts ADDED
@@ -0,0 +1,84 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { compile } from "@tailwindcss/node";
4
+
5
+ /**
6
+ * Extract Tailwind class names from source files by scanning for className attributes.
7
+ */
8
+ async function extractTailwindClasses(dir: string): Promise<Set<string>> {
9
+ const classes = new Set<string>();
10
+ const classPattern = /className\s*=\s*["'`]([^"'`]+)["'`]/g;
11
+
12
+ async function scanDir(currentDir: string): Promise<void> {
13
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
14
+ for (const entry of entries) {
15
+ const fullPath = path.join(currentDir, entry.name);
16
+ if (entry.isDirectory()) {
17
+ await scanDir(fullPath);
18
+ } else if (entry.isFile() && /\.(tsx|ts|jsx|js)$/.test(entry.name)) {
19
+ const content = await Bun.file(fullPath).text();
20
+ const matches = content.matchAll(classPattern);
21
+ for (const match of matches) {
22
+ for (const cls of match[1].split(/\s+/)) {
23
+ if (cls) classes.add(cls);
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ await scanDir(dir);
31
+ return classes;
32
+ }
33
+
34
+ // Clean dist
35
+ await fs.rm("./dist/client", { recursive: true, force: true });
36
+
37
+ // Build Tailwind CSS
38
+ console.log("Building Tailwind CSS...");
39
+ const sourceCss = await Bun.file("./src/client/styles.css").text();
40
+ const candidates = await extractTailwindClasses("./src/client");
41
+ const baseDir = path.resolve("./src/client");
42
+
43
+ const compiler = await compile(sourceCss, {
44
+ base: baseDir,
45
+ onDependency: () => {},
46
+ });
47
+ const tailwindOutput = compiler.build([...candidates]);
48
+ await Bun.write("./dist/client/styles.css", tailwindOutput);
49
+
50
+ // Build React app
51
+ console.log("Building React app...");
52
+ const result = await Bun.build({
53
+ entrypoints: ["./src/client/index.tsx"],
54
+ outdir: "./dist/client",
55
+ minify: true,
56
+ naming: "[dir]/[name].[ext]",
57
+ });
58
+
59
+ if (!result.success) {
60
+ console.error("Build failed");
61
+ for (const message of result.logs) {
62
+ console.error(message);
63
+ }
64
+ process.exit(1);
65
+ }
66
+
67
+ // Create index.html
68
+ const indexHtml = `<!DOCTYPE html>
69
+ <html lang="en">
70
+ <head>
71
+ <meta charset="UTF-8">
72
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
73
+ <title>AI Usage Statistics</title>
74
+ <link rel="stylesheet" href="styles.css">
75
+ </head>
76
+ <body>
77
+ <div id="root"></div>
78
+ <script src="index.js" type="module"></script>
79
+ </body>
80
+ </html>`;
81
+
82
+ await Bun.write("./dist/client/index.html", indexHtml);
83
+
84
+ console.log("Build complete");
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@f5xc-salesdemos/xcsh-stats",
4
+ "version": "14.0.3",
5
+ "description": "Local observability dashboard for pi AI usage statistics",
6
+ "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
+ "author": "Can Boluk",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/f5xc-salesdemos/xcsh.git",
12
+ "directory": "packages/stats"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/f5xc-salesdemos/xcsh/issues"
16
+ },
17
+ "keywords": [
18
+ "ai",
19
+ "observability",
20
+ "metrics",
21
+ "dashboard",
22
+ "llm",
23
+ "statistics"
24
+ ],
25
+ "main": "./src/index.ts",
26
+ "types": "./src/index.ts",
27
+ "bin": {
28
+ "xcsh-stats": "./src/index.ts"
29
+ },
30
+ "scripts": {
31
+ "build": "bun run build.ts",
32
+ "dev": "bun run src/index.ts",
33
+ "check": "biome check . && bun run check:types",
34
+ "check:types": "tsgo -p tsconfig.json --noEmit && tsgo -p tsconfig.client.json --noEmit",
35
+ "lint": "biome lint .",
36
+ "fix": "biome check --write --unsafe .",
37
+ "fmt": "biome format --write ."
38
+ },
39
+ "dependencies": {
40
+ "@f5xc-salesdemos/pi-ai": "14.0.1",
41
+ "@f5xc-salesdemos/pi-utils": "14.0.1",
42
+ "@tailwindcss/node": "^4.2",
43
+ "chart.js": "^4.5",
44
+ "date-fns": "^4.1",
45
+ "lucide-react": "^0.576",
46
+ "react": "^19.2",
47
+ "react-chartjs-2": "^5.3",
48
+ "react-dom": "^19.2"
49
+ },
50
+ "devDependencies": {
51
+ "@types/bun": "^1.3",
52
+ "@types/react": "^19.2",
53
+ "@types/react-dom": "^19.2",
54
+ "postcss": "^8.5",
55
+ "tailwindcss": "^4.2"
56
+ },
57
+ "engines": {
58
+ "bun": ">=1.3.7"
59
+ },
60
+ "files": [
61
+ "src",
62
+ "build.ts",
63
+ "tailwind.config.js",
64
+ "README.md"
65
+ ],
66
+ "exports": {
67
+ ".": {
68
+ "types": "./src/index.ts",
69
+ "import": "./src/index.ts"
70
+ },
71
+ "./*": {
72
+ "types": "./src/*.ts",
73
+ "import": "./src/*.ts"
74
+ },
75
+ "./client": {
76
+ "types": "./src/client/index.tsx",
77
+ "import": "./src/client/index.tsx"
78
+ },
79
+ "./client/*": {
80
+ "types": "./src/client/*.ts",
81
+ "import": "./src/client/*.ts"
82
+ },
83
+ "./client/components/*": {
84
+ "types": "./src/client/components/*.ts",
85
+ "import": "./src/client/components/*.ts"
86
+ },
87
+ "./*.js": "./src/*.ts"
88
+ }
89
+ }
@@ -0,0 +1,129 @@
1
+ import * as fs from "node:fs";
2
+ import {
3
+ getRecentErrors as dbGetRecentErrors,
4
+ getRecentRequests as dbGetRecentRequests,
5
+ getFileOffset,
6
+ getMessageById,
7
+ getMessageCount,
8
+ getModelPerformanceSeries,
9
+ getModelTimeSeries,
10
+ getOverallStats,
11
+ getStatsByFolder,
12
+ getStatsByModel,
13
+ getTimeSeries,
14
+ initDb,
15
+ insertMessageStats,
16
+ setFileOffset,
17
+ } from "./db";
18
+ import { getSessionEntry, listAllSessionFiles, parseSessionFile } from "./parser";
19
+ import type { DashboardStats, MessageStats, RequestDetails } from "./types";
20
+
21
+ /**
22
+ * Sync a single session file to the database.
23
+ * Only processes new entries since the last sync.
24
+ */
25
+ async function syncSessionFile(sessionFile: string): Promise<number> {
26
+ // Get file stats
27
+ let fileStats: fs.Stats;
28
+ try {
29
+ fileStats = await fs.promises.stat(sessionFile);
30
+ } catch {
31
+ return 0;
32
+ }
33
+
34
+ const lastModified = fileStats.mtimeMs;
35
+
36
+ // Check if file has changed since last sync
37
+ const stored = getFileOffset(sessionFile);
38
+ if (stored && stored.lastModified >= lastModified) {
39
+ return 0; // File hasn't changed
40
+ }
41
+
42
+ // Parse file from last offset
43
+ const fromOffset = stored?.offset ?? 0;
44
+ const { stats, newOffset } = await parseSessionFile(sessionFile, fromOffset);
45
+
46
+ if (stats.length > 0) {
47
+ insertMessageStats(stats);
48
+ }
49
+
50
+ // Update offset tracker
51
+ setFileOffset(sessionFile, newOffset, lastModified);
52
+
53
+ return stats.length;
54
+ }
55
+
56
+ /**
57
+ * Sync all session files to the database.
58
+ * Returns the number of new entries processed.
59
+ */
60
+ export async function syncAllSessions(): Promise<{ processed: number; files: number }> {
61
+ await initDb();
62
+
63
+ const files = await listAllSessionFiles();
64
+ let totalProcessed = 0;
65
+ let filesProcessed = 0;
66
+
67
+ for (const file of files) {
68
+ const count = await syncSessionFile(file);
69
+ if (count > 0) {
70
+ totalProcessed += count;
71
+ filesProcessed++;
72
+ }
73
+ }
74
+
75
+ return { processed: totalProcessed, files: filesProcessed };
76
+ }
77
+
78
+ /**
79
+ * Get all dashboard stats.
80
+ */
81
+ export async function getDashboardStats(): Promise<DashboardStats> {
82
+ await initDb();
83
+
84
+ return {
85
+ overall: getOverallStats(),
86
+ byModel: getStatsByModel(),
87
+ byFolder: getStatsByFolder(),
88
+ timeSeries: getTimeSeries(24),
89
+ modelSeries: getModelTimeSeries(14),
90
+ modelPerformanceSeries: getModelPerformanceSeries(14),
91
+ };
92
+ }
93
+
94
+ export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
95
+ await initDb();
96
+ return dbGetRecentRequests(limit);
97
+ }
98
+
99
+ export async function getRecentErrors(limit?: number): Promise<MessageStats[]> {
100
+ await initDb();
101
+ return dbGetRecentErrors(limit);
102
+ }
103
+
104
+ export async function getRequestDetails(id: number): Promise<RequestDetails | null> {
105
+ await initDb();
106
+ const msg = getMessageById(id);
107
+ if (!msg) return null;
108
+
109
+ const entry = await getSessionEntry(msg.sessionFile, msg.entryId);
110
+ if (!entry || entry.type !== "message") return null;
111
+
112
+ // TODO: Get parent/context messages?
113
+ // For now we return the single entry which contains the assistant response.
114
+ // The user prompt is likely the parent.
115
+
116
+ return {
117
+ ...msg,
118
+ messages: [entry],
119
+ output: (entry as any).message,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Get the current message count in the database.
125
+ */
126
+ export async function getTotalMessageCount(): Promise<number> {
127
+ await initDb();
128
+ return getMessageCount();
129
+ }
@@ -0,0 +1,116 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { getRecentErrors, getRecentRequests, getStats, sync } from "./api";
3
+ import { ChartsContainer } from "./components/ChartsContainer";
4
+ import { Header } from "./components/Header";
5
+ import { ModelsTable } from "./components/ModelsTable";
6
+ import { RequestDetail } from "./components/RequestDetail";
7
+ import { RequestList } from "./components/RequestList";
8
+ import { StatsGrid } from "./components/StatsGrid";
9
+ import type { DashboardStats, MessageStats } from "./types";
10
+
11
+ type Tab = "overview" | "requests" | "errors" | "models";
12
+
13
+ export default function App() {
14
+ const [stats, setStats] = useState<DashboardStats | null>(null);
15
+ const [recentRequests, setRecentRequests] = useState<MessageStats[]>([]);
16
+ const [recentErrors, setRecentErrors] = useState<MessageStats[]>([]);
17
+ const [selectedRequest, setSelectedRequest] = useState<number | null>(null);
18
+ const [syncing, setSyncing] = useState(false);
19
+ const [activeTab, setActiveTab] = useState<Tab>("overview");
20
+
21
+ const loadData = useCallback(async () => {
22
+ try {
23
+ const [s, r, e] = await Promise.all([getStats(), getRecentRequests(50), getRecentErrors(50)]);
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
+ }, [loadData]);
47
+
48
+ if (!stats) {
49
+ return (
50
+ <div className="min-h-screen flex items-center justify-center">
51
+ <div className="flex items-center gap-3 text-[var(--text-muted)]">
52
+ <div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
53
+ <span className="text-sm">Loading analytics...</span>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <div className="min-h-screen">
61
+ <div className="max-w-[1600px] mx-auto px-6 py-6">
62
+ <Header activeTab={activeTab} onTabChange={setActiveTab} onSync={handleSync} syncing={syncing} />
63
+
64
+ {activeTab === "overview" && (
65
+ <div className="space-y-6 animate-fade-in">
66
+ <StatsGrid stats={stats.overall} />
67
+
68
+ <div className="grid lg:grid-cols-2 gap-6">
69
+ <RequestList
70
+ title="Recent Requests"
71
+ requests={recentRequests.slice(0, 10)}
72
+ onSelect={r => r.id && setSelectedRequest(r.id)}
73
+ />
74
+ <RequestList
75
+ title="Recent Errors"
76
+ requests={recentErrors.slice(0, 10)}
77
+ onSelect={r => r.id && setSelectedRequest(r.id)}
78
+ />
79
+ </div>
80
+ </div>
81
+ )}
82
+
83
+ {activeTab === "requests" && (
84
+ <div className="h-[calc(100vh-140px)] animate-fade-in">
85
+ <RequestList
86
+ title="All Recent Requests"
87
+ requests={recentRequests}
88
+ onSelect={r => r.id && setSelectedRequest(r.id)}
89
+ />
90
+ </div>
91
+ )}
92
+
93
+ {activeTab === "errors" && (
94
+ <div className="h-[calc(100vh-140px)] animate-fade-in">
95
+ <RequestList
96
+ title="Failed Requests"
97
+ requests={recentErrors}
98
+ onSelect={r => r.id && setSelectedRequest(r.id)}
99
+ />
100
+ </div>
101
+ )}
102
+
103
+ {activeTab === "models" && (
104
+ <div className="space-y-6 animate-fade-in">
105
+ <ChartsContainer modelSeries={stats.modelSeries} />
106
+ <ModelsTable models={stats.byModel} performanceSeries={stats.modelPerformanceSeries} />
107
+ </div>
108
+ )}
109
+
110
+ {selectedRequest !== null && (
111
+ <RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
112
+ )}
113
+ </div>
114
+ </div>
115
+ );
116
+ }
@@ -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
+ }