claude-usage-dashboard 1.2.2 → 1.3.0
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 +67 -66
- package/package.json +1 -1
- package/public/css/style.css +5 -0
- package/public/index.html +5 -1
- package/public/js/app.js +65 -6
- package/public/js/charts/quota-gauge.js +11 -1
- package/public/js/charts/token-trend.js +305 -61
- package/public/js/components/date-picker.js +18 -4
- package/server/parser.js +28 -11
package/README.md
CHANGED
|
@@ -1,66 +1,67 @@
|
|
|
1
|
-
# Claude Usage Dashboard
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/claude-usage-dashboard)
|
|
4
|
-
|
|
5
|
-
A self-hosted dashboard that visualizes your [Claude Code](https://claude.ai/code) usage by parsing local JSONL session logs from `~/.claude/projects/`.
|
|
6
|
-
|
|
7
|
-

|
|
8
|
-
|
|
9
|
-
## Features
|
|
10
|
-
|
|
11
|
-
- **Token tracking** — Total tokens with breakdown by input, output, cache read, and cache write
|
|
12
|
-
- **Cost estimation** — API cost equivalent at standard pricing, compared against your subscription plan (Pro / Max 5x / Max 20x)
|
|
13
|
-
- **Subscription quota** — Real-time utilization gauges (5-hour, 7-day, per-model) pulled from the Anthropic API with auto-detection of your plan tier
|
|
14
|
-
- **Token consumption trend** — Stacked bar chart with hourly, daily, weekly, or monthly granularity
|
|
15
|
-
- **Model distribution** — Donut chart showing usage across Claude models
|
|
16
|
-
- **Cache efficiency** — Visual breakdown of cache read, cache creation, and uncached requests
|
|
17
|
-
- **Project distribution** — Horizontal bar chart comparing token usage across projects
|
|
18
|
-
- **Session details** — Sortable, paginated table of every session with cost and duration
|
|
19
|
-
- **Auto-refresh** — Dashboard polls every 30s for new usage data; quota refreshes every 2 minutes
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
npm
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- **
|
|
56
|
-
- **
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
# Claude Usage Dashboard
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/claude-usage-dashboard)
|
|
4
|
+
|
|
5
|
+
A self-hosted dashboard that visualizes your [Claude Code](https://claude.ai/code) usage by parsing local JSONL session logs from `~/.claude/projects/`.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Token tracking** — Total tokens with breakdown by input, output, cache read, and cache write
|
|
12
|
+
- **Cost estimation** — API cost equivalent at standard pricing, compared against your subscription plan (Pro / Max 5x / Max 20x)
|
|
13
|
+
- **Subscription quota** — Real-time utilization gauges (5-hour, 7-day, per-model) pulled from the Anthropic API with auto-detection of your plan tier. 7-day reset shows full date+time; all timestamps include timezone
|
|
14
|
+
- **Token consumption trend** — Stacked bar chart with hourly, daily, weekly, or monthly granularity. Toggle between tokens and dollar view. Includes period summary with avg/min/max stats, active hours heatmap, and smart date range limits per granularity
|
|
15
|
+
- **Model distribution** — Donut chart showing usage across Claude models
|
|
16
|
+
- **Cache efficiency** — Visual breakdown of cache read, cache creation, and uncached requests
|
|
17
|
+
- **Project distribution** — Horizontal bar chart comparing token usage across projects
|
|
18
|
+
- **Session details** — Sortable, paginated table of every session with cost and duration
|
|
19
|
+
- **Auto-refresh** — Dashboard polls every 30s for new usage data; quota refreshes every 2 minutes
|
|
20
|
+
- **Persistent settings** — Date range, granularity, auto-refresh, and y-axis mode are remembered across sessions via localStorage
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
Run directly without installing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx claude-usage-dashboard
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Open http://localhost:3000 in your browser.
|
|
31
|
+
|
|
32
|
+
### From Source
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/ludengz/claudeUsageDashboard.git
|
|
36
|
+
cd claudeUsageDashboard
|
|
37
|
+
npm install
|
|
38
|
+
npm start
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Custom Port
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
PORT=8080 npx claude-usage-dashboard
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How It Works
|
|
48
|
+
|
|
49
|
+
The dashboard reads Claude Code session logs from `~/.claude/projects/` — if you use Claude Code, these already exist on your machine. Logs are automatically re-read every 5 seconds, and new usage appears without restarting the server.
|
|
50
|
+
|
|
51
|
+
Subscription quota data is fetched from the Anthropic API using your local OAuth credentials (`~/.claude/.credentials.json`). Your plan tier (Pro / Max 5x / Max 20x) is auto-detected from the same file.
|
|
52
|
+
|
|
53
|
+
## Tech Stack
|
|
54
|
+
|
|
55
|
+
- **Backend:** Node.js, Express 5
|
|
56
|
+
- **Frontend:** Vanilla JS (ES modules), D3.js v7
|
|
57
|
+
- **Tests:** Mocha + Chai
|
|
58
|
+
|
|
59
|
+
## Running Tests
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm test
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
ISC
|
package/package.json
CHANGED
package/public/css/style.css
CHANGED
|
@@ -106,6 +106,11 @@ body {
|
|
|
106
106
|
.granularity-toggle button.active {
|
|
107
107
|
background: var(--blue);
|
|
108
108
|
color: white;
|
|
109
|
+
box-shadow: 0 0 0 1px rgba(59,130,246,0.5);
|
|
110
|
+
}
|
|
111
|
+
.granularity-toggle button:disabled {
|
|
112
|
+
opacity: 0.35;
|
|
113
|
+
cursor: not-allowed;
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
.table-controls { display: flex; gap: 8px; }
|
package/public/index.html
CHANGED
|
@@ -57,9 +57,13 @@
|
|
|
57
57
|
<section class="chart-section">
|
|
58
58
|
<div class="chart-header">
|
|
59
59
|
<h2>Token Consumption Trend</h2>
|
|
60
|
+
<div class="granularity-toggle" id="yaxis-toggle">
|
|
61
|
+
<button data-yaxis="tokens">Tokens</button>
|
|
62
|
+
<button data-yaxis="dollars">$</button>
|
|
63
|
+
</div>
|
|
60
64
|
<div class="granularity-toggle" id="granularity-toggle">
|
|
61
65
|
<button data-granularity="hourly">Hourly</button>
|
|
62
|
-
<button data-granularity="daily"
|
|
66
|
+
<button data-granularity="daily">Daily</button>
|
|
63
67
|
<button data-granularity="weekly">Weekly</button>
|
|
64
68
|
<button data-granularity="monthly">Monthly</button>
|
|
65
69
|
</div>
|
package/public/js/app.js
CHANGED
|
@@ -13,11 +13,12 @@ const state = {
|
|
|
13
13
|
dateRange: { from: null, to: null },
|
|
14
14
|
plan: { plan: 'max20x', customPrice: null },
|
|
15
15
|
granularity: localStorage.getItem('selectedGranularity') || 'hourly',
|
|
16
|
+
trendYAxis: localStorage.getItem('trendYAxis') || 'tokens',
|
|
16
17
|
sessionSort: 'date',
|
|
17
18
|
sessionOrder: 'desc',
|
|
18
19
|
sessionPage: 1,
|
|
19
20
|
sessionProject: '',
|
|
20
|
-
autoRefresh:
|
|
21
|
+
autoRefresh: localStorage.getItem('autoRefresh') !== 'false',
|
|
21
22
|
autoRefreshInterval: 30,
|
|
22
23
|
_refreshTimer: null,
|
|
23
24
|
quotaRefreshInterval: 120,
|
|
@@ -36,16 +37,22 @@ function updateLastUpdated() {
|
|
|
36
37
|
const el = document.getElementById('last-updated');
|
|
37
38
|
if (el) {
|
|
38
39
|
const now = new Date();
|
|
39
|
-
el.textContent = `Updated ${now.toLocaleTimeString()}`;
|
|
40
|
+
el.textContent = `Updated ${now.toLocaleTimeString()} ${getTimezoneAbbr()}`;
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
function getTimezoneAbbr() {
|
|
45
|
+
const parts = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }).formatToParts(new Date());
|
|
46
|
+
const tz = parts.find(p => p.type === 'timeZoneName');
|
|
47
|
+
return tz ? tz.value : '';
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
async function loadQuota() {
|
|
44
51
|
try {
|
|
45
52
|
const data = await fetchQuota();
|
|
46
53
|
renderQuotaGauges(document.getElementById('chart-quota'), data);
|
|
47
54
|
const el = document.getElementById('quota-last-updated');
|
|
48
|
-
if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()}`;
|
|
55
|
+
if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()} ${getTimezoneAbbr()}`;
|
|
49
56
|
} catch { /* silently degrade */ }
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -109,12 +116,12 @@ async function loadAll() {
|
|
|
109
116
|
|
|
110
117
|
// Set active granularity button
|
|
111
118
|
const activeGran = usage.granularity;
|
|
112
|
-
document.querySelectorAll('
|
|
119
|
+
document.querySelectorAll('#granularity-toggle button').forEach(btn => {
|
|
113
120
|
btn.classList.toggle('active', btn.dataset.granularity === activeGran);
|
|
114
121
|
});
|
|
115
122
|
|
|
116
123
|
// Charts
|
|
117
|
-
renderTokenTrend(document.getElementById('chart-token-trend'), usage);
|
|
124
|
+
renderTokenTrend(document.getElementById('chart-token-trend'), usage, { yAxis: state.trendYAxis });
|
|
118
125
|
renderCostComparison(document.getElementById('chart-cost-comparison'), cost);
|
|
119
126
|
renderModelDistribution(document.getElementById('chart-model-distribution'), models);
|
|
120
127
|
renderCacheEfficiency(document.getElementById('chart-cache-efficiency'), cache);
|
|
@@ -139,13 +146,47 @@ async function loadAll() {
|
|
|
139
146
|
updateLastUpdated();
|
|
140
147
|
}
|
|
141
148
|
|
|
149
|
+
// Max bucket limits per granularity to avoid crashing the browser
|
|
150
|
+
const GRANULARITY_MAX_DAYS = { hourly: 14, daily: 90, weekly: 365, monthly: 1825 };
|
|
151
|
+
|
|
152
|
+
function updateGranularityButtons() {
|
|
153
|
+
const { from, to } = state.dateRange;
|
|
154
|
+
const days = (from && to) ? (new Date(to) - new Date(from)) / (1000 * 60 * 60 * 24) : 30;
|
|
155
|
+
document.querySelectorAll('#granularity-toggle button').forEach(btn => {
|
|
156
|
+
const gran = btn.dataset.granularity;
|
|
157
|
+
const maxDays = GRANULARITY_MAX_DAYS[gran] || 9999;
|
|
158
|
+
const tooLarge = days > maxDays;
|
|
159
|
+
btn.disabled = tooLarge;
|
|
160
|
+
btn.title = tooLarge ? `Range too large for ${gran} view (max ${maxDays} days)` : '';
|
|
161
|
+
});
|
|
162
|
+
// If currently selected granularity is now disabled, switch to the finest available
|
|
163
|
+
const currentBtn = document.querySelector(`#granularity-toggle button[data-granularity="${state.granularity}"]`);
|
|
164
|
+
if (currentBtn && currentBtn.disabled) {
|
|
165
|
+
const order = ['hourly', 'daily', 'weekly', 'monthly'];
|
|
166
|
+
const available = order.find(g => {
|
|
167
|
+
const b = document.querySelector(`#granularity-toggle button[data-granularity="${g}"]`);
|
|
168
|
+
return b && !b.disabled;
|
|
169
|
+
});
|
|
170
|
+
if (available) {
|
|
171
|
+
state.granularity = available;
|
|
172
|
+
localStorage.setItem('selectedGranularity', state.granularity);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Update active class
|
|
176
|
+
document.querySelectorAll('#granularity-toggle button').forEach(btn => {
|
|
177
|
+
btn.classList.toggle('active', btn.dataset.granularity === state.granularity);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
142
181
|
function init() {
|
|
143
182
|
datePicker = initDatePicker(document.getElementById('date-picker'), (range) => {
|
|
144
183
|
state.dateRange = range;
|
|
145
184
|
state.sessionPage = 1;
|
|
185
|
+
updateGranularityButtons();
|
|
146
186
|
loadAll();
|
|
147
187
|
});
|
|
148
188
|
state.dateRange = datePicker.getRange();
|
|
189
|
+
updateGranularityButtons();
|
|
149
190
|
|
|
150
191
|
planSelector = initPlanSelector(document.getElementById('plan-selector'), (plan) => {
|
|
151
192
|
state.plan = plan;
|
|
@@ -153,13 +194,29 @@ function init() {
|
|
|
153
194
|
});
|
|
154
195
|
|
|
155
196
|
document.getElementById('granularity-toggle').addEventListener('click', (e) => {
|
|
156
|
-
if (e.target.tagName === 'BUTTON') {
|
|
197
|
+
if (e.target.tagName === 'BUTTON' && !e.target.disabled) {
|
|
157
198
|
state.granularity = e.target.dataset.granularity;
|
|
158
199
|
localStorage.setItem('selectedGranularity', state.granularity);
|
|
159
200
|
loadAll();
|
|
160
201
|
}
|
|
161
202
|
});
|
|
162
203
|
|
|
204
|
+
// Y-axis toggle (tokens / dollars)
|
|
205
|
+
const yaxisToggle = document.getElementById('yaxis-toggle');
|
|
206
|
+
yaxisToggle.querySelectorAll('button').forEach(btn => {
|
|
207
|
+
btn.classList.toggle('active', btn.dataset.yaxis === state.trendYAxis);
|
|
208
|
+
});
|
|
209
|
+
yaxisToggle.addEventListener('click', (e) => {
|
|
210
|
+
if (e.target.tagName === 'BUTTON') {
|
|
211
|
+
state.trendYAxis = e.target.dataset.yaxis;
|
|
212
|
+
localStorage.setItem('trendYAxis', state.trendYAxis);
|
|
213
|
+
yaxisToggle.querySelectorAll('button').forEach(btn => {
|
|
214
|
+
btn.classList.toggle('active', btn.dataset.yaxis === state.trendYAxis);
|
|
215
|
+
});
|
|
216
|
+
loadAll();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
163
220
|
const filterInput = document.getElementById('session-filter');
|
|
164
221
|
let filterTimeout;
|
|
165
222
|
filterInput.addEventListener('input', () => {
|
|
@@ -181,8 +238,10 @@ function init() {
|
|
|
181
238
|
document.getElementById('btn-refresh').addEventListener('click', () => { loadAll(); loadQuota(); });
|
|
182
239
|
|
|
183
240
|
const autoToggle = document.getElementById('auto-refresh-toggle');
|
|
241
|
+
autoToggle.checked = state.autoRefresh;
|
|
184
242
|
autoToggle.addEventListener('change', () => {
|
|
185
243
|
state.autoRefresh = autoToggle.checked;
|
|
244
|
+
localStorage.setItem('autoRefresh', state.autoRefresh);
|
|
186
245
|
if (state.autoRefresh) {
|
|
187
246
|
startAutoRefresh();
|
|
188
247
|
} else {
|
|
@@ -67,7 +67,17 @@ export function renderQuotaGauges(container, data) {
|
|
|
67
67
|
if (item.extraDetail) {
|
|
68
68
|
sub.textContent = item.extraDetail;
|
|
69
69
|
} else if (item.resets_at) {
|
|
70
|
-
|
|
70
|
+
const resetDate = new Date(item.resets_at);
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const isToday = resetDate.getFullYear() === now.getFullYear()
|
|
73
|
+
&& resetDate.getMonth() === now.getMonth()
|
|
74
|
+
&& resetDate.getDate() === now.getDate();
|
|
75
|
+
const tzParts = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }).formatToParts(resetDate);
|
|
76
|
+
const tz = tzParts.find(p => p.type === 'timeZoneName')?.value || '';
|
|
77
|
+
const resetStr = isToday
|
|
78
|
+
? resetDate.toLocaleTimeString()
|
|
79
|
+
: resetDate.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit' });
|
|
80
|
+
sub.textContent = `Resets ${resetStr} ${tz}`;
|
|
71
81
|
}
|
|
72
82
|
cell.appendChild(sub);
|
|
73
83
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// d3 is loaded as a global via <script> tag in index.html
|
|
2
2
|
|
|
3
|
-
export function renderTokenTrend(container, data) {
|
|
3
|
+
export function renderTokenTrend(container, data, opts = {}) {
|
|
4
|
+
const showDollars = opts.yAxis === 'dollars';
|
|
4
5
|
const el = d3.select(container);
|
|
5
6
|
el.selectAll('*').remove();
|
|
6
7
|
|
|
@@ -9,7 +10,7 @@ export function renderTokenTrend(container, data) {
|
|
|
9
10
|
return;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
const margin = { top: 20, right: 30, bottom:
|
|
13
|
+
const margin = { top: 20, right: 30, bottom: 60, left: 60 };
|
|
13
14
|
const width = container.clientWidth - margin.left - margin.right;
|
|
14
15
|
const height = 250 - margin.top - margin.bottom;
|
|
15
16
|
|
|
@@ -19,7 +20,37 @@ export function renderTokenTrend(container, data) {
|
|
|
19
20
|
.append('g')
|
|
20
21
|
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
+
const emptyBucket = (time) => ({ time, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_cost_usd: 0 });
|
|
24
|
+
|
|
25
|
+
// Fill in missing time slots so blank periods are visible
|
|
26
|
+
const bucketMap = new Map(data.buckets.map(b => [b.time, b]));
|
|
27
|
+
let allKeys;
|
|
28
|
+
if (data.granularity === 'hourly') {
|
|
29
|
+
allKeys = [];
|
|
30
|
+
const first = data.buckets[0].time; // e.g. "2026-03-15T08:00"
|
|
31
|
+
const last = data.buckets[data.buckets.length - 1].time;
|
|
32
|
+
const pad = n => String(n).padStart(2, '0');
|
|
33
|
+
const cur = new Date(first.replace('T', ' ').replace(/:00$/, ':00:00'));
|
|
34
|
+
const end = new Date(last.replace('T', ' ').replace(/:00$/, ':00:00'));
|
|
35
|
+
while (cur <= end) {
|
|
36
|
+
const key = `${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}T${pad(cur.getHours())}:00`;
|
|
37
|
+
allKeys.push(key);
|
|
38
|
+
cur.setHours(cur.getHours() + 1);
|
|
39
|
+
}
|
|
40
|
+
} else if (data.granularity === 'daily') {
|
|
41
|
+
allKeys = [];
|
|
42
|
+
const pad = n => String(n).padStart(2, '0');
|
|
43
|
+
const cur = new Date(data.buckets[0].time + 'T00:00:00');
|
|
44
|
+
const end = new Date(data.buckets[data.buckets.length - 1].time + 'T00:00:00');
|
|
45
|
+
while (cur <= end) {
|
|
46
|
+
allKeys.push(`${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}`);
|
|
47
|
+
cur.setDate(cur.getDate() + 1);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
allKeys = data.buckets.map(b => b.time);
|
|
51
|
+
}
|
|
52
|
+
const buckets = allKeys.map(k => bucketMap.get(k) || emptyBucket(k));
|
|
53
|
+
|
|
23
54
|
const x = d3.scaleBand()
|
|
24
55
|
.domain(buckets.map(d => d.time))
|
|
25
56
|
.range([0, width])
|
|
@@ -27,71 +58,106 @@ export function renderTokenTrend(container, data) {
|
|
|
27
58
|
|
|
28
59
|
// Helper to get total height for each bucket
|
|
29
60
|
const totalOf = d => d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
|
|
61
|
+
const costOf = d => d.estimated_cost_usd || 0;
|
|
62
|
+
const valueOf = showDollars ? costOf : totalOf;
|
|
30
63
|
|
|
31
|
-
const maxVal = d3.max(buckets,
|
|
64
|
+
const maxVal = d3.max(buckets, valueOf) || 1;
|
|
32
65
|
const y = d3.scaleLinear().domain([0, maxVal * 1.1]).range([height, 0]);
|
|
33
66
|
|
|
67
|
+
const maxTicks = data.granularity === 'hourly' ? 12 : 10;
|
|
68
|
+
const tickVals = x.domain().filter((_, i) => i % Math.ceil(buckets.length / maxTicks) === 0);
|
|
69
|
+
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
70
|
+
const formatTick = (t) => {
|
|
71
|
+
// Hourly: "2026-03-15T08:00" → "Mar 15 8AM"
|
|
72
|
+
const h = t.match(/^\d{4}-(\d{2})-(\d{2})T(\d{2}):00$/);
|
|
73
|
+
if (h) {
|
|
74
|
+
const hr = parseInt(h[3], 10);
|
|
75
|
+
const ampm = hr === 0 ? '12AM' : hr < 12 ? `${hr}AM` : hr === 12 ? '12PM' : `${hr - 12}PM`;
|
|
76
|
+
return `${months[parseInt(h[1], 10) - 1]} ${parseInt(h[2], 10)} ${ampm}`;
|
|
77
|
+
}
|
|
78
|
+
// Daily: "2026-03-08" → "Mar 8"
|
|
79
|
+
const m = t.match(/^\d{4}-(\d{2})-(\d{2})$/);
|
|
80
|
+
if (m) {
|
|
81
|
+
return `${months[parseInt(m[1], 10) - 1]} ${parseInt(m[2], 10)}`;
|
|
82
|
+
}
|
|
83
|
+
return t;
|
|
84
|
+
};
|
|
34
85
|
const xAxis = svg.append('g')
|
|
35
86
|
.attr('transform', `translate(0,${height})`)
|
|
36
|
-
.call(d3.axisBottom(x).tickValues(
|
|
87
|
+
.call(d3.axisBottom(x).tickValues(tickVals).tickFormat(formatTick));
|
|
37
88
|
xAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px')
|
|
38
89
|
.attr('transform', 'rotate(-45)').attr('text-anchor', 'end');
|
|
39
90
|
xAxis.selectAll('line, path').style('stroke', '#334155');
|
|
40
91
|
|
|
41
|
-
const
|
|
92
|
+
const yAxisFmt = showDollars ? (v => `$${v < 1 ? v.toFixed(2) : d3.format('.2s')(v)}`) : d3.format('.2s');
|
|
93
|
+
const yAxis = svg.append('g').call(d3.axisLeft(y).ticks(5).tickFormat(yAxisFmt));
|
|
42
94
|
yAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
|
|
43
95
|
yAxis.selectAll('line, path').style('stroke', '#334155');
|
|
44
96
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
if (showDollars) {
|
|
98
|
+
// Single bar per bucket showing cost
|
|
99
|
+
svg.selectAll('.bar-cost')
|
|
100
|
+
.data(buckets)
|
|
101
|
+
.enter().append('rect')
|
|
102
|
+
.attr('x', d => x(d.time))
|
|
103
|
+
.attr('y', d => y(costOf(d)))
|
|
104
|
+
.attr('width', x.bandwidth())
|
|
105
|
+
.attr('height', d => height - y(costOf(d)))
|
|
106
|
+
.attr('fill', '#fbbf24')
|
|
107
|
+
.attr('opacity', 0.7);
|
|
108
|
+
} else {
|
|
109
|
+
// Stack order (bottom to top): cache_read, cache_creation, input, output
|
|
110
|
+
// Cache read (bottom)
|
|
111
|
+
svg.selectAll('.bar-cache-read')
|
|
112
|
+
.data(buckets)
|
|
113
|
+
.enter().append('rect')
|
|
114
|
+
.attr('x', d => x(d.time))
|
|
115
|
+
.attr('y', d => y(d.cache_read_tokens || 0))
|
|
116
|
+
.attr('width', x.bandwidth())
|
|
117
|
+
.attr('height', d => height - y(d.cache_read_tokens || 0))
|
|
118
|
+
.attr('fill', '#4ade80')
|
|
119
|
+
.attr('opacity', 0.6);
|
|
120
|
+
|
|
121
|
+
// Cache creation (on top of cache read)
|
|
122
|
+
const cacheBase = d => (d.cache_read_tokens || 0);
|
|
123
|
+
svg.selectAll('.bar-cache-creation')
|
|
124
|
+
.data(buckets)
|
|
125
|
+
.enter().append('rect')
|
|
126
|
+
.attr('x', d => x(d.time))
|
|
127
|
+
.attr('y', d => y(cacheBase(d) + (d.cache_creation_tokens || 0)))
|
|
128
|
+
.attr('width', x.bandwidth())
|
|
129
|
+
.attr('height', d => y(cacheBase(d)) - y(cacheBase(d) + (d.cache_creation_tokens || 0)))
|
|
130
|
+
.attr('fill', '#f59e0b')
|
|
131
|
+
.attr('opacity', 0.6);
|
|
132
|
+
|
|
133
|
+
// Input (on top of cache)
|
|
134
|
+
const inputBase = d => cacheBase(d) + (d.cache_creation_tokens || 0);
|
|
135
|
+
svg.selectAll('.bar-input')
|
|
136
|
+
.data(buckets)
|
|
137
|
+
.enter().append('rect')
|
|
138
|
+
.attr('x', d => x(d.time))
|
|
139
|
+
.attr('y', d => y(inputBase(d) + d.input_tokens))
|
|
140
|
+
.attr('width', x.bandwidth())
|
|
141
|
+
.attr('height', d => y(inputBase(d)) - y(inputBase(d) + d.input_tokens))
|
|
142
|
+
.attr('fill', '#3b82f6')
|
|
143
|
+
.attr('opacity', 0.7);
|
|
144
|
+
|
|
145
|
+
// Output (top)
|
|
146
|
+
const outputBase = d => inputBase(d) + d.input_tokens;
|
|
147
|
+
svg.selectAll('.bar-output')
|
|
148
|
+
.data(buckets)
|
|
149
|
+
.enter().append('rect')
|
|
150
|
+
.attr('x', d => x(d.time))
|
|
151
|
+
.attr('y', d => y(outputBase(d) + d.output_tokens))
|
|
152
|
+
.attr('width', x.bandwidth())
|
|
153
|
+
.attr('height', d => y(outputBase(d)) - y(outputBase(d) + d.output_tokens))
|
|
154
|
+
.attr('fill', '#f97316')
|
|
155
|
+
.attr('opacity', 0.7);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Tooltip — remove any stale ones first
|
|
159
|
+
d3.selectAll('.d3-tooltip-token-trend').remove();
|
|
160
|
+
const tooltip = d3.select('body').append('div').attr('class', 'd3-tooltip d3-tooltip-token-trend').style('display', 'none');
|
|
95
161
|
|
|
96
162
|
svg.selectAll('rect')
|
|
97
163
|
.on('mouseover', (event, d) => {
|
|
@@ -105,9 +171,187 @@ export function renderTokenTrend(container, data) {
|
|
|
105
171
|
})
|
|
106
172
|
.on('mouseout', () => tooltip.style('display', 'none'));
|
|
107
173
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
174
|
+
// Aggregated totals for the selected period
|
|
175
|
+
const fmt = d3.format(',');
|
|
176
|
+
const fmtShort = (n) => {
|
|
177
|
+
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
|
|
178
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
179
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
180
|
+
return n.toString();
|
|
181
|
+
};
|
|
182
|
+
// Use server-computed totals (from raw records) for accuracy;
|
|
183
|
+
// fall back to summing buckets if data.total is unavailable
|
|
184
|
+
const t = data.total || {};
|
|
185
|
+
const totals = {
|
|
186
|
+
cacheRead: t.cache_read_tokens ?? buckets.reduce((s, d) => s + (d.cache_read_tokens || 0), 0),
|
|
187
|
+
cacheWrite: t.cache_creation_tokens ?? buckets.reduce((s, d) => s + (d.cache_creation_tokens || 0), 0),
|
|
188
|
+
input: t.input_tokens ?? buckets.reduce((s, d) => s + d.input_tokens, 0),
|
|
189
|
+
output: t.output_tokens ?? buckets.reduce((s, d) => s + d.output_tokens, 0),
|
|
190
|
+
cost: t.estimated_api_cost_usd ?? buckets.reduce((s, d) => s + (d.estimated_cost_usd || 0), 0),
|
|
191
|
+
};
|
|
192
|
+
totals.all = totals.cacheRead + totals.cacheWrite + totals.input + totals.output;
|
|
193
|
+
|
|
194
|
+
const summary = el.append('div')
|
|
195
|
+
.style('margin-top', '10px')
|
|
196
|
+
.style('padding', '10px 14px')
|
|
197
|
+
.style('background', '#1e293b')
|
|
198
|
+
.style('border-radius', '6px')
|
|
199
|
+
.style('font-size', '12px');
|
|
200
|
+
|
|
201
|
+
// Top row: total tokens + cost inline
|
|
202
|
+
const topRow = summary.append('div')
|
|
203
|
+
.style('margin-bottom', '8px');
|
|
204
|
+
topRow.append('span')
|
|
205
|
+
.style('color', '#e2e8f0')
|
|
206
|
+
.style('font-size', '13px')
|
|
207
|
+
.html(`Period Total: <strong title="${fmt(totals.all)} tokens">${fmtShort(totals.all)}</strong> tokens`)
|
|
208
|
+
.append('span')
|
|
209
|
+
.style('color', '#fbbf24')
|
|
210
|
+
.style('font-weight', '600')
|
|
211
|
+
.style('margin-left', '12px')
|
|
212
|
+
.html(`$${totals.cost.toFixed(2)}`);
|
|
213
|
+
|
|
214
|
+
// Segment breakdown with legend dots
|
|
215
|
+
const segments = [
|
|
216
|
+
{ label: 'Cache Read', value: totals.cacheRead, color: '#4ade80' },
|
|
217
|
+
{ label: 'Cache Write', value: totals.cacheWrite, color: '#f59e0b' },
|
|
218
|
+
{ label: 'Input', value: totals.input, color: '#60a5fa' },
|
|
219
|
+
{ label: 'Output', value: totals.output, color: '#f97316' },
|
|
220
|
+
];
|
|
221
|
+
const segRow = summary.append('div')
|
|
222
|
+
.style('display', 'flex')
|
|
223
|
+
.style('flex-wrap', 'wrap')
|
|
224
|
+
.style('gap', '6px 20px')
|
|
225
|
+
.style('margin-bottom', '8px');
|
|
226
|
+
for (const s of segments) {
|
|
227
|
+
segRow.append('span')
|
|
228
|
+
.style('color', s.color)
|
|
229
|
+
.style('font-size', '11px')
|
|
230
|
+
.html(`● ${s.label}: <strong title="${fmt(s.value)} tokens">${fmtShort(s.value)}</strong>`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Avg / Min / Max stats per bucket
|
|
234
|
+
const bucketVals = buckets.map(valueOf);
|
|
235
|
+
const nonZero = bucketVals.filter(v => v > 0);
|
|
236
|
+
const avg = nonZero.length > 0 ? nonZero.reduce((a, b) => a + b, 0) / nonZero.length : 0;
|
|
237
|
+
const min = nonZero.length > 0 ? Math.min(...nonZero) : 0;
|
|
238
|
+
const max = nonZero.length > 0 ? Math.max(...nonZero) : 0;
|
|
239
|
+
|
|
240
|
+
const fmtStat = showDollars
|
|
241
|
+
? (v => `$${v.toFixed(2)}`)
|
|
242
|
+
: (v => fmtShort(Math.round(v)));
|
|
243
|
+
const fmtStatTitle = showDollars
|
|
244
|
+
? (v => `$${v.toFixed(4)}`)
|
|
245
|
+
: (v => `${fmt(Math.round(v))} tokens`);
|
|
246
|
+
|
|
247
|
+
const granLabel = { hourly: 'hour', daily: 'day', weekly: 'week', monthly: 'month' }[data.granularity] || 'bucket';
|
|
248
|
+
const statsRow = summary.append('div')
|
|
249
|
+
.style('display', 'flex')
|
|
250
|
+
.style('flex-wrap', 'wrap')
|
|
251
|
+
.style('gap', '6px 20px')
|
|
252
|
+
.style('font-size', '11px')
|
|
253
|
+
.style('color', '#94a3b8');
|
|
254
|
+
|
|
255
|
+
statsRow.append('span').html(`Avg/${granLabel}: <strong title="${fmtStatTitle(avg)}" style="color:#e2e8f0">${fmtStat(avg)}</strong>`);
|
|
256
|
+
statsRow.append('span').html(`Min: <strong title="${fmtStatTitle(min)}" style="color:#e2e8f0">${fmtStat(min)}</strong>`);
|
|
257
|
+
statsRow.append('span').html(`Max: <strong title="${fmtStatTitle(max)}" style="color:#e2e8f0">${fmtStat(max)}</strong>`);
|
|
258
|
+
statsRow.append('span').html(`Active ${granLabel}s: <strong style="color:#e2e8f0">${nonZero.length}</strong> / ${buckets.length}`);
|
|
259
|
+
|
|
260
|
+
if (data.granularity === 'hourly') {
|
|
261
|
+
const days = new Set(buckets.map(b => b.time.slice(0, 10)));
|
|
262
|
+
const avgHours = (nonZero.length / days.size).toFixed(1);
|
|
263
|
+
statsRow.append('span').html(`Avg hours/day: <strong style="color:#e2e8f0">${avgHours}</strong>`);
|
|
264
|
+
} else if (data.granularity === 'daily') {
|
|
265
|
+
const weeks = Math.max(1, buckets.length / 7);
|
|
266
|
+
const avgDays = (nonZero.length / weeks).toFixed(1);
|
|
267
|
+
statsRow.append('span').html(`Avg days/week: <strong style="color:#e2e8f0">${avgDays}</strong>`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Most active hours heatmap — aggregate by hour-of-day across the entire range
|
|
271
|
+
const hourAgg = new Array(24).fill(0);
|
|
272
|
+
const hourCount = new Array(24).fill(0);
|
|
273
|
+
for (const b of buckets) {
|
|
274
|
+
// Extract hour from hourly buckets (e.g. "2026-03-15T08:00") or from daily/other granularities skip
|
|
275
|
+
const hm = b.time.match(/T(\d{2}):00$/);
|
|
276
|
+
if (hm) {
|
|
277
|
+
const hr = parseInt(hm[1], 10);
|
|
278
|
+
hourAgg[hr] += valueOf(b);
|
|
279
|
+
if (valueOf(b) > 0) hourCount[hr]++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const hasHourlyData = hourAgg.some(v => v > 0);
|
|
283
|
+
if (hasHourlyData) {
|
|
284
|
+
const peakHour = hourAgg.indexOf(Math.max(...hourAgg));
|
|
285
|
+
const maxHourVal = Math.max(...hourAgg);
|
|
286
|
+
|
|
287
|
+
// Find contiguous active ranges
|
|
288
|
+
const activeHours = hourAgg.map((v, i) => ({ hour: i, val: v })).filter(h => h.val > 0);
|
|
289
|
+
const ranges = [];
|
|
290
|
+
if (activeHours.length > 0) {
|
|
291
|
+
let start = activeHours[0].hour;
|
|
292
|
+
let prev = start;
|
|
293
|
+
for (let i = 1; i < activeHours.length; i++) {
|
|
294
|
+
if (activeHours[i].hour === prev + 1) {
|
|
295
|
+
prev = activeHours[i].hour;
|
|
296
|
+
} else {
|
|
297
|
+
ranges.push([start, prev]);
|
|
298
|
+
start = activeHours[i].hour;
|
|
299
|
+
prev = start;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
ranges.push([start, prev]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const fmtHr = h => {
|
|
306
|
+
if (h === 0) return '12AM';
|
|
307
|
+
if (h < 12) return `${h}AM`;
|
|
308
|
+
if (h === 12) return '12PM';
|
|
309
|
+
return `${h - 12}PM`;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const activeRangeStr = ranges.map(([s, e]) => s === e ? fmtHr(s) : `${fmtHr(s)}-${fmtHr((e + 1) % 24)}`).join(', ');
|
|
313
|
+
|
|
314
|
+
const hoursRow = summary.append('div')
|
|
315
|
+
.style('margin-top', '8px')
|
|
316
|
+
.style('font-size', '11px')
|
|
317
|
+
.style('color', '#94a3b8');
|
|
318
|
+
|
|
319
|
+
hoursRow.append('div')
|
|
320
|
+
.style('margin-bottom', '4px')
|
|
321
|
+
.html(`Peak hour: <strong style="color:#e2e8f0">${fmtHr(peakHour)}</strong> Active: <strong style="color:#e2e8f0">${activeRangeStr}</strong>`);
|
|
322
|
+
|
|
323
|
+
// Mini hour heatmap bar
|
|
324
|
+
const heatmap = hoursRow.append('div')
|
|
325
|
+
.style('display', 'flex')
|
|
326
|
+
.style('gap', '1px')
|
|
327
|
+
.style('align-items', 'end')
|
|
328
|
+
.style('height', '20px');
|
|
329
|
+
|
|
330
|
+
for (let h = 0; h < 24; h++) {
|
|
331
|
+
const pct = maxHourVal > 0 ? hourAgg[h] / maxHourVal : 0;
|
|
332
|
+
const color = pct === 0 ? '#1e293b'
|
|
333
|
+
: pct < 0.33 ? '#334155'
|
|
334
|
+
: pct < 0.66 ? '#3b82f6'
|
|
335
|
+
: '#60a5fa';
|
|
336
|
+
heatmap.append('div')
|
|
337
|
+
.attr('title', `${fmtHr(h)}: ${showDollars ? '$' + hourAgg[h].toFixed(2) : fmtShort(Math.round(hourAgg[h]))}`)
|
|
338
|
+
.style('flex', '1')
|
|
339
|
+
.style('height', `${Math.max(2, pct * 100)}%`)
|
|
340
|
+
.style('background', color)
|
|
341
|
+
.style('border-radius', '1px');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Hour labels under heatmap
|
|
345
|
+
const labels = hoursRow.append('div')
|
|
346
|
+
.style('display', 'flex')
|
|
347
|
+
.style('gap', '1px');
|
|
348
|
+
for (let h = 0; h < 24; h++) {
|
|
349
|
+
labels.append('div')
|
|
350
|
+
.style('flex', '1')
|
|
351
|
+
.style('text-align', 'center')
|
|
352
|
+
.style('font-size', '8px')
|
|
353
|
+
.style('color', '#64748b')
|
|
354
|
+
.text(h % 3 === 0 ? fmtHr(h) : '');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
113
357
|
}
|
|
@@ -4,18 +4,32 @@ export function initDatePicker(container, onChange) {
|
|
|
4
4
|
thirtyDaysAgo.setDate(today.getDate() - 30);
|
|
5
5
|
const fmt = d => d.toISOString().slice(0, 10);
|
|
6
6
|
|
|
7
|
+
const savedFrom = localStorage.getItem('datePickerFrom') || fmt(thirtyDaysAgo);
|
|
8
|
+
const savedTo = localStorage.getItem('datePickerTo') || fmt(today);
|
|
9
|
+
|
|
7
10
|
container.innerHTML = `
|
|
8
11
|
<span>📅</span>
|
|
9
|
-
<input type="date" id="date-from" value="${
|
|
12
|
+
<input type="date" id="date-from" value="${savedFrom}">
|
|
10
13
|
<span>–</span>
|
|
11
|
-
<input type="date" id="date-to" value="${
|
|
14
|
+
<input type="date" id="date-to" value="${savedTo}">
|
|
12
15
|
`;
|
|
13
16
|
|
|
14
17
|
const fromInput = container.querySelector('#date-from');
|
|
15
18
|
const toInput = container.querySelector('#date-to');
|
|
16
|
-
const emitChange = () =>
|
|
19
|
+
const emitChange = () => {
|
|
20
|
+
localStorage.setItem('datePickerFrom', fromInput.value);
|
|
21
|
+
localStorage.setItem('datePickerTo', toInput.value);
|
|
22
|
+
onChange({ from: fromInput.value, to: toInput.value });
|
|
23
|
+
};
|
|
17
24
|
fromInput.addEventListener('change', emitChange);
|
|
18
25
|
toInput.addEventListener('change', emitChange);
|
|
19
26
|
|
|
20
|
-
return {
|
|
27
|
+
return {
|
|
28
|
+
getRange: () => ({ from: fromInput.value, to: toInput.value }),
|
|
29
|
+
setRange: (from, to) => {
|
|
30
|
+
fromInput.value = from;
|
|
31
|
+
toInput.value = to;
|
|
32
|
+
emitChange();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
21
35
|
}
|
package/server/parser.js
CHANGED
|
@@ -2,21 +2,38 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
4
|
export function deriveProjectName(dirName) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
// Strip drive prefix like "C--" at the start
|
|
6
|
+
const clean = dirName.replace(/^[A-Za-z]--/, '');
|
|
7
|
+
|
|
8
|
+
// Known parent directory markers (case-insensitive search)
|
|
9
|
+
// Match the last occurrence of common parent dirs to get the project folder name
|
|
10
|
+
const lower = clean.toLowerCase();
|
|
11
|
+
const markers = ['-workspace-', '-projects-', '-repos-', '-src-', '-home-', '-desktop-', '-documents-', '-downloads-'];
|
|
12
|
+
let bestIdx = -1;
|
|
13
|
+
let bestLen = 0;
|
|
14
|
+
for (const m of markers) {
|
|
15
|
+
const idx = lower.lastIndexOf(m);
|
|
16
|
+
if (idx > bestIdx) {
|
|
17
|
+
bestIdx = idx;
|
|
18
|
+
bestLen = m.length;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (bestIdx !== -1) {
|
|
22
|
+
const result = clean.slice(bestIdx + bestLen);
|
|
23
|
+
// Handle worktree subdirs: "project--claude-worktrees-branch-name" → "project"
|
|
24
|
+
const wtIdx = result.indexOf('--claude-worktrees');
|
|
25
|
+
return wtIdx !== -1 ? result.slice(0, wtIdx) : result;
|
|
10
26
|
}
|
|
11
27
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
28
|
+
// Fallback: strip Users-username prefix, return the rest
|
|
29
|
+
const userMatch = clean.match(/^Users-[^-]+-(.+)$/);
|
|
30
|
+
if (userMatch) {
|
|
31
|
+
const rest = userMatch[1];
|
|
32
|
+
const wtIdx = rest.indexOf('--claude-worktrees');
|
|
33
|
+
return wtIdx !== -1 ? rest.slice(0, wtIdx) : rest;
|
|
16
34
|
}
|
|
17
35
|
|
|
18
|
-
|
|
19
|
-
return parts[parts.length - 1];
|
|
36
|
+
return clean;
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
export function parseLogFile(filePath) {
|