opencode-cache-hit 0.2.0 → 0.2.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/AGENTS.md +2 -1
- package/README.md +5 -18
- package/README.zh-CN.md +155 -96
- package/docs/assets/cache-hit-panel.v3.png +0 -0
- package/docs/en/design.md +22 -4
- package/docs/en/timeline-duplicate-writes.md +125 -0
- package/docs/en/timeline.md +17 -13
- package/docs/zh-CN/design.md +23 -5
- package/docs/zh-CN/timeline.md +18 -15
- package/package.json +1 -1
- package/scripts/README.md +63 -0
- package/scripts/timeline-dashboard.ts +728 -0
- package/src/agents-view.tsx +8 -8
- package/src/cache-ttl-view.tsx +3 -8
- package/src/format-cost.ts +70 -0
- package/src/format-model.ts +227 -0
- package/src/sidebar-host.tsx +6 -6
- package/src/timeline/collector.ts +40 -87
- package/src/timeline/records.ts +0 -30
- package/src/tui-panel/README.md +2 -2
- package/src/tui-panel/README.zh-CN.md +2 -2
- package/src/tui-panel/components.tsx +31 -4
- package/src/tui-panel/index.ts +6 -1
- package/src/tui-panel/palette.ts +5 -0
- package/src/version.ts +4 -1
- package/docs/assets/.gitkeep +0 -0
- package/docs/assets/cache-hit-panel.png +0 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Timeline JSONL -> interactive HTML dashboard (Bun, no npm deps).
|
|
4
|
+
*
|
|
5
|
+
* bun scripts/timeline-dashboard.ts # auto-detect logs/
|
|
6
|
+
* bun scripts/timeline-dashboard.ts ~/logs/timeline-*.jsonl # globs expanded by script
|
|
7
|
+
* bun scripts/timeline-dashboard.ts --open # open browser after write
|
|
8
|
+
* bun scripts/timeline-dashboard.ts -o /tmp/report.html # custom output
|
|
9
|
+
*
|
|
10
|
+
* Default output: /tmp/timeline-dashboard-YYYY-MM-DD-HHmmss.html
|
|
11
|
+
* Browser is NOT opened unless you pass --open.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from "child_process"
|
|
15
|
+
import { existsSync, readdirSync } from "fs"
|
|
16
|
+
import { homedir } from "os"
|
|
17
|
+
import { basename, dirname, resolve } from "path"
|
|
18
|
+
import { Glob } from "bun"
|
|
19
|
+
import {
|
|
20
|
+
createCostFormatter,
|
|
21
|
+
normalizeCostDisplay,
|
|
22
|
+
normalizeCostDisplayEmbed,
|
|
23
|
+
type CostDisplayEmbed,
|
|
24
|
+
} from "../src/format-cost.ts"
|
|
25
|
+
import { loadPluginConfig } from "../src/load-config.ts"
|
|
26
|
+
import type { LlmCallRecord } from "../src/timeline/types.ts"
|
|
27
|
+
|
|
28
|
+
function timestampSuffix(): string {
|
|
29
|
+
const d = new Date()
|
|
30
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
31
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv: string[]) {
|
|
35
|
+
const positional: string[] = []
|
|
36
|
+
let output = `/tmp/timeline-dashboard-${timestampSuffix()}.html`
|
|
37
|
+
let open = false
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const a = argv[i]
|
|
40
|
+
if (a === "-o" || a === "--output") {
|
|
41
|
+
const next = argv[++i]
|
|
42
|
+
if (!next) {
|
|
43
|
+
console.error("error: -o/--output requires a path")
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
output = next
|
|
47
|
+
} else if (a === "--open") {
|
|
48
|
+
open = true
|
|
49
|
+
} else if (a === "-h" || a === "--help") {
|
|
50
|
+
console.error(
|
|
51
|
+
"usage: bun scripts/timeline-dashboard.ts [files...] [-o path] [--open]\n" +
|
|
52
|
+
" --open open the HTML file in the default browser (macOS/Linux/Windows)",
|
|
53
|
+
)
|
|
54
|
+
process.exit(0)
|
|
55
|
+
} else if (a.startsWith("-")) {
|
|
56
|
+
console.error(`error: unknown option ${a}`)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
} else {
|
|
59
|
+
positional.push(a)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { patterns: positional, output, open }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function expandUserPath(p: string): string {
|
|
66
|
+
if (p.startsWith("~/")) return resolve(homedir(), p.slice(2))
|
|
67
|
+
return p
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isTimelineLogFile(name: string): boolean {
|
|
71
|
+
return name.startsWith("timeline-") && /\.jsonl(\.\d+)?$/.test(name)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function expandPattern(pattern: string): Promise<string[]> {
|
|
75
|
+
const p = expandUserPath(pattern)
|
|
76
|
+
if (!/[?*[]]/.test(p)) {
|
|
77
|
+
const abs = resolve(p)
|
|
78
|
+
return existsSync(abs) ? [abs] : []
|
|
79
|
+
}
|
|
80
|
+
const dir = resolve(dirname(p))
|
|
81
|
+
const base = basename(p)
|
|
82
|
+
if (!existsSync(dir)) return []
|
|
83
|
+
const glob = new Glob(base)
|
|
84
|
+
const out: string[] = []
|
|
85
|
+
for await (const file of glob.scan({ cwd: dir, onlyFiles: true, absolute: true })) {
|
|
86
|
+
if (isTimelineLogFile(basename(file))) out.push(file)
|
|
87
|
+
}
|
|
88
|
+
return out.sort()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveInputPaths(patterns: string[]): Promise<string[]> {
|
|
92
|
+
if (patterns.length > 0) {
|
|
93
|
+
const paths: string[] = []
|
|
94
|
+
for (const pat of patterns) {
|
|
95
|
+
paths.push(...(await expandPattern(pat)))
|
|
96
|
+
}
|
|
97
|
+
return [...new Set(paths)].sort()
|
|
98
|
+
}
|
|
99
|
+
const logDir = resolve(homedir(), ".local/share/opencode/logs/cache-hit")
|
|
100
|
+
if (!existsSync(logDir)) return []
|
|
101
|
+
return readdirSync(logDir)
|
|
102
|
+
.filter(isTimelineLogFile)
|
|
103
|
+
.sort()
|
|
104
|
+
.map((f) => resolve(logDir, f))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isValidRecord(v: unknown): v is LlmCallRecord {
|
|
108
|
+
if (!v || typeof v !== "object") return false
|
|
109
|
+
const r = v as Record<string, unknown>
|
|
110
|
+
if (r.schema !== 1) return false
|
|
111
|
+
if (typeof r.created !== "string") return false
|
|
112
|
+
if (new Date(r.created).getFullYear() < 2024) return false
|
|
113
|
+
if (typeof r.sessionId !== "string" || typeof r.rootSessionId !== "string") return false
|
|
114
|
+
if (r.scope !== "main" && r.scope !== "child") return false
|
|
115
|
+
for (const k of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "cost"] as const) {
|
|
116
|
+
if (typeof r[k] !== "number" || !Number.isFinite(r[k])) return false
|
|
117
|
+
}
|
|
118
|
+
if (r.hitPercent != null && (typeof r.hitPercent !== "number" || !Number.isFinite(r.hitPercent))) {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sortKey(r: LlmCallRecord): number {
|
|
125
|
+
const ts = r.completedAt ?? r.created
|
|
126
|
+
return new Date(ts).getTime() || 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function loadRecords(paths: string[]): Promise<LlmCallRecord[]> {
|
|
130
|
+
const records: LlmCallRecord[] = []
|
|
131
|
+
const seenKeys = new Set<string>()
|
|
132
|
+
for (const p of paths) {
|
|
133
|
+
if (!existsSync(p)) continue
|
|
134
|
+
const text = await Bun.file(p).text()
|
|
135
|
+
for (const line of text.split("\n")) {
|
|
136
|
+
const s = line.trim()
|
|
137
|
+
if (!s) continue
|
|
138
|
+
try {
|
|
139
|
+
const parsed: unknown = JSON.parse(s)
|
|
140
|
+
if (!isValidRecord(parsed)) continue
|
|
141
|
+
// Deduplicate by messageKey — handles historical duplicates from pre-fix logs
|
|
142
|
+
if (seenKeys.has(parsed.messageKey)) continue
|
|
143
|
+
seenKeys.add(parsed.messageKey)
|
|
144
|
+
records.push(parsed)
|
|
145
|
+
} catch {
|
|
146
|
+
/* skip malformed */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
records.sort((a, b) => sortKey(a) - sortKey(b))
|
|
151
|
+
return records
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function summarizeStats(data: LlmCallRecord[]) {
|
|
155
|
+
const sessionIds = new Set(data.map((r) => r.rootSessionId || "(unknown)"))
|
|
156
|
+
return {
|
|
157
|
+
totalCalls: data.length,
|
|
158
|
+
totalSessions: sessionIds.size,
|
|
159
|
+
totalCost: data.reduce((s, r) => s + r.cost, 0),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Safe JSON inside <script> (prevents </script> breakout). */
|
|
164
|
+
function embedJson(json: string): string {
|
|
165
|
+
return json.replace(/</g, "\\u003c")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function genHTML(data: LlmCallRecord[], cost: CostDisplayEmbed): string {
|
|
169
|
+
const jsonData = embedJson(JSON.stringify(data))
|
|
170
|
+
const jsonCost = embedJson(JSON.stringify(cost))
|
|
171
|
+
|
|
172
|
+
return `<!DOCTYPE html>
|
|
173
|
+
<html lang="zh-CN">
|
|
174
|
+
<head>
|
|
175
|
+
<meta charset="UTF-8">
|
|
176
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
177
|
+
<title>Timeline Dashboard</title>
|
|
178
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7"></script>
|
|
179
|
+
<style>
|
|
180
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
181
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0d1117;color:#e6edf3;padding:20px;max-width:1400px;margin:auto}
|
|
182
|
+
h1{font-size:24px;margin-bottom:8px;color:#f0f6fc}
|
|
183
|
+
h2{font-size:16px;margin:24px 0 8px;color:#e6edf3;border-bottom:1px solid #30363d;padding-bottom:4px}
|
|
184
|
+
.sub{color:#8b949e;font-size:13px;margin-bottom:16px}
|
|
185
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;margin-bottom:20px}
|
|
186
|
+
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px 16px}
|
|
187
|
+
.card .val{font-size:22px;font-weight:600;color:#f0f6fc;margin-top:4px}
|
|
188
|
+
.card .lbl{font-size:12px;color:#8b949e;text-transform:uppercase;letter-spacing:.05em}
|
|
189
|
+
.card .subval{font-size:13px;color:#8b949e;margin-top:2px}
|
|
190
|
+
.filters{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:16px;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px 16px}
|
|
191
|
+
.filters label{font-size:13px;color:#8b949e;margin-right:4px}
|
|
192
|
+
.filters input,.filters select{background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 10px;font-size:13px}
|
|
193
|
+
.filters select{min-width:120px}
|
|
194
|
+
.chart-wrap{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:16px}
|
|
195
|
+
.chart-wrap canvas{width:100%!important;max-height:320px}
|
|
196
|
+
.table-wrap{overflow-x:auto;background:#161b22;border:1px solid #30363d;border-radius:8px;margin-bottom:16px}
|
|
197
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
198
|
+
th{text-align:left;padding:10px 12px;background:#0d1117;color:#8b949e;font-weight:500;border-bottom:1px solid #30363d;white-space:nowrap;cursor:pointer;user-select:none}
|
|
199
|
+
th:hover{color:#e6edf3}
|
|
200
|
+
td{padding:8px 12px;border-bottom:1px solid #21262d;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
201
|
+
tr:hover td{background:#1c2128}
|
|
202
|
+
.num{text-align:right;font-family:"SF Mono","Cascadia Code","Fira Code",monospace}
|
|
203
|
+
.ok{color:#3fb950}
|
|
204
|
+
.warn{color:#d29922}
|
|
205
|
+
.err{color:#f85149}
|
|
206
|
+
.muted{color:#8b949e}
|
|
207
|
+
.pill{display:inline-block;padding:1px 8px;border-radius:10px;font-size:11px;font-weight:500}
|
|
208
|
+
.pill-main{background:#1f6feb33;color:#58a6ff}
|
|
209
|
+
.pill-child{background:#3fb95033;color:#3fb950}
|
|
210
|
+
.pill-model{background:#8b949e22;color:#8b949e;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
211
|
+
.pill-mixed{background:#d2992233;color:#d29922}
|
|
212
|
+
.dr{display:none}
|
|
213
|
+
.dr td{padding:0}
|
|
214
|
+
.dr.open{display:table-row}
|
|
215
|
+
.detail-inner{padding:12px 16px;background:#0d1117}
|
|
216
|
+
.dr .detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 16px}
|
|
217
|
+
.dg-item{display:flex;flex-direction:column;min-width:0}
|
|
218
|
+
.dg-label{font-size:11px;color:#8b949e;margin-bottom:2px}
|
|
219
|
+
.dg-value{font-size:13px;color:#e6edf3;font-family:"SF Mono",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
220
|
+
.tip-box{position:fixed;max-width:360px;padding:8px 12px;background:#1c2128;border:1px solid #30363d;border-radius:6px;font-size:12px;line-height:1.5;color:#e6edf3;pointer-events:none;z-index:999;display:none;box-shadow:0 4px 12px rgba(0,0,0,.4)}
|
|
221
|
+
</style>
|
|
222
|
+
</head>
|
|
223
|
+
<body>
|
|
224
|
+
|
|
225
|
+
<h1>Timeline Dashboard</h1>
|
|
226
|
+
<p class="sub" id="subtitle">Loading...</p>
|
|
227
|
+
<p class="sub" id="costNote" style="display:none;margin-top:-8px"></p>
|
|
228
|
+
|
|
229
|
+
<div class="grid" id="summaryGrid"></div>
|
|
230
|
+
|
|
231
|
+
<div class="filters">
|
|
232
|
+
<label>Time</label>
|
|
233
|
+
<input type="date" id="filterDateFrom">
|
|
234
|
+
<span class="muted">--</span>
|
|
235
|
+
<input type="date" id="filterDateTo">
|
|
236
|
+
|
|
237
|
+
<label style="margin-left:8px">Session</label>
|
|
238
|
+
<select id="filterSession"><option value="all">All</option></select>
|
|
239
|
+
|
|
240
|
+
<label style="margin-left:8px">Scope</label>
|
|
241
|
+
<select id="filterScope"><option value="all">All</option><option value="main">main</option><option value="child">child</option></select>
|
|
242
|
+
|
|
243
|
+
<label style="margin-left:8px">Model</label>
|
|
244
|
+
<select id="filterModel"><option value="all">All</option></select>
|
|
245
|
+
|
|
246
|
+
<label style="margin-left:8px">Search</label>
|
|
247
|
+
<input type="text" id="filterSearch" placeholder="session / model / messageKey..." style="width:220px">
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div class="chart-wrap">
|
|
251
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
252
|
+
<span style="font-size:14px;font-weight:500">Token Volume <span class="tip-trigger" data-tip="Input=cache miss tokens | Cache Read=cache hit tokens | Output=generated tokens | Cache Write=tokens written to cache" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></span>
|
|
253
|
+
<label style="font-size:12px;color:#8b949e">
|
|
254
|
+
<input type="checkbox" id="toggleStack" checked> Stacked
|
|
255
|
+
</label>
|
|
256
|
+
</div>
|
|
257
|
+
<canvas id="chartTokens"></canvas>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<div class="chart-wrap">
|
|
261
|
+
<span style="font-size:14px;font-weight:500;display:block;margin-bottom:8px">Cache Hit Rate & Cost <span class="tip-trigger" data-tip="Per-call prompt cache hit rate (green) and message cost (red) | Hit % = cacheRead / (cacheRead + input) | Avg hit excludes skippedForHit rows (same as plot-hit-rate.ts)" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></span>
|
|
262
|
+
<canvas id="chartHitCost"></canvas>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div class="chart-wrap">
|
|
266
|
+
<span style="font-size:14px;font-weight:500;display:block;margin-bottom:8px">Duration <span class="tip-trigger" data-tip="Assistant response generation time in milliseconds" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></span>
|
|
267
|
+
<canvas id="chartDuration"></canvas>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<h2>Session Summary <span class="tip-trigger" data-tip="Aggregated stats grouped by rootSessionId (one row per unique session)" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></h2>
|
|
271
|
+
<div class="table-wrap">
|
|
272
|
+
<table id="sessionTable">
|
|
273
|
+
<thead><tr>
|
|
274
|
+
<th>Session ID</th><th>Model</th><th>Scope</th><th class="num">Calls</th>
|
|
275
|
+
<th class="num">Total Tokens</th><th class="num">Input</th><th class="num">Output</th>
|
|
276
|
+
<th class="num">Cache Read</th><th class="num">Avg Hit</th><th class="num" id="thSessionCost">Cost</th><th>Start</th>
|
|
277
|
+
</tr></thead>
|
|
278
|
+
<tbody id="sessionBody"></tbody>
|
|
279
|
+
</table>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<h2>Per-Call Detail <span class="tip-trigger" data-tip="Each row is one assistant message. Click to expand all JSONL fields" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></h2> <span style="font-size:12px;color:#8b949e">(click row to expand; table shows latest N rows)</span>
|
|
283
|
+
<div class="filters" style="margin-bottom:4px">
|
|
284
|
+
<label style="color:#8b949e">Rows</label>
|
|
285
|
+
<select id="pageSize" style="width:70px">
|
|
286
|
+
<option value="20">20</option><option value="50" selected>50</option>
|
|
287
|
+
<option value="100">100</option><option value="500">500</option><option value="99999">All</option>
|
|
288
|
+
</select>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="table-wrap">
|
|
291
|
+
<table id="detailTable">
|
|
292
|
+
<thead><tr>
|
|
293
|
+
<th style="width:0"></th><th class="num">Time</th><th>Scope</th><th>Session</th><th>Model</th>
|
|
294
|
+
<th class="num">Input</th><th class="num">Output</th><th class="num">CacheR</th><th class="num">CacheW</th>
|
|
295
|
+
<th class="num">Hit%</th><th class="num">Cost</th><th class="num">Dur</th>
|
|
296
|
+
</tr></thead>
|
|
297
|
+
<tbody id="detailBody"></tbody>
|
|
298
|
+
</table>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<script>
|
|
302
|
+
var RAW_DATA = TMPL_DATA
|
|
303
|
+
var EXPAND_FIELDS = ["schema","recordedAt","sessionId","rootSessionId","scope","messageKey","modelId","created","completedAt","durationMs","isComplete","input","output","reasoning","cacheRead","cacheWrite","cost","hitPercent","skippedForHit"]
|
|
304
|
+
|
|
305
|
+
function fmtDur(ms) { if (ms == null) return "-"; return ms < 1000 ? ms + "ms" : (ms/1000).toFixed(1) + "s" }
|
|
306
|
+
function esc(s) { return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'") }
|
|
307
|
+
|
|
308
|
+
function ensureCostDisplay(raw) {
|
|
309
|
+
var d = { currency:"CNY", costUnit:"USD", rate:6.77, symbol:"¥", decimals:3, minDisplay:0.01, chartLabel:"Cost (¥)", costNote:"JSONL cost is USD; displayed as CNY @ 6.77" }
|
|
310
|
+
if (!raw || typeof raw !== "object") return d
|
|
311
|
+
var rate = Number(raw.rate)
|
|
312
|
+
if (!isFinite(rate) || rate <= 0) rate = d.rate
|
|
313
|
+
return {
|
|
314
|
+
currency: raw.currency || d.currency,
|
|
315
|
+
costUnit: raw.costUnit || d.costUnit,
|
|
316
|
+
rate: rate,
|
|
317
|
+
symbol: raw.symbol || d.symbol,
|
|
318
|
+
decimals: typeof raw.decimals === "number" && raw.decimals >= 0 ? raw.decimals : d.decimals,
|
|
319
|
+
minDisplay: typeof raw.minDisplay === "number" && raw.minDisplay > 0 ? raw.minDisplay : d.minDisplay,
|
|
320
|
+
chartLabel: raw.chartLabel || d.chartLabel,
|
|
321
|
+
costNote: typeof raw.costNote === "string" ? raw.costNote : d.costNote
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
var COST_DISPLAY = ensureCostDisplay(TMPL_COST)
|
|
325
|
+
|
|
326
|
+
function convertCost(amount) {
|
|
327
|
+
if (!isFinite(amount) || amount <= 0) return 0
|
|
328
|
+
return amount * COST_DISPLAY.rate
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function fmtCost(amount) {
|
|
332
|
+
if (!isFinite(amount) || amount <= 0) return "-"
|
|
333
|
+
var v = convertCost(amount)
|
|
334
|
+
if (!isFinite(v)) return "-"
|
|
335
|
+
if (v < COST_DISPLAY.minDisplay) return "<" + COST_DISPLAY.symbol + COST_DISPLAY.minDisplay
|
|
336
|
+
return "~" + COST_DISPLAY.symbol + v.toFixed(COST_DISPLAY.decimals)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function applyCostLabels() {
|
|
340
|
+
var th = document.getElementById("thSessionCost")
|
|
341
|
+
if (th) th.textContent = COST_DISPLAY.chartLabel
|
|
342
|
+
var note = document.getElementById("costNote")
|
|
343
|
+
if (!note) return
|
|
344
|
+
if (COST_DISPLAY.costNote) {
|
|
345
|
+
note.textContent = COST_DISPLAY.costNote
|
|
346
|
+
note.style.display = "block"
|
|
347
|
+
} else {
|
|
348
|
+
note.textContent = ""
|
|
349
|
+
note.style.display = "none"
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function hitValues(rows) {
|
|
354
|
+
return rows.filter(function(r){ return !r.skippedForHit && r.hitPercent != null }).map(function(r){ return r.hitPercent })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function sessionScopeLabel(rows) {
|
|
358
|
+
var scopes = [...new Set(rows.map(function(r){ return r.scope }).filter(Boolean))]
|
|
359
|
+
if (scopes.length === 0) return "(unknown)"
|
|
360
|
+
if (scopes.length === 1) return scopes[0]
|
|
361
|
+
return scopes.join("+")
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function scopePillClass(scope) {
|
|
365
|
+
if (scope === "main") return "pill-main"
|
|
366
|
+
if (scope === "child") return "pill-child"
|
|
367
|
+
return "pill-mixed"
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function renderSummary(data) {
|
|
371
|
+
var ti = 0, to = 0, tcr = 0, tcw = 0, tc = 0, tt = 0
|
|
372
|
+
data.forEach(function(r){ ti+=r.input; to+=r.output; tcr+=r.cacheRead; tcw+=r.cacheWrite; tc+=r.cost })
|
|
373
|
+
tt = ti+to+tcr+tcw
|
|
374
|
+
var hits = hitValues(data)
|
|
375
|
+
var avg = hits.length ? hits.reduce(function(s,h){return s+h},0)/hits.length : 0
|
|
376
|
+
var sessions = [...new Set(data.map(function(r){return r.rootSessionId}).filter(Boolean))].length
|
|
377
|
+
var models = [...new Set(data.map(function(r){return r.modelId}).filter(Boolean))].join(", ")
|
|
378
|
+
var cls = avg>90 ? "ok" : avg>70 ? "warn" : "err"
|
|
379
|
+
var cards = [
|
|
380
|
+
{l:"Records", v:data.length, s:sessions+" sessions"},
|
|
381
|
+
{l:"Total Tokens", v:(tt/1e6).toFixed(2)+"M", s:"In "+(ti/1e6).toFixed(2)+"M"},
|
|
382
|
+
{l:"Total Input", v:ti.toLocaleString(), s:"Out "+to.toLocaleString()},
|
|
383
|
+
{l:"Cache Read", v:tcr.toLocaleString(), s:"Write "+tcw.toLocaleString()},
|
|
384
|
+
{l:"Avg Hit Rate", v:avg.toFixed(1)+"%", s:hits.length+" plottable calls", c:cls},
|
|
385
|
+
{l:"Total Cost", v:fmtCost(tc), s:COST_DISPLAY.costUnit !== COST_DISPLAY.currency ? "raw "+COST_DISPLAY.costUnit+" in JSONL" : ""},
|
|
386
|
+
{l:"Models", v:models||"(none)", s:""},
|
|
387
|
+
{l:"Date Range", v:data.length?data[0].created.slice(0,10):"-", s:data.length?"~ "+data[data.length-1].created.slice(0,10):""}
|
|
388
|
+
]
|
|
389
|
+
document.getElementById("summaryGrid").innerHTML = cards.map(function(c){
|
|
390
|
+
return '<div class="card"><div class="lbl">'+c.l+'</div><div class="val'+(c.c?" "+c.c:"")+'">'+c.v+'</div>'+(c.s?'<div class="subval">'+c.s+'</div>':"")+'</div>'
|
|
391
|
+
}).join("")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function updateSubtitle(data) {
|
|
395
|
+
var sessions = [...new Set(data.map(function(r){return r.rootSessionId}).filter(Boolean))]
|
|
396
|
+
var models = [...new Set(data.map(function(r){return r.modelId}).filter(Boolean))]
|
|
397
|
+
document.getElementById("subtitle").textContent = data.length + " records (filtered), " + sessions.length + " sessions" + (models.length?", "+models.join(", "):"")
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function populateFilters() {
|
|
401
|
+
var sessions = [...new Set(RAW_DATA.filter(function(r){return r.rootSessionId}).map(function(r){return r.rootSessionId}))].sort()
|
|
402
|
+
var models = [...new Set(RAW_DATA.filter(function(r){return r.modelId}).map(function(r){return r.modelId}))].sort()
|
|
403
|
+
var selS = document.getElementById("filterSession")
|
|
404
|
+
sessions.forEach(function(s){ var o=document.createElement("option"); o.value=s; o.textContent=s.slice(-16); selS.appendChild(o) })
|
|
405
|
+
var selM = document.getElementById("filterModel")
|
|
406
|
+
models.forEach(function(m){ var o=document.createElement("option"); o.value=m; o.textContent=m; selM.appendChild(o) })
|
|
407
|
+
if (RAW_DATA.length > 0) {
|
|
408
|
+
var dates = RAW_DATA.map(function(r){return r.created.slice(0,10)}).filter(function(d,i,a){return a.indexOf(d)===i}).sort()
|
|
409
|
+
document.getElementById("filterDateFrom").value = dates[0]
|
|
410
|
+
document.getElementById("filterDateTo").value = dates[dates.length-1]
|
|
411
|
+
}
|
|
412
|
+
updateSubtitle(RAW_DATA)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getFilteredData() {
|
|
416
|
+
var df = document.getElementById("filterDateFrom").value
|
|
417
|
+
var dt = document.getElementById("filterDateTo").value
|
|
418
|
+
var sess = document.getElementById("filterSession").value
|
|
419
|
+
var sc = document.getElementById("filterScope").value
|
|
420
|
+
var mdl = document.getElementById("filterModel").value
|
|
421
|
+
var q = document.getElementById("filterSearch").value.toLowerCase()
|
|
422
|
+
return RAW_DATA.filter(function(r){
|
|
423
|
+
var d = r.created.slice(0,10)
|
|
424
|
+
if (df && d<df) return false
|
|
425
|
+
if (dt && d>dt) return false
|
|
426
|
+
if (sess!=="all" && r.rootSessionId!==sess) return false
|
|
427
|
+
if (sc!=="all" && r.scope!==sc) return false
|
|
428
|
+
if (mdl!=="all" && r.modelId!==mdl) return false
|
|
429
|
+
if (q) {
|
|
430
|
+
var mk = (r.messageKey || "").toLowerCase()
|
|
431
|
+
var rs = (r.rootSessionId || "").toLowerCase()
|
|
432
|
+
var sid = (r.sessionId || "").toLowerCase()
|
|
433
|
+
var mid = (r.modelId || "").toLowerCase()
|
|
434
|
+
if (mk.indexOf(q)===-1 && rs.indexOf(q)===-1 && sid.indexOf(q)===-1 && mid.indexOf(q)===-1) return false
|
|
435
|
+
}
|
|
436
|
+
return true
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
var chartTokens = null, chartHitCost = null, chartDuration = null
|
|
441
|
+
|
|
442
|
+
function chartAvailable() { return typeof Chart !== "undefined" }
|
|
443
|
+
|
|
444
|
+
function chartCtx(id) { try { return document.getElementById(id).getContext("2d") } catch(e){ return null } }
|
|
445
|
+
|
|
446
|
+
function buildTokenChart(data) {
|
|
447
|
+
if (!chartAvailable()) return
|
|
448
|
+
var ctx = chartCtx("chartTokens")
|
|
449
|
+
if (!ctx) return
|
|
450
|
+
var stacked = document.getElementById("toggleStack").checked
|
|
451
|
+
if (chartTokens) chartTokens.destroy()
|
|
452
|
+
chartTokens = new Chart(ctx, {
|
|
453
|
+
type: "bar",
|
|
454
|
+
data: {
|
|
455
|
+
labels: data.map(function(r){return r.created.slice(0,19).replace("T"," ")}),
|
|
456
|
+
datasets: [
|
|
457
|
+
{ label:"Input", data:data.map(function(r){return r.input}), backgroundColor:"#58a6ff80", borderColor:"#58a6ff", borderWidth:1 },
|
|
458
|
+
{ label:"Output", data:data.map(function(r){return r.output}), backgroundColor:"#3fb95080", borderColor:"#3fb950", borderWidth:1 },
|
|
459
|
+
{ label:"Cache Read", data:data.map(function(r){return r.cacheRead}), backgroundColor:"#d2992280", borderColor:"#d29922", borderWidth:1 },
|
|
460
|
+
{ label:"Cache Write", data:data.map(function(r){return r.cacheWrite}), backgroundColor:"#a371f780", borderColor:"#a371f7", borderWidth:1 }
|
|
461
|
+
]
|
|
462
|
+
},
|
|
463
|
+
options: {
|
|
464
|
+
responsive:true, maintainAspectRatio:false, animation:false,
|
|
465
|
+
scales: {
|
|
466
|
+
x: { ticks:{color:"#8b949e",maxRotation:45,maxTicksLimit:20}, grid:{color:"#21262d"} },
|
|
467
|
+
y: { stacked:stacked, beginAtZero:true, ticks:{color:"#8b949e"}, grid:{color:"#21262d"} }
|
|
468
|
+
},
|
|
469
|
+
plugins: { legend:{ labels:{color:"#e6edf3",boxWidth:12,padding:12} } }
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function buildHitCostChart(data) {
|
|
475
|
+
if (!chartAvailable()) return
|
|
476
|
+
var ctx = chartCtx("chartHitCost")
|
|
477
|
+
if (!ctx) return
|
|
478
|
+
if (chartHitCost) chartHitCost.destroy()
|
|
479
|
+
chartHitCost = new Chart(ctx, {
|
|
480
|
+
type: "line",
|
|
481
|
+
data: {
|
|
482
|
+
labels: data.map(function(r){return r.created.slice(0,19).replace("T"," ")}),
|
|
483
|
+
datasets: [
|
|
484
|
+
{ label:"Hit %", data:data.map(function(r){return r.hitPercent}), yAxisID:"y",
|
|
485
|
+
borderColor:"#3fb950", backgroundColor:"#3fb95022", fill:true, tension:0.2,
|
|
486
|
+
pointRadius:2, pointBackgroundColor:"#3fb950" },
|
|
487
|
+
{ label:COST_DISPLAY.chartLabel, data:data.map(function(r){return convertCost(r.cost)}), yAxisID:"y1",
|
|
488
|
+
borderColor:"#f85149", backgroundColor:"#f8514922", fill:true, tension:0.2,
|
|
489
|
+
pointRadius:2, pointBackgroundColor:"#f85149" }
|
|
490
|
+
]
|
|
491
|
+
},
|
|
492
|
+
options: {
|
|
493
|
+
responsive:true, maintainAspectRatio:false, animation:false,
|
|
494
|
+
scales: {
|
|
495
|
+
x: { ticks:{color:"#8b949e",maxRotation:45,maxTicksLimit:20}, grid:{color:"#21262d"} },
|
|
496
|
+
y: { type:"linear", position:"left", min:0, max:100, ticks:{color:"#3fb950"}, grid:{color:"#21262d"}, title:{display:true, text:"Hit %", color:"#3fb950"} },
|
|
497
|
+
y1: { type:"linear", position:"right", min:0, ticks:{color:"#f85149"}, grid:{display:false}, title:{display:true, text:COST_DISPLAY.chartLabel, color:"#f85149"} }
|
|
498
|
+
},
|
|
499
|
+
plugins: { legend:{ labels:{color:"#e6edf3",boxWidth:12,padding:12} } }
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function buildDurationChart(data) {
|
|
505
|
+
if (!chartAvailable()) return
|
|
506
|
+
var ctx = chartCtx("chartDuration")
|
|
507
|
+
if (!ctx) return
|
|
508
|
+
if (chartDuration) chartDuration.destroy()
|
|
509
|
+
chartDuration = new Chart(ctx, {
|
|
510
|
+
type: "bar",
|
|
511
|
+
data: {
|
|
512
|
+
labels: data.map(function(r){return r.created.slice(0,19).replace("T"," ")}),
|
|
513
|
+
datasets: [
|
|
514
|
+
{ label:"Duration (ms)", data:data.map(function(r){return r.durationMs||0}), backgroundColor:"#58a6ff80", borderColor:"#58a6ff", borderWidth:1 }
|
|
515
|
+
]
|
|
516
|
+
},
|
|
517
|
+
options: {
|
|
518
|
+
responsive:true, maintainAspectRatio:false, animation:false,
|
|
519
|
+
scales: {
|
|
520
|
+
x: { ticks:{color:"#8b949e",maxRotation:45,maxTicksLimit:20}, grid:{color:"#21262d"} },
|
|
521
|
+
y: { beginAtZero:true, ticks:{color:"#8b949e"}, grid:{color:"#21262d"} }
|
|
522
|
+
},
|
|
523
|
+
plugins: { legend:{ labels:{color:"#e6edf3",boxWidth:12,padding:12} } }
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function renderSessionTable(data) {
|
|
529
|
+
var map = {}
|
|
530
|
+
data.forEach(function(r){
|
|
531
|
+
var id = r.rootSessionId || "(unknown)"
|
|
532
|
+
if (!map[id]) map[id] = []
|
|
533
|
+
map[id].push(r)
|
|
534
|
+
})
|
|
535
|
+
var rows = []
|
|
536
|
+
Object.keys(map).forEach(function(id){
|
|
537
|
+
var rd = map[id]
|
|
538
|
+
var hits = hitValues(rd)
|
|
539
|
+
var models = [...new Set(rd.map(function(r){return r.modelId}).filter(Boolean))]
|
|
540
|
+
var scope = sessionScopeLabel(rd)
|
|
541
|
+
var avg = hits.length ? hits.reduce(function(s,h){return s+h},0)/hits.length : 0
|
|
542
|
+
var totalT = rd.reduce(function(s,r){return s+r.input+r.output+r.cacheRead+r.cacheWrite},0)
|
|
543
|
+
rows.push({
|
|
544
|
+
id:id, model:models.join(" | ")||"(unknown)", scope:scope,
|
|
545
|
+
calls:rd.length, totalT:totalT,
|
|
546
|
+
ti:rd.reduce(function(s,r){return s+r.input},0),
|
|
547
|
+
to:rd.reduce(function(s,r){return s+r.output},0),
|
|
548
|
+
tcr:rd.reduce(function(s,r){return s+r.cacheRead},0),
|
|
549
|
+
avg:avg, cost:rd.reduce(function(s,r){return s+r.cost},0),
|
|
550
|
+
start:rd[0].created||""
|
|
551
|
+
})
|
|
552
|
+
})
|
|
553
|
+
rows.sort(function(a,b){return a.start.localeCompare(b.start)})
|
|
554
|
+
var cls = function(p){return p>90?"ok":p>70?"warn":"err"}
|
|
555
|
+
document.getElementById("sessionBody").innerHTML = rows.map(function(r){
|
|
556
|
+
return '<tr><td style="max-width:180px;overflow:hidden;text-overflow:ellipsis" title="'+esc(r.id)+'">'+esc(r.id.slice(-16))+
|
|
557
|
+
'</td><td><span class="pill pill-model">'+esc(r.model)+'</span></td><td><span class="pill '+scopePillClass(r.scope)+'">'+esc(r.scope)+
|
|
558
|
+
'</span></td><td class="num">'+r.calls+'</td><td class="num">'+(r.totalT/1e6).toFixed(2)+'M</td><td class="num">'+r.ti.toLocaleString()+
|
|
559
|
+
'</td><td class="num">'+r.to.toLocaleString()+'</td><td class="num">'+r.tcr.toLocaleString()+
|
|
560
|
+
'</td><td class="num '+cls(r.avg)+'">'+r.avg.toFixed(1)+'%</td><td class="num">'+fmtCost(r.cost)+
|
|
561
|
+
'</td><td>'+r.start.slice(0,19)+'</td></tr>'
|
|
562
|
+
}).join("")
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function expandDetailGrid(r) {
|
|
566
|
+
return EXPAND_FIELDS.map(function(f){
|
|
567
|
+
var v = r[f]
|
|
568
|
+
if (v == null) v = "-"
|
|
569
|
+
else if (f === "cost") {
|
|
570
|
+
var rawCost = v
|
|
571
|
+
v = String(rawCost) + " " + COST_DISPLAY.costUnit
|
|
572
|
+
if (COST_DISPLAY.rate !== 1) v += " (" + fmtCost(rawCost) + ")"
|
|
573
|
+
}
|
|
574
|
+
else if (typeof v === "boolean") v = v ? "true" : "false"
|
|
575
|
+
else v = String(v)
|
|
576
|
+
return '<div class="dg-item"><span class="dg-label">'+f+'</span><span class="dg-value" title="'+esc(v)+'">'+esc(v)+'</span></div>'
|
|
577
|
+
}).join("")
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function renderDetailTable(data) {
|
|
581
|
+
var ps = parseInt(document.getElementById("pageSize").value)
|
|
582
|
+
var disp = ps >= data.length ? data : data.slice(data.length - ps)
|
|
583
|
+
var cls = function(p){return p!=null?(p>90?"ok":p>70?"warn":"err"):""}
|
|
584
|
+
document.getElementById("detailBody").innerHTML = disp.map(function(r, i){
|
|
585
|
+
var shortId = r.rootSessionId ? r.rootSessionId.slice(-12) : "-"
|
|
586
|
+
var hitPct = r.hitPercent != null ? r.hitPercent.toFixed(1)+"%" : "-"
|
|
587
|
+
return '<tr class="dp" data-idx="'+i+'">'+
|
|
588
|
+
'<td style="cursor:pointer;color:#8b949e;text-align:center;font-size:16px;user-select:none">▶</td>'+
|
|
589
|
+
'<td class="num">'+r.created.slice(0,19).replace("T"," ")+'</td>'+
|
|
590
|
+
'<td><span class="pill '+scopePillClass(r.scope)+'">'+esc(r.scope)+'</span></td>'+
|
|
591
|
+
'<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis" title="'+esc(r.rootSessionId||"")+'">'+esc(shortId)+'</td>'+
|
|
592
|
+
'<td><span class="pill pill-model">'+esc(r.modelId||"-")+'</span></td>'+
|
|
593
|
+
'<td class="num">'+r.input.toLocaleString()+'</td>'+
|
|
594
|
+
'<td class="num">'+r.output.toLocaleString()+'</td>'+
|
|
595
|
+
'<td class="num">'+r.cacheRead.toLocaleString()+'</td>'+
|
|
596
|
+
'<td class="num">'+r.cacheWrite.toLocaleString()+'</td>'+
|
|
597
|
+
'<td class="num '+cls(r.hitPercent)+'">'+hitPct+'</td>'+
|
|
598
|
+
'<td class="num">'+fmtCost(r.cost)+'</td>'+
|
|
599
|
+
'<td class="num">'+fmtDur(r.durationMs)+'</td>'+
|
|
600
|
+
'</tr>'+
|
|
601
|
+
'<tr class="dr" data-idx="'+i+'"><td colspan="12"><div class="detail-inner"><div class="detail-grid">'+expandDetailGrid(r)+'</div></div></td></tr>'
|
|
602
|
+
}).join("")
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
document.getElementById("detailBody").addEventListener("click", function(e){
|
|
606
|
+
var row = e.target.closest(".dp")
|
|
607
|
+
if (!row) return
|
|
608
|
+
var idx = row.dataset["idx"]
|
|
609
|
+
var detail = document.querySelector('.dr[data-idx="'+idx+'"]')
|
|
610
|
+
if (detail) {
|
|
611
|
+
detail.classList.toggle("open")
|
|
612
|
+
row.querySelector("td:first-child").innerHTML = detail.classList.contains("open") ? "▼" : "▶"
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
function refresh() {
|
|
617
|
+
var data = getFilteredData()
|
|
618
|
+
renderSummary(data)
|
|
619
|
+
updateSubtitle(data)
|
|
620
|
+
buildTokenChart(data)
|
|
621
|
+
buildHitCostChart(data)
|
|
622
|
+
buildDurationChart(data)
|
|
623
|
+
renderSessionTable(data)
|
|
624
|
+
renderDetailTable(data)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
var searchTimer = null
|
|
628
|
+
document.getElementById("toggleStack").addEventListener("change", refresh)
|
|
629
|
+
document.getElementById("filterDateFrom").addEventListener("change", refresh)
|
|
630
|
+
document.getElementById("filterDateTo").addEventListener("change", refresh)
|
|
631
|
+
document.getElementById("filterSession").addEventListener("change", refresh)
|
|
632
|
+
document.getElementById("filterScope").addEventListener("change", refresh)
|
|
633
|
+
document.getElementById("filterModel").addEventListener("change", refresh)
|
|
634
|
+
document.getElementById("filterSearch").addEventListener("input", function(){
|
|
635
|
+
clearTimeout(searchTimer)
|
|
636
|
+
searchTimer = setTimeout(refresh, 200)
|
|
637
|
+
})
|
|
638
|
+
document.getElementById("pageSize").addEventListener("change", refresh)
|
|
639
|
+
|
|
640
|
+
var tipEl = document.createElement("div")
|
|
641
|
+
tipEl.className = "tip-box"
|
|
642
|
+
document.body.appendChild(tipEl)
|
|
643
|
+
document.querySelectorAll(".tip-trigger").forEach(function(el){
|
|
644
|
+
el.addEventListener("mouseenter", function(e){
|
|
645
|
+
tipEl.textContent = el.getAttribute("data-tip")
|
|
646
|
+
tipEl.style.display = "block"
|
|
647
|
+
tipEl.style.left = e.clientX + "px"
|
|
648
|
+
tipEl.style.top = (e.clientY + 14) + "px"
|
|
649
|
+
})
|
|
650
|
+
el.addEventListener("mousemove", function(e){
|
|
651
|
+
tipEl.style.left = e.clientX + "px"
|
|
652
|
+
tipEl.style.top = (e.clientY + 14) + "px"
|
|
653
|
+
})
|
|
654
|
+
el.addEventListener("mouseleave", function(){
|
|
655
|
+
tipEl.style.display = "none"
|
|
656
|
+
})
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
applyCostLabels()
|
|
660
|
+
populateFilters()
|
|
661
|
+
refresh()
|
|
662
|
+
</script>
|
|
663
|
+
</body>
|
|
664
|
+
</html>`.replace("TMPL_DATA", jsonData).replace("TMPL_COST", jsonCost)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function openInBrowser(filePath: string): void {
|
|
668
|
+
const quoted = JSON.stringify(filePath)
|
|
669
|
+
if (process.platform === "darwin") execSync(`open ${quoted}`)
|
|
670
|
+
else if (process.platform === "win32") execSync(`cmd /c start "" ${quoted}`)
|
|
671
|
+
else execSync(`xdg-open ${quoted}`)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const { patterns, output, open } = parseArgs(process.argv.slice(2))
|
|
675
|
+
const paths = await resolveInputPaths(patterns)
|
|
676
|
+
if (paths.length === 0) {
|
|
677
|
+
console.error(
|
|
678
|
+
"No timeline JSONL files found.\n" +
|
|
679
|
+
"Pass paths/globs or ensure ~/.local/share/opencode/logs/cache-hit/ exists.",
|
|
680
|
+
)
|
|
681
|
+
process.exit(1)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
console.error("Reading " + paths.length + " file(s):")
|
|
685
|
+
for (const p of paths) {
|
|
686
|
+
const stats = await Bun.file(p).stat()
|
|
687
|
+
console.error(" " + p + " (" + (stats.size / 1024).toFixed(1) + " KB)")
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const records = await loadRecords(paths)
|
|
691
|
+
if (records.length === 0) {
|
|
692
|
+
console.error("No valid records found.")
|
|
693
|
+
process.exit(1)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function loadCostContext(): { embed: CostDisplayEmbed; format: (n: number) => string } {
|
|
697
|
+
try {
|
|
698
|
+
const cost = normalizeCostDisplay(loadPluginConfig().cost)
|
|
699
|
+
return {
|
|
700
|
+
embed: normalizeCostDisplayEmbed(cost),
|
|
701
|
+
format: createCostFormatter(cost),
|
|
702
|
+
}
|
|
703
|
+
} catch {
|
|
704
|
+
const cost = normalizeCostDisplay(null)
|
|
705
|
+
return { embed: normalizeCostDisplayEmbed(cost), format: createCostFormatter(cost) }
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const { embed: costEmbed, format: fmtCostCli } = loadCostContext()
|
|
710
|
+
const stats = summarizeStats(records)
|
|
711
|
+
console.error(
|
|
712
|
+
records.length +
|
|
713
|
+
" records, " +
|
|
714
|
+
stats.totalSessions +
|
|
715
|
+
" sessions, " +
|
|
716
|
+
(fmtCostCli(stats.totalCost) || stats.totalCost.toFixed(6) + " " + costEmbed.costUnit) +
|
|
717
|
+
" total cost",
|
|
718
|
+
)
|
|
719
|
+
if (costEmbed.costNote) console.error(" " + costEmbed.costNote)
|
|
720
|
+
|
|
721
|
+
const html = genHTML(records, costEmbed)
|
|
722
|
+
await Bun.write(output, html)
|
|
723
|
+
console.error("Written: " + output + " (" + (Buffer.byteLength(html) / 1024).toFixed(0) + " KB)")
|
|
724
|
+
|
|
725
|
+
if (open) {
|
|
726
|
+
openInBrowser(resolve(output))
|
|
727
|
+
console.error("Opened in browser")
|
|
728
|
+
}
|