graphtrack 0.1.0 → 0.1.1
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 +77 -42
- package/dist/graph.d.ts +9 -0
- package/dist/graph.js +26 -22
- package/dist/graph.js.map +1 -1
- package/dist/parser.d.ts +7 -1
- package/dist/parser.js +104 -20
- package/dist/parser.js.map +1 -1
- package/dist/server.js +39 -2
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +26 -1
- package/package.json +6 -3
- package/public/index.html +194 -28
package/README.md
CHANGED
|
@@ -1,70 +1,105 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# Graphtrack
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/graphtrack)
|
|
6
|
+
[](https://github.com/nicholasgriffintn/graphtrack/blob/main/LICENSE)
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
prompts, tool calls, and their token weight rendered as nodes and edges. Node size
|
|
7
|
-
is token share, color is tool type, and edges trace how your tokens actually flowed.
|
|
8
|
-
Find out at a glance which tool is eating your budget, or trace a single request
|
|
9
|
-
from prompt to result.
|
|
8
|
+
**Interactive token-usage dashboard for Claude Code.**
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
Run `npx graphtrack`, open `localhost:9090`, and see exactly where your tokens go —
|
|
11
|
+
as a graph, a table, or a multi-session overview.
|
|
12
|
+
|
|
13
|
+
Your logs never leave your machine.
|
|
14
|
+
|
|
15
|
+
[Installation](#installation) · [Quick Start](#quick-start) · [Features](#features) · [CLI Options](#cli-options) · [Development](#development) · [License](#license)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# No install needed — runs directly from npm
|
|
25
|
+
npx graphtrack
|
|
14
26
|
|
|
15
|
-
|
|
27
|
+
# Or install globally
|
|
28
|
+
npm install -g graphtrack
|
|
29
|
+
graphtrack
|
|
30
|
+
```
|
|
16
31
|
|
|
17
|
-
|
|
18
|
-
served to your own browser at `localhost`. Nothing is uploaded, stored, or shared.
|
|
19
|
-
Your usage data never leaves your computer.
|
|
32
|
+
Requires [Node.js](https://nodejs.org/) 18+.
|
|
20
33
|
|
|
21
|
-
##
|
|
34
|
+
## Quick Start
|
|
22
35
|
|
|
23
36
|
```bash
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
npx graphtrack
|
|
38
|
+
# → Server starts at http://localhost:9090
|
|
39
|
+
# → Picks up sessions from ~/.claude/projects automatically
|
|
40
|
+
# → Pick a session from the dropdown and explore
|
|
28
41
|
```
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
`~/.claude/projects`), and explore the graph.
|
|
43
|
+
Point at a custom log directory:
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
```bash
|
|
46
|
+
npx graphtrack --dir /path/to/.claude/projects
|
|
47
|
+
```
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
the visualization without any setup:
|
|
49
|
+
Use a different port:
|
|
37
50
|
|
|
38
51
|
```bash
|
|
39
|
-
|
|
40
|
-
node scripts/build-spike.mjs
|
|
41
|
-
# open public/index.html in a browser
|
|
52
|
+
npx graphtrack --port 3000
|
|
42
53
|
```
|
|
43
54
|
|
|
44
|
-
##
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
### Four views, one dashboard
|
|
58
|
+
|
|
59
|
+
| View | What it shows |
|
|
60
|
+
| ----------------------- | ------------------------------------------------------------------------------------------- |
|
|
61
|
+
| **Overview** | Aggregate stats across all sessions — total tokens, per-project breakdown, tool usage share |
|
|
62
|
+
| **Aggregate Graph** | Hub-and-spoke network: each tool is a node, size = token share, color = tool type |
|
|
63
|
+
| **Chronological Graph** | Turn-by-turn chain: each prompt links to the tools it triggered, ordered in time |
|
|
64
|
+
| **Table** | Sortable per-tool and per-turn tables with color-coded token bars |
|
|
45
65
|
|
|
46
|
-
|
|
47
|
-
- **Phase 1** — JSONL parser + typed model + per-tool aggregation
|
|
48
|
-
- **Phase 2** — localhost web app: vis.js graph + table view
|
|
49
|
-
- **Phase 3** — multi-session overview, empty state, error handling
|
|
50
|
-
- **Phase 4** — install polish, docs, CI
|
|
66
|
+
### What gets tracked
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
- **Input / output / cache tokens** per session, per turn, and per tool call
|
|
69
|
+
- **Real timestamps** — session start time and wall-clock duration from the actual logs
|
|
70
|
+
- **Tool breakdown** — which tools consume the most tokens, how many times each was called
|
|
71
|
+
- **Project grouping** — sessions organized by project in the Overview view
|
|
72
|
+
|
|
73
|
+
### Tool color legend
|
|
74
|
+
|
|
75
|
+
| Tool | Color |
|
|
76
|
+
| ----- | ------------------------------------------- |
|
|
77
|
+
| Edit | <span style="color:#ff6b6b">●</span> Red |
|
|
78
|
+
| Bash | <span style="color:#faff69">●</span> Yellow |
|
|
79
|
+
| Read | <span style="color:#3b82f6">●</span> Blue |
|
|
80
|
+
| Write | <span style="color:#4cff4c">●</span> Green |
|
|
81
|
+
| Glob | <span style="color:#b06bff">●</span> Purple |
|
|
82
|
+
| Grep | <span style="color:#ff8cff">●</span> Pink |
|
|
83
|
+
| Agent | <span style="color:#ffb347">●</span> Orange |
|
|
84
|
+
|
|
85
|
+
## Project Structure
|
|
53
86
|
|
|
54
87
|
```
|
|
55
88
|
src/
|
|
56
|
-
types.ts
|
|
57
|
-
parser.ts
|
|
58
|
-
graph.ts
|
|
59
|
-
|
|
60
|
-
|
|
89
|
+
types.ts Data model (Session, Turn, ToolCall, Usage, Overview)
|
|
90
|
+
parser.ts JSONL → Session + aggregateSessions()
|
|
91
|
+
graph.ts Session → vis.js nodes/edges (aggregate + chronological)
|
|
92
|
+
server.ts Express API + static file serving
|
|
93
|
+
cli.ts CLI entry point (--dir, --port, --help)
|
|
94
|
+
|
|
61
95
|
public/
|
|
62
|
-
index.html
|
|
63
|
-
|
|
96
|
+
index.html Dashboard UI (4 views, vanilla JS)
|
|
97
|
+
|
|
64
98
|
test/
|
|
65
|
-
|
|
99
|
+
parser.test.ts Unit tests for parser, aggregation, and graph builders
|
|
100
|
+
fixtures/ Sample session data for tests
|
|
66
101
|
```
|
|
67
102
|
|
|
68
103
|
## License
|
|
69
104
|
|
|
70
|
-
MIT
|
|
105
|
+
MIT
|
package/dist/graph.d.ts
CHANGED
|
@@ -37,6 +37,15 @@ export interface VisEdge {
|
|
|
37
37
|
arrows?: string;
|
|
38
38
|
}
|
|
39
39
|
export declare function toolColor(name: string): string;
|
|
40
|
+
/** Hub-and-tool aggregate graph: one root connected to tool nodes sized by token share. */
|
|
41
|
+
export declare function buildToolHubGraph(tools: {
|
|
42
|
+
name: string;
|
|
43
|
+
calls: number;
|
|
44
|
+
totalTokens: number;
|
|
45
|
+
}[], rootLabel: string, rootTitle?: string): {
|
|
46
|
+
nodes: VisNode[];
|
|
47
|
+
edges: VisEdge[];
|
|
48
|
+
};
|
|
40
49
|
/** Hub-and-tool aggregate graph for one session. */
|
|
41
50
|
export declare function buildAggregateGraph(session: Session): {
|
|
42
51
|
nodes: VisNode[];
|
package/dist/graph.js
CHANGED
|
@@ -33,43 +33,47 @@ export function toolColor(name) {
|
|
|
33
33
|
function scale(value) {
|
|
34
34
|
return Math.sqrt(value) / 10;
|
|
35
35
|
}
|
|
36
|
-
/** Hub-and-tool aggregate graph
|
|
37
|
-
export function
|
|
36
|
+
/** Hub-and-tool aggregate graph: one root connected to tool nodes sized by token share. */
|
|
37
|
+
export function buildToolHubGraph(tools, rootLabel, rootTitle = "This session") {
|
|
38
38
|
const nodes = [];
|
|
39
39
|
const edges = [];
|
|
40
|
-
const rootId = "session";
|
|
41
40
|
nodes.push({
|
|
42
|
-
id:
|
|
43
|
-
label:
|
|
41
|
+
id: "session",
|
|
42
|
+
label: rootLabel,
|
|
44
43
|
value: 6,
|
|
45
44
|
color: { background: "#faff69", border: "#e6eb52" },
|
|
46
45
|
group: "session",
|
|
47
|
-
title:
|
|
46
|
+
title: rootTitle,
|
|
48
47
|
});
|
|
48
|
+
const sorted = [...tools].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
49
|
+
const maxTokens = Math.max(1, ...[...tools].map((v) => v.totalTokens));
|
|
50
|
+
for (const t of sorted) {
|
|
51
|
+
const id = `tool:${t.name}`;
|
|
52
|
+
nodes.push({
|
|
53
|
+
id,
|
|
54
|
+
label: `${t.name}\n${t.calls}× · ${fmt(t.totalTokens)}`,
|
|
55
|
+
value: 1 + 5 * (t.totalTokens / maxTokens),
|
|
56
|
+
color: { background: toolColor(t.name), border: "#ffffff" },
|
|
57
|
+
group: "tool",
|
|
58
|
+
title: `${t.name}: ${t.calls} calls, ${fmt(t.totalTokens)} tokens`,
|
|
59
|
+
});
|
|
60
|
+
edges.push({ from: "session", to: id, value: 2 + 4 * (t.totalTokens / maxTokens), title: `${t.name}: ${fmt(t.totalTokens)}` });
|
|
61
|
+
}
|
|
62
|
+
return { nodes, edges };
|
|
63
|
+
}
|
|
64
|
+
/** Hub-and-tool aggregate graph for one session. */
|
|
65
|
+
export function buildAggregateGraph(session) {
|
|
49
66
|
// Group tool calls across the whole session (reuse aggregation).
|
|
50
67
|
const byTool = new Map();
|
|
51
68
|
for (const turn of session.turns) {
|
|
52
69
|
for (const call of turn.toolCalls) {
|
|
53
|
-
const entry = byTool.get(call.name) ?? { calls: 0,
|
|
70
|
+
const entry = byTool.get(call.name) ?? { name: call.name, calls: 0, totalTokens: 0 };
|
|
54
71
|
entry.calls += 1;
|
|
55
|
-
entry.
|
|
72
|
+
entry.totalTokens += call.totalTokens || 1;
|
|
56
73
|
byTool.set(call.name, entry);
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
|
-
|
|
60
|
-
for (const [name, agg] of byTool) {
|
|
61
|
-
const id = `tool:${name}`;
|
|
62
|
-
nodes.push({
|
|
63
|
-
id,
|
|
64
|
-
label: `${name}\n${agg.calls}× · ${fmt(agg.tokens)}`,
|
|
65
|
-
value: 1 + 5 * (agg.tokens / maxTokens),
|
|
66
|
-
color: { background: toolColor(name), border: "#ffffff" },
|
|
67
|
-
group: "tool",
|
|
68
|
-
title: `${name}: ${agg.calls} calls, ${fmt(agg.tokens)} tokens`,
|
|
69
|
-
});
|
|
70
|
-
edges.push({ from: rootId, to: id, value: 2 + 4 * (agg.tokens / maxTokens), title: `${name}: ${fmt(agg.tokens)}` });
|
|
71
|
-
}
|
|
72
|
-
return { nodes, edges };
|
|
76
|
+
return buildToolHubGraph([...byTool.values()], `${session.project}\n${session.turns.length} turns · ${fmt(session.totalTokens)} tok`, "This session");
|
|
73
77
|
}
|
|
74
78
|
/** Turn-by-turn chronological graph: session → prompts → their tool calls. */
|
|
75
79
|
export function buildChronologicalGraph(session) {
|
package/dist/graph.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graph.js","sourceRoot":"","sources":["../src/graph.ts"],"names":[],"mappings":"AAaA;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,OAAgB;IAC3C,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACjC,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,IAAI,CAAC,WAAW,GAAG,CAAC,EAAE,CAAC;YAClD,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;YACvD,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;gBAClC,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YACvC,CAAC;QACH,CAAC;IACH,CAAC;AACH,CAAC;AAoBD,+DAA+D;AAC/D,MAAM,WAAW,GAA2B;IAC1C,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,SAAS,EAAE,SAAS;IACpB,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,SAAS;IAChB,OAAO,EAAE,SAAS;CACnB,CAAC;AAEF,MAAM,UAAU,SAAS,CAAC,IAAY;IACpC,OAAO,WAAW,CAAC,IAAI,CAAC,IAAI,WAAW,CAAC,OAAO,CAAC;AAClD,CAAC;AAED,2EAA2E;AAC3E,SAAS,KAAK,CAAC,KAAa;IAC1B,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;AAC/B,CAAC;AAED,
|
|
1
|
+
{"version":3,"file":"graph.js","sourceRoot":"","sources":["../src/graph.ts"],"names":[],"mappings":"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"}
|
package/dist/parser.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import type { Session, ToolAggregate } from "./types.js";
|
|
1
|
+
import type { Overview, Session, ToolAggregate } from "./types.js";
|
|
2
2
|
/** Extracts tool calls from a session's JSONL and groups them into turns. */
|
|
3
3
|
export declare function parseSession(filePath: string, project: string): Session;
|
|
4
4
|
/** Aggregate tool calls across a session (the collapse-by-tool model). */
|
|
5
5
|
export declare function aggregateTools(session: Session): ToolAggregate[];
|
|
6
|
+
/**
|
|
7
|
+
* Collapse many parsed sessions into a single overview: per-project rows,
|
|
8
|
+
* global totals, and tool aggregates across everything. Callers must run
|
|
9
|
+
* spreadTokens() on each session first so per-tool attribution is seeded.
|
|
10
|
+
*/
|
|
11
|
+
export declare function aggregateSessions(sessions: Session[]): Overview;
|
package/dist/parser.js
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
* unreadable file (callers decide how to surface partial failures).
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
|
-
/**
|
|
8
|
+
/** Normalize a message's content field to an array for the rest of the parser. */
|
|
9
|
+
function normalizeContent(content) {
|
|
10
|
+
if (!content)
|
|
11
|
+
return [];
|
|
12
|
+
if (typeof content === "string")
|
|
13
|
+
return [{ type: "text", text: content }];
|
|
14
|
+
return Array.isArray(content) ? content : [content];
|
|
15
|
+
}
|
|
16
|
+
/** Parse one line of JSONL into the message we care about + its timestamp. */
|
|
9
17
|
function parseLine(raw) {
|
|
10
18
|
const line = raw.trim();
|
|
11
19
|
if (!line)
|
|
@@ -17,19 +25,13 @@ function parseLine(raw) {
|
|
|
17
25
|
catch {
|
|
18
26
|
return null; // malformed line — skip, don't crash
|
|
19
27
|
}
|
|
20
|
-
|
|
28
|
+
if (!obj?.message)
|
|
29
|
+
return null;
|
|
30
|
+
return { msg: obj.message, timestamp: obj.timestamp };
|
|
21
31
|
}
|
|
22
|
-
function contentSummary(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return content
|
|
26
|
-
.map((b) => {
|
|
27
|
-
if (b.type === "text")
|
|
28
|
-
return b.text ?? "";
|
|
29
|
-
if (b.type === "tool_use")
|
|
30
|
-
return "";
|
|
31
|
-
return "";
|
|
32
|
-
})
|
|
32
|
+
function contentSummary(blocks) {
|
|
33
|
+
return blocks
|
|
34
|
+
.map((b) => (b.type === "text" && typeof b.text === "string" ? b.text : ""))
|
|
33
35
|
.filter(Boolean)
|
|
34
36
|
.join(" ");
|
|
35
37
|
}
|
|
@@ -50,6 +52,7 @@ export function parseSession(filePath, project) {
|
|
|
50
52
|
project,
|
|
51
53
|
model: "unknown",
|
|
52
54
|
startedAt: "",
|
|
55
|
+
durationMs: null,
|
|
53
56
|
turns: [],
|
|
54
57
|
totalTokens: 0,
|
|
55
58
|
totalInput: 0,
|
|
@@ -57,17 +60,33 @@ export function parseSession(filePath, project) {
|
|
|
57
60
|
totalCacheRead: 0,
|
|
58
61
|
totalCacheWrite: 0,
|
|
59
62
|
};
|
|
63
|
+
// Track the earliest/latest timestamp across all lines to get real timings.
|
|
64
|
+
let startTs = null;
|
|
65
|
+
let lastTs = null;
|
|
66
|
+
const noteTime = (ts) => {
|
|
67
|
+
if (!ts)
|
|
68
|
+
return;
|
|
69
|
+
const n = Date.parse(ts);
|
|
70
|
+
if (Number.isNaN(n))
|
|
71
|
+
return;
|
|
72
|
+
if (startTs === null || n < startTs)
|
|
73
|
+
startTs = n;
|
|
74
|
+
if (lastTs === null || n > lastTs)
|
|
75
|
+
lastTs = n;
|
|
76
|
+
};
|
|
60
77
|
// We walk the stream and slice it into turns. A turn starts at a user message
|
|
61
78
|
// that contains a text prompt (i.e. a real request, not a tool_result).
|
|
62
79
|
let current = null;
|
|
63
80
|
for (const line of lines) {
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
81
|
+
const parsed = parseLine(line);
|
|
82
|
+
if (!parsed)
|
|
66
83
|
continue;
|
|
67
|
-
const
|
|
84
|
+
const { msg, timestamp } = parsed;
|
|
85
|
+
noteTime(timestamp);
|
|
86
|
+
const content = normalizeContent(msg.content);
|
|
87
|
+
const text = contentSummary(content);
|
|
68
88
|
// Determine message kind
|
|
69
89
|
const hasText = Boolean(text.trim());
|
|
70
|
-
const content = msg.content ?? [];
|
|
71
90
|
// A new turn begins when the user speaks with text (not a tool_result).
|
|
72
91
|
if (msg.role === "user" && hasText && content.every((b) => b.type !== "tool_result")) {
|
|
73
92
|
if (current)
|
|
@@ -108,9 +127,11 @@ export function parseSession(filePath, project) {
|
|
|
108
127
|
}
|
|
109
128
|
if (current)
|
|
110
129
|
session.turns.push(current);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
if (startTs !== null) {
|
|
131
|
+
session.startedAt = new Date(startTs).toISOString();
|
|
132
|
+
if (lastTs !== null)
|
|
133
|
+
session.durationMs = lastTs - startTs;
|
|
134
|
+
}
|
|
114
135
|
session.totalTokens = session.turns.reduce((a, t) => a + t.totalTokens, 0);
|
|
115
136
|
return session;
|
|
116
137
|
}
|
|
@@ -142,4 +163,67 @@ export function aggregateTools(session) {
|
|
|
142
163
|
}
|
|
143
164
|
return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
144
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Collapse many parsed sessions into a single overview: per-project rows,
|
|
168
|
+
* global totals, and tool aggregates across everything. Callers must run
|
|
169
|
+
* spreadTokens() on each session first so per-tool attribution is seeded.
|
|
170
|
+
*/
|
|
171
|
+
export function aggregateSessions(sessions) {
|
|
172
|
+
const projectMap = new Map();
|
|
173
|
+
const toolMap = new Map();
|
|
174
|
+
const totals = {
|
|
175
|
+
sessions: sessions.length,
|
|
176
|
+
totalTokens: 0,
|
|
177
|
+
totalInput: 0,
|
|
178
|
+
totalOutput: 0,
|
|
179
|
+
totalCacheRead: 0,
|
|
180
|
+
totalCacheWrite: 0,
|
|
181
|
+
};
|
|
182
|
+
for (const s of sessions) {
|
|
183
|
+
totals.totalTokens += s.totalTokens;
|
|
184
|
+
totals.totalInput += s.totalInput;
|
|
185
|
+
totals.totalOutput += s.totalOutput;
|
|
186
|
+
totals.totalCacheRead += s.totalCacheRead;
|
|
187
|
+
totals.totalCacheWrite += s.totalCacheWrite;
|
|
188
|
+
const p = projectMap.get(s.project) ?? {
|
|
189
|
+
project: s.project,
|
|
190
|
+
sessions: 0,
|
|
191
|
+
totalTokens: 0,
|
|
192
|
+
totalInput: 0,
|
|
193
|
+
totalOutput: 0,
|
|
194
|
+
totalCacheRead: 0,
|
|
195
|
+
totalCacheWrite: 0,
|
|
196
|
+
};
|
|
197
|
+
p.sessions += 1;
|
|
198
|
+
p.totalTokens += s.totalTokens;
|
|
199
|
+
p.totalInput += s.totalInput;
|
|
200
|
+
p.totalOutput += s.totalOutput;
|
|
201
|
+
p.totalCacheRead += s.totalCacheRead;
|
|
202
|
+
p.totalCacheWrite += s.totalCacheWrite;
|
|
203
|
+
projectMap.set(s.project, p);
|
|
204
|
+
for (const agg of aggregateTools(s)) {
|
|
205
|
+
const t = toolMap.get(agg.name) ?? {
|
|
206
|
+
name: agg.name,
|
|
207
|
+
calls: 0,
|
|
208
|
+
totalTokens: 0,
|
|
209
|
+
inputTokens: 0,
|
|
210
|
+
outputTokens: 0,
|
|
211
|
+
cacheRead: 0,
|
|
212
|
+
cacheWrite: 0,
|
|
213
|
+
};
|
|
214
|
+
t.calls += agg.calls;
|
|
215
|
+
t.totalTokens += agg.totalTokens;
|
|
216
|
+
t.inputTokens += agg.inputTokens;
|
|
217
|
+
t.outputTokens += agg.outputTokens;
|
|
218
|
+
t.cacheRead += agg.cacheRead;
|
|
219
|
+
t.cacheWrite += agg.cacheWrite;
|
|
220
|
+
toolMap.set(agg.name, t);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
projects: [...projectMap.values()].sort((a, b) => b.totalTokens - a.totalTokens),
|
|
225
|
+
totals,
|
|
226
|
+
toolAggregates: [...toolMap.values()].sort((a, b) => b.totalTokens - a.totalTokens),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
145
229
|
//# sourceMappingURL=parser.js.map
|
package/dist/parser.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parser.js","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"parser.js","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"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"}
|
package/dist/server.js
CHANGED
|
@@ -14,8 +14,8 @@ import { readdirSync } from "node:fs";
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join, relative, dirname, basename } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { parseSession, aggregateTools } from "./parser.js";
|
|
18
|
-
import { buildAggregateGraph, buildChronologicalGraph, toolColor, spreadTokens } from "./graph.js";
|
|
17
|
+
import { parseSession, aggregateTools, aggregateSessions } from "./parser.js";
|
|
18
|
+
import { buildAggregateGraph, buildChronologicalGraph, buildToolHubGraph, toolColor, spreadTokens } from "./graph.js";
|
|
19
19
|
export const DEFAULT_LOGS_DIR = join(homedir(), ".claude", "projects");
|
|
20
20
|
const DEFAULT_PORT = 9090;
|
|
21
21
|
/**
|
|
@@ -87,6 +87,8 @@ export function startServer(opts = {}) {
|
|
|
87
87
|
id: session.id,
|
|
88
88
|
project: session.project,
|
|
89
89
|
model: session.model,
|
|
90
|
+
startedAt: session.startedAt,
|
|
91
|
+
durationMs: session.durationMs,
|
|
90
92
|
turns: session.turns.length,
|
|
91
93
|
toolCalls: session.turns.reduce((a, t) => a + t.toolCalls.length, 0),
|
|
92
94
|
totalTokens: session.totalTokens,
|
|
@@ -95,11 +97,46 @@ export function startServer(opts = {}) {
|
|
|
95
97
|
totalCacheRead: session.totalCacheRead,
|
|
96
98
|
totalCacheWrite: session.totalCacheWrite,
|
|
97
99
|
},
|
|
100
|
+
turns: session.turns.map((t) => ({
|
|
101
|
+
index: t.index,
|
|
102
|
+
prompt: t.prompt,
|
|
103
|
+
toolCalls: t.toolCalls.length,
|
|
104
|
+
totalTokens: t.totalTokens,
|
|
105
|
+
})),
|
|
98
106
|
aggregate: buildAggregateGraph(session),
|
|
99
107
|
chronological: buildChronologicalGraph(session),
|
|
100
108
|
toolAggregates: aggregates,
|
|
101
109
|
});
|
|
102
110
|
});
|
|
111
|
+
// Multi-session overview: per-project totals + tool share across everything.
|
|
112
|
+
// Parses every session file; fine for a local tool, cached later if ever slow.
|
|
113
|
+
app.get("/api/overview", (_req, res) => {
|
|
114
|
+
const files = findSessionFiles(logsDir);
|
|
115
|
+
const sessions = [];
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
try {
|
|
118
|
+
const s = parseSession(file, projectOf(logsDir, file));
|
|
119
|
+
spreadTokens(s);
|
|
120
|
+
sessions.push(s);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Skip unparseable files — a broken session shouldn't sink the overview.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const overview = aggregateSessions(sessions);
|
|
127
|
+
res.json({
|
|
128
|
+
totals: overview.totals,
|
|
129
|
+
projects: overview.projects,
|
|
130
|
+
toolAggregates: overview.toolAggregates.map((a) => ({
|
|
131
|
+
name: a.name,
|
|
132
|
+
calls: a.calls,
|
|
133
|
+
totalTokens: a.totalTokens,
|
|
134
|
+
color: toolColor(a.name),
|
|
135
|
+
})),
|
|
136
|
+
graph: buildToolHubGraph(overview.toolAggregates.map((a) => ({ name: a.name, calls: a.calls, totalTokens: a.totalTokens })), `All sessions\n${overview.totals.sessions} sessions · ${overview.totals.totalTokens} tok`, "Every Claude Code session Graphtrack can see"),
|
|
137
|
+
logsDir,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
103
140
|
// Static app (resolved relative to the installed package, not the cwd).
|
|
104
141
|
app.use(express.static(PUBLIC_DIR));
|
|
105
142
|
return app.listen(port, () => {
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,OAAO,MAAM,SAAS,CAAC;AAC9B,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAC9D,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -38,8 +38,10 @@ export interface Session {
|
|
|
38
38
|
/** Project slug directory the session lives under. */
|
|
39
39
|
project: string;
|
|
40
40
|
model: string;
|
|
41
|
-
/** ISO timestamp of first activity. */
|
|
41
|
+
/** ISO timestamp of first activity ("" if the log carries no timestamps). */
|
|
42
42
|
startedAt: string;
|
|
43
|
+
/** Wall-clock duration from first to last timestamped line (null if unknown). */
|
|
44
|
+
durationMs: number | null;
|
|
43
45
|
turns: Turn[];
|
|
44
46
|
totalTokens: number;
|
|
45
47
|
totalInput: number;
|
|
@@ -60,3 +62,26 @@ export interface ToolAggregate {
|
|
|
60
62
|
cacheRead: number;
|
|
61
63
|
cacheWrite: number;
|
|
62
64
|
}
|
|
65
|
+
/** Totals for one project across all of its sessions. */
|
|
66
|
+
export interface ProjectOverview {
|
|
67
|
+
project: string;
|
|
68
|
+
sessions: number;
|
|
69
|
+
totalTokens: number;
|
|
70
|
+
totalInput: number;
|
|
71
|
+
totalOutput: number;
|
|
72
|
+
totalCacheRead: number;
|
|
73
|
+
totalCacheWrite: number;
|
|
74
|
+
}
|
|
75
|
+
/** The multi-session overview: per-project rows + global totals + tool share. */
|
|
76
|
+
export interface Overview {
|
|
77
|
+
projects: ProjectOverview[];
|
|
78
|
+
totals: {
|
|
79
|
+
sessions: number;
|
|
80
|
+
totalTokens: number;
|
|
81
|
+
totalInput: number;
|
|
82
|
+
totalOutput: number;
|
|
83
|
+
totalCacheRead: number;
|
|
84
|
+
totalCacheWrite: number;
|
|
85
|
+
};
|
|
86
|
+
toolAggregates: ToolAggregate[];
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphtrack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Visualize Claude Code token usage as an interactive graph. Local-first, open source, your logs never leave your machine.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
"prepublishOnly": "npm run build",
|
|
16
16
|
"pack:check": "npm pack --dry-run"
|
|
17
17
|
},
|
|
18
|
-
"files": [
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"public/index.html"
|
|
21
|
+
],
|
|
19
22
|
"keywords": [
|
|
20
23
|
"claude-code",
|
|
21
24
|
"token",
|
|
@@ -32,4 +35,4 @@
|
|
|
32
35
|
"@types/node": "^22.5.0",
|
|
33
36
|
"typescript": "^5.5.0"
|
|
34
37
|
}
|
|
35
|
-
}
|
|
38
|
+
}
|
package/public/index.html
CHANGED
|
@@ -55,10 +55,34 @@
|
|
|
55
55
|
#graph .empty h2 { color:#fff; margin:0; font-size:20px; }
|
|
56
56
|
#graph .empty p { color:var(--muted); margin:0; font-size:13px; max-width:460px; line-height:1.5; }
|
|
57
57
|
#graph .empty code { background:var(--card); padding:2px 6px; border-radius:4px; color:var(--yellow); }
|
|
58
|
-
#spinner { display:none; position:
|
|
58
|
+
#spinner { display:none; position:fixed; inset:0; z-index:1000; align-items:center; justify-content:center; }
|
|
59
59
|
#spinner.on { display:flex; }
|
|
60
60
|
#spinner span { color:var(--yellow); border:2px solid #3a3a3a; border-top-color:var(--yellow); border-radius:50%; width:34px; height:34px; animation:spin 0.8s linear infinite; }
|
|
61
61
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
62
|
+
#btnOverview {
|
|
63
|
+
background:var(--card); color:var(--body); border:1px solid #3a3a3a;
|
|
64
|
+
padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px;
|
|
65
|
+
}
|
|
66
|
+
#btnOverview:hover { border-color:#5a5a5a; }
|
|
67
|
+
#btnOverview.active { background:var(--yellow); color:#0a0a0a; border-color:var(--yellow); font-weight:600; }
|
|
68
|
+
.tables { height:100%; overflow:auto; padding:20px 28px 40px; }
|
|
69
|
+
.tbl-sec { margin-bottom:28px; max-width:900px; }
|
|
70
|
+
.tables h3 { color:#fff; margin:0 0 10px; font-size:14px; letter-spacing:1px; text-transform:uppercase; }
|
|
71
|
+
.tables table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
72
|
+
.tables th, .ov-list th { text-align:left; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.5px; font-weight:600; padding:6px 12px; border-bottom:1px solid #2a2a2a; }
|
|
73
|
+
.tables td, .ov-list td { padding:7px 12px; border-bottom:1px solid #242424; color:var(--body); }
|
|
74
|
+
.tables th.num, .tables td.num, .ov-list th.num, .ov-list td.num { text-align:right; font-variant-numeric:tabular-nums; }
|
|
75
|
+
.swatch { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:8px; vertical-align:middle; }
|
|
76
|
+
.bar { display:inline-block; height:8px; border-radius:4px; min-width:2px; vertical-align:middle; }
|
|
77
|
+
.tables .muted, .ov-list .muted { color:var(--muted); }
|
|
78
|
+
.ov-wrap { height:100%; display:flex; }
|
|
79
|
+
.ov-graph { flex:1 1 auto; min-width:0; position:relative; }
|
|
80
|
+
.ov-list { flex:0 0 420px; overflow:auto; padding:20px 24px; border-left:1px solid #2a2a2a; }
|
|
81
|
+
.ov-list h3 { color:#fff; margin:0 0 12px; font-size:14px; letter-spacing:1px; text-transform:uppercase; }
|
|
82
|
+
.ov-list .hint { color:var(--muted); font-size:12px; margin:12px 0 0; }
|
|
83
|
+
.project-row { cursor:pointer; }
|
|
84
|
+
.project-row:hover td { background:#121212; }
|
|
85
|
+
.proj-name { color:#fff; font-family:ui-monospace, monospace; font-size:12px; }
|
|
62
86
|
#glow {
|
|
63
87
|
position: fixed; inset: 0; pointer-events: none; z-index: 9999;
|
|
64
88
|
background: radial-gradient(circle 200px at var(--gx, -9999px) var(--gy, -9999px),
|
|
@@ -72,13 +96,17 @@
|
|
|
72
96
|
<header>
|
|
73
97
|
<h1><span class="dot">▪</span>Graphtrack</h1>
|
|
74
98
|
<span class="sub">Claude Code token usage, as a graph</span>
|
|
99
|
+
<button id="btnOverview" title="All sessions at a glance">Overview</button>
|
|
75
100
|
<select id="sessionSelect" disabled title="Select a session"><option>Loading sessions…</option></select>
|
|
76
101
|
<div class="toggle">
|
|
77
102
|
<button id="btnAgg" class="active">Aggregate</button>
|
|
78
103
|
<button id="btnChain">Chronological</button>
|
|
104
|
+
<button id="btnTable">Table</button>
|
|
79
105
|
</div>
|
|
80
106
|
</header>
|
|
81
107
|
<div id="stats">
|
|
108
|
+
<div class="stat"><span class="label">Started</span><span class="value" id="sStarted">–</span></div>
|
|
109
|
+
<div class="stat"><span class="label">Duration</span><span class="value" id="sDur">–</span></div>
|
|
82
110
|
<div class="stat"><span class="label">Model</span><span class="value" id="sModel">–</span></div>
|
|
83
111
|
<div class="stat"><span class="label">Turns</span><span class="value" id="sTurns">–</span></div>
|
|
84
112
|
<div class="stat"><span class="label">Tool calls</span><span class="value" id="sCalls">–</span></div>
|
|
@@ -88,17 +116,31 @@
|
|
|
88
116
|
<div class="stat"><span class="label">Cache read</span><span class="value" id="sRead">–</span></div>
|
|
89
117
|
<div class="stat"><span class="label">Cache write</span><span class="value" id="sWrite">–</span></div>
|
|
90
118
|
</div>
|
|
91
|
-
<div id="graph"
|
|
119
|
+
<div id="graph"></div>
|
|
120
|
+
<div id="spinner"><span></span></div>
|
|
92
121
|
<div id="glow"></div>
|
|
93
122
|
<script>
|
|
94
123
|
const $ = (id) => document.getElementById(id);
|
|
95
124
|
const fmt = (n) => n == null ? "–" : n >= 1e6 ? (n/1e6).toFixed(1)+"M" : n >= 1e3 ? (n/1e3).toFixed(1)+"k" : n;
|
|
125
|
+
const fmtTime = (iso) => iso ? new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "–";
|
|
126
|
+
const fmtDur = (ms) => {
|
|
127
|
+
if (ms == null) return "–";
|
|
128
|
+
const s = Math.round(ms / 1000);
|
|
129
|
+
if (s < 60) return s + "s";
|
|
130
|
+
const m = Math.floor(s / 60), r = s % 60;
|
|
131
|
+
return m < 60 ? m + "m " + r + "s" : Math.floor(m / 60) + "h " + (m % 60) + "m";
|
|
132
|
+
};
|
|
133
|
+
function escapeHtml(s) {
|
|
134
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
135
|
+
}
|
|
136
|
+
function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
|
|
96
137
|
|
|
97
138
|
const sessionSelect = $("sessionSelect");
|
|
98
139
|
const spinner = $("spinner");
|
|
99
140
|
const container = $("graph");
|
|
100
|
-
let current = null; // { session, aggregate, chronological, toolAggregates }
|
|
101
|
-
let
|
|
141
|
+
let current = null; // { session, turns, aggregate, chronological, toolAggregates }
|
|
142
|
+
let overview = null; // { totals, projects, toolAggregates, graph }
|
|
143
|
+
let view = "overview";
|
|
102
144
|
let network = null;
|
|
103
145
|
|
|
104
146
|
const glow = document.getElementById("glow");
|
|
@@ -117,23 +159,19 @@
|
|
|
117
159
|
edges: { color: { color: "#3a3a3a", highlight: "#faff69" }, smooth: { enabled: true, type: "dynamic" } },
|
|
118
160
|
};
|
|
119
161
|
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (network) network.destroy();
|
|
124
|
-
network = new vis.Network(container, { nodes: new vis.DataSet(data.nodes), edges: new vis.DataSet(data.edges) }, options);
|
|
162
|
+
function showSpinner(on) { spinner.classList.toggle("on", on); }
|
|
163
|
+
function setViewActive(active) {
|
|
164
|
+
["btnAgg", "btnChain", "btnTable", "btnOverview"].forEach((id) => $(id).classList.toggle("active", id === active));
|
|
125
165
|
}
|
|
126
|
-
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
network =
|
|
130
|
-
container.innerHTML = '<div class="empty">' + (title ? "<h2></h2><p></p>" : "") + '</div><div id="spinner"><span></span></div>';
|
|
131
|
-
if (title) { container.querySelector("h2").textContent = title; container.querySelector("p").textContent = body; }
|
|
132
|
-
// reattach glow/spinner refs are fixed; spinner is a child now
|
|
133
|
-
spinner.classList.remove("on");
|
|
166
|
+
function unmountGraph() { if (network) { network.destroy(); network = null; } }
|
|
167
|
+
function mountGraph(data, el) {
|
|
168
|
+
unmountGraph();
|
|
169
|
+
network = new vis.Network(el || container, { nodes: new vis.DataSet(data.nodes), edges: new vis.DataSet(data.edges) }, options);
|
|
134
170
|
}
|
|
135
171
|
|
|
136
172
|
function fillStats(s) {
|
|
173
|
+
$("sStarted").textContent = fmtTime(s.startedAt);
|
|
174
|
+
$("sDur").textContent = fmtDur(s.durationMs);
|
|
137
175
|
$("sModel").textContent = s.model;
|
|
138
176
|
$("sTurns").textContent = s.turns;
|
|
139
177
|
$("sCalls").textContent = s.toolCalls;
|
|
@@ -144,27 +182,152 @@
|
|
|
144
182
|
$("sWrite").textContent = fmt(s.totalCacheWrite);
|
|
145
183
|
}
|
|
146
184
|
|
|
185
|
+
function showEmpty(title, body) {
|
|
186
|
+
unmountGraph();
|
|
187
|
+
container.innerHTML = '<div class="empty">' + (title ? "<h2></h2><p></p>" : "") + '</div>';
|
|
188
|
+
if (title) { container.querySelector("h2").textContent = title; container.querySelector("p").textContent = body; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- session views (aggregate / chronological / table) ----
|
|
192
|
+
|
|
193
|
+
function render() {
|
|
194
|
+
if (!current) { showEmpty("No session selected", "Pick a session from the dropdown, or open Overview for all sessions."); return; }
|
|
195
|
+
if (view === "table") { renderTable(); return; }
|
|
196
|
+
mountGraph(view === "chronological" ? current.chronological : current.aggregate);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderTable() {
|
|
200
|
+
unmountGraph();
|
|
201
|
+
const tools = current.toolAggregates || [];
|
|
202
|
+
const turns = current.turns || [];
|
|
203
|
+
const toolMax = Math.max(1, ...tools.map((t) => t.totalTokens));
|
|
204
|
+
const toolRows = tools.map((t) => `
|
|
205
|
+
<tr>
|
|
206
|
+
<td><span class="swatch" style="background:${t.color}"></span>${escapeHtml(t.name)}</td>
|
|
207
|
+
<td class="num">${t.calls}</td>
|
|
208
|
+
<td class="num">${fmt(t.totalTokens)}</td>
|
|
209
|
+
<td><div class="bar" style="width:${Math.max(2, Math.round(t.totalTokens / toolMax * 100))}%;background:${t.color}"></div></td>
|
|
210
|
+
</tr>`).join("");
|
|
211
|
+
const turnRows = turns.map((t) => `
|
|
212
|
+
<tr>
|
|
213
|
+
<td class="num">${t.index + 1}</td>
|
|
214
|
+
<td title="${escapeHtml(t.prompt)}">${escapeHtml(truncate(t.prompt, 64))}</td>
|
|
215
|
+
<td class="num">${t.toolCalls}</td>
|
|
216
|
+
<td class="num">${fmt(t.totalTokens)}</td>
|
|
217
|
+
</tr>`).join("");
|
|
218
|
+
container.innerHTML = `
|
|
219
|
+
<div class="tables">
|
|
220
|
+
<section class="tbl-sec">
|
|
221
|
+
<h3>Tool share</h3>
|
|
222
|
+
<table>
|
|
223
|
+
<thead><tr><th>Tool</th><th class="num">Calls</th><th class="num">Tokens</th><th style="width:34%">Share</th></tr></thead>
|
|
224
|
+
<tbody>${toolRows || '<tr><td colspan="4" class="muted">No tool calls in this session.</td></tr>'}</tbody>
|
|
225
|
+
</table>
|
|
226
|
+
</section>
|
|
227
|
+
<section class="tbl-sec">
|
|
228
|
+
<h3>Turns</h3>
|
|
229
|
+
<table>
|
|
230
|
+
<thead><tr><th class="num">#</th><th>Prompt</th><th class="num">Calls</th><th class="num">Tokens</th></tr></thead>
|
|
231
|
+
<tbody>${turnRows || '<tr><td colspan="4" class="muted">No turns in this session.</td></tr>'}</tbody>
|
|
232
|
+
</table>
|
|
233
|
+
</section>
|
|
234
|
+
</div>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
147
237
|
async function loadSession(slug, id) {
|
|
148
|
-
|
|
238
|
+
showSpinner(true);
|
|
149
239
|
try {
|
|
150
240
|
const r = await fetch("/api/session/" + encodeURIComponent(slug) + "/" + encodeURIComponent(id));
|
|
151
241
|
if (!r.ok) throw new Error("no data");
|
|
152
242
|
current = await r.json();
|
|
153
243
|
fillStats(current.session);
|
|
154
|
-
|
|
244
|
+
view = "aggregate";
|
|
245
|
+
setViewActive("btnAgg");
|
|
155
246
|
render();
|
|
156
247
|
} catch (e) {
|
|
157
|
-
spinner.classList.remove("on");
|
|
158
248
|
showEmpty("Could not load session", "The server couldn't parse this session file. It may be malformed or the API returned no data.");
|
|
249
|
+
} finally {
|
|
250
|
+
showSpinner(false);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- overview ----
|
|
255
|
+
|
|
256
|
+
function overviewStats(o) {
|
|
257
|
+
return {
|
|
258
|
+
model: "All sessions",
|
|
259
|
+
startedAt: "",
|
|
260
|
+
durationMs: null,
|
|
261
|
+
turns: o.totals.sessions,
|
|
262
|
+
toolCalls: o.toolAggregates.reduce((a, t) => a + t.calls, 0),
|
|
263
|
+
totalTokens: o.totals.totalTokens,
|
|
264
|
+
totalInput: o.totals.totalInput,
|
|
265
|
+
totalOutput: o.totals.totalOutput,
|
|
266
|
+
totalCacheRead: o.totals.totalCacheRead,
|
|
267
|
+
totalCacheWrite: o.totals.totalCacheWrite,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function filterSessions(project) {
|
|
272
|
+
for (const opt of sessionSelect.options) opt.hidden = opt.dataset.slug !== project;
|
|
273
|
+
const firstVisible = [...sessionSelect.options].find((o) => !o.hidden);
|
|
274
|
+
if (firstVisible) { sessionSelect.value = firstVisible.value; loadSession(firstVisible.dataset.slug, firstVisible.value); }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function renderOverview() {
|
|
278
|
+
unmountGraph();
|
|
279
|
+
const o = overview;
|
|
280
|
+
if (!o) { showEmpty("No overview data", "The server didn't return an overview."); return; }
|
|
281
|
+
fillStats(overviewStats(o));
|
|
282
|
+
const projMax = Math.max(1, ...o.projects.map((p) => p.totalTokens));
|
|
283
|
+
const total = o.totals.totalTokens || 1;
|
|
284
|
+
const projRows = o.projects.map((p) => `
|
|
285
|
+
<tr class="project-row" data-project="${escapeHtml(p.project)}">
|
|
286
|
+
<td class="proj-name">${escapeHtml(p.project)}</td>
|
|
287
|
+
<td class="num">${p.sessions}</td>
|
|
288
|
+
<td class="num">${fmt(p.totalTokens)}</td>
|
|
289
|
+
<td class="num">${Math.round(p.totalTokens / total * 100)}%</td>
|
|
290
|
+
<td><div class="bar" style="width:${Math.max(2, Math.round(p.totalTokens / projMax * 100))}%;background:var(--yellow)"></div></td>
|
|
291
|
+
</tr>`).join("");
|
|
292
|
+
container.innerHTML = `
|
|
293
|
+
<div class="ov-wrap">
|
|
294
|
+
<div class="ov-graph" id="ovGraph"></div>
|
|
295
|
+
<div class="ov-list">
|
|
296
|
+
<h3>Projects · ${o.totals.sessions} sessions</h3>
|
|
297
|
+
<table>
|
|
298
|
+
<thead><tr><th>Project</th><th class="num">Sess</th><th class="num">Tokens</th><th class="num">%</th><th style="width:30%"></th></tr></thead>
|
|
299
|
+
<tbody>${projRows || '<tr><td colspan="5" class="muted">No sessions found.</td></tr>'}</tbody>
|
|
300
|
+
</table>
|
|
301
|
+
<p class="hint">Click a project to load its sessions above.</p>
|
|
302
|
+
</div>
|
|
303
|
+
</div>`;
|
|
304
|
+
container.querySelectorAll(".project-row").forEach((tr) =>
|
|
305
|
+
tr.addEventListener("click", () => filterSessions(tr.dataset.project)));
|
|
306
|
+
const hub = o.graph || { nodes: [{ id: "session", label: "All sessions", value: 6, color: { background: "#faff69" } }], edges: [] };
|
|
307
|
+
if ($("ovGraph")) mountGraph(hub, $("ovGraph"));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function loadOverview() {
|
|
311
|
+
showSpinner(true);
|
|
312
|
+
try {
|
|
313
|
+
const r = await fetch("/api/overview");
|
|
314
|
+
overview = await r.json();
|
|
315
|
+
view = "overview";
|
|
316
|
+
setViewActive("btnOverview");
|
|
317
|
+
renderOverview();
|
|
318
|
+
} catch (e) {
|
|
319
|
+
showEmpty("Can't reach the Graphtrack server", "Start it with: graphtrack — this page must be served from http://localhost:9090.");
|
|
320
|
+
} finally {
|
|
321
|
+
showSpinner(false);
|
|
159
322
|
}
|
|
160
323
|
}
|
|
161
324
|
|
|
325
|
+
// ---- boot + controls ----
|
|
326
|
+
|
|
162
327
|
async function loadSessions() {
|
|
163
|
-
spinner.classList.add("on");
|
|
164
328
|
try {
|
|
165
329
|
const r = await fetch("/api/sessions");
|
|
166
330
|
const data = await r.json();
|
|
167
|
-
spinner.classList.remove("on");
|
|
168
331
|
if (!data.sessions || data.sessions.length === 0) {
|
|
169
332
|
showEmpty("No Claude Code sessions found",
|
|
170
333
|
"Graphtrack reads session logs from " + data.logsDir + ". Run Claude Code a few times, then restart with: graphtrack --dir <path> if your logs live elsewhere.");
|
|
@@ -179,10 +342,8 @@
|
|
|
179
342
|
opt.textContent = s.project + " · " + s.id;
|
|
180
343
|
sessionSelect.appendChild(opt);
|
|
181
344
|
});
|
|
182
|
-
|
|
183
|
-
loadSession(first.project, first.id);
|
|
345
|
+
loadOverview();
|
|
184
346
|
} catch (e) {
|
|
185
|
-
spinner.classList.remove("on");
|
|
186
347
|
showEmpty("Can't reach the Graphtrack server",
|
|
187
348
|
"Start it with: graphtrack — this page must be served from http://localhost:9090.");
|
|
188
349
|
}
|
|
@@ -192,9 +353,14 @@
|
|
|
192
353
|
const opt = sessionSelect.selectedOptions[0];
|
|
193
354
|
if (opt) loadSession(opt.dataset.slug, opt.value);
|
|
194
355
|
});
|
|
195
|
-
$("
|
|
196
|
-
|
|
197
|
-
|
|
356
|
+
$("btnOverview").addEventListener("click", () => {
|
|
357
|
+
view = "overview";
|
|
358
|
+
setViewActive("btnOverview");
|
|
359
|
+
if (!overview) loadOverview(); else renderOverview();
|
|
360
|
+
});
|
|
361
|
+
$("btnAgg").addEventListener("click", () => { view = "aggregate"; setViewActive("btnAgg"); render(); });
|
|
362
|
+
$("btnChain").addEventListener("click", () => { view = "chronological"; setViewActive("btnChain"); render(); });
|
|
363
|
+
$("btnTable").addEventListener("click", () => { view = "table"; setViewActive("btnTable"); if (!current) render(); else renderTable(); });
|
|
198
364
|
|
|
199
365
|
loadSessions();
|
|
200
366
|
</script>
|